taler-android

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

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 }