Implement the StartExerciseScreen with comprehensive filtering and configuration options.

This commit is contained in:
jonasgaudian
2026-02-17 13:07:07 +01:00
parent 2db2b47c38
commit c061e41cc6
5 changed files with 641 additions and 233 deletions

View File

@@ -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<List<Pair<Language, Language>>>(emptyList()) }
var selectedCategories by remember { mutableStateOf<List<VocabularyCategory>>(emptyList()) }
var selectedStages by remember { mutableStateOf<List<VocabularyStage>>(emptyList()) }
var selectedOriginLanguage by remember { mutableStateOf<Language?>(null) }
var selectedTargetLanguage by remember { mutableStateOf<Language?>(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<Pair<Language, Language>>,
onPairsChanged: (List<Pair<Language, Language>>) -> 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<VocabularyCategory>,
onCategoriesChanged: (List<VocabularyCategory>) -> 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<TagCategory>()
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<VocabularyStage>,
onStagesChanged: (List<VocabularyStage>) -> 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<VocabularyExerciseType>,
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
)
}
}
}

View File

@@ -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<Language?>(null) }
var selectedTargetLanguage by remember { mutableStateOf<Language?>(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(

View File

@@ -90,6 +90,9 @@ class VocabularyExerciseViewModel @Inject constructor(
// Exercise configuration state
private val _exerciseConfig = MutableStateFlow(ExerciseConfig())
private val _pendingExerciseConfig = MutableStateFlow(ExerciseConfig())
val pendingExerciseConfig: StateFlow<ExerciseConfig> = _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

View File

@@ -617,6 +617,56 @@ class VocabularyViewModel @Inject constructor(
}
}
fun filterVocabularyItemsByPairs(
languagePairs: List<Pair<Int, Int>>?,
query: String?,
categoryIds: List<Int>?,
stages: List<VocabularyStage>?,
wordClass: String? = null,
dueTodayOnly: Boolean = false,
sortOrder: SortOrder
): Flow<List<VocabularyItem>> {
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()

View File

@@ -292,7 +292,7 @@
<string name="label_start_exercise_2d">Übung starten (%1$d)</string>
<string name="number_of_cards">Anzahl der Karten: %1$d / %2$d</string>
<string name="no_cards_found_for_the_selected_filters">Keine Karten für die gewählten Filter gefunden.</string>
<string name="label_choose_exercise_types">Übungstypen wählen</string>
<string name="label_choose_exercise_types">Die richtige Antwort wählen</string>
<string name="options">Optionen</string>
<string name="shuffle_cards">Karten mischen</string>
<string name="quit">Beenden</string>
@@ -338,7 +338,7 @@
<string name="statistics_are_loading">Statistiken werden geladen…</string>
<string name="to_d">nach %1$s</string>
<string name="label_translate_from_2d">Übersetze von %1$s</string>
<string name="text_assemble_the_word_here">Bilde das Wort hier</string>
<string name="text_assemble_the_word_here">Bringe die Buchstaben in Reihenfolge</string>
<string name="correct_answer">Richtige Antwort: %1$s</string>
<string name="label_quit_exercise_qm">Übung beenden?</string>
<string name="text_are_you_sure_you_want_to_quit_your_progress_in_this_session_will_be_lost">Möchtest du wirklich beenden? Dein Fortschritt in dieser Sitzung geht verloren.</string>
@@ -680,7 +680,7 @@
<string name="label_language_direction">Sprachenrichtung
</string>
<string name="text_language_direction_explanation">Du kannst eine optionale Einstellung vornehmen, welche Sprache zuerst oder zweit kommen soll.</string>
<string name="label_guessing_exercise">Vermutung</string>
<string name="label_guessing_exercise">Raten</string>
<string name="label_spelling_exercise">Rechtschreibung</string>
<string name="label_multiple_choice_exercise">Multiple Choice</string>
<string name="label_word_jumble_exercise">Wortwirrwarr</string>