Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Check out the [TMDB developer documentation][tmdb] for more info.

## App Architecture

The app uses a reactive architecture built atop Flow. The app follows a layered architecture with data, domain and presentation layer. The package structure is an attempt to package by feature, however both screens share the data and domain layers. The app uses a single activity + fragments and the Jetpack Navigation Component.
The app uses a reactive architecture built atop Flow. The app follows a layered architecture with data, domain and presentation layer. The package structure is an attempt to package by feature, however both screens share the data and domain layers. The app uses a single activity approach. Each screen is represented by a `@Composable` function and navigation is handled by Jatpack Navigation 3.

### Data Layer

Expand All @@ -36,23 +36,23 @@ The main class here is `Movie`. It has a static `create` function that converts

### Presentation Layer

Each screen is represented by a `Fragment` which plays the role of glue code. It's responsible for DI, forwarding actions from the view (Compose) to the `ViewModel` and forwarding state from the `ViewModel` to the view. It also handles any side effect emitted by the `ViewModel` e.g. navigation events.
Each screen is represented by a `@Composable` function which plays the role of glue code. It's responsible for DI, forwarding actions from the view (Compose) to the `ViewModel` and forwarding state from the `ViewModel` to the view. It also handles any side effect emitted by the `ViewModel` e.g. navigation events.

Each fragment has a Jetpack ViewModel that:
Each screen has a Jetpack ViewModel that:

- exposes a single `Flow<ViewState>` backed by a `MutableStateFlow` (caching the last item) describing the state of the view at a given time
- exposes a single `Flow<Effect>` backed by a `Channel` for side effects like navigation, Snackbar or similar. Event that happen when no-one is subscribed are cached. All events are delivered when subscribed
- exposes a `CoroutineScope` with operations tied to it's lifecycle

The Fragment observes the `Flow<ViewState>` between `onStart` and `onStop` and updates the `Sceen`. The Fragment observes `Flow<Effect>` between `onStart` and `onStop` making sure fragment transactions are executed only when the view is active.
The screen observes the `Flow<ViewState>` between `onStart` and `onStop` and updates the `Screen`. The screen observes `Flow<Effect>` between `onStart` and `onStop` making sure navigation happens only when the view is active.

The Fragment observes the `Flow<Action>` from `onStart` until `onStop`. However any network calls that result from those interactions are de-coupled from this lifecycle. The operations triggered by the view actions are coupled to the `ViewModel` lifecycle and are only disposed in the `ViewMode.onDispose()` function. Check the [fork() function][fork] for more details.
The screen observes the `Flow<Action>` from `onStart` until `onStop`. However any network calls that result from those interactions are de-coupled from this lifecycle. The operations triggered by the view actions are coupled to the `ViewModel` lifecycle and are only disposed in the `ViewModel.onDispose()` function. Check the [fork() function][fork] for more details.

The logic is written as extension functions on top of a module (collection of dependencies).

### Dependency Injection

The sample uses the DI approach from [Simple Kotlin DI][simple-di]. The dependencies with Singleton scope live in the app as `AppModule`. Each fragment uses the `AppModule` dependencies and can add it's own (e.g. the `ViewModel`) that are un-scoped or use Jetpack for scoping (e.g. `ViewModel`).
The sample uses the DI approach from [Simple Kotlin DI][simple-di]. The dependencies with Singleton scope live in the app as `AppModule`. Each screen uses the `AppModule` dependencies and can add it's own (e.g. the `ViewModel`) that are un-scoped or use Jetpack for scoping (e.g. `ViewModel`).

The logic is written as extension functions on top of a module (collection of dependencies).

Expand All @@ -62,7 +62,7 @@ This sample uses JUnit as a testing library and [kotest assertions][kotest] for

The view is tested in isolation using Paparazzi, by setting a ViewState and verifying the current image matches the golden images from the repo.

There is also one E2E (black box) test using Maestro that tests both fragments + activity together.
There is also one E2E (black box) test using Maestro that tests everything glued together. The test uses a test server using Wiremock.

## Acknowledgments

