
Capstone Project: Architecting a Real App from Scratch
Practical blueprint for building a complete Android app from scratch: GitHub Repository Finder. Integrates idiomatic Kotlin, Android engine, MVVM,...
β¨TL;DR / Executive Summary
Practical blueprint for building a complete Android app from scratch: GitHub Repository Finder. Integrates idiomatic Kotlin, Android engine, MVVM,...
π‘ TL;DR (Too Long; Didn't Read)
- GitHub Repository Finder Project: Complete app that searches GitHub users, lists repositories, and shows details, exercising all learned concepts
- Data Layer: Retrofit for networking with
suspend fun, Repository pattern for abstraction, Kotlin data classes for models- Presentation Layer: ViewModel with StateFlow for state management, sealed class for UI states, viewModelScope for coroutines
- View Layer: Fragments with View Binding, flowWithLifecycle for safe observation, RecyclerView with ListAdapter for performance
- Key Takeaway: This project is the litmus test that solidifies idiomatic Kotlin + Android Engine + MVVM into a real, complete, and professional architecture
Estimated Reading Time: 30 minutes
You've traveled an intense path. You mastered idiomatic Kotlin syntax, understood Android's rigid engine, and learned the MVVM architecture that protects you. Now, it's time to bring it all together.
This article is no longer a tutorial. It's an architectural blueprint for a capstone project you'll build over the next 2-3 days. The goal is not just to create an app that works, but to build an app that is well-architected, testable, and reflects the modern best practices we've explored.
The Project: GitHub Repository Finder
A simple app, but complete enough to exercise all key concepts. It will allow a user to search for a GitHub profile, view their public repositories, and see details of each one.
App Functional Specification
- Search Screen: An initial screen with an
EditTextfor the GitHub username and a "Search" button. - Repository List Screen: After the search, the app navigates to a screen displaying a list of the user's public repositories. Each list item should show the repository name and description.
- Repository Details Screen: When clicking on a repository from the list, the app navigates to a details screen showing:
- Full name
- Description
- Main language
- Star count
- Fork count
- State Handling: The app should elegantly handle loading states (show a
ProgressBar), success (show data), and error (show an error message).
Architectural Blueprint: Layer by Layer
Let's dissect the project structure, applying each knowledge block.
Layer 1: Data (The Model)
This layer is responsible for obtaining and managing data, completely isolated from the UI.
1.1. Data Models (Kotlin Idioms)
Create data classes to represent the GitHub API response. Use the GitHub API as reference.
// data/models/Repo.kt
data class Repo(
val id: Long,
val name: String,
val fullName: String,
val description: String?,
val language: String?,
val stargazersCount: Int,
val forksCount: Int
)
// data/models/User.kt
data class User(
val id: Long,
val login: String,
val name: String?,
val publicRepos: Int
)Applied Concept: data classes for clean and concise data models. Kotlin automatically generates equals(), hashCode(), toString(), copy().
1.2. Remote Data Source (Retrofit + Coroutines)
Configure Retrofit to communicate with the GitHub API. The key here is to use suspend fun to make network calls asynchronous and integrated with coroutines.
// network/GitHubApiService.kt
interface GitHubApiService {
@GET("users/{user}/repos")
suspend fun listRepositories(@Path("user") user: String): List<Repo>
@GET("repos/{owner}/{repo}")
suspend fun getRepositoryDetails(
@Path("owner") owner: String,
@Path("repo") repo: String
): Repo
}Applied Concept: Retrofit for networking and suspend fun for asynchronous operations integrated with coroutines.
1.3. The Repository (The Repository Pattern)
Create a GitHubRepository class that will serve as the single source of truth for the UI. It will decide where the data comes from.
// data/GitHubRepository.kt
class GitHubRepository(private val apiService: GitHubApiService) {
// The function is suspended to be called from a coroutine in the ViewModel
suspend fun getUserRepositories(username: String): Result<List<Repo>> {
return try {
val repos = apiService.listRepositories(username)
Result.success(repos)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun getRepositoryDetails(
owner: String,
repoName: String
): Result<Repo> {
return try {
val repo = apiService.getRepositoryDetails(owner, repoName)
Result.success(repo)
} catch (e: Exception) {
Result.failure(e)
}
}
}Applied Concept: The Repository pattern to abstract the data source and manage errors centrally.
Layer 2: Presentation (The ViewModel)
This layer contains the UI business logic and exposes data reactively.
2.1. UI State Management (StateFlow + Sealed Class)
Use a sealed class to represent different UI states. This is much safer and more type-safe than using booleans or enums.
// ui/RepoUiState.kt
sealed class RepoUiState {
object Idle : RepoUiState()
object Loading : RepoUiState()
data class Success(val repos: List<Repo>) : RepoUiState()
data class Error(val message: String) : RepoUiState()
}
// ui/RepoDetailUiState.kt
sealed class RepoDetailUiState {
object Idle : RepoDetailUiState()
object Loading : RepoDetailUiState()
data class Success(val repo: Repo) : RepoDetailUiState()
data class Error(val message: String) : RepoDetailUiState()
}2.2. The ViewModel in Action
Create a RepoListViewModel that depends on GitHubRepository.
// ui/RepoListViewModel.kt
class RepoListViewModel(private val repository: GitHubRepository) : ViewModel() {
private val _uiState = MutableStateFlow<RepoUiState>(RepoUiState.Idle)
val uiState: StateFlow<RepoUiState> = _uiState.asStateFlow()
fun searchRepositories(username: String) {
if (username.isBlank()) return
viewModelScope.launch {
_uiState.value = RepoUiState.Loading
val result = repository.getUserRepositories(username)
_uiState.value = when {
result.isSuccess -> RepoUiState.Success(result.getOrNull() ?: emptyList())
result.isFailure -> RepoUiState.Error("Failed to fetch repositories.")
}
}
}
}
// ui/RepoDetailViewModel.kt
class RepoDetailViewModel(private val repository: GitHubRepository) : ViewModel() {
private val _uiState = MutableStateFlow<RepoDetailUiState>(RepoDetailUiState.Idle)
val uiState: StateFlow<RepoDetailUiState> = _uiState.asStateFlow()
fun loadRepositoryDetails(owner: String, repoName: String) {
viewModelScope.launch {
_uiState.value = RepoDetailUiState.Loading
val result = repository.getRepositoryDetails(owner, repoName)
_uiState.value = when {
result.isSuccess -> RepoDetailUiState.Success(result.getOrNull()!!)
result.isFailure -> RepoDetailUiState.Error("Failed to load details.")
}
}
}
}Applied Concept: ViewModel to survive configuration changes, StateFlow for reactivity, viewModelScope to manage coroutines, sealed class for type-safe states.
Layer 3: View (The View)
This layer consists of Fragments that observe the ViewModel and render the UI.
3.1. Search Screen (Fragment + View Binding)
// ui/SearchFragment.kt
class SearchFragment : Fragment(R.layout.fragment_search) {
private var _binding: FragmentSearchBinding? = null
private val binding get() = _binding!!
private val viewModel: RepoListViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
_binding = FragmentSearchBinding.bind(view)
binding.searchButton.setOnClickListener {
val username = binding.usernameEditText.text.toString()
viewModel.searchRepositories(username)
}
setupObservers()
}
private fun setupObservers() {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
.collect { state ->
when (state) {
is RepoUiState.Loading -> {
binding.progressBar.isVisible = true
binding.errorMessage.isVisible = false
}
is RepoUiState.Success -> {
binding.progressBar.isVisible = false
// Navigate to repository list
val action = SearchFragmentDirections
.actionSearchToRepoList(state.repos.toTypedArray())
findNavController().navigate(action)
}
is RepoUiState.Error -> {
binding.progressBar.isVisible = false
binding.errorMessage.isVisible = true
binding.errorMessage.text = state.message
}
RepoUiState.Idle -> {}
}
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}Applied Concept: Fragment for modular UI, View Binding for safe view access, observation with flowWithLifecycle for a reactive and lifecycle-aware connection.
3.2. List Screen (RecyclerView + ListAdapter)
Use ListAdapter to automatically compare differences between lists using DiffUtil.
// ui/RepoListAdapter.kt
class RepoListAdapter(
private val onItemClick: (Repo) -> Unit
) : ListAdapter<Repo, RepoListAdapter.RepoViewHolder>(RepoDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RepoViewHolder {
val binding = ItemRepoBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return RepoViewHolder(binding, onItemClick)
}
override fun onBindViewHolder(holder: RepoViewHolder, position: Int) {
holder.bind(getItem(position))
}
class RepoViewHolder(
private val binding: ItemRepoBinding,
private val onItemClick: (Repo) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(repo: Repo) {
binding.repoName.text = repo.name
binding.repoDescription.text = repo.description ?: "No description"
binding.root.setOnClickListener { onItemClick(repo) }
}
}
class RepoDiffCallback : DiffUtil.ItemCallback<Repo>() {
override fun areItemsTheSame(oldItem: Repo, newItem: Repo) =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: Repo, newItem: Repo) =
oldItem == newItem
}
}
// ui/RepoListFragment.kt
class RepoListFragment : Fragment(R.layout.fragment_repo_list) {
private var _binding: FragmentRepoListBinding? = null
private val binding get() = _binding!!
private val viewModel: RepoListViewModel by viewModels()
private lateinit var adapter: RepoListAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
_binding = FragmentRepoListBinding.bind(view)
adapter = RepoListAdapter { repo ->
val action = RepoListFragmentDirections
.actionRepoListToRepoDetail(repo.fullName)
findNavController().navigate(action)
}
binding.recyclerView.adapter = adapter
setupObservers()
}
private fun setupObservers() {
// List is passed via Navigation arguments
val repos = RepoListFragmentArgs.fromBundle(requireArguments()).repos.toList()
adapter.submitList(repos)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}Applied Concept: ListAdapter with DiffUtil for optimized performance, RecyclerView for large lists, View Binding for safe view access.
The Complete Flow: From Click to Screen
Let's trace the complete path of a user action:
-
User types "google" and clicks "Search".
-
SearchFragment: The button'sOnClickListenercallsviewModel.searchRepositories("google"). -
RepoListViewModel: ThesearchRepositoriesfunction is called. It updates_uiStatetoLoadingand starts a coroutine inviewModelScope. -
Inside the Coroutine: The ViewModel calls
repository.getUserRepositories("google"). -
GitHubRepository: ThegetUserRepositoriesfunction callsapiService.listRepositories("google"). -
Retrofit: Makes the HTTP call to the GitHub API on an I/O thread (thanks tosuspend). -
API Response: The API returns a list of repositories. Retrofit deserializes it into
List<Repo>. -
GitHubRepository: Receives the list, wraps it inResult.success(), and returns it. -
RepoListViewModel: Receives theResult, updates_uiStatetoRepoUiState.Success(repos). -
SearchFragment: TheflowWithLifecyclecollector observinguiStateis triggered with the newSuccessstate. -
Navigation: The
whenblock navigates to the repository list screen, passing repos as arguments. -
Rendering: The list is displayed via
RecyclerViewwithListAdapter, which calculates differences withDiffUtiland animates only changed items.
The Rotation Miracle
If the user rotates the screen during loading or after data display:
- The
Fragmentis destroyed and recreated. - It reconnects to the same
RepoListViewModel, which still contains the last state. - The UI is repainted instantly, without any network call.
- The experience is seamless.
Recommended File Structure
app/src/main/java/com/example/githubrepofinder/
βββ data/
β βββ models/
β β βββ Repo.kt
β β βββ User.kt
β βββ network/
β β βββ GitHubApiService.kt
β βββ GitHubRepository.kt
βββ ui/
β βββ search/
β β βββ SearchFragment.kt
β βββ repolist/
β β βββ RepoListFragment.kt
β β βββ RepoListViewModel.kt
β β βββ RepoListAdapter.kt
β βββ repodetail/
β β βββ RepoDetailFragment.kt
β β βββ RepoDetailViewModel.kt
β βββ MainActivity.kt
β βββ RepoUiState.kt
β βββ RepoDetailUiState.kt
βββ app/
βββ (Gradle configs, strings, styles, etc.)Next Evolutions: From Prototype to Professional
Building this project from scratch, following this blueprint, is a milestone. Upon completing this project, you will have applied in an integrated way:
- Kotlin Idioms:
data class,suspend fun, Scope Functions. - Android Engine:
Gradle, Lifecycle,View Binding, Navigation. - MVVM Architecture:
ViewModel,StateFlow,Repository,Retrofit,RecyclerView.
From here, the path to specialization becomes clear:
- Local Persistence: Integrate Room to implement "Favorites" with local database.
- Dependency Injection: Use Hilt to remove manual instantiation and decouple further.
- Testing: Write unit tests for
ViewModel(with fake Repository) and UI tests with Espresso. - Jetpack Compose: Rewrite a screen using Android's new declarative UI toolkit.
Conclusion: You Are Now an Android Architect
Congratulations. You haven't just learned Kotlin syntax, the Android engine, and the MVVM pattern. You integrated all of this into a cohesive and professional project. You're no longer following tutorials; you're architecting solutions.
This is the end of the beginning. The next step is specialization, and now you have the solid foundation to choose your path: developing more complex features, industrial-quality testing, performance optimization, or any other specialization that interests you.
An Android engineer's journey never ends. But you now have the right map and tools.