ProcessPaymentFragment.kt (17207B)
1 /* 2 * This file is part of GNU Taler 3 * (C) 2020 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.merchantpos.payment 18 19 import android.os.Bundle 20 import androidx.compose.foundation.layout.Arrangement 21 import androidx.compose.foundation.layout.Box 22 import androidx.compose.foundation.layout.BoxWithConstraints 23 import androidx.compose.foundation.layout.Column 24 import androidx.compose.foundation.layout.Row 25 import androidx.compose.foundation.layout.Spacer 26 import androidx.compose.foundation.layout.fillMaxSize 27 import androidx.compose.foundation.layout.fillMaxWidth 28 import androidx.compose.foundation.layout.height 29 import androidx.compose.foundation.layout.padding 30 import androidx.compose.foundation.layout.size 31 import androidx.compose.foundation.layout.statusBarsPadding 32 import androidx.compose.foundation.layout.width 33 import androidx.compose.material3.AlertDialog 34 import androidx.compose.material3.Button 35 import androidx.compose.material3.ButtonDefaults 36 import androidx.compose.material3.CircularProgressIndicator 37 import androidx.compose.material3.MaterialTheme 38 import androidx.compose.material3.OutlinedButton 39 import androidx.compose.material3.Text 40 import androidx.compose.runtime.Composable 41 import androidx.compose.runtime.LaunchedEffect 42 import androidx.compose.runtime.getValue 43 import androidx.compose.runtime.mutableStateOf 44 import androidx.compose.runtime.remember 45 import androidx.compose.runtime.setValue 46 import androidx.compose.ui.Alignment 47 import androidx.compose.ui.Modifier 48 import androidx.compose.ui.platform.ComposeView 49 import androidx.compose.ui.platform.LocalConfiguration 50 import androidx.compose.ui.platform.ViewCompositionStrategy 51 import androidx.compose.ui.res.painterResource 52 import androidx.compose.ui.res.stringResource 53 import androidx.compose.ui.text.style.TextAlign 54 import androidx.compose.ui.unit.dp 55 import androidx.fragment.app.Fragment 56 import androidx.fragment.app.activityViewModels 57 import androidx.compose.runtime.livedata.observeAsState 58 import net.taler.lib.android.AnimatedQrCodeComposable 59 import net.taler.lib.android.TalerNfcService.Companion.hasNfc 60 import net.taler.lib.android.copyToClipBoard 61 import net.taler.lib.android.shareText 62 import net.taler.merchantpos.MainActivity 63 import net.taler.merchantpos.MainViewModel 64 import net.taler.merchantpos.PosDestination 65 import net.taler.merchantpos.R 66 import net.taler.merchantpos.compose.PosTheme 67 import net.taler.merchantpos.showPosError 68 69 class ProcessPaymentFragment : Fragment() { 70 71 private val model: MainViewModel by activityViewModels() 72 private val paymentManager by lazy { model.paymentManager } 73 74 private var deviceHasNfc = false 75 76 override fun onCreateView( 77 inflater: android.view.LayoutInflater, 78 container: android.view.ViewGroup?, 79 savedInstanceState: Bundle?, 80 ) = ComposeView(requireContext()).apply { 81 setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) 82 setContent { 83 val payment by paymentManager.payment.observeAsState() 84 val deleteNeedsForce by paymentManager.deleteNeedsForce.observeAsState() 85 payment?.let { 86 ProcessPaymentScreen( 87 payment = it, 88 deviceHasNfc = deviceHasNfc, 89 showForceDeleteDialog = deleteNeedsForce == true, 90 onCancel = { 91 if (it.claimed) { 92 paymentManager.tryDeleteOrder() 93 } else { 94 onPaymentCancel() 95 } 96 }, 97 onForceDelete = ::onForceDeleteOrder, 98 onDismissForceDelete = { 99 paymentManager.clearDeleteNeedsForce() 100 }, 101 onShare = { uri -> requireContext().shareText(uri) }, 102 onCopy = { uri -> 103 copyToClipBoard(requireContext(), "Payment URI", uri) 104 }, 105 ) 106 } 107 108 // Navigate back on successful (non-force) delete 109 LaunchedEffect(deleteNeedsForce) { 110 if (deleteNeedsForce == false) { 111 paymentManager.clearDeleteNeedsForce() 112 (requireActivity() as MainActivity).navigateBack() 113 } 114 } 115 } 116 } 117 118 override fun onViewCreated(view: android.view.View, savedInstanceState: Bundle?) { 119 deviceHasNfc = hasNfc(requireContext()) 120 paymentManager.payment.observe(viewLifecycleOwner) { payment -> 121 onPaymentStateChanged(payment) 122 } 123 } 124 125 override fun onDestroy() { 126 super.onDestroy() 127 } 128 129 private fun onPaymentStateChanged(payment: Payment) { 130 if (payment.error != null) { 131 val (mainText, detailText) = getPaymentErrorDisplay(payment) 132 requireActivity().showPosError(mainText, detailText) 133 (requireActivity() as MainActivity).navigateBack() 134 return 135 } 136 if (payment.paid) { 137 model.orderManager.onOrderPaid(payment.order.id) 138 (requireActivity() as MainActivity).navigateTo(PosDestination.PaymentSuccess) 139 } 140 } 141 142 private fun onPaymentCancel() { 143 paymentManager.cancelPayment() 144 (requireActivity() as MainActivity).navigateBack() 145 } 146 147 private fun onForceDeleteOrder() { 148 paymentManager.forceDeleteOrder() 149 (requireActivity() as MainActivity).navigateBack() 150 } 151 152 private fun getPaymentErrorDisplay(payment: Payment): Pair<String, String> { 153 val error = payment.error.orEmpty() 154 if (payment.orderId != null) { 155 return getString(R.string.error_payment) to error 156 } 157 val normalized = error.lowercase() 158 return when { 159 "inventory" in normalized || 160 "stock" in normalized || 161 "insufficient" in normalized || 162 "sold out" in normalized || 163 "out of stock" in normalized -> 164 getString(R.string.error_inventory_unavailable) to error 165 166 else -> 167 getString(R.string.error_order_creation) to error 168 } 169 } 170 } 171 172 @Composable 173 private fun ProcessPaymentScreen( 174 payment: Payment, 175 deviceHasNfc: Boolean, 176 showForceDeleteDialog: Boolean, 177 onCancel: () -> Unit, 178 onForceDelete: () -> Unit, 179 onDismissForceDelete: () -> Unit, 180 onShare: (String) -> Unit, 181 onCopy: (String) -> Unit, 182 ) { 183 if (showForceDeleteDialog) { 184 AlertDialog( 185 onDismissRequest = onDismissForceDelete, 186 title = { Text(stringResource(R.string.force_delete_dialog_title)) }, 187 text = { Text(stringResource(R.string.force_delete_dialog_message)) }, 188 confirmButton = { 189 Button( 190 onClick = onForceDelete, 191 colors = ButtonDefaults.buttonColors( 192 containerColor = MaterialTheme.colorScheme.error, 193 ), 194 ) { 195 Text(stringResource(R.string.force_delete_dialog_confirm)) 196 } 197 }, 198 dismissButton = { 199 OutlinedButton(onClick = onDismissForceDelete) { 200 Text(stringResource(R.string.payment_cancel)) 201 } 202 }, 203 ) 204 } 205 206 PosTheme { 207 val introText = if (payment.claimed) { 208 stringResource(R.string.payment_claimed) 209 } else if (deviceHasNfc && payment.talerPayUri != null) { 210 stringResource(R.string.payment_intro_nfc) 211 } else { 212 stringResource(R.string.payment_intro) 213 } 214 val isTabletLayout = LocalConfiguration.current.smallestScreenWidthDp >= 720 215 216 if (isTabletLayout) { 217 TabletProcessPaymentScreen(payment, introText, onCancel, onShare, onCopy) 218 } else { 219 PhoneProcessPaymentScreen(payment, introText, onCancel, onShare, onCopy) 220 } 221 } 222 } 223 224 @Composable 225 private fun TabletProcessPaymentScreen( 226 payment: Payment, 227 introText: String, 228 onCancel: () -> Unit, 229 onShare: (String) -> Unit, 230 onCopy: (String) -> Unit, 231 ) { 232 Row( 233 modifier = Modifier 234 .fillMaxSize() 235 .statusBarsPadding() 236 .padding(16.dp), 237 horizontalArrangement = Arrangement.spacedBy(16.dp), 238 ) { 239 Column( 240 modifier = Modifier 241 .weight(0.54f) 242 .fillMaxSize(), 243 verticalArrangement = Arrangement.spacedBy(12.dp), 244 horizontalAlignment = Alignment.CenterHorizontally, 245 ) { 246 Box( 247 modifier = Modifier 248 .fillMaxWidth() 249 .weight(1f), 250 contentAlignment = Alignment.Center, 251 ) { 252 if (!payment.claimed) { 253 BoxWithConstraints( 254 modifier = Modifier 255 .fillMaxSize() 256 .padding(12.dp), 257 contentAlignment = Alignment.Center, 258 ) { 259 val payUri = payment.talerPayUri 260 val qrSize = minOf(maxWidth, maxHeight) 261 Box( 262 modifier = Modifier.size(qrSize), 263 contentAlignment = Alignment.Center, 264 ) { 265 if (payUri == null) { 266 CircularProgressIndicator() 267 } else { 268 AnimatedQrCodeComposable( 269 link = payUri, 270 logoPainter = painterResource(R.drawable.ic_taler_logo_qr), 271 modifier = Modifier.fillMaxSize(), 272 ) 273 } 274 } 275 } 276 } 277 } 278 279 if (!payment.claimed) payment.talerPayUri?.let { payUri -> 280 Row( 281 modifier = Modifier.fillMaxWidth(), 282 horizontalArrangement = Arrangement.spacedBy(12.dp), 283 ) { 284 Button(onClick = { onShare(payUri) }, modifier = Modifier.weight(1f)) { 285 Text(stringResource(R.string.share)) 286 } 287 Button(onClick = { onCopy(payUri) }, modifier = Modifier.weight(1f)) { 288 Text(stringResource(R.string.copy)) 289 } 290 } 291 } 292 } 293 294 Column( 295 modifier = Modifier 296 .weight(0.46f) 297 .fillMaxSize() 298 .padding(horizontal = 8.dp), 299 verticalArrangement = Arrangement.spacedBy(16.dp), 300 horizontalAlignment = Alignment.CenterHorizontally, 301 ) { 302 Text( 303 text = introText, 304 style = MaterialTheme.typography.headlineSmall, 305 textAlign = TextAlign.Center, 306 ) 307 Text( 308 text = payment.order.total.toString(), 309 style = MaterialTheme.typography.headlineMedium, 310 textAlign = TextAlign.Center, 311 ) 312 payment.orderId?.let { 313 Text( 314 text = stringResource(R.string.payment_order_id, it), 315 style = MaterialTheme.typography.bodyLarge, 316 textAlign = TextAlign.Center, 317 ) 318 } 319 Spacer(modifier = Modifier.weight(1f)) 320 OutlinedButton( 321 onClick = onCancel, 322 modifier = Modifier.fillMaxWidth(), 323 colors = ButtonDefaults.outlinedButtonColors( 324 containerColor = MaterialTheme.colorScheme.error, 325 contentColor = MaterialTheme.colorScheme.onError, 326 ), 327 ) { 328 Text(stringResource(R.string.payment_cancel)) 329 } 330 } 331 } 332 } 333 334 @Composable 335 private fun PhoneProcessPaymentScreen( 336 payment: Payment, 337 introText: String, 338 onCancel: () -> Unit, 339 onShare: (String) -> Unit, 340 onCopy: (String) -> Unit, 341 ) { 342 Row( 343 modifier = Modifier 344 .fillMaxSize() 345 .statusBarsPadding() 346 .padding(16.dp), 347 horizontalArrangement = Arrangement.spacedBy(16.dp), 348 ) { 349 Column( 350 modifier = Modifier 351 .weight(0.5f) 352 .fillMaxSize(), 353 verticalArrangement = Arrangement.Center, 354 horizontalAlignment = Alignment.CenterHorizontally, 355 ) { 356 if (!payment.claimed) { 357 Box( 358 modifier = Modifier 359 .fillMaxWidth() 360 .weight(1f), 361 contentAlignment = Alignment.Center, 362 ) { 363 BoxWithConstraints( 364 modifier = Modifier 365 .fillMaxSize() 366 .padding(12.dp), 367 contentAlignment = Alignment.Center, 368 ) { 369 val payUri = payment.talerPayUri 370 val qrSize = minOf(maxWidth, maxHeight) 371 Box( 372 modifier = Modifier.size(qrSize), 373 contentAlignment = Alignment.Center, 374 ) { 375 if (payUri == null) { 376 CircularProgressIndicator() 377 } else { 378 AnimatedQrCodeComposable( 379 link = payUri, 380 logoPainter = painterResource(R.drawable.ic_taler_logo_qr), 381 modifier = Modifier.fillMaxSize(), 382 ) 383 } 384 } 385 } 386 } 387 } 388 } 389 390 Column( 391 modifier = Modifier 392 .weight(0.5f) 393 .fillMaxSize() 394 .padding(horizontal = 8.dp), 395 verticalArrangement = Arrangement.Center, 396 horizontalAlignment = Alignment.CenterHorizontally, 397 ) { 398 Text( 399 text = introText, 400 style = MaterialTheme.typography.headlineSmall, 401 textAlign = TextAlign.Center, 402 ) 403 Text( 404 text = payment.order.total.toString(), 405 style = MaterialTheme.typography.headlineMedium, 406 textAlign = TextAlign.Center, 407 ) 408 payment.orderId?.let { 409 Text( 410 text = stringResource(R.string.payment_order_id, it), 411 style = MaterialTheme.typography.bodyLarge, 412 textAlign = TextAlign.Center, 413 ) 414 } 415 Spacer(modifier = Modifier.height(8.dp)) 416 payment.talerPayUri?.let { payUri -> 417 Row( 418 modifier = Modifier.fillMaxWidth(), 419 horizontalArrangement = Arrangement.spacedBy(8.dp), 420 ) { 421 Button( 422 onClick = { onShare(payUri) }, 423 modifier = Modifier.weight(1f), 424 ) { 425 Text(stringResource(R.string.share)) 426 } 427 OutlinedButton( 428 onClick = { onCopy(payUri) }, 429 modifier = Modifier.weight(1f), 430 ) { 431 Text(stringResource(R.string.copy)) 432 } 433 } 434 } 435 OutlinedButton( 436 onClick = onCancel, 437 modifier = Modifier.fillMaxWidth(), 438 colors = ButtonDefaults.outlinedButtonColors( 439 containerColor = MaterialTheme.colorScheme.error, 440 contentColor = MaterialTheme.colorScheme.onError, 441 ), 442 ) { 443 Text(stringResource(R.string.payment_cancel)) 444 } 445 } 446 } 447 } 448 449 @Composable 450 internal fun PaymentScreenContent(payment: Payment) { 451 ProcessPaymentScreen( 452 payment = payment, 453 deviceHasNfc = false, 454 showForceDeleteDialog = false, 455 onCancel = {}, 456 onForceDelete = {}, 457 onDismissForceDelete = {}, 458 onShare = {}, 459 onCopy = {}, 460 ) 461 }