diff --git a/app/src/androidTest/java/com/rumosoft/marvelcompose/data/FakeCharactersNetwork.kt b/app/src/androidTest/java/com/rumosoft/marvelcompose/data/FakeCharactersNetwork.kt index 176b64f..6e75ed4 100644 --- a/app/src/androidTest/java/com/rumosoft/marvelcompose/data/FakeCharactersNetwork.kt +++ b/app/src/androidTest/java/com/rumosoft/marvelcompose/data/FakeCharactersNetwork.kt @@ -20,7 +20,7 @@ class FakeCharactersNetwork @Inject constructor(): CharactersNetwork { ), characters = (offset .. offset + CHARACTERS_SEARCH_LIMIT).map { HeroDto( - id = it, + id = it.toLong(), name = "character $it", description = "description $it", thumbnail = null, @@ -32,6 +32,13 @@ class FakeCharactersNetwork @Inject constructor(): CharactersNetwork { return Result.success(heroesResult) } + override suspend fun getHeroDetails(heroId: Long): Result { + val hero = searchHeroes(0, 0, "").getOrNull()?.characters?.find { it.id == heroId } + return hero?.let { + Result.success(it) + } ?: Result.failure(Exception("Hero not found")) + } + override suspend fun getComicThumbnail(comicId: Int): Result = Result.success("thumbnail") } diff --git a/app/src/main/java/com/rumosoft/marvelcompose/presentation/MainActivity.kt b/app/src/main/java/com/rumosoft/marvelcompose/presentation/MainActivity.kt index 3e8cc19..46e8362 100644 --- a/app/src/main/java/com/rumosoft/marvelcompose/presentation/MainActivity.kt +++ b/app/src/main/java/com/rumosoft/marvelcompose/presentation/MainActivity.kt @@ -22,9 +22,10 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import com.rumosoft.characters.presentation.navigation.NavCharItem -import com.rumosoft.comics.presentation.navigation.NavComicItem import com.rumosoft.components.presentation.component.isWindowCompact +import com.rumosoft.components.presentation.deeplinks.CharactersScreen +import com.rumosoft.components.presentation.deeplinks.ComicsScreen +import com.rumosoft.components.presentation.deeplinks.Screen import com.rumosoft.components.presentation.theme.MarvelComposeTheme import com.rumosoft.marvelcompose.R import com.rumosoft.marvelcompose.presentation.navigation.BottomNavigationBar @@ -70,10 +71,9 @@ fun MarvelApp() { snackbarHost = { SnackbarHost(hostState = snackBarHostState) }, bottomBar = { if (shouldShowBottomBar(navController)) { - val navBackStackEntry by navController.currentBackStackEntryAsState() BottomNavigationBar( navigationItems = navigationItems, - currentRoute = navBackStackEntry?.destination?.route, + currentScreen = getCurrentScreen(navController), onTabClick = { onTabClick(it, navController) }, ) } @@ -81,10 +81,9 @@ fun MarvelApp() { ) { innerPadding -> if (shouldShowNavigationRail()) { Row { - val navBackStackEntry by navController.currentBackStackEntryAsState() NavigationRailBar( navigationItems = navigationItems, - currentRoute = navBackStackEntry?.destination?.route, + currentScreen = getCurrentScreen(navController), onTabClick = { onTabClick(it, navController) }, ) NavigationHost(navController, modifier = Modifier.padding(innerPadding)) @@ -98,10 +97,23 @@ fun MarvelApp() { @Composable private fun shouldShowBottomBar( navController: NavHostController -) = isWindowCompact() && currentRoute(navController) in listOf( - NavCharItem.Characters.destination, - NavComicItem.Comics.route, -) +): Boolean { + println("isWindowCompact: ${isWindowCompact()}") + println("currentRoute: ${currentRoute(navController)}") + return isWindowCompact() && ( + currentRoute(navController)?.contains("CharactersScreen") == true || + currentRoute(navController)?.contains("ComicsScreen") == true) +} + +@Composable +private fun getCurrentScreen(navController: NavHostController): Screen { + val route = navController.currentBackStackEntryAsState().value?.destination?.route + return when { + route?.contains("CharactersScreen") == true -> CharactersScreen + route?.contains("ComicsScreen") == true -> ComicsScreen + else -> CharactersScreen + } +} @Composable private fun shouldShowNavigationRail() = !isWindowCompact() diff --git a/app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/BottomNavigationBar.kt b/app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/BottomNavigationBar.kt index de6e5f5..02473ce 100644 --- a/app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/BottomNavigationBar.kt +++ b/app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/BottomNavigationBar.kt @@ -9,18 +9,20 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.rumosoft.components.presentation.deeplinks.CharactersScreen +import com.rumosoft.components.presentation.deeplinks.Screen import com.rumosoft.components.presentation.theme.MarvelComposeTheme @Composable fun BottomNavigationBar( navigationItems: List, - currentRoute: String?, + currentScreen: Screen, onTabClick: (Tabs) -> Unit = {}, ) { NavigationBar { navigationItems.forEach { tab -> val tabTitle = stringResource(id = tab.title) - val selected = currentRoute == tab.route + val selected = currentScreen == tab.screen NavigationBarItem( icon = { Icon( @@ -49,7 +51,7 @@ fun BottomNavigationBar( fun PreviewBottomNavigationBar() { MarvelComposeTheme { BottomNavigationBar( - currentRoute = Tabs.Characters.route, + currentScreen = CharactersScreen, navigationItems = listOf( Tabs.Characters, Tabs.Comics, diff --git a/app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/NavigationHost.kt b/app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/NavigationHost.kt index 13b4f7a..8f78c91 100644 --- a/app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/NavigationHost.kt +++ b/app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/NavigationHost.kt @@ -5,9 +5,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost -import com.rumosoft.characters.presentation.navigation.NavCharItem import com.rumosoft.characters.presentation.navigation.charactersGraph import com.rumosoft.comics.presentation.navigation.comicsGraph +import com.rumosoft.components.presentation.deeplinks.CharactersScreen const val NAVIGATION_HOST_TEST_TAG = "NavigationHost" @@ -15,7 +15,7 @@ const val NAVIGATION_HOST_TEST_TAG = "NavigationHost" fun NavigationHost(navController: NavHostController, modifier: Modifier = Modifier) { NavHost( navController, - startDestination = NavCharItem.Characters.route, + startDestination = CharactersScreen, modifier = modifier.testTag(NAVIGATION_HOST_TEST_TAG) ) { charactersGraph(navController) diff --git a/app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/NavigationRailBar.kt b/app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/NavigationRailBar.kt index 268f660..9e1b7fb 100644 --- a/app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/NavigationRailBar.kt +++ b/app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/NavigationRailBar.kt @@ -12,19 +12,21 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.rumosoft.components.presentation.deeplinks.CharactersScreen +import com.rumosoft.components.presentation.deeplinks.Screen import com.rumosoft.components.presentation.theme.MarvelComposeTheme @Composable fun NavigationRailBar( navigationItems: List, - currentRoute: String?, + currentScreen: Screen, onTabClick: (Tabs) -> Unit = {}, ) { NavigationRail { Spacer(modifier = Modifier.height(8.dp)) navigationItems.forEach { tab -> val tabTitle = stringResource(id = tab.title) - val selected = currentRoute == tab.route + val selected = currentScreen == tab.screen NavigationRailItem( icon = { Icon( @@ -46,7 +48,7 @@ fun NavigationRailBar( fun PreviewNavigationRailBar() { MarvelComposeTheme { NavigationRailBar( - currentRoute = Tabs.Characters.route, + currentScreen = CharactersScreen, navigationItems = listOf( Tabs.Characters, Tabs.Comics, diff --git a/app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/Tabs.kt b/app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/Tabs.kt index f32fbf8..1bd4b2e 100644 --- a/app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/Tabs.kt +++ b/app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/Tabs.kt @@ -2,23 +2,24 @@ package com.rumosoft.marvelcompose.presentation.navigation import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import com.rumosoft.characters.presentation.navigation.NavCharItem -import com.rumosoft.comics.presentation.navigation.NavComicItem +import com.rumosoft.components.presentation.deeplinks.CharactersScreen +import com.rumosoft.components.presentation.deeplinks.ComicsScreen +import com.rumosoft.components.presentation.deeplinks.Screen import com.rumosoft.marvelcompose.R sealed class Tabs( - val route: String, + val screen: Screen, @StringRes val title: Int, @DrawableRes val icon: Int, ) { - object Characters : Tabs( - NavCharItem.Characters.destination, + data object Characters : Tabs( + CharactersScreen, com.rumosoft.characters.R.string.characters, R.drawable.ic_characters, ) - object Comics : Tabs( - NavComicItem.Comics.destination, + data object Comics : Tabs( + ComicsScreen, com.rumosoft.comics.R.string.comics, R.drawable.ic_comics, ) diff --git a/app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/onTabClick.kt b/app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/onTabClick.kt index f1b4dc3..b38213f 100644 --- a/app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/onTabClick.kt +++ b/app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/onTabClick.kt @@ -6,16 +6,11 @@ fun onTabClick( tab: Tabs, navController: NavHostController, ) { - if (tab.route != navController.currentDestination?.route) { - navController.navigate(tab.route) { - navController.currentDestination?.route?.let { - popUpTo(it) { - saveState = true - inclusive = true - } - } - - launchSingleTop = true + navController.navigate(tab.screen) { + popUpTo(tab.screen) { + saveState = true + inclusive = true } + launchSingleTop = true } } diff --git a/app/src/main/java/com/rumosoft/marvelcompose/presentation/widget/WidgetContent.kt b/app/src/main/java/com/rumosoft/marvelcompose/presentation/widget/WidgetContent.kt index 73437c8..ece48df 100644 --- a/app/src/main/java/com/rumosoft/marvelcompose/presentation/widget/WidgetContent.kt +++ b/app/src/main/java/com/rumosoft/marvelcompose/presentation/widget/WidgetContent.kt @@ -2,9 +2,9 @@ package com.rumosoft.marvelcompose.presentation.widget import android.content.Context import android.content.Intent -import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.glance.Button import androidx.glance.GlanceId import androidx.glance.GlanceModifier @@ -22,8 +22,11 @@ import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.padding import androidx.glance.layout.width import androidx.glance.text.Text -import com.rumosoft.characters.presentation.navigation.NavCharItem -import com.rumosoft.comics.presentation.navigation.NavComicItem +import com.rumosoft.components.presentation.deeplinks.CharactersScreen +import com.rumosoft.components.presentation.deeplinks.ComicsScreen +import com.rumosoft.components.presentation.deeplinks.DEEP_LINKS_BASE_PATH +import com.rumosoft.components.presentation.deeplinks.Screen +import com.rumosoft.marvelcompose.presentation.MainActivity @Composable internal fun WidgetContent() { @@ -57,7 +60,7 @@ class OpenCharactersAction : ActionCallback { glanceId: GlanceId, parameters: ActionParameters ) { - openDeepLink(context, NavCharItem.Characters.deepLink) + openDeepLink(context, CharactersScreen) } } @@ -67,13 +70,17 @@ class OpenComicsAction : ActionCallback { glanceId: GlanceId, parameters: ActionParameters ) { - openDeepLink(context, NavComicItem.Comics.deepLink) + openDeepLink(context, ComicsScreen) } } -private fun openDeepLink(context: Context, deepLink: String) { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(deepLink)).apply { - setPackage(context.packageName) +private fun openDeepLink(context: Context, screen: Screen) { + val intent = Intent(context, MainActivity::class.java).apply { + if (screen is CharactersScreen) { + data = "$DEEP_LINKS_BASE_PATH/characters".toUri() + } else { + data = "$DEEP_LINKS_BASE_PATH/comics".toUri() + } flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } context.startActivity(intent) diff --git a/feature-characters/src/androidTest/java/com/rumosoft/characters/presentation/FakeCharacters.kt b/feature-characters/src/androidTest/java/com/rumosoft/characters/presentation/FakeCharacters.kt index a05d601..f7bb256 100644 --- a/feature-characters/src/androidTest/java/com/rumosoft/characters/presentation/FakeCharacters.kt +++ b/feature-characters/src/androidTest/java/com/rumosoft/characters/presentation/FakeCharacters.kt @@ -6,6 +6,7 @@ object FakeCharacters { fun getSampleCharacters(numCharacters: Int = 5): List = (1..numCharacters).map { characterId -> Character( + id = characterId.toLong(), name = "character $characterId", description = "description $characterId", thumbnail = "", diff --git a/feature-characters/src/main/java/com/rumosoft/characters/data/mappers/toHero.kt b/feature-characters/src/main/java/com/rumosoft/characters/data/mappers/toHero.kt index af3e25a..2540b60 100644 --- a/feature-characters/src/main/java/com/rumosoft/characters/data/mappers/toHero.kt +++ b/feature-characters/src/main/java/com/rumosoft/characters/data/mappers/toHero.kt @@ -10,6 +10,7 @@ import com.rumosoft.marvelapi.data.network.apimodels.toThumbnailUrl fun HeroDto.toHero(): Character { return Character( + id = id, name = name.orEmpty(), description = description.orEmpty(), thumbnail = thumbnail?.toThumbnailUrl().orEmpty(), @@ -28,4 +29,4 @@ fun ComicSummaryDto.toComicSummary(): ComicSummary = ComicSummary( title = name.orEmpty(), url = resourceUri.orEmpty(), - ) \ No newline at end of file + ) diff --git a/feature-characters/src/main/java/com/rumosoft/characters/data/repository/SearchRepositoryImpl.kt b/feature-characters/src/main/java/com/rumosoft/characters/data/repository/SearchRepositoryImpl.kt index 0bb0755..551e67f 100644 --- a/feature-characters/src/main/java/com/rumosoft/characters/data/repository/SearchRepositoryImpl.kt +++ b/feature-characters/src/main/java/com/rumosoft/characters/data/repository/SearchRepositoryImpl.kt @@ -32,6 +32,14 @@ class SearchRepositoryImpl @Inject constructor( return networkResult } + override suspend fun getCharacterDetails(heroId: Long): Result { + val heroDetails = network.getHeroDetails(heroId) + if (heroDetails.isSuccess) { + Timber.d("Returned hero details") + } + return network.getHeroDetails(heroId).map { it?.toHero() } + } + override suspend fun getThumbnail(comicId: Int): Result { return network.getComicThumbnail(comicId) } diff --git a/feature-characters/src/main/java/com/rumosoft/characters/domain/model/Character.kt b/feature-characters/src/main/java/com/rumosoft/characters/domain/model/Character.kt index f89acaa..6f86df0 100644 --- a/feature-characters/src/main/java/com/rumosoft/characters/domain/model/Character.kt +++ b/feature-characters/src/main/java/com/rumosoft/characters/domain/model/Character.kt @@ -14,6 +14,7 @@ import kotlinx.serialization.json.Json @Parcelize @Serializable data class Character( + val id: Long, val name: String, val description: String, val thumbnail: String, diff --git a/feature-characters/src/main/java/com/rumosoft/characters/domain/usecase/GetCharacterDetailsUseCase.kt b/feature-characters/src/main/java/com/rumosoft/characters/domain/usecase/GetCharacterDetailsUseCase.kt new file mode 100644 index 0000000..5543a8e --- /dev/null +++ b/feature-characters/src/main/java/com/rumosoft/characters/domain/usecase/GetCharacterDetailsUseCase.kt @@ -0,0 +1,14 @@ +package com.rumosoft.characters.domain.usecase + +import com.rumosoft.characters.domain.model.Character +import com.rumosoft.characters.domain.usecase.interfaces.SearchRepository +import javax.inject.Inject + +class GetCharacterDetailsUseCase @Inject constructor( + private val repository: SearchRepository, +) { + suspend operator fun invoke( + characterId: Long, + ): Result = + repository.getCharacterDetails(characterId) +} diff --git a/feature-characters/src/main/java/com/rumosoft/characters/domain/usecase/interfaces/SearchRepository.kt b/feature-characters/src/main/java/com/rumosoft/characters/domain/usecase/interfaces/SearchRepository.kt index 95b6bfc..3e4ed76 100644 --- a/feature-characters/src/main/java/com/rumosoft/characters/domain/usecase/interfaces/SearchRepository.kt +++ b/feature-characters/src/main/java/com/rumosoft/characters/domain/usecase/interfaces/SearchRepository.kt @@ -4,5 +4,6 @@ import com.rumosoft.characters.domain.model.Character interface SearchRepository { suspend fun performSearch(nameStartsWith: String, page: Int): Result> + suspend fun getCharacterDetails(heroId: Long): Result suspend fun getThumbnail(comicId: Int): Result } diff --git a/feature-characters/src/main/java/com/rumosoft/characters/infrastructure/sampleData/SampleData.kt b/feature-characters/src/main/java/com/rumosoft/characters/infrastructure/sampleData/SampleData.kt index 893a20d..4f0be5e 100644 --- a/feature-characters/src/main/java/com/rumosoft/characters/infrastructure/sampleData/SampleData.kt +++ b/feature-characters/src/main/java/com/rumosoft/characters/infrastructure/sampleData/SampleData.kt @@ -10,7 +10,7 @@ import com.rumosoft.marvelapi.data.network.apimodels.UrlDto object SampleData { val heroesDtoSample = (1..10).map { HeroDto( - id = it, + id = it.toLong(), name = "Hero$it", description = "Description hero $it", thumbnail = ImageDto( diff --git a/feature-characters/src/main/java/com/rumosoft/characters/presentation/navigation/CharactersNavModule.kt b/feature-characters/src/main/java/com/rumosoft/characters/presentation/navigation/CharactersNavModule.kt index 7f58dde..384c661 100644 --- a/feature-characters/src/main/java/com/rumosoft/characters/presentation/navigation/CharactersNavModule.kt +++ b/feature-characters/src/main/java/com/rumosoft/characters/presentation/navigation/CharactersNavModule.kt @@ -1,46 +1,52 @@ package com.rumosoft.characters.presentation.navigation -import androidx.core.net.toUri +import androidx.compose.runtime.LaunchedEffect import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable import androidx.navigation.navDeepLink +import androidx.navigation.toRoute import com.rumosoft.characters.presentation.screen.DetailsScreen import com.rumosoft.characters.presentation.screen.HeroListScreen import com.rumosoft.characters.presentation.viewmodel.DetailsViewModel import com.rumosoft.characters.presentation.viewmodel.HeroListViewModel -import com.rumosoft.marvelapi.DeepLinks +import com.rumosoft.components.presentation.deeplinks.CharacterDetails +import com.rumosoft.components.presentation.deeplinks.CharactersScreen +import com.rumosoft.components.presentation.deeplinks.ComicDetails +import com.rumosoft.components.presentation.deeplinks.DEEP_LINKS_BASE_PATH fun NavGraphBuilder.charactersGraph(navController: NavHostController) { - composable( - route = NavCharItem.Characters.route, - deepLinks = listOf(navDeepLink { uriPattern = NavCharItem.Characters.deepLink }), + composable( + deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINKS_BASE_PATH/characters")), ) { navBackStackEntry -> val viewModel: HeroListViewModel = hiltViewModel(navBackStackEntry) HeroListScreen( viewModel = viewModel, onCharacterSelected = { selectedCharacter -> viewModel.resetSelectedCharacter() - navController.navigate(NavCharItem.Details.routeOfCharacter(selectedCharacter)) + navController.navigate(CharacterDetails(selectedCharacter.id)) }, ) } - composable( - route = NavCharItem.Details.route, - arguments = NavCharItem.Details.navArgs, + composable( + deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINKS_BASE_PATH/characters")), ) { navBackStackEntry -> - val viewModel: DetailsViewModel = hiltViewModel(navBackStackEntry) + val viewModel: DetailsViewModel = hiltViewModel() + LaunchedEffect(Unit) { + val characterId = navBackStackEntry.toRoute().characterId + viewModel.setCharacter(characterId) + } DetailsScreen( viewModel = viewModel, onBackPressed = { navController.popBackStack( - route = NavCharItem.Characters.route, + route = CharactersScreen, inclusive = false, ) }, onComicSelected = { comicId -> - navController.navigate(DeepLinks.ComicDetails.routeOfComic(comicId).toUri()) + navController.navigate(ComicDetails(comicId)) }, ) } diff --git a/feature-characters/src/main/java/com/rumosoft/characters/presentation/viewmodel/DetailsViewModel.kt b/feature-characters/src/main/java/com/rumosoft/characters/presentation/viewmodel/DetailsViewModel.kt index 66e3c32..ba905fe 100644 --- a/feature-characters/src/main/java/com/rumosoft/characters/presentation/viewmodel/DetailsViewModel.kt +++ b/feature-characters/src/main/java/com/rumosoft/characters/presentation/viewmodel/DetailsViewModel.kt @@ -1,34 +1,37 @@ package com.rumosoft.characters.presentation.viewmodel -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.rumosoft.characters.domain.model.Character +import com.rumosoft.characters.domain.usecase.GetCharacterDetailsUseCase import com.rumosoft.characters.domain.usecase.GetComicThumbnailUseCase -import com.rumosoft.characters.presentation.navigation.NavCharItem import com.rumosoft.characters.presentation.viewmodel.state.DetailsState import com.rumosoft.marvelapi.infrastructure.extensions.update import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel class DetailsViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, private val getComicThumbnailUseCase: GetComicThumbnailUseCase, + private val getCharacterDetailsUseCase: GetCharacterDetailsUseCase, ) : ViewModel() { - - val character: Character = savedStateHandle[NavCharItem.Details.navArgs[0].name]!! val detailsState: StateFlow get() = _detailsState private val _detailsState = MutableStateFlow(initialDetailsState()) - init { + fun setCharacter(characterId: Long) { viewModelScope.launch { - _detailsState.emit(DetailsState.Success(character)) - loadComicThumbnails(character) + Timber.d("characterId: $characterId") + val character = getCharacterDetailsUseCase(characterId).getOrNull() + if (character != null) { + _detailsState.emit(DetailsState.Success(character)) + loadComicThumbnails(character) + return@launch + } } } diff --git a/feature-characters/src/test/java/com/rumosoft/characters/presentation/viewmodel/DetailsViewModelTest.kt b/feature-characters/src/test/java/com/rumosoft/characters/presentation/viewmodel/DetailsViewModelTest.kt index 4fb4801..43abb2c 100644 --- a/feature-characters/src/test/java/com/rumosoft/characters/presentation/viewmodel/DetailsViewModelTest.kt +++ b/feature-characters/src/test/java/com/rumosoft/characters/presentation/viewmodel/DetailsViewModelTest.kt @@ -1,9 +1,8 @@ package com.rumosoft.characters.presentation.viewmodel -import androidx.lifecycle.SavedStateHandle +import com.rumosoft.characters.domain.usecase.GetCharacterDetailsUseCase import com.rumosoft.characters.domain.usecase.GetComicThumbnailUseCase import com.rumosoft.characters.infrastructure.sampleData.SampleData -import com.rumosoft.characters.presentation.navigation.NavCharItem import com.rumosoft.characters.presentation.viewmodel.state.DetailsState import com.rumosoft.libraryTests.TestCoroutineExtension import io.mockk.coEvery @@ -17,16 +16,15 @@ import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(TestCoroutineExtension::class) internal class DetailsViewModelTest { private val getComicThumbnailUseCase: GetComicThumbnailUseCase = mockk() + private val getCharacterDetailsUseCase: GetCharacterDetailsUseCase = mockk() private lateinit var detailsViewModel: DetailsViewModel private val hero = SampleData.heroesSampleWithoutImages.first() - private val savedStateHandle = SavedStateHandle().apply { - this[NavCharItem.Details.navArgs[0].name] = hero - } @Test fun `When the view model is created getComicThumbnailUseCase is invoked`() { runTest { `given getComicThumbnailUseCase invocation returns results`() + `given getCharacterDetailsUseCase invocation returns results`() `when view model is initialised`() @@ -38,6 +36,7 @@ internal class DetailsViewModelTest { fun `When the view model is created and getComicThumbnailUseCase returns result the state is Success`() { runTest { `given getComicThumbnailUseCase invocation returns results`() + `given getCharacterDetailsUseCase invocation returns results`() `when view model is initialised`() @@ -50,8 +49,16 @@ internal class DetailsViewModelTest { Result.success("thumbnail") } + private fun `given getCharacterDetailsUseCase invocation returns results`() { + coEvery { getCharacterDetailsUseCase.invoke(hero.id) } returns Result.success(hero) + } + private fun `when view model is initialised`() { - detailsViewModel = DetailsViewModel(savedStateHandle, getComicThumbnailUseCase) + detailsViewModel = DetailsViewModel( + getComicThumbnailUseCase, + getCharacterDetailsUseCase, + ) + detailsViewModel.setCharacter(hero.id) } private fun `then the state is Success`() { diff --git a/feature-comics/src/main/java/com/rumosoft/comics/domain/model/Comic.kt b/feature-comics/src/main/java/com/rumosoft/comics/domain/model/Comic.kt index 6d30a83..cb62a00 100644 --- a/feature-comics/src/main/java/com/rumosoft/comics/domain/model/Comic.kt +++ b/feature-comics/src/main/java/com/rumosoft/comics/domain/model/Comic.kt @@ -3,9 +3,11 @@ package com.rumosoft.comics.domain.model import android.os.Parcelable import androidx.annotation.Keep import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable @Keep @Parcelize +@Serializable data class Comic( val id: Int, val digitalId: Int, @@ -19,12 +21,14 @@ data class Comic( @Keep @Parcelize +@Serializable data class CharacterSummary( val items: List, ) : Parcelable @Keep @Parcelize +@Serializable data class CharacterUrls( val name: String, val resourceUri: String, diff --git a/feature-comics/src/main/java/com/rumosoft/comics/presentation/navigation/ComicsNavModule.kt b/feature-comics/src/main/java/com/rumosoft/comics/presentation/navigation/ComicsNavModule.kt index b727769..82fee9a 100644 --- a/feature-comics/src/main/java/com/rumosoft/comics/presentation/navigation/ComicsNavModule.kt +++ b/feature-comics/src/main/java/com/rumosoft/comics/presentation/navigation/ComicsNavModule.kt @@ -1,47 +1,50 @@ package com.rumosoft.comics.presentation.navigation -import androidx.core.net.toUri +import androidx.compose.runtime.LaunchedEffect import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable import androidx.navigation.navDeepLink +import androidx.navigation.toRoute import com.rumosoft.comics.presentation.screen.ComicDetailsScreen import com.rumosoft.comics.presentation.screen.ComicsScreen import com.rumosoft.comics.presentation.viewmodel.ComicDetailsViewModel import com.rumosoft.comics.presentation.viewmodel.ComicListViewModel -import com.rumosoft.marvelapi.DeepLinks +import com.rumosoft.components.presentation.deeplinks.ComicDetails +import com.rumosoft.components.presentation.deeplinks.ComicsScreen +import com.rumosoft.components.presentation.deeplinks.DEEP_LINKS_BASE_PATH fun NavGraphBuilder.comicsGraph(navController: NavHostController) { - composable( - NavComicItem.Comics.route, - deepLinks = listOf(navDeepLink { uriPattern = NavComicItem.Comics.deepLink }), + composable( + deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINKS_BASE_PATH/comics")), ) { navBackStackEntry -> val viewModel: ComicListViewModel = hiltViewModel(navBackStackEntry) ComicsScreen( viewModel = viewModel, onComicSelected = { selectedComic -> viewModel.resetSelectedComic() - navController.navigate(NavComicItem.Details.routeOfComic(selectedComic.id)) + navController.navigate(ComicDetails(selectedComic.id)) }, ) } - composable( - NavComicItem.Details.route, - deepLinks = listOf(navDeepLink { uriPattern = NavComicItem.Details.deepLink }), + composable( + deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINKS_BASE_PATH/comics")), ) { navBackStackEntry -> val viewModel: ComicDetailsViewModel = hiltViewModel(navBackStackEntry) + LaunchedEffect(Unit) { + val comicId: Int = navBackStackEntry.toRoute().comicId + viewModel.setComic(comicId) + } ComicDetailsScreen( viewModel = viewModel, onBackPressed = { - try { - navController.getBackStackEntry(NavComicItem.Comics.route) - navController.popBackStack( - route = NavComicItem.Comics.route, - inclusive = false, - ) - } catch (e: Exception) { - navController.navigate(DeepLinks.Comics.route.toUri()) + val navigatedToComics = navController.popBackStack( + route = ComicsScreen, + inclusive = false, + ) + if (!navigatedToComics) { + navController.navigate(ComicsScreen) } }, ) diff --git a/feature-comics/src/main/java/com/rumosoft/comics/presentation/viewmodel/ComicDetailsViewModel.kt b/feature-comics/src/main/java/com/rumosoft/comics/presentation/viewmodel/ComicDetailsViewModel.kt index 8e2edf7..dd31a46 100644 --- a/feature-comics/src/main/java/com/rumosoft/comics/presentation/viewmodel/ComicDetailsViewModel.kt +++ b/feature-comics/src/main/java/com/rumosoft/comics/presentation/viewmodel/ComicDetailsViewModel.kt @@ -1,10 +1,8 @@ package com.rumosoft.comics.presentation.viewmodel -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.rumosoft.comics.domain.usecase.GetComicDetailsUseCase -import com.rumosoft.comics.presentation.navigation.NavComicItem import com.rumosoft.comics.presentation.viewmodel.state.ComicDetailsState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -15,16 +13,13 @@ import javax.inject.Inject @HiltViewModel class ComicDetailsViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, private val getComicDetailsUseCase: GetComicDetailsUseCase, ) : ViewModel() { - val comicId = (checkNotNull(savedStateHandle[NavComicItem.Details.navArgs[0].name]) as String).toInt() - val detailsState: StateFlow get() = _detailsState private val _detailsState = MutableStateFlow(ComicDetailsState.Loading) - init { + fun setComic(comicId: Int) { viewModelScope.launch { getComicDetailsUseCase(comicId).fold( onSuccess = { comic -> diff --git a/feature-comics/src/test/java/com/rumosoft/comics/presentation/viewmodel/ComicDetailsViewModelTest.kt b/feature-comics/src/test/java/com/rumosoft/comics/presentation/viewmodel/ComicDetailsViewModelTest.kt index 0ef5e52..084d5c9 100644 --- a/feature-comics/src/test/java/com/rumosoft/comics/presentation/viewmodel/ComicDetailsViewModelTest.kt +++ b/feature-comics/src/test/java/com/rumosoft/comics/presentation/viewmodel/ComicDetailsViewModelTest.kt @@ -1,9 +1,7 @@ package com.rumosoft.comics.presentation.viewmodel -import androidx.lifecycle.SavedStateHandle import com.rumosoft.comics.domain.usecase.GetComicDetailsUseCase import com.rumosoft.comics.infrastructure.sampleData.SampleData -import com.rumosoft.comics.presentation.navigation.NavComicItem import com.rumosoft.comics.presentation.viewmodel.state.ComicDetailsState import com.rumosoft.libraryTests.TestCoroutineExtension import io.mockk.coEvery @@ -18,9 +16,6 @@ import org.junit.jupiter.api.extension.ExtendWith internal class ComicDetailsViewModelTest { private val comicDetailsUseCase: GetComicDetailsUseCase = mockk() private val comicId = 123 - private val savedStateHandle = SavedStateHandle().apply { - this[NavComicItem.Details.navArgs[0].name] = comicId.toString() - } private lateinit var viewModel: ComicDetailsViewModel @Test @@ -50,7 +45,8 @@ internal class ComicDetailsViewModelTest { } private fun `when view model is initialised`() { - viewModel = ComicDetailsViewModel(savedStateHandle, comicDetailsUseCase) + viewModel = ComicDetailsViewModel(comicDetailsUseCase) + viewModel.setComic(comicId) } private fun `then use case gets invoked`() { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e4af79e..3252667 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] agp = "8.6.0" -compose-bom = "2024.09.00" +compose-bom = "2024.09.01" konsist = "0.16.1" kotlin = "2.0.20" appcompat = "1.7.0" @@ -29,7 +29,7 @@ espresso = "3.6.1" junitparams = "1.1.1" junit-jupiter = "5.11.0" mockk = "1.13.12" -coroutines-test = "1.8.1" +coroutines-test = "1.9.0" mockwebserver = "4.12.0" secrets-gradle-plugin = "2.0.1" shot = "6.1.0" @@ -37,7 +37,7 @@ versions = "0.51.0" ksp = "2.0.20-1.0.25" glance = "1.1.0" glance-experimental = "0.2.2" -ui-tooling-preview-android = "1.7.0" +ui-tooling-preview-android = "1.7.1" test-core = "1.6.1" [libraries] diff --git a/library-components/build.gradle.kts b/library-components/build.gradle.kts index 936ec83..cc2efa7 100644 --- a/library-components/build.gradle.kts +++ b/library-components/build.gradle.kts @@ -3,6 +3,7 @@ plugins { alias(libs.plugins.kotlinAndroid) alias(libs.plugins.compose.compiler) alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.kotlin.serialization) id("shot") } @@ -63,6 +64,8 @@ dependencies { implementation(libs.lottie.compose) + implementation(libs.kotlinx.serialization.json) + androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.espresso.core) diff --git a/library-components/src/main/java/com/rumosoft/components/presentation/deeplinks/DeepLinks.kt b/library-components/src/main/java/com/rumosoft/components/presentation/deeplinks/DeepLinks.kt new file mode 100644 index 0000000..36a8b50 --- /dev/null +++ b/library-components/src/main/java/com/rumosoft/components/presentation/deeplinks/DeepLinks.kt @@ -0,0 +1,19 @@ +package com.rumosoft.components.presentation.deeplinks + +import kotlinx.serialization.Serializable + +interface Screen + +@Serializable +data object CharactersScreen : Screen + +@Serializable +data class CharacterDetails(val characterId: Long) : Screen + +@Serializable +data object ComicsScreen : Screen + +@Serializable +data class ComicDetails(val comicId: Int) : Screen + +const val DEEP_LINKS_BASE_PATH = "rumosoft://marvelcompose" diff --git a/library-marvelapi/src/main/java/com/rumosoft/marvelapi/data/network/CharactersNetwork.kt b/library-marvelapi/src/main/java/com/rumosoft/marvelapi/data/network/CharactersNetwork.kt index 261fc65..c63bd00 100644 --- a/library-marvelapi/src/main/java/com/rumosoft/marvelapi/data/network/CharactersNetwork.kt +++ b/library-marvelapi/src/main/java/com/rumosoft/marvelapi/data/network/CharactersNetwork.kt @@ -6,6 +6,8 @@ import com.rumosoft.marvelapi.data.network.apimodels.PaginationInfo interface CharactersNetwork { suspend fun searchHeroes(offset: Int, limit: Int, nameStartsWith: String): Result + suspend fun getHeroDetails(heroId: Long): Result + suspend fun getComicThumbnail(comicId: Int): Result } diff --git a/library-marvelapi/src/main/java/com/rumosoft/marvelapi/data/network/CharactersNetworkImpl.kt b/library-marvelapi/src/main/java/com/rumosoft/marvelapi/data/network/CharactersNetworkImpl.kt index 30985bb..e4527dd 100644 --- a/library-marvelapi/src/main/java/com/rumosoft/marvelapi/data/network/CharactersNetworkImpl.kt +++ b/library-marvelapi/src/main/java/com/rumosoft/marvelapi/data/network/CharactersNetworkImpl.kt @@ -1,8 +1,10 @@ package com.rumosoft.marvelapi.data.network import com.rumosoft.marvelapi.data.network.apimodels.ErrorParsingException +import com.rumosoft.marvelapi.data.network.apimodels.HeroDto import com.rumosoft.marvelapi.data.network.apimodels.PaginationInfo import com.rumosoft.marvelapi.data.network.apimodels.getThumbnail +import kotlinx.coroutines.CancellationException import timber.log.Timber import javax.inject.Inject @@ -24,8 +26,8 @@ class CharactersNetworkImpl @Inject constructor( Result.success( HeroesResult( paginationInfo = PaginationInfo( - current = data.offset?.div(20) ?: 1, - total = data.total?.div(20) ?: Int.MAX_VALUE, + current = data.offset?.div(limit) ?: 1, + total = data.total?.div(limit) ?: Int.MAX_VALUE, ), characters = data.results.orEmpty(), ), @@ -34,6 +36,20 @@ class CharactersNetworkImpl @Inject constructor( Timber.d("Error parsing results") Result.failure(ErrorParsingException("No results")) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.d("Something went wrong: $e") + Result.failure(e) + } + } + + override suspend fun getHeroDetails(heroId: Long): Result { + return try { + val result = marvelService.searchHero(heroId) + Result.success(result.data?.results?.firstOrNull()) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Timber.d("Something went wrong: $e") Result.failure(e) @@ -46,6 +62,8 @@ class CharactersNetworkImpl @Inject constructor( Result.success( result.data?.results?.firstOrNull()?.getThumbnail().orEmpty(), ) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Timber.d("Something went wrong: $e") Result.failure(e) diff --git a/library-marvelapi/src/main/java/com/rumosoft/marvelapi/data/network/MarvelService.kt b/library-marvelapi/src/main/java/com/rumosoft/marvelapi/data/network/MarvelService.kt index 34204e6..003749b 100644 --- a/library-marvelapi/src/main/java/com/rumosoft/marvelapi/data/network/MarvelService.kt +++ b/library-marvelapi/src/main/java/com/rumosoft/marvelapi/data/network/MarvelService.kt @@ -15,6 +15,11 @@ interface MarvelService { @Query(NAME_STARTS_WITH) nameStartsWith: String? = null, ): HeroResults + @GET("/v1/public/characters/{$CHARACTER_ID}") + suspend fun searchHero( + @Path(CHARACTER_ID) characterId: Long, + ): HeroResults + @GET("/v1/public/comics") suspend fun searchComics( @Query(OFFSET) offset: Int = 0, @@ -28,6 +33,7 @@ interface MarvelService { ): ComicResults companion object { + const val CHARACTER_ID = "characterId" const val COMIC_ID = "comicId" const val OFFSET = "offset" const val LIMIT = "limit" diff --git a/library-marvelapi/src/main/java/com/rumosoft/marvelapi/data/network/apimodels/HeroDto.kt b/library-marvelapi/src/main/java/com/rumosoft/marvelapi/data/network/apimodels/HeroDto.kt index 83e3106..34086ab 100644 --- a/library-marvelapi/src/main/java/com/rumosoft/marvelapi/data/network/apimodels/HeroDto.kt +++ b/library-marvelapi/src/main/java/com/rumosoft/marvelapi/data/network/apimodels/HeroDto.kt @@ -3,7 +3,7 @@ package com.rumosoft.marvelapi.data.network.apimodels import com.google.gson.annotations.SerializedName data class HeroDto( - val id: Int? = null, + val id: Long, val name: String?, val description: String? = null, val modified: String? = null, diff --git a/library-marvelapi/src/test/java/com/rumosoft/marvelapi/data/network/CharactersNetworkImplTest.kt b/library-marvelapi/src/test/java/com/rumosoft/marvelapi/data/network/CharactersNetworkImplTest.kt index a85e85d..5253a65 100644 --- a/library-marvelapi/src/test/java/com/rumosoft/marvelapi/data/network/CharactersNetworkImplTest.kt +++ b/library-marvelapi/src/test/java/com/rumosoft/marvelapi/data/network/CharactersNetworkImplTest.kt @@ -93,6 +93,7 @@ internal class CharactersNetworkImplTest { count = 1, results = listOf( HeroDto( + id = 0, name = "Batman", thumbnail = ImageDto( "path",