taler-android

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

OutgoingPushComposable.kt (16516B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2022 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.peer
     18 
     19 import androidx.compose.animation.AnimatedVisibility
     20 import androidx.compose.foundation.layout.Column
     21 import androidx.compose.foundation.layout.fillMaxSize
     22 import androidx.compose.foundation.layout.fillMaxWidth
     23 import androidx.compose.foundation.layout.imePadding
     24 import androidx.compose.foundation.layout.padding
     25 import androidx.compose.foundation.rememberScrollState
     26 import androidx.compose.foundation.text.KeyboardOptions
     27 import androidx.compose.foundation.verticalScroll
     28 import androidx.compose.material3.Button
     29 import androidx.compose.material3.MaterialTheme
     30 import androidx.compose.material3.OutlinedTextField
     31 import androidx.compose.material3.Text
     32 import androidx.compose.runtime.Composable
     33 import androidx.compose.runtime.LaunchedEffect
     34 import androidx.compose.runtime.getValue
     35 import androidx.compose.runtime.mutableLongStateOf
     36 import androidx.compose.runtime.mutableStateOf
     37 import androidx.compose.runtime.remember
     38 import androidx.compose.runtime.saveable.rememberSaveable
     39 import androidx.compose.runtime.setValue
     40 import androidx.compose.ui.Alignment.Companion.CenterHorizontally
     41 import androidx.compose.ui.Modifier
     42 import androidx.compose.ui.focus.FocusRequester
     43 import androidx.compose.ui.focus.focusRequester
     44 import androidx.compose.ui.res.stringResource
     45 import androidx.compose.ui.text.input.ImeAction
     46 import androidx.compose.ui.tooling.preview.Preview
     47 import androidx.compose.ui.unit.dp
     48 import kotlinx.serialization.json.JsonPrimitive
     49 import net.taler.common.Amount
     50 import net.taler.common.CurrencySpecification
     51 import net.taler.wallet.BottomInsetsSpacer
     52 import net.taler.wallet.R
     53 import net.taler.wallet.backend.TalerErrorCode
     54 import net.taler.wallet.backend.TalerErrorInfo
     55 import net.taler.wallet.balances.ScopeInfo
     56 import net.taler.wallet.cleanExchange
     57 import net.taler.wallet.compose.AmountScope
     58 import net.taler.wallet.compose.AmountScopeField
     59 import net.taler.wallet.compose.BottomButtonBox
     60 import net.taler.wallet.compose.ErrorComposable
     61 import net.taler.wallet.compose.LoadingScreen
     62 import net.taler.wallet.compose.TalerSurface
     63 import net.taler.wallet.exchanges.ExchangeTosStatus
     64 import net.taler.wallet.payment.stringResId
     65 import net.taler.wallet.peer.CheckFeeResult.InsufficientBalance
     66 import net.taler.wallet.peer.CheckFeeResult.None
     67 import net.taler.wallet.peer.CheckFeeResult.Success
     68 import net.taler.wallet.systemBarsPaddingBottom
     69 import net.taler.wallet.transactions.TransactionInfoComposable
     70 import net.taler.wallet.useDebounce
     71 import kotlin.random.Random
     72 
     73 @Composable
     74 fun OutgoingPushComposable(
     75     state: OutgoingState,
     76     defaultScope: ScopeInfo?,
     77     scopes: List<ScopeInfo>,
     78     devMode: Boolean,
     79     getCurrencySpec: (scope: ScopeInfo) -> CurrencySpecification?,
     80     getFees: suspend (amount: AmountScope) -> CheckFeeResult?,
     81     onSend: (amount: AmountScope, summary: String, hours: Long) -> Unit,
     82     modifier: Modifier = Modifier,
     83 ) {
     84     when(state) {
     85         is OutgoingChecking, is OutgoingCreating, is OutgoingResponse -> LoadingScreen(modifier)
     86         is OutgoingIntro, is OutgoingChecked, is OutgoingError -> OutgoingPushIntroComposable(
     87             state = state,
     88             defaultScope = defaultScope,
     89             scopes = scopes,
     90             devMode = devMode,
     91             getCurrencySpec = getCurrencySpec,
     92             getFees = getFees,
     93             onSend = onSend,
     94             modifier = modifier,
     95         )
     96     }
     97 }
     98 
     99 @Composable
    100 fun OutgoingPushIntroComposable(
    101     state: OutgoingState,
    102     defaultScope: ScopeInfo?,
    103     scopes: List<ScopeInfo>,
    104     devMode: Boolean,
    105     getCurrencySpec: (scope: ScopeInfo) -> CurrencySpecification?,
    106     getFees: suspend (amount: AmountScope) -> CheckFeeResult?,
    107     onSend: (amount: AmountScope, summary: String, hours: Long) -> Unit,
    108     modifier: Modifier = Modifier,
    109 ) {
    110     var amount by remember {
    111         val scope = defaultScope ?: scopes[0]
    112         val currency = scope.currency
    113         mutableStateOf(AmountScope(Amount.zero(currency), scope))
    114     }
    115     val selectedSpec = remember(amount.scope) { getCurrencySpec(amount.scope) }
    116     var feeResult by remember { mutableStateOf<CheckFeeResult>(None()) }
    117     var subject by rememberSaveable { mutableStateOf("") }
    118 
    119     var option by rememberSaveable { mutableStateOf(DEFAULT_EXPIRY) }
    120     var hours by rememberSaveable { mutableLongStateOf(DEFAULT_EXPIRY.hours) }
    121 
    122     amount.useDebounce {
    123         feeResult = getFees(it) ?: None()
    124     }
    125 
    126     val amountFocusRequester = remember { FocusRequester() }
    127     val subjectFocusRequester = remember { FocusRequester() }
    128 
    129     LaunchedEffect(Unit) {
    130         amountFocusRequester.requestFocus()
    131     }
    132 
    133     Column(
    134         modifier
    135             .fillMaxSize()
    136             .imePadding(),
    137     ) {
    138         Column(
    139             modifier = Modifier
    140                 .fillMaxWidth()
    141                 .weight(1f)
    142                 .verticalScroll(rememberScrollState()),
    143             horizontalAlignment = CenterHorizontally,
    144         ) {
    145             AnimatedVisibility(feeResult.maxDepositAmountRaw != null) {
    146                 feeResult.maxDepositAmountRaw?.let {
    147                     Text(
    148                         modifier = Modifier.padding(16.dp),
    149                         text = if (feeResult.maxDepositAmountEffective == it) {
    150                             stringResource(
    151                                 R.string.amount_available_transfer,
    152                                 it.withSpec(selectedSpec),
    153                             )
    154                         } else {
    155                             stringResource(
    156                                 R.string.amount_available_transfer_fees,
    157                                 it.withSpec(selectedSpec),
    158                             )
    159                         },
    160                     )
    161                 }
    162             }
    163 
    164             var shortcutSelected by remember { mutableStateOf(false) }
    165             AmountScopeField(
    166                 modifier = Modifier
    167                     .padding(horizontal = 16.dp)
    168                     .fillMaxWidth()
    169                     .focusRequester(amountFocusRequester),
    170                 amount = amount.copy(amount = amount.amount.withSpec(selectedSpec)),
    171                 scopes = scopes,
    172                 readOnly = false,
    173                 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
    174                 showAmount = state !is OutgoingError,
    175                 showShortcuts = true,
    176                 onAmountChanged = {
    177                     amount = it
    178                     shortcutSelected = false
    179                 },
    180                 onShortcutSelected = {
    181                     amount = it
    182                     shortcutSelected = true
    183                 },
    184                 label = { Text(stringResource(R.string.amount_send)) },
    185                 isError = amount.amount.isZero() || feeResult is InsufficientBalance,
    186                 supportingText = {
    187                     when (val res = feeResult) {
    188                         is Success -> if (res.amountEffective > res.amountRaw) {
    189                             val fee = res.amountEffective - res.amountRaw
    190                             Text(
    191                                 text = stringResource(
    192                                     id = R.string.payment_fee,
    193                                     fee.withSpec(selectedSpec)
    194                                 ),
    195                                 softWrap = false,
    196                                 color = MaterialTheme.colorScheme.error,
    197                             )
    198                         }
    199 
    200                         is InsufficientBalance -> {
    201                             Text(
    202                                 stringResource(
    203                                     res.causeHint?.stringResId()
    204                                         ?: R.string.payment_balance_insufficient
    205                                 )
    206                             )
    207                         }
    208 
    209                         else -> {}
    210                     }
    211                 }
    212             )
    213 
    214             if (state is OutgoingError) {
    215                 ErrorComposable(state.info,
    216                     modifier = Modifier.fillMaxSize(),
    217                     devMode = devMode)
    218                 return@Column
    219             }
    220 
    221             AnimatedVisibility(feeResult is Success && !amount.amount.isZero()) {
    222                 Column(
    223                     modifier = Modifier.padding(bottom = 8.dp),
    224                     horizontalAlignment = CenterHorizontally,
    225                 ) {
    226                     OutlinedTextField(
    227                         modifier = Modifier
    228                             .padding(horizontal = 16.dp)
    229                             .fillMaxWidth()
    230                             .focusRequester(subjectFocusRequester),
    231                         singleLine = true,
    232                         value = subject,
    233                         onValueChange = { input ->
    234                             if (input.length <= MAX_LENGTH_SUBJECT)
    235                                 subject = input.replace('\n', ' ')
    236                         },
    237                         placeholder = { Text(stringResource(R.string.send_peer_default_purpose)) },
    238                         label = { Text(stringResource(R.string.send_peer_purpose)) },
    239                         supportingText = {
    240                             Text(
    241                                 stringResource(
    242                                     R.string.char_count,
    243                                     subject.length,
    244                                     MAX_LENGTH_SUBJECT
    245                                 )
    246                             )
    247                         },
    248                     )
    249 
    250                     Text(
    251                         modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp),
    252                         text = stringResource(R.string.send_peer_expiration_period),
    253                         style = MaterialTheme.typography.bodyMedium,
    254                     )
    255 
    256                     ExpirationComposable(
    257                         modifier = Modifier.padding(
    258                             vertical = 8.dp,
    259                             horizontal = 16.dp,
    260                         ),
    261                         option = option,
    262                         hours = hours,
    263                         onOptionChange = { option = it }
    264                     ) { hours = it }
    265 
    266                     (feeResult as? Success)?.let {
    267                         if (amount.scope is ScopeInfo.Global) {
    268                             TransactionInfoComposable(
    269                                 label = stringResource(id = R.string.withdraw_exchange),
    270                                 info = cleanExchange(it.exchangeBaseUrl),
    271                                 marquee = true,
    272                             )
    273                         }
    274                     }
    275                 }
    276 
    277                 LaunchedEffect(Unit) {
    278                     // do not steal focus when manually typing amount
    279                     if (shortcutSelected) subjectFocusRequester.requestFocus()
    280                 }
    281             }
    282 
    283             BottomInsetsSpacer()
    284         }
    285 
    286         val defaultSubject = stringResource(R.string.send_peer_default_purpose)
    287         BottomButtonBox(Modifier.fillMaxWidth()) {
    288             Button(
    289                 modifier = Modifier.systemBarsPaddingBottom(),
    290                 enabled = feeResult is Success && !amount.amount.isZero(),
    291                 onClick = { onSend(
    292                     amount,
    293                     subject.ifBlank { defaultSubject },
    294                     hours,
    295                 ) },
    296             ) {
    297                 Text(text = stringResource(R.string.send_peer_create_button_amount,
    298                     amount.amount.withSpec(selectedSpec)))
    299             }
    300         }
    301     }
    302 }
    303 
    304 @Preview
    305 @Composable
    306 fun PeerPushComposableCreatingPreview() {
    307     TalerSurface {
    308         OutgoingPushComposable(
    309             state = OutgoingCreating,
    310             defaultScope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    311             scopes = listOf(
    312                 ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    313                 ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"),
    314                 ScopeInfo.Global("CHF"),
    315             ),
    316             devMode = true,
    317             getCurrencySpec = { null },
    318             getFees = { Success(
    319                 amountEffective = Amount.fromJSONString("KUDOS:10"),
    320                 amountRaw = Amount.fromJSONString("KUDOS:12"),
    321                 exchangeBaseUrl = "https://exchange.demo.taler.net"
    322             ) },
    323             onSend = { _, _, _ -> },
    324         )
    325     }
    326 }
    327 
    328 @Preview
    329 @Composable
    330 fun PeerPushComposableCheckingPreview() {
    331     TalerSurface {
    332         val state = if (Random.nextBoolean()) OutgoingIntro else OutgoingChecking
    333         OutgoingPushComposable(
    334             state = state,
    335             defaultScope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    336             scopes = listOf(
    337                 ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    338                 ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"),
    339                 ScopeInfo.Global("CHF"),
    340             ),
    341             devMode = true,
    342             getCurrencySpec = { null },
    343             getFees = { Success(
    344                 amountEffective = Amount.fromJSONString("KUDOS:10"),
    345                 amountRaw = Amount.fromJSONString("KUDOS:12"),
    346                 maxDepositAmountEffective = Amount.fromJSONString("KUDOS:12"),
    347                 exchangeBaseUrl = "https://exchange.demo.taler.net"
    348             ) },
    349             onSend = { _, _, _ -> },
    350         )
    351     }
    352 }
    353 
    354 @Preview
    355 @Composable
    356 fun PeerPushComposableCheckedPreview() {
    357     TalerSurface {
    358         val amountEffective = Amount.fromString("TESTKUDOS", "42.42")
    359         val amountRaw = Amount.fromString("TESTKUDOS", "42.23")
    360         val state = OutgoingChecked(amountRaw, amountEffective, "https://exchange.demo.taler.net", ExchangeTosStatus.Accepted)
    361         OutgoingPushComposable(
    362             state = state,
    363             devMode = true,
    364             getCurrencySpec = { null },
    365             defaultScope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    366             scopes = listOf(
    367                 ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    368                 ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"),
    369                 ScopeInfo.Global("CHF"),
    370             ),
    371             getFees = { Success(
    372                 amountEffective = Amount.fromJSONString("KUDOS:10"),
    373                 amountRaw = Amount.fromJSONString("KUDOS:12"),
    374                 maxDepositAmountEffective = Amount.fromJSONString("KUDOS:12"),
    375                 exchangeBaseUrl = "https://exchange.demo.taler.net"
    376             ) },
    377             onSend = { _, _, _ -> },
    378         )
    379     }
    380 }
    381 
    382 @Preview
    383 @Composable
    384 fun PeerPushComposableErrorPreview() {
    385     TalerSurface {
    386         val json = mapOf("foo" to JsonPrimitive("bar"))
    387         val state = OutgoingError(TalerErrorInfo(TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED, "hint", "message", json))
    388         OutgoingPushComposable(
    389             state = state,
    390             defaultScope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    391             scopes = listOf(
    392                 ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    393                 ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"),
    394                 ScopeInfo.Global("CHF"),
    395             ),
    396             devMode = true,
    397             getCurrencySpec = { null },
    398             getFees = { Success(
    399                 amountEffective = Amount.fromJSONString("KUDOS:10"),
    400                 amountRaw = Amount.fromJSONString("KUDOS:12"),
    401                 maxDepositAmountEffective = Amount.fromJSONString("KUDOS:12"),
    402                 exchangeBaseUrl = "https://exchange.demo.taler.net"
    403             ) },
    404             onSend = { _, _, _ -> },
    405         )
    406     }
    407 }