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 }