WithdrawalShowInfo.kt (18685B)
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.withdraw 18 19 import androidx.compose.foundation.layout.Column 20 import androidx.compose.foundation.layout.fillMaxSize 21 import androidx.compose.foundation.layout.fillMaxWidth 22 import androidx.compose.foundation.layout.imePadding 23 import androidx.compose.foundation.layout.padding 24 import androidx.compose.foundation.layout.size 25 import androidx.compose.foundation.rememberScrollState 26 import androidx.compose.foundation.verticalScroll 27 import androidx.compose.material.icons.Icons 28 import androidx.compose.material.icons.filled.ArrowDropDown 29 import androidx.compose.material.icons.filled.Edit 30 import androidx.compose.material3.Button 31 import androidx.compose.material3.CircularProgressIndicator 32 import androidx.compose.material3.DropdownMenu 33 import androidx.compose.material3.DropdownMenuItem 34 import androidx.compose.material3.Icon 35 import androidx.compose.material3.IconButton 36 import androidx.compose.material3.Text 37 import androidx.compose.runtime.Composable 38 import androidx.compose.runtime.LaunchedEffect 39 import androidx.compose.runtime.getValue 40 import androidx.compose.runtime.mutableStateOf 41 import androidx.compose.runtime.remember 42 import androidx.compose.runtime.setValue 43 import androidx.compose.ui.Alignment 44 import androidx.compose.ui.Modifier 45 import androidx.compose.ui.focus.FocusRequester 46 import androidx.compose.ui.focus.focusRequester 47 import androidx.compose.ui.platform.LocalSoftwareKeyboardController 48 import androidx.compose.ui.res.stringResource 49 import androidx.compose.ui.tooling.preview.Preview 50 import androidx.compose.ui.unit.dp 51 import net.taler.common.Amount 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.compose.WarningLabel 64 import net.taler.wallet.exchanges.ExchangeItem 65 import net.taler.wallet.exchanges.ExchangeTosStatus 66 import net.taler.wallet.systemBarsPaddingBottom 67 import net.taler.wallet.transactions.AmountType 68 import net.taler.wallet.transactions.TransactionAmountComposable 69 import net.taler.wallet.transactions.TransactionInfoComposable 70 import net.taler.wallet.useDebounce 71 import net.taler.wallet.withdraw.WithdrawStatus.Status.Error 72 import net.taler.wallet.withdraw.WithdrawStatus.Status.TosReviewRequired 73 import net.taler.wallet.withdraw.WithdrawStatus.Status.Updating 74 import net.taler.wallet.withdraw.WithdrawalOperationStatusFlag.Pending 75 76 @Composable 77 fun WithdrawalShowInfo( 78 status: WithdrawStatus, 79 devMode: Boolean, 80 initialAmountScope: AmountScope?, 81 editableScope: Boolean, 82 scopes: List<ScopeInfo>, 83 onSelectAmount: (amount: Amount, scope: ScopeInfo) -> Unit, 84 onSelectExchange: () -> Unit, 85 onTosReview: () -> Unit, 86 onConfirm: (age: Int?) -> Unit, 87 modifier: Modifier = Modifier, 88 ) { 89 val maxAmount = status.uriInfo?.maxAmount 90 val editableAmount = status.uriInfo?.editableAmount ?: true 91 val wireFee = status.uriInfo?.wireFee 92 val exchange = status.exchangeBaseUrl 93 val possibleExchanges = status.uriInfo?.possibleExchanges ?: emptyList() 94 val ageRestrictionOptions = status.amountInfo?.ageRestrictionOptions ?: emptyList() 95 96 if (scopes.isEmpty()) { 97 LoadingScreen(modifier) 98 return 99 } 100 101 val keyboardController = LocalSoftwareKeyboardController.current 102 var selectedAmount by remember(initialAmountScope != null) { 103 mutableStateOf(initialAmountScope 104 ?: AmountScope( 105 amount = Amount.zero(scopes.first().currency), 106 scope = scopes.first(), 107 userInput = false, 108 ), 109 ) 110 } 111 var selectedAge by remember { mutableStateOf<Int?>(null) } 112 val scrollState = rememberScrollState() 113 val insufficientBalance = remember(selectedAmount, maxAmount) { 114 val amount = selectedAmount.amount 115 if (maxAmount != null && amount.currency == maxAmount.currency) { 116 amount > maxAmount 117 } else { 118 false 119 } 120 } 121 122 val focusRequester = remember { FocusRequester() } 123 124 LaunchedEffect(selectedAmount) { 125 val selected = selectedAmount 126 if (!selected.debounce && selected.userInput) { 127 onSelectAmount( 128 selected.amount, 129 selected.scope, 130 ) 131 } 132 } 133 134 selectedAmount.useDebounce { 135 val selected = selectedAmount 136 if (selected.debounce && selected.userInput) { 137 onSelectAmount( 138 selected.amount, 139 selected.scope, 140 ) 141 } 142 } 143 144 Column( 145 modifier 146 .fillMaxSize() 147 .imePadding(), 148 ) { 149 Column( 150 modifier = Modifier 151 .weight(1f) 152 .verticalScroll(scrollState) 153 .fillMaxWidth(), 154 horizontalAlignment = Alignment.CenterHorizontally, 155 ) { 156 if (editableScope) AmountScopeField( 157 modifier = Modifier 158 .padding(horizontal = 16.dp) 159 .fillMaxWidth(), 160 amount = selectedAmount.copy( 161 amount = selectedAmount.amount.withSpec(status.selectedSpec) 162 ), 163 scopes = scopes, 164 showAmount = false, 165 showScope = true, 166 showShortcuts = false, 167 readOnly = status.status == Updating, 168 onAmountChanged = { amount -> 169 selectedAmount = amount.copy( 170 amount = Amount.zero(amount.amount.currency), 171 debounce = false, 172 userInput = true, 173 ) 174 }, 175 ) 176 177 if (status.status == WithdrawStatus.Status.Loading 178 || status.status == WithdrawStatus.Status.None) { 179 LoadingScreen(Modifier.weight(1f)) 180 return 181 } else if (status.status == Error && status.error != null) { 182 ErrorComposable(status.error, 183 modifier = Modifier.fillMaxSize(), 184 devMode = devMode) 185 return 186 } else if (status.isCashAcceptor) { 187 WarningLabel( 188 label = stringResource(R.string.withdraw_cash_acceptor), 189 modifier = Modifier 190 .padding(16.dp) 191 .fillMaxWidth(), 192 ) 193 } else if (editableAmount) { 194 AmountScopeField( 195 modifier = Modifier 196 .padding(horizontal = 16.dp) 197 .padding(bottom = 16.dp) 198 .fillMaxWidth() 199 .focusRequester(focusRequester), 200 amount = selectedAmount.copy( 201 amount = selectedAmount.amount.withSpec(status.selectedSpec)), 202 scopes = scopes, 203 showScope = false, 204 showAmount = status.status != TosReviewRequired, 205 readOnly = status.status == Updating, 206 onAmountChanged = { amount -> 207 selectedAmount = amount.copy( 208 debounce = true, 209 userInput = true, 210 ) 211 }, 212 label = { Text(stringResource(R.string.amount_withdraw)) }, 213 isError = selectedAmount.amount.isZero() || insufficientBalance, 214 supportingText = { 215 if (insufficientBalance && maxAmount != null) { 216 Text(stringResource(R.string.amount_excess, maxAmount)) 217 } 218 }, 219 showShortcuts = true, 220 onShortcutSelected = { amount -> 221 selectedAmount = amount.copy( 222 debounce = false, 223 userInput = true, 224 ) 225 } 226 ) 227 228 LaunchedEffect(Unit) { 229 focusRequester.requestFocus() 230 } 231 232 if (status.status == TosReviewRequired) Text( 233 modifier = Modifier.padding(22.dp), 234 text = stringResource(R.string.withdraw_review_terms), 235 ) 236 } else if (status.amountInfo != null) { 237 TransactionAmountComposable( 238 label = if (wireFee != null && wireFee.isZero()) { 239 stringResource(R.string.amount_total) 240 } else { 241 stringResource(R.string.amount_chosen) 242 }, 243 amount = status.amountInfo.amountRaw.withSpec(status.selectedSpec), 244 amountType = if (wireFee != null && wireFee.isZero()) { 245 AmountType.Positive 246 } else { 247 AmountType.Neutral 248 }, 249 ) 250 } 251 252 if (status.status != TosReviewRequired 253 && status.amountInfo != null 254 && wireFee != null 255 && !wireFee.isZero()) { 256 TransactionAmountComposable( 257 label = stringResource(R.string.amount_fee), 258 amount = wireFee.withSpec(status.selectedSpec), 259 amountType = AmountType.Negative, 260 ) 261 262 TransactionAmountComposable( 263 label = stringResource(R.string.amount_total), 264 amount = status.amountInfo.amountEffective.withSpec(status.selectedSpec) + wireFee, 265 amountType = AmountType.Positive, 266 ) 267 } 268 269 if (exchange != null && selectedAmount.scope !is ScopeInfo.Exchange) { 270 TransactionInfoComposable( 271 label = stringResource(R.string.withdraw_exchange), 272 info = cleanExchange(exchange), 273 marquee = true, 274 trailing = { 275 if (devMode && possibleExchanges.size > 1) { 276 IconButton( 277 modifier = Modifier.padding(start = 8.dp), 278 onClick = { onSelectExchange() }, 279 ) { 280 Icon( 281 Icons.Default.Edit, 282 contentDescription = stringResource(R.string.edit), 283 ) 284 } 285 } 286 }, 287 ) 288 } 289 290 var expanded by remember { mutableStateOf(false) } 291 292 if (status.status != TosReviewRequired && ageRestrictionOptions.isNotEmpty()) { 293 TransactionInfoComposable( 294 label = stringResource(R.string.withdraw_restrict_age), 295 info = selectedAge?.toString() 296 ?: stringResource(R.string.withdraw_restrict_age_unrestricted) 297 ) { 298 IconButton( 299 modifier = Modifier.padding(start = 8.dp), 300 onClick = { expanded = true }) { 301 Icon( 302 Icons.Default.ArrowDropDown, 303 contentDescription = stringResource(R.string.edit), 304 ) 305 } 306 307 DropdownMenu( 308 expanded = expanded, 309 onDismissRequest = { expanded = false }, 310 ) { 311 DropdownMenuItem( 312 text = { Text(stringResource(R.string.withdraw_restrict_age_unrestricted)) }, 313 onClick = { 314 selectedAge = null 315 expanded = false 316 }, 317 ) 318 319 ageRestrictionOptions.forEach { age -> 320 DropdownMenuItem( 321 text = { Text(age.toString()) }, 322 onClick = { 323 selectedAge = age 324 expanded = false 325 }, 326 ) 327 } 328 } 329 } 330 } 331 } 332 333 BottomButtonBox(Modifier.fillMaxWidth()) { 334 Button( 335 modifier = Modifier 336 .systemBarsPaddingBottom(), 337 enabled = status.status != Updating 338 && (status.isCashAcceptor 339 || status.status == TosReviewRequired 340 || status.selectedAmount?.isZero() == false), 341 onClick = { 342 keyboardController?.hide() 343 if (status.status == TosReviewRequired) { 344 onTosReview() 345 } else onConfirm(selectedAge) 346 }, 347 ) { 348 when (status.status) { 349 Updating -> CircularProgressIndicator(modifier = Modifier.size(15.dp)) 350 TosReviewRequired -> Text(stringResource(R.string.withdraw_button_tos)) 351 else -> Text(stringResource(R.string.withdraw_button_confirm)) 352 } 353 } 354 } 355 } 356 } 357 358 private fun buildPreviewWithdrawStatus( 359 status: WithdrawStatus.Status, 360 ) = WithdrawStatus( 361 status = status, 362 talerWithdrawUri = "taler://", 363 exchangeBaseUrl = "exchange.head.taler.net", 364 transactionId = "tx:343434", 365 error = TalerErrorInfo(TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE), 366 uriInfo = WithdrawalDetailsForUri( 367 amount = null, 368 currency = "KUDOS", 369 editableAmount = true, 370 status = Pending, 371 maxAmount = Amount.fromJSONString("KUDOS:10"), 372 wireFee = Amount.fromJSONString("KUDOS:0.2"), 373 defaultExchangeBaseUrl = "exchange.head.taler.net", 374 possibleExchanges = listOf( 375 ExchangeItem( 376 exchangeBaseUrl = "exchange.demo.taler.net", 377 currency = "KUDOS", 378 paytoUris = emptyList(), 379 scopeInfo = null, 380 tosStatus = ExchangeTosStatus.Accepted, 381 ), 382 ExchangeItem( 383 exchangeBaseUrl = "exchange.head.taler.net", 384 currency = "KUDOS", 385 paytoUris = emptyList(), 386 scopeInfo = null, 387 tosStatus = ExchangeTosStatus.Accepted, 388 ), 389 ), 390 ), 391 amountInfo = WithdrawalDetailsForAmount( 392 amountRaw = Amount.fromJSONString("KUDOS:10.1"), 393 amountEffective = Amount.fromJSONString("KUDOS:10.2"), 394 withdrawalAccountsList = emptyList(), 395 ageRestrictionOptions = listOf(18, 23), 396 scopeInfo = ScopeInfo.Exchange( 397 currency = "KUDOS", 398 url = "exchange.head.taler.net", 399 ), 400 ) 401 ) 402 403 @Preview 404 @Composable 405 fun WithdrawalShowInfoUpdatingPreview() { 406 TalerSurface { 407 WithdrawalShowInfo( 408 status = buildPreviewWithdrawStatus(Updating), 409 devMode = true, 410 initialAmountScope = AmountScope( 411 amount = Amount.fromJSONString("KUDOS:10.10"), 412 scope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), 413 ), 414 editableScope = true, 415 scopes = listOf( 416 ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), 417 ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"), 418 ScopeInfo.Global("CHF"), 419 ), 420 onSelectExchange = {}, 421 onSelectAmount = { _, _ -> }, 422 onTosReview = {}, 423 onConfirm = {}, 424 ) 425 } 426 } 427 428 @Preview 429 @Composable 430 fun WithdrawalShowInfoTosReviewPreview() { 431 TalerSurface { 432 WithdrawalShowInfo( 433 status = buildPreviewWithdrawStatus(TosReviewRequired), 434 devMode = true, 435 initialAmountScope = AmountScope( 436 amount = Amount.fromJSONString("KUDOS:10.10"), 437 scope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), 438 ), 439 editableScope = true, 440 scopes = listOf( 441 ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), 442 ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"), 443 ScopeInfo.Global("CHF"), 444 ), 445 onSelectExchange = {}, 446 onSelectAmount = { _, _ -> }, 447 onTosReview = {}, 448 onConfirm = {}, 449 ) 450 } 451 } 452 453 @Preview 454 @Composable 455 fun WithdrawalShowInfoErrorPreview() { 456 TalerSurface { 457 WithdrawalShowInfo( 458 status = buildPreviewWithdrawStatus(Error), 459 devMode = true, 460 initialAmountScope = AmountScope( 461 amount = Amount.fromJSONString("KUDOS:10.10"), 462 scope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), 463 ), 464 editableScope = true, 465 scopes = listOf( 466 ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), 467 ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"), 468 ScopeInfo.Global("CHF"), 469 ), 470 onSelectExchange = {}, 471 onSelectAmount = { _, _ -> }, 472 onTosReview = {}, 473 onConfirm = {}, 474 ) 475 } 476 }