Expand All @@ -74,6 +74,5 @@ Approaches is this sample are heavily inspired by open source code I have read.
[tmdb]: https://developer.themoviedb.org/docs/getting-started
[fun-stream]: https://github.com/47degrees/FunctionalStreamsSpringSample
[simple-di]: https://gist.github.com/raulraja/97e2d5bf60e9d96680cf1fddcc90ee67
[view-binding]: https://developer.android.com/topic/libraries/view-binding
[fork]: app/src/main/java/io/github/lordraydenmk/themoviedbapp/common/observable.kt
[fork]: app/src/main/java/io/github/lordraydenmk/themoviedbapp/common/flow.kt
[kotest]: https://github.com/kotest/kotest
13 changes: 7 additions & 6 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ dependencies {

implementation 'androidx.core:core-ktx:1.17.0'
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'androidx.activity:activity-compose:1.12.1'
def composeBom = platform('androidx.compose:compose-bom:2025.12.00')
implementation 'androidx.activity:activity-compose:1.12.2'
def composeBom = platform('androidx.compose:compose-bom:2025.12.01')
implementation composeBom
implementation "androidx.compose.ui:ui"
implementation "androidx.compose.foundation:foundation"
Expand All @@ -85,10 +85,11 @@ dependencies {
def lifecycle = "2.10.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle"
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle"
implementation 'androidx.fragment:fragment-ktx:1.8.9'
def nav_version = "2.9.6"
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"

def nav3Core = "1.0.0"
implementation "androidx.navigation3:navigation3-runtime:$nav3Core"
implementation "androidx.navigation3:navigation3-ui:$nav3Core"
implementation "androidx.lifecycle:lifecycle-viewmodel-navigation3:2.10.0"

implementation 'com.jakewharton.timber:timber:5.0.1'

Expand Down
6 changes: 3 additions & 3 deletions app/src/main/java/io/github/lordraydenmk/themoviedbapp/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import coil3.SingletonImageLoader
import coil3.request.crossfade
import timber.log.Timber

class App : Application(), ModuleOwner, SingletonImageLoader.Factory {
class App : Application(), SingletonImageLoader.Factory {

override val appModule by lazy { AppModule.create() }
val appModule by lazy { AppModule.create() }

override fun onCreate() {
super.onCreate()
Expand All @@ -24,4 +24,4 @@ class App : Application(), ModuleOwner, SingletonImageLoader.Factory {
.build()
}

fun Context.appModule(): AppModule = (applicationContext as ModuleOwner).appModule
fun Context.appModule(): AppModule = (applicationContext as App).appModule
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package io.github.lordraydenmk.themoviedbapp

import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity(R.layout.activity_main) {
class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
MoviesApplication(appModule())
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package io.github.lordraydenmk.themoviedbapp

import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.rememberSerializable
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.runtime.serialization.NavBackStackSerializer
import androidx.navigation3.runtime.serialization.NavKeySerializer
import androidx.navigation3.ui.NavDisplay
import io.github.lordraydenmk.themoviedbapp.movies.Screen
import io.github.lordraydenmk.themoviedbapp.movies.Screen.MovieDetails
import io.github.lordraydenmk.themoviedbapp.movies.Screen.PopularMovies
import io.github.lordraydenmk.themoviedbapp.movies.moviedetails.MovieDetailsNavScreen
import io.github.lordraydenmk.themoviedbapp.movies.popularmovies.MoviesNavScreen

typealias BackStack = NavBackStack<Screen>

@Composable
fun <T : NavKey> rememberNavBackStack(vararg elements: NavKey): NavBackStack<T> {
return rememberSerializable(
serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer())
) {
@Suppress("UNCHECKED_CAST")
NavBackStack(*elements) as NavBackStack<T>
}
}

@Composable
fun MoviesApplication(appModule: AppModule) {
val backStack = rememberNavBackStack<Screen>(PopularMovies)
NavDisplay(
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = { key ->
when (key) {
PopularMovies -> NavEntry(key) {
MoviesNavScreen(appModule, backStack)
}

is MovieDetails -> NavEntry(key) {
MovieDetailsNavScreen(appModule, key.movieId, backStack)
}
}
}
)
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
package io.github.lordraydenmk.themoviedbapp.common

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
Expand All @@ -21,7 +17,6 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext

Expand Down Expand Up @@ -65,13 +60,3 @@ suspend inline fun <A, B, C> parZip(
@Suppress("UNCHECKED_CAST")
f(a as A, b as B)
}

fun <A> Flow<A>.observeIn(
lifecycleOwner: LifecycleOwner,
state: Lifecycle.State = Lifecycle.State.STARTED
): Job =
lifecycleOwner.lifecycleScope.launch {
lifecycleOwner.lifecycle.repeatOnLifecycle(state) {
collect()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package io.github.lordraydenmk.themoviedbapp.movies

import androidx.navigation3.runtime.NavKey
import io.github.lordraydenmk.themoviedbapp.movies.domain.MovieId
import kotlinx.serialization.Serializable

@Serializable
sealed class Screen : NavKey {
@Serializable
data object PopularMovies : Screen()

@Serializable
data class MovieDetails(val movieId: MovieId) : Screen()
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package io.github.lordraydenmk.themoviedbapp.movies.moviedetails

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import io.github.lordraydenmk.themoviedbapp.AppModule
import io.github.lordraydenmk.themoviedbapp.BackStack
import io.github.lordraydenmk.themoviedbapp.common.presentation.ViewModelAlgebra
import io.github.lordraydenmk.themoviedbapp.movies.domain.MovieId
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow

@Composable
fun MovieDetailsNavScreen(
appModule: AppModule,
movieId: MovieId,
backStack: BackStack,
viewModel: MovieDetailsViewModel = viewModel()
) {
val module = remember {
object : MovieDetailsModule,
AppModule by appModule,
ViewModelAlgebra<MovieDetailsViewState, MovieDetailsEffect> by viewModel {
override val actions: Channel<MovieDetailsAction> = Channel(Channel.UNLIMITED)
}
}


with(module) {
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
program(movieId, actions.receiveAsFlow())
}
}
LaunchedEffect(lifecycleOwner) {
viewModel.effects
.flowWithLifecycle(lifecycleOwner.lifecycle)
.map { effect ->
when (effect) {
is NavigateUp -> backStack.removeLastOrNull()
}
}.collect()
}
}
val state by viewModel.viewState.collectAsState(Loading)
MovieDetailsScreen(state, movieId, module.actions)
}
Loading