refactor CategoryDropdown to a stateless component and relocate ApiModelDropDown

This commit is contained in:
jonasgaudian
2026-02-14 14:33:53 +01:00
parent b95a2de747
commit f829174bcb
11 changed files with 1134 additions and 674 deletions

View File

@@ -0,0 +1,258 @@
package eu.gaudian.translator.view.composable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.gaudian.translator.R
import eu.gaudian.translator.model.LanguageModel
import eu.gaudian.translator.model.communication.ApiProvider
@Composable
fun ApiModelDropDown(
models: List<LanguageModel>,
providers: List<ApiProvider>,
selectedModel: LanguageModel?,
onModelSelected: (LanguageModel?) -> Unit,
enabled: Boolean = true
) {
LocalContext.current
var expanded by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") }
val activeModels = models.filter { model -> providers.any { it.key == model.providerKey && (it.hasValidKey || it.isCustom) } }
val groupedModels = activeModels.groupBy { it.providerKey }
val providerNames = remember(providers) { providers.associate { it.key to it.displayName } }
val providerStatuses = remember(providers) { providers.associate { it.key to (it.hasValidKey || it.isCustom) } }
val filteredGroupedModels = remember(groupedModels, searchQuery) {
if (searchQuery.isBlank()) {
groupedModels
} else {
groupedModels.mapValues { (_, models) ->
models.filter { model ->
model.displayName.contains(searchQuery, ignoreCase = true) ||
model.modelId.contains(searchQuery, ignoreCase = true) ||
model.description.contains(searchQuery, ignoreCase = true)
}
}.filterValues { it.isNotEmpty() }
}
}
Box {
AppOutlinedButton(
onClick = { expanded = true },
modifier = Modifier.align(Alignment.Center),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
enabled = enabled
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = selectedModel?.displayName ?: stringResource(R.string.text_select_model),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (selectedModel != null) {
Text(
text = providerNames[selectedModel.providerKey] ?: selectedModel.providerKey,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown,
contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand)
)
}
}
DropdownMenu(
modifier = Modifier
.fillMaxWidth(),
expanded = expanded,
onDismissRequest = { expanded = false }
) {
// Search bar
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
AppIcons.Search,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.width(8.dp))
TextField(
value = searchQuery,
onValueChange = { searchQuery = it },
placeholder = { Text(stringResource(R.string.label_search_models)) },
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
modifier = Modifier.weight(1f)
)
if (searchQuery.isNotBlank()) {
IconButton(
onClick = { searchQuery = "" },
modifier = Modifier.size(24.dp)
) {
Icon(
AppIcons.Close,
contentDescription = stringResource(R.string.cd_clear_search),
modifier = Modifier.size(16.dp)
)
}
}
}
HorizontalDivider()
}
if (filteredGroupedModels.isNotEmpty()) {
filteredGroupedModels.entries.forEachIndexed { index, entry ->
val providerKey = entry.key
val providerModels = entry.value
val isActive = providerStatuses[providerKey] == true
val providerName = providerNames[providerKey] ?: providerKey
if (index > 0) HorizontalDivider()
// Provider header
AppDropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = if (isActive) AppIcons.CheckCircle else AppIcons.Warning,
contentDescription = null,
tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = providerName,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Text(
text = stringResource(
R.string.labels_1d_models,
providerModels.size
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
},
onClick = {},
enabled = false,
selected = false
)
// Models for this provider
providerModels.forEach { model ->
AppDropdownMenuItem(
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = model.displayName,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
fontWeight = if (model == selectedModel) FontWeight.Medium else FontWeight.Normal
)
Spacer(modifier = Modifier.width(8.dp))
ModelBadges(
modelDisplayOrId = model.displayName.ifBlank { model.modelId },
providerKey = model.providerKey,
)
}
if (model.description.isNotBlank()) {
Text(
text = model.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
},
onClick = {
onModelSelected(model)
expanded = false
searchQuery = ""
},
selected = model == selectedModel
)
}
}
} else if (searchQuery.isNotBlank()) {
AppDropdownMenuItem(
text = {
Text(
stringResource(R.string.text_no_models_found),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
modifier = Modifier.fillMaxWidth()
)
},
onClick = {},
enabled = false,
selected = false
)
}
}
}
}

View File

