taler-android

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

OutgoingPullComposable.kt (14688B)


      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.systemBarsPaddingBottom
     65 import net.taler.wallet.transactions.TransactionInfoComposable
     66 import net.taler.wallet.useDebounce
     67 import kotlin.random.Random
     68 
     69 @Composable
     70 fun OutgoingPullComposable(
     71     state: OutgoingState,
     72     defaultScope: ScopeInfo?,
     73     scopes: List<ScopeInfo>,
     74     devMode: Boolean,
     75     getCurrencySpec: (scope: ScopeInfo) -> CurrencySpecification?,
     76     checkPeerPullCredit: suspend (amount: AmountScope, loading: Boolean) -> CheckPeerPullCreditResult?,
     77     onCreateInvoice: (amount: AmountScope, subject: String, hours: Long, exchangeBaseUrl: String) -> Unit,
     78     onTosAccept: (exchangeBaseUrl: String) -> Unit,
     79     modifier: Modifier = Modifier,
     80 ) {
     81     var subject by rememberSaveable { mutableStateOf("") }
     82     var amount by remember {
     83         val scope = defaultScope ?: scopes[0]
     84         val currency = scope.currency
     85         mutableStateOf(AmountScope(Amount.zero(currency), scope))
     86     }
     87     val selectedSpec = remember(amount.scope) { getCurrencySpec(amount.scope) }
     88     var checkResult by remember { mutableStateOf<CheckPeerPullCreditResult?>(null) }
     89     val res = checkResult
     90 
     91     var option by rememberSaveable { mutableStateOf(DEFAULT_EXPIRY) }
     92     var hours by rememberSaveable { mutableLongStateOf(DEFAULT_EXPIRY.hours) }
     93 
     94     val tosReview = checkResult != null && !checkResult!!.tosStatus!!.isAccepted()
     95 
     96     amount.amount.useDebounce {
     97         if (amount.debounce) {
     98             checkResult = checkPeerPullCredit(amount, false)
     99         }
    100     }
    101 
    102     LaunchedEffect(amount) {
    103         if (!amount.debounce) {
    104             checkResult = checkPeerPullCredit(amount, true)
    105         }
    106     }
    107 
    108     if (state is OutgoingChecking ||
    109         state is OutgoingCreating ||
    110         state is OutgoingResponse) {
    111         LoadingScreen(modifier)
    112         return
    113     }
    114 
    115     val amountFocusRequester = remember { FocusRequester() }
    116     val subjectFocusRequester = remember { FocusRequester() }
    117 
    118     Column(
    119         modifier
    120             .fillMaxSize()
    121             .imePadding(),
    122     ) {
    123         Column(
    124             modifier = Modifier
    125                 .fillMaxWidth()
    126                 .weight(1f)
    127                 .verticalScroll(rememberScrollState()),
    128             horizontalAlignment = CenterHorizontally,
    129         ) {
    130             var shortcutSelected by remember { mutableStateOf(false) }
    131             AmountScopeField(
    132                 modifier = Modifier
    133                     .padding(16.dp)
    134                     .fillMaxWidth()
    135                     .focusRequester(amountFocusRequester),
    136                 amount = amount.copy(amount = amount.amount.withSpec(selectedSpec)),
    137                 scopes = scopes,
    138                 readOnly = false,
    139                 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
    140                 showAmount = !tosReview && state !is OutgoingError,
    141                 showShortcuts = true,
    142                 onAmountChanged = {
    143                     amount = it.copy(debounce = amount.scope == it.scope)
    144                     shortcutSelected = false
    145                 },
    146                 onShortcutSelected = {
    147                     amount = it.copy(debounce = true)
    148                     shortcutSelected = true
    149                 },
    150                 isError = amount.amount.isZero(),
    151                 label = { Text(stringResource(R.string.amount_receive)) },
    152             )
    153 
    154             LaunchedEffect(tosReview) {
    155                 if (!tosReview) amountFocusRequester.requestFocus()
    156             }
    157 
    158             if (state is OutgoingError) {
    159                 ErrorComposable(state.info,
    160                     modifier = Modifier.fillMaxSize(),
    161                     devMode = devMode)
    162                 return@Column
    163             }
    164 
    165             if (tosReview) {
    166                 Text(
    167                     modifier = Modifier.padding(16.dp),
    168                     text = stringResource(R.string.receive_peer_review_terms)
    169                 )
    170             } else AnimatedVisibility(!amount.amount.isZero()) {
    171                 Column {
    172                     OutlinedTextField(
    173                         modifier = Modifier
    174                             .padding(horizontal = 16.dp)
    175                             .fillMaxWidth()
    176                             .focusRequester(subjectFocusRequester),
    177                         singleLine = true,
    178                         value = subject,
    179                         onValueChange = { input ->
    180                             if (input.length <= MAX_LENGTH_SUBJECT)
    181                                 subject = input.replace('\n', ' ')
    182                         },
    183                         label = { Text(stringResource(R.string.send_peer_purpose)) },
    184                         placeholder = { Text(stringResource(R.string.receive_peer_default_purpose)) },
    185                         supportingText = {
    186                             Text(
    187                                 stringResource(
    188                                     R.string.char_count,
    189                                     subject.length,
    190                                     MAX_LENGTH_SUBJECT
    191                                 )
    192                             )
    193                         },
    194                     )
    195 
    196                     if (res != null && res.amountRaw != null && res.amountEffective != null) {
    197                         if (res.amountEffective > res.amountRaw) {
    198                             val fee = res.amountEffective - res.amountRaw
    199                             Text(
    200                                 modifier = Modifier.padding(vertical = 16.dp),
    201                                 text = stringResource(
    202                                     id = R.string.payment_fee,
    203                                     fee.withSpec(selectedSpec)
    204                                 ),
    205                                 softWrap = false,
    206                                 color = MaterialTheme.colorScheme.error,
    207                             )
    208                         }
    209                     }
    210 
    211                     Text(
    212                         modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp),
    213                         text = stringResource(R.string.send_peer_expiration_period),
    214                         style = MaterialTheme.typography.bodyMedium,
    215                     )
    216 
    217                     ExpirationComposable(
    218                         modifier = Modifier
    219                             .padding(horizontal = 16.dp)
    220                             .padding(top = 8.dp, bottom = 16.dp),
    221                         option = option,
    222                         hours = hours,
    223                         onOptionChange = { option = it }
    224                     ) { hours = it }
    225                 }
    226 
    227                 LaunchedEffect(Unit) {
    228                     // do not steal focus when manually typing amount
    229                     if (shortcutSelected) subjectFocusRequester.requestFocus()
    230                 }
    231             }
    232 
    233             // only show provider for global scope,
    234             // otherwise it's already in scope selector
    235             if (amount.scope is ScopeInfo.Global) {
    236                 checkResult?.exchangeBaseUrl?.let { exchangeBaseUrl ->
    237                     TransactionInfoComposable(
    238                         label = stringResource(id = R.string.withdraw_exchange),
    239                         info = cleanExchange(exchangeBaseUrl),
    240                         marquee = true,
    241                     )
    242                 }
    243             }
    244 
    245             BottomInsetsSpacer()
    246         }
    247 
    248         val defaultSubject = stringResource(R.string.receive_peer_default_purpose)
    249         BottomButtonBox(Modifier.fillMaxWidth()) {
    250             Button(
    251                 modifier = Modifier
    252                     .systemBarsPaddingBottom(),
    253                 enabled = tosReview || (res != null && !amount.amount.isZero()),
    254                 onClick = {
    255                     val ex = res?.exchangeBaseUrl ?: error("clickable without exchange")
    256                     if (res.tosStatus?.isAccepted() == true) {
    257                         onCreateInvoice(
    258                             amount,
    259                             subject.ifBlank { defaultSubject },
    260                             hours,
    261                             ex
    262                         )
    263                     } else onTosAccept(ex)
    264                 },
    265             ) {
    266                 if (checkResult != null && !checkResult!!.tosStatus!!.isAccepted()) {
    267                     Text(text = stringResource(R.string.exchange_tos_view))
    268                 } else {
    269                     Text(text = stringResource(R.string.receive_peer_create_button_amount,
    270                         amount.amount.withSpec(selectedSpec)))
    271                 }
    272             }
    273         }
    274     }
    275 }
    276 
    277 @Preview
    278 @Composable
    279 fun PeerPullComposableCreatingPreview() {
    280     TalerSurface {
    281         OutgoingPullComposable(
    282             state = OutgoingCreating,
    283             defaultScope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    284             scopes = listOf(
    285                 ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    286                 ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"),
    287                 ScopeInfo.Global("CHF"),
    288             ),
    289             devMode = true,
    290             getCurrencySpec = { null },
    291             checkPeerPullCredit = { _, _ -> null },
    292             onCreateInvoice = { _, _, _, _ -> },
    293             onTosAccept = {},
    294         )
    295     }
    296 }
    297 
    298 @Preview
    299 @Composable
    300 fun PeerPullComposableCheckingPreview() {
    301     TalerSurface {
    302         OutgoingPullComposable(
    303             state = if (Random.nextBoolean()) OutgoingIntro else OutgoingChecking,
    304             defaultScope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    305             scopes = listOf(
    306                 ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    307                 ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"),
    308                 ScopeInfo.Global("CHF"),
    309             ),
    310             devMode = true,
    311             getCurrencySpec = { null },
    312             checkPeerPullCredit = { _, _ -> null },
    313             onCreateInvoice = { _, _, _, _ -> },
    314             onTosAccept = {},
    315         )
    316     }
    317 }
    318 
    319 @Preview
    320 @Composable
    321 fun PeerPullComposableCheckedPreview() {
    322     TalerSurface {
    323         val amountRaw = Amount.fromString("TESTKUDOS", "42.42")
    324         val amountEffective = Amount.fromString("TESTKUDOS", "42.23")
    325         OutgoingPullComposable(
    326             state = OutgoingChecked(amountRaw, amountEffective, "https://exchange.demo.taler.net/", ExchangeTosStatus.Accepted),
    327             defaultScope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    328             scopes = listOf(
    329                 ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    330                 ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"),
    331                 ScopeInfo.Global("CHF"),
    332             ),
    333             devMode = true,
    334             getCurrencySpec = { null },
    335             checkPeerPullCredit = { _, _ -> null },
    336             onCreateInvoice = { _, _, _, _ -> },
    337             onTosAccept = {},
    338         )
    339     }
    340 }
    341 
    342 @Preview
    343 @Composable
    344 fun PeerPullComposableErrorPreview() {
    345     TalerSurface {
    346         val json = mapOf("foo" to JsonPrimitive("bar"))
    347         val state = OutgoingError(TalerErrorInfo(TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED, "hint", "message", json))
    348         OutgoingPullComposable(
    349             state = state,
    350             defaultScope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    351             scopes = listOf(
    352                 ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    353                 ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"),
    354                 ScopeInfo.Global("CHF"),
    355             ),
    356             devMode = true,
    357             getCurrencySpec = { null },
    358             checkPeerPullCredit = { _, _ -> null },
    359             onCreateInvoice = { _, _, _, _ -> },
    360             onTosAccept = {},
    361         )
    362     }
    363 }