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 }