This is an Android chat app built with Jetpack Compose, Kotlin 2.4, and MVI ( Model-View-Intent) architecture with heavy modularization. The codebase separates concerns into 8 core modules and 8+ feature modules, each with clear responsibilities.
Key Stack: Compose, Kotlin Coroutines, Koin DI, Room DB, Firebase, Material 3, Navigation Compose
Pattern: State-driven UI with unidirectional data flow.
- ViewModel: Extends
BaseViewModel<State>fromcore:uimodule - State: Sealed interface implementing
UIState(e.g.,Loading,Success(data),Fail(error)) - Intent: User actions trigger state changes via UseCase → ViewModel → StateFlow
Example in feature:people_list:
class PeopleListViewModel(val getPeopleListUseCase: GetPeopleListUseCase) : BaseViewModel<PeopleProfileUIState>() {
override val initialState = PeopleProfileUIState.Loading
override fun observeState() = getPeopleListUseCase.execute()
.map { result -> try { PeopleProfileUIState.Success(result.getOrThrow()) } catch (t) { PeopleProfileUIState.Fail(t) } }
}
sealed interface PeopleProfileUIState : UIState {
data object Loading : PeopleProfileUIState
data class Success(val profileList: List<PeopleProfile>) : PeopleProfileUIState
data class Fail(val throwable: Throwable) : PeopleProfileUIState
}Consume in Compose:
@Composable
fun PeopleListScreen(vm: PeopleListViewModel = rememberNavViewModel { modules }) {
val state by vm.uiState.collectAsState()
when(state) { /* render based on state */ }
}Two layers:
:core:*modules: Shared infrastructure (UI, DI, Navigation, Domain, Coroutines, Analytics, Database, Utils):feature:*modules: Feature screens (people_list, home, chat_list, user_profile, etc.)
Dependency flow: Feature modules → Core modules (never the reverse). Navigation orchestrates
feature composition.
Module names use underscores: people_list → namespace com.mobiledevpro.people.list (via
core-module.gradle.kts)
Located in build-logic/src/main/kotlin/:
core-module.gradle.kts: Applied to all core modules. Sets namespace, minSdk, SDK versions, desugaring, flavors (dev/production)feature-module.gradle.kts: Extends core-module, adds Compose, auto-includes core dependencies (ui, di, domain, coroutines, util, analytics, lifecycle, coil)core-compose-module.gradle.kts: For core modules needing Compose (e.g.,core:ui)kotlin-convention.gradle.kts: Kotlin/JDK configuration
Example: A feature module only needs plugins { id("feature-module") } to get all standard
dependencies.
gradle/libs.versions.toml: Single source for all versions (Kotlin 2.4, Compose BOM 2026.05.01, AGP 9.2.1, Koin 4.2.1, Room 2.8.4, Firebase BOM 34.14.0)- Release versions in
gradle/libs.versions.toml(app-version-code,app-version-name)
- Flavors:
dev(default, app ID.apptemplate.compose) andproduction(.closetalk.app) - Build Types: Debug (Crashlytics disabled) and Release (minified, Crashlytics enabled)
- Build output:
.aabfiles auto-renamed viaRenameBundleTaskinapp/build.gradle.kts
./gradlew clean build --profile # Build with performance profiling
./gradlew bundleProductionRelease # Create AAB for Play Store
./gradlew createModuleGraph # Update module graph in README
./gradlew --profile # Generate build metrics- Module registration: Each feature defines a
di/Module.ktwithval featureNameModule = module { ... } - Scope pattern: Use
scope<ViewModel>to tie dependencies to ViewModel lifecycle - ViewModels registered as:
viewModelOf(::ClassName)(uses SavedStateHandle automatically) - Other dependencies:
scopedOf(::UseCase)for classes tied to scope,singleOf()for singletons
Example from feature:people_list:
val featurePeopleListModule = module {
scope<PeopleListViewModel> {
viewModelOf(::PeopleListViewModel)
scopedOf(::GetPeopleListUseCase)
}
}Via core:di helper: rememberNavViewModel<ViewModelType> { listOf(module) } - handles module
loading/unloading automatically.
uiState: StateFlow<State>exposed lazily- Uses
SharingStarted.WhileSubscribed(stopTimeoutMillis=0, replayExpirationMillis=9000ms) - Handles lifecycle safety automatically
- Use Turbine (
app.cash.turbine) for Flow testing:vm.uiState.test { awaitItem() ... } - Use Robolectric for instrumented tests without device
- Use
StandardTestDispatcherwith test scheduler for deterministic timing - See
PeopleListViewModelTest.ktfor pattern
- Unit tests:
src/test/kotlin- mock data, no Android - Instrumented tests:
src/androidTest/kotlin- need Android framework - Robolectric tests: Marked with
@RunWith(RobolectricTestRunner::class)for hybrid tests - Dependencies:
libs.bundles.test.commonincludes junit, kotlin-test, kotlinx-coroutines-test, mockk, turbine
@Before fun setUp() { startKoin { modules(...) } }
@After fun finish() { database.close(); stopKoin() }
// Use StandardTestDispatcher for deterministic async tests- Navigation screens defined as composables in
navigation/screen/(e.g.,PeopleListScreenNav.kt) - Each screen is a sealed class destination + Composable factory
HomeNavGraphorchestrates feature composition and routing- Features are lazy-composed and scoped via Koin
- Define destination in
core:navigation/screen/ - Create UI in
feature:*/view/ - Register ViewModel + UseCase in
feature:*/di/Module.kt - Include in appropriate NavGraph
- Sealed interfaces for states (not abstract classes)
*ViewModelfor state holders*UseCasefor business logic*Screenfor top-level Composables*Modulefor Koin module definitions- Package:
com.mobiledevpro.<module_name>(replaces underscores with dots)
- Compiler metrics generated to
build/compose_metrics/(seebuild.gradle.kts) - Interpreted via https://github.com/JetBrains/kotlin/blob/master/plugins/compose/design/compiler-metrics.md
- Helps identify recomposition inefficiencies
- App-level:
app/proguard-rules.pro(minified release builds) - Module-level:
*/proguard-rules.pro+*/consumer-rules.pro(for library consumers)
- Crashlytics: Enabled in release builds, disabled in debug
- Analytics: Via
core:analyticsmodule, injected as dependency - Performance Monitoring:
firebase-perfincluded - Firestore: Via
core:firestoremodule - Messaging: FCM support via Firebase
adb shell setprop log.tag.FA VERBOSE
adb shell setprop log.tag.FA-SVC VERBOSE
adb logcat -v time -s FA FA-SVC- Configuration:
maestro/people-profile-flow.yamlexample - Installation:
curl -Ls "https://get.maestro.mobile.dev" | bash - Run:
maestro test -c maestro/people-profile-flow.yaml(emulator only)
AppDatabasesingleton managed by Koin- Schema migrations tracked in
schemas/directory - Used by feature modules via UseCase injection
- Preferences stored via
androidx.datastore - Protobuf integration for type-safe storage
- Create
feature/<feature_name>/directory build.gradle.kts:plugins { id("feature-module") }- Create
src/main/kotlin/com/mobiledevpro/<feature_name>/:di/Module.kt- register ViewModel + dependenciesview/vm/ViewModel.kt- extends BaseViewModelview/state/<Feature>UIState.kt- sealed interfaceview/<Feature>Screen.kt- Composabledomain/usecase/- business logic
- Include in
settings.gradle.kts - Register navigation in
core:navigation
- Update sealed interface in
view/state/<Feature>UIState.kt - Update ViewModel's
observeState()to emit new states - Update Composable UI branch for new state
- Update ViewModel tests with Turbine
- Check Gradle build issues:
./gradlew assembleDebugDev -x firebase - Monitor Compose recompositions:
build/compose_metrics/ - Inspect Koin DI: Debug breakpoints in
rememberNavViewModel
| File | Purpose |
|---|---|
gradle/libs.versions.toml |
All dependency versions |
build-logic/src/main/kotlin/*.gradle.kts |
Module plugin templates |
core/ui/src/main/kotlin/com/mobiledevpro/ui/vm/BaseViewModel.kt |
MVI base class |
core:navigation |
Feature routing & composition |
feature:people_list |
Well-documented example feature |
app/build.gradle.kts |
App configuration, flavors, signing |
settings.gradle.kts |
Module includes |
- MVI Architecture: https://proandroiddev.com
- Jetpack Compose: https://developer.android.com/jetpack/compose
- Koin DI: https://insert-koin.io
- Module Graph Plugin: https://github.com/iurysza/module-graph
- Compose Compiler Metrics: https://github.com/JetBrains/kotlin/blob/master/plugins/compose/design/compiler-metrics.md