taler-android

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

AmountScopeField.kt (12598B)


      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 androidx.compose.animation.AnimatedVisibility
     20 import androidx.compose.foundation.clickable
     21 import androidx.compose.foundation.interaction.MutableInteractionSource
     22 import androidx.compose.foundation.layout.Arrangement
     23 import androidx.compose.foundation.layout.Box
     24 import androidx.compose.foundation.layout.Column
     25 import androidx.compose.foundation.layout.FlowRow
     26 import androidx.compose.foundation.layout.fillMaxWidth
     27 import androidx.compose.foundation.layout.height
     28 import androidx.compose.foundation.layout.padding
     29 import androidx.compose.foundation.text.BasicTextField
     30 import androidx.compose.foundation.text.KeyboardActions
     31 import androidx.compose.foundation.text.KeyboardOptions
     32 import androidx.compose.material.icons.Icons
     33 import androidx.compose.material.icons.filled.Check
     34 import androidx.compose.material3.DropdownMenuItem
     35 import androidx.compose.material3.ExperimentalMaterial3Api
     36 import androidx.compose.material3.ExposedDropdownMenuBox
     37 import androidx.compose.material3.ExposedDropdownMenuDefaults
     38 import androidx.compose.material3.Icon
     39 import androidx.compose.material3.MenuDefaults
     40 import androidx.compose.material3.OutlinedTextFieldDefaults
     41 import androidx.compose.material3.Text
     42 import androidx.compose.runtime.Composable
     43 import androidx.compose.runtime.getValue
     44 import androidx.compose.runtime.mutableIntStateOf
     45 import androidx.compose.runtime.mutableStateOf
     46 import androidx.compose.runtime.remember
     47 import androidx.compose.runtime.setValue
     48 import androidx.compose.ui.Modifier
     49 import androidx.compose.ui.res.stringResource
     50 import androidx.compose.ui.text.TextStyle
     51 import androidx.compose.ui.text.input.ImeAction
     52 import androidx.compose.ui.text.input.VisualTransformation
     53 import androidx.compose.ui.tooling.preview.Preview
     54 import androidx.compose.ui.unit.dp
     55 import net.taler.common.Amount
     56 import net.taler.common.CurrencySpecification
     57 import net.taler.wallet.R
     58 import net.taler.wallet.balances.ScopeInfo
     59 import net.taler.wallet.cleanExchange
     60 
     61 data class AmountScope(
     62     val amount: Amount = Amount.zero(scope.currency),
     63     val scope: ScopeInfo,
     64     // whether fee calculation should be debounced
     65     val debounce: Boolean = false,
     66     // whether it originated from user input
     67     val userInput: Boolean = false,
     68 )
     69 
     70 @Composable
     71 fun AmountScopeField(
     72     modifier: Modifier = Modifier,
     73     amount: AmountScope,
     74     editableScope: Boolean = true,
     75     scopes: List<ScopeInfo>,
     76     onAmountChanged: (amount: AmountScope) -> Unit,
     77     label: @Composable (() -> Unit)? = null,
     78     supportingText: @Composable (() -> Unit)? = null,
     79     isError: Boolean = false,
     80     readOnly: Boolean = false,
     81     keyboardActions: KeyboardActions = KeyboardActions.Default,
     82     keyboardOptions: KeyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
     83     showAmount: Boolean = true,
     84     showScope: Boolean = true,
     85     showShortcuts: Boolean = false,
     86     onShortcutSelected: ((amount: AmountScope) -> Unit)? = null,
     87 ) {
     88     Column(modifier) {
     89         if (showScope) {
     90             ScopeDropdown(
     91                 modifier = Modifier
     92                     .fillMaxWidth()
     93                     .padding(bottom = 10.dp),
     94                 scopes = scopes,
     95                 onScopeChanged = { scope ->
     96                     onAmountChanged(amount.copy(
     97                         scope = scope,
     98                         amount = Amount.zero(scope.currency),
     99                     ))
    100                 },
    101                 initialScope = amount.scope,
    102                 readOnly = readOnly || !editableScope,
    103             )
    104         }
    105 
    106         if (showAmount) {
    107             AmountInputFieldBase(
    108                 modifier = Modifier
    109                     .fillMaxWidth(),
    110                 amount = amount.amount,
    111                 onAmountChanged = {
    112                     onAmountChanged(amount.copy(amount = it))
    113                 },
    114                 label = label,
    115                 isError = isError,
    116                 supportingText = supportingText,
    117                 readOnly = readOnly,
    118                 keyboardActions = keyboardActions,
    119                 keyboardOptions = keyboardOptions,
    120                 showSymbol = true,
    121             )
    122 
    123             val commonAmounts = amount.amount.spec?.commonAmounts
    124                 ?.map { it.withSpec(amount.amount.spec) }
    125             AnimatedVisibility(showShortcuts && amount.amount.isZero() && commonAmounts != null) {
    126                 if (commonAmounts != null) {
    127                     AmountInputShortcuts(
    128                         modifier = Modifier.padding(top = 10.dp),
    129                         amounts = commonAmounts,
    130                         onSelected = { shortcut ->
    131                             onShortcutSelected?.let {
    132                                 it(amount.copy(amount = shortcut))
    133                             }
    134                         },
    135                     )
    136                 }
    137             }
    138         }
    139     }
    140 }
    141 
    142 @OptIn(ExperimentalMaterial3Api::class)
    143 @Composable
    144 fun ScopeDropdown(
    145     scopes: List<ScopeInfo>,
    146     onScopeChanged: (ScopeInfo) -> Unit,
    147     modifier: Modifier = Modifier,
    148     initialScope: ScopeInfo? = null,
    149     readOnly: Boolean = false,
    150 ) {
    151     val initialIndex = scopes.indexOf(initialScope).let { if (it < 0) 0 else it }
    152     var selectedIndex by remember { mutableIntStateOf(initialIndex) }
    153     var expanded by remember { mutableStateOf(false) }
    154     Box(
    155         modifier = modifier,
    156     ) {
    157         val scope = scopes.getOrNull(selectedIndex)
    158             ?: initialScope
    159             ?: error("no scope available")
    160 
    161         val value = when (scope) {
    162             is ScopeInfo.Global -> scope.currency
    163             is ScopeInfo.Exchange -> cleanExchange(scope.url)
    164             is ScopeInfo.Auditor -> cleanExchange(scope.url)
    165         }
    166 
    167         val colors = OutlinedTextFieldDefaults.colors()
    168         val singleLine = true
    169         val enabled = false
    170         val interactionSource = remember { MutableInteractionSource() }
    171 
    172         ExposedDropdownMenuBox(
    173             expanded = expanded,
    174             onExpandedChange = { expanded = it },
    175             modifier = Modifier,
    176         ) {
    177             BasicTextField(
    178                 value = value,
    179                 modifier = Modifier
    180                     .height(45.dp)
    181                     .clickable { if (!readOnly) expanded = true }
    182                     .fillMaxWidth(),
    183                 onValueChange = { },
    184                 enabled = enabled,
    185                 readOnly = true,
    186                 textStyle = TextStyle(color = colors.focusedTextColor),
    187                 interactionSource = interactionSource,
    188                 singleLine = singleLine,
    189                 decorationBox =
    190                     @Composable { innerTextField ->
    191                         OutlinedTextFieldDefaults.DecorationBox(
    192                             value = value,
    193                             innerTextField = innerTextField,
    194                             singleLine = singleLine,
    195                             enabled = enabled,
    196                             visualTransformation = VisualTransformation.None,
    197                             interactionSource = interactionSource,
    198                             prefix = {
    199                                 Text(
    200                                     modifier = Modifier.padding(end = 6.dp),
    201                                     text = stringResource(R.string.currency_via),
    202                                 )
    203                             },
    204                             contentPadding = OutlinedTextFieldDefaults.contentPadding(
    205                                 top = 0.dp,
    206                                 bottom = 0.dp,
    207                             ),
    208                             colors = ExposedDropdownMenuDefaults.textFieldColors(),
    209                             trailingIcon = {
    210                                 ExposedDropdownMenuDefaults.TrailingIcon(expanded)
    211                             }
    212                         )
    213                     },
    214             )
    215             
    216             ExposedDropdownMenu(
    217                 expanded = expanded,
    218                 onDismissRequest = { expanded = false },
    219                 containerColor = MenuDefaults.containerColor,
    220                 shape = MenuDefaults.shape,
    221             ) {
    222                 scopes.forEachIndexed { index, s ->
    223                     DropdownMenuItem(
    224                         contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
    225                         leadingIcon = {
    226                             if (selectedIndex == index) {
    227                                 Icon(Icons.Filled.Check, contentDescription = null)
    228                             }
    229                         },
    230                         text = {
    231                             Text(
    232                                 text = when (s) {
    233                                     is ScopeInfo.Global -> s.currency
    234                                     is ScopeInfo.Exchange -> stringResource(
    235                                         R.string.currency_url,
    236                                         s.currency,
    237                                         cleanExchange(s.url),
    238                                     )
    239 
    240                                     is ScopeInfo.Auditor -> stringResource(
    241                                         R.string.currency_url,
    242                                         s.currency,
    243                                         cleanExchange(s.url),
    244                                     )
    245                                 }
    246                             )
    247                         },
    248                         onClick = {
    249                             selectedIndex = index
    250                             onScopeChanged(scopes[index])
    251                             expanded = false
    252                         }
    253                     )
    254                 }
    255             }
    256         }
    257     }
    258 }
    259 
    260 @Composable
    261 private fun AmountInputShortcuts(
    262     modifier: Modifier = Modifier,
    263     amounts: List<Amount>,
    264     onSelected: (amount: Amount) -> Unit,
    265 ) {
    266     FlowRow(
    267         modifier = modifier
    268             .fillMaxWidth(),
    269         maxItemsInEachRow = 2,
    270         horizontalArrangement = Arrangement.SpaceEvenly,
    271     ) {
    272         amounts.forEach {
    273             SelectionChip (
    274                 selected = false,
    275                 label = { Text(it.toString()) },
    276                 value = it,
    277                 onSelected = onSelected,
    278             )
    279         }
    280     }
    281 }
    282 
    283 @Preview
    284 @Composable
    285 fun AmountInputFieldPreview() {
    286     TalerSurface {
    287         var amount by remember {
    288             mutableStateOf(AmountScope(
    289                 amount = Amount.fromJSONString("KUDOS:0").withSpec(
    290                     CurrencySpecification(
    291                         name = "Kudos",
    292                         numFractionalInputDigits = 2,
    293                         numFractionalNormalDigits = 2,
    294                         numFractionalTrailingZeroDigits = 2,
    295                         altUnitNames = mapOf(),
    296                         commonAmounts = listOf(
    297                             Amount.fromJSONString("KUDOS:5"),
    298                             Amount.fromJSONString("KUDOS:10"),
    299                             Amount.fromJSONString("KUDOS:25"),
    300                             Amount.fromJSONString("KUDOS:50"),
    301                         ),
    302                     ),
    303                 ),
    304                 scope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    305             ))
    306         }
    307         AmountScopeField(
    308             amount = amount,
    309             editableScope = true,
    310             scopes = listOf(
    311                 ScopeInfo.Global("CHF"),
    312                 ScopeInfo.Exchange("KUDOS", url = "https://exchange.demo.taler.net/"),
    313                 ScopeInfo.Auditor("TESTKUDOS", url = "https://auditor.test.taler.net/"),
    314             ),
    315             onAmountChanged = { amount = it },
    316             label = { Text("Amount to withdraw") },
    317             isError = false,
    318             readOnly = false,
    319             showShortcuts = true,
    320             onShortcutSelected = { amount = it },
    321         )
    322     }
    323 }