refactor CategoryDropdown and improve vocabulary filtering with multi-category support

This commit is contained in:
jonasgaudian
2026-02-15 14:56:23 +01:00
parent fa3524268a
commit a715ab78e9
10 changed files with 53 additions and 81 deletions

View File

@@ -15,29 +15,34 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import eu.gaudian.translator.R
import eu.gaudian.translator.model.TagCategory
import eu.gaudian.translator.model.VocabularyCategory
import eu.gaudian.translator.model.VocabularyFilter
import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppCheckbox
import eu.gaudian.translator.view.composable.AppDropdownMenuItem
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedButton
import eu.gaudian.translator.view.composable.AppOutlinedTextField
import eu.gaudian.translator.viewmodel.CategoryViewModel
/**
@@ -95,7 +100,7 @@ fun CategoryDropdownContent(
text = when {
state.selectedCategories.isEmpty() -> stringResource(R.string.text_select_category)
state.selectedCategories.size == 1 -> state.selectedCategories.first()?.name
?: stringResource(R.string.text_none)
?: stringResource(R.string.label_no_category)
else -> stringResource(R.string.text_2d_categories_selected, state.selectedCategories.size)
},
modifier = Modifier.weight(1f),
@@ -139,7 +144,7 @@ fun CategoryDropdownContent(
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.text_none))
Text(stringResource(R.string.label_no_category))
}
},
onClick = {
@@ -270,13 +275,13 @@ fun CategoryDropdownContent(
*/
@Composable
fun CategoryDropdown(
modifier: Modifier = Modifier,
initialCategoryId: Int? = null,
onCategorySelected: (List<VocabularyCategory?>) -> Unit,
noneSelectable: Boolean? = true,
multipleSelectable: Boolean = false,
onlyLists: Boolean = false,
addCategory: Boolean = false,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
var selectedCategories by remember {
@@ -284,9 +289,12 @@ fun CategoryDropdown(
}
var newCategoryName by remember { mutableStateOf("") }
// For production use, this would come from ViewModel
// For preview, we'll use empty list or pass via state
val categories by remember { mutableStateOf(emptyList<VocabularyCategory>()) }
val activity = LocalContext.current.findActivity()
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
val categories by categoryViewModel.categories.collectAsState(initial = emptyList())
// Find initial category
val initialCategory = remember(categories, initialCategoryId) {

View File

@@ -8,9 +8,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -18,7 +15,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import eu.gaudian.translator.R
import eu.gaudian.translator.model.TagCategory
import eu.gaudian.translator.model.VocabularyCategory
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppDialog
@@ -34,9 +30,6 @@ fun CategorySelectionDialog(
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
val categories by categoryViewModel.categories.collectAsState(initial = emptyList())
var selectedCategories by remember { mutableStateOf<List<VocabularyCategory?>>(emptyList()) }
var newCategoryName by remember { mutableStateOf("") }
var expanded by remember { mutableStateOf(false) }
AppDialog(
onDismissRequest = onDismissRequest,
@@ -45,21 +38,8 @@ fun CategorySelectionDialog(
}
) {
// Dropdown button and menu
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = expanded,
selectedCategories = selectedCategories,
newCategoryName = newCategoryName,
categories = categories,
),
onExpand = { isExpanded -> expanded = isExpanded },
onCategorySelected = { selectedCategories = it },
onNewCategoryNameChange = { newCategoryName = it },
onAddCategory = { name ->
val newCategory = TagCategory(id = 0, name = name.trim())
categoryViewModel.createCategory(newCategory)
newCategoryName = ""
},
CategoryDropdown(
onCategorySelected = onCategorySelected,
noneSelectable = false,
multipleSelectable = true,
onlyLists = true,
@@ -79,10 +59,11 @@ fun CategorySelectionDialog(
DialogButton(
onClick = {
onCategorySelected(selectedCategories)
// The selected categories are handled by CategoryDropdown's internal state
// and passed to onCategorySelected callback
onDismissRequest()
},
enabled = selectedCategories.isNotEmpty()
enabled = true // Always enabled since CategoryDropdown handles validation
) {
Text(stringResource(R.string.label_confirm))
}

View File

@@ -55,9 +55,8 @@ fun StartExerciseDialog(
// Map displayed Language to its DB id (lid) using position mapping from load
var languageIdMap by remember { mutableStateOf<Map<Language, Int>>(emptyMap()) }
var selectedLanguages by remember { mutableStateOf<List<Language>>(emptyList()) }
var selectedCategories by remember { mutableStateOf<List<VocabularyCategory>>(emptyList()) }
var selectedStages by remember { mutableStateOf<List<VocabularyStage>>(emptyList()) }
var expanded by remember { mutableStateOf(false) }
var selectedCategories by remember { mutableStateOf<List<VocabularyCategory>>(emptyList()) }
LaunchedEffect(Unit) {
coroutineScope.launch {
@@ -87,19 +86,10 @@ fun StartExerciseDialog(
},
languages
)
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = expanded,
selectedCategories = selectedCategories,
newCategoryName = "",
categories = categories,
),
onExpand = { isExpanded -> expanded = isExpanded },
CategoryDropdown(
onCategorySelected = { cats ->
selectedCategories = cats.filterIsInstance<VocabularyCategory>()
},
onNewCategoryNameChange = {},
onAddCategory = {},
multipleSelectable = true,
onlyLists = false, // Show both filters and lists
addCategory = false,

View File

@@ -27,7 +27,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import eu.gaudian.translator.R
import eu.gaudian.translator.model.TagCategory
import eu.gaudian.translator.model.VocabularyCategory
import eu.gaudian.translator.model.VocabularyItem
import eu.gaudian.translator.utils.findActivity
@@ -54,8 +53,6 @@ fun VocabularyReviewScreen(
val selectedItems = remember { mutableStateListOf<VocabularyItem>() }
val duplicates = remember { mutableStateListOf<Boolean>() }
var selectedCategories by remember { mutableStateOf<List<VocabularyCategory?>>(emptyList()) }
var newCategoryName by remember { mutableStateOf("") }
var expanded by remember { mutableStateOf(false) }
LaunchedEffect(generatedItems) {
val duplicateResults = vocabularyViewModel.findDuplicates(generatedItems)
@@ -134,21 +131,8 @@ fun VocabularyReviewScreen(
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(8.dp)
)
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = expanded,
selectedCategories = selectedCategories,
newCategoryName = newCategoryName,
categories = categories,
),
onExpand = { isExpanded -> expanded = isExpanded },
CategoryDropdown(
onCategorySelected = { selectedCategories = it },
onNewCategoryNameChange = { newCategoryName = it },
onAddCategory = { name ->
val newCategory = TagCategory(id = 0, name = name.trim())
categoryViewModel.createCategory(newCategory)
newCategoryName = ""
},
noneSelectable = false,
multipleSelectable = true,
onlyLists = true,

View File

@@ -238,7 +238,7 @@ fun VocabularyCardHost(
listOf(currentVocabularyItem),
it.mapNotNull { category -> category?.id }
)
showCategoryDialog = false
//showCategoryDialog = false
},
onDismissRequest = { showCategoryDialog = false }
)

View File

@@ -103,7 +103,7 @@ private data class VocabularyFilterState(
val searchQuery: String = "",
val selectedStage: VocabularyStage? = null,
val sortOrder: SortOrder = SortOrder.NEWEST_FIRST,
val categoryId: Int? = null,
val categoryIds: List<Int> = emptyList(),
val dueTodayOnly: Boolean = false,
val selectedLanguageIds: List<Int> = emptyList(),
val selectedWordClass: String? = null
@@ -133,7 +133,7 @@ fun VocabularyListScreen(
var filterState by rememberSaveable {
mutableStateOf(
VocabularyFilterState(
categoryId = categoryId,
categoryIds = categoryId?.let { listOf(it) } ?: emptyList(),
dueTodayOnly = showDueTodayOnly == true,
selectedStage = stage
)
@@ -142,7 +142,7 @@ fun VocabularyListScreen(
val isFilterActive by remember(filterState) {
derivedStateOf {
filterState.selectedStage != null ||
(filterState.categoryId != null && filterState.categoryId != 0) ||
filterState.categoryIds.isNotEmpty() ||
filterState.dueTodayOnly ||
filterState.selectedLanguageIds.isNotEmpty() ||
!filterState.selectedWordClass.isNullOrBlank()
@@ -165,7 +165,7 @@ fun VocabularyListScreen(
vocabularyViewModel.filterVocabularyItems(
languages = filterState.selectedLanguageIds,
query = filterState.searchQuery.takeIf { it.isNotBlank() },
categoryId = filterState.categoryId,
categoryIds = filterState.categoryIds,
stage = filterState.selectedStage,
wordClass = filterState.selectedWordClass,
dueTodayOnly = filterState.dueTodayOnly,
@@ -179,7 +179,7 @@ fun VocabularyListScreen(
LaunchedEffect(categoryId, showDueTodayOnly, stage) {
filterState = filterState.copy(
categoryId = categoryId,
categoryIds = categoryId?.let { listOf(it) } ?: emptyList(),
dueTodayOnly = showDueTodayOnly == true,
selectedStage = stage
)
@@ -382,7 +382,8 @@ fun VocabularyListScreen(
languageViewModel = languageViewModel,
languagesPresent = allLanguages.filter { it.nameResId in languagesPresent },
hideCategory = categoryId != null && categoryId != 0,
hideStage = stage != null
hideStage = stage != null,
categoryViewModel = categoryViewModel
)
}
@@ -394,7 +395,7 @@ fun VocabularyListScreen(
selectedItems,
it.mapNotNull { category -> category?.id }
)
showCategoryDialog = false
//showCategoryDialog = false
},
onDismissRequest = { showCategoryDialog = false }
)
@@ -807,11 +808,12 @@ private fun FilterSortBottomSheet(
onDismiss: () -> Unit,
onApplyFilters: (VocabularyFilterState) -> Unit,
hideCategory: Boolean = false,
hideStage: Boolean = false
hideStage: Boolean = false,
categoryViewModel: CategoryViewModel
) {
var selectedStage by rememberSaveable { mutableStateOf(currentFilterState.selectedStage) }
var dueTodayOnly by rememberSaveable { mutableStateOf(currentFilterState.dueTodayOnly) }
var selectedCategoryId by rememberSaveable { mutableStateOf(currentFilterState.categoryId) }
var selectedCategoryIds by rememberSaveable { mutableStateOf(currentFilterState.categoryIds) }
var selectedLanguageIds by rememberSaveable { mutableStateOf(currentFilterState.selectedLanguageIds) }
var selectedWordClass by rememberSaveable { mutableStateOf(currentFilterState.selectedWordClass) }
@@ -840,7 +842,7 @@ private fun FilterSortBottomSheet(
TextButton(onClick = {
if (!hideStage) selectedStage = null
dueTodayOnly = false
if (!hideCategory) selectedCategoryId = null
if (!hideCategory) selectedCategoryIds = emptyList()
selectedLanguageIds = emptyList()
selectedWordClass = null
}) {
@@ -853,7 +855,7 @@ private fun FilterSortBottomSheet(
currentFilterState.copy(
selectedStage = selectedStage,
dueTodayOnly = dueTodayOnly,
categoryId = selectedCategoryId,
categoryIds = selectedCategoryIds,
selectedLanguageIds = selectedLanguageIds,
selectedWordClass = selectedWordClass
)
@@ -893,16 +895,18 @@ private fun FilterSortBottomSheet(
Text(stringResource(R.string.label_category), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
CategoryDropdown(
initialCategoryId = selectedCategoryId,
initialCategoryId = selectedCategoryIds.firstOrNull(),
onCategorySelected = { categories ->
selectedCategoryId = categories.firstOrNull()?.id
}
selectedCategoryIds = categories.mapNotNull { it?.id }
},
multipleSelectable = true,
noneSelectable = false
)
Spacer(Modifier.height(16.dp))
}
if (!hideStage) {
Text(stringResource(R.string.filter_by_stage), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.label_filter_by_stage), style = MaterialTheme.typography.titleMedium)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
@@ -955,6 +959,7 @@ private fun FilterSortBottomSheet(
fun FilterSortBottomSheetPreview() {
val activity = LocalContext.current.findActivity()
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
FilterSortBottomSheet(
currentFilterState = VocabularyFilterState(),
languageViewModel = languageViewModel,
@@ -962,6 +967,7 @@ fun FilterSortBottomSheetPreview() {
onDismiss = {},
onApplyFilters = {},
hideCategory = false,
hideStage = false
hideStage = false,
categoryViewModel = categoryViewModel
)
}

View File

@@ -299,6 +299,7 @@ fun VocabularySortingItem(
val activity = LocalContext.current.findActivity()
val languageConfigViewModel: LanguageConfigViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
var wordFirst by remember { mutableStateOf(item.wordFirst) }
var wordSecond by remember { mutableStateOf(item.wordSecond) }
var selectedCategories by remember { mutableStateOf<List<Int>>(emptyList()) }
@@ -313,6 +314,7 @@ fun VocabularySortingItem(
var articlesLangSecond by remember { mutableStateOf(emptySet<String>()) }
var showDuplicateDialog by remember { mutableStateOf(false) }
val categories by categoryViewModel.categories.collectAsState(initial = emptyList())
// NEW: Calculate if the item is valid for the "Done" button in faulty mode
val isItemNowValid by remember(wordFirst, wordSecond, langFirst, langSecond) {

View File

@@ -360,7 +360,7 @@
<string name="days_2d">%1$d Tage</string>
<string name="progress_by_category">Fortschritt nach Kategorie</string>
<string name="label_apply_filters">Filter anwenden</string>
<string name="filter_by_stage">Nach Stufe filtern</string>
<string name="label_filter_by_stage">Nach Stufe filtern</string>
<string name="label_category">Kategorie</string>
<string name="language">Sprache</string>
<string name="label_clear_all">Alle löschen</string>

View File

@@ -358,7 +358,7 @@
<string name="days_2d">%1$d dias</string>
<string name="progress_by_category">Progresso por Categoria</string>
<string name="label_apply_filters">Aplicar Filtros</string>
<string name="filter_by_stage">Filtrar por Estágio</string>
<string name="label_filter_by_stage">Filtrar por Estágio</string>
<string name="label_category">Categoria</string>
<string name="language">Idioma</string>
<string name="label_clear_all">Limpar Tudo</string>

View File

@@ -140,7 +140,7 @@
<string name="fetching_grammar_details">Fetching Grammar Details</string>
<string name="filter_and_sort">Filter and Sort</string>
<string name="filter_by_stage">Filter by Stage</string>
<string name="label_filter_by_stage">Filter by Stage</string>
<string name="filter_by_word_type">Filter by Word Type</string>
<string name="find_translations">Find Translations</string>
@@ -1039,4 +1039,5 @@
<string name="duplicate">Duplicate</string>
<string name="hint_scan_hint_title">Finding the right AI model</string>
<string name="hint_translate_how_it_works">How translation works</string>
<string name="label_no_category">None</string>
</resources>