AmountInputFIeld.kt (12872B)
1 /* 2 * This file is part of GNU Taler 3 * (C) 2024 Taler Systems S.A. 4 * 5 * GNU Taler is free software; you can redistribute it and/or modify it under the 6 * terms of the GNU General Public License as published by the Free Software 7 * Foundation; either version 3, or (at your option) any later version. 8 * 9 * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY 10 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 * A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 * 13 * You should have received a copy of the GNU General Public License along with 14 * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 package net.taler.wallet.compose 18 19 import android.annotation.SuppressLint 20 import androidx.compose.foundation.clickable 21 import androidx.compose.foundation.interaction.MutableInteractionSource 22 import androidx.compose.foundation.interaction.collectIsFocusedAsState 23 import androidx.compose.foundation.interaction.collectIsPressedAsState 24 import androidx.compose.foundation.layout.Arrangement 25 import androidx.compose.foundation.layout.Box 26 import androidx.compose.foundation.layout.Column 27 import androidx.compose.foundation.layout.FlowRow 28 import androidx.compose.foundation.layout.Row 29 import androidx.compose.foundation.layout.fillMaxWidth 30 import androidx.compose.foundation.layout.padding 31 import androidx.compose.foundation.text.KeyboardActions 32 import androidx.compose.foundation.text.KeyboardOptions 33 import androidx.compose.material.icons.Icons 34 import androidx.compose.material.icons.automirrored.filled.Backspace 35 import androidx.compose.material3.DropdownMenu 36 import androidx.compose.material3.DropdownMenuItem 37 import androidx.compose.material3.Icon 38 import androidx.compose.material3.IconButton 39 import androidx.compose.material3.LocalTextStyle 40 import androidx.compose.material3.MaterialTheme 41 import androidx.compose.material3.OutlinedTextField 42 import androidx.compose.material3.Text 43 import androidx.compose.runtime.Composable 44 import androidx.compose.runtime.LaunchedEffect 45 import androidx.compose.runtime.getValue 46 import androidx.compose.runtime.mutableIntStateOf 47 import androidx.compose.runtime.mutableStateOf 48 import androidx.compose.runtime.remember 49 import androidx.compose.runtime.rememberUpdatedState 50 import androidx.compose.runtime.setValue 51 import androidx.compose.ui.Modifier 52 import androidx.compose.ui.focus.FocusDirection 53 import androidx.compose.ui.input.key.Key 54 import androidx.compose.ui.input.key.KeyEventType 55 import androidx.compose.ui.input.key.key 56 import androidx.compose.ui.input.key.onKeyEvent 57 import androidx.compose.ui.input.key.type 58 import androidx.compose.ui.input.key.utf16CodePoint 59 import androidx.compose.ui.platform.LocalFocusManager 60 import androidx.compose.ui.platform.LocalTextInputService 61 import androidx.compose.ui.res.stringResource 62 import androidx.compose.ui.text.InternalTextApi 63 import androidx.compose.ui.text.input.BackspaceCommand 64 import androidx.compose.ui.text.input.CommitTextCommand 65 import androidx.compose.ui.text.input.DeleteSurroundingTextCommand 66 import androidx.compose.ui.text.input.EditCommand 67 import androidx.compose.ui.text.input.ImeAction 68 import androidx.compose.ui.text.input.ImeOptions 69 import androidx.compose.ui.text.input.KeyboardCapitalization 70 import androidx.compose.ui.text.input.KeyboardType 71 import androidx.compose.ui.text.input.TextFieldValue 72 import androidx.compose.ui.text.input.TextInputService 73 import androidx.compose.ui.text.input.TextInputSession 74 import androidx.compose.ui.unit.dp 75 import net.taler.common.Amount 76 import net.taler.wallet.R 77 78 @Deprecated( 79 message = "Use AmountScopeField for scopeInfo support", 80 replaceWith = ReplaceWith("AmountScopeField"), 81 ) 82 @Composable 83 fun AmountCurrencyField( 84 modifier: Modifier = Modifier, 85 amount: Amount, 86 editableCurrency: Boolean = true, 87 currencies: List<String>, 88 onAmountChanged: (amount: Amount) -> Unit, 89 label: @Composable (() -> Unit)? = null, 90 supportingText: @Composable (() -> Unit)? = null, 91 isError: Boolean = false, 92 readOnly: Boolean = false, 93 keyboardActions: KeyboardActions = KeyboardActions.Default, 94 keyboardOptions: KeyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), 95 enabled: Boolean = true, 96 showShortcuts: Boolean = false, 97 onShortcutSelected: ((amount: Amount) -> Unit)? = null, 98 ) { 99 Column(modifier) { 100 Row { 101 AmountInputFieldBase( 102 modifier = Modifier 103 .weight(2f, true) 104 .padding(end = 16.dp), 105 amount = amount, 106 onAmountChanged = onAmountChanged, 107 label = label, 108 isError = isError, 109 supportingText = supportingText, 110 readOnly = readOnly, 111 enabled = enabled, 112 keyboardActions = keyboardActions, 113 keyboardOptions = keyboardOptions, 114 showSymbol = !editableCurrency 115 || amount.currency != amount.spec?.symbol, 116 ) 117 118 if (editableCurrency) { 119 CurrencyDropdown( 120 modifier = Modifier.weight(1f), 121 currencies = currencies, 122 onCurrencyChanged = { onAmountChanged(amount.copy(currency = it, spec = null)) }, 123 initialCurrency = amount.currency, 124 readOnly = readOnly || !enabled, 125 ) 126 } 127 } 128 129 val commonAmounts = amount.spec?.commonAmounts 130 if (showShortcuts && commonAmounts != null) { 131 AmountInputShortcuts( 132 amounts = commonAmounts, 133 onSelected = { shortcut -> 134 onShortcutSelected?.let { it(shortcut) } 135 }, 136 ) 137 } 138 } 139 } 140 141 @Composable 142 private fun CurrencyDropdown( 143 currencies: List<String>, 144 onCurrencyChanged: (String) -> Unit, 145 modifier: Modifier = Modifier, 146 initialCurrency: String? = null, 147 readOnly: Boolean = false, 148 ) { 149 val initialIndex = currencies.indexOf(initialCurrency).let { if (it < 0) 0 else it } 150 var selectedIndex by remember { mutableIntStateOf(initialIndex) } 151 var expanded by remember { mutableStateOf(false) } 152 Box( 153 modifier = modifier, 154 ) { 155 OutlinedTextField( 156 modifier = Modifier 157 .clickable(onClick = { if (!readOnly) expanded = true }) 158 .fillMaxWidth(), 159 value = currencies.getOrNull(selectedIndex) 160 ?: initialCurrency // wallet is empty or currency is new 161 ?: error("no currency available"), 162 onValueChange = { }, 163 readOnly = true, 164 enabled = false, 165 textStyle = LocalTextStyle.current.copy( // show text as if not disabled 166 color = MaterialTheme.colorScheme.onSurfaceVariant, 167 fontSize = MaterialTheme.typography.titleLarge.fontSize, 168 ), 169 singleLine = true, 170 label = { 171 Text(stringResource(R.string.currency)) 172 } 173 ) 174 DropdownMenu( 175 expanded = expanded, 176 onDismissRequest = { expanded = false }, 177 modifier = Modifier, 178 ) { 179 currencies.forEachIndexed { index, s -> 180 DropdownMenuItem( 181 text = { 182 Text(text = s) 183 }, 184 onClick = { 185 selectedIndex = index 186 onCurrencyChanged(s) 187 expanded = false 188 } 189 ) 190 } 191 } 192 } 193 } 194 195 @Composable 196 private fun AmountInputShortcuts( 197 amounts: List<Amount>, 198 onSelected: (amount: Amount) -> Unit, 199 ) { 200 FlowRow( 201 modifier = Modifier.fillMaxWidth(), 202 maxItemsInEachRow = 2, 203 horizontalArrangement = Arrangement.SpaceEvenly, 204 ) { 205 amounts.forEach { 206 SelectionChip ( 207 selected = false, 208 label = { Text(it.toString()) }, 209 value = it, 210 onSelected = onSelected, 211 ) 212 } 213 } 214 } 215 216 @Composable 217 internal fun AmountInputFieldBase( 218 amount: Amount, 219 onAmountChanged: (amount: Amount) -> Unit, 220 modifier: Modifier, 221 label: @Composable (() -> Unit)? = null, 222 supportingText: @Composable (() -> Unit)? = null, 223 isError: Boolean = false, 224 readOnly: Boolean = false, 225 enabled: Boolean = true, 226 showSymbol: Boolean = true, 227 keyboardOptions: KeyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), 228 keyboardActions: KeyboardActions = KeyboardActions.Default, 229 ) { 230 // TODO: use non-deprecated PlatformTextInputModifierNode instead 231 val inputService = LocalTextInputService.current 232 val focusManager = LocalFocusManager.current 233 234 val interactionSource = remember { MutableInteractionSource() } 235 val isFocused: Boolean by interactionSource.collectIsFocusedAsState() 236 val isClicked: Boolean by interactionSource.collectIsPressedAsState() 237 var session by remember { mutableStateOf<TextInputSession?>(null) } 238 239 val currentOnEnterDigit by rememberUpdatedState { digit: Char -> 240 amount.addInputDigit(digit)?.let { 241 onAmountChanged(it) 242 true 243 } ?: false 244 } 245 246 val currentOnRemoveDigit by rememberUpdatedState { 247 amount.removeInputDigit()?.let { 248 onAmountChanged(it) 249 true 250 } ?: false 251 } 252 253 LaunchedEffect(isFocused, isClicked) { 254 if (readOnly && !enabled) return@LaunchedEffect 255 if (isFocused || isClicked) { 256 session = startSession( 257 imeAction = keyboardOptions.imeAction, 258 textInputService = inputService, 259 onEditCommand = { commands -> 260 commands.forEach { cmd -> 261 when (cmd) { 262 is BackspaceCommand -> currentOnRemoveDigit() 263 is DeleteSurroundingTextCommand -> currentOnRemoveDigit() 264 is CommitTextCommand -> cmd.text.forEach { currentOnEnterDigit(it) } 265 } 266 } 267 }, 268 onImeActionPerformed = { action -> 269 when (action) { 270 ImeAction.Done -> focusManager.clearFocus() 271 ImeAction.Next -> focusManager.moveFocus(FocusDirection.Next) 272 } 273 } 274 ) 275 } else if (session != null) { 276 session?.let { inputService?.stopInput(it) } 277 session = null 278 } 279 } 280 281 OutlinedTextField( 282 value = amount.toString(showSymbol = showSymbol), 283 onValueChange = {}, 284 modifier = modifier.onKeyEvent { 285 if (it.type == KeyEventType.KeyDown) return@onKeyEvent false 286 if (it.key == Key.Backspace) { 287 currentOnRemoveDigit() 288 } else { 289 currentOnEnterDigit(it.utf16CodePoint.toChar()) 290 } 291 }, 292 readOnly = true, 293 textStyle = LocalTextStyle.current.copy( 294 fontSize = MaterialTheme.typography.titleLarge.fontSize, 295 ), 296 label = label, 297 supportingText = supportingText, 298 isError = isError, 299 keyboardOptions = KeyboardOptions.Default.copy( 300 keyboardType = KeyboardType.NumberPassword, 301 ).merge(keyboardOptions), 302 keyboardActions = keyboardActions, 303 singleLine = true, 304 maxLines = 1, 305 interactionSource = interactionSource, 306 enabled = enabled, 307 trailingIcon = { 308 if (!readOnly && !amount.isZero()) IconButton(onClick = { 309 onAmountChanged(amount.minus(amount)) 310 }) { 311 Icon( 312 Icons.AutoMirrored.Default.Backspace, 313 contentDescription = stringResource(R.string.reset), 314 ) 315 } 316 } 317 ) 318 } 319 320 @SuppressLint("RestrictedApi") 321 @OptIn(InternalTextApi::class) 322 fun startSession( 323 imeAction: ImeAction = ImeAction.Done, 324 textInputService: TextInputService?, 325 onEditCommand: (List<EditCommand>) -> Unit, 326 onImeActionPerformed: (ImeAction) -> Unit, 327 ): TextInputSession? = textInputService?.let { service -> 328 service.startInput( 329 TextFieldValue(), 330 imeOptions = ImeOptions.Default.copy( 331 singleLine = false, 332 autoCorrect = false, 333 capitalization = KeyboardCapitalization.None, 334 keyboardType = KeyboardType.NumberPassword, 335 imeAction = imeAction, 336 ), 337 onEditCommand = onEditCommand, 338 onImeActionPerformed = { action -> 339 service.stopInput() 340 onImeActionPerformed(action) 341 } 342 ) 343 }