Skip to content

Commit

Permalink
Movie detail screen (#5)
Browse files Browse the repository at this point in the history
- Works only online (no cache).
- Graphics to be improved.
  • Loading branch information
julioromano authored Mar 9, 2023
1 parent a6540a6 commit 4ec3b4e
Show file tree
Hide file tree
Showing 15 changed files with 448 additions and 1 deletion.
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ dependencies {
implementation(projects.httpapi.implWiring)
implementation(projects.database.implWiring)
implementation(projects.trending)
implementation(projects.movie)
implementation(libs.androidx.activityCompose)
implementation(libs.androidx.core)
implementation(libs.androidx.coreSplashscreen)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.google.accompanist.navigation.animation.AnimatedNavHost
import com.google.accompanist.navigation.animation.rememberAnimatedNavController
import net.marcoromano.tmdb.movie.MovieNavigation
import net.marcoromano.tmdb.trending.TrendingNavigation

@OptIn(ExperimentalAnimationApi::class)
Expand All @@ -20,7 +21,11 @@ fun AppNavHost() {
) {
TrendingNavigation.navGraphBuilder(
navGraphBuilder = this,
navToDetail = { /* TODO() */ },
navToDetail = { MovieNavigation.navigate(navController, it) },
)
MovieNavigation.navGraphBuilder(
navGraphBuilder = this,
navBack = navController::popBackStack,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ public data class Movie(
val id: Long,
val title: String,
val poster_path: String,
val backdrop_path: String,
val overview: String,
val vote_average: Double,
val popularity: Double,
val runtime: Int,
val release_date: String,
)
20 changes: 20 additions & 0 deletions movie/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
plugins {
id("conventions.android")
}

android {
namespace = "net.marcoromano.tmdb.movie"
}

dependencies {
implementation(projects.database.public)
implementation(projects.httpapi.public)
implementation(libs.coilCompose)
implementation(libs.androidx.pagingRuntime)
implementation(libs.androidx.pagingCompose)
implementation(libs.square.sqlDelightAndroidPaging3)
implementation(libs.square.sqlDelightCoroutines)
testImplementation(libs.square.turbine)
testImplementation(projects.database.fake)
testImplementation(projects.httpapi.fake)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package net.marcoromano.tmdb.movie

import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import org.junit.Rule
import org.junit.Test

internal class FeatureScreenIntegrationTest {

@get:Rule
internal val rule = createComposeRule()

@Test
internal fun buttonSendsTextFieldContentToGreetHandler() {
rule.setContent {
MovieScreen()
}

rule.onNodeWithContentDescription("Insert name")
.performTextInput("Mario")
rule.onNodeWithContentDescription("Greet button")
.performClick()
rule.onNodeWithContentDescription("Greeting")
.assertTextEquals("Hello, Mario")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package net.marcoromano.tmdb.movie

import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals

internal class FeatureScreenUnitTest {

@get:Rule
internal val rule = createComposeRule()

@Test
internal fun greetingIsDisplayed() {
rule.setContent {
MovieScreen(
state = TrendingState(greeting = "SomeGreeting"),
greet = {},
)
}

rule.onNodeWithContentDescription("Greeting")
.assertTextEquals("SomeGreeting")
}

@Test
internal fun buttonSendsTextFieldContentToGreetHandler() {
var text = ""
rule.setContent {
MovieScreen(
state = TrendingState(),
greet = { text = it },
)
}

rule.onNodeWithContentDescription("Insert name")
.performTextInput("Mario")
rule.onNodeWithContentDescription("Greet button")
.performClick()
assertEquals("Mario", text)
}
}
18 changes: 18 additions & 0 deletions movie/src/main/kotlin/net/marcoromano/tmdb/movie/DemoMovie.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package net.marcoromano.tmdb.movie

import net.marcoromano.tmdb.httpapi.Movie

internal fun demoMovie() = Movie(
id = 261414,
title = "Larry David: Curb Your Enthusiasm",
poster_path = "/m9QE3XkVc9hjDgpRTVoMpTNtfOY.jpg",
overview = "Mock documentary about Seinfeld writer Larry David featuring contributions from " +
"his friends and colleagues. Larry makes a return to stand-up comedy and prepares to film " +
"a television special for HBO. This is the original special that gave birth to the " +
"long-running award-winning HBO series.",
vote_average = 7.6,
runtime = 59,
backdrop_path = "/jG8qyfFdfbxmvmYEYFUx64toKIp.jpg",
popularity = 6.57,
release_date = "1999-10-17",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package net.marcoromano.tmdb.movie

import androidx.compose.animation.ExperimentalAnimationApi
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.navArgument
import com.google.accompanist.navigation.animation.composable

public object MovieNavigation {
@OptIn(ExperimentalAnimationApi::class)
public fun navGraphBuilder(
navGraphBuilder: NavGraphBuilder,
navBack: () -> Unit,
) {
navGraphBuilder.composable(
route = "movie/{id}",
arguments = listOf(
navArgument(name = "id") {
type = NavType.LongType
},
),
) {
MovieScreen(
navBack = navBack,
)
}
}

public fun navigate(navController: NavController, id: Long) {
navController.navigate("movie/$id")
}
}
103 changes: 103 additions & 0 deletions movie/src/main/kotlin/net/marcoromano/tmdb/movie/MovieScreen.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package net.marcoromano.tmdb.movie

import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import net.marcoromano.tmdb.movie.widgets.Movie

@Composable
internal fun MovieScreen(
navBack: () -> Unit,
) {
val vm = hiltViewModel<MovieViewModel>()
val state by vm.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
if (state.movie == null) vm.load()
}
MovieScreen(
state = state,
navBack = navBack,
)
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MovieScreen(
state: MovieState,
navBack: () -> Unit,
) {
val behavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(behavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
title = { Text(text = state.movie?.title ?: stringResource(R.string.movie)) },
navigationIcon = {
IconButton(onClick = navBack) {
Icon(imageVector = Icons.Default.ArrowBack, contentDescription = null)
}
},
scrollBehavior = behavior,
)
},
) { paddingValues ->
if (state.isLoading) {
Box(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.fillMaxSize(fraction = 0.5f),
)
}
} else {
LazyColumn(
modifier = Modifier.padding(paddingValues),
) {
state.movie?.let {
item {
Movie(
movie = it,
)
}
}
}
}
}
}

@Preview(name = "Day mode")
@Preview(name = "Night mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
MovieScreen(
state = MovieState(
movie = demoMovie(),
),
navBack = {},
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package net.marcoromano.tmdb.movie

import net.marcoromano.tmdb.httpapi.Movie

internal data class MovieState(
val isLoading: Boolean = false,
val errors: Map<String, Throwable> = emptyMap(),
val movie: Movie? = null,
)
50 changes: 50 additions & 0 deletions movie/src/main/kotlin/net/marcoromano/tmdb/movie/MovieViewModel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package net.marcoromano.tmdb.movie

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import net.marcoromano.tmdb.httpapi.HttpApi
import net.marcoromano.tmdb.httpapi.Movie
import java.util.UUID
import javax.inject.Inject

@HiltViewModel
internal class MovieViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val httpApi: HttpApi,
) : ViewModel() {
private val isLoading = MutableStateFlow(false)
private val errors = MutableStateFlow<Map<String, Throwable>>(emptyMap())
private val movie = MutableStateFlow<Movie?>(null)

val state = combine(isLoading, errors, movie, ::MovieState)
.stateIn(viewModelScope, SharingStarted.Lazily, MovieState())

fun load() {
viewModelScope.launch {
isLoading.value = true
runCatching {
httpApi.movie(movieId = savedStateHandle["id"]!!)
}.onSuccess {
movie.value = it
isLoading.value = false
}.onFailure { throwable ->
errors.update {
it + (UUID.randomUUID().toString() to throwable)
}
isLoading.value = false
}
}
}

fun consumeError(uuid: String) {
errors.update { it - uuid }
}
}
Loading

0 comments on commit 4ec3b4e

Please sign in to comment.