diff --git a/README.md b/README.md index 674a0d05..26953c97 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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` backed by a `MutableStateFlow` (caching the last item) describing the state of the view at a given time - exposes a single `Flow` 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` between `onStart` and `onStop` and updates the `Sceen`. The Fragment observes `Flow` between `onStart` and `onStop` making sure fragment transactions are executed only when the view is active. +The screen observes the `Flow` between `onStart` and `onStop` and updates the `Screen`. The screen observes `Flow` between `onStart` and `onStop` making sure navigation happens only when the view is active. -The Fragment observes the `Flow` 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` 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). @@ -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 @@ -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 diff --git a/app/build.gradle b/app/build.gradle index 8c56e084..a6713346 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" @@ -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' diff --git a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/App.kt b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/App.kt index d1062ff4..8dbd66c1 100644 --- a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/App.kt +++ b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/App.kt @@ -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() @@ -24,4 +24,4 @@ class App : Application(), ModuleOwner, SingletonImageLoader.Factory { .build() } -fun Context.appModule(): AppModule = (applicationContext as ModuleOwner).appModule \ No newline at end of file +fun Context.appModule(): AppModule = (applicationContext as App).appModule \ No newline at end of file diff --git a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/MainActivity.kt b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/MainActivity.kt index 484a6401..368810f1 100644 --- a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/MainActivity.kt +++ b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/MainActivity.kt @@ -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()) + } } } \ No newline at end of file diff --git a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/ModuleOwner.kt b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/ModuleOwner.kt deleted file mode 100644 index 6f69b763..00000000 --- a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/ModuleOwner.kt +++ /dev/null @@ -1,11 +0,0 @@ -package io.github.lordraydenmk.themoviedbapp - -/** - * Breaks the dependency between Fragments and Application - * - * Enables having a separate Application class in Espresso tests that implements this interface - */ -interface ModuleOwner { - - val appModule: AppModule -} \ No newline at end of file diff --git a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/MoviesApp.kt b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/MoviesApp.kt new file mode 100644 index 00000000..776028f2 --- /dev/null +++ b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/MoviesApp.kt @@ -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 + +@Composable +fun rememberNavBackStack(vararg elements: NavKey): NavBackStack { + return rememberSerializable( + serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()) + ) { + @Suppress("UNCHECKED_CAST") + NavBackStack(*elements) as NavBackStack + } +} + +@Composable +fun MoviesApplication(appModule: AppModule) { + val backStack = rememberNavBackStack(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) + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/common/flow.kt b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/common/flow.kt index 1127912c..a449ce62 100644 --- a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/common/flow.kt +++ b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/common/flow.kt @@ -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 @@ -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 @@ -65,13 +60,3 @@ suspend inline fun parZip( @Suppress("UNCHECKED_CAST") f(a as A, b as B) } - -fun Flow.observeIn( - lifecycleOwner: LifecycleOwner, - state: Lifecycle.State = Lifecycle.State.STARTED -): Job = - lifecycleOwner.lifecycleScope.launch { - lifecycleOwner.lifecycle.repeatOnLifecycle(state) { - collect() - } - } diff --git a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/Screen.kt b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/Screen.kt new file mode 100644 index 00000000..b7063a2a --- /dev/null +++ b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/Screen.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/MovieDetailsFragment.kt b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/MovieDetailsFragment.kt deleted file mode 100644 index a339e85c..00000000 --- a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/MovieDetailsFragment.kt +++ /dev/null @@ -1,82 +0,0 @@ -package io.github.lordraydenmk.themoviedbapp.movies.moviedetails - -import android.os.Bundle -import android.view.View -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle.State -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import io.github.lordraydenmk.themoviedbapp.AppModule -import io.github.lordraydenmk.themoviedbapp.R -import io.github.lordraydenmk.themoviedbapp.appModule -import io.github.lordraydenmk.themoviedbapp.common.observeIn -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.map -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch - -class MovieDetailsFragment : Fragment(R.layout.fragment_compose) { - - private val movieId: Long by lazy(LazyThreadSafetyMode.NONE) { - val id = requireArguments().getLong(EXTRA_MOVIE_ID, -1) - check(id != -1L) { "Please use newBundle() for creating the arguments" } - id - } - - private val viewModel: MovieDetailsViewModel by viewModels() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val composeView = view as ComposeView - composeView.setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed) - - val module = object : MovieDetailsModule, - AppModule by requireActivity().appModule(), - ViewModelAlgebra by viewModel {} - - val actions = Channel(Channel.UNLIMITED) - - with(module) { - composeView.setContent { - MovieDetailsScreen( - stateFlow = viewState, - initialState = Loading, - movieId = movieId, - actions = actions - ) - } - - lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(State.STARTED) { - program(movieId, actions.receiveAsFlow()) - } - } - } - - handleEffects() - } - - private fun handleEffects() { - viewModel.effects.map { effect -> - when (effect) { - NavigateUp -> findNavController().navigateUp() - } - }.observeIn(this) - } - - companion object { - - private const val EXTRA_MOVIE_ID = "EXTRA_MOVIE_ID" - - fun newBundle(movieId: MovieId): Bundle = - Bundle().apply { - putLong(EXTRA_MOVIE_ID, movieId) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/MovieDetailsNavScreen.kt b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/MovieDetailsNavScreen.kt new file mode 100644 index 00000000..774206e8 --- /dev/null +++ b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/MovieDetailsNavScreen.kt @@ -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 by viewModel { + override val actions: Channel = 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) +} \ No newline at end of file diff --git a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/MovieDetailsScreen.kt b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/MovieDetailsScreen.kt index 63a122e5..649244c8 100644 --- a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/MovieDetailsScreen.kt +++ b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/MovieDetailsScreen.kt @@ -21,7 +21,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -30,32 +29,27 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import io.github.lordraydenmk.themoviedbapp.movies.domain.MovieId import io.github.lordraydenmk.themoviedbapp.movies.ui.common.MovieLoading import io.github.lordraydenmk.themoviedbapp.movies.ui.common.MovieProblem import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow import okhttp3.HttpUrl.Companion.toHttpUrl @OptIn(ExperimentalMaterial3Api::class) @Composable fun MovieDetailsScreen( - stateFlow: Flow, - initialState: MovieDetailsViewState, + state: MovieDetailsViewState, movieId: MovieId, actions: Channel ) { - val state by stateFlow.collectAsStateWithLifecycle(initialState) - Column { MovieDetailsAppBar(state, actions) - when (val viewState = state) { - is Content -> MovieContent(content = viewState) + when (state) { + is Content -> MovieContent(content = state) Loading -> MovieLoading() - is Problem -> MovieProblem(textRes = viewState.stringId) { + is Problem -> MovieProblem(textRes = state.stringId) { actions.trySend(Refresh(movieId)) } } diff --git a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/movieDetails.kt b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/movieDetails.kt index 9364aa11..4f17adab 100644 --- a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/movieDetails.kt +++ b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/movieDetails.kt @@ -14,11 +14,14 @@ import io.github.lordraydenmk.themoviedbapp.movies.data.movieDetails import io.github.lordraydenmk.themoviedbapp.movies.domain.Movie import io.github.lordraydenmk.themoviedbapp.movies.domain.MovieId import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map interface MovieDetailsModule : AppModule, - ViewModelAlgebra + ViewModelAlgebra { + val actions: Channel +} suspend fun MovieDetailsModule.program( movieId: MovieId, diff --git a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/MoviesFragment.kt b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/MoviesFragment.kt deleted file mode 100644 index fe5529ee..00000000 --- a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/MoviesFragment.kt +++ /dev/null @@ -1,70 +0,0 @@ -package io.github.lordraydenmk.themoviedbapp.movies.popularmovies - -import android.os.Bundle -import android.view.View -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import io.github.lordraydenmk.themoviedbapp.AppModule -import io.github.lordraydenmk.themoviedbapp.R -import io.github.lordraydenmk.themoviedbapp.appModule -import io.github.lordraydenmk.themoviedbapp.common.observeIn -import io.github.lordraydenmk.themoviedbapp.common.presentation.ViewModelAlgebra -import io.github.lordraydenmk.themoviedbapp.movies.moviedetails.MovieDetailsFragment -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch - -@ExperimentalMaterial3Api -class MoviesFragment : Fragment(R.layout.fragment_compose) { - - private val viewModel: MoviesViewModel by viewModels() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val composeView = view as ComposeView - composeView.setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed) - - val module: TheMovieDbModule = object : TheMovieDbModule, - AppModule by requireActivity().appModule(), - ViewModelAlgebra by viewModel {} - - val actions = Channel(Channel.UNLIMITED) - - with(module) { - composeView.setContent { - PopularMoviesScreen( - stateFlow = viewState, - initialValue = Loading, - actions = actions - ) - } - - lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - program(actions.receiveAsFlow()) - } - } - } - - handleEffects() - } - - private fun handleEffects() { - viewModel.effects.map { effect -> - when (effect) { - is NavigateToDetails -> findNavController().navigate( - R.id.action_details, - MovieDetailsFragment.newBundle(effect.movieId) - ) - } - }.observeIn(this) - } -} \ No newline at end of file diff --git a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/MoviesNavScreen.kt b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/MoviesNavScreen.kt new file mode 100644 index 00000000..208ef3e6 --- /dev/null +++ b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/MoviesNavScreen.kt @@ -0,0 +1,58 @@ +package io.github.lordraydenmk.themoviedbapp.movies.popularmovies + +import androidx.compose.material3.ExperimentalMaterial3Api +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.Screen +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MoviesNavScreen( + appModule: AppModule, + backStack: BackStack, + viewModel: MoviesViewModel = viewModel() +) { + val module = remember { + object : TheMovieDbModule, + AppModule by appModule, + ViewModelAlgebra by viewModel { + override val actions: Channel = Channel(Channel.UNLIMITED) + } + } + + with(module) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + program(actions.receiveAsFlow()) + } + } + LaunchedEffect(lifecycleOwner) { + viewModel.effects + .flowWithLifecycle(lifecycleOwner.lifecycle) + .map { effect -> + when (effect) { + is NavigateToDetails -> backStack.add(Screen.MovieDetails(effect.movieId)) + } + }.collect() + } + } + + val state by viewModel.viewState.collectAsState(Loading) + PopularMoviesScreen(state = state, module.actions) +} \ No newline at end of file diff --git a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/MoviesScreen.kt b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/MoviesScreen.kt index 83ac7064..b3155a66 100644 --- a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/MoviesScreen.kt +++ b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/MoviesScreen.kt @@ -19,7 +19,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -28,23 +27,19 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import io.github.lordraydenmk.themoviedbapp.R import io.github.lordraydenmk.themoviedbapp.movies.domain.MovieId import io.github.lordraydenmk.themoviedbapp.movies.ui.common.MovieLoading import io.github.lordraydenmk.themoviedbapp.movies.ui.common.MovieProblem import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow @ExperimentalMaterial3Api @Composable fun PopularMoviesScreen( - stateFlow: Flow, - initialValue: PopularMoviesViewState, + state: PopularMoviesViewState, actions: Channel ) { - val state by stateFlow.collectAsStateWithLifecycle(initialValue) Scaffold( topBar = { TopAppBar( @@ -54,10 +49,10 @@ fun PopularMoviesScreen( } ) { innerPadding -> Box(modifier = Modifier.padding(innerPadding)) { - when (val s = state) { + when (state) { Loading -> MovieLoading() - is Content -> Content(s) { actions.trySend(LoadDetails(it)) } - is Problem -> MovieProblem(s.stringId) { actions.trySend(Refresh) } + is Content -> Content(state) { actions.trySend(LoadDetails(it)) } + is Problem -> MovieProblem(state.stringId) { actions.trySend(Refresh) } } } } diff --git a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/moviesList.kt b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/moviesList.kt index 87030deb..111a84e4 100644 --- a/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/moviesList.kt +++ b/app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/moviesList.kt @@ -11,10 +11,13 @@ import io.github.lordraydenmk.themoviedbapp.movies.NetworkError import io.github.lordraydenmk.themoviedbapp.movies.ServerError import io.github.lordraydenmk.themoviedbapp.movies.data.popularMovies import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -interface TheMovieDbModule : AppModule, ViewModelAlgebra +interface TheMovieDbModule : AppModule, ViewModelAlgebra { + val actions: Channel +} suspend fun TheMovieDbModule.program(actions: Flow): Unit = parZip(Dispatchers.Default, { firstLoad() }, { handleActions(actions) }) diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index c2e11fbf..00000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,11 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_compose.xml b/app/src/main/res/layout/fragment_compose.xml deleted file mode 100644 index 86372554..00000000 --- a/app/src/main/res/layout/fragment_compose.xml +++ /dev/null @@ -1,5 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml deleted file mode 100644 index a8a70b02..00000000 --- a/app/src/main/res/navigation/nav_graph.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/paparazzi/kotlin/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/MovieDetailsScreenTest.kt b/app/src/paparazzi/kotlin/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/MovieDetailsScreenTest.kt index 0f0d8658..9013ef42 100644 --- a/app/src/paparazzi/kotlin/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/MovieDetailsScreenTest.kt +++ b/app/src/paparazzi/kotlin/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/MovieDetailsScreenTest.kt @@ -8,7 +8,6 @@ import io.github.lordraydenmk.themoviedbapp.common.MainDispatcherRule import io.github.lordraydenmk.themoviedbapp.common.setupCoil import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.emptyFlow import okhttp3.HttpUrl.Companion.toHttpUrl import org.junit.Before import org.junit.Rule @@ -35,8 +34,7 @@ class MovieDetailsScreenTest { fun loadingState() { paparazzi.snapshot { MovieDetailsScreen( - stateFlow = emptyFlow(), - initialState = Loading, + Loading, movieId = 0, actions = Channel() ) @@ -48,22 +46,24 @@ class MovieDetailsScreenTest { val viewState = Problem(ErrorTextRes(R.string.error_recoverable_network)) paparazzi.snapshot { - MovieDetailsScreen(emptyFlow(), viewState, movieId = 0, actions = Channel()) + MovieDetailsScreen(viewState, movieId = 0, actions = Channel()) } } @Test fun contentState() { - val url = "http://i.annihil.us/u/prod/marvel/i/mg/c/e0/535fecbbb9784.jpg".toHttpUrl() - val viewState = Content(MovieDetailsViewEntity( - "Hulk", - "Movie overview", - VoteAverage(0.745f, "7.5"), - url - )) + val url = "https://i.annihil.us/u/prod/marvel/i/mg/c/e0/535fecbbb9784.jpg".toHttpUrl() + val viewState = Content( + MovieDetailsViewEntity( + "Hulk", + "Movie overview", + VoteAverage(0.745f, "7.5"), + url + ) + ) paparazzi.snapshot { - MovieDetailsScreen(emptyFlow(), viewState, movieId = 0, actions = Channel()) + MovieDetailsScreen(viewState, movieId = 0, actions = Channel()) } } } \ No newline at end of file diff --git a/app/src/paparazzi/kotlin/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/PopularMoviesScreenTest.kt b/app/src/paparazzi/kotlin/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/PopularMoviesScreenTest.kt index 5eeef76f..ba9b8964 100644 --- a/app/src/paparazzi/kotlin/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/PopularMoviesScreenTest.kt +++ b/app/src/paparazzi/kotlin/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/PopularMoviesScreenTest.kt @@ -9,7 +9,6 @@ import io.github.lordraydenmk.themoviedbapp.common.MainDispatcherRule import io.github.lordraydenmk.themoviedbapp.common.setupCoil import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.emptyFlow import okhttp3.HttpUrl.Companion.toHttpUrl import org.junit.Before import org.junit.Rule @@ -36,7 +35,7 @@ class PopularMoviesScreenTest { @Test fun loadingState() { paparazzi.snapshot { - PopularMoviesScreen(emptyFlow(), Loading, actions = Channel()) + PopularMoviesScreen(Loading, actions = Channel()) } } @@ -44,7 +43,6 @@ class PopularMoviesScreenTest { fun errorWithRetry() { paparazzi.snapshot { PopularMoviesScreen( - emptyFlow(), Problem(ErrorTextRes(R.string.error_recoverable_network)), actions = Channel() ) @@ -53,7 +51,7 @@ class PopularMoviesScreenTest { @Test fun content() { - val url = "http://i.annihil.us/u/prod/marvel/i/mg/c/e0/535fecbbb9784.jpg".toHttpUrl() + val url = "https://i.annihil.us/u/prod/marvel/i/mg/c/e0/535fecbbb9784.jpg".toHttpUrl() val viewState = Content( listOf( MovieViewEntity(42, "Ant Man", url), @@ -63,8 +61,7 @@ class PopularMoviesScreenTest { ) ) paparazzi.snapshot { - PopularMoviesScreen(emptyFlow(), viewState, actions = Channel()) + PopularMoviesScreen(viewState, actions = Channel()) } - } } \ No newline at end of file diff --git a/app/src/test/java/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/MovieDetailsKtTest.kt b/app/src/test/java/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/MovieDetailsKtTest.kt index 3ecc5257..0db9f7b7 100644 --- a/app/src/test/java/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/MovieDetailsKtTest.kt +++ b/app/src/test/java/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/MovieDetailsKtTest.kt @@ -10,6 +10,7 @@ import io.github.lordraydenmk.themoviedbapp.movies.data.MovieDto import io.github.lordraydenmk.themoviedbapp.movies.data.TheMovieDbService import io.github.lordraydenmk.themoviedbapp.movies.testMovieDbService import io.kotest.matchers.shouldBe +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -38,7 +39,11 @@ class MovieDetailsKtTest { service: TheMovieDbService, viewModelAlgebra: ViewModelAlgebra ): MovieDetailsModule = object : MovieDetailsModule, AppModule by AppModule.create(service), - ViewModelAlgebra by viewModelAlgebra {} + ViewModelAlgebra by viewModelAlgebra { + override val actions: Channel + get() = TODO("Not yet implemented") + + } @Test diff --git a/app/src/test/java/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/PopularMoviesKtTest.kt b/app/src/test/java/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/PopularMoviesKtTest.kt index 4e4a34bd..9ce228da 100644 --- a/app/src/test/java/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/PopularMoviesKtTest.kt +++ b/app/src/test/java/io/github/lordraydenmk/themoviedbapp/movies/popularmovies/PopularMoviesKtTest.kt @@ -9,6 +9,7 @@ import io.github.lordraydenmk.themoviedbapp.movies.data.MovieDto import io.github.lordraydenmk.themoviedbapp.movies.data.TheMovieDbService import io.github.lordraydenmk.themoviedbapp.movies.testMovieDbService import io.kotest.matchers.shouldBe +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf @@ -39,7 +40,10 @@ class PopularMoviesKtTest { viewModel: ViewModelAlgebra ): TheMovieDbModule = object : TheMovieDbModule, AppModule by AppModule.create(service), - ViewModelAlgebra by viewModel {} + ViewModelAlgebra by viewModel { + override val actions: Channel + get() = TODO("Not yet implemented") + } @Test