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:
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()) {