taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

commit e9b5a91161ea74eefc326f3739d6b2cfcfc81f7a
parent d077f2e1fd80a2719f48ffd942474a3d313278b4
Author: Iván Ávalos <avalos@disroot.org>
Date:   Sat, 22 Nov 2025 14:31:37 +0100

[wallet] amount input improvements

Diffstat:
Mtaler-kotlin-android/src/main/java/net/taler/common/CurrencySpecification.kt | 2++
Mwallet/src/main/java/net/taler/wallet/compose/AmountInputFIeld.kt | 63++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mwallet/src/main/java/net/taler/wallet/compose/AmountScopeField.kt | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt | 19++++++++++++++++++-
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt | 19++++++++++++++++++-
Mwallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt | 14++------------
6 files changed, 180 insertions(+), 66 deletions(-)

diff --git a/taler-kotlin-android/src/main/java/net/taler/common/CurrencySpecification.kt b/taler-kotlin-android/src/main/java/net/taler/common/CurrencySpecification.kt @@ -30,6 +30,8 @@ data class CurrencySpecification( val numFractionalTrailingZeroDigits: Int, @SerialName("alt_unit_names") val altUnitNames: Map<Int, String>, + @SerialName("common_amounts") + val commonAmounts: List<Amount>? = null, ) { // TODO: add support for alt units val symbol: String? get() = altUnitNames[0] diff --git a/wallet/src/main/java/net/taler/wallet/compose/AmountInputFIeld.kt b/wallet/src/main/java/net/taler/wallet/compose/AmountInputFIeld.kt @@ -28,9 +28,14 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Backspace import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -44,12 +49,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.utf16CodePoint +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalTextInputService import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.InternalTextApi @@ -84,6 +91,8 @@ fun AmountCurrencyField( supportingText: @Composable (() -> Unit)? = null, isError: Boolean = false, readOnly: Boolean = false, + keyboardActions: KeyboardActions = KeyboardActions.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), enabled: Boolean = true, showShortcuts: Boolean = false, onShortcutSelected: ((amount: Amount) -> Unit)? = null, @@ -101,8 +110,10 @@ fun AmountCurrencyField( supportingText = supportingText, readOnly = readOnly, enabled = enabled, + keyboardActions = keyboardActions, + keyboardOptions = keyboardOptions, showSymbol = !editableCurrency - || amount.currency != amount.spec?.symbol + || amount.currency != amount.spec?.symbol, ) if (editableCurrency) { @@ -219,9 +230,13 @@ internal fun AmountInputFieldBase( readOnly: Boolean = false, enabled: Boolean = true, showSymbol: Boolean = true, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), + keyboardActions: KeyboardActions = KeyboardActions.Default, ) { // TODO: use non-deprecated PlatformTextInputModifierNode instead val inputService = LocalTextInputService.current + val focusManager = LocalFocusManager.current + val interactionSource = remember { MutableInteractionSource() } val isFocused: Boolean by interactionSource.collectIsFocusedAsState() val isClicked: Boolean by interactionSource.collectIsPressedAsState() @@ -244,15 +259,25 @@ internal fun AmountInputFieldBase( LaunchedEffect(isFocused, isClicked) { if (readOnly && !enabled) return@LaunchedEffect if (isFocused || isClicked) { - session = startSession(inputService) { commands -> - commands.forEach { cmd -> - when (cmd) { - is BackspaceCommand -> currentOnRemoveDigit() - is DeleteSurroundingTextCommand -> currentOnRemoveDigit() - is CommitTextCommand -> cmd.text.forEach { currentOnEnterDigit(it) } + session = startSession( + imeAction = keyboardOptions.imeAction, + textInputService = inputService, + onEditCommand = { commands -> + commands.forEach { cmd -> + when (cmd) { + is BackspaceCommand -> currentOnRemoveDigit() + is DeleteSurroundingTextCommand -> currentOnRemoveDigit() + is CommitTextCommand -> cmd.text.forEach { currentOnEnterDigit(it) } + } + } + }, + onImeActionPerformed = { action -> + when (action) { + ImeAction.Done -> focusManager.clearFocus() + ImeAction.Next -> focusManager.moveFocus(FocusDirection.Next) } } - } + ) } else if (session != null) { session?.let { inputService?.stopInput(it) } session = null @@ -279,19 +304,32 @@ internal fun AmountInputFieldBase( isError = isError, keyboardOptions = KeyboardOptions.Default.copy( keyboardType = KeyboardType.NumberPassword, - ), + ).merge(keyboardOptions), + keyboardActions = keyboardActions, singleLine = true, maxLines = 1, interactionSource = interactionSource, enabled = enabled, + trailingIcon = { + if (!amount.isZero()) IconButton(onClick = { + onAmountChanged(amount.minus(amount)) + }) { + Icon( + Icons.AutoMirrored.Default.Backspace, + contentDescription = stringResource(R.string.reset), + ) + } + } ) } @SuppressLint("RestrictedApi") @OptIn(InternalTextApi::class) fun startSession( + imeAction: ImeAction = ImeAction.Done, textInputService: TextInputService?, onEditCommand: (List<EditCommand>) -> Unit, + onImeActionPerformed: (ImeAction) -> Unit, ): TextInputSession? = textInputService?.let { service -> service.startInput( TextFieldValue(), @@ -300,13 +338,12 @@ fun startSession( autoCorrect = false, capitalization = KeyboardCapitalization.None, keyboardType = KeyboardType.NumberPassword, - imeAction = ImeAction.Done, + imeAction = imeAction, ), onEditCommand = onEditCommand, onImeActionPerformed = { action -> - if (action == ImeAction.Done) { - service.stopInput() - } + service.stopInput() + onImeActionPerformed(action) } ) } \ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/compose/AmountScopeField.kt b/wallet/src/main/java/net/taler/wallet/compose/AmountScopeField.kt @@ -16,19 +16,24 @@ package net.taler.wallet.compose +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -37,9 +42,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import net.taler.common.Amount +import net.taler.common.CurrencySpecification import net.taler.wallet.R import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.cleanExchange @@ -60,6 +69,8 @@ fun AmountScopeField( supportingText: @Composable (() -> Unit)? = null, isError: Boolean = false, readOnly: Boolean = false, + keyboardActions: KeyboardActions = KeyboardActions.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), enabledAmount: Boolean = true, enabledScope: Boolean = true, showShortcuts: Boolean = false, @@ -95,25 +106,25 @@ fun AmountScopeField( isError = isError, supportingText = supportingText, readOnly = readOnly, + keyboardActions = keyboardActions, + keyboardOptions = keyboardOptions, showSymbol = true, ) - if (showShortcuts) { - val currency = amount.amount.currency - AmountInputShortcuts( - // TODO: currency-appropriate presets - amounts = listOf( - Amount.fromString(currency, "50").withSpec(amount.amount.spec), - Amount.fromString(currency, "25").withSpec(amount.amount.spec), - Amount.fromString(currency, "10").withSpec(amount.amount.spec), - Amount.fromString(currency, "5").withSpec(amount.amount.spec), - ), - onSelected = { shortcut -> - onShortcutSelected?.let { - it(amount.copy(amount = shortcut)) - } - }, - ) + val commonAmounts = amount.amount.spec?.commonAmounts?.map { + it.withSpec(amount.amount.spec) } + AnimatedVisibility(showShortcuts && amount.amount.isZero() && commonAmounts != null) { + if (commonAmounts != null) { + AmountInputShortcuts( + modifier = Modifier.padding(top = 10.dp), + amounts = commonAmounts, + onSelected = { shortcut -> + onShortcutSelected?.let { + it(amount.copy(amount = shortcut)) + } + }, + ) + } } } } @@ -137,27 +148,53 @@ fun ScopeDropdown( ?: initialScope ?: error("no scope available") - OutlinedTextField( + val value = when (scope) { + is ScopeInfo.Global -> scope.currency + is ScopeInfo.Exchange -> cleanExchange(scope.url) + is ScopeInfo.Auditor -> cleanExchange(scope.url) + } + + val colors = OutlinedTextFieldDefaults.colors() + val singleLine = true + val enabled = false + val interactionSource = remember { MutableInteractionSource() } + + BasicTextField( + value = value, modifier = Modifier - .clickable(onClick = { if (!readOnly) expanded = true }) + .height(45.dp) + .clickable { if (!readOnly) expanded = true } .fillMaxWidth(), - value = when (scope) { - is ScopeInfo.Global -> scope.currency - is ScopeInfo.Exchange -> cleanExchange(scope.url) - is ScopeInfo.Auditor -> cleanExchange(scope.url) - }, - prefix = { Text( - modifier = Modifier.padding(end = 6.dp), - text = stringResource(R.string.currency_via) - ) }, onValueChange = { }, + enabled = enabled, readOnly = true, - enabled = false, - textStyle = LocalTextStyle.current.copy( // show text as if not disabled - color = MaterialTheme.colorScheme.onSurfaceVariant - ), - singleLine = true, + textStyle = TextStyle(color = colors.focusedTextColor), + interactionSource = interactionSource, + singleLine = singleLine, + decorationBox = + @Composable { innerTextField -> + OutlinedTextFieldDefaults.DecorationBox( + value = value, + innerTextField = innerTextField, + singleLine = singleLine, + enabled = enabled, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + prefix = { + Text( + modifier = Modifier.padding(end = 6.dp), + text = stringResource(R.string.currency_via), + ) + }, + contentPadding = OutlinedTextFieldDefaults.contentPadding( + top = 0.dp, + bottom = 0.dp, + ), + colors = TextFieldDefaults.colors(), + ) + }, ) + DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, @@ -193,13 +230,13 @@ fun ScopeDropdown( @Composable private fun AmountInputShortcuts( + modifier: Modifier = Modifier, amounts: List<Amount>, onSelected: (amount: Amount) -> Unit, ) { FlowRow( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), + modifier = modifier + .fillMaxWidth(), maxItemsInEachRow = 2, horizontalArrangement = Arrangement.SpaceEvenly, ) { @@ -220,7 +257,21 @@ fun AmountInputFieldPreview() { TalerSurface { var amount by remember { mutableStateOf(AmountScope( - amount = Amount.fromJSONString("KUDOS:10"), + amount = Amount.fromJSONString("KUDOS:10").withSpec( + CurrencySpecification( + name = "Kudos", + numFractionalInputDigits = 2, + numFractionalNormalDigits = 2, + numFractionalTrailingZeroDigits = 2, + altUnitNames = mapOf(), + commonAmounts = listOf( + Amount.fromJSONString("KUDOS:5"), + Amount.fromJSONString("KUDOS:10"), + Amount.fromJSONString("KUDOS:25"), + Amount.fromJSONString("KUDOS:50"), + ), + ), + ), scope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), )) } diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -42,8 +43,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlinx.serialization.json.JsonPrimitive @@ -106,6 +112,9 @@ fun OutgoingPullComposable( return } + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + Column( Modifier .fillMaxSize() @@ -125,8 +134,15 @@ fun OutgoingPullComposable( amount = amount.copy(amount = amount.amount.withSpec(selectedSpec)), scopes = scopes, readOnly = false, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), enabledAmount = !tosReview, + showShortcuts = true, onAmountChanged = { amount = it }, + onShortcutSelected = { + amount = it + focusManager.moveFocus(FocusDirection.Next) + focusRequester.requestFocus() + }, isError = amount.amount.isZero(), label = { Text(stringResource(R.string.amount_receive)) }, ) @@ -145,7 +161,8 @@ fun OutgoingPullComposable( OutlinedTextField( modifier = Modifier .padding(horizontal = 16.dp) - .fillMaxWidth(), + .fillMaxWidth() + .focusRequester(focusRequester), singleLine = true, value = subject, onValueChange = { input -> diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme @@ -38,8 +39,13 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlinx.serialization.json.JsonPrimitive @@ -116,6 +122,9 @@ fun OutgoingPushIntroComposable( feeResult = getFees(amount) ?: None() } + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + Column( Modifier .fillMaxSize() @@ -154,7 +163,14 @@ fun OutgoingPushIntroComposable( amount = amount.copy(amount = amount.amount.withSpec(selectedSpec)), scopes = scopes, readOnly = false, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + showShortcuts = true, onAmountChanged = { amount = it }, + onShortcutSelected = { + amount = it + focusManager.moveFocus(FocusDirection.Next) + focusRequester.requestFocus() + }, label = { Text(stringResource(R.string.amount_send)) }, isError = amount.amount.isZero() || feeResult is InsufficientBalance, supportingText = { @@ -188,7 +204,8 @@ fun OutgoingPushIntroComposable( OutlinedTextField( modifier = Modifier .padding(horizontal = 16.dp) - .fillMaxWidth(), + .fillMaxWidth() + .focusRequester(focusRequester), singleLine = true, value = subject, onValueChange = { input -> diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt @@ -35,15 +35,12 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect 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.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -93,7 +90,6 @@ fun WithdrawalShowInfo( val possibleExchanges = status.uriInfo?.possibleExchanges ?: emptyList() val ageRestrictionOptions = status.amountInfo?.ageRestrictionOptions ?: emptyList() - val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current var selectedAmount by remember { mutableStateOf(AmountScope(defaultAmount, defaultScope)) } var selectedAge by remember { mutableStateOf<Int?>(null) } @@ -129,8 +125,7 @@ fun WithdrawalShowInfo( if (editableScope) AmountScopeField( modifier = Modifier .padding(horizontal = 16.dp) - .fillMaxWidth() - .focusRequester(focusRequester), + .fillMaxWidth(), amount = selectedAmount.copy( amount = selectedAmount.amount.withSpec(spec) ), @@ -158,8 +153,7 @@ fun WithdrawalShowInfo( modifier = Modifier .padding(horizontal = 16.dp) .padding(bottom = 16.dp) - .fillMaxWidth() - .focusRequester(focusRequester), + .fillMaxWidth(), amount = selectedAmount.copy( amount = selectedAmount.amount.withSpec(spec)), scopes = scopes, @@ -192,10 +186,6 @@ fun WithdrawalShowInfo( modifier = Modifier.padding(22.dp), text = stringResource(R.string.withdraw_review_terms), ) - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } } else { TransactionAmountComposable( label = if (wireFee.isZero()) {