Step 1 in unifying dropdowns

This commit is contained in:
jonasgaudian
2026-02-15 16:01:08 +01:00
parent a715ab78e9
commit 2e0fe76fbf
10 changed files with 1025 additions and 986 deletions

View File

@@ -1,22 +1,19 @@
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.heightIn
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.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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
@@ -24,8 +21,6 @@ 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
@@ -42,7 +37,6 @@ fun ApiModelDropDown(
onModelSelected: (LanguageModel?) -> Unit,
enabled: Boolean = true
) {
LocalContext.current
var expanded by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") }
@@ -65,92 +59,58 @@ fun ApiModelDropDown(
}
}
Box {
AppOutlinedButton(
onClick = { expanded = true },
modifier = Modifier.align(Alignment.Center),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
enabled = enabled
// Custom button content showing selected model and provider
val buttonContent: @Composable () -> Unit = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
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 = selectedModel?.displayName ?: stringResource(R.string.text_select_model),
style = MaterialTheme.typography.bodyMedium,
text = providerNames[selectedModel.providerKey] ?: selectedModel.providerKey,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
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)
)
}
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(
AppDropdownContainer(
expanded = expanded,
onDismissRequest = {
expanded = false
searchQuery = ""
},
onExpandRequest = { expanded = true },
buttonText = "", // Not used with custom button content
modifier = Modifier.fillMaxWidth(),
enabled = enabled,
showSearch = true,
searchQuery = searchQuery,
onSearchQueryChange = { searchQuery = it },
searchPlaceholder = stringResource(R.string.label_search_models),
buttonContent = buttonContent
) {
Column(
modifier = Modifier
.fillMaxWidth(),
expanded = expanded,
onDismissRequest = { expanded = false }
.heightIn(max = 400.dp)
.verticalScroll(rememberScrollState())
) {
// 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
@@ -158,7 +118,7 @@ fun ApiModelDropDown(
val isActive = providerStatuses[providerKey] == true
val providerName = providerNames[providerKey] ?: providerKey
if (index > 0) HorizontalDivider()
if (index > 0) HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Provider header
AppDropdownMenuItem(

View File

@@ -2,11 +2,14 @@
package eu.gaudian.translator.view.composable
import androidx.compose.foundation.BorderStroke
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -14,22 +17,30 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
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.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@@ -48,6 +59,8 @@ 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.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
@@ -56,100 +69,362 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.PopupProperties
import eu.gaudian.translator.R
/**
* A modern, custom dropdown menu composable that provides a styled text field with a dropdown list of options.
* This implementation uses a custom dropdown for a more tailored look compared to the stock menu, behaving like a normal ExposedDropdownMenu.
* Allows managing selection and expansion, making it a convenient wrapper for dropdowns.
*
* @param expanded Whether the dropdown menu is expanded.
* @param onDismissRequest Callback invoked when the dropdown menu should be dismissed.
* @param modifier Modifier for the composable.
* @param label Composable for the label displayed in the text field.
* @param enabled Whether the dropdown is enabled.
* @param placeholder Optional placeholder text when no option is selected.
* @param selectedText The text to display in the text field for the selected option.
* @param onExpandRequest Callback invoked when the dropdown should expand.
* @param content Composable content for the dropdown items, typically using AppDropdownMenuItem.
*/
@Composable
fun AppDropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
label: @Composable (() -> Unit)? = null,
enabled: Boolean = true,
placeholder: @Composable (() -> Unit)? = null,
selectedText: String = "",
onExpandRequest: () -> Unit = {},
content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit,
) {
var textFieldSize by remember { mutableStateOf(Size.Zero) }
val interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
// =========================================
// UNIFIED DROPDOWN STYLES & CONSTANTS
// =========================================
Column(modifier = modifier) {
OutlinedTextField(
value = selectedText,
onValueChange = {},
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned { coordinates ->
textFieldSize = coordinates.size.toSize()
}
.clickable(
enabled = enabled,
onClick = onExpandRequest,
interactionSource = interactionSource,
indication = null
),
readOnly = true,
label = label,
placeholder = placeholder,
trailingIcon = {
val icon = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown
Icon(
imageVector = icon,
contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
},
shape = ComponentDefaults.DefaultShape,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = ComponentDefaults.ALPHA_LOW),
focusedLabelColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
disabledBorderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = ComponentDefaults.ALPHA_LOW),
disabledLabelColor = MaterialTheme.colorScheme.onSurface.copy(alpha = ComponentDefaults.ALPHA_MEDIUM)
),
enabled = enabled,
interactionSource = interactionSource
)
object DropdownDefaults {
val shape = RoundedCornerShape(8.dp)
val itemPaddingHorizontal = 8.dp
val itemPaddingVertical = 2.dp
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
modifier = Modifier
.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(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()
@Composable
fun containerColor(): Color = MaterialTheme.colorScheme.surface
@Composable
fun itemBackground(selected: Boolean): Color {
return if (selected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f)
} else {
Color.Transparent
}
}
@Composable
fun itemContentColor(selected: Boolean, enabled: Boolean): Color {
return when {
!enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
selected -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurface
}
}
}
/**
* A modern and stylish composable for individual dropdown items, featuring enhanced visual design
* with subtle shadows, rounded corners, and smooth interactions.
* A drop-in replacement for [androidx.compose.material3.DropdownMenu] that opens
* as a BottomSheet. Compatible with the standard M3 signature.
*/
@Suppress("unused", "HardCodedStringLiteral")
@Composable
fun AppDropDownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
offset: DpOffset = DpOffset(0.dp, 0.dp), // Retained for signature compatibility
scrollState: ScrollState = rememberScrollState(),
properties: PopupProperties = PopupProperties(focusable = true), // Retained for signature compatibility
content: @Composable ColumnScope.() -> Unit
) {
if (expanded) {
// skipPartiallyExpanded = true ensures it behaves more like a menu
// (fully open or completely closed) rather than a peekable sheet.
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = sheetState,
// Container color, shape, etc., can be linked to your DropdownDefaults here if needed.
) {
Column(
modifier = modifier
.fillMaxWidth()
.verticalScroll(scrollState)
) {
// Execute standard DropdownMenuItems here
content()
// Extra padding to ensure the last item isn't hidden behind the system navigation bar
Spacer(modifier = Modifier.navigationBarsPadding().height(16.dp))
}
}
}
}
// =========================================
// UNIFIED DROPDOWN CONTAINER
// =========================================
/**
* A unified dropdown container that provides consistent styling and behavior
* for all dropdown menus in the app.
*
* @param expanded Whether the dropdown is currently expanded
* @param onDismissRequest Callback when the dropdown should be dismissed
* @param onExpandRequest Callback when the dropdown should expand (click on button)
* @param buttonText The text to display on the dropdown button
* @param modifier Modifier for the container
* @param enabled Whether the dropdown is enabled
* @param showSearch Whether to show the search field at the top of the dropdown
* @param searchQuery Current search query (only used if showSearch is true)
* @param onSearchQueryChange Callback when search query changes (only used if showSearch is true)
* @param searchPlaceholder Placeholder text for search field
* @param showDoneButton Whether to show a "Done" button at the bottom (for multi-select)
* @param onDoneClick Callback when Done button is clicked
* @param buttonContent Custom content for the button (if null, uses default text-based button)
* @param dropdownContent Content to display inside the dropdown menu
*/
@Composable
fun AppDropdownContainer(
expanded: Boolean,
onDismissRequest: () -> Unit,
onExpandRequest: () -> Unit,
buttonText: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
showSearch: Boolean = false,
searchQuery: String = "",
onSearchQueryChange: ((String) -> Unit)? = null,
searchPlaceholder: String? = null,
showDoneButton: Boolean = false,
onDoneClick: (() -> Unit)? = null,
buttonContent: @Composable (() -> Unit)? = null,
dropdownContent: @Composable ColumnScope.() -> Unit
) {
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.TopCenter
) {
// Dropdown Button
if (buttonContent != null) {
AppOutlinedButton(
onClick = onExpandRequest,
modifier = Modifier.fillMaxWidth(),
enabled = enabled
) {
buttonContent()
}
} else {
AppOutlinedButton(
shape = DropdownDefaults.shape,
onClick = onExpandRequest,
modifier = Modifier.fillMaxWidth(),
enabled = enabled
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = buttonText,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Icon(
imageVector = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown,
contentDescription = if (expanded)
stringResource(R.string.cd_collapse)
else
stringResource(R.string.cd_expand)
)
}
}
}
// Bottom Sheet "Dropdown" Menu
if (expanded) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = sheetState,
containerColor = DropdownDefaults.containerColor()
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
// Pinned Search field (optional)
if (showSearch && onSearchQueryChange != null) {
DropdownSearchField(
searchQuery = searchQuery,
onSearchQueryChange = onSearchQueryChange,
placeholder = {
Text(searchPlaceholder ?: stringResource(R.string.text_search))
}
)
HorizontalDivider()
}
// Scrollable Content
// Weight ensures this takes up available space without pushing
// the done button off-screen if the list is very long.
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f, fill = false)
.verticalScroll(rememberScrollState())
) {
dropdownContent()
}
// Pinned Done button (optional, for multi-select)
if (showDoneButton && onDoneClick != null) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
AppButton(
onClick = {
onDoneClick()
onDismissRequest() // Often expected to close on 'Done'
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(stringResource(R.string.label_done))
}
}
// Extra padding for the system navigation bar so the bottom
// item/button isn't cut off by gesture hints or software keys.
Spacer(modifier = Modifier.navigationBarsPadding().height(16.dp))
}
}
}
}
}
// =========================================
// UNIFIED DROPDOWN SEARCH FIELD
// =========================================
/**
* A standardized search field for dropdown menus.
* Provides consistent styling across all dropdowns with search functionality.
*/
@Composable
fun DropdownSearchField(
modifier: Modifier = Modifier,
searchQuery: String,
onSearchQueryChange: (String) -> Unit,
placeholder: @Composable () -> Unit = { Text(stringResource(R.string.text_search)) },
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = 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 = onSearchQueryChange,
placeholder = placeholder,
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = MaterialTheme.colorScheme.primary,
),
modifier = Modifier.weight(1f)
)
if (searchQuery.isNotBlank()) {
IconButton(
onClick = { onSearchQueryChange("") },
modifier = Modifier.size(24.dp)
) {
Icon(
imageVector = AppIcons.Close,
contentDescription = stringResource(R.string.cd_clear_search),
modifier = Modifier.size(16.dp)
)
}
}
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true, name = "Search Field - Empty")
@Composable
fun DropdownSearchFieldEmptyPreview() {
MaterialTheme {
Surface {
DropdownSearchField(
searchQuery = "",
onSearchQueryChange = {}
)
}
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true, name = "Search Field - Filled")
@Composable
fun DropdownSearchFieldFilledPreview() {
MaterialTheme {
Surface {
DropdownSearchField(
searchQuery = "English",
onSearchQueryChange = {}
)
}
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true, name = "Search Field - With Close Button")
@Composable
fun DropdownSearchFieldWithClosePreview() {
MaterialTheme {
Surface {
DropdownSearchField(
searchQuery = "German",
onSearchQueryChange = {}
// Providing this triggers the right-most close icon
)
}
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true, name = "Search Field - Interactive")
@Composable
fun DropdownSearchFieldInteractivePreview() {
MaterialTheme {
Surface {
var query by remember { mutableStateOf("") }
DropdownSearchField(
searchQuery = query,
onSearchQueryChange = { query = it }
)
}
}
}
// =========================================
// UNIFIED DROPDOWN HEADER
// =========================================
/**
* A standardized header for dropdown sections.
* Provides consistent styling for section headers like "Favorites", "Recent", etc.
*/
@Composable
fun DropdownHeader(
text: String,
modifier: Modifier = Modifier,
) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
// =========================================
// UNIFIED DROPDOWN ITEM COMPONENT
// =========================================
@Composable
fun AppDropdownMenuItem(
text: @Composable () -> Unit,
@@ -160,23 +435,25 @@ fun AppDropdownMenuItem(
trailingIcon: @Composable (() -> Unit)? = null,
selected: Boolean = false,
) {
val contentColor = if (enabled) {
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
} else {
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
val contentColor by animateColorAsState(
targetValue = DropdownDefaults.itemContentColor(selected, enabled),
label = "contentColor"
)
val backgroundColor by animateColorAsState(
targetValue = DropdownDefaults.itemBackground(selected),
label = "backgroundColor"
)
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp) // Outer padding creates the floating shape
.clip(RoundedCornerShape(8.dp))
.padding(
horizontal = DropdownDefaults.itemPaddingHorizontal,
vertical = DropdownDefaults.itemPaddingVertical
)
.clip(DropdownDefaults.shape)
.background(backgroundColor)
.clickable(enabled = enabled) { onClick() }
//.padding(horizontal = 12.dp, vertical = 10.dp) // Inner padding keeps content comfortable
) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -199,7 +476,112 @@ fun AppDropdownMenuItem(
}
}
// ... [Previews remain exactly the same as your original file] ...
/**
* A lightweight, modern dropdown menu composable with a clean text field and dropdown list.
*/
@Suppress("unused", "HardCodedStringLiteral")
@Composable
fun AppDropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
label: @Composable (() -> Unit)? = null,
enabled: Boolean = true,
placeholder: @Composable (() -> Unit)? = null,
selectedText: String = "",
onExpandRequest: () -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
var textFieldSize by remember { mutableStateOf(Size.Zero) }
val interactionSource = remember { MutableInteractionSource() }
Column(modifier = modifier) {
OutlinedTextField(
value = selectedText,
onValueChange = {},
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned { coordinates ->
textFieldSize = coordinates.size.toSize()
}
.clickable(
enabled = enabled,
onClick = onExpandRequest,
interactionSource = interactionSource,
indication = null
),
readOnly = true,
label = label,
placeholder = placeholder,
trailingIcon = {
Icon(
imageVector = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown,
contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
},
shape = DropdownDefaults.shape,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.6f),
focusedLabelColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
disabledBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
disabledLabelColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
),
enabled = enabled,
interactionSource = interactionSource
)
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
modifier = Modifier.width(with(LocalDensity.current) { textFieldSize.width.toDp() }),
offset = DpOffset(0.dp, 2.dp),
properties = PopupProperties(focusable = true),
shape = DropdownDefaults.shape,
containerColor = DropdownDefaults.containerColor()
) {
content()
}
}
}
// =========================================
// LEGACY COMPONENTS (UPDATED TO USE UNIFIED STYLES)
// =========================================
@Composable
fun LargeDropdownMenuItem(
text: String,
selected: Boolean,
enabled: Boolean,
onClick: () -> Unit,
) {
val contentColor = DropdownDefaults.itemContentColor(selected, enabled)
val backgroundColor = DropdownDefaults.itemBackground(selected)
val fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
CompositionLocalProvider(LocalContentColor provides contentColor) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = DropdownDefaults.itemPaddingHorizontal,
vertical = DropdownDefaults.itemPaddingVertical
)
.clip(DropdownDefaults.shape)
.background(backgroundColor)
.clickable(enabled) { onClick() }
.padding(horizontal = 16.dp, vertical = 14.dp)
) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = fontWeight),
)
}
}
}
@Composable
fun <T> LargeDropdownMenu(
@@ -210,12 +592,12 @@ fun <T> LargeDropdownMenu(
items: List<T>,
selectedIndex: Int = -1,
onItemSelected: (index: Int, item: T) -> Unit,
selectedItemToString: (T) -> String = { it.toString() },
drawItem: @Composable (T, Boolean, Boolean, () -> Unit) -> Unit = { item, selected, itemEnabled, onClick ->
selectedItemToString: (T) -> String = { item: T -> item.toString() },
drawItem: @Composable (T, Boolean, Boolean, () -> Unit) -> Unit = { item: T, selected: Boolean, _: Boolean, onClick: () -> Unit ->
LargeDropdownMenuItem(
text = item.toString(),
selected = selected,
enabled = itemEnabled,
enabled = true,
onClick = onClick,
)
},
@@ -247,13 +629,10 @@ fun <T> LargeDropdownMenu(
}
if (expanded) {
Dialog(
onDismissRequest = { expanded = false }, // Fixed bug from original code
) {
Dialog(onDismissRequest = { expanded = false }) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shadowElevation = 8.dp,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp
) {
val listState = rememberLazyListState()
@@ -263,7 +642,6 @@ fun <T> LargeDropdownMenu(
}
}
// Added vertical padding to the list instead of hard dividers
LazyColumn(
modifier = Modifier.fillMaxWidth(),
state = listState,
@@ -279,7 +657,7 @@ fun <T> LargeDropdownMenu(
)
}
}
itemsIndexed(items) { index, item ->
itemsIndexed(items) { index: Int, item: T ->
val selectedItem = index == selectedIndex
drawItem(
item,
@@ -296,39 +674,7 @@ fun <T> LargeDropdownMenu(
}
}
@Composable
fun LargeDropdownMenuItem(
text: String,
selected: Boolean,
enabled: Boolean,
onClick: () -> Unit,
) {
val contentColor = when {
!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
.fillMaxWidth()
.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.copy(fontWeight = fontWeight),
)
}
}
}
// ============== PREVIEWS ==============
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@@ -354,6 +700,30 @@ fun LargeDropdownMenuItemSelectedPreview() {
)
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun AppDropdownMenuItemPreview() {
AppDropdownMenuItem(
text = { Text("Sample Item") },
onClick = {},
selected = false,
enabled = true
)
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun AppDropdownMenuItemSelectedPreview() {
AppDropdownMenuItem(
text = { Text("Selected Item") },
onClick = {},
selected = true,
enabled = true
)
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
@@ -365,53 +735,8 @@ fun LargeDropdownMenuPreview() {
label = "Select Option",
items = options,
selectedIndex = selectedIndex,
onItemSelected = { index, _ ->
onItemSelected = { index: Int, _: String ->
selectedIndex = index
}
)
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun LargeDropdownMenuExpandedPreview() {
val options = listOf("Option 1", "Option 2", "Option 3", "Option 4", "Option 5", "Option 6")
var selectedIndex by remember { mutableIntStateOf(2) }
// Simulate expanded state by showing the dropdown and the dialog content
Column {
LargeDropdownMenu(
label = "Select Option",
items = options,
selectedIndex = selectedIndex,
onItemSelected = { index, _ ->
selectedIndex = index
}
)
// Manually show the expanded dialog content for preview
Dialog(onDismissRequest = {}) {
Surface(shape = RoundedCornerShape(12.dp)) {
val listState = rememberLazyListState()
LaunchedEffect("ScrollToSelected") {
listState.scrollToItem(index = selectedIndex)
}
LazyColumn(modifier = Modifier.fillMaxWidth(), state = listState) {
itemsIndexed(options) { index, item ->
LargeDropdownMenuItem(
text = item,
selected = index == selectedIndex,
enabled = true,
onClick = { selectedIndex = index }
)
if (index < options.lastIndex) {
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
}
}
}
}
}
}
}

View File

@@ -1,3 +1,5 @@
@file:Suppress("AssignedValueIsNeverRead", "HardCodedStringLiteral")
package eu.gaudian.translator.view.composable
import androidx.compose.foundation.layout.Box
@@ -6,19 +8,19 @@ 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.heightIn
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -33,7 +35,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -82,7 +83,10 @@ fun BaseLanguageDropDown(
else -> stringResource(R.string.label_language_none)
}
Box(modifier = modifier) {
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.TopCenter
) {
AppOutlinedButton(
shape = RoundedCornerShape(8.dp),
onClick = { expanded = true },
@@ -104,241 +108,237 @@ fun BaseLanguageDropDown(
}
}
DropdownMenu(modifier = modifier.fillMaxWidth(), expanded = expanded, onDismissRequest = {
expanded = false
searchText = ""
tempSelection = emptyList() // Also reset temp selection on dismiss
}) {
// Helper composable for a single language row in multiple selection mode
@Composable
fun MultiSelectItem(language: Language) {
val isSelected = tempSelection.contains(language)
AppDropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
AppCheckbox(
checked = isSelected,
onCheckedChange = {
tempSelection = if (isSelected) tempSelection - language else tempSelection + language
@Suppress("AssignedValueIsNeverRead")
selectedLanguagesCount = tempSelection.size
onLanguagesSelected(tempSelection)
}
)
Spacer(modifier = Modifier.width(8.dp))
Text(language.name)
if (language.nativeName != language.name) {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "(${language.nativeName})",
style = TextStyle(
fontStyle = FontStyle.Italic,
fontFamily = FontFamily.Default
)
if (expanded) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = {
expanded = false
searchText = ""
tempSelection = emptyList()
},
sheetState = sheetState
) {
@Composable
fun MultiSelectItem(language: Language) {
val isSelected = tempSelection.contains(language)
AppDropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
AppCheckbox(
checked = isSelected,
onCheckedChange = {
tempSelection = if (isSelected) tempSelection - language else tempSelection + language
selectedLanguagesCount = tempSelection.size
onLanguagesSelected(tempSelection)
}
)
}
}
},
onClick = {
tempSelection = if (isSelected) tempSelection - language else tempSelection + language
@Suppress("AssignedValueIsNeverRead")
selectedLanguagesCount = tempSelection.size
}
)
}
// Helper composable for a single language row in single selection mode
@Composable
fun SingleSelectItem(language: Language) {
val languageNames = languages.map { it.name }
val duplicateNames = languageNames.groupingBy { it }.eachCount().filter { it.value > 1 }.keys
val isDuplicate = duplicateNames.contains(language.name)
AppDropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column {
Text(text = language.name)
Spacer(modifier = Modifier.width(8.dp))
Text(language.name)
if (language.nativeName != language.name) {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = language.nativeName,
text = "(${language.nativeName})",
style = TextStyle(
fontStyle = FontStyle.Italic,
fontSize = 12.sp,
fontFamily = FontFamily.Default
)
)
}
}
if (isDuplicate) {
Spacer(modifier = Modifier.width(4.dp))
Text(text = "(${language.region})")
}
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = {
val isCurrentlyFavorite = favoriteLanguages.contains(language)
val updatedFavorites = if (!isCurrentlyFavorite) favoriteLanguages + language else favoriteLanguages - language
languageViewModel.updateFavoriteLanguages(updatedFavorites)
}) {
Icon(
imageVector = if (favoriteLanguages.contains(language)) AppIcons.Favorite else AppIcons.FavoriteOutline,
contentDescription = if (favoriteLanguages.contains(language)) stringResource(
R.string.text_remove_from_favorites
) else stringResource(R.string.text_add_to_favorites)
)
}
},
onClick = {
tempSelection = if (isSelected) tempSelection - language else tempSelection + language
selectedLanguagesCount = tempSelection.size
}
},
onClick = {
onLanguageSelected(language)
expanded = false
searchText = ""
}
)
}
)
}
@Composable
fun SingleSelectItem(language: Language) {
val languageNames = languages.map { it.name }
val duplicateNames = languageNames.groupingBy { it }.eachCount().filter { it.value > 1 }.keys
val isDuplicate = duplicateNames.contains(language.name)
// --- Main Dropdown Content ---
Column(
modifier = Modifier
.heightIn(max = 900.dp) // Constrain the height
) {
// Search bar with a back arrow
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { expanded = false; searchText = "" }) {
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.label_close))
}
TextField(
value = searchText,
onValueChange = { searchText = it },
singleLine = true,
placeholder = { Text(stringResource(R.string.text_search_3d)) },
trailingIcon = {
if (searchText.isNotBlank()) {
IconButton(onClick = { searchText = "" }) {
Icon(imageVector = AppIcons.Close, contentDescription = stringResource(R.string.cd_clear_search))
AppDropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column {
Text(text = language.name)
if (language.nativeName != language.name) {
Text(
text = language.nativeName,
style = TextStyle(
fontStyle = FontStyle.Italic,
fontSize = 12.sp,
fontFamily = FontFamily.Default
)
)
}
}
if (isDuplicate) {
Spacer(modifier = Modifier.width(4.dp))
Text(text = "(${language.region})")
}
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = {
val isCurrentlyFavorite = favoriteLanguages.contains(language)
val updatedFavorites = if (!isCurrentlyFavorite) favoriteLanguages + language else favoriteLanguages - language
languageViewModel.updateFavoriteLanguages(updatedFavorites)
}) {
Icon(
imageVector = if (favoriteLanguages.contains(language)) AppIcons.Favorite else AppIcons.FavoriteOutline,
contentDescription = if (favoriteLanguages.contains(language)) stringResource(
R.string.text_remove_from_favorites
) else stringResource(R.string.text_add_to_favorites)
)
}
}
},
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
modifier = Modifier.weight(1f)
)
}
HorizontalDivider()
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
val isSearching = searchText.isNotBlank()
if (isSearching) {
val searchResults = (favoriteLanguages + languageHistory + languages)
.distinctBy { it.nameResId }
.filter { language ->
val matchesName = language.name.contains(searchText, ignoreCase = true)
val matchesNativeName = language.nativeName.contains(searchText, ignoreCase = true)
matchesName || matchesNativeName
}
.sortedBy { it.name }
if (enableMultipleSelection) {
searchResults.forEach { language -> MultiSelectItem(language) }
} else {
searchResults.forEach { language -> SingleSelectItem(language) }
}
} else if (alternateLanguages.isNotEmpty()) {
val sortedAlternate = alternateLanguages.sortedBy { it.name }
if (enableMultipleSelection) {
Text(stringResource(R.string.text_all_languages), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp))
sortedAlternate.forEach { language -> MultiSelectItem(language) }
} else {
Text(stringResource(R.string.text_all_languages), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp))
sortedAlternate.forEach { language -> SingleSelectItem(language) }
}
} else {
if (enableMultipleSelection) {
if (favoriteLanguages.isNotEmpty()) {
Text(stringResource(R.string.text_favorites), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp))
favoriteLanguages.forEach { language -> MultiSelectItem(language) }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
val recentHistoryFiltered = languageHistory.filter { it !in favoriteLanguages }.takeLast(5)
if (recentHistoryFiltered.isNotEmpty() && alternateLanguages.isEmpty()) {
Text(stringResource(R.string.text_recent_history), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp))
recentHistoryFiltered.forEach { language -> MultiSelectItem(language) }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
val remainingLanguages = languages.sortedBy { it.name }
if (remainingLanguages.isNotEmpty()) {
Text(stringResource(R.string.text_all_languages), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp))
remainingLanguages.forEach { language -> MultiSelectItem(language) }
}
} else {
// Logic for single selection default view
if (showAutoOption) {
AppDropdownMenuItem(text = { Text(stringResource(R.string.text_select_auto_recognition)) }, onClick = { onAutoSelected(); expanded = false; searchText = "" })
HorizontalDivider()
}
if (showNoneOption) {
AppDropdownMenuItem(text = { Text(stringResource(R.string.text_select_none)) }, onClick = { onNoneSelected(); expanded = false; searchText = "" })
HorizontalDivider()
}
if (favoriteLanguages.any {
@Suppress("HardCodedStringLiteral")
it.code != "none" && it.code != "auto"
}) {
Text(stringResource(R.string.text_favorites), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp))
favoriteLanguages.filter {
@Suppress("HardCodedStringLiteral")
it.code != "none" && it.code != "auto"
}.forEach { language -> SingleSelectItem(language) }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
val recentHistoryFiltered = languageHistory.filter {
@Suppress("HardCodedStringLiteral")
it !in favoriteLanguages && it.code != "none" && it.code != "auto"
}.takeLast(5)
if (recentHistoryFiltered.isNotEmpty()) {
Text(stringResource(R.string.text_recent_history), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp))
recentHistoryFiltered.forEach { language -> SingleSelectItem(language) }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
val remainingLanguages = languages.filter {
@Suppress("HardCodedStringLiteral")
it !in favoriteLanguages && it !in recentHistoryFiltered && it.code != "none" && it.code != "auto"
}.sortedBy { it.name }
if (remainingLanguages.isNotEmpty()) {
Text(stringResource(R.string.text_all_languages), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp))
remainingLanguages.forEach { language -> SingleSelectItem(language) }
}
}
}
}
// Done button for multiple selection mode
if (enableMultipleSelection) {
HorizontalDivider()
AppButton(
onClick = {
onLanguagesSelected(tempSelection)
@Suppress("AssignedValueIsNeverRead")
selectedLanguagesCount = tempSelection.size
onLanguageSelected(language)
expanded = false
searchText = ""
},
}
)
}
Column(
modifier = Modifier.fillMaxWidth()
) {
DropdownSearchField(
searchQuery = searchText,
onSearchQueryChange = { searchText = it },
placeholder = { Text(stringResource(R.string.text_search_3d)) },
)
HorizontalDivider()
// Replaced height(max = 900.dp) with standard weight logic to allow proper scrolling bounds
Column(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth()
.weight(1f, fill = false)
.verticalScroll(rememberScrollState())
) {
Text(stringResource(R.string.label_done))
val isSearching = searchText.isNotBlank()
if (isSearching) {
val searchResults = (favoriteLanguages + languageHistory + languages)
.distinctBy { it.nameResId }
.filter { language ->
val matchesName = language.name.contains(searchText, ignoreCase = true)
val matchesNativeName = language.nativeName.contains(searchText, ignoreCase = true)
matchesName || matchesNativeName
}
.sortedBy { it.name }
if (enableMultipleSelection) {
searchResults.forEach { language -> MultiSelectItem(language) }
} else {
searchResults.forEach { language -> SingleSelectItem(language) }
}
} else if (alternateLanguages.isNotEmpty()) {
val sortedAlternate = alternateLanguages.sortedBy { it.name }
if (enableMultipleSelection) {
DropdownHeader(text = stringResource(R.string.text_all_languages))
sortedAlternate.forEach { language -> MultiSelectItem(language) }
} else {
DropdownHeader(text = stringResource(R.string.text_all_languages))
sortedAlternate.forEach { language -> SingleSelectItem(language) }
}
} else {
if (enableMultipleSelection) {
if (favoriteLanguages.isNotEmpty()) {
DropdownHeader(text = stringResource(R.string.text_favorites))
favoriteLanguages.forEach { language -> MultiSelectItem(language) }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
val recentHistoryFiltered = languageHistory.filter { it !in favoriteLanguages }.takeLast(5)
if (recentHistoryFiltered.isNotEmpty() && alternateLanguages.isEmpty()) {
DropdownHeader(text = stringResource(R.string.text_recent_history))
recentHistoryFiltered.forEach { language -> MultiSelectItem(language) }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
val remainingLanguages = languages.sortedBy { it.name }
if (remainingLanguages.isNotEmpty()) {
DropdownHeader(text = stringResource(R.string.text_all_languages))
remainingLanguages.forEach { language -> MultiSelectItem(language) }
}
} else {
if (showAutoOption) {
LargeDropdownMenuItem(
text = stringResource(R.string.text_select_auto_recognition),
selected = false, // Set to true if you want to highlight it when active
enabled = true,
onClick = {
onAutoSelected()
expanded = false
searchText = ""
}
)
HorizontalDivider()
}
if (showNoneOption) {
LargeDropdownMenuItem(
text = stringResource(R.string.text_select_no_language),
selected = false, // Set to true if you want to highlight it when active
enabled = true,
onClick = {
onNoneSelected()
expanded = false
searchText = ""
}
)
HorizontalDivider()
}
if (favoriteLanguages.any {
it.code != "none" && it.code != "auto"
}) {
DropdownHeader(text = stringResource(R.string.text_favorites))
favoriteLanguages.filter {
it.code != "none" && it.code != "auto"
}.forEach { language -> SingleSelectItem(language) }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
val recentHistoryFiltered = languageHistory.filter {
it !in favoriteLanguages && it.code != "none" && it.code != "auto"
}.takeLast(5)
if (recentHistoryFiltered.isNotEmpty()) {
DropdownHeader(text = stringResource(R.string.text_recent_history))
recentHistoryFiltered.forEach { language -> SingleSelectItem(language) }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
val remainingLanguages = languages.filter {
it !in favoriteLanguages && it !in recentHistoryFiltered && it.code != "none" && it.code != "auto"
}.sortedBy { it.name }
if (remainingLanguages.isNotEmpty()) {
DropdownHeader(text = stringResource(R.string.text_all_languages))
remainingLanguages.forEach { language -> SingleSelectItem(language) }
}
}
}
}
if (enableMultipleSelection) {
HorizontalDivider()
AppButton(
onClick = {
onLanguagesSelected(tempSelection)
selectedLanguagesCount = tempSelection.size
expanded = false
searchText = ""
},
modifier = Modifier
.padding(8.dp)
.fillMaxWidth()
) {
Text(stringResource(R.string.label_done))
}
}
// Provides breathing room for system gestures at bottom of sheet
Spacer(modifier = Modifier.navigationBarsPadding().height(16.dp))
}
}
}
@@ -400,7 +400,7 @@ fun TargetLanguageDropdown(
iconEnabled = iconEnabled,
noBorder = noBorder,
)
)
}
@Composable

