Back to all articles
Capstone Project: Architecting a Real App from Scratch

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,...

Human-architected research synthesized with the assistance of AI personas.
10 min read

✨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

  1. Search Screen: An initial screen with an EditText for the GitHub username and a "Search" button.
  2. 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.
  3. 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
  4. 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.

kotlin
// 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.

kotlin
// 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.

kotlin
// 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.

kotlin
// 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.

kotlin
// 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)

kotlin
// 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.

kotlin
// 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:

  1. User types "google" and clicks "Search".

  2. SearchFragment: The button's OnClickListener calls viewModel.searchRepositories("google").

  3. RepoListViewModel: The searchRepositories function is called. It updates _uiState to Loading and starts a coroutine in viewModelScope.

  4. Inside the Coroutine: The ViewModel calls repository.getUserRepositories("google").

  5. GitHubRepository: The getUserRepositories function calls apiService.listRepositories("google").

  6. Retrofit: Makes the HTTP call to the GitHub API on an I/O thread (thanks to suspend).

  7. API Response: The API returns a list of repositories. Retrofit deserializes it into List<Repo>.

  8. GitHubRepository: Receives the list, wraps it in Result.success(), and returns it.

  9. RepoListViewModel: Receives the Result, updates _uiState to RepoUiState.Success(repos).

  10. SearchFragment: The flowWithLifecycle collector observing uiState is triggered with the new Success state.

  11. Navigation: The when block navigates to the repository list screen, passing repos as arguments.

  12. Rendering: The list is displayed via RecyclerView with ListAdapter, which calculates differences with DiffUtil and animates only changed items.

The Rotation Miracle

If the user rotates the screen during loading or after data display:

  • The Fragment is 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.

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:

  1. Local Persistence: Integrate Room to implement "Favorites" with local database.
  2. Dependency Injection: Use Hilt to remove manual instantiation and decouple further.
  3. Testing: Write unit tests for ViewModel (with fake Repository) and UI tests with Espresso.
  4. 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.

Receive new articles

Subscribe to receive notifications about new articles directly to your email

We won't send spam. You can unsubscribe at any time.