diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml index 0ad17ea..058a49f 100644 --- a/.idea/androidTestResultsUserPreferences.xml +++ b/.idea/androidTestResultsUserPreferences.xml @@ -543,6 +543,19 @@ + + + + + + + @@ -595,6 +608,19 @@ + + + + + + + @@ -636,6 +662,19 @@ + + + + + + + diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 0fc3113..69e8615 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 0ad17cb..773fe0f 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,7 +1,6 @@ - - + diff --git a/app/.gitignore b/app/.gitignore index 42afabf..c9bc8fa 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1 +1,2 @@ -/build \ No newline at end of file +/build +../.idea/androidTestResultsUserPreferences.xml \ No newline at end of file diff --git a/app/src/androidTest/java/com/marcopla/flashcards/data/FlashCardRepositoryTest.kt b/app/src/androidTest/java/com/marcopla/flashcards/data/FlashCardRepositoryTest.kt index fd48328..a53bd13 100644 --- a/app/src/androidTest/java/com/marcopla/flashcards/data/FlashCardRepositoryTest.kt +++ b/app/src/androidTest/java/com/marcopla/flashcards/data/FlashCardRepositoryTest.kt @@ -121,7 +121,7 @@ class FlashCardRepositoryTest { } @Test - fun whenAddingResult_thenIsSaved() { + fun whenAddingResult_thenIsSaved() = runTest { val quizResult = QuizResult( FlashCard("Engels", "English"), "Dutch", @@ -130,7 +130,7 @@ class FlashCardRepositoryTest { repository.addResult(quizResult) - val currentResults = repository.getCurrentResults() + val currentResults = repository.getCurrentResults().first() assertEquals(listOf(quizResult), currentResults) } } diff --git a/app/src/androidTest/java/com/marcopla/flashcards/presentation/add/AddScreenRobot.kt b/app/src/androidTest/java/com/marcopla/flashcards/presentation/add/AddScreenRobot.kt index 0c63246..7a729ce 100644 --- a/app/src/androidTest/java/com/marcopla/flashcards/presentation/add/AddScreenRobot.kt +++ b/app/src/androidTest/java/com/marcopla/flashcards/presentation/add/AddScreenRobot.kt @@ -1,13 +1,18 @@ package com.marcopla.flashcards.presentation.add import androidx.activity.ComponentActivity -import androidx.compose.ui.test.* +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.rules.ActivityScenarioRule import com.marcopla.flashcards.R import com.marcopla.flashcards.data.repository.FlashCardRepository import com.marcopla.flashcards.domain.usecase.AddUseCase -import com.marcopla.flashcards.presentation.screen.add.AddScreen +import com.marcopla.flashcards.presentation.screen.add.AddRoute import com.marcopla.flashcards.presentation.screen.add.AddViewModel import com.marcopla.testing_shared.TestFlashCardRepository @@ -19,9 +24,8 @@ fun launchAddScreen( repository: FlashCardRepository = TestFlashCardRepository(), block: AddScreenRobot.() -> Unit ): AddScreenRobot { - composeRule.setContent { - AddScreen(viewModel = AddViewModel(AddUseCase(repository))) - } + val viewModel = AddViewModel(AddUseCase(repository)) + composeRule.setContent { AddRoute(viewModel = viewModel) } return AddScreenRobot(composeRule).apply(block) } diff --git a/app/src/androidTest/java/com/marcopla/flashcards/presentation/carousel/CarouselRobot.kt b/app/src/androidTest/java/com/marcopla/flashcards/presentation/carousel/CarouselRobot.kt index e3c6a42..e086c37 100644 --- a/app/src/androidTest/java/com/marcopla/flashcards/presentation/carousel/CarouselRobot.kt +++ b/app/src/androidTest/java/com/marcopla/flashcards/presentation/carousel/CarouselRobot.kt @@ -1,15 +1,19 @@ package com.marcopla.flashcards.presentation.carousel import androidx.activity.ComponentActivity -import androidx.compose.ui.test.* +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.rules.ActivityScenarioRule import com.marcopla.flashcards.R import com.marcopla.flashcards.data.model.FlashCard import com.marcopla.flashcards.data.repository.FlashCardRepositoryImpl import com.marcopla.flashcards.domain.usecase.LoadUseCase import com.marcopla.flashcards.domain.usecase.SubmitQuizUseCase -import com.marcopla.flashcards.presentation.screen.carousel.CarouselScreen +import com.marcopla.flashcards.presentation.screen.carousel.CarouselRoute import com.marcopla.flashcards.presentation.screen.carousel.CarouselViewModel import com.marcopla.testing_shared.FakeFlashCardDao @@ -21,15 +25,12 @@ fun launchCarouselScreen( flashCards: List, block: CarouselScreenRobot.() -> Unit ): CarouselScreenRobot { - composeRule.setContent { - val repository = FlashCardRepositoryImpl(FakeFlashCardDao(flashCards)) - CarouselScreen( - viewModel = CarouselViewModel( - LoadUseCase(repository), - SubmitQuizUseCase(repository) - ) - ) {} - } + val repository = FlashCardRepositoryImpl(FakeFlashCardDao(flashCards)) + val viewModel = CarouselViewModel( + LoadUseCase(repository), + SubmitQuizUseCase(repository) + ) + composeRule.setContent { CarouselRoute(viewModel) {} } return CarouselScreenRobot(composeRule).apply(block) } diff --git a/app/src/androidTest/java/com/marcopla/flashcards/presentation/edit/EditScreenRobot.kt b/app/src/androidTest/java/com/marcopla/flashcards/presentation/edit/EditScreenRobot.kt index e582798..ef5c5d6 100644 --- a/app/src/androidTest/java/com/marcopla/flashcards/presentation/edit/EditScreenRobot.kt +++ b/app/src/androidTest/java/com/marcopla/flashcards/presentation/edit/EditScreenRobot.kt @@ -1,8 +1,12 @@ package com.marcopla.flashcards.presentation.edit import androidx.activity.ComponentActivity -import androidx.compose.ui.test.* +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.rules.ActivityScenarioRule import com.marcopla.flashcards.R @@ -13,7 +17,7 @@ import com.marcopla.flashcards.domain.usecase.DeleteUseCase import com.marcopla.flashcards.domain.usecase.EditUseCase import com.marcopla.flashcards.domain.usecase.LoadUseCase import com.marcopla.flashcards.presentation.navigation.FLASH_CARD_ID_ARG_KEY -import com.marcopla.flashcards.presentation.screen.edit.EditScreen +import com.marcopla.flashcards.presentation.screen.edit.EditRoute import com.marcopla.flashcards.presentation.screen.edit.EditViewModel import com.marcopla.testing_shared.FakeFlashCardDao import com.marcopla.testing_shared.TestFlashCardRepository @@ -27,19 +31,16 @@ fun launchEditScreenFor( flashCardRepository: FlashCardRepository = TestFlashCardRepository(), block: EditScreenRobot.() -> Unit ): EditScreenRobot { + val viewModel = EditViewModel( + SavedStateHandle(mapOf(FLASH_CARD_ID_ARG_KEY to selectedFlashCard.id)), + EditUseCase(flashCardRepository), + LoadUseCase( + FlashCardRepositoryImpl(FakeFlashCardDao(listOf(selectedFlashCard))) + ), + DeleteUseCase(TestFlashCardRepository()) + ) composeRule.setContent { - EditScreen( - viewModel = EditViewModel( - SavedStateHandle(mapOf(FLASH_CARD_ID_ARG_KEY to selectedFlashCard.id)), - EditUseCase(flashCardRepository), - LoadUseCase( - FlashCardRepositoryImpl(FakeFlashCardDao(listOf(selectedFlashCard))) - ), - DeleteUseCase(TestFlashCardRepository()) - ), - onFlashCardEdited = {}, - onFlashCardDeleted = {} - ) + EditRoute(viewModel) {} } return EditScreenRobot(composeRule).apply(block) } diff --git a/app/src/androidTest/java/com/marcopla/flashcards/presentation/home/HomeScreenRobot.kt b/app/src/androidTest/java/com/marcopla/flashcards/presentation/home/HomeScreenRobot.kt index 08160e9..d08bd2a 100644 --- a/app/src/androidTest/java/com/marcopla/flashcards/presentation/home/HomeScreenRobot.kt +++ b/app/src/androidTest/java/com/marcopla/flashcards/presentation/home/HomeScreenRobot.kt @@ -8,24 +8,20 @@ import androidx.compose.ui.test.onNodeWithText import androidx.test.ext.junit.rules.ActivityScenarioRule import com.marcopla.flashcards.R import com.marcopla.flashcards.data.model.FlashCard -import com.marcopla.flashcards.data.repository.FlashCardRepositoryImpl -import com.marcopla.flashcards.domain.usecase.LoadUseCase import com.marcopla.flashcards.presentation.screen.home.HomeScreen -import com.marcopla.flashcards.presentation.screen.home.HomeViewModel -import com.marcopla.testing_shared.FakeFlashCardDao +import com.marcopla.flashcards.presentation.screen.home.HomeScreenState typealias ComponentActivityTestRule = AndroidComposeTestRule, ComponentActivity> suspend fun launchHomeScreen( rule: ComponentActivityTestRule, - flashCards: List = emptyList(), + screenState: HomeScreenState, block: suspend HomeScreenRobot.() -> Unit ): HomeScreenRobot { - val repository = FlashCardRepositoryImpl(FakeFlashCardDao(flashCards)) rule.setContent { HomeScreen( - viewModel = HomeViewModel(LoadUseCase(repository)), + screenState = screenState, onNavigateToAddScreen = {}, onItemClicked = {}, onNavigateToCarouselScreen = {} diff --git a/app/src/androidTest/java/com/marcopla/flashcards/presentation/home/HomeScreenTest.kt b/app/src/androidTest/java/com/marcopla/flashcards/presentation/home/HomeScreenTest.kt index 1bb05ca..a79a031 100644 --- a/app/src/androidTest/java/com/marcopla/flashcards/presentation/home/HomeScreenTest.kt +++ b/app/src/androidTest/java/com/marcopla/flashcards/presentation/home/HomeScreenTest.kt @@ -3,6 +3,7 @@ package com.marcopla.flashcards.presentation.home import androidx.activity.ComponentActivity import androidx.compose.ui.test.junit4.createAndroidComposeRule import com.marcopla.flashcards.data.model.FlashCard +import com.marcopla.flashcards.presentation.screen.home.HomeScreenState import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -16,7 +17,7 @@ class HomeScreenTest { @Test fun homeScreen_whenGettingEmptyState_thenShowEmptyMessage() = runTest { - launchHomeScreen(composeRule, emptyList()) { + launchHomeScreen(composeRule, HomeScreenState.Empty) { // Do nothing } verify { emptyMessageIsDisplayed() @@ -31,7 +32,7 @@ class HomeScreenTest { FlashCard(frontText = "front3", backText = "back3") ) - launchHomeScreen(composeRule, flashCards) { + launchHomeScreen(composeRule, HomeScreenState.Cards(flashCards)) { // Do nothing } verify { listOfFlashCardsIsDisplayed(flashCards) @@ -40,7 +41,7 @@ class HomeScreenTest { @Test fun homeScreen_whenNoFlashCards_thenDoNotShowCarouselButton() = runTest { - launchHomeScreen(composeRule, emptyList()) { + launchHomeScreen(composeRule, HomeScreenState.Empty) { // Empty } verify { carouselButtonIsNotDisplayed() diff --git a/app/src/main/java/com/marcopla/flashcards/data/repository/FlashCardRepository.kt b/app/src/main/java/com/marcopla/flashcards/data/repository/FlashCardRepository.kt index 63eef51..69a7989 100644 --- a/app/src/main/java/com/marcopla/flashcards/data/repository/FlashCardRepository.kt +++ b/app/src/main/java/com/marcopla/flashcards/data/repository/FlashCardRepository.kt @@ -19,7 +19,7 @@ interface FlashCardRepository { fun addResult(quizResult: QuizResult) - fun getCurrentResults(): List + fun getCurrentResults(): Flow> fun clearResults() } \ No newline at end of file diff --git a/app/src/main/java/com/marcopla/flashcards/data/repository/FlashCardRepositoryImpl.kt b/app/src/main/java/com/marcopla/flashcards/data/repository/FlashCardRepositoryImpl.kt index 6b28c06..6daaaba 100644 --- a/app/src/main/java/com/marcopla/flashcards/data/repository/FlashCardRepositoryImpl.kt +++ b/app/src/main/java/com/marcopla/flashcards/data/repository/FlashCardRepositoryImpl.kt @@ -5,6 +5,7 @@ import com.marcopla.flashcards.data.datasource.FlashCardDao import com.marcopla.flashcards.data.model.FlashCard import com.marcopla.flashcards.data.model.QuizResult import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow class FlashCardRepositoryImpl( private val flashCardDao: FlashCardDao @@ -49,8 +50,8 @@ class FlashCardRepositoryImpl( * Get the stored results for this session. * Note: results are currently just saved in memory. */ - override fun getCurrentResults(): List { - return currentResults + override fun getCurrentResults(): Flow> { + return flow { emit(currentResults) } } override fun clearResults() { diff --git a/app/src/main/java/com/marcopla/flashcards/domain/usecase/LoadResultsUseCase.kt b/app/src/main/java/com/marcopla/flashcards/domain/usecase/LoadResultsUseCase.kt index 52ca374..11bc0f1 100644 --- a/app/src/main/java/com/marcopla/flashcards/domain/usecase/LoadResultsUseCase.kt +++ b/app/src/main/java/com/marcopla/flashcards/domain/usecase/LoadResultsUseCase.kt @@ -3,11 +3,12 @@ package com.marcopla.flashcards.domain.usecase import com.marcopla.flashcards.data.model.QuizResult import com.marcopla.flashcards.data.repository.FlashCardRepository import javax.inject.Inject +import kotlinx.coroutines.flow.Flow // TODO: Currently the results are stored only in memory. Would be better to store them on disk. class LoadResultsUseCase @Inject constructor(private val repository: FlashCardRepository) { - operator fun invoke(): List { + operator fun invoke(): Flow> { return repository.getCurrentResults() } diff --git a/app/src/main/java/com/marcopla/flashcards/presentation/navigation/AppNavHost.kt b/app/src/main/java/com/marcopla/flashcards/presentation/navigation/AppNavHost.kt index c0f086f..bc59832 100644 --- a/app/src/main/java/com/marcopla/flashcards/presentation/navigation/AppNavHost.kt +++ b/app/src/main/java/com/marcopla/flashcards/presentation/navigation/AppNavHost.kt @@ -6,11 +6,11 @@ import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument -import com.marcopla.flashcards.presentation.screen.add.AddScreen -import com.marcopla.flashcards.presentation.screen.carousel.CarouselScreen -import com.marcopla.flashcards.presentation.screen.edit.EditScreen -import com.marcopla.flashcards.presentation.screen.home.HomeScreen -import com.marcopla.flashcards.presentation.screen.result.ResultsScreen +import com.marcopla.flashcards.presentation.screen.add.AddRoute +import com.marcopla.flashcards.presentation.screen.carousel.CarouselRoute +import com.marcopla.flashcards.presentation.screen.edit.EditRoute +import com.marcopla.flashcards.presentation.screen.home.HomeRoute +import com.marcopla.flashcards.presentation.screen.result.ResultsRoute @Composable fun AppNavHost( @@ -21,7 +21,7 @@ fun AppNavHost( startDestination = Routes.HOME_SCREEN ) { composable(Routes.HOME_SCREEN) { - HomeScreen( + HomeRoute( onNavigateToAddScreen = { navController.navigate(Routes.ADD_SCREEN) }, @@ -34,34 +34,31 @@ fun AppNavHost( ) } composable(Routes.ADD_SCREEN) { - AddScreen() + AddRoute() } composable( - "${Routes.EDIT_SCREEN}/{$FLASH_CARD_ID_ARG_KEY}", + route = "${Routes.EDIT_SCREEN}/{$FLASH_CARD_ID_ARG_KEY}", arguments = listOf( navArgument(FLASH_CARD_ID_ARG_KEY) { type = NavType.IntType } ) ) { - EditScreen( - onFlashCardEdited = { - navController.popBackStack(Routes.HOME_SCREEN, false) - }, - onFlashCardDeleted = { + EditRoute( + onPopBackStack = { navController.popBackStack(Routes.HOME_SCREEN, false) } ) } composable(Routes.CAROUSEL_SCREEN) { - CarouselScreen( + CarouselRoute( onLastFlashCardPlayed = { navController.navigate(Routes.RESULT_SCREEN) } ) } composable(Routes.RESULT_SCREEN) { - ResultsScreen( + ResultsRoute( onDoneClicked = { navController.popBackStack(Routes.HOME_SCREEN, false) } diff --git a/app/src/main/java/com/marcopla/flashcards/presentation/screen/add/AddScreen.kt b/app/src/main/java/com/marcopla/flashcards/presentation/screen/add/AddScreen.kt index 6bc4664..d25bd2d 100644 --- a/app/src/main/java/com/marcopla/flashcards/presentation/screen/add/AddScreen.kt +++ b/app/src/main/java/com/marcopla/flashcards/presentation/screen/add/AddScreen.kt @@ -17,32 +17,50 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.marcopla.flashcards.R -@OptIn(ExperimentalLayoutApi::class) @Composable -fun AddScreen( - modifier: Modifier = Modifier, - viewModel: AddViewModel = hiltViewModel() -) { +fun AddRoute(viewModel: AddViewModel = hiltViewModel()) { + val infoTextState = viewModel.infoTextState.value val scaffoldState = rememberScaffoldState() HandleInfoTextEffect( - viewModel.infoTextState.value.messageStringRes, + infoTextState.messageStringRes, scaffoldState.snackbarHostState ) + AddScreen( + frontTextState = viewModel.frontTextState.value, + backTextState = viewModel.backTextState.value, + addScreenState = viewModel.addScreenState.value, + onSubmitClick = { + viewModel.attemptSubmit( + frontText = viewModel.frontTextState.value.text, + backText = viewModel.backTextState.value.text + ) + }, + onFrontTextValueChange = { frontInput -> viewModel.updateFrontText(frontInput) }, + onBackTextValueChange = { backInput -> viewModel.updateBackText(backInput) }, + scaffoldState = scaffoldState + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AddScreen( + frontTextState: FrontTextState, + backTextState: BackTextState, + addScreenState: AddScreenState, + onSubmitClick: () -> Unit, + onFrontTextValueChange: (String) -> Unit, + onBackTextValueChange: (String) -> Unit, + scaffoldState: ScaffoldState, + modifier: Modifier = Modifier +) { Scaffold( scaffoldState = scaffoldState, topBar = { TopAppBar(title = { Text(stringResource(R.string.addScreenTitle)) }) }, floatingActionButton = { - FloatingActionButton( - onClick = { - viewModel.attemptSubmit( - frontText = viewModel.frontTextState.value.text, - backText = viewModel.backTextState.value.text - ) - } - ) { + FloatingActionButton(onClick = onSubmitClick) { Icon( imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.addCardButton) @@ -52,16 +70,16 @@ fun AddScreen( ) { paddingValues: PaddingValues -> Column(modifier = modifier.padding(4.dp).consumeWindowInsets(paddingValues)) { FontTextField( - value = viewModel.frontTextState.value.text, - isError = viewModel.frontTextState.value.showError, - isFocused = viewModel.addScreenState.value == AddScreenState.SUCCESSFUL_SAVE, - onValueChange = { frontInput -> viewModel.updateFrontText(frontInput) } + value = frontTextState.text, + isError = frontTextState.showError, + isFocused = addScreenState == AddScreenState.SUCCESSFUL_SAVE, + onValueChange = onFrontTextValueChange ) Spacer(modifier = Modifier.height(8.dp)) BackTextField( - value = viewModel.backTextState.value.text, - isError = viewModel.backTextState.value.showError, - onValueChange = { backInput -> viewModel.updateBackText(backInput) } + value = backTextState.text, + isError = backTextState.showError, + onValueChange = onBackTextValueChange ) } } @@ -114,7 +132,7 @@ private fun BackTextField( } @Composable -private fun HandleInfoTextEffect( +fun HandleInfoTextEffect( infoTextStringRes: Int?, snackbarHostState: SnackbarHostState ) { @@ -123,4 +141,4 @@ private fun HandleInfoTextEffect( LaunchedEffect(infoTextStringRes) { snackbarHostState.showSnackbar(infoText) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/marcopla/flashcards/presentation/screen/carousel/CarouselScreen.kt b/app/src/main/java/com/marcopla/flashcards/presentation/screen/carousel/CarouselScreen.kt index a5c10f5..ef25506 100644 --- a/app/src/main/java/com/marcopla/flashcards/presentation/screen/carousel/CarouselScreen.kt +++ b/app/src/main/java/com/marcopla/flashcards/presentation/screen/carousel/CarouselScreen.kt @@ -13,24 +13,37 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.marcopla.flashcards.R -@OptIn(ExperimentalLayoutApi::class) @Composable -fun CarouselScreen( - modifier: Modifier = Modifier, +fun CarouselRoute( viewModel: CarouselViewModel = hiltViewModel(), onLastFlashCardPlayed: () -> Unit ) { - LaunchedEffect(key1 = Unit) { - viewModel.loadFlashCards() - } - - val screenState = viewModel.screenState - if (screenState.value == CarouselScreenState.Finished) { + val screenState = viewModel.screenState.value + if (screenState == CarouselScreenState.Finished) { LaunchedEffect(key1 = Unit) { onLastFlashCardPlayed() } } + CarouselScreen( + screenState = screenState, + onSubmitClicked = { + viewModel.submit(viewModel.guessInput.value) + }, + guessInputState = viewModel.guessInput.value, + onGuessInputChange = viewModel::updateGuessInput + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun CarouselScreen( + screenState: CarouselScreenState, + onSubmitClicked: () -> Unit, + guessInputState: String, + onGuessInputChange: (String) -> Unit, + modifier: Modifier = Modifier +) { Scaffold( topBar = { TopAppBar( @@ -40,9 +53,7 @@ fun CarouselScreen( ) }, floatingActionButton = { - FloatingActionButton(onClick = { - viewModel.submit(viewModel.guessInput.value) - }) { + FloatingActionButton(onClick = onSubmitClicked) { Icon(imageVector = Icons.Default.Done, stringResource(id = R.string.nextButton)) } } @@ -52,10 +63,10 @@ fun CarouselScreen( .padding(4.dp) .consumeWindowInsets(it) ) { - Prompt(screenState.value) + Prompt(screenState) Guess( - value = viewModel.guessInput.value, - onValueChange = viewModel::updateGuessInput + value = guessInputState, + onValueChange = onGuessInputChange ) } } @@ -94,4 +105,4 @@ private fun Prompt(screenState: CarouselScreenState) { } } Text(text = promptText) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/marcopla/flashcards/presentation/screen/carousel/CarouselViewModel.kt b/app/src/main/java/com/marcopla/flashcards/presentation/screen/carousel/CarouselViewModel.kt index 62acca1..bdc2cea 100644 --- a/app/src/main/java/com/marcopla/flashcards/presentation/screen/carousel/CarouselViewModel.kt +++ b/app/src/main/java/com/marcopla/flashcards/presentation/screen/carousel/CarouselViewModel.kt @@ -30,7 +30,11 @@ class CarouselViewModel @Inject constructor( private val isLastFlashCard: Boolean get() = currentFlashCardIndex >= flashCards.size - 1 - fun loadFlashCards() { + init { + loadFlashCards() + } + + private fun loadFlashCards() { viewModelScope.launch { flashCards = loadUseCase.loadAll().first() _screenState.value = CarouselScreenState.Initial(flashCards[0]) diff --git a/app/src/main/java/com/marcopla/flashcards/presentation/screen/edit/EditScreen.kt b/app/src/main/java/com/marcopla/flashcards/presentation/screen/edit/EditScreen.kt index 191e71d..8213a3b 100644 --- a/app/src/main/java/com/marcopla/flashcards/presentation/screen/edit/EditScreen.kt +++ b/app/src/main/java/com/marcopla/flashcards/presentation/screen/edit/EditScreen.kt @@ -16,33 +16,64 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.marcopla.flashcards.R +@Composable +fun EditRoute( + viewModel: EditViewModel = hiltViewModel(), + onPopBackStack: () -> Unit +) { + if (viewModel.shouldShowDeleteConfirmation.value) { + DeleteConfirmationDialog( + onConfirmationClick = viewModel::delete, + onCancelClick = viewModel::hideDeleteConfirmationDialog, + onDismissRequest = viewModel::hideDeleteConfirmationDialog + ) + } + + EditScreen( + onFlashCardEdited = onPopBackStack, + onFlashCardDeleted = { + viewModel.hideDeleteConfirmationDialog() + onPopBackStack() + }, + editScreenState = viewModel.screenState.value, + onReset = viewModel::reset, + onShowDeleteConfirmationDialog = viewModel::showDeleteConfirmationDialog, + onSubmit = { + viewModel.attemptSubmit( + viewModel.frontTextState.value.text, + viewModel.backTextState.value.text + ) + }, + frontTextState = viewModel.frontTextState.value, + onFrontTextValueChange = viewModel::updateFrontText, + backTextState = viewModel.backTextState.value, + onBackTextValueChange = viewModel::updateBackText + ) +} + @OptIn(ExperimentalLayoutApi::class) @Composable fun EditScreen( onFlashCardEdited: () -> Unit, onFlashCardDeleted: () -> Unit, - modifier: Modifier = Modifier, - viewModel: EditViewModel = hiltViewModel() + editScreenState: EditScreenState, + onReset: () -> Unit, + onShowDeleteConfirmationDialog: () -> Unit, + onSubmit: () -> Unit, + frontTextState: EditFrontTextState, + onFrontTextValueChange: (String) -> Unit, + backTextState: EditBackTextState, + onBackTextValueChange: (String) -> Unit, + modifier: Modifier = Modifier ) { val scaffoldState = rememberScaffoldState() HandleScreenState( - viewModel.screenState.value, + editScreenState, scaffoldState, onFlashCardEdited, - onFlashCardDeleted = { - viewModel.hideDeleteConfirmationDialog() - onFlashCardDeleted() - } + onFlashCardDeleted ) - if (viewModel.shouldShowDeleteConfirmation.value) { - DeleteConfirmationDialog( - onConfirmationClick = { viewModel.delete() }, - onCancelClick = { viewModel.hideDeleteConfirmationDialog() }, - onDismissRequest = { viewModel.hideDeleteConfirmationDialog() } - ) - } - Scaffold( scaffoldState = scaffoldState, topBar = { @@ -51,17 +82,15 @@ fun EditScreen( Text(text = stringResource(R.string.editScreenTitle)) }, actions = { - if (viewModel.screenState.value == EditScreenState.Editing) { + if (editScreenState == EditScreenState.Editing) { Icon( - modifier = Modifier.clickable(onClick = viewModel::reset), + modifier = Modifier.clickable(onClick = onReset), imageVector = Icons.Default.Refresh, contentDescription = stringResource(id = R.string.resetButton) ) } else { Icon( - modifier = Modifier.clickable { - viewModel.showDeleteConfirmationDialog() - }, + modifier = Modifier.clickable(onClick = onShowDeleteConfirmationDialog), imageVector = Icons.Default.Delete, contentDescription = stringResource(R.string.deleteButton) ) @@ -70,12 +99,7 @@ fun EditScreen( ) }, floatingActionButton = { - FloatingActionButton(onClick = { - viewModel.attemptSubmit( - viewModel.frontTextState.value.text, - viewModel.backTextState.value.text - ) - }) { + FloatingActionButton(onClick = onSubmit) { Icon( imageVector = Icons.Default.Edit, contentDescription = stringResource(R.string.editButton) @@ -83,11 +107,6 @@ fun EditScreen( } } ) { - // TODO: Consider using Flows and call collectAsStateWithLifecycle here. - LaunchedEffect(key1 = Unit) { - viewModel.initState() - } - Column( modifier = modifier .padding(4.dp) @@ -100,10 +119,10 @@ fun EditScreen( .semantics { contentDescription = frontTextContentDescription }, - value = viewModel.frontTextState.value.text, + value = frontTextState.text, label = { Text(stringResource(R.string.frontTextFieldLabel)) }, - isError = viewModel.frontTextState.value.showError, - onValueChange = viewModel::updateFrontText + isError = frontTextState.showError, + onValueChange = onFrontTextValueChange ) Spacer(modifier = Modifier.height(8.dp)) @@ -115,10 +134,10 @@ fun EditScreen( .semantics { contentDescription = backTextContentDescription }, - value = viewModel.backTextState.value.text, + value = backTextState.text, label = { Text(stringResource(R.string.backTextFieldLabel)) }, - isError = viewModel.backTextState.value.showError, - onValueChange = viewModel::updateBackText + isError = backTextState.showError, + onValueChange = onBackTextValueChange ) } } @@ -151,7 +170,7 @@ private fun HandleScreenState( } @Composable -private fun DeleteConfirmationDialog( +fun DeleteConfirmationDialog( onConfirmationClick: () -> Unit, onCancelClick: () -> Unit, onDismissRequest: () -> Unit, diff --git a/app/src/main/java/com/marcopla/flashcards/presentation/screen/edit/EditViewModel.kt b/app/src/main/java/com/marcopla/flashcards/presentation/screen/edit/EditViewModel.kt index 77da895..8e9139b 100644 --- a/app/src/main/java/com/marcopla/flashcards/presentation/screen/edit/EditViewModel.kt +++ b/app/src/main/java/com/marcopla/flashcards/presentation/screen/edit/EditViewModel.kt @@ -40,7 +40,11 @@ class EditViewModel @Inject constructor( private val flashCardId: Int by lazy { checkNotNull(savedStateHandle[FLASH_CARD_ID_ARG_KEY]) } - fun initState() { + init { + initializeFlashCard() + } + + private fun initializeFlashCard() { viewModelScope.launch { val flashCard = loadFlashCardUseCase.loadById(flashCardId) _frontTextState.value = _frontTextState.value.copy(text = flashCard.frontText) diff --git a/app/src/main/java/com/marcopla/flashcards/presentation/screen/home/HomeScreen.kt b/app/src/main/java/com/marcopla/flashcards/presentation/screen/home/HomeScreen.kt index 75917eb..5bff620 100644 --- a/app/src/main/java/com/marcopla/flashcards/presentation/screen/home/HomeScreen.kt +++ b/app/src/main/java/com/marcopla/flashcards/presentation/screen/home/HomeScreen.kt @@ -25,14 +25,29 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @Composable -fun HomeScreen( +fun HomeRoute( onNavigateToAddScreen: () -> Unit, onItemClicked: (Int) -> Unit, onNavigateToCarouselScreen: () -> Unit, - modifier: Modifier = Modifier, viewModel: HomeViewModel = hiltViewModel() ) { val screenState: HomeScreenState by viewModel.homeScreenState.collectAsStateWithLifecycle() + HomeScreen( + screenState = screenState, + onNavigateToAddScreen = onNavigateToAddScreen, + onItemClicked = onItemClicked, + onNavigateToCarouselScreen = onNavigateToCarouselScreen + ) +} + +@Composable +fun HomeScreen( + screenState: HomeScreenState, + onNavigateToAddScreen: () -> Unit, + onItemClicked: (Int) -> Unit, + onNavigateToCarouselScreen: () -> Unit, + modifier: Modifier = Modifier +) { Scaffold( topBar = { TopAppBar( @@ -157,4 +172,4 @@ private fun CardsList( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/marcopla/flashcards/presentation/screen/result/ResultViewModel.kt b/app/src/main/java/com/marcopla/flashcards/presentation/screen/result/ResultViewModel.kt index 31f4e2c..63c8e6e 100644 --- a/app/src/main/java/com/marcopla/flashcards/presentation/screen/result/ResultViewModel.kt +++ b/app/src/main/java/com/marcopla/flashcards/presentation/screen/result/ResultViewModel.kt @@ -1,19 +1,24 @@ package com.marcopla.flashcards.presentation.screen.result -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.marcopla.flashcards.data.model.QuizResult import com.marcopla.flashcards.domain.usecase.LoadResultsUseCase import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn @HiltViewModel class ResultViewModel @Inject constructor( private val loadResults: LoadResultsUseCase ) : ViewModel() { - private val _results = mutableStateOf(loadResults()) - val results: State> = _results + val results: StateFlow> = loadResults().stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList() + ) fun clearResults() { loadResults.clearAll() diff --git a/app/src/main/java/com/marcopla/flashcards/presentation/screen/result/ResultsScreen.kt b/app/src/main/java/com/marcopla/flashcards/presentation/screen/result/ResultsScreen.kt index dcbac0e..c6d44c2 100644 --- a/app/src/main/java/com/marcopla/flashcards/presentation/screen/result/ResultsScreen.kt +++ b/app/src/main/java/com/marcopla/flashcards/presentation/screen/result/ResultsScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.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.res.stringResource @@ -14,14 +15,32 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.marcopla.flashcards.R +import com.marcopla.flashcards.data.model.QuizResult +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun ResultsRoute( + onDoneClicked: () -> Unit, + viewModel: ResultViewModel = hiltViewModel() +) { + val results: List by viewModel.results.collectAsStateWithLifecycle() + ResultsScreen( + onDoneClicked = onDoneClicked, + results = results.toImmutableList(), + clearResults = { viewModel.clearResults() } + ) +} @OptIn(ExperimentalLayoutApi::class) @Composable fun ResultsScreen( - modifier: Modifier = Modifier, - viewModel: ResultViewModel = hiltViewModel(), - onDoneClicked: () -> Unit + onDoneClicked: () -> Unit, + results: ImmutableList, + clearResults: () -> Unit, + modifier: Modifier = Modifier ) { Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.results)) }) @@ -34,9 +53,9 @@ fun ResultsScreen( horizontalAlignment = Alignment.CenterHorizontally ) { LazyColumn { - items(viewModel.results.value.size) { index -> + items(results.size) { index -> ResultItem( - quizResult = viewModel.results.value[index] + quizResult = results[index] ) } } @@ -45,11 +64,11 @@ fun ResultsScreen( modifier = Modifier.semantics { contentDescription = buttonText }, onClick = { onDoneClicked() - viewModel.clearResults() + clearResults() } ) { Text(buttonText) } } } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/marcopla/flashcards/domain/usecase/SubmitQuizUseCaseTest.kt b/app/src/test/java/com/marcopla/flashcards/domain/usecase/SubmitQuizUseCaseTest.kt index b46e145..6c6214e 100644 --- a/app/src/test/java/com/marcopla/flashcards/domain/usecase/SubmitQuizUseCaseTest.kt +++ b/app/src/test/java/com/marcopla/flashcards/domain/usecase/SubmitQuizUseCaseTest.kt @@ -2,31 +2,36 @@ package com.marcopla.flashcards.domain.usecase import com.marcopla.flashcards.data.model.FlashCard import com.marcopla.testing_shared.TestFlashCardRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource +@OptIn(ExperimentalCoroutinesApi::class) class SubmitQuizUseCaseTest { @ParameterizedTest @ValueSource(strings = ["", " ", " "]) - fun whenSubmittingBlankGuess_thenAddWrongResult_andReturnWrongValidation(blankGuess: String) { - val repository = TestFlashCardRepository() - val submitQuizUseCase = SubmitQuizUseCase(repository) + fun whenSubmittingBlankGuess_thenAddWrongResult_andReturnWrongValidation(blankGuess: String) = + runTest { + val repository = TestFlashCardRepository() + val submitQuizUseCase = SubmitQuizUseCase(repository) - val isValidationCorrect = submitQuizUseCase.invoke( - FlashCard("Engels", "English"), - blankGuess - ) + val isValidationCorrect = submitQuizUseCase.invoke( + FlashCard("Engels", "English"), + blankGuess + ) - val quizResult = repository.getCurrentResults().first() - assertFalse(quizResult.isCorrect) - assertFalse(isValidationCorrect) - } + val quizResult = repository.getCurrentResults().first().first() + assertFalse(quizResult.isCorrect) + assertFalse(isValidationCorrect) + } @Test - fun whenSubmittingWrongGuess_thenAddWrongResult_andReturnWrongValidation() { + fun whenSubmittingWrongGuess_thenAddWrongResult_andReturnWrongValidation() = runTest { val repository = TestFlashCardRepository() val submitQuizUseCase = SubmitQuizUseCase(repository) @@ -35,13 +40,13 @@ class SubmitQuizUseCaseTest { ":wrongGuess:" ) - val quizResult = repository.getCurrentResults().first() + val quizResult = repository.getCurrentResults().first().first() assertFalse(quizResult.isCorrect) assertFalse(isValidationCorrect) } @Test - fun whenSubmittingCorrectGuess_thenAddCorrectResult_andReturnCorrectValidation() { + fun whenSubmittingCorrectGuess_thenAddCorrectResult_andReturnCorrectValidation() = runTest { val repository = TestFlashCardRepository() val submitQuizUseCase = SubmitQuizUseCase(repository) @@ -50,7 +55,7 @@ class SubmitQuizUseCaseTest { "English" ) - val quizResult = repository.getCurrentResults().first() + val quizResult = repository.getCurrentResults().first().first() assertTrue(quizResult.isCorrect) assertTrue(isValidationCorrect) } diff --git a/app/src/test/java/com/marcopla/flashcards/presentation/carousel/CarouselViewModelTest.kt b/app/src/test/java/com/marcopla/flashcards/presentation/carousel/CarouselViewModelTest.kt index e7cf3e2..0910895 100644 --- a/app/src/test/java/com/marcopla/flashcards/presentation/carousel/CarouselViewModelTest.kt +++ b/app/src/test/java/com/marcopla/flashcards/presentation/carousel/CarouselViewModelTest.kt @@ -9,6 +9,8 @@ import com.marcopla.flashcards.presentation.screen.carousel.CarouselScreenState import com.marcopla.flashcards.presentation.screen.carousel.CarouselViewModel import com.marcopla.testing_shared.FakeFlashCardDao import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -34,7 +36,6 @@ class CarouselViewModelTest { LoadUseCase(repository), SubmitQuizUseCase(repository) ) - viewModel.loadFlashCards() viewModel.submit(emptyUserGuess) @@ -59,8 +60,6 @@ class CarouselViewModelTest { SubmitQuizUseCase(repository) ) - viewModel.loadFlashCards() - assertEquals( CarouselScreenState.Initial( listOf( @@ -86,7 +85,6 @@ class CarouselViewModelTest { LoadUseCase(repository), SubmitQuizUseCase(repository) ) - viewModel.loadFlashCards() viewModel.submit("wrong guess") @@ -110,7 +108,6 @@ class CarouselViewModelTest { LoadUseCase(repository), SubmitQuizUseCase(repository) ) - viewModel.loadFlashCards() viewModel.submit("English") @@ -134,7 +131,6 @@ class CarouselViewModelTest { LoadUseCase(repository), SubmitQuizUseCase(repository) ) - viewModel.loadFlashCards() viewModel.submit("English") viewModel.submit("Dutch") @@ -156,7 +152,6 @@ class CarouselViewModelTest { LoadUseCase(repository), SubmitQuizUseCase(repository) ) - viewModel.loadFlashCards() viewModel.updateGuessInput(":guess:") viewModel.submit(":guess:") @@ -165,7 +160,7 @@ class CarouselViewModelTest { } @Test - fun whenFinished_allFlashCardsWereProcessed() { + fun whenFinished_allFlashCardsWereProcessed() = runTest { val storedFlashCards = listOf( FlashCard("Engels", "English"), FlashCard("Nederlands", "Dutch"), @@ -176,12 +171,11 @@ class CarouselViewModelTest { LoadUseCase(repository), SubmitQuizUseCase(repository) ) - viewModel.loadFlashCards() repeat(storedFlashCards.size) { viewModel.submit(":guess:") } - assertEquals(storedFlashCards.size, repository.getCurrentResults().size) + assertEquals(storedFlashCards.size, repository.getCurrentResults().first().size) } } \ No newline at end of file diff --git a/app/src/test/java/com/marcopla/flashcards/presentation/result/ResultViewModelTest.kt b/app/src/test/java/com/marcopla/flashcards/presentation/result/ResultViewModelTest.kt index b43f4b9..1f9aea1 100644 --- a/app/src/test/java/com/marcopla/flashcards/presentation/result/ResultViewModelTest.kt +++ b/app/src/test/java/com/marcopla/flashcards/presentation/result/ResultViewModelTest.kt @@ -1,17 +1,25 @@ package com.marcopla.flashcards.presentation.result +import com.marcopla.flashcards.MainDispatcherExtension import com.marcopla.flashcards.data.model.FlashCard import com.marcopla.flashcards.data.model.QuizResult import com.marcopla.flashcards.data.repository.FlashCardRepositoryImpl import com.marcopla.flashcards.domain.usecase.LoadResultsUseCase import com.marcopla.flashcards.presentation.screen.result.ResultViewModel import com.marcopla.testing_shared.FakeFlashCardDao +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(MainDispatcherExtension::class) class ResultViewModelTest { @Test - fun whenResultsAreLoaded_thenEmitResults() { + fun whenResultsAreLoaded_thenEmitResults() = runTest { val storedResults = listOf( QuizResult(FlashCard("Engels", "English"), "English", true), QuizResult(FlashCard("Nederlands", "Dutch"), "", false), @@ -21,7 +29,9 @@ class ResultViewModelTest { storedResults.forEach { addResult(it) } } val viewModel = ResultViewModel(LoadResultsUseCase(repository)) + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.results.collect {} } assertEquals(storedResults, viewModel.results.value) + collectJob.cancel() } } \ No newline at end of file diff --git a/app/testing/src/main/java/com/marcopla/testing_shared/DuplicateFlashCardRepository.kt b/app/testing/src/main/java/com/marcopla/testing_shared/DuplicateFlashCardRepository.kt index 2d4b545..ea9122b 100644 --- a/app/testing/src/main/java/com/marcopla/testing_shared/DuplicateFlashCardRepository.kt +++ b/app/testing/src/main/java/com/marcopla/testing_shared/DuplicateFlashCardRepository.kt @@ -31,7 +31,7 @@ class DuplicateFlashCardRepository : FlashCardRepository { TODO("Not yet implemented") } - override fun getCurrentResults(): List { + override fun getCurrentResults(): Flow> { TODO("Not yet implemented") } diff --git a/app/testing/src/main/java/com/marcopla/testing_shared/TestFlashCardRepository.kt b/app/testing/src/main/java/com/marcopla/testing_shared/TestFlashCardRepository.kt index 9b21c56..20c7241 100644 --- a/app/testing/src/main/java/com/marcopla/testing_shared/TestFlashCardRepository.kt +++ b/app/testing/src/main/java/com/marcopla/testing_shared/TestFlashCardRepository.kt @@ -41,8 +41,8 @@ class TestFlashCardRepository : FlashCardRepository { currentResults.add(quizResult) } - override fun getCurrentResults(): List { - return currentResults + override fun getCurrentResults(): Flow> { + return flow { emit(currentResults) } } override fun clearResults() {