taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

AmountEntryFragment.kt (21902B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2026 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.amount
     18 
     19 import android.os.Bundle
     20 import android.view.LayoutInflater
     21 import android.view.View
     22 import android.view.ViewGroup
     23 import androidx.compose.foundation.layout.Arrangement
     24 import androidx.compose.foundation.layout.Box
     25 import androidx.compose.foundation.layout.BoxWithConstraints
     26 import androidx.compose.foundation.layout.Column
     27 import androidx.compose.foundation.layout.PaddingValues
     28 import androidx.compose.foundation.layout.Row
     29 import androidx.compose.foundation.layout.fillMaxSize
     30 import androidx.compose.foundation.layout.fillMaxWidth
     31 import androidx.compose.foundation.layout.height
     32 import androidx.compose.foundation.layout.padding
     33 import androidx.compose.foundation.layout.Spacer
     34 import androidx.compose.foundation.layout.statusBarsPadding
     35 import androidx.compose.foundation.layout.width
     36 import androidx.compose.foundation.layout.widthIn
     37 import androidx.compose.foundation.layout.wrapContentWidth
     38 import androidx.compose.foundation.shape.RoundedCornerShape
     39 import androidx.compose.material3.Button
     40 import androidx.compose.material3.ButtonDefaults
     41 import androidx.compose.material3.DropdownMenuItem
     42 import androidx.compose.material3.DropdownMenu
     43 import androidx.compose.material3.Icon
     44 import androidx.compose.material3.ExperimentalMaterial3Api
     45 import androidx.compose.material3.ExposedDropdownMenuAnchorType
     46 import androidx.compose.material3.ExposedDropdownMenuBox
     47 import androidx.compose.material3.ExposedDropdownMenuDefaults
     48 import androidx.compose.material3.MaterialTheme
     49 import androidx.compose.material3.OutlinedTextField
     50 import androidx.compose.material3.OutlinedTextFieldDefaults
     51 import androidx.compose.material3.Text
     52 import androidx.compose.runtime.Composable
     53 import androidx.compose.runtime.getValue
     54 import androidx.compose.runtime.mutableStateOf
     55 import androidx.compose.runtime.remember
     56 import androidx.compose.runtime.setValue
     57 import androidx.compose.ui.Alignment
     58 import androidx.compose.ui.Modifier
     59 import androidx.compose.ui.graphics.Color
     60 import androidx.compose.ui.platform.ComposeView
     61 import androidx.compose.ui.platform.LocalConfiguration
     62 import androidx.compose.ui.platform.ViewCompositionStrategy
     63 import androidx.compose.ui.res.colorResource
     64 import androidx.compose.ui.res.painterResource
     65 import androidx.compose.ui.res.stringResource
     66 import androidx.compose.ui.text.font.FontWeight
     67 import androidx.compose.ui.text.style.TextAlign
     68 import androidx.compose.ui.text.style.TextOverflow
     69 import androidx.compose.ui.unit.dp
     70 import androidx.compose.ui.unit.sp
     71 import androidx.compose.ui.unit.TextUnit
     72 import androidx.fragment.app.Fragment
     73 import androidx.fragment.app.activityViewModels
     74 import net.taler.common.Amount
     75 import net.taler.merchantpos.PosDestination
     76 import net.taler.merchantpos.MainViewModel
     77 import net.taler.merchantpos.R
     78 import net.taler.merchantpos.compose.PosTheme
     79 import net.taler.merchantpos.config.ConfigProduct
     80 import net.taler.merchantpos.order.Order
     81 import net.taler.merchantpos.showPosError
     82 
     83 private const val QUICK_AMOUNT_ORDER_ID = -1
     84 private const val QUICK_AMOUNT_PRODUCT_ID = "quick_amount"
     85 
     86 class AmountEntryFragment : Fragment() {
     87 
     88     private val viewModel: MainViewModel by activityViewModels()
     89     private val paymentManager by lazy { viewModel.paymentManager }
     90 
     91     private var selectedCurrency by mutableStateOf<String?>(null)
     92     private var amount by mutableStateOf<Amount?>(null)
     93 
     94     override fun onCreateView(
     95         inflater: LayoutInflater,
     96         container: ViewGroup?,
     97         savedInstanceState: Bundle?,
     98     ): View {
     99         initializeAmountState()
    100         return ComposeView(requireContext()).apply {
    101             setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
    102             setContent {
    103                 AmountEntryScreen(
    104                     amountText = amount?.toString(showSymbol = false) ?: "0.00",
    105                     selectedCurrency = selectedCurrency,
    106                     currencyOptions = viewModel.configManager.currency?.let(::listOf) ?: emptyList(),
    107                     chargeEnabled = amount?.isZero() == false,
    108                     onCurrencySelected = ::setCurrency,
    109                     onDigitPressed = ::onDigitPressed,
    110                     onClearPressed = ::clearAmount,
    111                     onBackspacePressed = ::onBackspacePressed,
    112                     onChargePressed = ::onChargePressed,
    113                 )
    114             }
    115         }
    116     }
    117 
    118     override fun onStart() {
    119         super.onStart()
    120         if (!viewModel.configManager.config.isValid()) {
    121             (requireActivity() as net.taler.merchantpos.MainActivity).navigateTo(PosDestination.Config)
    122         } else if (viewModel.configManager.currency == null) {
    123             (requireActivity() as net.taler.merchantpos.MainActivity).navigateTo(PosDestination.ConfigFetcher)
    124         }
    125     }
    126 
    127     private fun initializeAmountState() {
    128         val configuredCurrency = viewModel.configManager.currency ?: return
    129         if (selectedCurrency == null) {
    130             selectedCurrency = configuredCurrency
    131         }
    132         if (amount == null) {
    133             amount = Amount.zero(configuredCurrency).withSpec(viewModel.configManager.currencySpec)
    134         }
    135     }
    136 
    137     private fun setCurrency(currency: String) {
    138         selectedCurrency = currency
    139         val currentAmount = amount
    140         val spec = viewModel.configManager.currencySpec
    141         amount = when {
    142             currentAmount == null -> Amount.zero(currency)
    143             currentAmount.currency == currency -> currentAmount
    144             else -> currentAmount.withCurrency(currency)
    145         }.withSpec(spec)
    146     }
    147 
    148     private fun onDigitPressed(digit: Char) {
    149         val currentAmount = amount ?: return
    150         amount = currentAmount.addInputDigit(digit)?.withSpec(currentAmount.spec) ?: currentAmount
    151     }
    152 
    153     private fun onBackspacePressed() {
    154         val currentAmount = amount ?: return
    155         amount = currentAmount.removeInputDigit()?.withSpec(currentAmount.spec) ?: currentAmount
    156     }
    157 
    158     private fun clearAmount() {
    159         val currency = selectedCurrency ?: return
    160         amount = Amount.zero(currency).withSpec(viewModel.configManager.currencySpec)
    161     }
    162 
    163     private fun onChargePressed() {
    164         val configuredCurrency = viewModel.configManager.currency ?: run {
    165             (requireActivity() as net.taler.merchantpos.MainActivity).navigateTo(PosDestination.ConfigFetcher)
    166             return
    167         }
    168         val enteredCurrency = selectedCurrency ?: configuredCurrency
    169         val enteredAmount = amount
    170             ?: Amount.zero(enteredCurrency).withSpec(viewModel.configManager.currencySpec)
    171 
    172         if (enteredAmount.isZero()) {
    173             requireActivity().showPosError(R.string.amount_entry_error_zero)
    174             return
    175         }
    176         if (enteredCurrency != configuredCurrency) {
    177             requireActivity().showPosError(R.string.amount_entry_error_wrong_currency)
    178             return
    179         }
    180 
    181         val order = Order(
    182             id = QUICK_AMOUNT_ORDER_ID,
    183             currency = configuredCurrency,
    184             currencySpec = viewModel.configManager.currencySpec,
    185             availableCategories = emptyMap(),
    186         )
    187         val product = ConfigProduct(
    188             description = getString(R.string.amount_entry_product_description),
    189             productId = QUICK_AMOUNT_PRODUCT_ID,
    190             price = enteredAmount.withSpec(viewModel.configManager.currencySpec),
    191             categories = listOf(Int.MIN_VALUE),
    192         )
    193         val orderWithProduct = order + product
    194 
    195         // Backend doesn't require products; omit them for this "quick amount" flow.
    196         paymentManager.createPayment(orderWithProduct, includeProducts = false)
    197         (requireActivity() as net.taler.merchantpos.MainActivity).navigateTo(PosDestination.ProcessPayment)
    198     }
    199 }
    200 
    201 @OptIn(ExperimentalMaterial3Api::class)
    202 @Composable
    203 private fun AmountEntryScreen(
    204     amountText: String,
    205     selectedCurrency: String?,
    206     currencyOptions: List<String>,
    207     chargeEnabled: Boolean,
    208     onCurrencySelected: (String) -> Unit,
    209     onDigitPressed: (Char) -> Unit,
    210     onClearPressed: () -> Unit,
    211     onBackspacePressed: () -> Unit,
    212     onChargePressed: () -> Unit,
    213 ) {
    214     PosTheme {
    215         val configuration = LocalConfiguration.current
    216         val isTabletLayout = configuration.smallestScreenWidthDp >= 600
    217         Box(
    218             modifier = Modifier
    219                 .fillMaxSize()
    220                 .statusBarsPadding(),
    221         ) {
    222             if (!isTabletLayout) {
    223                 Row(
    224                     modifier = Modifier
    225                         .fillMaxSize()
    226                         .padding(8.dp),
    227                     horizontalArrangement = Arrangement.spacedBy(8.dp),
    228                 ) {
    229                     AmountPane(
    230                         amountText = amountText,
    231                         selectedCurrency = selectedCurrency,
    232                         currencyOptions = currencyOptions,
    233                         isTabletLayout = false,
    234                         onCurrencySelected = onCurrencySelected,
    235                         modifier = Modifier
    236                             .weight(0.32f)
    237                             .padding(horizontal = 8.dp, vertical = 4.dp),
    238                     )
    239                     KeypadPane(
    240                         isTabletLayout = false,
    241                         chargeEnabled = chargeEnabled,
    242                         onDigitPressed = onDigitPressed,
    243                         onClearPressed = onClearPressed,
    244                         onBackspacePressed = onBackspacePressed,
    245                         onChargePressed = onChargePressed,
    246                         modifier = Modifier
    247                             .weight(0.68f)
    248                             .padding(4.dp),
    249                     )
    250                 }
    251             } else {
    252                 Column(
    253                     modifier = Modifier
    254                         .fillMaxSize()
    255                         .padding(16.dp),
    256                     verticalArrangement = Arrangement.spacedBy(12.dp),
    257                 ) {
    258                     AmountPane(
    259                         amountText = amountText,
    260                         selectedCurrency = selectedCurrency,
    261                         currencyOptions = currencyOptions,
    262                         isTabletLayout = true,
    263                         onCurrencySelected = onCurrencySelected,
    264                         modifier = Modifier
    265                             .fillMaxWidth()
    266                             .weight(0.35f),
    267                     )
    268                     Row(
    269                         modifier = Modifier
    270                             .fillMaxWidth()
    271                             .weight(0.65f),
    272                         horizontalArrangement = Arrangement.Center,
    273                     ) {
    274                         KeypadPane(
    275                             isTabletLayout = true,
    276                             chargeEnabled = chargeEnabled,
    277                             onDigitPressed = onDigitPressed,
    278                             onClearPressed = onClearPressed,
    279                             onBackspacePressed = onBackspacePressed,
    280                             onChargePressed = onChargePressed,
    281                             modifier = Modifier.fillMaxWidth(0.6f),
    282                         )
    283                     }
    284                 }
    285             }
    286         }
    287     }
    288 }
    289 
    290 @OptIn(ExperimentalMaterial3Api::class)
    291 @Composable
    292 private fun AmountPane(
    293     amountText: String,
    294     selectedCurrency: String?,
    295     currencyOptions: List<String>,
    296     isTabletLayout: Boolean,
    297     onCurrencySelected: (String) -> Unit,
    298     modifier: Modifier = Modifier,
    299 ) {
    300     var expanded by remember { mutableStateOf(false) }
    301 
    302     val dropdownComposable: @Composable () -> Unit = {
    303         ExposedDropdownMenuBox(
    304             expanded = expanded,
    305             onExpandedChange = { if (currencyOptions.isNotEmpty()) expanded = !expanded },
    306             modifier = Modifier.wrapContentWidth(Alignment.CenterHorizontally),
    307         ) {
    308             OutlinedTextField(
    309                 modifier = Modifier
    310                     .menuAnchor(
    311                         type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
    312                         enabled = currencyOptions.isNotEmpty(),
    313                     )
    314                     .widthIn(min = 96.dp),
    315                 value = selectedCurrency.orEmpty(),
    316                 onValueChange = {},
    317                 readOnly = true,
    318                 singleLine = true,
    319                 label = { Text(stringResource(R.string.amount_entry_label)) },
    320                 trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
    321                 shape = RoundedCornerShape(14.dp),
    322                 colors = OutlinedTextFieldDefaults.colors(
    323                     focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
    324                     unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
    325                     disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
    326                     focusedBorderColor = MaterialTheme.colorScheme.primary,
    327                     unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant,
    328                     focusedLabelColor = MaterialTheme.colorScheme.primary,
    329                     unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
    330                     focusedTextColor = MaterialTheme.colorScheme.onSurface,
    331                     unfocusedTextColor = MaterialTheme.colorScheme.onSurface,
    332                     focusedTrailingIconColor = MaterialTheme.colorScheme.primary,
    333                     unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
    334                 ),
    335             )
    336 
    337             DropdownMenu(
    338                 expanded = expanded,
    339                 onDismissRequest = { expanded = false },
    340             ) {
    341                 currencyOptions.forEach { currency ->
    342                     DropdownMenuItem(
    343                         text = { Text(currency) },
    344                         onClick = {
    345                             expanded = false
    346                             onCurrencySelected(currency)
    347                         },
    348                     )
    349                 }
    350             }
    351         }
    352     }
    353 
    354     if (isTabletLayout) {
    355         Row(
    356             modifier = modifier,
    357             horizontalArrangement = Arrangement.Center,
    358             verticalAlignment = Alignment.CenterVertically,
    359         ) {
    360             Text(
    361                 text = amountText,
    362                 textAlign = TextAlign.Center,
    363                 style = MaterialTheme.typography.headlineLarge.copy(fontSize = 56.sp),
    364                 maxLines = 1,
    365             )
    366             Spacer(modifier = Modifier.width(12.dp))
    367             dropdownComposable()
    368         }
    369     } else {
    370         val phoneAmountFontSize = when (amountText.length) {
    371             in 0..6 -> 56.sp
    372             in 7..8 -> 48.sp
    373             in 9..10 -> 40.sp
    374             in 11..12 -> 32.sp
    375             in 13..14 -> 26.sp
    376             else -> 22.sp
    377         }
    378 
    379         Column(
    380             modifier = modifier,
    381             verticalArrangement = Arrangement.Top,
    382             horizontalAlignment = Alignment.CenterHorizontally,
    383         ) {
    384             Text(
    385                 text = amountText,
    386                 textAlign = TextAlign.Center,
    387                 style = MaterialTheme.typography.headlineLarge.copy(fontSize = phoneAmountFontSize),
    388                 maxLines = 1,
    389                 softWrap = false,
    390             )
    391 
    392             Spacer(modifier = Modifier.height(12.dp))
    393             dropdownComposable()
    394             Spacer(modifier = Modifier.weight(1f))
    395         }
    396     }
    397 }
    398 
    399 @Composable
    400 private fun KeypadPane(
    401     isTabletLayout: Boolean,
    402     chargeEnabled: Boolean,
    403     onDigitPressed: (Char) -> Unit,
    404     onClearPressed: () -> Unit,
    405     onBackspacePressed: () -> Unit,
    406     onChargePressed: () -> Unit,
    407     modifier: Modifier = Modifier,
    408 ) {
    409     val configuration = LocalConfiguration.current
    410     val isCompactPhone = !isTabletLayout && configuration.screenHeightDp <= 720
    411     val rowSpacing = if (isCompactPhone) 6.dp else 8.dp
    412     val digitFontSize = if (isCompactPhone) 24.sp else 28.sp
    413 
    414     Column(
    415         modifier = modifier,
    416         verticalArrangement = Arrangement.spacedBy(rowSpacing),
    417     ) {
    418         val keyContainerColor = colorResource(R.color.amount_entry_key_background)
    419         val keyContentColor = colorResource(R.color.amount_entry_key_text)
    420         val clearLabel = stringResource(R.string.amount_entry_clear)
    421         val clearTextSize = when {
    422             clearLabel.length >= 14 -> if (isCompactPhone) 12.sp else 14.sp
    423             clearLabel.length >= 10 -> if (isCompactPhone) 14.sp else 16.sp
    424             else -> if (isCompactPhone) 16.sp else 20.sp
    425         }
    426 
    427         listOf(
    428             listOf("1", "2", "3"),
    429             listOf("4", "5", "6"),
    430             listOf("7", "8", "9"),
    431         ).forEach { row ->
    432             Row(
    433                 modifier = Modifier
    434                     .fillMaxWidth()
    435                     .weight(1f),
    436                 horizontalArrangement = Arrangement.spacedBy(rowSpacing),
    437             ) {
    438                 row.forEach { key ->
    439                     KeyButton(
    440                         text = key,
    441                         modifier = Modifier.weight(1f),
    442                         containerColor = keyContainerColor,
    443                         contentColor = keyContentColor,
    444                         fontSize = digitFontSize,
    445                         onClick = { onDigitPressed(key.first()) },
    446                     )
    447                 }
    448             }
    449         }
    450 
    451         Row(
    452             modifier = Modifier
    453                 .fillMaxWidth()
    454                 .weight(1f),
    455             horizontalArrangement = Arrangement.spacedBy(rowSpacing),
    456         ) {
    457             KeyButton(
    458                 text = clearLabel,
    459                 modifier = Modifier.weight(1f),
    460                 containerColor = keyContainerColor,
    461                 contentColor = keyContentColor,
    462                 fontSize = clearTextSize,
    463                 onClick = onClearPressed,
    464             )
    465             KeyButton(
    466                 text = "0",
    467                 modifier = Modifier.weight(1f),
    468                 containerColor = keyContainerColor,
    469                 contentColor = keyContentColor,
    470                 onClick = { onDigitPressed('0') },
    471             )
    472             Button(
    473                 modifier = Modifier
    474                     .weight(1f)
    475                     .fillMaxSize(),
    476                 onClick = onBackspacePressed,
    477                 colors = ButtonDefaults.buttonColors(
    478                     containerColor = keyContainerColor,
    479                     contentColor = keyContentColor,
    480                 ),
    481                 contentPadding = PaddingValues(0.dp),
    482             ) {
    483                 Icon(
    484                     painter = painterResource(R.drawable.ic_backspace),
    485                     contentDescription = stringResource(R.string.amount_entry_backspace),
    486                     tint = keyContentColor,
    487                 )
    488             }
    489         }
    490 
    491         Button(
    492             modifier = Modifier
    493                 .fillMaxWidth()
    494                 .height(56.dp),
    495             onClick = onChargePressed,
    496             enabled = chargeEnabled,
    497             colors = ButtonDefaults.buttonColors(
    498                 containerColor = colorResource(R.color.colorPrimary),
    499                 contentColor = colorResource(R.color.colorOnPrimary),
    500                 disabledContainerColor = colorResource(R.color.colorSecondary).copy(alpha = 0.12f),
    501             ),
    502         ) {
    503             Text(
    504                 text = stringResource(R.string.amount_entry_create_order_charge),
    505                 fontWeight = FontWeight.SemiBold,
    506             )
    507         }
    508     }
    509 }
    510 
    511 @Composable
    512 private fun KeyButton(
    513     text: String,
    514     modifier: Modifier,
    515     containerColor: Color,
    516     contentColor: Color,
    517     fontSize: TextUnit? = null,
    518     onClick: () -> Unit,
    519 ) {
    520     Button(
    521         modifier = modifier.fillMaxSize(),
    522         onClick = onClick,
    523         colors = ButtonDefaults.buttonColors(
    524             containerColor = containerColor,
    525             contentColor = contentColor,
    526         ),
    527         contentPadding = PaddingValues(horizontal = 2.dp, vertical = 0.dp),
    528     ) {
    529         Text(
    530             text = text,
    531             textAlign = TextAlign.Center,
    532             maxLines = 1,
    533             softWrap = false,
    534             overflow = TextOverflow.Ellipsis,
    535             style = fontSize?.let {
    536                 MaterialTheme.typography.headlineMedium.copy(
    537                     fontSize = it,
    538                     fontWeight = FontWeight.SemiBold,
    539                 )
    540             } ?: MaterialTheme.typography.headlineMedium.copy(
    541                 fontWeight = FontWeight.SemiBold,
    542             ),
    543         )
    544     }
    545 }
    546 
    547 @OptIn(ExperimentalMaterial3Api::class)
    548 @Composable
    549 internal fun AmountEntryScreenContent(
    550     amountText: String,
    551     selectedCurrency: String,
    552     currencyOptions: List<String>,
    553     chargeEnabled: Boolean,
    554 ) {
    555     AmountEntryScreen(
    556         amountText = amountText,
    557         selectedCurrency = selectedCurrency,
    558         currencyOptions = currencyOptions,
    559         chargeEnabled = chargeEnabled,
    560         onCurrencySelected = {},
    561         onDigitPressed = {},
    562         onClearPressed = {},
    563         onBackspacePressed = {},
    564         onChargePressed = {},
    565     )
    566 }