diff --git a/app/src/main/java/com/london/app/navigation/graph/onboardingNavGraph.kt b/app/src/main/java/com/london/app/navigation/graph/onboardingNavGraph.kt index 489c748d2..de253516a 100644 --- a/app/src/main/java/com/london/app/navigation/graph/onboardingNavGraph.kt +++ b/app/src/main/java/com/london/app/navigation/graph/onboardingNavGraph.kt @@ -24,7 +24,7 @@ fun NavGraphBuilder.onboardingNavGraph( composable { WelcomeScreen( onNavigateLogin = navController::navigateToAuthGraph, - onNavigateContinue = navController::navigateToMainGraph + onNavigateAsGuest = navController::navigateToMainGraph ) } } diff --git a/data/src/main/java/com/london/data/local/database/dao/recent/search/RecentSearchDao.kt b/data/src/main/java/com/london/data/local/database/dao/recent/search/RecentSearchDao.kt index 3ee1d9692..add6bf2fe 100644 --- a/data/src/main/java/com/london/data/local/database/dao/recent/search/RecentSearchDao.kt +++ b/data/src/main/java/com/london/data/local/database/dao/recent/search/RecentSearchDao.kt @@ -35,5 +35,5 @@ interface RecentSearchDao : RecentDao { @Delete suspend fun delete(item: RecentSearchLocal) - + } diff --git a/presentation/src/main/java/com/london/presentation/feature/details/tvshow/episode/EpisodeDetailScreen.kt b/presentation/src/main/java/com/london/presentation/feature/details/tvshow/episode/EpisodeDetailScreen.kt index 2703f0100..5e134939f 100644 --- a/presentation/src/main/java/com/london/presentation/feature/details/tvshow/episode/EpisodeDetailScreen.kt +++ b/presentation/src/main/java/com/london/presentation/feature/details/tvshow/episode/EpisodeDetailScreen.kt @@ -124,7 +124,9 @@ private fun Content( ) { BackgroundGradient( - modifier = Modifier.align(Alignment.TopStart).zIndex(2f) + modifier = Modifier + .align(Alignment.TopStart) + .zIndex(2f) ) TopBar( diff --git a/presentation/src/main/java/com/london/presentation/feature/details/tvshow/episode/EpisodeDetailsViewModel.kt b/presentation/src/main/java/com/london/presentation/feature/details/tvshow/episode/EpisodeDetailsViewModel.kt index d81ad1b4a..e54f37ad8 100644 --- a/presentation/src/main/java/com/london/presentation/feature/details/tvshow/episode/EpisodeDetailsViewModel.kt +++ b/presentation/src/main/java/com/london/presentation/feature/details/tvshow/episode/EpisodeDetailsViewModel.kt @@ -32,7 +32,7 @@ class EpisodeDetailsViewModel @Inject constructor( getEpisodeDetails() } - private fun getEpisodeDetails(){ + private fun getEpisodeDetails() { loadEpisodeDetails() loadEpisodeRating() loadVideoProvider() diff --git a/presentation/src/main/java/com/london/presentation/feature/home/HomeScreen.kt b/presentation/src/main/java/com/london/presentation/feature/home/HomeScreen.kt index 10bdfbc71..ab3f4ba21 100644 --- a/presentation/src/main/java/com/london/presentation/feature/home/HomeScreen.kt +++ b/presentation/src/main/java/com/london/presentation/feature/home/HomeScreen.kt @@ -181,10 +181,14 @@ private fun HomeScreenLayout( homeScreenContract: HomeScreenContract ) { Box( - modifier = Modifier.fillMaxSize().navBarBottomPadding() + modifier = Modifier + .fillMaxSize() + .navBarBottomPadding() ) { BackgroundGradient( - modifier = Modifier.align(Alignment.TopStart).zIndex(1f) + modifier = Modifier + .align(Alignment.TopStart) + .zIndex(1f) ) Column(modifier = Modifier.fillMaxSize()) { diff --git a/presentation/src/main/java/com/london/presentation/feature/list/bottomsheets/DeleteListBottomSheet.kt b/presentation/src/main/java/com/london/presentation/feature/list/bottomsheets/DeleteListBottomSheet.kt index 51a503291..b870a09f0 100644 --- a/presentation/src/main/java/com/london/presentation/feature/list/bottomsheets/DeleteListBottomSheet.kt +++ b/presentation/src/main/java/com/london/presentation/feature/list/bottomsheets/DeleteListBottomSheet.kt @@ -104,8 +104,7 @@ private fun Content( shape = RoundedCornerShape(8.dp) ) .background(color = NovixTheme.colors.iconBackgroundLow) - .clickable(onClick = hideSheet) - , + .clickable(onClick = hideSheet), contentAlignment = Alignment.Center ) { Icon( diff --git a/presentation/src/main/java/com/london/presentation/feature/reviews/ReviewsScreen.kt b/presentation/src/main/java/com/london/presentation/feature/reviews/ReviewsScreen.kt index e8f32cec7..52fb49629 100644 --- a/presentation/src/main/java/com/london/presentation/feature/reviews/ReviewsScreen.kt +++ b/presentation/src/main/java/com/london/presentation/feature/reviews/ReviewsScreen.kt @@ -90,9 +90,11 @@ private fun Content( Box( modifier = Modifier.fillMaxSize() - ){ + ) { BackgroundGradient( - modifier = Modifier.align(Alignment.TopStart).zIndex(1f) + modifier = Modifier + .align(Alignment.TopStart) + .zIndex(1f) ) Column( diff --git a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingEffect.kt b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingEffect.kt index 96e043c27..0a8fb9a39 100644 --- a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingEffect.kt +++ b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingEffect.kt @@ -1,6 +1,6 @@ package com.london.presentation.feature.welcome.onboarding sealed interface OnboardingEffect { - data object NavigateToWelcome : OnboardingEffect + data object WelcomeNavigation : OnboardingEffect data class ScrollToPage(val page: Int) : OnboardingEffect } diff --git a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingScreen.kt b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingScreen.kt index e51ce9779..ea11e2b5d 100644 --- a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingScreen.kt +++ b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingScreen.kt @@ -50,6 +50,7 @@ import com.london.designsystem.theme.NovixTheme import com.london.designsystem.theme.ThemePreviews import com.london.designsystem.utils.painter import com.london.presentation.R +import com.london.presentation.utils.Listen import kotlinx.coroutines.CoroutineScope @Composable @@ -66,7 +67,19 @@ fun OnboardingScreen( ) HandlePagerStateChanges(pagerState, viewModel) - HandleEffects(effect, viewModel, pagerState, scope, onComplete) + + effect?.Listen { currentEffect -> + when (currentEffect) { + is OnboardingEffect.ScrollToPage -> { + viewModel.scrollToPage(pagerState, currentEffect.page, scope) + } + + OnboardingEffect.WelcomeNavigation -> { + viewModel.onboardingFinished() + onComplete() + } + } + } Box( modifier = Modifier @@ -87,30 +100,6 @@ fun OnboardingScreen( } } -@Composable -private fun HandleEffects( - effect: OnboardingEffect?, - viewModel: OnboardingViewModel, - pagerState: PagerState, - scope: CoroutineScope, - onComplete: () -> Unit -) { - LaunchedEffect(effect) { - when (effect) { - is OnboardingEffect.ScrollToPage -> { - viewModel.scrollToPage(pagerState, effect.page, scope) - } - - OnboardingEffect.NavigateToWelcome -> { - viewModel.onboardingFinished() - onComplete() - } - - null -> {} - } - } -} - @Composable private fun Content( pagerState: PagerState, diff --git a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt index e4df1a1cf..6e949701b 100644 --- a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt +++ b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt @@ -1,16 +1,14 @@ package com.london.presentation.feature.welcome.onboarding -import android.util.Log import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.pager.PagerState -import androidx.lifecycle.viewModelScope import com.london.domain.service.AppPreferencesService import com.london.presentation.shared.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -51,19 +49,24 @@ class OnboardingViewModel @Inject constructor( fun navigateToWelcome() { setOnBoardingShown() - emitEffect(OnboardingEffect.NavigateToWelcome) + emitEffect(OnboardingEffect.WelcomeNavigation) } fun onboardingFinished() { - viewModelScope.launch(Dispatchers.IO) { - runCatching { appPreferencesService.setOnBoardingShown() } - .onFailure { Log.e("OnboardingViewModel", "onboardingFinished: ", it) } - } + tryToExecute( + block = { appPreferencesService.setOnBoardingShown() }, + onError = { errorState -> + Timber.e("Failed to set onboarding shown: $errorState") + } + ) } private fun setOnBoardingShown() { - viewModelScope.launch(Dispatchers.IO) { - runCatching { appPreferencesService.setOnBoardingShown() } - } + tryToExecute( + block = { appPreferencesService.setOnBoardingShown() }, + onError = { errorState -> + Timber.e("Failed to set onboarding shown: $errorState") + } + ) } } diff --git a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/WelcomeScreen.kt b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/WelcomeScreen.kt index a98eeec5d..4d01bafec 100644 --- a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/WelcomeScreen.kt +++ b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/WelcomeScreen.kt @@ -38,7 +38,7 @@ import com.london.presentation.R @Composable fun WelcomeScreen( onNavigateLogin: () -> Unit, - onNavigateContinue: () -> Unit, + onNavigateAsGuest: () -> Unit, ) { val screenHeight = with(LocalDensity.current) { @@ -62,14 +62,14 @@ fun WelcomeScreen( } Content( onNavigateLogin = onNavigateLogin, - onNavigateContinue = onNavigateContinue + onNavigateAsGuest = onNavigateAsGuest ) } @Composable private fun Content( onNavigateLogin: () -> Unit, - onNavigateContinue: () -> Unit, + onNavigateAsGuest: () -> Unit, modifier: Modifier = Modifier ) { Column( @@ -118,7 +118,7 @@ private fun Content( OutlineButton( text = stringResource(R.string.continue_as_guest), - onClick = onNavigateContinue, + onClick = onNavigateAsGuest, modifier = Modifier.fillMaxWidth(), hasLabel = true, icon = null, @@ -171,7 +171,7 @@ private fun WelcomePoster(modifier: Modifier = Modifier) { @Composable private fun PreviewWelcomeScreen() { WelcomeScreen( - onNavigateContinue = {}, + onNavigateAsGuest = {}, onNavigateLogin = {} ) } diff --git a/presentation/src/main/java/com/london/presentation/shared/buildscreen/BuildScreen.kt b/presentation/src/main/java/com/london/presentation/shared/buildscreen/BuildScreen.kt index b5b0f56a4..119335048 100644 --- a/presentation/src/main/java/com/london/presentation/shared/buildscreen/BuildScreen.kt +++ b/presentation/src/main/java/com/london/presentation/shared/buildscreen/BuildScreen.kt @@ -83,7 +83,6 @@ fun BuildScreen( } - (pagingFlow != null) && pagingFlow.isEmpty() && emptyLayoutMessage.isNotNull() -> { diff --git a/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt b/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt new file mode 100644 index 000000000..1fbe76332 --- /dev/null +++ b/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt @@ -0,0 +1,330 @@ +package com.london.presentation.feature.onboarding + +import androidx.compose.foundation.pager.PagerState +import androidx.lifecycle.viewModelScope +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.london.domain.service.AppPreferencesService +import com.london.presentation.feature.welcome.onboarding.OnboardingEffect +import com.london.presentation.feature.welcome.onboarding.OnboardingViewModel +import io.mockk.MockKAnnotations +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class OnboardingViewModelTest { + private lateinit var appPreferencesService: AppPreferencesService + private val pagerState = mockk(relaxed = true) + private var viewModel: OnboardingViewModel? = null + private val mainDispatcher = StandardTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(mainDispatcher) + MockKAnnotations.init(this) + setupDefaultMocks() + viewModel = createViewModel() + } + + private fun setupDefaultMocks() { + appPreferencesService = mockk() + every { pagerState.currentPage } returns 0 + every { pagerState.pageCount } returns 3 + coEvery { pagerState.animateScrollToPage(any(), any()) } just Runs + coEvery { appPreferencesService.setOnBoardingShown() } just Runs + } + + private fun createViewModel() = OnboardingViewModel(appPreferencesService) + + @After + fun tearDown() { + Dispatchers.resetMain() + viewModel?.viewModelScope?.cancel() + viewModel = null + } + + @Test + fun `when viewModel is initialized, currentPage should be 0`(): Unit = runTest { + // When + advanceUntilIdle() + + // Then + viewModel?.state?.test { + val state = expectMostRecentItem() + assertThat(state.currentPage).isEqualTo(0) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `when page is changed, currentPage should be updated`() = runTest { + // Given + val targetPage = 2 + + // When + viewModel?.onPageChanged(targetPage) + advanceUntilIdle() + + // Then + viewModel?.state?.test { + val state = expectMostRecentItem() + assertThat(state.currentPage).isEqualTo(targetPage) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `when scrollToPage is called with valid page, animation should be triggered`() = runTest { + // Given + val targetPage = 1 + val scope = this + + // When + viewModel?.scrollToPage(pagerState, targetPage, scope) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { + pagerState.animateScrollToPage( + page = targetPage, + animationSpec = any() + ) + } + } + + @Test + fun `when scrollToPage is called with invalid negative page, animation should not be triggered`() = + runTest { + // Given + val invalidPage = -1 + val scope = this + + // When + viewModel?.scrollToPage(pagerState, invalidPage, scope) + advanceUntilIdle() + + // Then + coVerify(exactly = 0) { + pagerState.animateScrollToPage(any(), any()) + } + } + + @Test + fun `when scrollToPage is called with page exceeding count, animation should not be triggered`() = + runTest { + // Given + val invalidPage = 5 // pageCount is 3 + val scope = this + + // When + viewModel?.scrollToPage(pagerState, invalidPage, scope) + advanceUntilIdle() + + // Then + coVerify(exactly = 0) { + pagerState.animateScrollToPage(any(), any()) + } + } + + @Test + fun `when scrollPrevious is called from middle page, previous page should be navigated`() = runTest { + // Given + every { pagerState.currentPage } returns 2 + val scope = this + + // When + viewModel?.scrollPrevious(pagerState, scope) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { + pagerState.animateScrollToPage( + page = 1, + animationSpec = any() + ) + } + } + + @Test + fun `when scrollPrevious is called from first page, navigation should not happen`() = runTest { + // Given + every { pagerState.currentPage } returns 0 + val scope = this + + // When + viewModel?.scrollPrevious(pagerState, scope) + advanceUntilIdle() + + // Then + coVerify(exactly = 0) { + pagerState.animateScrollToPage(any(), any()) + } + } + + @Test + fun `when scrollNext is called from middle page, next page should be navigated`() = runTest { + // Given + every { pagerState.currentPage } returns 1 + val scope = this + + // When + viewModel?.scrollNext(pagerState, scope) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { + pagerState.animateScrollToPage( + page = 2, + animationSpec = any() + ) + } + } + + @Test + fun `when scrollNext is called from last page, welcome navigation effect should be emitted`() = runTest { + // Given + every { pagerState.currentPage } returns 2 // Last page + val scope = this + + // When & Then + viewModel?.effect?.test { + viewModel?.scrollNext(pagerState, scope) + advanceUntilIdle() + assertThat(awaitItem()).isEqualTo(OnboardingEffect.WelcomeNavigation) + } + } + + @Test + fun `when scrollNext is called from last page, onboarding should be marked as shown`() = runTest { + // Given + every { pagerState.currentPage } returns 2 // Last page + val scope = this + + // When + viewModel?.scrollNext(pagerState, scope) + advanceUntilIdle() + + // Then - Use timeout for IO operations + coVerify(timeout = 2000, exactly = 1) { + appPreferencesService.setOnBoardingShown() + } + } + + @Test + fun `when navigateToWelcome is called, welcome navigation effect should be emitted`() = runTest { + viewModel?.effect?.test { + viewModel?.navigateToWelcome() + advanceUntilIdle() + assertThat(awaitItem()).isEqualTo(OnboardingEffect.WelcomeNavigation) + } + } + + @Test + fun `when onboardingFinished is called, preferences service should be invoked`() = runTest { + // When + viewModel?.onboardingFinished() + advanceUntilIdle() + + coVerify(timeout = 2000, exactly = 1) { + appPreferencesService.setOnBoardingShown() + } + } + + @Test + fun `when onboardingFinished fails, exception should be handled gracefully`() = runTest { + // Given + coEvery { appPreferencesService.setOnBoardingShown() } throws Exception("Network error") + + // When + viewModel?.onboardingFinished() + advanceUntilIdle() + + coVerify(timeout = 2000, exactly = 1) { + appPreferencesService.setOnBoardingShown() + } + } + + @Test + fun `when navigateToWelcome is called, onboarding should be marked as shown`() = runTest { + // When + viewModel?.navigateToWelcome() + advanceUntilIdle() + + coVerify(timeout = 2000, exactly = 1) { + appPreferencesService.setOnBoardingShown() + } + } + + @Test + fun `when page changes multiple times, state should be updated correctly`() = runTest { + // When + viewModel?.onPageChanged(1) + advanceUntilIdle() + + viewModel?.state?.test { + val firstState = expectMostRecentItem() + assertThat(firstState.currentPage).isEqualTo(1) + + viewModel?.onPageChanged(2) + advanceUntilIdle() + + val secondState = expectMostRecentItem() + assertThat(secondState.currentPage).isEqualTo(2) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `when scrollToPage is called with first page, animation should work correctly`() = runTest { + // Given + val firstPage = 0 + val scope = this + + // When + viewModel?.scrollToPage(pagerState, firstPage, scope) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { + pagerState.animateScrollToPage( + page = firstPage, + animationSpec = any() + ) + } + } + + @Test + fun `when scrollToPage is called with last page, animation should work correctly`() = runTest { + // Given + val lastPage = 2 // pageCount - 1 + val scope = this + + // When + viewModel?.scrollToPage(pagerState, lastPage, scope) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { + pagerState.animateScrollToPage( + page = lastPage, + animationSpec = any() + ) + } + } +}