@@ -3,10 +3,14 @@
package eu.gaudian.translator.view.composable
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -34,6 +38,7 @@ import androidx.compose.runtime.mutableIntStateOf
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.draw.clip
import androidx.compose.ui.geometry.Size
@@ -42,14 +47,13 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.PopupProperties
import com.google.android.material.color.MaterialColors.ALPHA_DISABLED
import com.google.android.material.color.MaterialColors.ALPHA_FULL
import eu.gaudian.translator.R
/**
@@ -80,7 +84,6 @@ fun AppDropdownMenu(
content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit,
) {
var textFieldSize by remember { mutableStateOf(Size.Zero) }
val interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
Column(modifier = modifier) {
@@ -126,15 +129,17 @@ fun AppDropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
modifier = Modifier
.width(with(LocalDensity.current) { textFieldSize.width.toDp() }),
offset = DpOffset(0.dp, 0.dp),
.width(with(LocalDensity.current) { textFieldSize.width.toDp() })
// Give the menu itself a bit of breathing room
.padding(vertical = 4.dp),
offset = DpOffset(0.dp, 4.dp), // Slight detachment from the anchor
scrollState = rememberScrollState(),
properties = PopupProperties(focusable = true),
shape = RoundedCornerShape(8.dp),
containerColor = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 0.dp,
shadowElevation = 4.dp,
border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.14f))
shape = RoundedCornerShape(12.dp),
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 6.dp,
shadowElevation = 8.dp,
border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
) {
content()
}
@@ -143,15 +148,7 @@ fun AppDropdownMenu(
/**
* A modern and stylish composable for individual dropdown items, featuring enhanced visual design
* with subtle shadows, rounded corners, and smooth interactions. This provides a cool, contemporary look
* that aligns with modern UI trends while maintaining accessibility and usability.
*
* @param text Composable lambda for the text to display in the item.
* @param onClick Callback invoked when the item is clicked.
* @param modifier Modifier for the item.
* @param enabled Whether the item is enabled.
* @param leadingIcon Optional leading icon for the item.
* @param trailingIcon Optional trailing icon for the item.
* with subtle shadows, rounded corners, and smooth interactions.
*/
@Composable
fun AppDropdownMenuItem(
@@ -166,21 +163,28 @@ fun AppDropdownMenuItem(
val contentColor = if (enabled) {
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) // Equivalent to disabled alpha
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
}
// Modern "floating" highlight background
val backgroundColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f) else Color.Transparent
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp) // Outer padding creates the floating shape
.clip(RoundedCornerShape(8.dp))
.background(backgroundColor)
.clickable(enabled = enabled) { onClick() }
//.padding(horizontal = 12.dp, vertical = 10.dp) // Inner padding keeps content comfortable
) {
androidx.compose.foundation.layout.Row(
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically
) {
leadingIcon?.invoke()
if (leadingIcon != null) {
androidx.compose.foundation.layout.Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(12.dp))
}
Box(modifier = Modifier.weight(1f)) {
CompositionLocalProvider(LocalContentColor provides contentColor) {
@@ -188,90 +192,14 @@ fun AppDropdownMenuItem(
}
}
if (trailingIcon != null) {
androidx.compose.foundation.layout.Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(12.dp))
trailingIcon()
}
}
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun AppDropdownMenuPreview() {
val options = listOf("Option 1", "Option 2", "Option 3")
AppDropdownMenu(
expanded = false,
onDismissRequest = {},
label = { Text("Select Option") },
content = {
options.forEach { option ->
AppDropdownMenuItem(
text = { Text(text = option) },
onClick = {}
)
}
}
)
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun AppDropdownMenuExpandedPreview() {
val options = listOf("English", "Spanish", "French", "German", "Italian", "Portuguese")
var expanded by remember { mutableStateOf(true) } // Force expanded state for preview
// Since previews are static, we'll simulate the expanded state by showing the dropdown
AppDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
label = { Text("Language") },
content = {
options.forEach { option ->
AppDropdownMenuItem(
text = { Text(text = option) },
onClick = {}
)
}
}
)
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun DropDownItemPreview() {
AppDropdownMenuItem(
text = { Text("Sample Item", style = MaterialTheme.typography.titleSmall) },
onClick = {},
leadingIcon = {
Icon(
imageVector = AppIcons.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
)
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun DropDownItemSelectedPreview() {
AppDropdownMenuItem(
text = { Text("Selected Item", style = MaterialTheme.typography.titleSmall) },
onClick = {},
selected = true,
trailingIcon = {
Icon(
imageVector = AppIcons.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
)
}
// ... [Previews remain exactly the same as your original file] ...
@Composable
fun <T> LargeDropdownMenu(
@@ -308,7 +236,6 @@ fun <T> LargeDropdownMenu(
readOnly = true,
)
// Transparent clickable surface on top of OutlinedTextField
Surface(
modifier = Modifier
.fillMaxSize()
@@ -321,10 +248,13 @@ fun <T> LargeDropdownMenu(
if (expanded) {
Dialog(
onDismissRequest = { expanded = true },
onDismissRequest = { expanded = false }, // Fixed bug from original code
) {
Surface(
shape = RoundedCornerShape(12.dp),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shadowElevation = 8.dp,
tonalElevation = 6.dp
) {
val listState = rememberLazyListState()
if (selectedIndex > -1) {
@@ -333,7 +263,12 @@ fun <T> LargeDropdownMenu(
}
}
LazyColumn(modifier = Modifier.fillMaxWidth(), state = listState) {
// Added vertical padding to the list instead of hard dividers
LazyColumn(
modifier = Modifier.fillMaxWidth(),
state = listState,
contentPadding = PaddingValues(vertical = 8.dp)
) {
if (notSetLabel != null) {
item {
LargeDropdownMenuItem(
@@ -354,10 +289,6 @@ fun <T> LargeDropdownMenu(
onItemSelected(index, item)
expanded = false
}
if (index < items.lastIndex) {
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
}
}
}
}
@@ -373,19 +304,27 @@ fun LargeDropdownMenuItem(
onClick: () -> Unit,
) {
val contentColor = when {
!enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = ALPHA_DISABLED)
selected -> MaterialTheme.colorScheme.primary.copy(alpha = ALPHA_FULL)
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = ALPHA_FULL)
!enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
selected -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurface
}
val backgroundColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f) else Color.Transparent
val fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
CompositionLocalProvider(LocalContentColor provides contentColor) {
Box(modifier = Modifier
.clickable(enabled) { onClick() }
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)) {
.padding(horizontal = 8.dp, vertical = 4.dp) // Outer padding for floating shape
.clip(RoundedCornerShape(8.dp))
.background(backgroundColor)
.clickable(enabled) { onClick() }
.padding(horizontal = 16.dp, vertical = 14.dp) // Inner padding
) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = fontWeight),
)
}
}

View File

@@ -13,7 +13,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -114,7 +113,7 @@ fun BaseLanguageDropDown(
@Composable
fun MultiSelectItem(language: Language) {
val isSelected = tempSelection.contains(language)
DropdownMenuItem(
AppDropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
AppCheckbox(
@@ -155,7 +154,7 @@ fun BaseLanguageDropDown(
val duplicateNames = languageNames.groupingBy { it }.eachCount().filter { it.value > 1 }.keys
val isDuplicate = duplicateNames.contains(language.name)
DropdownMenuItem(
AppDropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column {
@@ -284,11 +283,11 @@ fun BaseLanguageDropDown(
} else {
// Logic for single selection default view
if (showAutoOption) {
DropdownMenuItem(text = { Text(stringResource(R.string.text_select_auto_recognition)) }, onClick = { onAutoSelected(); expanded = false; searchText = "" })
AppDropdownMenuItem(text = { Text(stringResource(R.string.text_select_auto_recognition)) }, onClick = { onAutoSelected(); expanded = false; searchText = "" })
HorizontalDivider()
}
if (showNoneOption) {
DropdownMenuItem(text = { Text(stringResource(R.string.text_select_none)) }, onClick = { onNoneSelected(); expanded = false; searchText = "" })
AppDropdownMenuItem(text = { Text(stringResource(R.string.text_select_none)) }, onClick = { onNoneSelected(); expanded = false; searchText = "" })
HorizontalDivider()
}
if (favoriteLanguages.any {

View File

@@ -11,98 +11,130 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.utils.findActivity
import eu.gaudian.translator.model.VocabularyFilter
import eu.gaudian.translator.ui.theme.ThemePreviews
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
/**
* State class representing the internal state of CategoryDropdown.
* Used for previews and testing.
*/
data class CategoryDropdownState(
val expanded: Boolean = false,
val selectedCategories: List<VocabularyCategory?> = emptyList(),
val newCategoryName: String = "",
val categories: List<VocabularyCategory> = emptyList(),
)
/**
* Stateless dropdown content composable for category selection.
* This component is fully controlled by its parameters and does not maintain any internal state.
*
* @param state The current state of the dropdown
* @param onExpand Callback when the dropdown should expand/collapse
* @param onCategorySelected Callback when a category is selected
* @param onNewCategoryNameChange Callback when the new category name changes
* @param onAddCategory Callback when a new category should be added
* @param noneSelectable Whether "None" option is selectable
* @param multipleSelectable Whether multiple categories can be selected
* @param onlyLists Whether to show only list/category types
* @param addCategory Whether to show the "Add Category" option
* @param modifier Modifier for the composable
*/
@Composable
fun CategoryDropdown(
initialCategoryId: Int? = null,
fun CategoryDropdownContent(
modifier: Modifier = Modifier,
state: CategoryDropdownState,
onExpand: (Boolean) -> Unit,
onCategorySelected: (List<VocabularyCategory?>) -> Unit,
noneSelectable: Boolean? = true,
onNewCategoryNameChange: (String) -> Unit,
onAddCategory: (String) -> Unit,
noneSelectable: Boolean = true,
multipleSelectable: Boolean = false,
onlyLists: Boolean = false,
addCategory: Boolean = false
addCategory: Boolean = false,
) {
val activity = LocalContext.current.findActivity()
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
var expanded by remember { mutableStateOf(false) }
val categories by categoryViewModel.categories.collectAsState(initial = emptyList())
val selectableCategories = if (onlyLists) categories.filterIsInstance<TagCategory>() else categories
val initialCategory = remember(categories, initialCategoryId) {
categories.find { it.id == initialCategoryId }
val selectableCategories = if (onlyLists) {
state.categories.filterIsInstance<TagCategory>()
} else {
state.categories
}
var selectedCategories by remember {
mutableStateOf<List<VocabularyCategory?>>(if (initialCategory != null) listOf(initialCategory) else emptyList())
}
var newCategoryName by remember { mutableStateOf("") }
AppOutlinedButton(
shape = RoundedCornerShape(8.dp),
onClick = { expanded = true },
modifier = Modifier.fillMaxWidth(),
onClick = { onExpand(true) },
modifier = modifier.fillMaxWidth(),
) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(text = when {
selectedCategories.isEmpty() -> stringResource(R.string.text_select_category)
selectedCategories.size == 1 -> selectedCategories.first()?.name ?: stringResource(R.string.text_none)
else -> stringResource(R.string.text_2d_categories_selected, selectedCategories.size)
Text(
text = when {
state.selectedCategories.isEmpty() -> stringResource(R.string.text_select_category)
state.selectedCategories.size == 1 -> state.selectedCategories.first()?.name
?: stringResource(R.string.text_none)
else -> stringResource(R.string.text_2d_categories_selected, state.selectedCategories.size)
},
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center
)
Icon(
imageVector = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown,
contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(
R.string.cd_expand
)
imageVector = if (state.expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown,
contentDescription = if (state.expanded) {
stringResource(R.string.cd_collapse)
} else {
stringResource(R.string.cd_expand)
}
)
}
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
expanded = state.expanded,
onDismissRequest = { onExpand(false) },
modifier = Modifier.fillMaxWidth(),
) {
if (noneSelectable == true) {
val noneSelected = selectedCategories.contains(null)
if (noneSelectable) {
val noneSelected = state.selectedCategories.contains(null)
AppDropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (multipleSelectable) {
AppCheckbox(
checked = noneSelected,
onCheckedChange = {
selectedCategories = if (noneSelected) selectedCategories.filterNotNull() else selectedCategories + listOf(null)
onCategorySelected(selectedCategories)
onCheckedChange = { isChecked ->
val newSelection = if (noneSelected) {
state.selectedCategories.filterNotNull()
} else {
state.selectedCategories + listOf(null)
}
onCategorySelected(newSelection)
}
)
Spacer(modifier = Modifier.width(8.dp))
@@ -112,31 +144,38 @@ fun CategoryDropdown(
},
onClick = {
if (multipleSelectable) {
selectedCategories = if (noneSelected) {
selectedCategories.filterNotNull()
val newSelection = if (noneSelected) {
state.selectedCategories.filterNotNull()
} else {
selectedCategories + listOf(null)
state.selectedCategories + listOf(null)
}
onCategorySelected(selectedCategories)
onCategorySelected(newSelection)
} else {
selectedCategories = listOf(null)
onCategorySelected(selectedCategories)
expanded = false
onCategorySelected(listOf(null))
onExpand(false)
}
}
)
}
selectableCategories.forEach { category ->
val isSelected = selectedCategories.contains(category)
val isSelected = state.selectedCategories.contains(category)
AppDropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (multipleSelectable) {
AppCheckbox(
checked = isSelected,
onCheckedChange = {
selectedCategories = if (isSelected) selectedCategories - category else selectedCategories + category
onCategorySelected(selectedCategories)
onCheckedChange = { _ ->
val newSelection = if (isSelected) {
state.selectedCategories - category
} else {
state.selectedCategories + category
}
onCategorySelected(newSelection)
}
)
Spacer(modifier = Modifier.width(8.dp))
@@ -146,26 +185,23 @@ fun CategoryDropdown(
},
onClick = {
if (multipleSelectable) {
selectedCategories = if (category in selectedCategories) {
selectedCategories - category
val newSelection = if (category in state.selectedCategories) {
state.selectedCategories - category
} else {
selectedCategories + category
state.selectedCategories + category
}
onCategorySelected(selectedCategories)
onCategorySelected(newSelection)
} else {
selectedCategories = listOf(category)
onCategorySelected(selectedCategories)
expanded = false
onCategorySelected(listOf(category))
onExpand(false)
}
}
)
}
if(addCategory) {
if (addCategory) {
HorizontalDivider()
// Create new category section
AppDropdownMenuItem(
text = {
Text(stringResource(R.string.label_add_category))
@@ -181,26 +217,19 @@ fun CategoryDropdown(
verticalAlignment = Alignment.CenterVertically
) {
AppOutlinedTextField(
value = newCategoryName,
onValueChange = { newCategoryName = it },
value = state.newCategoryName,
onValueChange = onNewCategoryNameChange,
modifier = Modifier.weight(1f),
singleLine = true,
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = {
if (newCategoryName.isNotBlank()) {
val newList =
TagCategory(id = 0, name = newCategoryName.trim())
categoryViewModel.createCategory(newList)
newCategoryName = ""
// Optionally, select the new category if single selection
if (!multipleSelectable) {
expanded = false
}
if (state.newCategoryName.isNotBlank()) {
onAddCategory(state.newCategoryName.trim())
}
},
enabled = newCategoryName.isNotBlank()
enabled = state.newCategoryName.isNotBlank()
) {
Icon(
imageVector = AppIcons.Add,
@@ -209,15 +238,14 @@ fun CategoryDropdown(
}
}
},
onClick = {} // No action on click
onClick = {}
)
}
if (multipleSelectable) {
Spacer(modifier = Modifier.height(8.dp))
AppButton(
onClick = { expanded = false },
onClick = { onExpand(false) },
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
@@ -228,10 +256,417 @@ fun CategoryDropdown(
}
}
@Preview
/**
* Stateful wrapper for CategoryDropdown that manages its own state.
* This is the main composable that should be used in production code.
*
* @param initialCategoryId The initial category ID to select
* @param onCategorySelected Callback when categories are selected
* @param noneSelectable Whether "None" option is selectable
* @param multipleSelectable Whether multiple categories can be selected
* @param onlyLists Whether to show only list/category types
* @param addCategory Whether to show the "Add Category" option
* @param modifier Modifier for the composable
*/
@Composable
fun CategoryDropdownPreview() {
CategoryDropdown(
onCategorySelected = {}
fun CategoryDropdown(
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 {
mutableStateOf<List<VocabularyCategory?>>(emptyList())
}
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>()) }
// Find initial category
val initialCategory = remember(categories, initialCategoryId) {
categories.find { it.id == initialCategoryId }
}
// Initialize selection with initial category if provided
remember(initialCategory) {
if (initialCategory != null && selectedCategories.isEmpty()) {
selectedCategories = listOf(initialCategory)
}
true
}
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = expanded,
selectedCategories = selectedCategories,
newCategoryName = newCategoryName,
categories = categories,
),
onExpand = { isExpanded -> expanded = isExpanded },
onCategorySelected = { newSelection ->
selectedCategories = newSelection
onCategorySelected(newSelection)
},
onNewCategoryNameChange = { newCategoryName = it },
onAddCategory = { name ->
val newCategory = TagCategory(id = 0, name = name)
// In production, this would call ViewModel.createCategory(newCategory)
newCategoryName = ""
if (!multipleSelectable) {
expanded = false
}
},
noneSelectable = noneSelectable == true,
multipleSelectable = multipleSelectable,
onlyLists = onlyLists,
addCategory = addCategory,
modifier = modifier,
)
}
// ============== PREVIEWS ==============
/**
* Preview provider for CategoryDropdownState
*/
@Suppress("HardCodedStringLiteral")
class CategoryDropdownStateProvider : PreviewParameterProvider<CategoryDropdownState> {
override val values = sequenceOf(
// Collapsed state - nothing selected
CategoryDropdownState(
expanded = false,
selectedCategories = emptyList(),
categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
VocabularyFilter(3, "Filters", languages = listOf(1, 2)),
)
),
// Collapsed state - one category selected
CategoryDropdownState(
expanded = false,
selectedCategories = listOf(TagCategory(1, "Animals")),
categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
TagCategory(3, "Travel"),
)
),
// Collapsed state - multiple categories selected
CategoryDropdownState(
expanded = false,
selectedCategories = listOf(
TagCategory(1, "Animals"),
TagCategory(3, "Travel"),
),
categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
TagCategory(3, "Travel"),
)
),
// Expanded state - nothing selected
CategoryDropdownState(
expanded = true,
selectedCategories = emptyList(),
categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
TagCategory(3, "Travel"),
)
),
// Expanded state - one selected
CategoryDropdownState(
expanded = true,
selectedCategories = listOf(TagCategory(2, "Food")),
categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
TagCategory(3, "Travel"),
)
),
// With "None" option selected
CategoryDropdownState(
expanded = true,
selectedCategories = listOf(null),
categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
)
),
// With add category option
CategoryDropdownState(
expanded = true,
selectedCategories = emptyList(),
newCategoryName = "New Cat",
categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
)
),
)
}
@ThemePreviews
@Preview(showBackground = true)
@Composable
fun CategoryDropdownCollapsedPreview(
@PreviewParameter(CategoryDropdownStateProvider::class) state: CategoryDropdownState
) {
Surface(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
CategoryDropdownContent(
state = state.copy(expanded = false),
onExpand = {},
onCategorySelected = {},
onNewCategoryNameChange = {},
onAddCategory = {},
)
}
}
@ThemePreviews
@Preview(showBackground = true)
@Composable
fun CategoryDropdownExpandedPreview(
@PreviewParameter(CategoryDropdownStateProvider::class) state: CategoryDropdownState
) {
Surface(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
CategoryDropdownContent(
state = state.copy(expanded = true),
onExpand = {},
onCategorySelected = {},
onNewCategoryNameChange = {},
onAddCategory = {},
)
}
}
@Suppress("HardCodedStringLiteral")
@ThemePreviews
@Preview(showBackground = true)
@Composable
fun CategoryDropdownMultipleSelectionPreview() {
val categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
TagCategory(3, "Travel"),
TagCategory(4, "Business"),
TagCategory(5, "Technology"),
)
var selectedCategories by remember {
mutableStateOf<List<VocabularyCategory?>>(listOf(categories[0], categories[2]))
}
Surface(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = true,
selectedCategories = selectedCategories,
categories = categories,
),
onExpand = {},
onCategorySelected = { selectedCategories = it },
onNewCategoryNameChange = {},
onAddCategory = {},
multipleSelectable = true,
noneSelectable = true,
)
}
}
@Suppress("HardCodedStringLiteral")
@ThemePreviews
@Preview(showBackground = true)
@Composable
fun CategoryDropdownWithAddCategoryPreview() {
val categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
)
var newCategoryName by remember { mutableStateOf("New Category") }
Surface(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = true,
selectedCategories = emptyList(),
newCategoryName = newCategoryName,
categories = categories,
),
onExpand = {},
onCategorySelected = {},
onNewCategoryNameChange = { newCategoryName = it },
onAddCategory = {},
addCategory = true,
)
}
}
@Suppress("HardCodedStringLiteral")
@ThemePreviews
@Preview(showBackground = true)
@Composable
fun CategoryDropdownOnlyListsPreview() {
val categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
VocabularyFilter(3, "Language Pair EN-DE", languages = listOf(1, 2)),
TagCategory(4, "Travel"),
)
Surface(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = true,
selectedCategories = emptyList(),
categories = categories,
),
onExpand = {},
onCategorySelected = {},
onNewCategoryNameChange = {},
onAddCategory = {},
onlyLists = true,
)
}
}
@Suppress("HardCodedStringLiteral")
@ThemePreviews
@Preview(showBackground = true)
@Composable
fun CategoryDropdownNoNoneOptionPreview() {
val categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
)
Surface(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = true,
selectedCategories = emptyList(),
categories = categories,
),
onExpand = {},
onCategorySelected = {},
onNewCategoryNameChange = {},
onAddCategory = {},
noneSelectable = false,
)
}
}
@ThemePreviews
@Preview(showBackground = true)
@Composable
fun CategoryDropdownEmptyPreview() {
Surface(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = false,
selectedCategories = emptyList(),
categories = emptyList(),
),
onExpand = {},
onCategorySelected = {},
onNewCategoryNameChange = {},
onAddCategory = {},
)
}
}
@Suppress("HardCodedStringLiteral")
@ThemePreviews
@Preview(showBackground = true)
@Composable
fun CategoryDropdownStatefulPreview() {
var expanded by remember { mutableStateOf(true) }
var selectedCategories by remember {
mutableStateOf<List<VocabularyCategory?>>(listOf(TagCategory(1, "Animals")))
}
Surface(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = expanded,
selectedCategories = selectedCategories,
categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
TagCategory(3, "Travel"),
),
),
onExpand = { expanded = it },
onCategorySelected = { selectedCategories = it },
onNewCategoryNameChange = {},
onAddCategory = {},
multipleSelectable = true,
noneSelectable = true,
)
}
}
@Suppress("HardCodedStringLiteral")
@ThemePreviews
@Preview(showBackground = true)
@Composable
fun CategoryDropdownFullExpandedPreview() {
Surface(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = true,
selectedCategories = emptyList(),
categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
TagCategory(3, "Travel"),
TagCategory(4, "Business"),
TagCategory(5, "Technology"),
TagCategory(6, "Sports"),
TagCategory(7, "Music"),
TagCategory(8, "Art"),
),
),
onExpand = {},
onCategorySelected = {},
onNewCategoryNameChange = {},
onAddCategory = {},
addCategory = true,
multipleSelectable = true,
noneSelectable = true,
)
}
}

View File

@@ -6,40 +6,65 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.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
import eu.gaudian.translator.view.composable.DialogButton
import eu.gaudian.translator.viewmodel.CategoryViewModel
@Composable
fun CategorySelectionDialog(
onCategorySelected: (List<VocabularyCategory?>) -> Unit,
onDismissRequest: () -> Unit,
) {
var selectedCategory by remember { mutableStateOf<List<VocabularyCategory?>>(emptyList()) }
val activity = LocalContext.current.findActivity()
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
AppDialog(onDismissRequest = onDismissRequest, title = {
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,
title = {
Text(text = stringResource(R.string.text_select_categories))
}) {
CategoryDropdown(
onCategorySelected = { categories ->
selectedCategory = categories
}
) {
// 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 = ""
},
noneSelectable = false,
multipleSelectable = true,
onlyLists = true,
addCategory = true
addCategory = true,
modifier = Modifier.fillMaxWidth(),
)
Row(
@@ -54,10 +79,10 @@ fun CategorySelectionDialog(
DialogButton(
onClick = {
onCategorySelected(selectedCategory)
onCategorySelected(selectedCategories)
onDismissRequest()
},
enabled = true
enabled = selectedCategories.isNotEmpty()
) {
Text(stringResource(R.string.label_confirm))
}

View File

@@ -9,6 +9,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -27,6 +28,7 @@ import eu.gaudian.translator.model.VocabularyStage
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppDialog
import eu.gaudian.translator.view.composable.MultipleLanguageDropdown
import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel
import kotlinx.coroutines.launch
@@ -42,12 +44,20 @@ fun StartExerciseDialog(
) {
val activity = LocalContext.current.findActivity()
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val vocabularyViewModel : VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
val coroutineScope = rememberCoroutineScope()
val categories by categoryViewModel.categories.collectAsState(initial = emptyList())
var lids by remember { mutableStateOf<List<Int>>(emptyList()) }
var languages by remember { mutableStateOf<List<Language>>(emptyList()) }
// 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) }
LaunchedEffect(Unit) {
coroutineScope.launch {
@@ -59,9 +69,6 @@ fun StartExerciseDialog(
languageIdMap = languages.mapIndexed { index, lang -> lang to lids.getOrNull(index) }.filter { it.second != null }.associate { it.first to it.second!! }
}
}
var selectedLanguages by remember { mutableStateOf<List<Language>>(emptyList()) }
var selectedCategories by remember { mutableStateOf<List<VocabularyCategory>>(emptyList()) }
var selectedStages by remember { mutableStateOf<List<VocabularyStage>>(emptyList()) }
AppDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.label_start_exercise)) }) {
@@ -80,11 +87,23 @@ fun StartExerciseDialog(
},
languages
)
CategoryDropdown(
onCategorySelected = { categories ->
selectedCategories = categories.filterIsInstance<VocabularyCategory>()
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = expanded,
selectedCategories = selectedCategories,
newCategoryName = "",
categories = categories,
),
onExpand = { isExpanded -> expanded = isExpanded },
onCategorySelected = { cats ->
selectedCategories = cats.filterIsInstance<VocabularyCategory>()
},
multipleSelectable = true
onNewCategoryNameChange = {},
onAddCategory = {},
multipleSelectable = true,
onlyLists = false, // Show both filters and lists
addCategory = false,
modifier = Modifier.fillMaxWidth(),
)
VocabularyStageDropDown(
modifier = Modifier.fillMaxWidth(),

View File

@@ -27,6 +27,7 @@ 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
@@ -35,6 +36,7 @@ import eu.gaudian.translator.view.composable.AppCheckbox
import eu.gaudian.translator.view.composable.AppScaffold
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.hints.getVocabularyReviewHint
import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel
@Composable
@@ -43,12 +45,17 @@ fun VocabularyReviewScreen(
onCancel: () -> Unit
) {
val activity = LocalContext.current.findActivity()
val vocabularyViewModel : VocabularyViewModel = hiltViewModel(activity)
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(activity)
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
val generatedItems: List<VocabularyItem> by vocabularyViewModel.generatedVocabularyItems.collectAsState()
val categories by categoryViewModel.categories.collectAsState(initial = emptyList())
val selectedItems = remember { mutableStateListOf<VocabularyItem>() }
val duplicates = remember { mutableStateListOf<Boolean>() }
var selectedCategoryId by remember { mutableStateOf<List<Int>>(emptyList()) }
LocalContext.current
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)
@@ -127,11 +134,28 @@ fun VocabularyReviewScreen(
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(8.dp)
)
CategoryDropdown(
onCategorySelected = { categories: List<VocabularyCategory?> ->
selectedCategoryId = categories.filterNotNull().map { it.id }
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 = ""
},
onlyLists = true
noneSelectable = false,
multipleSelectable = true,
onlyLists = true,
addCategory = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Row(
modifier = Modifier
@@ -143,9 +167,13 @@ fun VocabularyReviewScreen(
Text(stringResource(R.string.label_cancel))
}
Spacer(modifier = Modifier.width(8.dp))
AppButton(onClick = {
onConfirm(selectedItems.toList(), selectedCategoryId)
}) {
AppButton(
onClick = {
val selectedCategoryIds = selectedCategories.filterNotNull().map { it.id }
onConfirm(selectedItems.toList(), selectedCategoryIds)
},
enabled = selectedItems.isNotEmpty()
) {
Text(stringResource(R.string.label_add_, selectedItems.size))
}
}

View File

@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
@@ -28,6 +27,7 @@ import eu.gaudian.translator.R
import eu.gaudian.translator.model.VocabularyStage
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
@@ -76,7 +76,7 @@ fun VocabularyStageDropDown(
) {
if (noneSelectable == true) {
val noneSelected = selectedStages.contains(null)
DropdownMenuItem(
AppDropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (multipleSelectable) {
@@ -111,7 +111,7 @@ fun VocabularyStageDropDown(
VocabularyStage.entries.forEach { stage ->
val isSelected = selectedStages.contains(stage)
DropdownMenuItem(
AppDropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (multipleSelectable) {

View File

@@ -66,6 +66,7 @@ import eu.gaudian.translator.R
import eu.gaudian.translator.model.LanguageModel
import eu.gaudian.translator.model.communication.ApiProvider
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.ApiModelDropDown
import eu.gaudian.translator.view.composable.AppAlertDialog
import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppCard

View File

@@ -1,48 +1,28 @@
package eu.gaudian.translator.view.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.gaudian.translator.R
import eu.gaudian.translator.model.LanguageModel
import eu.gaudian.translator.model.communication.ApiProvider
import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.view.composable.ApiModelDropDown
import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedButton
import eu.gaudian.translator.view.composable.InspiringSearchField
import eu.gaudian.translator.view.composable.ModelBadges
data class PromptSettingsState(
val availableModels: List<LanguageModel> = emptyList(),
@@ -118,232 +98,7 @@ fun BasePromptSettingsScreen(
}
}
@Composable
fun ApiModelDropDown(
models: List<LanguageModel>,
providers: List<ApiProvider>,
selectedModel: LanguageModel?,
onModelSelected: (LanguageModel?) -> Unit,
enabled: Boolean = true
) {
LocalContext.current
var expanded by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") }
val activeModels = models.filter { model -> providers.any { it.key == model.providerKey && (it.hasValidKey || it.isCustom) } }
val groupedModels = activeModels.groupBy { it.providerKey }
val providerNames = remember(providers) { providers.associate { it.key to it.displayName } }
val providerStatuses = remember(providers) { providers.associate { it.key to (it.hasValidKey || it.isCustom) } }
val filteredGroupedModels = remember(groupedModels, searchQuery) {
if (searchQuery.isBlank()) {
groupedModels
} else {
groupedModels.mapValues { (_, models) ->
models.filter { model ->
model.displayName.contains(searchQuery, ignoreCase = true) ||
model.modelId.contains(searchQuery, ignoreCase = true) ||
model.description.contains(searchQuery, ignoreCase = true)
}
}.filterValues { it.isNotEmpty() }
}
}
Box {
AppOutlinedButton(
onClick = { expanded = true },
modifier = Modifier.align(Alignment.Center),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
enabled = enabled
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = selectedModel?.displayName ?: stringResource(R.string.text_select_model),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (selectedModel != null) {
Text(
text = providerNames[selectedModel.providerKey] ?: selectedModel.providerKey,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown,
contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand)
)
}
}
DropdownMenu(
modifier = Modifier
.fillMaxWidth(),
expanded = expanded,
onDismissRequest = { expanded = false }
) {
// Search bar
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
AppIcons.Search,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.width(8.dp))
TextField(
value = searchQuery,
onValueChange = { searchQuery = it },
placeholder = { Text(stringResource(R.string.label_search_models)) },
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
modifier = Modifier.weight(1f)
)
if (searchQuery.isNotBlank()) {
IconButton(
onClick = { searchQuery = "" },
modifier = Modifier.size(24.dp)
) {
Icon(
AppIcons.Close,
contentDescription = stringResource(R.string.cd_clear_search),
modifier = Modifier.size(16.dp)
)
}
}
}
HorizontalDivider()
}
if (filteredGroupedModels.isNotEmpty()) {
filteredGroupedModels.entries.forEachIndexed { index, entry ->
val providerKey = entry.key
val providerModels = entry.value
val isActive = providerStatuses[providerKey] == true
val providerName = providerNames[providerKey] ?: providerKey
if (index > 0) HorizontalDivider()
// Provider header
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = if (isActive) AppIcons.CheckCircle else AppIcons.Warning,
contentDescription = null,
tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = providerName,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Text(
text = stringResource(
R.string.labels_1d_models,
providerModels.size
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
},
enabled = false,
onClick = {}
)
// Models for this provider
providerModels.forEach { model ->
DropdownMenuItem(
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = model.displayName,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
fontWeight = if (model == selectedModel) FontWeight.Medium else FontWeight.Normal
)
Spacer(modifier = Modifier.width(8.dp))
ModelBadges(
modelDisplayOrId = model.displayName.ifBlank { model.modelId },
providerKey = model.providerKey,
)
}
if (model.description.isNotBlank()) {
Text(
text = model.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
},
onClick = {
onModelSelected(model)
expanded = false
searchQuery = ""
},
modifier = if (model == selectedModel) {
Modifier.background(
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
)
} else {
Modifier
}
)
}
}
} else if (searchQuery.isNotBlank()) {
DropdownMenuItem(
text = {
Text(
stringResource(R.string.text_no_models_found),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
modifier = Modifier.fillMaxWidth()
)
},
enabled = false,
onClick = {}
)
}
}
}
}

View File

@@ -29,6 +29,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import eu.gaudian.translator.R
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.ApiModelDropDown
import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedTextField