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.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.Icons
import androidx.compose.material.icons.filled.ArrowBackIosNew import androidx.compose.material.icons.filled.ArrowBackIosNew
import androidx.compose.material.icons.filled.CheckCircle 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.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Circle 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme 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.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavHostController 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 @Composable
fun StartExerciseScreen( fun StartExerciseScreen(
navController: NavHostController, navController: NavHostController,
modifier: Modifier = Modifier 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( Box(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter contentAlignment = Alignment.TopCenter
@@ -67,7 +132,15 @@ fun StartExerciseScreen(
.widthIn(max = 700.dp) // Keeps it from over-stretching on tablets .widthIn(max = 700.dp) // Keeps it from over-stretching on tablets
.fillMaxSize() .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( LazyColumn(
modifier = Modifier modifier = Modifier
@@ -76,27 +149,107 @@ fun StartExerciseScreen(
verticalArrangement = Arrangement.spacedBy(32.dp) verticalArrangement = Arrangement.spacedBy(32.dp)
) { ) {
item { Spacer(modifier = Modifier.height(8.dp)) } item { Spacer(modifier = Modifier.height(8.dp)) }
item { LanguagePairSection() } item {
item { CategoriesSection() } LanguagePairSection(
item { DifficultySection() } selectedPairs = selectedLanguagePairs,
item { NumberOfCardsSection() } onPairsChanged = { selectedLanguagePairs = it },
item { QuestionTypesSection() } 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)) } 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 @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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -118,15 +271,45 @@ fun TopBarSection(onBackClick: () -> Unit) {
} }
Text( Text(
text = "Start Exercise", text = stringResource(R.string.label_start_exercise),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
textAlign = androidx.compose.ui.text.style.TextAlign.Center textAlign = TextAlign.Center
) )
// Spacer to balance the back button for centering IconButton(
Spacer(modifier = Modifier.size(48.dp)) 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 @Composable
fun LanguagePairSection() { fun LanguagePairSection(
var selectedPair by remember { mutableStateOf(0) } 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 { Column {
SectionHeader(title = "Language Pair", actionText = "Change") SectionHeader(title = stringResource(R.string.language_pair))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
LanguageChip( if (availablePairs.isEmpty()) {
text = "EN → ES", Text(
isSelected = selectedPair == 0, text = stringResource(R.string.text_no_dictionary_language_pairs_found),
modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium,
onClick = { selectedPair = 0 } color = MaterialTheme.colorScheme.onSurfaceVariant
)
LanguageChip(
text = "EN → FR",
isSelected = selectedPair == 1,
modifier = Modifier.weight(1f),
onClick = { selectedPair = 1 }
) )
} 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) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun CategoriesSection() { fun CategoriesSection(
val categories = listOf("Travel", "Business", "Food", "Technology", "Slang", "Academic", "Relationships") selectedCategories: List<VocabularyCategory>,
var selectedCategories by remember { mutableStateOf(setOf("Travel", "Food")) } 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 { 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( FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)
modifier = Modifier.fillMaxWidth()
) { ) {
categories.forEach { category -> VocabularyStage.entries.forEach { stage ->
val isSelected = selectedCategories.contains(category) val isSelected = selectedStages.contains(stage)
Surface( Surface(
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
onClick = { onClick = {
selectedCategories = if (isSelected) selectedCategories - category else selectedCategories + category val updated = if (isSelected) {
selectedStages - stage
} else {
selectedStages + stage
}
onStagesChanged(updated)
} }
) { ) {
Text( Text(
text = category, text = stage.toString(context),
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodyMedium, 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 @Composable
fun DifficultySection() { fun NumberOfCardsSection(
val stages = listOf("New", "Stage 1", "Stage 2", "Stage 3", "Stage 4", "Stage 5", "Learned") totalAvailable: Int,
var selectedDifficulty by remember { mutableStateOf("Medium") } amount: Int,
onAmountChanged: (Int) -> Unit,
dueTodayOnly: Boolean,
onDueTodayOnlyChanged: (Boolean) -> Unit
) {
Column { Column {
SectionHeader(title = "Difficulty Level") OptionItemSwitch(
Surface( title = stringResource(R.string.text_due_today_only),
shape = RoundedCornerShape(50), description = stringResource(R.string.text_due_today_only_description),
color = MaterialTheme.colorScheme.surfaceVariant, checked = dueTodayOnly,
modifier = Modifier.fillMaxWidth().height(56.dp) onCheckedChange = onDueTodayOnlyChanged
) { )
Row(
modifier = Modifier.fillMaxSize().padding(4.dp), Spacer(modifier = Modifier.height(16.dp))
verticalAlignment = Alignment.CenterVertically
) { if (totalAvailable == 0) {
stages.forEach { level -> Text(
val isSelected = selectedDifficulty == level text = stringResource(R.string.no_cards_found_for_the_selected_filters),
Box( style = MaterialTheme.typography.bodyMedium,
modifier = Modifier color = MaterialTheme.colorScheme.error
.weight(1f) )
.fillMaxHeight() return@Column
.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
)
}
}
}
} }
}
}
//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( Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = "NUMBER OF CARDS", text = stringResource(R.string.text_amount_of_cards).uppercase(),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
letterSpacing = 1.sp, letterSpacing = 1.sp,
@@ -311,7 +640,7 @@ fun NumberOfCardsSection() {
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) { ) {
Text( Text(
text = sliderPosition.toInt().toString(), text = "$coercedAmount / $totalAvailable",
color = MaterialTheme.colorScheme.onPrimary, color = MaterialTheme.colorScheme.onPrimary,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@@ -320,59 +649,73 @@ fun NumberOfCardsSection() {
} }
} }
Slider( AppSlider(
value = sliderPosition, value = coercedAmount.toFloat(),
onValueChange = { sliderPosition = it }, onValueChange = { onAmountChanged(it.toInt().coerceIn(1, maxAvailable)) },
valueRange = 5f..50f, valueRange = 1f..maxAvailable.toFloat(),
steps = 45 steps = if (maxAvailable > 1) maxAvailable - 2 else 0,
modifier = Modifier.fillMaxWidth()
) )
Row( val quickSelectValues = listOf(10, 25, 50, 100)
modifier = Modifier.fillMaxWidth(), val availableQuickSelections = quickSelectValues.filter { it <= maxAvailable }
horizontalArrangement = Arrangement.SpaceBetween if (availableQuickSelections.isNotEmpty()) {
) { Spacer(modifier = Modifier.height(12.dp))
Text("5 CARDS", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Row(
Text("50 CARDS", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) 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, user must select at least one
//TODO use our four question types here, from StartScreen.kt
@Composable @Composable
fun QuestionTypesSection() { fun QuestionTypesSection(
var selectedTypes by remember { mutableStateOf(setOf("Multiple Choice", "Spelling")) } selectedTypes: Set<VocabularyExerciseType>,
onTypeSelected: (VocabularyExerciseType) -> Unit
) {
Column { Column {
SectionHeader(title = "Question Types") SectionHeader(title = stringResource(R.string.text_question_types))
QuestionTypeCard( QuestionTypeCard(
title = "Multiple Choice", title = stringResource(R.string.label_guessing_exercise),
subtitle = "Choose the correct meaning", subtitle = stringResource(R.string.flip_card),
icon = Icons.Default.List, icon = AppIcons.Guessing,
isSelected = selectedTypes.contains("Multiple Choice"), isSelected = selectedTypes.contains(VocabularyExerciseType.GUESSING),
onClick = { onClick = { onTypeSelected(VocabularyExerciseType.GUESSING) }
selectedTypes = if (selectedTypes.contains("Multiple Choice")) selectedTypes - "Multiple Choice" else selectedTypes + "Multiple Choice"
}
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
QuestionTypeCard( QuestionTypeCard(
title = "Spelling", title = stringResource(R.string.label_spelling_exercise),
subtitle = "Type the translated word", subtitle = stringResource(R.string.type_the_translation),
icon = Icons.Default.Edit, icon = AppIcons.SpellCheck,
isSelected = selectedTypes.contains("Spelling"), isSelected = selectedTypes.contains(VocabularyExerciseType.SPELLING),
onClick = { onClick = { onTypeSelected(VocabularyExerciseType.SPELLING) }
selectedTypes = if (selectedTypes.contains("Spelling")) selectedTypes - "Spelling" else selectedTypes + "Spelling"
}
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
QuestionTypeCard( QuestionTypeCard(
title = "Listening", title = stringResource(R.string.label_multiple_choice_exercise),
subtitle = "Recognize spoken words", subtitle = stringResource(R.string.label_choose_exercise_types),
icon = Icons.Default.Hearing, icon = AppIcons.CheckList,
isSelected = selectedTypes.contains("Listening"), isSelected = selectedTypes.contains(VocabularyExerciseType.MULTIPLE_CHOICE),
onClick = { onClick = { onTypeSelected(VocabularyExerciseType.MULTIPLE_CHOICE) }
selectedTypes = if (selectedTypes.contains("Listening")) selectedTypes - "Listening" else selectedTypes + "Listening" )
} 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 @Composable
fun BottomButtonSection() { fun BottomButtonSection(
enabled: Boolean,
amount: Int,
onStart: () -> Unit
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(24.dp) .padding(24.dp)
) { ) {
Button( AppButton(
onClick = { /* TODO: Start Session */ }, onClick = onStart,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(56.dp), .height(56.dp),
shape = RoundedCornerShape(28.dp), enabled = enabled,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary) shape = RoundedCornerShape(28.dp)
) { ) {
Row( Row(
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = "Start Session", text = stringResource(R.string.label_start_exercise_2d, amount),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Icon( Icon(
imageVector = Icons.Default.PlayArrow, imageVector = Icons.Default.PlayArrow,
contentDescription = "Play", contentDescription = stringResource(R.string.cd_play),
modifier = Modifier.size(20.dp) 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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -28,10 +27,8 @@ import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.model.Language
import eu.gaudian.translator.utils.findActivity import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppAlertDialog import eu.gaudian.translator.view.composable.AppAlertDialog
import eu.gaudian.translator.viewmodel.ExerciseConfig
import eu.gaudian.translator.viewmodel.ScreenState import eu.gaudian.translator.viewmodel.ScreenState
import eu.gaudian.translator.viewmodel.VocabularyExerciseViewModel import eu.gaudian.translator.viewmodel.VocabularyExerciseViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel import eu.gaudian.translator.viewmodel.VocabularyViewModel
@@ -57,14 +54,7 @@ fun VocabularyExerciseHostScreen(
val cardSet by vocabularyViewModel.cardSet.collectAsState() val cardSet by vocabularyViewModel.cardSet.collectAsState()
val screenState by exerciseViewModel.screenState.collectAsState() val screenState by exerciseViewModel.screenState.collectAsState()
val pendingConfig by exerciseViewModel.pendingExerciseConfig.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) }
var finalScore by remember { mutableIntStateOf(0) } var finalScore by remember { mutableIntStateOf(0) }
var finalWrongAnswers by remember { mutableIntStateOf(0) } var finalWrongAnswers by remember { mutableIntStateOf(0) }
@@ -76,16 +66,40 @@ fun VocabularyExerciseHostScreen(
false false
} }
LaunchedEffect(Unit) { LaunchedEffect(categoryIdsAsJson, stageNamesAsJson, languageIdsAsJson, dailyOnly) {
// Reset exercise state when starting fresh // Only reset and prepare if the host is opened via explicit filters.
exerciseViewModel.resetExercise() if (!categoryIdsAsJson.isNullOrBlank() || !stageNamesAsJson.isNullOrBlank() || !languageIdsAsJson.isNullOrBlank() || dailyOnly) {
exerciseViewModel.resetExercise()
vocabularyViewModel.prepareExercise( vocabularyViewModel.prepareExercise(
categoryIdsAsJson, categoryIdsAsJson,
stageNamesAsJson, stageNamesAsJson,
languageIdsAsJson, languageIdsAsJson,
dailyOnly = dailyOnly, 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) { if (cardSet == null && screenState != ScreenState.START) {
@@ -95,68 +109,9 @@ fun VocabularyExerciseHostScreen(
} else { } else {
when (screenState) { when (screenState) {
ScreenState.START -> { ScreenState.START -> {
StartScreen( Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
cardSet = cardSet, CircularProgressIndicator()
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
}
)
} }
ScreenState.EXERCISE -> { ScreenState.EXERCISE -> {
ExerciseScreen( ExerciseScreen(

View File

@@ -90,6 +90,9 @@ class VocabularyExerciseViewModel @Inject constructor(
// Exercise configuration state // Exercise configuration state
private val _exerciseConfig = MutableStateFlow(ExerciseConfig()) private val _exerciseConfig = MutableStateFlow(ExerciseConfig())
private val _pendingExerciseConfig = MutableStateFlow(ExerciseConfig())
val pendingExerciseConfig: StateFlow<ExerciseConfig> = _pendingExerciseConfig.asStateFlow()
// Exercise results state // Exercise results state
private val _exerciseResults = MutableStateFlow(ExerciseResults()) private val _exerciseResults = MutableStateFlow(ExerciseResults())
@@ -399,12 +402,17 @@ class VocabularyExerciseViewModel @Inject constructor(
config: ExerciseConfig config: ExerciseConfig
) { ) {
_exerciseConfig.value = config _exerciseConfig.value = config
_pendingExerciseConfig.value = config
_totalItems.value = items.size _totalItems.value = items.size
_originalItems.value = items _originalItems.value = items
startExercise(items, config.selectedExerciseTypes, config.shuffleLanguages) startExercise(items, config.selectedExerciseTypes, config.shuffleLanguages)
_screenState.value = ScreenState.EXERCISE _screenState.value = ScreenState.EXERCISE
} }
fun updatePendingExerciseConfig(config: ExerciseConfig) {
_pendingExerciseConfig.value = config
}
fun finishExercise(score: Int, wrongAnswers: Int) { fun finishExercise(score: Int, wrongAnswers: Int) {
_exerciseResults.value = ExerciseResults(score, wrongAnswers) _exerciseResults.value = ExerciseResults(score, wrongAnswers)
_screenState.value = ScreenState.RESULT _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) { suspend fun generateVocabularyItems(category: String, amount: Int) {
val selectedSourceLanguage = languageRepository.loadSelectedSourceLanguage().first() val selectedSourceLanguage = languageRepository.loadSelectedSourceLanguage().first()
val selectedTargetLanguage = languageRepository.loadSelectedTargetLanguage().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="label_start_exercise_2d">Übung starten (%1$d)</string>
<string name="number_of_cards">Anzahl der Karten: %1$d / %2$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="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="options">Optionen</string>
<string name="shuffle_cards">Karten mischen</string> <string name="shuffle_cards">Karten mischen</string>
<string name="quit">Beenden</string> <string name="quit">Beenden</string>
@@ -338,7 +338,7 @@
<string name="statistics_are_loading">Statistiken werden geladen…</string> <string name="statistics_are_loading">Statistiken werden geladen…</string>
<string name="to_d">nach %1$s</string> <string name="to_d">nach %1$s</string>
<string name="label_translate_from_2d">Übersetze von %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="correct_answer">Richtige Antwort: %1$s</string>
<string name="label_quit_exercise_qm">Übung beenden?</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> <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 name="label_language_direction">Sprachenrichtung
</string> </string>
<string name="text_language_direction_explanation">Du kannst eine optionale Einstellung vornehmen, welche Sprache zuerst oder zweit kommen soll.</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_spelling_exercise">Rechtschreibung</string>
<string name="label_multiple_choice_exercise">Multiple Choice</string> <string name="label_multiple_choice_exercise">Multiple Choice</string>
<string name="label_word_jumble_exercise">Wortwirrwarr</string> <string name="label_word_jumble_exercise">Wortwirrwarr</string>