Compare commits
2
Commits
b518c2a8c2
...
642b3b9c68
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
642b3b9c68
|
||
|
|
291694eaf4
|
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user