diff --git a/app/src/main/java/eu/gaudian/translator/view/composable/ApiModelDropdown.kt b/app/src/main/java/eu/gaudian/translator/view/composable/ApiModelDropdown.kt index 36866a5..a23d2a0 100644 --- a/app/src/main/java/eu/gaudian/translator/view/composable/ApiModelDropdown.kt +++ b/app/src/main/java/eu/gaudian/translator/view/composable/ApiModelDropdown.kt @@ -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( @@ -255,4 +215,4 @@ fun ApiModelDropDown( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/gaudian/translator/view/composable/AppDropdownMenu.kt b/app/src/main/java/eu/gaudian/translator/view/composable/AppDropdownMenu.kt index 6bfe67e..90500c0 100644 --- a/app/src/main/java/eu/gaudian/translator/view/composable/AppDropdownMenu.kt +++ b/app/src/main/java/eu/gaudian/translator/view/composable/AppDropdownMenu.kt @@ -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 LargeDropdownMenu( @@ -210,12 +592,12 @@ fun LargeDropdownMenu( items: List, 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 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 LargeDropdownMenu( } } - // Added vertical padding to the list instead of hard dividers LazyColumn( modifier = Modifier.fillMaxWidth(), state = listState, @@ -279,7 +657,7 @@ fun LargeDropdownMenu( ) } } - itemsIndexed(items) { index, item -> + itemsIndexed(items) { index: Int, item: T -> val selectedItem = index == selectedIndex drawItem( item, @@ -296,39 +674,7 @@ fun 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)) - } - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/composable/LanguageDropDown.kt b/app/src/main/java/eu/gaudian/translator/view/composable/LanguageDropDown.kt index a3d7075..ff405e6 100644 --- a/app/src/main/java/eu/gaudian/translator/view/composable/LanguageDropDown.kt +++ b/app/src/main/java/eu/gaudian/translator/view/composable/LanguageDropDown.kt @@ -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 diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/AddCategoryDialog.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/AddCategoryDialog.kt index 9acea63..ec4a028 100644 --- a/app/src/main/java/eu/gaudian/translator/view/dialogs/AddCategoryDialog.kt +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/AddCategoryDialog.kt @@ -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 diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/CategoryDropdown.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/CategoryDropdown.kt index 4d98e6d..d7ba384 100644 --- a/app/src/main/java/eu/gaudian/translator/view/dialogs/CategoryDropdown.kt +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/CategoryDropdown.kt @@ -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 = emptyList(), val newCategoryName: String = "", val categories: List = 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) -> 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() @@ -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>(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 { - 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>(listOf(categories[0], categories[2])) - } + val categories = listOf(TagCategory(1, "Animals"), TagCategory(2, "Food"), TagCategory(3, "Travel")) + var selectedCategories by remember { mutableStateOf>(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>(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, ) } } diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/VocabularyStageDropdown.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/VocabularyStageDropdown.kt index f9126f0..ffe07b1 100644 --- a/app/src/main/java/eu/gaudian/translator/view/dialogs/VocabularyStageDropdown.kt +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/VocabularyStageDropdown.kt @@ -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)) } - } + ) } } } @@ -166,4 +145,4 @@ fun VocabularyStageDropDownPreview() { preselectedStages = listOf(VocabularyStage.NEW), onStageSelected = {} ) -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/gaudian/translator/view/dictionary/CorrectionScreen.kt b/app/src/main/java/eu/gaudian/translator/view/dictionary/CorrectionScreen.kt index 3dde9cd..4d799d4 100644 --- a/app/src/main/java/eu/gaudian/translator/view/dictionary/CorrectionScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/dictionary/CorrectionScreen.kt @@ -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)) + } } } } diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 61dc47c..be160d6 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -28,7 +28,6 @@ Definition neu erstellen Suche löschen Übersetzungsverlauf - App beenden? Neu laden Einzeln Streak @@ -210,7 +209,7 @@ Favoriten Verlauf Automatische Erkennung auswählen - Keine auswählen + Keine auswählen Sprachoptionen Alle Sprachen auswählen Eigene Sprache löschen diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index f1e252e..50217a4 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -28,7 +28,6 @@ Gerar Definição Novamente Limpar pesquisa Histórico de Tradução - Fechar o aplicativo? Recarregar Único Sequência @@ -207,7 +206,7 @@ Favoritos Histórico Selecionar Reconhecimento Automático - Não selecionar nenhum + Não selecionar nenhum Opções de Idioma Selecionar todos os idiomas Excluir idioma personalizado @@ -629,7 +628,7 @@ Cole ou abra um link do YouTube para ver as legendas aqui. Erro: %1$s Repetir Respostas Erradas - Nenhuma + Nenhum Flexões Mais Traduções diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5c2fd07..3d05f72 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -316,7 +316,7 @@ Preview (first 5) for second column: %1$s Pronoun Providers - Quit app? + Quit App Quit Exercise? Raw Data: Related Words @@ -884,7 +884,7 @@ Select Category Select Languages Select Model - Select None + Select None Select the content to be generated for a dictionary entry. Select Translations to Add Selected @@ -1040,4 +1040,6 @@ Finding the right AI model How translation works None + Select + Search