View File

@@ -52,6 +52,7 @@ import eu.gaudian.translator.view.hints.CategoryHint
import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.LanguageViewModel
enum class DialogCategoryType { TAG, FILTER }
@Composable

View File

@@ -1,13 +1,12 @@
@file:Suppress("AssignedValueIsNeverRead", "HardCodedStringLiteral")
package eu.gaudian.translator.view.dialogs
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -24,24 +23,20 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import eu.gaudian.translator.R
import eu.gaudian.translator.model.TagCategory
import eu.gaudian.translator.model.VocabularyCategory
import eu.gaudian.translator.model.VocabularyFilter
import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppCheckbox
import eu.gaudian.translator.view.composable.AppDropdownContainer
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.view.composable.DropdownHeader
import eu.gaudian.translator.viewmodel.CategoryViewModel
@@ -54,22 +49,12 @@ data class CategoryDropdownState(
val selectedCategories: List<VocabularyCategory?> = emptyList(),
val newCategoryName: String = "",
val categories: List<VocabularyCategory> = emptyList(),
val searchQuery: String = "",
)
/**
* 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(
@@ -79,10 +64,12 @@ fun CategoryDropdownContent(
onCategorySelected: (List<VocabularyCategory?>) -> Unit,
onNewCategoryNameChange: (String) -> Unit,
onAddCategory: (String) -> Unit,
onSearchQueryChange: (String) -> Unit = {},
noneSelectable: Boolean = true,
multipleSelectable: Boolean = false,
onlyLists: Boolean = false,
addCategory: Boolean = false,
enableSearch: Boolean = false,
) {
val selectableCategories = if (onlyLists) {
state.categories.filterIsInstance<TagCategory>()
@@ -90,37 +77,34 @@ fun CategoryDropdownContent(
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.label_no_category)
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)
}
)
// Filter categories by search query if search is enabled
val filteredCategories = if (enableSearch && state.searchQuery.isNotBlank()) {
selectableCategories.filter { category ->
category.name.contains(state.searchQuery, ignoreCase = true)
}
} else {
selectableCategories
}
DropdownMenu(
val buttonText = when {
state.selectedCategories.isEmpty() -> stringResource(R.string.text_select_category)
state.selectedCategories.size == 1 -> state.selectedCategories.first()?.name
?: stringResource(R.string.label_no_category)
else -> stringResource(R.string.text_2d_categories_selected, state.selectedCategories.size)
}
AppDropdownContainer(
expanded = state.expanded,
onDismissRequest = { onExpand(false) },
modifier = Modifier.fillMaxWidth(),
onExpandRequest = { onExpand(true) },
buttonText = buttonText,
modifier = modifier,
showSearch = enableSearch,
searchQuery = state.searchQuery,
onSearchQueryChange = onSearchQueryChange,
searchPlaceholder = stringResource(R.string.text_search),
showDoneButton = multipleSelectable,
onDoneClick = { onExpand(false) }
) {
if (noneSelectable) {
val noneSelected = state.selectedCategories.contains(null)
@@ -133,7 +117,7 @@ fun CategoryDropdownContent(
if (multipleSelectable) {
AppCheckbox(
checked = noneSelected,
onCheckedChange = { isChecked ->
onCheckedChange = { _ ->
val newSelection = if (noneSelected) {
state.selectedCategories.filterNotNull()
} else {
@@ -144,7 +128,10 @@ fun CategoryDropdownContent(
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.label_no_category))
Text(
text = stringResource(R.string.label_no_category),
color = MaterialTheme.colorScheme.onSurface
)
}
},
onClick = {
@@ -163,7 +150,7 @@ fun CategoryDropdownContent(
)
}
selectableCategories.forEach { category ->
filteredCategories.forEach { category ->
val isSelected = state.selectedCategories.contains(category)
AppDropdownMenuItem(
text = {
@@ -185,7 +172,10 @@ fun CategoryDropdownContent(
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(category.name)
Text(
text = category.name,
color = MaterialTheme.colorScheme.onSurface
)
}
},
onClick = {
@@ -204,16 +194,24 @@ fun CategoryDropdownContent(
)
}
if (addCategory) {
HorizontalDivider()
if (enableSearch && state.searchQuery.isNotBlank() && filteredCategories.isEmpty()) {
AppDropdownMenuItem(
text = {
Text(stringResource(R.string.label_add_category))
Text(
text = stringResource(R.string.text_no_models_found),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
modifier = Modifier.fillMaxWidth()
)
},
onClick = {},
modifier = Modifier.padding(4.dp)
enabled = false
)
}
if (addCategory) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
DropdownHeader(text = stringResource(R.string.label_add_category))
AppDropdownMenuItem(
text = {
@@ -227,7 +225,7 @@ fun CategoryDropdownContent(
modifier = Modifier.weight(1f),
singleLine = true,
)
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(4.dp))
IconButton(
onClick = {
if (state.newCategoryName.isNotBlank()) {
@@ -246,32 +244,11 @@ fun CategoryDropdownContent(
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
fun CategoryDropdown(
@@ -282,26 +259,23 @@ fun CategoryDropdown(
multipleSelectable: Boolean = false,
onlyLists: Boolean = false,
addCategory: Boolean = false,
enableSearch: Boolean = false,
) {
var expanded by remember { mutableStateOf(false) }
var selectedCategories by remember {
mutableStateOf<List<VocabularyCategory?>>(emptyList())
}
var newCategoryName by remember { mutableStateOf("") }
var searchQuery by remember { mutableStateOf("") }
val activity = LocalContext.current.findActivity()
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
val categories by categoryViewModel.categories.collectAsState(initial = emptyList())
// Find initial category
val initialCategory = remember(categories, initialCategoryId) {
categories.find { it.id == initialCategoryId }
}
// Initialize selection with initial category if provided
remember(initialCategory) {
if (initialCategory != null && selectedCategories.isEmpty()) {
selectedCategories = listOf(initialCategory)
@@ -315,6 +289,7 @@ fun CategoryDropdown(
selectedCategories = selectedCategories,
newCategoryName = newCategoryName,
categories = categories,
searchQuery = searchQuery,
),
onExpand = { isExpanded -> expanded = isExpanded },
onCategorySelected = { newSelection ->
@@ -324,115 +299,35 @@ fun CategoryDropdown(
onNewCategoryNameChange = { newCategoryName = it },
onAddCategory = { name ->
val newCategory = TagCategory(id = 0, name = name)
// In production, this would call ViewModel.createCategory(newCategory)
newCategoryName = ""
categoryViewModel.createCategory(newCategory)
//selectedCategories = selectedCategories + newCategory
if (!multipleSelectable) {
expanded = false
}
},
onSearchQueryChange = { searchQuery = it },
noneSelectable = noneSelectable == true,
multipleSelectable = multipleSelectable,
onlyLists = onlyLists,
addCategory = addCategory,
enableSearch = enableSearch,
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
) {
fun CategoryDropdownCollapsedPreview() {
Surface(modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.background) {
CategoryDropdownContent(
state = state.copy(expanded = false),
state = CategoryDropdownState(
expanded = false,
selectedCategories = emptyList(),
categories = listOf(TagCategory(1, "Animals"), TagCategory(2, "Food")),
),
onExpand = {},
onCategorySelected = {},
onNewCategoryNameChange = {},
@@ -444,15 +339,14 @@ fun CategoryDropdownCollapsedPreview(
@ThemePreviews
@Preview(showBackground = true)
@Composable
fun CategoryDropdownExpandedPreview(
@PreviewParameter(CategoryDropdownStateProvider::class) state: CategoryDropdownState
) {
Surface(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
fun CategoryDropdownExpandedPreview() {
Surface(modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.background) {
CategoryDropdownContent(
state = state.copy(expanded = true),
state = CategoryDropdownState(
expanded = true,
selectedCategories = listOf(TagCategory(1, "Animals")),
categories = listOf(TagCategory(1, "Animals"), TagCategory(2, "Food"), TagCategory(3, "Travel")),
),
onExpand = {},
onCategorySelected = {},
onNewCategoryNameChange = {},
@@ -466,21 +360,10 @@ fun CategoryDropdownExpandedPreview(
@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]))
}
val categories = listOf(TagCategory(1, "Animals"), TagCategory(2, "Food"), TagCategory(3, "Travel"))
var selectedCategories by remember { mutableStateOf<List<VocabularyCategory?>>(listOf(categories[0], categories[2])) }
Surface(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
Surface(modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.background) {
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = true,
@@ -502,26 +385,17 @@ fun CategoryDropdownMultipleSelectionPreview() {
@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
) {
Surface(modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.background) {
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = true,
selectedCategories = emptyList(),
newCategoryName = newCategoryName,
categories = categories,
newCategoryName = "New Cat",
categories = listOf(TagCategory(1, "Animals"), TagCategory(2, "Food")),
),
onExpand = {},
onCategorySelected = {},
onNewCategoryNameChange = { newCategoryName = it },
onNewCategoryNameChange = {},
onAddCategory = {},
addCategory = true,
)
@@ -532,127 +406,8 @@ fun CategoryDropdownWithAddCategoryPreview() {
@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
) {
fun CategoryDropdownWithSearchPreview() {
Surface(modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.background) {
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = true,
@@ -661,20 +416,16 @@ fun CategoryDropdownFullExpandedPreview() {
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
TagCategory(3, "Travel"),
TagCategory(4, "Business"),
TagCategory(5, "Technology"),
TagCategory(6, "Sports"),
TagCategory(7, "Music"),
TagCategory(8, "Art"),
TagCategory(4, "Technology"),
TagCategory(5, "Sports")
),
searchQuery = "",
),
onExpand = {},
onCategorySelected = {},
onNewCategoryNameChange = {},
onAddCategory = {},
addCategory = true,
multipleSelectable = true,
noneSelectable = true,
enableSearch = true,
)
}
}

View File

@@ -1,15 +1,11 @@
@file:Suppress("AssignedValueIsNeverRead")
package eu.gaudian.translator.view.dialogs
import androidx.compose.foundation.layout.Box
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.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -20,16 +16,13 @@ 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.unit.dp
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.AppDropdownContainer
import eu.gaudian.translator.view.composable.AppDropdownMenuItem
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedButton
@Composable
@@ -44,117 +37,103 @@ fun VocabularyStageDropDown(
var selectedStages by remember { mutableStateOf(preselectedStages) }
val context = LocalContext.current
Box(
val buttonText = when {
selectedStages.isEmpty() -> stringResource(R.string.label_select_stage)
selectedStages.size == 1 -> selectedStages.first()?.toString(context) ?: stringResource(R.string.text_none)
else -> stringResource(R.string.stages_selected, selectedStages.size)
}
AppDropdownContainer(
expanded = expanded,
onDismissRequest = { expanded = false },
onExpandRequest = { expanded = true },
buttonText = buttonText,
modifier = modifier,
contentAlignment = Alignment.CenterEnd
showDoneButton = multipleSelectable,
onDoneClick = { expanded = false }
) {
AppOutlinedButton(
shape = RoundedCornerShape(8.dp),
onClick = { expanded = true },
modifier = Modifier.fillMaxWidth(),
) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(text = when {
selectedStages.isEmpty() -> stringResource(R.string.label_select_stage)
selectedStages.size == 1 -> selectedStages.first()?.toString(context)?:stringResource(R.string.text_none)
else -> stringResource(R.string.stages_selected, selectedStages.size)
if (noneSelectable == true) {
val noneSelected = selectedStages.contains(null)
AppDropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (multipleSelectable) {
AppCheckbox(
checked = noneSelected,
onCheckedChange = {
selectedStages = if (noneSelected) {
selectedStages.filterNotNull()
} else {
selectedStages + listOf(null)
}
onStageSelected(selectedStages)
}
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(text = stringResource(R.string.text_none))
}
},
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
Icon(
imageVector = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown,
contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand)
)
}
onClick = {
if (multipleSelectable) {
selectedStages = if (null in selectedStages) {
selectedStages.filterNotNull()
} else {
selectedStages + listOf(null)
}
onStageSelected(selectedStages)
} else {
selectedStages = listOf(null)
onStageSelected(selectedStages)
expanded = false
}
}
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.fillMaxWidth()
) {
if (noneSelectable == true) {
val noneSelected = selectedStages.contains(null)
AppDropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (multipleSelectable) {
AppCheckbox(
checked = noneSelected,
onCheckedChange = {
selectedStages = if (noneSelected) selectedStages.filterNotNull() else selectedStages + listOf(null)
onStageSelected(selectedStages)
}
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(text = stringResource(R.string.text_none))
}
},
onClick = {
VocabularyStage.entries.forEach { stage ->
val isSelected = selectedStages.contains(stage)
AppDropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (multipleSelectable) {
selectedStages = if (null in selectedStages) {
selectedStages.filterNotNull()
} else {
selectedStages + listOf(null)
}
onStageSelected(selectedStages)
} else {
selectedStages = listOf(null)
onStageSelected(selectedStages)
expanded = false
}
}
)
}
VocabularyStage.entries.forEach { stage ->
val isSelected = selectedStages.contains(stage)
AppDropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (multipleSelectable) {
AppCheckbox(
checked = isSelected,
onCheckedChange = {
selectedStages = if (isSelected) selectedStages - stage else selectedStages + stage
onStageSelected(selectedStages)
AppCheckbox(
checked = isSelected,
onCheckedChange = {
selectedStages = if (isSelected) {
selectedStages - stage
} else {
selectedStages + stage
}
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stage.toString(context))
}
},
onClick = {
if (multipleSelectable) {
selectedStages = if (stage in selectedStages) {
selectedStages - stage
} else {
selectedStages + stage
}
onStageSelected(selectedStages)
} else {
selectedStages = listOf(stage)
onStageSelected(selectedStages)
expanded = false
onStageSelected(selectedStages)
}
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stage.toString(context))
}
},
onClick = {
if (multipleSelectable) {
selectedStages = if (stage in selectedStages) {
selectedStages - stage
} else {
selectedStages + stage
}
onStageSelected(selectedStages)
} else {
selectedStages = listOf(stage)
onStageSelected(selectedStages)
expanded = false
}
)
}
if (multipleSelectable) {
HorizontalDivider()
AppButton(
onClick = { expanded = false },
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Text(stringResource(R.string.label_done))
}
}
)
}
}
}

View File

@@ -10,7 +10,6 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -20,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -32,14 +32,14 @@ import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
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.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -67,6 +67,8 @@ import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedButton
import eu.gaudian.translator.view.composable.AppSwitch
import eu.gaudian.translator.view.composable.DictionaryLanguageDropDown
import eu.gaudian.translator.view.composable.DropdownDefaults
import eu.gaudian.translator.view.composable.LargeDropdownMenuItem
import eu.gaudian.translator.viewmodel.CorrectionViewModel
import eu.gaudian.translator.viewmodel.LanguageViewModel
import kotlinx.coroutines.launch
@@ -415,6 +417,7 @@ fun CorrectionScreenContent(
}
}
@Composable
private fun ToneDropdown(
selectedTone: CorrectionViewModel.Tone,
@@ -447,39 +450,59 @@ private fun ToneDropdown(
}
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp))
) {
DropdownMenuItem(
text = { Text(text = stringResource(R.string.text_none)) },
onClick = {
onToneSelected(CorrectionViewModel.Tone.NONE)
expanded = false
},
enabled = enabled
)
HorizontalDivider()
val options = listOf(
CorrectionViewModel.Tone.FORMAL,
CorrectionViewModel.Tone.CASUAL,
CorrectionViewModel.Tone.COLLOQUIAL,
CorrectionViewModel.Tone.POLITE,
CorrectionViewModel.Tone.PROFESSIONAL,
CorrectionViewModel.Tone.FRIENDLY,
CorrectionViewModel.Tone.ACADEMIC,
CorrectionViewModel.Tone.CREATIVE
)
options.forEach { tone ->
DropdownMenuItem(
text = { Text(text = labelFor(tone)) },
onClick = {
onToneSelected(tone)
expanded = false
},
enabled = enabled
)
if (expanded) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = { expanded = false },
sheetState = sheetState,
containerColor = DropdownDefaults.containerColor()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp) // Gives breathing room at top/bottom of list
) {
// Replaced with LargeDropdownMenuItem
LargeDropdownMenuItem(
text = stringResource(R.string.text_none),
selected = selectedTone == CorrectionViewModel.Tone.NONE,
enabled = enabled,
onClick = {
onToneSelected(CorrectionViewModel.Tone.NONE)
expanded = false
}
)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
val options = listOf(
CorrectionViewModel.Tone.FORMAL,
CorrectionViewModel.Tone.CASUAL,
CorrectionViewModel.Tone.COLLOQUIAL,
CorrectionViewModel.Tone.POLITE,
CorrectionViewModel.Tone.PROFESSIONAL,
CorrectionViewModel.Tone.FRIENDLY,
CorrectionViewModel.Tone.ACADEMIC,
CorrectionViewModel.Tone.CREATIVE
)
options.forEach { tone ->
// Replaced with LargeDropdownMenuItem
LargeDropdownMenuItem(
text = labelFor(tone),
selected = selectedTone == tone,
enabled = enabled,
onClick = {
onToneSelected(tone)
expanded = false
}
)
}
Spacer(modifier = Modifier.navigationBarsPadding().height(16.dp))
}
}
}
}

View File

@@ -28,7 +28,6 @@
<string name="cd_re_generate_definition">Definition neu erstellen</string>
<string name="cd_clear_search">Suche löschen</string>
<string name="cd_translation_history">Übersetzungsverlauf</string>
<string name="label_quit_app">App beenden?</string>
<string name="label_reload">Neu laden</string>
<string name="title_single">Einzeln</string>
<string name="title_widget_streak">Streak</string>
@@ -210,7 +209,7 @@
<string name="text_favorites">Favoriten</string>
<string name="text_recent_history">Verlauf</string>
<string name="text_select_auto_recognition">Automatische Erkennung auswählen</string>
<string name="text_select_none">Keine auswählen</string>
<string name="text_select_no_language">Keine auswählen</string>
<string name="text_language_options">Sprachoptionen</string>
<string name="text_select_all_languages">Alle Sprachen auswählen</string>
<string name="text_delete_custom_language">Eigene Sprache löschen</string>

View File

@@ -28,7 +28,6 @@
<string name="cd_re_generate_definition">Gerar Definição Novamente</string>
<string name="cd_clear_search">Limpar pesquisa</string>
<string name="cd_translation_history">Histórico de Tradução</string>
<string name="label_quit_app">Fechar o aplicativo?</string>
<string name="label_reload">Recarregar</string>
<string name="title_single">Único</string>
<string name="title_widget_streak">Sequência</string>
@@ -207,7 +206,7 @@
<string name="text_favorites">Favoritos</string>
<string name="text_recent_history">Histórico</string>
<string name="text_select_auto_recognition">Selecionar Reconhecimento Automático</string>
<string name="text_select_none">Não selecionar nenhum</string>
<string name="text_select_no_language">Não selecionar nenhum</string>
<string name="text_language_options">Opções de Idioma</string>
<string name="text_select_all_languages">Selecionar todos os idiomas</string>
<string name="text_delete_custom_language">Excluir idioma personalizado</string>
@@ -629,7 +628,7 @@
<string name="text_paste_or_open_a_">Cole ou abra um link do YouTube para ver as legendas aqui.</string>
<string name="text_error_2d">Erro: %1$s</string>
<string name="text_repeat_wrong_guesses">Repetir Respostas Erradas</string>
<string name="label_language_none">Nenhuma</string>
<string name="label_language_none">Nenhum</string>
<string name="label_grammar_inflections">Flexões</string>
<string name="label_more">Mais</string>
<string name="label_translations">Traduções</string>

View File

@@ -316,7 +316,7 @@
<string name="label_preview_second">Preview (first 5) for second column: %1$s</string>
<string name="label_pronoun">Pronoun</string>
<string name="label_providers">Providers</string>
<string name="label_quit_app">Quit app?</string>
<string name="label_quit_app">Quit App</string>
<string name="label_quit_exercise_qm">Quit Exercise?</string>
<string name="label_raw_data_2d">Raw Data:</string>
<string name="label_related_words">Related Words</string>
@@ -884,7 +884,7 @@
<string name="text_select_category">Select Category</string>
<string name="text_select_languages">Select Languages</string>
<string name="text_select_model">Select Model</string>
<string name="text_select_none">Select None</string>
<string name="text_select_no_language">Select None</string>
<string name="text_select_the_content_dictionary">Select the content to be generated for a dictionary entry.</string>
<string name="text_select_translations_to_add">Select Translations to Add</string>
<string name="text_selected">Selected</string>
@@ -1040,4 +1040,6 @@
<string name="hint_scan_hint_title">Finding the right AI model</string>
<string name="hint_translate_how_it_works">How translation works</string>
<string name="label_no_category">None</string>
<string name="text_select">Select</string>
<string name="text_search">Search</string>
</resources>