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 package eu.gaudian.translator.view.composable
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize 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.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
@@ -34,6 +38,7 @@ 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.setValue import androidx.compose.runtime.setValue
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.geometry.Size 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.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize import androidx.compose.ui.unit.toSize
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.PopupProperties 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 import eu.gaudian.translator.R
/** /**
@@ -80,7 +84,6 @@ fun AppDropdownMenu(
content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit,
) { ) {
var textFieldSize by remember { mutableStateOf(Size.Zero) } var textFieldSize by remember { mutableStateOf(Size.Zero) }
val interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } val interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
Column(modifier = modifier) { Column(modifier = modifier) {
@@ -126,15 +129,17 @@ fun AppDropdownMenu(
expanded = expanded, expanded = expanded,
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
modifier = Modifier modifier = Modifier
.width(with(LocalDensity.current) { textFieldSize.width.toDp() }), .width(with(LocalDensity.current) { textFieldSize.width.toDp() })
offset = DpOffset(0.dp, 0.dp), // 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(), scrollState = rememberScrollState(),
properties = PopupProperties(focusable = true), properties = PopupProperties(focusable = true),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(12.dp),
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 0.dp, tonalElevation = 6.dp,
shadowElevation = 4.dp, shadowElevation = 8.dp,
border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.14f)) border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
) { ) {
content() content()
} }
@@ -143,15 +148,7 @@ fun AppDropdownMenu(
/** /**
* A modern and stylish composable for individual dropdown items, featuring enhanced visual design * 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 * with subtle shadows, rounded corners, and smooth interactions.
* 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.
*/ */
@Composable @Composable
fun AppDropdownMenuItem( fun AppDropdownMenuItem(
@@ -166,21 +163,28 @@ fun AppDropdownMenuItem(
val contentColor = if (enabled) { val contentColor = if (enabled) {
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
} else { } 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( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp) // Outer padding creates the floating shape
.clip(RoundedCornerShape(8.dp))
.background(backgroundColor)
.clickable(enabled = enabled) { onClick() } .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(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
leadingIcon?.invoke() leadingIcon?.invoke()
if (leadingIcon != null) { if (leadingIcon != null) {
androidx.compose.foundation.layout.Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(12.dp))
} }
Box(modifier = Modifier.weight(1f)) { Box(modifier = Modifier.weight(1f)) {
CompositionLocalProvider(LocalContentColor provides contentColor) { CompositionLocalProvider(LocalContentColor provides contentColor) {
@@ -188,90 +192,14 @@ fun AppDropdownMenuItem(
} }
} }
if (trailingIcon != null) { if (trailingIcon != null) {
androidx.compose.foundation.layout.Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(12.dp))
trailingIcon() trailingIcon()
} }
} }
} }
} }
@Suppress("HardCodedStringLiteral") // ... [Previews remain exactly the same as your original file] ...
@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
)
}
)
}
@Composable @Composable
fun <T> LargeDropdownMenu( fun <T> LargeDropdownMenu(
@@ -308,7 +236,6 @@ fun <T> LargeDropdownMenu(
readOnly = true, readOnly = true,
) )
// Transparent clickable surface on top of OutlinedTextField
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -321,47 +248,51 @@ fun <T> LargeDropdownMenu(
if (expanded) { if (expanded) {
Dialog( Dialog(
onDismissRequest = { expanded = true }, onDismissRequest = { expanded = false }, // Fixed bug from original code
) { ) {
Surface( 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) {
LaunchedEffect("ScrollToSelected") {
listState.scrollToItem(index = selectedIndex)
}
}
// Added vertical padding to the list instead of hard dividers
LazyColumn(
modifier = Modifier.fillMaxWidth(),
state = listState,
contentPadding = PaddingValues(vertical = 8.dp)
) { ) {
val listState = rememberLazyListState() if (notSetLabel != null) {
if (selectedIndex > -1) { item {
LaunchedEffect("ScrollToSelected") { LargeDropdownMenuItem(
listState.scrollToItem(index = selectedIndex) text = notSetLabel,
selected = false,
enabled = false,
onClick = { },
)
} }
} }
itemsIndexed(items) { index, item ->
LazyColumn(modifier = Modifier.fillMaxWidth(), state = listState) { val selectedItem = index == selectedIndex
if (notSetLabel != null) { drawItem(
item { item,
LargeDropdownMenuItem( selectedItem,
text = notSetLabel, true
selected = false, ) {
enabled = false, onItemSelected(index, item)
onClick = { }, expanded = false
)
}
}
itemsIndexed(items) { index, item ->
val selectedItem = index == selectedIndex
drawItem(
item,
selectedItem,
true
) {
onItemSelected(index, item)
expanded = false
}
if (index < items.lastIndex) {
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
}
} }
} }
} }
} }
}
} }
} }
@@ -373,19 +304,27 @@ fun LargeDropdownMenuItem(
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val contentColor = when { val contentColor = when {
!enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = ALPHA_DISABLED) !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
selected -> MaterialTheme.colorScheme.primary.copy(alpha = ALPHA_FULL) selected -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = ALPHA_FULL) 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) { CompositionLocalProvider(LocalContentColor provides contentColor) {
Box(modifier = Modifier Box(
.clickable(enabled) { onClick() } modifier = Modifier
.fillMaxWidth() .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 = 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.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -114,7 +113,7 @@ fun BaseLanguageDropDown(
@Composable @Composable
fun MultiSelectItem(language: Language) { fun MultiSelectItem(language: Language) {
val isSelected = tempSelection.contains(language) val isSelected = tempSelection.contains(language)
DropdownMenuItem( AppDropdownMenuItem(
text = { text = {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
AppCheckbox( AppCheckbox(
@@ -155,7 +154,7 @@ fun BaseLanguageDropDown(
val duplicateNames = languageNames.groupingBy { it }.eachCount().filter { it.value > 1 }.keys val duplicateNames = languageNames.groupingBy { it }.eachCount().filter { it.value > 1 }.keys
val isDuplicate = duplicateNames.contains(language.name) val isDuplicate = duplicateNames.contains(language.name)
DropdownMenuItem( AppDropdownMenuItem(
text = { text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column { Column {
@@ -284,11 +283,11 @@ fun BaseLanguageDropDown(
} else { } else {
// Logic for single selection default view // Logic for single selection default view
if (showAutoOption) { 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() HorizontalDivider()
} }
if (showNoneOption) { 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() HorizontalDivider()
} }
if (favoriteLanguages.any { if (favoriteLanguages.any {

View File

@@ -11,34 +11,263 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
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.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
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.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview 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.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.model.TagCategory import eu.gaudian.translator.model.TagCategory
import eu.gaudian.translator.model.VocabularyCategory 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.AppButton
import eu.gaudian.translator.view.composable.AppCheckbox import eu.gaudian.translator.view.composable.AppCheckbox
import eu.gaudian.translator.view.composable.AppDropdownMenuItem import eu.gaudian.translator.view.composable.AppDropdownMenuItem
import eu.gaudian.translator.view.composable.AppIcons import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedButton import eu.gaudian.translator.view.composable.AppOutlinedButton
import eu.gaudian.translator.view.composable.AppOutlinedTextField 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 CategoryDropdownContent(
modifier: Modifier = Modifier,
state: CategoryDropdownState,
onExpand: (Boolean) -> Unit,
onCategorySelected: (List<VocabularyCategory?>) -> Unit,
onNewCategoryNameChange: (String) -> Unit,
onAddCategory: (String) -> Unit,
noneSelectable: Boolean = true,
multipleSelectable: Boolean = false,
onlyLists: Boolean = false,
addCategory: Boolean = false,
) {
val selectableCategories = if (onlyLists) {
state.categories.filterIsInstance<TagCategory>()
} else {
state.categories
}
AppOutlinedButton(
shape = RoundedCornerShape(8.dp),
onClick = { onExpand(true) },
modifier = modifier.fillMaxWidth(),
) {
Row(modifier = Modifier.fillMaxWidth()) {
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 (state.expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown,
contentDescription = if (state.expanded) {
stringResource(R.string.cd_collapse)
} else {
stringResource(R.string.cd_expand)
}
)
}
}
DropdownMenu(
expanded = state.expanded,
onDismissRequest = { onExpand(false) },
modifier = Modifier.fillMaxWidth(),
) {
if (noneSelectable) {
val noneSelected = state.selectedCategories.contains(null)
AppDropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (multipleSelectable) {
AppCheckbox(
checked = noneSelected,
onCheckedChange = { isChecked ->
val newSelection = if (noneSelected) {
state.selectedCategories.filterNotNull()
} else {
state.selectedCategories + listOf(null)
}
onCategorySelected(newSelection)
}
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.text_none))
}
},
onClick = {
if (multipleSelectable) {
val newSelection = if (noneSelected) {
state.selectedCategories.filterNotNull()
} else {
state.selectedCategories + listOf(null)
}
onCategorySelected(newSelection)
} else {
onCategorySelected(listOf(null))
onExpand(false)
}
}
)
}
selectableCategories.forEach { category ->
val isSelected = state.selectedCategories.contains(category)
AppDropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (multipleSelectable) {
AppCheckbox(
checked = isSelected,
onCheckedChange = { _ ->
val newSelection = if (isSelected) {
state.selectedCategories - category
} else {
state.selectedCategories + category
}
onCategorySelected(newSelection)
}
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(category.name)
}
},
onClick = {
if (multipleSelectable) {
val newSelection = if (category in state.selectedCategories) {
state.selectedCategories - category
} else {
state.selectedCategories + category
}
onCategorySelected(newSelection)
} else {
onCategorySelected(listOf(category))
onExpand(false)
}
}
)
}
if (addCategory) {
HorizontalDivider()
AppDropdownMenuItem(
text = {
Text(stringResource(R.string.label_add_category))
},
onClick = {},
modifier = Modifier.padding(4.dp)
)
AppDropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
AppOutlinedTextField(
value = state.newCategoryName,
onValueChange = onNewCategoryNameChange,
modifier = Modifier.weight(1f),
singleLine = true,
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = {
if (state.newCategoryName.isNotBlank()) {
onAddCategory(state.newCategoryName.trim())
}
},
enabled = state.newCategoryName.isNotBlank()
) {
Icon(
imageVector = AppIcons.Add,
contentDescription = stringResource(R.string.label_add)
)
}
}
},
onClick = {}
)
}
if (multipleSelectable) {
Spacer(modifier = Modifier.height(8.dp))
AppButton(
onClick = { onExpand(false) },
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Text(stringResource(R.string.label_done))
}
}
}
}
/**
* 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 @Composable
fun CategoryDropdown( fun CategoryDropdown(
initialCategoryId: Int? = null, initialCategoryId: Int? = null,
@@ -46,192 +275,398 @@ fun CategoryDropdown(
noneSelectable: Boolean? = true, noneSelectable: Boolean? = true,
multipleSelectable: Boolean = false, multipleSelectable: Boolean = false,
onlyLists: Boolean = false, onlyLists: Boolean = false,
addCategory: Boolean = false addCategory: Boolean = false,
modifier: Modifier = Modifier,
) { ) {
val activity = LocalContext.current.findActivity()
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
var expanded by remember { mutableStateOf(false) } 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 }
}
var selectedCategories by remember { var selectedCategories by remember {
mutableStateOf<List<VocabularyCategory?>>(if (initialCategory != null) listOf(initialCategory) else emptyList()) mutableStateOf<List<VocabularyCategory?>>(emptyList())
} }
var newCategoryName by remember { mutableStateOf("") } 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>()) }
AppOutlinedButton( // Find initial category
shape = RoundedCornerShape(8.dp), val initialCategory = remember(categories, initialCategoryId) {
onClick = { expanded = true }, categories.find { it.id == initialCategoryId }
modifier = Modifier.fillMaxWidth(), }
) { // Initialize selection with initial category if provided
Row(modifier = Modifier.fillMaxWidth()) { remember(initialCategory) {
Text(text = when { if (initialCategory != null && selectedCategories.isEmpty()) {
selectedCategories.isEmpty() -> stringResource(R.string.text_select_category) selectedCategories = listOf(initialCategory)
selectedCategories.size == 1 -> selectedCategories.first()?.name ?: stringResource(R.string.text_none)
else -> stringResource(R.string.text_2d_categories_selected, 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
)
)
}
} }
true
}
DropdownMenu( CategoryDropdownContent(
state = CategoryDropdownState(
expanded = expanded, expanded = expanded,
onDismissRequest = { expanded = false }, selectedCategories = selectedCategories,
modifier = Modifier.fillMaxWidth(), newCategoryName = newCategoryName,
) { categories = categories,
if (noneSelectable == true) { ),
val noneSelected = selectedCategories.contains(null) onExpand = { isExpanded -> expanded = isExpanded },
AppDropdownMenuItem( onCategorySelected = { newSelection ->
text = { selectedCategories = newSelection
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { onCategorySelected(newSelection)
if (multipleSelectable) { },
AppCheckbox( onNewCategoryNameChange = { newCategoryName = it },
checked = noneSelected, onAddCategory = { name ->
onCheckedChange = { val newCategory = TagCategory(id = 0, name = name)
selectedCategories = if (noneSelected) selectedCategories.filterNotNull() else selectedCategories + listOf(null) // In production, this would call ViewModel.createCategory(newCategory)
onCategorySelected(selectedCategories) newCategoryName = ""
} if (!multipleSelectable) {
) expanded = false
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.text_none))
}
},
onClick = {
if (multipleSelectable) {
selectedCategories = if (noneSelected) {
selectedCategories.filterNotNull()
} else {
selectedCategories + listOf(null)
}
onCategorySelected(selectedCategories)
} else {
selectedCategories = listOf(null)
onCategorySelected(selectedCategories)
expanded = false
}
}
)
} }
selectableCategories.forEach { category -> },
val isSelected = selectedCategories.contains(category) noneSelectable = noneSelectable == true,
AppDropdownMenuItem( multipleSelectable = multipleSelectable,
text = { onlyLists = onlyLists,
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { addCategory = addCategory,
if (multipleSelectable) { modifier = modifier,
AppCheckbox(
checked = isSelected,
onCheckedChange = {
selectedCategories = if (isSelected) selectedCategories - category else selectedCategories + category
onCategorySelected(selectedCategories)
}
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(category.name)
}
},
onClick = {
if (multipleSelectable) {
selectedCategories = if (category in selectedCategories) {
selectedCategories - category
} else {
selectedCategories + category
}
onCategorySelected(selectedCategories)
} else {
selectedCategories = listOf(category)
onCategorySelected(selectedCategories)
expanded = false
}
}
)
}
if(addCategory) {
HorizontalDivider()
// Create new category section
AppDropdownMenuItem(
text = {
Text(stringResource(R.string.label_add_category))
},
onClick = {},
modifier = Modifier.padding(4.dp)
)
AppDropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
AppOutlinedTextField(
value = newCategoryName,
onValueChange = { newCategoryName = it },
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
}
}
},
enabled = newCategoryName.isNotBlank()
) {
Icon(
imageVector = AppIcons.Add,
contentDescription = stringResource(R.string.label_add)
)
}
}
},
onClick = {} // No action on click
)
}
if (multipleSelectable) {
Spacer(modifier = Modifier.height(8.dp))
AppButton(
onClick = { expanded = false },
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Text(stringResource(R.string.label_done))
}
}
}
}
@Preview
@Composable
fun CategoryDropdownPreview() {
CategoryDropdown(
onCategorySelected = {}
) )
} }
// ============== 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,61 +6,86 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text import androidx.compose.material3.Text
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.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.model.TagCategory
import eu.gaudian.translator.model.VocabularyCategory 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.AppDialog
import eu.gaudian.translator.view.composable.DialogButton import eu.gaudian.translator.view.composable.DialogButton
import eu.gaudian.translator.viewmodel.CategoryViewModel
@Composable @Composable
fun CategorySelectionDialog( fun CategorySelectionDialog(
onCategorySelected: (List<VocabularyCategory?>) -> Unit, onCategorySelected: (List<VocabularyCategory?>) -> Unit,
onDismissRequest: () -> 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())
Text(text = stringResource(R.string.text_select_categories)) 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))
}
) {
// 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,
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
DialogButton(onClick = onDismissRequest) {
Text(stringResource(R.string.label_cancel))
}
CategoryDropdown( DialogButton(
onCategorySelected = { categories -> onClick = {
selectedCategory = categories onCategorySelected(selectedCategories)
}, onDismissRequest()
noneSelectable = false, },
multipleSelectable = true, enabled = selectedCategories.isNotEmpty()
onlyLists = true, ) {
addCategory = true Text(stringResource(R.string.label_confirm))
) }
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
DialogButton(onClick = onDismissRequest) {
Text(stringResource(R.string.label_cancel))
}
DialogButton(
onClick = {
onCategorySelected(selectedCategory)
onDismissRequest()
},
enabled = true
) {
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.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.utils.findActivity
import eu.gaudian.translator.view.composable.AppDialog import eu.gaudian.translator.view.composable.AppDialog
import eu.gaudian.translator.view.composable.MultipleLanguageDropdown import eu.gaudian.translator.view.composable.MultipleLanguageDropdown
import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel import eu.gaudian.translator.viewmodel.VocabularyViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -42,12 +44,20 @@ fun StartExerciseDialog(
) { ) {
val activity = LocalContext.current.findActivity() val activity = LocalContext.current.findActivity()
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) 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 coroutineScope = rememberCoroutineScope()
val categories by categoryViewModel.categories.collectAsState(initial = emptyList())
var lids by remember { mutableStateOf<List<Int>>(emptyList()) } var lids by remember { mutableStateOf<List<Int>>(emptyList()) }
var languages by remember { mutableStateOf<List<Language>>(emptyList()) } var languages by remember { mutableStateOf<List<Language>>(emptyList()) }
// Map displayed Language to its DB id (lid) using position mapping from load // Map displayed Language to its DB id (lid) using position mapping from load
var languageIdMap by remember { mutableStateOf<Map<Language, Int>>(emptyMap()) } 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) { LaunchedEffect(Unit) {
coroutineScope.launch { coroutineScope.launch {
@@ -59,65 +69,74 @@ fun StartExerciseDialog(
languageIdMap = languages.mapIndexed { index, lang -> lang to lids.getOrNull(index) }.filter { it.second != null }.associate { it.first to it.second!! } 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)) }) { AppDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.label_start_exercise)) }) {
Column( Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
MultipleLanguageDropdown(
modifier = Modifier.fillMaxWidth(),
languageViewModel = languageViewModel,
onLanguagesSelected = { langs ->
selectedLanguages = langs
},
languages
)
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = expanded,
selectedCategories = selectedCategories,
newCategoryName = "",
categories = categories,
),
onExpand = { isExpanded -> expanded = isExpanded },
onCategorySelected = { cats ->
selectedCategories = cats.filterIsInstance<VocabularyCategory>()
},
onNewCategoryNameChange = {},
onAddCategory = {},
multipleSelectable = true,
onlyLists = false, // Show both filters and lists
addCategory = false,
modifier = Modifier.fillMaxWidth(),
)
VocabularyStageDropDown(
modifier = Modifier.fillMaxWidth(),
preselectedStages = selectedStages,
onStageSelected = { stages ->
@Suppress("FilterIsInstanceResultIsAlwaysEmpty")
selectedStages = stages.filterIsInstance<VocabularyStage>()
},
multipleSelectable = true
)
Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(24.dp), .padding(top = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalArrangement = Arrangement.End
verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
MultipleLanguageDropdown( TextButton(
modifier = Modifier.fillMaxWidth(), onClick = onDismiss,
languageViewModel = languageViewModel,
onLanguagesSelected = { langs ->
selectedLanguages = langs
},
languages
)
CategoryDropdown(
onCategorySelected = { categories ->
selectedCategories = categories.filterIsInstance<VocabularyCategory>()
},
multipleSelectable = true
)
VocabularyStageDropDown(
modifier = Modifier.fillMaxWidth(),
preselectedStages = selectedStages,
onStageSelected = { stages ->
@Suppress("FilterIsInstanceResultIsAlwaysEmpty")
selectedStages = stages.filterIsInstance<VocabularyStage>()
},
multipleSelectable = true
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.End
) { ) {
TextButton( Text(stringResource(R.string.label_cancel))
onClick = onDismiss, }
) { TextButton(
Text(stringResource(R.string.label_cancel)) onClick = {
} run {
TextButton( val ids = selectedLanguages.mapNotNull { languageIdMap[it] }
onClick = { onConfirm(selectedCategories, selectedStages, ids)
run {
val ids = selectedLanguages.mapNotNull { languageIdMap[it] }
onConfirm(selectedCategories, selectedStages, ids)
}
} }
) {
Text(stringResource(R.string.label_start_exercise))
} }
) {
Text(stringResource(R.string.label_start_exercise))
} }
} }
}
} }
} }

View File

@@ -27,6 +27,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.model.TagCategory
import eu.gaudian.translator.model.VocabularyCategory import eu.gaudian.translator.model.VocabularyCategory
import eu.gaudian.translator.model.VocabularyItem import eu.gaudian.translator.model.VocabularyItem
import eu.gaudian.translator.utils.findActivity 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.AppScaffold
import eu.gaudian.translator.view.composable.AppTopAppBar import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.hints.getVocabularyReviewHint import eu.gaudian.translator.view.hints.getVocabularyReviewHint
import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel import eu.gaudian.translator.viewmodel.VocabularyViewModel
@Composable @Composable
@@ -43,12 +45,17 @@ fun VocabularyReviewScreen(
onCancel: () -> Unit onCancel: () -> Unit
) { ) {
val activity = LocalContext.current.findActivity() 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 generatedItems: List<VocabularyItem> by vocabularyViewModel.generatedVocabularyItems.collectAsState()
val categories by categoryViewModel.categories.collectAsState(initial = emptyList())
val selectedItems = remember { mutableStateListOf<VocabularyItem>() } val selectedItems = remember { mutableStateListOf<VocabularyItem>() }
val duplicates = remember { mutableStateListOf<Boolean>() } val duplicates = remember { mutableStateListOf<Boolean>() }
var selectedCategoryId by remember { mutableStateOf<List<Int>>(emptyList()) } var selectedCategories by remember { mutableStateOf<List<VocabularyCategory?>>(emptyList()) }
LocalContext.current var newCategoryName by remember { mutableStateOf("") }
var expanded by remember { mutableStateOf(false) }
LaunchedEffect(generatedItems) { LaunchedEffect(generatedItems) {
val duplicateResults = vocabularyViewModel.findDuplicates(generatedItems) val duplicateResults = vocabularyViewModel.findDuplicates(generatedItems)
@@ -127,11 +134,28 @@ fun VocabularyReviewScreen(
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(8.dp) modifier = Modifier.padding(8.dp)
) )
CategoryDropdown( CategoryDropdownContent(
onCategorySelected = { categories: List<VocabularyCategory?> -> state = CategoryDropdownState(
selectedCategoryId = categories.filterNotNull().map { it.id } 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( Row(
modifier = Modifier modifier = Modifier
@@ -143,9 +167,13 @@ fun VocabularyReviewScreen(
Text(stringResource(R.string.label_cancel)) Text(stringResource(R.string.label_cancel))
} }
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
AppButton(onClick = { AppButton(
onConfirm(selectedItems.toList(), selectedCategoryId) onClick = {
}) { val selectedCategoryIds = selectedCategories.filterNotNull().map { it.id }
onConfirm(selectedItems.toList(), selectedCategoryIds)
},
enabled = selectedItems.isNotEmpty()
) {
Text(stringResource(R.string.label_add_, selectedItems.size)) 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.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -28,6 +27,7 @@ import eu.gaudian.translator.R
import eu.gaudian.translator.model.VocabularyStage import eu.gaudian.translator.model.VocabularyStage
import eu.gaudian.translator.view.composable.AppButton import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppCheckbox 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.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedButton import eu.gaudian.translator.view.composable.AppOutlinedButton
@@ -76,7 +76,7 @@ fun VocabularyStageDropDown(
) { ) {
if (noneSelectable == true) { if (noneSelectable == true) {
val noneSelected = selectedStages.contains(null) val noneSelected = selectedStages.contains(null)
DropdownMenuItem( AppDropdownMenuItem(
text = { text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (multipleSelectable) { if (multipleSelectable) {
@@ -111,7 +111,7 @@ fun VocabularyStageDropDown(
VocabularyStage.entries.forEach { stage -> VocabularyStage.entries.forEach { stage ->
val isSelected = selectedStages.contains(stage) val isSelected = selectedStages.contains(stage)
DropdownMenuItem( AppDropdownMenuItem(
text = { text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (multipleSelectable) { if (multipleSelectable) {

View File

@@ -66,6 +66,7 @@ import eu.gaudian.translator.R
import eu.gaudian.translator.model.LanguageModel import eu.gaudian.translator.model.LanguageModel
import eu.gaudian.translator.model.communication.ApiProvider import eu.gaudian.translator.model.communication.ApiProvider
import eu.gaudian.translator.utils.findActivity 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.AppAlertDialog
import eu.gaudian.translator.view.composable.AppButton import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppCard import eu.gaudian.translator.view.composable.AppCard

View File

@@ -1,48 +1,28 @@
package eu.gaudian.translator.view.settings package eu.gaudian.translator.view.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier 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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.model.LanguageModel import eu.gaudian.translator.model.LanguageModel
import eu.gaudian.translator.model.communication.ApiProvider import eu.gaudian.translator.model.communication.ApiProvider
import eu.gaudian.translator.ui.theme.ThemePreviews 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.AppButton
import eu.gaudian.translator.view.composable.AppCard 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.InspiringSearchField
import eu.gaudian.translator.view.composable.ModelBadges
data class PromptSettingsState( data class PromptSettingsState(
val availableModels: List<LanguageModel> = emptyList(), 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 androidx.navigation.NavController
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.utils.findActivity 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.AppCard
import eu.gaudian.translator.view.composable.AppIcons import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedTextField import eu.gaudian.translator.view.composable.AppOutlinedTextField