2 Commits
Author SHA1 Message Date
sora 642b3b9c68 Fix keyboard (and add calculator bar) 2026-09-30 22:41:10 +02:00
sora 291694eaf4 Allow Comma values 2026-09-30 22:18:25 +02:00
2 changed files with 175 additions and 46 deletions
@@ -1,11 +1,14 @@
package net.helcel.cowspent.android.bill_edit package net.helcel.cowspent.android.bill_edit
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Build
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
@@ -19,18 +22,24 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@@ -62,6 +71,8 @@ fun EditBillScreen(
val canEdit = accessLevel == DBProject.ACCESS_LEVEL_UNKNOWN || accessLevel >= DBProject.ACCESS_LEVEL_PARTICIPANT val canEdit = accessLevel == DBProject.ACCESS_LEVEL_UNKNOWN || accessLevel >= DBProject.ACCESS_LEVEL_PARTICIPANT
val context = LocalContext.current val context = LocalContext.current
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
var amountFocused by remember { mutableStateOf(false) }
var amountSelection by remember { mutableStateOf(TextRange(viewModel.amount.length)) }
// The activity reads the project off the main thread and only then reports whether this is a // The activity reads the project off the main thread and only then reports whether this is a
// new bill, so the flag arrives after the first composition. Keying on it rather than on Unit // new bill, so the flag arrives after the first composition. Keying on it rather than on Unit
@@ -118,16 +129,21 @@ fun EditBillScreen(
val errorOwers = stringResource(R.string.error_invalid_bill_owers) val errorOwers = stringResource(R.string.error_invalid_bill_owers)
val errorInvalidForm = stringResource(R.string.error_generic) val errorInvalidForm = stringResource(R.string.error_generic)
FloatingActionButton(onClick = { FloatingActionButton(
val validationError = viewModel.getValidationError( // Keep the button clear of the operator row pinned above the keyboard.
errorWhat, errorDate, errorPayer, errorOwers, errorInvalidForm modifier = Modifier.padding(
) bottom = if (amountFocused) MathOperatorRowHeight else 0.dp
if (validationError == null) { ),
onSave() onClick = {
} else { val validationError = viewModel.getValidationError(
showToast(context, validationError) errorWhat, errorDate, errorPayer, errorOwers, errorInvalidForm
} )
}) { if (validationError == null) {
onSave()
} else {
showToast(context, validationError)
}
}) {
Icon( Icon(
Icons.Default.Done, Icons.Default.Done,
contentDescription = stringResource(R.string.action_save) contentDescription = stringResource(R.string.action_save)
@@ -141,42 +157,138 @@ fun EditBillScreen(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.imePadding() .imePadding()
.padding(16.dp)
.fillMaxSize() .fillMaxSize()
.verticalScroll(scrollState)
) { ) {
BillBasicInfoSection( Column(
viewModel = viewModel, modifier = Modifier
canEdit = canEdit, .weight(1f)
onDateClick = onDateClick, .padding(16.dp)
onTimeClick = onTimeClick, .verticalScroll(scrollState)
amountFocusRequester = focusRequester ) {
) BillBasicInfoSection(
viewModel = viewModel,
canEdit = canEdit,
onDateClick = onDateClick,
onTimeClick = onTimeClick,
amountFocusRequester = focusRequester,
amountSelection = amountSelection,
onAmountSelectionChange = { amountSelection = it },
onAmountFocusChange = { amountFocused = it }
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
PayerSection( PayerSection(
viewModel = viewModel, viewModel = viewModel,
canEdit = canEdit canEdit = canEdit
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
OwerSelectionSection( OwerSelectionSection(
viewModel = viewModel, viewModel = viewModel,
canEdit = canEdit canEdit = canEdit
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
BillAdditionalDetailsSection( BillAdditionalDetailsSection(
viewModel = viewModel, viewModel = viewModel,
categories = categories, categories = categories,
paymentModes = paymentModes, paymentModes = paymentModes,
canEdit = canEdit canEdit = canEdit
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
}
if (amountFocused && canEdit) {
MathOperatorRow(onInsert = { op ->
val text = viewModel.amount
val start = amountSelection.min.coerceIn(0, text.length)
val end = amountSelection.max.coerceIn(0, text.length)
viewModel.amount = text.replaceRange(start, end, op)
amountSelection = TextRange(start + op.length)
viewModel.updateSplits()
})
}
}
}
}
private val MathOperatorRowHeight = 54.dp
private data class KeyboardColors(val tray: Color, val edge: Color, val key: Color, val label: Color)
@Composable
private fun keyboardColors(): KeyboardColors {
val dark = isSystemInDarkTheme()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val (tray, edge) = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> if (dark) {
colorResource(android.R.color.system_surface_container_dark) to
colorResource(android.R.color.system_surface_container_high_dark)
} else {
colorResource(android.R.color.system_surface_container_light) to
colorResource(android.R.color.system_surface_container_high_light)
}
else -> if (dark) {
colorResource(android.R.color.system_neutral1_900) to
colorResource(android.R.color.system_neutral1_800)
} else {
colorResource(android.R.color.system_neutral1_50) to
colorResource(android.R.color.system_neutral1_100)
}
}
if (dark) KeyboardColors(
tray = tray,
edge = edge,
key = colorResource(android.R.color.system_accent2_700),
label = colorResource(android.R.color.system_neutral1_50)
) else KeyboardColors(
tray = tray,
edge = edge,
key = colorResource(android.R.color.system_accent2_100),
label = colorResource(android.R.color.system_neutral1_900)
)
} else {
if (dark) KeyboardColors(Color(0xFF1B1B1F), Color(0xFF2B2B2F), Color(0xFF43474E), Color(0xFFE3E2E6))
else KeyboardColors(Color(0xFFEEEDF6), Color(0xFFE7E7F0), Color(0xFFDDE2F9), Color(0xFF1B1B1F))
}
}
/** Operator keys shown above the numeric keypad, which has no room for them itself. */
@Composable
fun MathOperatorRow(onInsert: (String) -> Unit) {
val colors = keyboardColors()
Surface(color = colors.tray) {
// Flat pill-shaped keys, styled like the keyboard's own function keys (e.g. its minus).
val keyShape = RoundedCornerShape(percent = 50)
Row(
modifier = Modifier
.fillMaxWidth()
.height(MathOperatorRowHeight)
.drawBehind {
// A thin edge in the same tone the keyboard uses for its own top border.
drawLine(colors.edge, Offset(0f, 0f), Offset(size.width, 0f), 1.dp.toPx())
}
.padding(horizontal = 3.dp, vertical = 5.dp)
) {
listOf("(" to "(", ")" to ")", "+" to "+", "−" to "-", "×" to "*", "÷" to "/").forEach { (label, op) ->
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(horizontal = 3.dp)
.clip(keyShape)
.background(colors.key)
.clickable { onInsert(op) },
contentAlignment = Alignment.Center
) {
Text(label, fontSize = 24.sp, color = colors.label)
}
}
} }
} }
} }
@@ -187,7 +299,10 @@ fun BillBasicInfoSection(
canEdit: Boolean, canEdit: Boolean,
onDateClick: () -> Unit, onDateClick: () -> Unit,
onTimeClick: () -> Unit, onTimeClick: () -> Unit,
amountFocusRequester: FocusRequester amountFocusRequester: FocusRequester,
amountSelection: TextRange = TextRange(viewModel.amount.length),
onAmountSelectionChange: (TextRange) -> Unit = {},
onAmountFocusChange: (Boolean) -> Unit = {}
) { ) {
Text( Text(
text = "GENERAL", text = "GENERAL",
@@ -203,15 +318,29 @@ fun BillBasicInfoSection(
stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName) stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName)
OutlinedTextField( OutlinedTextField(
value = viewModel.amount, value = TextFieldValue(
text = viewModel.amount,
selection = TextRange(
amountSelection.start.coerceIn(0, viewModel.amount.length),
amountSelection.end.coerceIn(0, viewModel.amount.length)
)
),
onValueChange = { nv -> onValueChange = { nv ->
val filteredValue = nv.filter { it in "0123456789.+-*/" } val filteredValue = nv.text.filter { it in "0123456789.,+-*/()" }
viewModel.amount = filteredValue onAmountSelectionChange(
viewModel.updateSplits() if (filteredValue == nv.text) nv.selection else TextRange(filteredValue.length)
)
if (filteredValue != viewModel.amount) {
viewModel.amount = filteredValue
viewModel.updateSplits()
}
}, },
enabled = canEdit, enabled = canEdit,
placeholder = { Text("0") }, placeholder = { Text("0") },
modifier = Modifier.fillMaxWidth().focusRequester(amountFocusRequester), modifier = Modifier
.fillMaxWidth()
.focusRequester(amountFocusRequester)
.onFocusChanged { onAmountFocusChange(it.isFocused) },
leadingIcon = { leadingIcon = {
val currencyToShow = viewModel.selectedCurrencyName.ifEmpty { val currencyToShow = viewModel.selectedCurrencyName.ifEmpty {
viewModel.mainCurrencyName.ifEmpty { "$" } viewModel.mainCurrencyName.ifEmpty { "$" }
@@ -245,7 +374,7 @@ fun BillBasicInfoSection(
Icon(Icons.Default.SwapHoriz, contentDescription = "Change Currency") Icon(Icons.Default.SwapHoriz, contentDescription = "Change Currency")
} }
}, },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone, imeAction = ImeAction.Next), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }) keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
) )
@@ -473,7 +602,7 @@ fun OwerSelectionSection(
BasicTextField( BasicTextField(
value = value, value = value,
onValueChange = { nv -> onValueChange = { nv ->
val filteredValue = nv.filter { it in "0123456789.+-*/" } val filteredValue = nv.filter { it in "0123456789,.+-*/" }
if (viewModel.splitMode == SplitMode.PERCENT) { if (viewModel.splitMode == SplitMode.PERCENT) {
viewModel.owersPercentSplit[member.id] = filteredValue viewModel.owersPercentSplit[member.id] = filteredValue
} else { } else {
@@ -48,7 +48,7 @@ class EditBillViewModel : ViewModel() {
if (input.isBlank()) return 0.0 if (input.isBlank()) return 0.0
val sanitized = input.replace(',', '.') val sanitized = input.replace(',', '.')
// If it contains any math operator, try evalMath first // If it contains any math operator, try evalMath first
if (sanitized.any { it in "+-*/" }) { if (sanitized.any { it in "+-*/()" }) {
try { try {
val result = evalMath(sanitized) val result = evalMath(sanitized)
if (result != 0.0) return SupportUtil.round2(result) if (result != 0.0) return SupportUtil.round2(result)