From b7c9c39862804c6a20bfa6c8270bb25c97faf84c Mon Sep 17 00:00:00 2001 From: Achmad Setyabudi Susilo Date: Sun, 28 Jun 2026 21:55:33 +0700 Subject: [PATCH] fix(#41,#42,#43): numeric amount input, clickable DateField, search-based category picker #41: pipe amount input through sanitizeAmountInput() (digits + single decimal) in both AddEditExpenseScreenModel and AddEditRecurringScreenModel so KeyboardType.Decimal is no longer bypassable via paste or hardware keys. #42: wrap DateField's OutlinedTextField in a Box.clickable so the whole row (label, text, icon) opens the date picker, not just the trailing icon. #43: replace CategoryDropdownField with a new CategoryPickerField that opens a generic ListSearchDialog (search bar, scrollable list, swatch, checkmark on selected row), decoupled from the Preference machinery. ListSearchPreferenceWidget is unchanged. --- .../ui/components/CategoryDropdownField.kt | 61 ------ .../ui/components/CategoryPickerField.kt | 89 ++++++++ .../achmad/ledgerr/ui/components/DateField.kt | 28 +-- .../ledgerr/ui/components/ListSearchDialog.kt | 200 ++++++++++++++++++ .../add_edit_expense/AddEditExpenseScreen.kt | 4 +- .../AddEditExpenseScreenModel.kt | 3 +- .../AddEditRecurringScreen.kt | 4 +- .../AddEditRecurringScreenModel.kt | 3 +- .../dev/achmad/ledgerr/ui/util/StringUtil.kt | 9 + app/src/main/res/values/strings.xml | 4 + 10 files changed, 326 insertions(+), 79 deletions(-) delete mode 100644 app/src/main/java/dev/achmad/ledgerr/ui/components/CategoryDropdownField.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/ui/components/CategoryPickerField.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/ui/components/ListSearchDialog.kt diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/components/CategoryDropdownField.kt b/app/src/main/java/dev/achmad/ledgerr/ui/components/CategoryDropdownField.kt deleted file mode 100644 index 441aa55..0000000 --- a/app/src/main/java/dev/achmad/ledgerr/ui/components/CategoryDropdownField.kt +++ /dev/null @@ -1,61 +0,0 @@ -package dev.achmad.ledgerr.ui.components - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuAnchorType -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import dev.achmad.ledgerr.domain.category.model.Category - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun CategoryDropdownField( - categoryId: Long?, - categories: List, - onCategoryChange: (Long) -> Unit, - label: String, - modifier: Modifier = Modifier, -) { - var expanded by remember { mutableStateOf(false) } - val selectedName = categories.firstOrNull { it.id == categoryId }?.name.orEmpty() - - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded }, - modifier = modifier, - ) { - OutlinedTextField( - value = selectedName, - onValueChange = {}, - readOnly = true, - label = { Text(label) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - modifier = Modifier - .fillMaxWidth() - .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled = true), - ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - categories.forEach { category -> - DropdownMenuItem( - text = { Text(category.name) }, - onClick = { - onCategoryChange(category.id) - expanded = false - }, - ) - } - } - } -} diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/components/CategoryPickerField.kt b/app/src/main/java/dev/achmad/ledgerr/ui/components/CategoryPickerField.kt new file mode 100644 index 0000000..f49073a --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/components/CategoryPickerField.kt @@ -0,0 +1,89 @@ +package dev.achmad.ledgerr.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import dev.achmad.ledgerr.R +import dev.achmad.ledgerr.domain.category.model.Category +import androidx.compose.ui.res.stringResource + +@Composable +fun CategoryPickerField( + categoryId: Long?, + categories: List, + onCategoryChange: (Long) -> Unit, + label: String, + modifier: Modifier = Modifier, +) { + var showDialog by remember { mutableStateOf(false) } + val selected = categories.firstOrNull { it.id == categoryId } + + Box( + modifier = modifier + .fillMaxWidth() + .clickable { showDialog = true }, + ) { + OutlinedTextField( + value = selected?.name.orEmpty(), + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + leadingIcon = { + if (selected != null) { + Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(Color(selected.color)) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + shape = CircleShape, + ), + ) + } + }, + trailingIcon = { + Icon( + imageVector = Icons.Outlined.ArrowDropDown, + contentDescription = stringResource(R.string.action_pick), + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + } + + if (showDialog) { + ListSearchDialog( + title = label, + items = categories, + selected = selected, + onSelected = { category -> + onCategoryChange(category.id) + showDialog = false + }, + onDismiss = { showDialog = false }, + labelFor = { it.name }, + swatchColorFor = { it.color }, + ) + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/components/DateField.kt b/app/src/main/java/dev/achmad/ledgerr/ui/components/DateField.kt index 606a4f7..8d9d51e 100644 --- a/app/src/main/java/dev/achmad/ledgerr/ui/components/DateField.kt +++ b/app/src/main/java/dev/achmad/ledgerr/ui/components/DateField.kt @@ -1,6 +1,7 @@ package dev.achmad.ledgerr.ui.components import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CalendarMonth @@ -8,7 +9,6 @@ import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -36,21 +36,25 @@ fun DateField( ) { var showPicker by remember { mutableStateOf(false) } - OutlinedTextField( - value = date.format(dateFormatter()), - onValueChange = {}, - label = { Text(label) }, - readOnly = true, - modifier = modifier.fillMaxWidth().clickable { showPicker = true }, - trailingIcon = { - IconButton(onClick = { showPicker = true }) { + Box( + modifier = modifier + .fillMaxWidth() + .clickable { showPicker = true }, + ) { + OutlinedTextField( + value = date.format(dateFormatter()), + onValueChange = {}, + label = { Text(label) }, + readOnly = true, + trailingIcon = { Icon( imageVector = Icons.Outlined.CalendarMonth, contentDescription = stringResource(R.string.action_pick), ) - } - }, - ) + }, + modifier = Modifier.fillMaxWidth(), + ) + } if (showPicker) { val initialMillis = date.atStartOfDay(ZoneId.of("UTC")) diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/components/ListSearchDialog.kt b/app/src/main/java/dev/achmad/ledgerr/ui/components/ListSearchDialog.kt new file mode 100644 index 0000000..89c4678 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/components/ListSearchDialog.kt @@ -0,0 +1,200 @@ +package dev.achmad.ledgerr.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.achmad.ledgerr.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ListSearchDialog( + title: String, + items: List, + selected: T?, + onSelected: (T) -> Unit, + onDismiss: () -> Unit, + labelFor: (T) -> String, + swatchColorFor: ((T) -> Int)? = null, +) { + val density = LocalDensity.current + var searchBarHeight by remember { mutableStateOf(0.dp) } + var searchQuery by remember { mutableStateOf("") } + val filteredItems by remember { + derivedStateOf { + if (searchQuery.isEmpty()) { + items + } else { + items.filter { labelFor(it).contains(searchQuery, ignoreCase = true) } + } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = title) }, + text = { + Box { + val state = rememberLazyListState() + Column( + modifier = Modifier + .fillMaxWidth() + .onSizeChanged { searchBarHeight = with(density) { it.height.toDp() } }, + ) { + SearchBar( + modifier = Modifier.offset(y = (-4).dp), + inputField = { + SearchBarDefaults.InputField( + query = searchQuery, + onQueryChange = { searchQuery = it }, + onSearch = {}, + expanded = false, + onExpandedChange = {}, + placeholder = { Text(stringResource(R.string.picker_search_hint)) }, + trailingIcon = { + Icon( + imageVector = Icons.Outlined.Search, + contentDescription = null, + ) + }, + ) + }, + shape = RectangleShape, + expanded = false, + onExpandedChange = {}, + content = {}, + ) + HorizontalDivider() + } + if (filteredItems.isEmpty()) { + Text( + text = stringResource(R.string.picker_no_results), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .padding(top = searchBarHeight) + .align(Alignment.Center) + .padding(vertical = 48.dp), + ) + } else { + ScrollbarLazyColumn( + modifier = Modifier + .padding(top = searchBarHeight) + .padding(top = 8.dp), + state = state, + ) { + filteredItems.forEach { current -> + val isSelected = selected == current + item { + ListSearchDialogRow( + label = labelFor(current), + isSelected = isSelected, + swatchColor = swatchColorFor?.invoke(current), + onSelected = { + onSelected(current) + }, + ) + } + } + } + } + HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter)) + if (state.canScrollForward) { + HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter)) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(R.string.cancel)) + } + }, + ) +} + +@Composable +private fun ListSearchDialogRow( + label: String, + isSelected: Boolean, + swatchColor: Int?, + onSelected: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.small) + .selectable( + selected = isSelected, + onClick = { if (!isSelected) onSelected() }, + ) + .padding(horizontal = 8.dp) + .minimumInteractiveComponentSize(), + ) { + if (swatchColor != null) { + Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(Color(swatchColor)) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + shape = CircleShape, + ), + ) + Spacer(modifier = Modifier.width(16.dp)) + } else { + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + if (isSelected) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_expense/AddEditExpenseScreen.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_expense/AddEditExpenseScreen.kt index 6d6af9e..d470089 100644 --- a/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_expense/AddEditExpenseScreen.kt +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_expense/AddEditExpenseScreen.kt @@ -29,7 +29,7 @@ import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import dev.achmad.ledgerr.R import dev.achmad.ledgerr.ui.components.AppBar -import dev.achmad.ledgerr.ui.components.CategoryDropdownField +import dev.achmad.ledgerr.ui.components.CategoryPickerField import dev.achmad.ledgerr.ui.components.DateField import dev.achmad.ledgerr.ui.screens.category.CategoryScreen @@ -96,7 +96,7 @@ data class AddEditExpenseScreen( modifier = Modifier.fillMaxWidth(), ) - CategoryDropdownField( + CategoryPickerField( categoryId = categoryId, categories = categories, onCategoryChange = screenModel::setCategory, diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_expense/AddEditExpenseScreenModel.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_expense/AddEditExpenseScreenModel.kt index 56bd53f..26cb906 100644 --- a/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_expense/AddEditExpenseScreenModel.kt +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_expense/AddEditExpenseScreenModel.kt @@ -8,6 +8,7 @@ import dev.achmad.ledgerr.domain.category.model.Category import dev.achmad.ledgerr.domain.expense.interactor.GetExpenses import dev.achmad.ledgerr.domain.expense.interactor.UpsertExpense import dev.achmad.ledgerr.domain.expense.model.Expense +import dev.achmad.ledgerr.ui.util.sanitizeAmountInput import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -74,7 +75,7 @@ class AddEditExpenseScreenModel( } fun setAmount(value: String) { - _amount.value = value + _amount.value = value.sanitizeAmountInput() refreshIsValid() } diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_recurring/AddEditRecurringScreen.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_recurring/AddEditRecurringScreen.kt index 07b00da..49e52cb 100644 --- a/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_recurring/AddEditRecurringScreen.kt +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_recurring/AddEditRecurringScreen.kt @@ -32,7 +32,7 @@ import cafe.adriel.voyager.navigator.currentOrThrow import dev.achmad.ledgerr.R import dev.achmad.ledgerr.domain.recurring.model.RecurringInterval import dev.achmad.ledgerr.ui.components.AppBar -import dev.achmad.ledgerr.ui.components.CategoryDropdownField +import dev.achmad.ledgerr.ui.components.CategoryPickerField import dev.achmad.ledgerr.ui.components.DateField import dev.achmad.ledgerr.ui.components.LabeledRadioButton import dev.achmad.ledgerr.ui.components.ToggleItem @@ -102,7 +102,7 @@ data class AddEditRecurringScreen( modifier = Modifier.fillMaxWidth(), ) - CategoryDropdownField( + CategoryPickerField( categoryId = categoryId, categories = categories, onCategoryChange = screenModel::setCategory, diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_recurring/AddEditRecurringScreenModel.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_recurring/AddEditRecurringScreenModel.kt index c0a0682..8fea7e1 100644 --- a/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_recurring/AddEditRecurringScreenModel.kt +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_recurring/AddEditRecurringScreenModel.kt @@ -9,6 +9,7 @@ import dev.achmad.ledgerr.domain.recurring.interactor.GetRecurringExpenses import dev.achmad.ledgerr.domain.recurring.interactor.UpsertRecurringExpense import dev.achmad.ledgerr.domain.recurring.model.RecurringExpense import dev.achmad.ledgerr.domain.recurring.model.RecurringInterval +import dev.achmad.ledgerr.ui.util.sanitizeAmountInput import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -86,7 +87,7 @@ class AddEditRecurringScreenModel( } fun setAmount(value: String) { - _amount.value = value + _amount.value = value.sanitizeAmountInput() refreshIsValid() } diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/util/StringUtil.kt b/app/src/main/java/dev/achmad/ledgerr/ui/util/StringUtil.kt index de323d6..4e453a6 100644 --- a/app/src/main/java/dev/achmad/ledgerr/ui/util/StringUtil.kt +++ b/app/src/main/java/dev/achmad/ledgerr/ui/util/StringUtil.kt @@ -19,3 +19,12 @@ fun String.toTitleCase(): String { word.replaceFirstChar { it.uppercase() } } } + +fun String.sanitizeAmountInput(): String { + val parts = split('.', limit = 2) + return when (parts.size) { + 1 -> parts[0].filter(Char::isDigit) + 2 -> parts[0].filter(Char::isDigit) + "." + parts[1].filter(Char::isDigit) + else -> "" + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5c9581e..c447b5e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -126,4 +126,8 @@ No expenses yet for this period Settings Export failed + + + Search… + No matches