Merge pull request 'fix(#41,#42,#43): numeric amount input, clickable DateField, search-based category picker' (#47) from fix/41-42-43-form-input-fixes into main

Reviewed-on: #47
This commit was merged in pull request #47.
This commit is contained in:
2026-06-28 14:59:29 +00:00
10 changed files with 326 additions and 79 deletions
@@ -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<Category>,
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
},
)
}
}
}
}
@@ -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<Category>,
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 },
)
}
}
@@ -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"))
@@ -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 <T : Any> ListSearchDialog(
title: String,
items: List<T>,
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,
)
}
}
}
@@ -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,
@@ -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()
}
@@ -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,
@@ -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()
}
@@ -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 -> ""
}
}
+4
View File
@@ -126,4 +126,8 @@
<string name="home_dashboard_empty">No expenses yet for this period</string>
<string name="home_settings">Settings</string>
<string name="home_export_failure">Export failed</string>
<!-- Picker dialog (issue #43) -->
<string name="picker_search_hint">Search…</string>
<string name="picker_no_results">No matches</string>
</resources>