diff --git a/app/src/main/java/eu/gaudian/translator/view/exercises/StartExerciseScreen.kt b/app/src/main/java/eu/gaudian/translator/view/exercises/StartExerciseScreen.kt index 35febb1..c0a0559 100644 --- a/app/src/main/java/eu/gaudian/translator/view/exercises/StartExerciseScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/exercises/StartExerciseScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -24,40 +23,106 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBackIosNew import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Hearing -import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.outlined.Circle -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavHostController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.TagCategory +import eu.gaudian.translator.model.VocabularyCategory +import eu.gaudian.translator.model.VocabularyStage +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppOutlinedButton +import eu.gaudian.translator.view.composable.AppSlider +import eu.gaudian.translator.view.composable.OptionItemSwitch +import eu.gaudian.translator.view.dialogs.CategoryDropdown +import eu.gaudian.translator.view.vocabulary.VocabularyExerciseType +import eu.gaudian.translator.viewmodel.CategoryViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.VocabularyExerciseViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import kotlinx.coroutines.launch @Composable fun StartExerciseScreen( navController: NavHostController, modifier: Modifier = Modifier ) { + val activity = androidx.compose.ui.platform.LocalContext.current.findActivity() + val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity) + val exerciseViewModel: VocabularyExerciseViewModel = hiltViewModel(viewModelStoreOwner = activity) + + val exerciseConfig by exerciseViewModel.pendingExerciseConfig.collectAsState() + var selectedLanguagePairs by remember { mutableStateOf>>(emptyList()) } + var selectedCategories by remember { mutableStateOf>(emptyList()) } + var selectedStages by remember { mutableStateOf>(emptyList()) } + + var selectedOriginLanguage by remember { mutableStateOf(null) } + var selectedTargetLanguage by remember { mutableStateOf(null) } + + val selectedPairsIds = remember(selectedLanguagePairs) { + selectedLanguagePairs.map { it.first.nameResId to it.second.nameResId } + } + val selectedCategoryIds = remember(selectedCategories) { + selectedCategories.map { it.id } + } + + val filteredItemsFlow = remember( + selectedPairsIds, + selectedCategoryIds, + selectedStages, + exerciseConfig.dueTodayOnly + ) { + vocabularyViewModel.filterVocabularyItemsByPairs( + languagePairs = selectedPairsIds.ifEmpty { null }, + query = null, + categoryIds = selectedCategoryIds.ifEmpty { null }, + stages = selectedStages.ifEmpty { null }, + sortOrder = VocabularyViewModel.SortOrder.NEWEST_FIRST, + dueTodayOnly = exerciseConfig.dueTodayOnly + ) + } + val itemsToShow by filteredItemsFlow.collectAsState(initial = emptyList()) + val totalItemCount = itemsToShow.size + + var amount by remember { mutableStateOf(0) } + androidx.compose.runtime.LaunchedEffect(totalItemCount) { + amount = totalItemCount + } + + val updateConfig: (eu.gaudian.translator.viewmodel.ExerciseConfig) -> Unit = { config -> + exerciseViewModel.updatePendingExerciseConfig(config) + } + Box( modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter @@ -67,7 +132,15 @@ fun StartExerciseScreen( .widthIn(max = 700.dp) // Keeps it from over-stretching on tablets .fillMaxSize() ) { - TopBarSection(onBackClick = { navController.popBackStack() }) + TopBarSection( + onBackClick = { navController.popBackStack() }, + shuffleCards = exerciseConfig.shuffleCards, + onShuffleCardsChanged = { updateConfig(exerciseConfig.copy(shuffleCards = it)) }, + shuffleLanguages = exerciseConfig.shuffleLanguages, + onShuffleLanguagesChanged = { updateConfig(exerciseConfig.copy(shuffleLanguages = it)) }, + trainingMode = exerciseConfig.trainingMode, + onTrainingModeChanged = { updateConfig(exerciseConfig.copy(trainingMode = it)) }, + ) LazyColumn( modifier = Modifier @@ -76,27 +149,107 @@ fun StartExerciseScreen( verticalArrangement = Arrangement.spacedBy(32.dp) ) { item { Spacer(modifier = Modifier.height(8.dp)) } - item { LanguagePairSection() } - item { CategoriesSection() } - item { DifficultySection() } - item { NumberOfCardsSection() } - item { QuestionTypesSection() } + item { + LanguagePairSection( + selectedPairs = selectedLanguagePairs, + onPairsChanged = { selectedLanguagePairs = it }, + onOriginLanguageSelected = { language -> + selectedOriginLanguage = language + if (selectedTargetLanguage?.nameResId == language?.nameResId) { + selectedTargetLanguage = null + } + updateConfig(exerciseConfig.copy(originLanguageId = language?.nameResId)) + }, + onTargetLanguageSelected = { language -> + selectedTargetLanguage = language + if (selectedOriginLanguage?.nameResId == language?.nameResId) { + selectedOriginLanguage = null + } + updateConfig(exerciseConfig.copy(targetLanguageId = language?.nameResId)) + }, + selectedOriginLanguage = selectedOriginLanguage, + selectedTargetLanguage = selectedTargetLanguage + ) + } + item { + CategoriesSection( + selectedCategories = selectedCategories, + onCategoriesChanged = { selectedCategories = it } + ) + } + item { + DifficultySection( + selectedStages = selectedStages, + onStagesChanged = { selectedStages = it } + ) + } + item { + NumberOfCardsSection( + totalAvailable = totalItemCount, + amount = amount, + onAmountChanged = { amount = it }, + dueTodayOnly = exerciseConfig.dueTodayOnly, + onDueTodayOnlyChanged = { updateConfig(exerciseConfig.copy(dueTodayOnly = it)) } + ) + } + item { + QuestionTypesSection( + selectedTypes = exerciseConfig.selectedExerciseTypes, + onTypeSelected = { type -> + val current = exerciseConfig.selectedExerciseTypes.toMutableSet() + if (type in current) { + if (current.size > 1) current.remove(type) + } else { + current.add(type) + } + updateConfig(exerciseConfig.copy(selectedExerciseTypes = current)) + } + ) + } item { Spacer(modifier = Modifier.height(24.dp)) } } - BottomButtonSection() + BottomButtonSection( + enabled = totalItemCount > 0 && amount > 0, + amount = amount, + onStart = { + val finalItems = if (exerciseConfig.shuffleCards) { + itemsToShow.shuffled().take(amount) + } else { + itemsToShow.take(amount) + } + + exerciseViewModel.startExerciseWithConfig( + finalItems, + exerciseConfig.copy( + exerciseItemCount = finalItems.size, + originalExerciseItems = finalItems, + originLanguageId = selectedOriginLanguage?.nameResId, + targetLanguageId = selectedTargetLanguage?.nameResId + ) + ) + + navController.navigate("vocabulary_exercise/false") + } + ) } } } -//TODO add a settings icon here (top right), this should open a modal bottom sheed with our -// OptionItemSwitch from startscreen -//change the behavior: shuffle cards, default: on -//shuffle languages, default: on -//training mode, default: off -//due today will be place more prominent, not in the bottom sheet @Composable -fun TopBarSection(onBackClick: () -> Unit) { +fun TopBarSection( + onBackClick: () -> Unit, + shuffleCards: Boolean, + onShuffleCardsChanged: (Boolean) -> Unit, + shuffleLanguages: Boolean, + onShuffleLanguagesChanged: (Boolean) -> Unit, + trainingMode: Boolean, + onTrainingModeChanged: (Boolean) -> Unit +) { + var showSettings by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + Row( modifier = Modifier .fillMaxWidth() @@ -118,15 +271,45 @@ fun TopBarSection(onBackClick: () -> Unit) { } Text( - text = "Start Exercise", + text = stringResource(R.string.label_start_exercise), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f), - textAlign = androidx.compose.ui.text.style.TextAlign.Center + textAlign = TextAlign.Center ) - // Spacer to balance the back button for centering - Spacer(modifier = Modifier.size(48.dp)) + IconButton( + onClick = { showSettings = true }, + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Settings", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } + + if (showSettings) { + StartExerciseSettingsBottomSheet( + sheetState = sheetState, + shuffleCards = shuffleCards, + onShuffleCardsChanged = onShuffleCardsChanged, + shuffleLanguages = shuffleLanguages, + onShuffleLanguagesChanged = onShuffleLanguagesChanged, + trainingMode = trainingMode, + onTrainingModeChanged = onTrainingModeChanged, + onDismiss = { + scope.launch { sheetState.hide() }.invokeOnCompletion { + if (!sheetState.isVisible) { + showSettings = false + } + } + } + ) } } @@ -156,27 +339,129 @@ fun SectionHeader(title: String, actionText: String? = null, onActionClick: () - } } -//TODO Get our (exisiting) language pairs from the viewmodel, user should be able to select 0,1 , or several, also order by more popular (amount of flashcard in one language) -// so that more used/popular language pairs appear first @Composable -fun LanguagePairSection() { - var selectedPair by remember { mutableStateOf(0) } +fun LanguagePairSection( + selectedPairs: List>, + onPairsChanged: (List>) -> Unit, + onOriginLanguageSelected: (Language?) -> Unit, + onTargetLanguageSelected: (Language?) -> Unit, + selectedOriginLanguage: Language?, + selectedTargetLanguage: Language? +) { + val activity = androidx.compose.ui.platform.LocalContext.current.findActivity() + val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity) + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + val languagesPresent by vocabularyViewModel.languagesPresent.collectAsState(initial = emptySet()) + val allLanguages by languageViewModel.allLanguages.collectAsState(initial = emptyList()) + + val availableLanguages = remember(languagesPresent, allLanguages) { + val presentIds = languagesPresent.filterNotNull().toSet() + allLanguages.filter { it.nameResId in presentIds } + } + + val allItems by vocabularyViewModel.vocabularyItems.collectAsState(initial = emptyList()) + val pairCounts = remember(allItems) { + allItems.mapNotNull { item -> + val first = item.languageFirstId + val second = item.languageSecondId + if (first != null && second != null) { + val pair = if (first < second) first to second else second to first + pair + } else { + null + } + }.groupingBy { it }.eachCount() + } + + val availablePairs = remember(pairCounts, allLanguages) { + pairCounts.entries + .sortedByDescending { it.value } + .mapNotNull { (pairIds, _) -> + val first = allLanguages.find { it.nameResId == pairIds.first } + val second = allLanguages.find { it.nameResId == pairIds.second } + if (first != null && second != null) first to second else null + } + } Column { - SectionHeader(title = "Language Pair", actionText = "Change") - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - LanguageChip( - text = "EN → ES", - isSelected = selectedPair == 0, - modifier = Modifier.weight(1f), - onClick = { selectedPair = 0 } - ) - LanguageChip( - text = "EN → FR", - isSelected = selectedPair == 1, - modifier = Modifier.weight(1f), - onClick = { selectedPair = 1 } + SectionHeader(title = stringResource(R.string.language_pair)) + + if (availablePairs.isEmpty()) { + Text( + text = stringResource(R.string.text_no_dictionary_language_pairs_found), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) + } else { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + availablePairs.forEach { pair -> + val isSelected = selectedPairs.contains(pair) + LanguageChip( + text = "${pair.first.name} → ${pair.second.name}", + isSelected = isSelected, + modifier = Modifier.widthIn(min = 160.dp), + onClick = { + val updated = if (isSelected) { + selectedPairs - pair + } else { + selectedPairs + pair + } + onPairsChanged(updated) + } + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.label_language_direction), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource(R.string.text_language_direction_explanation), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(12.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.label_origin_language), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + eu.gaudian.translator.view.composable.SingleLanguageDropDown( + modifier = Modifier.fillMaxWidth(), + languageViewModel = languageViewModel, + selectedLanguage = selectedOriginLanguage, + onLanguageSelected = onOriginLanguageSelected, + showNoneOption = true, + alternateLanguages = availableLanguages + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.label_target_language), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + eu.gaudian.translator.view.composable.SingleLanguageDropDown( + modifier = Modifier.fillMaxWidth(), + languageViewModel = languageViewModel, + selectedLanguage = selectedTargetLanguage, + onLanguageSelected = onTargetLanguageSelected, + showNoneOption = true, + alternateLanguages = availableLanguages + ) + } } } } @@ -207,32 +492,93 @@ fun LanguageChip(text: String, isSelected: Boolean, modifier: Modifier = Modifie } } -//TODO uSe our real categories here -//If there are too many categories (+15) use CategoryDropDown instead of FlowRow @OptIn(ExperimentalLayoutApi::class) @Composable -fun CategoriesSection() { - val categories = listOf("Travel", "Business", "Food", "Technology", "Slang", "Academic", "Relationships") - var selectedCategories by remember { mutableStateOf(setOf("Travel", "Food")) } +fun CategoriesSection( + selectedCategories: List, + onCategoriesChanged: (List) -> Unit +) { + val activity = androidx.compose.ui.platform.LocalContext.current.findActivity() + val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity) + val categories by categoryViewModel.categories.collectAsState(initial = emptyList()) Column { - SectionHeader(title = "Categories") + SectionHeader(title = stringResource(R.string.label_categories)) + + val tagCategories = categories.filterIsInstance() + if (tagCategories.size > 15) { + CategoryDropdown( + onCategorySelected = { selections -> + onCategoriesChanged(selections.filterNotNull()) + }, + multipleSelectable = true, + onlyLists = false, + addCategory = false, + modifier = Modifier.fillMaxWidth(), + enableSearch = true + ) + } else { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + tagCategories.forEach { category -> + val isSelected = selectedCategories.contains(category) + Surface( + shape = RoundedCornerShape(20.dp), + color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + onClick = { + val updated = if (isSelected) { + selectedCategories - category + } else { + selectedCategories + category + } + onCategoriesChanged(updated) + } + ) { + Text( + text = category.name, + color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ) + } + } + } + } + } +} +//Make it a flow row, as all stages in one row does not work +@Composable +fun DifficultySection( + selectedStages: List, + onStagesChanged: (List) -> Unit +) { + val context = androidx.compose.ui.platform.LocalContext.current + Column { + SectionHeader(title = stringResource(R.string.label_filter_by_stage)) FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - categories.forEach { category -> - val isSelected = selectedCategories.contains(category) + VocabularyStage.entries.forEach { stage -> + val isSelected = selectedStages.contains(stage) Surface( shape = RoundedCornerShape(20.dp), color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, onClick = { - selectedCategories = if (isSelected) selectedCategories - category else selectedCategories + category + val updated = if (isSelected) { + selectedStages - stage + } else { + selectedStages + stage + } + onStagesChanged(updated) } ) { Text( - text = category, + text = stage.toString(context), color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), style = MaterialTheme.typography.bodyMedium, @@ -244,63 +590,46 @@ fun CategoriesSection() { } } -//TODO use our real stages here -//Make it a flow row, as all stages in one row does not work @Composable -fun DifficultySection() { - val stages = listOf("New", "Stage 1", "Stage 2", "Stage 3", "Stage 4", "Stage 5", "Learned") - var selectedDifficulty by remember { mutableStateOf("Medium") } - +fun NumberOfCardsSection( + totalAvailable: Int, + amount: Int, + onAmountChanged: (Int) -> Unit, + dueTodayOnly: Boolean, + onDueTodayOnlyChanged: (Boolean) -> Unit +) { Column { - SectionHeader(title = "Difficulty Level") - Surface( - shape = RoundedCornerShape(50), - color = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.fillMaxWidth().height(56.dp) - ) { - Row( - modifier = Modifier.fillMaxSize().padding(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - stages.forEach { level -> - val isSelected = selectedDifficulty == level - Box( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .clip(RoundedCornerShape(50)) - .background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent) - .clickable { selectedDifficulty = level }, - contentAlignment = Alignment.Center - ) { - Text( - text = level, - color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - style = MaterialTheme.typography.bodyMedium - ) - } - } - } + OptionItemSwitch( + title = stringResource(R.string.text_due_today_only), + description = stringResource(R.string.text_due_today_only_description), + checked = dueTodayOnly, + onCheckedChange = onDueTodayOnlyChanged + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (totalAvailable == 0) { + Text( + text = stringResource(R.string.no_cards_found_for_the_selected_filters), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + return@Column } - } -} -//TODO add optionitemswitch "Die Today Only", make sure that the amount of cards displayed is correct + val maxAvailable = totalAvailable.coerceAtLeast(1) + val coercedAmount = amount.coerceIn(1, maxAvailable) + if (coercedAmount != amount) { + onAmountChanged(coercedAmount) + } -//TODO use the slider from ImportVocabulary dialog here, also with the quick selection of cards -@Composable -fun NumberOfCardsSection() { - var sliderPosition by remember { mutableFloatStateOf(25f) } - - Column { Row( modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( - text = "NUMBER OF CARDS", + text = stringResource(R.string.text_amount_of_cards).uppercase(), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, @@ -311,7 +640,7 @@ fun NumberOfCardsSection() { color = MaterialTheme.colorScheme.primary, ) { Text( - text = sliderPosition.toInt().toString(), + text = "$coercedAmount / $totalAvailable", color = MaterialTheme.colorScheme.onPrimary, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, @@ -320,59 +649,73 @@ fun NumberOfCardsSection() { } } - Slider( - value = sliderPosition, - onValueChange = { sliderPosition = it }, - valueRange = 5f..50f, - steps = 45 + AppSlider( + value = coercedAmount.toFloat(), + onValueChange = { onAmountChanged(it.toInt().coerceIn(1, maxAvailable)) }, + valueRange = 1f..maxAvailable.toFloat(), + steps = if (maxAvailable > 1) maxAvailable - 2 else 0, + modifier = Modifier.fillMaxWidth() ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text("5 CARDS", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - Text("50 CARDS", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + val quickSelectValues = listOf(10, 25, 50, 100) + val availableQuickSelections = quickSelectValues.filter { it <= maxAvailable } + if (availableQuickSelections.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + availableQuickSelections.forEach { value -> + AppOutlinedButton( + onClick = { onAmountChanged(value) }, + modifier = Modifier.weight(1f) + ) { + Text(value.toString()) + } + } + } } } } - -//TODO use our four question types here, from StartScreen.kt +//TODO use our four question types here, from StartScreen.kt, user must select at least one @Composable -fun QuestionTypesSection() { - var selectedTypes by remember { mutableStateOf(setOf("Multiple Choice", "Spelling")) } - +fun QuestionTypesSection( + selectedTypes: Set, + onTypeSelected: (VocabularyExerciseType) -> Unit +) { Column { - SectionHeader(title = "Question Types") + SectionHeader(title = stringResource(R.string.text_question_types)) QuestionTypeCard( - title = "Multiple Choice", - subtitle = "Choose the correct meaning", - icon = Icons.Default.List, - isSelected = selectedTypes.contains("Multiple Choice"), - onClick = { - selectedTypes = if (selectedTypes.contains("Multiple Choice")) selectedTypes - "Multiple Choice" else selectedTypes + "Multiple Choice" - } + title = stringResource(R.string.label_guessing_exercise), + subtitle = stringResource(R.string.flip_card), + icon = AppIcons.Guessing, + isSelected = selectedTypes.contains(VocabularyExerciseType.GUESSING), + onClick = { onTypeSelected(VocabularyExerciseType.GUESSING) } ) Spacer(modifier = Modifier.height(12.dp)) QuestionTypeCard( - title = "Spelling", - subtitle = "Type the translated word", - icon = Icons.Default.Edit, - isSelected = selectedTypes.contains("Spelling"), - onClick = { - selectedTypes = if (selectedTypes.contains("Spelling")) selectedTypes - "Spelling" else selectedTypes + "Spelling" - } + title = stringResource(R.string.label_spelling_exercise), + subtitle = stringResource(R.string.type_the_translation), + icon = AppIcons.SpellCheck, + isSelected = selectedTypes.contains(VocabularyExerciseType.SPELLING), + onClick = { onTypeSelected(VocabularyExerciseType.SPELLING) } ) Spacer(modifier = Modifier.height(12.dp)) QuestionTypeCard( - title = "Listening", - subtitle = "Recognize spoken words", - icon = Icons.Default.Hearing, - isSelected = selectedTypes.contains("Listening"), - onClick = { - selectedTypes = if (selectedTypes.contains("Listening")) selectedTypes - "Listening" else selectedTypes + "Listening" - } + title = stringResource(R.string.label_multiple_choice_exercise), + subtitle = stringResource(R.string.label_choose_exercise_types), + icon = AppIcons.CheckList, + isSelected = selectedTypes.contains(VocabularyExerciseType.MULTIPLE_CHOICE), + onClick = { onTypeSelected(VocabularyExerciseType.MULTIPLE_CHOICE) } + ) + Spacer(modifier = Modifier.height(12.dp)) + QuestionTypeCard( + title = stringResource(R.string.label_word_jumble_exercise), + subtitle = stringResource(R.string.text_assemble_the_word_here), + icon = AppIcons.Extension, + isSelected = selectedTypes.contains(VocabularyExerciseType.WORD_JUMBLE), + onClick = { onTypeSelected(VocabularyExerciseType.WORD_JUMBLE) } ) } } @@ -413,36 +756,88 @@ fun QuestionTypeCard(title: String, subtitle: String, icon: ImageVector, isSelec } } @Composable -fun BottomButtonSection() { +fun BottomButtonSection( + enabled: Boolean, + amount: Int, + onStart: () -> Unit +) { Box( modifier = Modifier .fillMaxWidth() .padding(24.dp) ) { - Button( - onClick = { /* TODO: Start Session */ }, + AppButton( + onClick = onStart, modifier = Modifier .fillMaxWidth() .height(56.dp), - shape = RoundedCornerShape(28.dp), - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary) + enabled = enabled, + shape = RoundedCornerShape(28.dp) ) { Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Start Session", + text = stringResource(R.string.label_start_exercise_2d, amount), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.width(8.dp)) Icon( imageVector = Icons.Default.PlayArrow, - contentDescription = "Play", + contentDescription = stringResource(R.string.cd_play), modifier = Modifier.size(20.dp) ) } } } +} + +@Composable +private fun StartExerciseSettingsBottomSheet( + sheetState: SheetState, + shuffleCards: Boolean, + onShuffleCardsChanged: (Boolean) -> Unit, + shuffleLanguages: Boolean, + onShuffleLanguagesChanged: (Boolean) -> Unit, + trainingMode: Boolean, + onTrainingModeChanged: (Boolean) -> Unit, + onDismiss: () -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.options), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + OptionItemSwitch( + title = stringResource(R.string.shuffle_cards), + description = stringResource(R.string.text_shuffle_card_order_description), + checked = shuffleCards, + onCheckedChange = onShuffleCardsChanged + ) + OptionItemSwitch( + title = stringResource(R.string.text_shuffle_languages), + description = stringResource(R.string.text_shuffle_languages_description), + checked = shuffleLanguages, + onCheckedChange = onShuffleLanguagesChanged + ) + OptionItemSwitch( + title = stringResource(R.string.label_training_mode), + description = stringResource(R.string.text_training_mode_description), + checked = trainingMode, + onCheckedChange = onTrainingModeChanged + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyExerciseHostScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyExerciseHostScreen.kt index 610f9ab..fb418c4 100644 --- a/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyExerciseHostScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyExerciseHostScreen.kt @@ -19,7 +19,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -28,10 +27,8 @@ import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import eu.gaudian.translator.R -import eu.gaudian.translator.model.Language import eu.gaudian.translator.utils.findActivity import eu.gaudian.translator.view.composable.AppAlertDialog -import eu.gaudian.translator.viewmodel.ExerciseConfig import eu.gaudian.translator.viewmodel.ScreenState import eu.gaudian.translator.viewmodel.VocabularyExerciseViewModel import eu.gaudian.translator.viewmodel.VocabularyViewModel @@ -57,14 +54,7 @@ fun VocabularyExerciseHostScreen( val cardSet by vocabularyViewModel.cardSet.collectAsState() val screenState by exerciseViewModel.screenState.collectAsState() - - var shuffleCards by rememberSaveable { mutableStateOf(false) } - var shuffleLanguages by remember { mutableStateOf(false) } - var trainingMode by remember { mutableStateOf(false) } - var dueTodayOnly by remember { mutableStateOf(false) } - var selectedExerciseTypes by remember { mutableStateOf(setOf(VocabularyExerciseType.GUESSING)) } - var selectedOriginLanguage by remember { mutableStateOf(null) } - var selectedTargetLanguage by remember { mutableStateOf(null) } + val pendingConfig by exerciseViewModel.pendingExerciseConfig.collectAsState() var finalScore by remember { mutableIntStateOf(0) } var finalWrongAnswers by remember { mutableIntStateOf(0) } @@ -76,16 +66,40 @@ fun VocabularyExerciseHostScreen( false } - LaunchedEffect(Unit) { - // Reset exercise state when starting fresh - exerciseViewModel.resetExercise() - - vocabularyViewModel.prepareExercise( - categoryIdsAsJson, - stageNamesAsJson, - languageIdsAsJson, - dailyOnly = dailyOnly, - ) + LaunchedEffect(categoryIdsAsJson, stageNamesAsJson, languageIdsAsJson, dailyOnly) { + // Only reset and prepare if the host is opened via explicit filters. + if (!categoryIdsAsJson.isNullOrBlank() || !stageNamesAsJson.isNullOrBlank() || !languageIdsAsJson.isNullOrBlank() || dailyOnly) { + exerciseViewModel.resetExercise() + vocabularyViewModel.prepareExercise( + categoryIdsAsJson, + stageNamesAsJson, + languageIdsAsJson, + dailyOnly = dailyOnly, + ) + } + } + + LaunchedEffect(cardSet, screenState, pendingConfig) { + if (cardSet != null && screenState == ScreenState.START) { + val items = cardSet?.cards.orEmpty() + if (items.isNotEmpty()) { + val selectedCount = pendingConfig.exerciseItemCount + .takeIf { it > 0 } + ?: items.size + val finalItems = if (pendingConfig.shuffleCards) { + items.shuffled().take(selectedCount) + } else { + items.take(selectedCount) + } + exerciseViewModel.startExerciseWithConfig( + finalItems, + pendingConfig.copy( + exerciseItemCount = finalItems.size, + originalExerciseItems = finalItems + ) + ) + } + } } if (cardSet == null && screenState != ScreenState.START) { @@ -95,68 +109,9 @@ fun VocabularyExerciseHostScreen( } else { when (screenState) { ScreenState.START -> { - StartScreen( - cardSet = cardSet, - onStartClicked = { finalItems -> - exerciseViewModel.startExerciseWithConfig( - finalItems, - ExerciseConfig( - shuffleCards = shuffleCards, - shuffleLanguages = shuffleLanguages, - trainingMode = trainingMode, - dueTodayOnly = dueTodayOnly, - selectedExerciseTypes = selectedExerciseTypes, - exerciseItemCount = finalItems.size, - originalExerciseItems = finalItems, - originLanguageId = selectedOriginLanguage?.nameResId, - targetLanguageId = selectedTargetLanguage?.nameResId - ) - ) - }, - onClose = onClose, - shuffleCards = shuffleCards, - onShuffleCardsChanged = { - @Suppress("AssignedValueIsNeverRead") - shuffleCards = it - }, - shuffleLanguages = shuffleLanguages, - onShuffleLanguagesChanged = { - @Suppress("AssignedValueIsNeverRead") - shuffleLanguages = it - }, - trainingMode = trainingMode, - onTrainingModeChanged = { - @Suppress("AssignedValueIsNeverRead") - trainingMode = it - exerciseViewModel.onTrainingModeChanged(it) - }, - hideTodayOnlySwitch = dailyOnly, - dueTodayOnly = dueTodayOnly, - onDueTodayOnlyChanged = { - @Suppress("AssignedValueIsNeverRead") - dueTodayOnly = it - }, - selectedExerciseTypes = selectedExerciseTypes, - onExerciseTypeSelected = { type -> - val currentTypes = selectedExerciseTypes.toMutableSet() - if (type in currentTypes) { - if (currentTypes.size > 1) currentTypes.remove(type) - } else { - currentTypes.add(type) - } - selectedExerciseTypes = currentTypes - }, - selectedOriginLanguage = selectedOriginLanguage, - onOriginLanguageChanged = { - @Suppress("AssignedValueIsNeverRead") - selectedOriginLanguage = it - }, - selectedTargetLanguage = selectedTargetLanguage, - onTargetLanguageChanged = { - @Suppress("AssignedValueIsNeverRead") - selectedTargetLanguage = it - } - ) + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } } ScreenState.EXERCISE -> { ExerciseScreen( diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/VocabularyExerciseViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/VocabularyExerciseViewModel.kt index 3647cca..0b41d00 100644 --- a/app/src/main/java/eu/gaudian/translator/viewmodel/VocabularyExerciseViewModel.kt +++ b/app/src/main/java/eu/gaudian/translator/viewmodel/VocabularyExerciseViewModel.kt @@ -90,6 +90,9 @@ class VocabularyExerciseViewModel @Inject constructor( // Exercise configuration state private val _exerciseConfig = MutableStateFlow(ExerciseConfig()) + private val _pendingExerciseConfig = MutableStateFlow(ExerciseConfig()) + + val pendingExerciseConfig: StateFlow = _pendingExerciseConfig.asStateFlow() // Exercise results state private val _exerciseResults = MutableStateFlow(ExerciseResults()) @@ -399,12 +402,17 @@ class VocabularyExerciseViewModel @Inject constructor( config: ExerciseConfig ) { _exerciseConfig.value = config + _pendingExerciseConfig.value = config _totalItems.value = items.size _originalItems.value = items startExercise(items, config.selectedExerciseTypes, config.shuffleLanguages) _screenState.value = ScreenState.EXERCISE } + fun updatePendingExerciseConfig(config: ExerciseConfig) { + _pendingExerciseConfig.value = config + } + fun finishExercise(score: Int, wrongAnswers: Int) { _exerciseResults.value = ExerciseResults(score, wrongAnswers) _screenState.value = ScreenState.RESULT diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/VocabularyViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/VocabularyViewModel.kt index 45f9d8a..238fa61 100644 --- a/app/src/main/java/eu/gaudian/translator/viewmodel/VocabularyViewModel.kt +++ b/app/src/main/java/eu/gaudian/translator/viewmodel/VocabularyViewModel.kt @@ -617,6 +617,56 @@ class VocabularyViewModel @Inject constructor( } } + fun filterVocabularyItemsByPairs( + languagePairs: List>?, + query: String?, + categoryIds: List?, + stages: List?, + wordClass: String? = null, + dueTodayOnly: Boolean = false, + sortOrder: SortOrder + ): Flow> { + val baseFlow = filterVocabularyItems( + languages = null, + query = query, + categoryIds = categoryIds, + stage = null, + wordClass = wordClass, + dueTodayOnly = dueTodayOnly, + sortOrder = sortOrder + ) + + val normalizedPairs = languagePairs + ?.map { pair -> if (pair.first < pair.second) pair else pair.second to pair.first } + ?.toSet() + + return combine(baseFlow, stageMapping) { items, stageMap -> + var filteredItems = items + + if (!normalizedPairs.isNullOrEmpty()) { + filteredItems = filteredItems.filter { item -> + val firstId = item.languageFirstId + val secondId = item.languageSecondId + if (firstId == null || secondId == null) { + false + } else { + val normalizedPair = if (firstId < secondId) firstId to secondId else secondId to firstId + normalizedPairs.contains(normalizedPair) + } + } + } + + if (!stages.isNullOrEmpty()) { + filteredItems = filteredItems.filter { item -> + val stage = stageMap[item.id] ?: VocabularyStage.NEW + stage in stages + } + } + + filteredItems + } + } + suspend fun generateVocabularyItems(category: String, amount: Int) { val selectedSourceLanguage = languageRepository.loadSelectedSourceLanguage().first() val selectedTargetLanguage = languageRepository.loadSelectedTargetLanguage().first() diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 1882d06..292c845 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -292,7 +292,7 @@ Übung starten (%1$d) Anzahl der Karten: %1$d / %2$d Keine Karten für die gewählten Filter gefunden. - Übungstypen wählen + Die richtige Antwort wählen Optionen Karten mischen Beenden @@ -338,7 +338,7 @@ Statistiken werden geladen… nach %1$s Übersetze von %1$s - Bilde das Wort hier + Bringe die Buchstaben in Reihenfolge Richtige Antwort: %1$s Übung beenden? Möchtest du wirklich beenden? Dein Fortschritt in dieser Sitzung geht verloren. @@ -680,7 +680,7 @@ Sprachenrichtung Du kannst eine optionale Einstellung vornehmen, welche Sprache zuerst oder zweit kommen soll. - Vermutung + Raten Rechtschreibung Multiple Choice Wortwirrwarr