AmountEntryFragment.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.amount
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.unit.sp
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.AmountKeypad
import net.taler.merchantpos.compose.PosTheme
import net.taler.merchantpos.config.ConfigProduct
import net.taler.merchantpos.order.Order
import net.taler.merchantpos.showPosError
private const val QUICK_AMOUNT_ORDER_ID = -1
private const val QUICK_AMOUNT_PRODUCT_ID = "quick_amount"
private const val STATE_AMOUNT_CURRENCY = "amount_entry.currency"
private const val STATE_AMOUNT_INTEGER = "amount_entry.integer"
private const val STATE_AMOUNT_FRACTION = "amount_entry.fraction"
internal data class AmountEntryDraft(
val currency: String,
val amount: Amount,
)
internal fun saveAmountEntryDraft(
outState: Bundle,
currency: String?,
amount: Amount?,
)
{
currency?.let { outState.putString(STATE_AMOUNT_CURRENCY, it) }
amount?.let {
outState.putLong(STATE_AMOUNT_INTEGER, it.value)
outState.putInt(STATE_AMOUNT_FRACTION, it.fraction)
}
}
internal fun restoreAmountEntryDraft(
savedState: Bundle?,
configuredCurrency: String,
currencySpec: CurrencySpecification?,
): AmountEntryDraft?
{
val state = savedState ?: return null
val currency =
state
.getString(STATE_AMOUNT_CURRENCY)
?.takeIf { it == configuredCurrency } ?: return null
if (!state.containsKey(STATE_AMOUNT_INTEGER) ||
!state.containsKey(STATE_AMOUNT_FRACTION))
{
return null
}
return AmountEntryDraft(
currency = currency,
amount =
Amount(
currency = currency,
value = state.getLong(STATE_AMOUNT_INTEGER),
fraction = state.getInt(STATE_AMOUNT_FRACTION),
spec = currencySpec,
),
)
}
class AmountEntryFragment : Fragment()
{
private val viewModel: MainViewModel by activityViewModels()
private val paymentManager by lazy { viewModel.paymentManager }
private var selectedCurrency by mutableStateOf<String?>(null)
private var amount by mutableStateOf<Amount?>(null)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View
{
initializeAmountState(savedInstanceState)
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
AmountEntryScreen(
amountText = amount?.toString(showSymbol = false) ?: "0.00",
selectedCurrency = selectedCurrency,
currencyOptions = viewModel.configManager.currency?.let(::listOf) ?: emptyList(),
chargeEnabled = amount?.isZero() == false,
onCurrencySelected = ::setCurrency,
onDigitPressed = ::onDigitPressed,
onClearPressed = ::clearAmount,
onBackspacePressed = ::onBackspacePressed,
onChargePressed = ::onChargePressed,
)
}
}
}
override fun onSaveInstanceState(outState: Bundle)
{
super.onSaveInstanceState(outState)
saveAmountEntryDraft(outState, selectedCurrency, amount)
}
override fun onStart()
{
super.onStart()
val activity = requireActivity() as MainActivity
val configManager = viewModel.configManager
if (!configManager.config.isValid())
{
activity.navigateTo(PosDestination.Config)
}
else if (configManager.currency == null)
{
activity.navigateTo(PosDestination.ConfigFetcher)
}
}
private fun initializeAmountState(savedInstanceState: Bundle?)
{
val configuredCurrency = viewModel.configManager.currency ?: return
val currencySpec = viewModel.configManager.currencySpec
val restored = restoreAmountEntryDraft(savedInstanceState, configuredCurrency, currencySpec)
val currency = restored?.currency ?: configuredCurrency
selectedCurrency = currency
amount = restored?.amount ?: amount ?: Amount.zero(currency).withSpec(currencySpec)
}
private fun setCurrency(currency: String)
{
selectedCurrency = currency
val currentAmount = amount
val spec = viewModel.configManager.currencySpec
amount =
when
{
currentAmount == null -> Amount.zero(currency)
currentAmount.currency == currency -> currentAmount
else -> currentAmount.withCurrency(currency)
}.withSpec(spec)
}
private fun onDigitPressed(digit: Char)
{
val currentAmount = amount ?: return
amount = currentAmount.addInputDigit(digit)?.withSpec(currentAmount.spec) ?: currentAmount
}
private fun onBackspacePressed()
{
val currentAmount = amount ?: return
amount = currentAmount.removeInputDigit()?.withSpec(currentAmount.spec) ?: currentAmount
}
private fun clearAmount()
{
val currency = selectedCurrency ?: return
amount = Amount.zero(currency).withSpec(viewModel.configManager.currencySpec)
}
private fun onChargePressed()
{
val activity = requireActivity() as MainActivity
val configuredCurrency =
viewModel.configManager.currency ?: run {
activity.navigateTo(PosDestination.ConfigFetcher)
return
}
val enteredCurrency = selectedCurrency ?: configuredCurrency
val enteredAmount =
amount
?: Amount.zero(enteredCurrency).withSpec(viewModel.configManager.currencySpec)
if (enteredAmount.isZero())
{
requireActivity().showPosError(R.string.amount_entry_error_zero)
return
}
if (enteredCurrency != configuredCurrency)
{
requireActivity().showPosError(R.string.amount_entry_error_wrong_currency)
return
}
val order =
Order(
id = QUICK_AMOUNT_ORDER_ID,
currency = configuredCurrency,
currencySpec = viewModel.configManager.currencySpec,
availableCategories = emptyMap(),
)
val product =
ConfigProduct(
description = getString(R.string.amount_entry_product_description),
productId = QUICK_AMOUNT_PRODUCT_ID,
price = enteredAmount.withSpec(viewModel.configManager.currencySpec),
categories = listOf(Int.MIN_VALUE),
)
val orderWithProduct = order + product
// Backend doesn't require products; omit them for this "quick amount" flow.
val checkoutManager = viewModel.checkoutManager
if (checkoutManager.shouldUseCheckout())
{
checkoutManager.start(orderWithProduct, includeProducts = false)
activity.navigateTo(PosDestination.Checkout)
}
else
{
paymentManager.createPayment(orderWithProduct, includeProducts = false)
activity.navigateTo(PosDestination.ProcessPayment)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AmountEntryScreen(
amountText: String,
selectedCurrency: String?,
currencyOptions: List<String>,
chargeEnabled: Boolean,
onCurrencySelected: (String) -> Unit,
onDigitPressed: (Char) -> Unit,
onClearPressed: () -> Unit,
onBackspacePressed: () -> Unit,
onChargePressed: () -> Unit,
)
{
PosTheme {
val configuration = LocalConfiguration.current
val isTabletLayout = configuration.smallestScreenWidthDp >= 600
val portrait = configuration.screenHeightDp > configuration.screenWidthDp
Box(
modifier = Modifier.fillMaxSize(),
) {
if (portrait)
{
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val contentHeight = maxHeight - 48.dp
val keypadHeight =
(contentHeight * if (isTabletLayout) 0.44f else 0.56f)
.coerceIn(
minimumValue = if (isTabletLayout) 460.dp else 280.dp,
maximumValue = if (isTabletLayout) 516.dp else 420.dp,
)
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
val contentWidth =
if (isTabletLayout)
{
Modifier.widthIn(
max = 560.dp,
)
}
else
{
Modifier.fillMaxWidth()
}
AmountPane(
amountText = amountText,
selectedCurrency = selectedCurrency,
currencyOptions = currencyOptions,
isTabletLayout = isTabletLayout,
portrait = true,
onCurrencySelected = onCurrencySelected,
modifier = contentWidth.weight(1f),
)
AmountKeypad(
isTabletLayout = isTabletLayout,
chargeEnabled = chargeEnabled,
onDigitPressed = onDigitPressed,
onClearPressed = onClearPressed,
onBackspacePressed = onBackspacePressed,
onChargePressed = onChargePressed,
modifier = contentWidth.height(keypadHeight),
)
}
}
}
else if (!isTabletLayout)
{
Row(
modifier =
Modifier
.fillMaxSize()
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AmountPane(
amountText = amountText,
selectedCurrency = selectedCurrency,
currencyOptions = currencyOptions,
isTabletLayout = false,
onCurrencySelected = onCurrencySelected,
modifier =
Modifier
.weight(0.32f)
.padding(horizontal = 8.dp, vertical = 4.dp),
)
AmountKeypad(
isTabletLayout = false,
chargeEnabled = chargeEnabled,
onDigitPressed = onDigitPressed,
onClearPressed = onClearPressed,
onBackspacePressed = onBackspacePressed,
onChargePressed = onChargePressed,
modifier =
Modifier
.weight(0.68f)
.padding(4.dp),
)
}
}
else
{
Column(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
AmountPane(
amountText = amountText,
selectedCurrency = selectedCurrency,
currencyOptions = currencyOptions,
isTabletLayout = true,
onCurrencySelected = onCurrencySelected,
modifier =
Modifier
.fillMaxWidth()
.weight(0.35f),
)
Row(
modifier =
Modifier
.fillMaxWidth()
.weight(0.65f),
horizontalArrangement = Arrangement.Center,
) {
AmountKeypad(
isTabletLayout = true,
chargeEnabled = chargeEnabled,
onDigitPressed = onDigitPressed,
onClearPressed = onClearPressed,
onBackspacePressed = onBackspacePressed,
onChargePressed = onChargePressed,
modifier = Modifier.fillMaxWidth(0.6f),
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AmountPane(
amountText: String,
selectedCurrency: String?,
currencyOptions: List<String>,
isTabletLayout: Boolean,
onCurrencySelected: (String) -> Unit,
modifier: Modifier = Modifier,
portrait: Boolean = false,
)
{
var expanded by remember { mutableStateOf(false) }
val availableCurrencies = currencyOptions.distinct()
val dropdownComposable: @Composable () -> Unit = {
if (availableCurrencies.size <= 1)
{
Text(
text = availableCurrencies.singleOrNull() ?: selectedCurrency.orEmpty(),
style = MaterialTheme.typography.titleLarge.copy(fontSize = 24.sp, fontWeight = FontWeight.Medium),
color = MaterialTheme.colorScheme.onSurface,
)
}
else
{
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { if (currencyOptions.isNotEmpty()) expanded = !expanded },
modifier =
if (portrait)
{
Modifier.fillMaxWidth()
}
else
{
Modifier.wrapContentWidth(
Alignment.CenterHorizontally,
)
},
) {
OutlinedTextField(
modifier =
Modifier
.menuAnchor(
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
enabled = currencyOptions.isNotEmpty(),
).then(if (portrait) Modifier.fillMaxWidth() else Modifier.widthIn(min = 96.dp)),
value = selectedCurrency.orEmpty(),
onValueChange = {},
readOnly = true,
singleLine = true,
label = { Text(stringResource(R.string.amount_entry_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
shape = RoundedCornerShape(14.dp),
colors =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant,
focusedLabelColor = MaterialTheme.colorScheme.primary,
unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
focusedTextColor = MaterialTheme.colorScheme.onSurface,
unfocusedTextColor = MaterialTheme.colorScheme.onSurface,
focusedTrailingIconColor = MaterialTheme.colorScheme.primary,
unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
availableCurrencies.forEach { currency ->
DropdownMenuItem(
text = { Text(currency) },
onClick = {
expanded = false
onCurrencySelected(currency)
},
)
}
}
}
}
}
if (portrait)
{
val compactPortrait = LocalConfiguration.current.screenHeightDp <= 680
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
if (availableCurrencies.size > 1)
{
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterStart) {
dropdownComposable()
}
Spacer(
modifier =
Modifier.height(
if (isTabletLayout)
{
44.dp
}
else if (compactPortrait)
{
8.dp
}
else
{
40.dp
},
),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
amountText,
modifier = Modifier.weight(1f, fill = false).alignByBaseline(),
style =
MaterialTheme.typography.headlineLarge.copy(
fontSize =
if (isTabletLayout)
{
64.sp
}
else if (compactPortrait)
{
48.sp
}
else
{
56.sp
},
),
maxLines = 1,
softWrap = false,
)
if (availableCurrencies.size <= 1)
{
Text(
text = availableCurrencies.singleOrNull() ?: selectedCurrency.orEmpty(),
modifier = Modifier.alignByBaseline(),
style =
MaterialTheme.typography.titleLarge.copy(
fontSize = 24.sp,
fontWeight = FontWeight.Medium,
),
color = MaterialTheme.colorScheme.onSurface,
)
}
}
Spacer(modifier = Modifier.height(if (compactPortrait) 8.dp else 24.dp))
Text(stringResource(R.string.amount_entry_hint), style = MaterialTheme.typography.bodyLarge)
}
}
else if (isTabletLayout)
{
Row(
modifier = modifier,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = amountText,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineLarge.copy(fontSize = 56.sp),
maxLines = 1,
)
Spacer(modifier = Modifier.width(12.dp))
dropdownComposable()
}
}
else
{
val phoneAmountFontSize =
when (amountText.length)
{
in 0..6 -> 56.sp
in 7..8 -> 48.sp
in 9..10 -> 40.sp
in 11..12 -> 32.sp
in 13..14 -> 26.sp
else -> 22.sp
}
Column(
modifier = modifier,
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = amountText,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineLarge.copy(fontSize = phoneAmountFontSize),
maxLines = 1,
softWrap = false,
)
Spacer(modifier = Modifier.height(12.dp))
dropdownComposable()
Spacer(modifier = Modifier.weight(1f))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun AmountEntryScreenContent(
amountText: String,
selectedCurrency: String,
currencyOptions: List<String>,
chargeEnabled: Boolean,
)
{
AmountEntryScreen(
amountText = amountText,
selectedCurrency = selectedCurrency,
currencyOptions = currencyOptions,
chargeEnabled = chargeEnabled,
onCurrencySelected = {},
onDigitPressed = {},
onClearPressed = {},
onBackspacePressed = {},
onChargePressed = {},
)
}