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 }