This project demonstrates a modular, feature-first architecture for Jetpack Compose apps. It splits functionality into coarse modules, separates concerns by layers, and wires everything together through Hilt.
This project uses a custom ViewModelScope implementation instead of the standard viewModelScope
. This provides:
- Single VM Factory Map: Uses Dagger multibindings to eliminate duplicate ViewModel lists
- Nested Scopes:
ScreenScope(nested = true)
creates independent child scopes - Scoped Dependencies: ViewModels can inject screen-scoped dependencies like
ScreenBus
- Constructor DI: ViewModels use
@AssistedInject
for type-safe dependency injection
// Wrap screens with ScreenScope
@Composable
fun SomeScreen() {
ScreenScope {
val presenter: CatalogPresenter = rememberPresenter<CatalogPresenter, Unit>()
// ViewModels share ScreenBus instance within this scope
}
}
// Create nested independent scopes
ScreenScope {
ParentContent()
ScreenScope(nested = true) {
ChildContent() // Gets fresh ScreenBus instance
}
}
- app – Application module hosting the navigation graph and providing Hilt bindings for presenters and the app scope.
- core
core:designsystem
– Compose UI theme and design components.core:common
– Shared utilities including presenter infrastructure and app-wide classes.
- feature – Each feature is composed of three modules:
feature:*:api
– Public contracts (routes, state, presenter interfaces).feature:*:ui
– Pure Compose screens depending only on the API and core modules.feature:*:impl
– Implementation with ViewModels, repositories and Hilt bindings.
Each feature is separated into layers that match the modules above:
Layer | Responsibility | Module example |
---|---|---|
API | Defines navigation destinations and presenter contracts | feature/catalog/api |
UI | UI built with Compose, retrieves a presenter via rememberPresenter |
feature/catalog/ui |
Impl | ViewModels and data sources backing the feature | feature/catalog/impl |
The core:common
module provides PresenterResolver
and helpers such as rememberPresenter
used by UI modules to obtain their presenters.
@HiltAndroidApp
MyApp
is the entry point for dependency injection.- A custom
AppComponent
andAppScopeManager
create an application-scoped component that holds anApp
object with navigation actions. - Custom
ScreenComponent
andSubscreenComponent
provide screen-level scoping with nested scope support. HiltPresenterResolver
is injected into theMainActivity
and uses multibindings to map presenter interfaces to their implementations.- Each feature implementation module contributes ViewModels to the factory map via
@AssistedFactory
and@VmKey
annotations.
This structure allows UI modules to remain free of Hilt while still obtaining their presenters through the shared PresenterResolver
, keeping feature APIs clean and implementations encapsulated.
Feature implementation modules own their network and persistence code. Retrofit and OkHttp service interfaces (e.g., WikipediaService
, SummarizerService
, TranslatorService
, and DictionaryService
) live beside Room entities and DAOs. Hilt modules provide these services and compose them into repositories, such as ArticleRepository
. These repositories expose Flow
-based APIs to the rest of the app. This keeps networking concerns isolated within the impl
layer.
The starter ships with a few sample features wired through the architecture:
- Catalog – fetches random Wikipedia articles, summarizes them and lists translated vocabulary.
- Detail – shows the full article content along with translation details and pronunciation when available.
- Settings – allows choosing native and learning languages that drive translation.
- Choose your languages. Open the Settings panel from the catalog screen and pick the language you already know for the Native (from) field and the language you want to practice for the Learning (to) field. Use the dropdown in each field to search and select from the supported languages.
- Fetch a random article. Return to the catalog and press Refresh to pull a fresh random article that matches your language choices. Each refresh updates the list with another suggestion to explore.
- Open the article. Tap any article card in the catalog to view the full text along with its summary and additional metadata.
- Translate vocabulary on the fly. While reading, touch any word to highlight it and trigger an instant translation into your selected learning language. Keep your finger on the text and slide to nearby words to see their translations in place.
- Create three modules under
feature/<name>/
(api
,ui
,impl
) and include them insettings.gradle.kts
. - In
feature/<name>/api
, declare the route and presenter contract:
@Serializable data object Foo
data class FooState(val text: String = "")
interface FooPresenter : ParamInit<Unit> {
val state: StateFlow<FooState>
fun onAction()
}
- In
feature/<name>/ui
, build the Compose screen and obtain the presenter:
@Composable
fun FooScreen(p: FooPresenter? = null) {
val presenter = p ?: rememberPresenter<FooPresenter, Unit>()
val state by presenter.state.collectAsStateWithLifecycle()
Text(state.text, Modifier.clickable { presenter.onAction() })
}
- In
feature/<name>/impl
, provide the presenter implementation and Hilt bindings:
class FooViewModel @AssistedInject constructor(
private val screenBus: ScreenBus,
@Assisted private val handle: SavedStateHandle
) : ViewModel(), FooPresenter {
@AssistedFactory
interface Factory : AssistedVmFactory<FooViewModel>
}
@Module
@InstallIn(ScreenComponent::class)
abstract class FooVmBindingModule {
@Binds @IntoMap @VmKey(FooViewModel::class)
abstract fun fooFactory(f: FooViewModel.Factory): AssistedVmFactory<out ViewModel>
}
@Module
@InstallIn(SingletonComponent::class)
object FooPresenterBindings {
@Provides @IntoMap @ClassKey(FooPresenter::class)
fun provideFooPresenterProvider(): PresenterProvider<*> {
return object : PresenterProvider<FooPresenter> {
@Composable
override fun provide(key: String?): FooPresenter {
return magicViewModel<FooViewModel>()
}
}
}
}
- Wire the feature into navigation by updating
NavigationActions
and wrapping the screen withScreenScope { }
if needed.
To publish a release APK through GitHub Actions, create and push an annotated tag:
git tag -a v0.1.0 -m "Release 0.1.0"
git push origin v0.1.0
The CI workflow will build and upload the release APK for that tag.