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:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+2
-1
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+2
-1
@@ -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 -> ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user