CheckoutFragment.kt
/*
* This file is part of GNU Taler
* (C) 2026 Taler Systems S.A.
*
* GNU Taler is free software; you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3, or (at your option) any later version.
*
* GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
package net.taler.merchantpos.checkout
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import net.taler.common.Amount
import net.taler.common.CurrencySpecification
import net.taler.merchantpos.MainActivity
import net.taler.merchantpos.MainViewModel
import net.taler.merchantpos.PosDestination
import net.taler.merchantpos.R
import net.taler.merchantpos.compose.PaymentMethodTile
import net.taler.merchantpos.compose.PaymentTileStyle
import net.taler.merchantpos.compose.PosTheme
import net.taler.merchantpos.compose.TalerPaymentTile
import net.taler.merchantpos.paymentoptions.CashOption
import net.taler.merchantpos.paymentoptions.ChargeResult
import net.taler.merchantpos.paymentoptions.PaymentOption
import net.taler.merchantpos.paymentoptions.PaymentOptionState
import net.taler.merchantpos.paymentoptions.PaymentOptionsSummary
import net.taler.merchantpos.paymentoptions.TALER_OPTION_ID
import net.taler.merchantpos.paymentoptions.statusLabel
import net.taler.merchantpos.showPosError
import kotlinx.coroutines.launch
/**
* Takes a sale's payments in order: cash, card and other external methods first, then
* GNU Taler for whatever is still open (DD 96).
*/
class CheckoutFragment : Fragment()
{
private val model: MainViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
) = ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
val checkoutManager = model.checkoutManager
val paymentOptions = model.paymentOptions
setContent {
val checkouts by checkoutManager.checkouts.observeAsState(emptyList())
val currentId by checkoutManager.currentId.observeAsState()
val summary by paymentOptions.summary.observeAsState(PaymentOptionsSummary())
val chargeEvent by checkoutManager.chargeEvent.observeAsState()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
val spec = model.configManager.currencySpec
val checkout = currentId?.let { id -> checkouts.firstOrNull { it.id == id } }
PosTheme {
if (checkout == null)
{
NoCheckout(onBack = { activity().navigateBack() })
return@PosTheme
}
CheckoutScreen(
checkout = checkout,
spec = spec,
options = paymentOptions.options,
optionStates = summary.options,
busy = busy,
onRecordCash = { tendered ->
runCatching { checkoutManager.recordCash(tendered) }
.onFailure {
requireActivity().showPosError(
getString(R.string.checkout_record_failed),
it.message.orEmpty(),
)
}.getOrNull()
},
onCharge = { option, amount ->
checkoutManager.chargeWith(option, amount, checkout.contractTerms.summary)
},
onResolvePending = { reference, paid, code ->
checkoutManager.resolvePendingCharge(reference, paid, code)
},
onFinishWithTaler = {
busy = true
scope.launch {
when (val result = checkoutManager.finishWithTaler())
{
is CheckoutResult.AwaitingWallet ->
{
model.paymentManager.showCheckoutOrder(result.checkout)
activity().navigateTo(PosDestination.ProcessPayment)
}
is CheckoutResult.Failed ->
{
requireActivity().showPosError(
getString(R.string.error_order_creation),
result.message,
)
}
CheckoutResult.Completed ->
{
Unit
}
}
busy = false
}
},
onAbandon = {
busy = true
scope.launch {
checkoutManager.abandon()
busy = false
activity().navigateBack()
}
},
)
chargeEvent?.let { event ->
val amount = event.amount.withSpec(spec).toString()
when (val result = event.result)
{
is ChargeResult.Success ->
{
LaunchedEffect(event) {
Toast
.makeText(
requireContext(),
getString(R.string.checkout_card_recorded, amount),
Toast.LENGTH_SHORT,
).show()
checkoutManager.consumeChargeEvent()
}
}
ChargeResult.Cancelled ->
{
LaunchedEffect(event) {
checkoutManager.consumeChargeEvent()
}
}
is ChargeResult.Failed ->
{
AlertDialog(
onDismissRequest = checkoutManager::consumeChargeEvent,
title = { Text(stringResource(R.string.checkout_card_title)) },
text = {
Text(
listOfNotNull(
stringResource(R.string.checkout_card_failed),
result.message,
).joinToString("\n\n"),
)
},
confirmButton = {
TextButton(onClick = checkoutManager::consumeChargeEvent) {
Text(stringResource(android.R.string.ok))
}
},
)
}
is ChargeResult.Unknown ->
{
AlertDialog(
onDismissRequest = {},
title = { Text(stringResource(R.string.checkout_card_unknown_title)) },
text = { Text(stringResource(R.string.checkout_card_unknown, amount)) },
confirmButton = {
Button(onClick = checkoutManager::consumeChargeEvent) {
Text(stringResource(android.R.string.ok))
}
},
)
}
}
}
}
}
}
private fun activity() = requireActivity() as MainActivity
}
@Composable
private fun CheckoutScreen(
checkout: Checkout,
spec: CurrencySpecification?,
options: List<PaymentOption>,
optionStates: List<PaymentOptionState>,
busy: Boolean,
onRecordCash: (Amount) -> CashOutcome?,
onCharge: (PaymentOption, Amount) -> Boolean,
onFinishWithTaler: () -> Unit,
onAbandon: () -> Unit,
onResolvePending: (String, Boolean, String?) -> Unit = { _, _, _ -> },
initialCashOpen: Boolean = false,
initialCardOptionId: String? = null,
initialShowPayments: Boolean = false,
)
{
var cashOpen by rememberSaveable { mutableStateOf(initialCashOpen) }
var cardOptionId by rememberSaveable { mutableStateOf(initialCardOptionId) }
val cardOption = options.firstOrNull { it.id == cardOptionId }
var showPayments by rememberSaveable { mutableStateOf(initialShowPayments) }
var changeDue by rememberSaveable { mutableStateOf<String?>(null) }
var showAbout by rememberSaveable { mutableStateOf(false) }
var cardNotReady by rememberSaveable { mutableStateOf(false) }
fun stateOf(option: PaymentOption) = optionStates.firstOrNull { it.id == option.id }
val external =
options.filter { option ->
option.id != TALER_OPTION_ID && stateOf(option)?.enabled == true &&
(option.id == CashOption.id || option.chargesExternally)
}
val taler = options.firstOrNull { it.id == TALER_OPTION_ID && stateOf(it)?.enabled == true }
val open = checkout.open
val canTake = !busy && checkout.canTakeExternalPayments
val anythingPaid = !checkout.paid.isZero()
val fullyPaid = open.isZero()
BoxWithConstraints(Modifier.fillMaxSize().padding(16.dp)) {
// Use the available window, including split-screen and enlarged display settings.
val compact = maxHeight < 600.dp || maxWidth < 600.dp
val landscape = maxWidth > maxHeight
val tablet = LocalConfiguration.current.smallestScreenWidthDp >= 600
val split = landscape || tablet
val phoneLandscape = landscape && !tablet
val paidProgress =
if (checkout.total.value == 0L &&
checkout.total.fraction == 0)
{
0f
}
else
{
val totalValue = checkout.total.value + checkout.total.fraction / 100_000_000.0
val paidValue = checkout.paid.value + checkout.paid.fraction / 100_000_000.0
(paidValue / totalValue).toFloat().coerceIn(0f, 1f)
}
val summary: @Composable () -> Unit = {
if (phoneLandscape)
{
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
stringResource(
if (anythingPaid)
{
R.string.checkout_remaining_heading
}
else
{
R.string.checkout_order_total_heading
},
),
style = MaterialTheme.typography.titleSmall,
)
Text(
if (anythingPaid) open.shown(spec) else checkout.total.shown(spec),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color =
if (anythingPaid)
{
MaterialTheme.colorScheme.primary
}
else
{
MaterialTheme.colorScheme.onSurface
},
)
Text(
checkout.contractTerms.summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
)
Text(
stringResource(R.string.checkout_total_amount, checkout.total.shown(spec)),
style = MaterialTheme.typography.bodySmall,
)
if (anythingPaid)
{
Text(
stringResource(R.string.checkout_paid_amount, checkout.paid.shown(spec)),
style = MaterialTheme.typography.bodySmall,
)
}
OutlinedButton(
onClick = { showPayments = true },
modifier = Modifier.fillMaxWidth().heightIn(min = 40.dp),
enabled = checkout.payments.isNotEmpty(),
) {
Text(
stringResource(R.string.checkout_payments_button, checkout.payments.size),
style = MaterialTheme.typography.labelLarge,
)
}
}
}
}
else
{
Column(verticalArrangement = Arrangement.spacedBy(20.dp)) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
stringResource(R.string.checkout_order_total_heading),
style = MaterialTheme.typography.titleMedium,
)
Text(
checkout.total.shown(spec),
style =
if (compact)
{
MaterialTheme.typography.headlineMedium
}
else
{
MaterialTheme.typography.headlineLarge
},
fontWeight = FontWeight.Bold,
)
if (checkout.posOrderId >= 0)
{
Text(
stringResource(R.string.checkout_order_reference, checkout.posOrderId),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
checkout.contractTerms.summary,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Box(
Modifier
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
Box(
Modifier
.fillMaxWidth(paidProgress)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.primary),
)
}
Text(
stringResource(R.string.checkout_paid_amount, checkout.paid.shown(spec)),
style = MaterialTheme.typography.bodyMedium,
)
Text(
stringResource(R.string.checkout_remaining_amount, open.shown(spec)),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
}
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(R.string.checkout_payments_heading),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f),
)
Text(
stringResource(R.string.checkout_payments_added, checkout.payments.size),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (checkout.payments.isEmpty())
{
Text(
stringResource(R.string.checkout_payments_empty_inline),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else
{
checkout.payments.forEach { PaymentLine(it, spec, options) }
}
}
}
}
val methods: @Composable (Modifier) -> Unit = { tileModifier ->
Column(
tileModifier,
verticalArrangement = Arrangement.spacedBy(if (phoneLandscape) 8.dp else 12.dp),
horizontalAlignment = Alignment.Start,
) {
if (!phoneLandscape)
{
Text(
stringResource(R.string.checkout_choose_payment_method),
style = MaterialTheme.typography.titleLarge,
)
Text(
stringResource(R.string.checkout_payment_method_helper),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
checkout.pendingCharge?.let { pending ->
PendingChargeRecovery(pending, spec, onResolvePending)
}
if (checkout.creationPending)
{
Text(stringResource(R.string.checkout_creation_pending))
}
external.forEach { option ->
val name = stringResource(option.nameRes)
if (option.id == CashOption.id)
{
PaymentMethodTile(
icon = option.icon,
style = PaymentTileStyle.Surface,
title = name,
detail =
if (fullyPaid)
{
stringResource(R.string.checkout_fully_paid)
}
else
{
stringResource(R.string.checkout_tap_to_enter_amount)
},
enabled = canTake,
modifier = tileModifier,
) {
cashOpen = true
}
}
else
{
val state = stateOf(option)
val usable = state != null && option.canCharge(state)
PaymentMethodTile(
icon = option.icon,
style = PaymentTileStyle.Surface,
// Keep the provider's connection status visible until it is ready.
detail =
if (fullyPaid)
{
stringResource(R.string.checkout_fully_paid)
}
else if (usable)
{
stringResource(R.string.checkout_tap_to_enter_amount)
}
else
{
state?.let { statusLabel(it.status) }
},
title = name,
enabled = canTake && usable,
modifier = tileModifier,
) {
cardOptionId = option.id
}
}
}
taler?.let {
TalerPaymentTile(
title =
if (fullyPaid)
{
stringResource(R.string.checkout_complete_payment)
}
else if (anythingPaid)
{
stringResource(R.string.checkout_charge_remaining_taler)
}
else
{
stringResource(R.string.checkout_charge_with_taler)
},
amount = open.takeUnless { fullyPaid }?.shown(spec),
detail =
if (fullyPaid)
{
stringResource(R.string.checkout_collect_receipt_hint)
}
else
{
stringResource(R.string.checkout_complete_order_hint)
},
enabled = !busy && checkout.pendingCharge == null,
modifier = Modifier.heightIn(min = 88.dp),
onClick = onFinishWithTaler,
)
}
}
}
val hint: @Composable () -> Unit = {
Text(
text =
stringResource(
when
{
fullyPaid -> R.string.checkout_collect_receipt_hint
checkout.stage == CheckoutStage.AwaitingTaler -> R.string.checkout_order_created_hint
open.isZero() -> R.string.checkout_settled_hint
taler == null -> R.string.checkout_no_taler_hint
else -> R.string.checkout_scan_to_pay_hint
},
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
val footer: @Composable () -> Unit = {
// Once money has been taken, cancelling is no longer a simple discard.
if (anythingPaid)
{
OutlinedButton(
onClick = { showAbout = true },
enabled = !busy,
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = if (compact) 48.dp else 64.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.primary),
) {
Text(stringResource(R.string.checkout_about_action), style = MaterialTheme.typography.titleMedium)
}
}
else
{
OutlinedButton(
onClick = onAbandon,
enabled = !busy,
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = if (compact) 48.dp else 64.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.checkout_cancel), style = MaterialTheme.typography.titleMedium)
}
}
}
if (split)
{
val methodWeight =
if (tablet &&
!landscape)
{
0.5f
}
else
{
0.6f
}
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(24.dp)) {
Column(
Modifier.weight(methodWeight).fillMaxHeight().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(if (phoneLandscape) 8.dp else 12.dp),
) {
methods(Modifier.fillMaxWidth())
if (!phoneLandscape) hint()
footer()
}
Box(Modifier.width(1.dp).fillMaxHeight().background(MaterialTheme.colorScheme.outlineVariant))
Column(
Modifier.weight(1f - methodWeight).fillMaxHeight().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(if (compact) 12.dp else 20.dp),
) {
summary()
}
}
}
else
{
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
summary()
methods(Modifier.fillMaxWidth())
hint()
footer()
}
}
if (busy) CircularProgressIndicator(Modifier.align(Alignment.Center))
}
if (cashOpen)
{
CashDialog(
open = open,
spec = spec,
methodLabel = stringResource(CashOption.nameRes),
methodIcon = CashOption.icon,
methodInstruction = stringResource(R.string.checkout_cash_entry_instruction),
onRecord = { tendered ->
val outcome = onRecordCash(tendered)
if (outcome != null)
{
cashOpen = false
if (!outcome.change.isZero()) changeDue = outcome.change.shown(spec)
}
},
onDismiss = { cashOpen = false },
)
}
cardOption?.let { option ->
CardDialog(
open = open,
spec = spec,
methodLabel = stringResource(option.nameRes),
methodIcon = option.icon,
methodInstruction = stringResource(R.string.checkout_card_entry_instruction),
onCharge = { amount ->
cardOptionId = null
if (!onCharge(option, amount)) cardNotReady = true
},
onDismiss = { cardOptionId = null },
)
}
changeDue?.let { change ->
AlertDialog(
onDismissRequest = {},
title = { Text(stringResource(R.string.checkout_change_due_title)) },
text = {
Text(
text = stringResource(R.string.checkout_change_due, change),
style = MaterialTheme.typography.headlineSmall,
)
},
confirmButton = {
Button(onClick = { changeDue = null }) { Text(stringResource(android.R.string.ok)) }
},
)
}
if (cardNotReady)
{
AlertDialog(
onDismissRequest = { cardNotReady = false },
title = { Text(stringResource(R.string.checkout_card_title)) },
text = { Text(stringResource(R.string.checkout_card_not_ready)) },
confirmButton = {
TextButton(onClick = { cardNotReady = false }) { Text(stringResource(android.R.string.ok)) }
},
)
}
if (showPayments)
{
PaymentsDialog(checkout, spec, options, onDismiss = { showPayments = false })
}
if (showAbout)
{
AlertDialog(
onDismissRequest = { showAbout = false },
title = { Text(stringResource(R.string.checkout_about)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.checkout_about_message, checkout.paid.shown(spec)))
checkout.payments.forEach { payment -> PaymentLine(payment, spec, options) }
}
},
confirmButton = {
TextButton(onClick = { showAbout = false }) {
Text(stringResource(R.string.terminal_close))
}
},
dismissButton = {
TextButton(
onClick = {
showAbout = false
onAbandon()
},
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.checkout_cancel_anyway))
}
},
)
}
}
/** Renders the checkout with fixed data, for screenshots and previews. */
@Composable
internal fun CheckoutScreenContent(
checkout: Checkout,
options: List<PaymentOption>,
optionStates: List<PaymentOptionState>,
spec: CurrencySpecification? = null,
initialCashOpen: Boolean = false,
initialCardOptionId: String? = null,
initialShowPayments: Boolean = false,
)
{
CheckoutScreen(
checkout = checkout,
spec = spec,
options = options,
optionStates = optionStates,
busy = false,
onRecordCash = { null },
onCharge = { _, _ -> false },
onFinishWithTaler = {},
onAbandon = {},
initialCashOpen = initialCashOpen,
initialCardOptionId = initialCardOptionId,
initialShowPayments = initialShowPayments,
)
}
@Composable
private fun PaymentsDialog(
checkout: Checkout,
spec: CurrencySpecification?,
options: List<PaymentOption>,
onDismiss: () -> Unit,
)
{
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.checkout_payments_title)) },
text = {
LazyColumn(
modifier = Modifier.heightIn(max = 360.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(checkout.payments, key = { it.id }) { payment ->
PaymentLine(payment, spec, options)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.terminal_close)) }
},
)
}
@Composable
private fun CashDialog(
open: Amount,
spec: CurrencySpecification?,
methodLabel: String,
methodIcon: ImageVector,
methodInstruction: String,
onRecord: (Amount) -> Unit,
onDismiss: () -> Unit,
)
{
val zero = Amount.zero(open.currency).withSpec(spec)
var entered by rememberSaveable(open.currency, stateSaver = amountStateSaver(open.currency, spec)) {
mutableStateOf(zero)
}
FullScreenDialog(onDismiss) {
val returnedChange = if (entered > open) entered - open else null
val afterPayment =
if (returnedChange != null)
{
stringResource(R.string.checkout_change_to_return_amount, returnedChange.shown(spec))
}
else
{
val remaining = if (entered >= open) Amount.zero(open.currency).withSpec(spec) else open - entered
stringResource(R.string.checkout_remaining_after_payment, remaining.shown(spec))
}
CheckoutAmountEntry(
title = stringResource(R.string.checkout_cash_received_heading),
remainingLabel = stringResource(R.string.checkout_remaining_to_pay, open.shown(spec)),
amountLabel = stringResource(R.string.checkout_cash_received_heading),
amount = entered.shown(spec),
remainingAmount = open,
spec = spec,
methodLabel = methodLabel,
methodIcon = methodIcon,
methodInstruction = methodInstruction,
afterPaymentLabel = afterPayment,
helper = stringResource(R.string.checkout_cash_change_explanation),
submitLabel = stringResource(R.string.checkout_record_cash_action, entered.shown(spec)),
submitEnabled = !entered.isZero(),
onDigit = { digit -> entered = entered.addInputDigit(digit)?.withSpec(spec) ?: entered },
onClear = { entered = zero },
onBackspace = { entered = entered.removeInputDigit()?.withSpec(spec) ?: entered },
onFractionAmount = { denominator -> entered = open.fractionAmount(denominator, spec) },
onSubmit = { onRecord(entered) },
onDismiss = onDismiss,
)
}
}
@Composable
private fun CardDialog(
open: Amount,
spec: CurrencySpecification?,
methodLabel: String,
methodIcon: ImageVector,
methodInstruction: String,
onCharge: (Amount) -> Unit,
onDismiss: () -> Unit,
)
{
val zero = Amount.zero(open.currency).withSpec(spec)
var entered by rememberSaveable(open.currency, stateSaver = amountStateSaver(open.currency, spec)) {
mutableStateOf(open.withSpec(spec))
}
val tooMuch = entered > open
FullScreenDialog(onDismiss) {
val afterPayment = if (entered >= open) Amount.zero(open.currency).withSpec(spec) else open - entered
CheckoutAmountEntry(
title = stringResource(R.string.checkout_card_payment_heading),
remainingLabel = stringResource(R.string.checkout_remaining_to_pay, open.shown(spec)),
amountLabel = stringResource(R.string.checkout_card_amount),
amount = entered.shown(spec),
remainingAmount = open,
spec = spec,
methodLabel = methodLabel,
methodIcon = methodIcon,
methodInstruction = methodInstruction,
afterPaymentLabel = stringResource(R.string.checkout_remaining_after_payment, afterPayment.shown(spec)),
helper =
stringResource(
if (tooMuch) R.string.checkout_card_too_much else R.string.checkout_card_amount_limit_hint,
),
helperIsError = tooMuch,
submitLabel = stringResource(R.string.checkout_charge_card_action, entered.shown(spec)),
submitEnabled = !entered.isZero() && !tooMuch,
onDigit = { digit -> entered = entered.addInputDigit(digit)?.withSpec(spec) ?: entered },
onClear = { entered = zero },
onBackspace = { entered = entered.removeInputDigit()?.withSpec(spec) ?: entered },
onFractionAmount = { denominator -> entered = open.fractionAmount(denominator, spec) },
onSubmit = { onCharge(entered) },
onDismiss = onDismiss,
)
}
}
@Composable
internal fun PaymentLine(
payment: RecordedPayment,
spec: CurrencySpecification?,
options: List<PaymentOption>,
)
{
val method =
options.firstOrNull { it.id == payment.method }?.let { stringResource(it.nameRes) }
?: payment.method
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(method, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f))
Text(payment.amount.shown(spec), style = MaterialTheme.typography.titleSmall)
}
if (payment.tendered != null &&
payment.change != null)
{
Text(
text =
stringResource(
R.string.checkout_payment_line_change,
payment.tendered.shown(spec),
payment.change.shown(spec),
),
style = MaterialTheme.typography.bodySmall,
)
}
payment.transactionCode?.let {
Text(
stringResource(R.string.checkout_payment_line_reference, it),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@Composable
private fun FullScreenDialog(
onDismiss: () -> Unit,
content: @Composable () -> Unit,
)
{
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
content()
}
}
}
@Composable
private fun NoCheckout(onBack: () -> Unit)
{
Column(
modifier =
Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
) {
Text(stringResource(R.string.checkout_none), style = MaterialTheme.typography.titleMedium)
Button(onClick = onBack) { Text(stringResource(R.string.payment_back_button)) }
}
}
internal fun Amount.shown(spec: CurrencySpecification?): String = withSpec(spec).toString()
private fun amountStateSaver(
currency: String,
spec: CurrencySpecification?,
) = Saver<Amount, LongArray>(
save = { amount -> longArrayOf(amount.value, amount.fraction.toLong()) },
restore = { saved -> Amount(currency, saved[0], saved[1].toInt(), spec) },
)
/** A provider result is never guessed from a timeout or from an app restart. */
@Composable
private fun PendingChargeRecovery(
pending: PendingCharge,
spec: CurrencySpecification?,
onResolve: (String, Boolean, String?) -> Unit,
)
{
var outcome by rememberSaveable(pending.reference) { mutableStateOf<Boolean?>(null) }
var transaction by rememberSaveable(pending.reference) { mutableStateOf("") }
Text(
stringResource(R.string.checkout_pending_charge, pending.amount.shown(spec), pending.method, pending.reference),
)
OutlinedButton(onClick = { outcome = true }, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.checkout_pending_paid))
}
OutlinedButton(onClick = { outcome = false }, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.checkout_pending_not_paid))
}
outcome?.let { paid ->
AlertDialog(
onDismissRequest = { outcome = null },
title = { Text(stringResource(R.string.checkout_pending_confirm_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
stringResource(
if (paid)
{
R.string.checkout_pending_confirm_paid
}
else
{
R.string.checkout_pending_confirm_unpaid
},
),
)
if (paid)
{
OutlinedTextField(
value = transaction,
onValueChange = { transaction = it },
label = { Text(stringResource(R.string.checkout_pending_transaction)) },
singleLine = true,
)
}
}
},
confirmButton = {
TextButton(
enabled = !paid || transaction.isNotBlank(),
onClick = {
onResolve(pending.reference, paid, transaction.takeIf { paid })
outcome = null
}
) { Text(stringResource(android.R.string.ok)) }
},
dismissButton = {
TextButton(onClick = { outcome = null }) { Text(stringResource(android.R.string.cancel)) }
},
)
}
}