taler-android

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

BalancesComposable.kt (14608B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2025 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.balances
     18 
     19 import androidx.compose.animation.AnimatedVisibility
     20 import androidx.compose.animation.animateContentSize
     21 import androidx.compose.foundation.background
     22 import androidx.compose.foundation.clickable
     23 import androidx.compose.foundation.layout.Box
     24 import androidx.compose.foundation.layout.Column
     25 import androidx.compose.foundation.layout.PaddingValues
     26 import androidx.compose.foundation.layout.consumeWindowInsets
     27 import androidx.compose.foundation.layout.fillMaxSize
     28 import androidx.compose.foundation.layout.fillMaxWidth
     29 import androidx.compose.foundation.layout.padding
     30 import androidx.compose.foundation.lazy.LazyColumn
     31 import androidx.compose.foundation.lazy.items
     32 import androidx.compose.material.icons.Icons
     33 import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
     34 import androidx.compose.material.icons.filled.EventRepeat
     35 import androidx.compose.material3.Badge
     36 import androidx.compose.material3.HorizontalDivider
     37 import androidx.compose.material3.Icon
     38 import androidx.compose.material3.ListItem
     39 import androidx.compose.material3.ListItemDefaults
     40 import androidx.compose.material3.MaterialTheme
     41 import androidx.compose.material3.OutlinedCard
     42 import androidx.compose.material3.ProvideTextStyle
     43 import androidx.compose.material3.Text
     44 import androidx.compose.runtime.Composable
     45 import androidx.compose.ui.Modifier
     46 import androidx.compose.ui.res.painterResource
     47 import androidx.compose.ui.res.stringResource
     48 import androidx.compose.ui.tooling.preview.Preview
     49 import androidx.compose.ui.unit.dp
     50 import net.taler.common.Amount
     51 import net.taler.wallet.R
     52 import net.taler.wallet.backend.TalerErrorInfo
     53 import net.taler.wallet.balances.ScopeInfo.Auditor
     54 import net.taler.wallet.balances.ScopeInfo.Exchange
     55 import net.taler.wallet.balances.ScopeInfo.Global
     56 import net.taler.wallet.cleanExchange
     57 import net.taler.wallet.compose.ErrorComposable
     58 import net.taler.wallet.compose.LoadingScreen
     59 import net.taler.wallet.compose.TalerSurface
     60 import net.taler.wallet.compose.cardPaddings
     61 import net.taler.wallet.donau.DonauSummaryItem
     62 import net.taler.wallet.ui.theme.TalerTheme
     63 
     64 // TODO: rename to AssetsComposable
     65 @Composable
     66 fun BalancesComposable(
     67     innerPadding: PaddingValues,
     68     state: BalanceState,
     69     devMode: Boolean,
     70     networkStatus: Boolean,
     71     onWithdrawMoneyClicked: () -> Unit,
     72     onGetDemoMoneyClicked: () -> Unit,
     73     onBalanceClicked: (balance: BalanceItem) -> Unit,
     74     onPendingClicked: (balance: BalanceItem) -> Unit,
     75     onStatementClicked: (host: String) -> Unit,
     76     onShowDiscounts: () -> Unit,
     77     onShowPasses: () -> Unit,
     78 ) {
     79     if (state.showWelcome()) {
     80         EmptyBalancesComposable(
     81             innerPadding = innerPadding,
     82             networkStatus = networkStatus,
     83             onWithdrawMoneyClicked,
     84             onGetDemoMoneyClicked,
     85         )
     86 
     87         return
     88     }
     89 
     90     when (state) {
     91         is BalanceState.Loading -> LoadingScreen()
     92         is BalanceState.Error -> ErrorComposable(state.error,
     93             devMode = devMode,
     94             modifier = Modifier.fillMaxSize())
     95         is BalanceState.Success -> LazyColumn(
     96             Modifier
     97                 .consumeWindowInsets(innerPadding)
     98                 .fillMaxSize(),
     99             contentPadding = innerPadding,
    100         ) {
    101             items(state.balances, key = { it.scopeInfo.hashCode() }) { balance ->
    102                 BalanceRow(
    103                     balance,
    104                     onClick = { onBalanceClicked(balance) },
    105                     onPendingClick = { onPendingClicked(balance) },
    106                 )
    107             }
    108 
    109             if (state.donauSummary.isNotEmpty()) stickyHeader {
    110                 SectionHeader { Text(stringResource(R.string.balances_section_statements)) }
    111             }
    112 
    113             items(state.donauSummary) { statement ->
    114                 StatementRow(
    115                     statement,
    116                     onClick = { onStatementClicked(statement.donauBaseUrl) },
    117                 )
    118             }
    119 
    120             stickyHeader {
    121                 SectionHeader { Text(stringResource(R.string.balances_section_tokens)) }
    122             }
    123 
    124             item {
    125                 OutlinedCard(Modifier.cardPaddings()) {
    126                     ListItem(
    127                         modifier = Modifier.clickable { onShowDiscounts() },
    128                         leadingContent = { Icon(
    129                             painterResource(R.drawable.ic_discounts),
    130                             contentDescription = null,
    131                         ) },
    132                         headlineContent = {
    133                             Text(
    134                                 stringResource(R.string.discounts_title),
    135                                 style = MaterialTheme.typography.titleMedium,
    136                             )
    137                         },
    138                     )
    139                 }
    140             }
    141 
    142             item {
    143                 OutlinedCard(Modifier.cardPaddings()) {
    144                     ListItem(
    145                         modifier = Modifier.clickable { onShowPasses() },
    146                         leadingContent = { Icon(Icons.Default.EventRepeat, contentDescription = null) },
    147                         headlineContent = {
    148                             Text(stringResource(R.string.passes_title),
    149                                 style = MaterialTheme.typography.titleMedium)
    150                         }
    151                     )
    152                 }
    153             }
    154         }
    155 
    156         else -> {}
    157     }
    158 }
    159 
    160 @Composable
    161 fun SectionHeader(
    162     label: @Composable () -> Unit,
    163 ) {
    164     Box(Modifier
    165         .fillMaxWidth()
    166         .background(MaterialTheme.colorScheme.background)) {
    167         Box(Modifier.cardPaddings()) {
    168             ProvideTextStyle(MaterialTheme.typography.titleMedium
    169                 .copy(color = MaterialTheme.colorScheme.onBackground)) {
    170                 label()
    171             }
    172         }
    173     }
    174 }
    175 
    176 @Composable
    177 fun BalanceRow(
    178     balance: BalanceItem,
    179     onClick: () -> Unit,
    180     onPendingClick: () -> Unit,
    181 ) {
    182     OutlinedCard(Modifier.cardPaddings()) {
    183         Column {
    184             ListItem(
    185                 modifier = Modifier
    186                     .animateContentSize()
    187                     .clickable { onClick() }
    188                     .padding(vertical = 6.dp),
    189                 headlineContent = {
    190                     Text(
    191                         balance.available.toString(),
    192                         style = MaterialTheme.typography.displaySmall,
    193                     )
    194                 },
    195                 overlineContent = {
    196                     ProvideTextStyle(MaterialTheme.typography.bodySmall) {
    197                         when (balance.scopeInfo) {
    198                             is Exchange -> Text(
    199                                 stringResource(
    200                                     R.string.balance_scope_exchange,
    201                                     cleanExchange(balance.scopeInfo.url)
    202                                 ),
    203                             )
    204 
    205                             is Auditor -> Text(
    206                                 stringResource(
    207                                     R.string.balance_scope_auditor,
    208                                     cleanExchange(balance.scopeInfo.url)
    209                                 ),
    210                             )
    211 
    212                             else -> {}
    213                         }
    214                     }
    215                 },
    216             )
    217 
    218             if (!balance.pendingIncoming.isZero() || !balance.pendingOutgoing.isZero()) {
    219                 HorizontalDivider()
    220                 PendingComposable(balance, onPendingClick)
    221             }
    222         }
    223     }
    224 }
    225 
    226 @Composable
    227 fun StatementRow(
    228     summaryItem: DonauSummaryItem,
    229     onClick: () -> Unit,
    230 ) {
    231     OutlinedCard(Modifier.cardPaddings()) {
    232         Column {
    233             ListItem(
    234                 modifier = Modifier
    235                     .animateContentSize()
    236                     .clickable { onClick() }
    237                     .padding(vertical = 6.dp),
    238                 headlineContent = {
    239                     Text(
    240                         summaryItem.amountReceiptsAvailable.toString(),
    241                         style = MaterialTheme.typography.displaySmall,
    242                     )
    243                 },
    244                 overlineContent = {
    245                     ProvideTextStyle(value = MaterialTheme.typography.bodySmall) {
    246                         // FIXME: security risk, faking donauBaseUrl!
    247                         Text(stringResource(R.string.balance_scope_exchange,
    248                             summaryItem.legalDomain
    249                                 ?: cleanExchange(summaryItem.donauBaseUrl)))
    250                     }
    251                 },
    252                 trailingContent = {
    253                     Badge(
    254                         containerColor = MaterialTheme.colorScheme.primaryContainer,
    255                         contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
    256                     ) {
    257                         Text(
    258                             "${summaryItem.year}",
    259                             modifier = Modifier.padding(6.dp),
    260                             style = MaterialTheme.typography.titleMedium,
    261                         )
    262                     }
    263                 },
    264             )
    265         }
    266     }
    267 }
    268 
    269 @Composable
    270 fun PendingComposable(
    271     balance: BalanceItem,
    272     onClick: () -> Unit,
    273 ) {
    274     ListItem(
    275         modifier = Modifier
    276             .animateContentSize()
    277             .clickable { onClick() },
    278         colors = ListItemDefaults.colors(
    279             containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
    280         ),
    281         headlineContent = {
    282             Column(modifier = Modifier.padding(vertical = 5.dp)) {
    283                 ProvideTextStyle(MaterialTheme.typography.bodyLarge) {
    284                     AnimatedVisibility(!balance.pendingIncoming.isZero()) {
    285                         Text(
    286                             stringResource(
    287                                 R.string.balances_inbound_amount,
    288                                 balance.pendingIncoming.toString(showSymbol = false),
    289                             ),
    290                             color = TalerTheme.extraColors.success,
    291                         )
    292                     }
    293 
    294                     AnimatedVisibility(!balance.pendingOutgoing.isZero()) {
    295                         Text(
    296                             stringResource(
    297                                 R.string.balances_outbound_amount,
    298                                 balance.pendingOutgoing.toString(showSymbol = false)
    299                             ),
    300                             color = MaterialTheme.colorScheme.error,
    301                         )
    302                     }
    303                 }
    304             }
    305         },
    306         trailingContent = {
    307             Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight,
    308                 tint = MaterialTheme.colorScheme.onSurfaceVariant,
    309                 contentDescription = null,
    310             )
    311         }
    312     )
    313 }
    314 
    315 @Preview
    316 @Composable
    317 fun BalancesComposablePreview() {
    318     val balances = listOf(
    319         BalanceItem(
    320             scopeInfo = Global("CHF"),
    321             available = Amount.fromJSONString("CHF:10.20"),
    322             pendingIncoming = Amount.fromJSONString("CHF:1.20"),
    323             pendingOutgoing = Amount.fromJSONString("CHF:0.40"),
    324             disablePeerPayments = false,
    325         ),
    326         BalanceItem(
    327             scopeInfo = Exchange("KUDOS", "https://exchange.demo.taler.net"),
    328             available = Amount.fromJSONString("KUDOS:1407.37"),
    329             pendingIncoming = Amount.fromJSONString("KUDOS:0"),
    330             pendingOutgoing = Amount.fromJSONString("KUDOS:2.15"),
    331             disablePeerPayments = false,
    332         ),
    333         BalanceItem(
    334             scopeInfo = Auditor("MXN", "https://auditor.taler.banxico.org.mx"),
    335             available = Amount.fromJSONString("MXN:5.50"),
    336             pendingIncoming = Amount.fromJSONString("MXN:1.40"),
    337             pendingOutgoing = Amount.fromJSONString("MXN:0"),
    338             disablePeerPayments = false,
    339         ),
    340     )
    341 
    342     val donauSummary = listOf(
    343         DonauSummaryItem(
    344             donauBaseUrl = "https://donau.test.taler.net/",
    345             legalDomain = "Gnuland",
    346             year = 2025,
    347             amountReceiptsSubmitted = Amount.fromJSONString("KUDOS:10"),
    348             amountReceiptsAvailable = Amount.fromJSONString("KUDOS:10"),
    349         )
    350     )
    351 
    352     TalerSurface {
    353         BalancesComposable(
    354             innerPadding = PaddingValues(0.dp),
    355             state = BalanceState.Success(balances, donauSummary),
    356             devMode = false,
    357             networkStatus = true,
    358             onWithdrawMoneyClicked = {},
    359             onGetDemoMoneyClicked = {},
    360             onBalanceClicked = {},
    361             onPendingClicked = {},
    362             onStatementClicked = {},
    363             onShowDiscounts = {},
    364             onShowPasses = {},
    365         )
    366     }
    367 }
    368 
    369 @Preview
    370 @Composable
    371 fun BalancesComposableErrorPreview() {
    372     TalerSurface {
    373         BalancesComposable (
    374             innerPadding = PaddingValues(0.dp),
    375             state = BalanceState.Error(TalerErrorInfo
    376                 .makeCustomError("Balances could not be loaded")),
    377             devMode = false,
    378             networkStatus = false,
    379             onWithdrawMoneyClicked = {},
    380             onGetDemoMoneyClicked = {},
    381             onBalanceClicked = {},
    382             onPendingClicked = {},
    383             onStatementClicked = {},
    384             onShowDiscounts = {},
    385             onShowPasses = {},
    386         )
    387     }
    388 }
    389 
    390 @Preview
    391 @Composable
    392 fun BalancesComposableEmptyPreview() {
    393     TalerSurface {
    394         BalancesComposable (
    395             innerPadding = PaddingValues(0.dp),
    396             state = BalanceState.Success(listOf(), listOf()),
    397             devMode = false,
    398             networkStatus = false,
    399             onWithdrawMoneyClicked = {},
    400             onGetDemoMoneyClicked = {},
    401             onBalanceClicked = {},
    402             onPendingClicked = {},
    403             onStatementClicked = {},
    404             onShowDiscounts = {},
    405             onShowPasses = {},
    406         )
    407     }
    408 }