taler-android

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

OrderFragment.kt (37002B)


      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.order
     18 
     19 import android.os.Bundle
     20 import androidx.compose.foundation.BorderStroke
     21 import androidx.compose.foundation.Image
     22 import androidx.compose.foundation.background
     23 import androidx.compose.foundation.clickable
     24 import androidx.compose.foundation.horizontalScroll
     25 import androidx.compose.foundation.verticalScroll
     26 import androidx.compose.foundation.layout.Arrangement
     27 import androidx.compose.foundation.layout.Box
     28 import androidx.compose.foundation.layout.BoxWithConstraints
     29 import androidx.compose.foundation.layout.Column
     30 import androidx.compose.foundation.layout.IntrinsicSize
     31 import androidx.compose.foundation.layout.Row
     32 import androidx.compose.foundation.layout.Spacer
     33 import androidx.compose.foundation.layout.fillMaxHeight
     34 import androidx.compose.foundation.layout.fillMaxSize
     35 import androidx.compose.foundation.layout.fillMaxWidth
     36 import androidx.compose.foundation.layout.height
     37 import androidx.compose.foundation.layout.heightIn
     38 import androidx.compose.foundation.layout.padding
     39 import androidx.compose.foundation.layout.size
     40 import androidx.compose.foundation.layout.statusBarsPadding
     41 import androidx.compose.foundation.layout.width
     42 import androidx.compose.foundation.lazy.LazyColumn
     43 import androidx.compose.foundation.lazy.items
     44 import androidx.compose.foundation.rememberScrollState
     45 import androidx.compose.material3.AlertDialog
     46 import androidx.compose.material3.Button
     47 import androidx.compose.material3.ButtonDefaults
     48 import androidx.compose.material3.Card
     49 import androidx.compose.material3.CardDefaults
     50 import androidx.compose.material3.HorizontalDivider
     51 import androidx.compose.material3.Icon
     52 import androidx.compose.material3.MaterialTheme
     53 import androidx.compose.material3.OutlinedButton
     54 import androidx.compose.material3.OutlinedTextField
     55 import androidx.compose.material3.Surface
     56 import androidx.compose.material3.Text
     57 import androidx.compose.runtime.Composable
     58 import androidx.compose.runtime.LaunchedEffect
     59 import androidx.compose.runtime.getValue
     60 import androidx.compose.runtime.livedata.observeAsState
     61 import androidx.compose.runtime.mutableStateOf
     62 import androidx.compose.runtime.remember
     63 import androidx.compose.runtime.saveable.rememberSaveable
     64 import androidx.compose.runtime.setValue
     65 import androidx.compose.ui.Alignment
     66 import androidx.compose.ui.Modifier
     67 import androidx.compose.ui.graphics.Color
     68 import androidx.compose.ui.graphics.asImageBitmap
     69 import androidx.compose.ui.platform.ComposeView
     70 import androidx.compose.ui.platform.LocalConfiguration
     71 import androidx.compose.ui.platform.ViewCompositionStrategy
     72 import androidx.compose.ui.res.painterResource
     73 import androidx.compose.ui.res.stringResource
     74 import androidx.compose.ui.text.font.FontWeight
     75 import androidx.compose.ui.text.style.TextAlign
     76 import androidx.compose.ui.text.style.TextOverflow
     77 import androidx.compose.ui.unit.dp
     78 import androidx.fragment.app.Fragment
     79 import androidx.fragment.app.activityViewModels
     80 import net.taler.common.Amount
     81 import net.taler.common.AmountParserException
     82 import net.taler.lib.android.base64Bitmap
     83 import net.taler.merchantpos.MainActivity
     84 import net.taler.merchantpos.MainViewModel
     85 import net.taler.merchantpos.PosDestination
     86 import net.taler.merchantpos.R
     87 import net.taler.merchantpos.compose.PosTheme
     88 import net.taler.merchantpos.config.Category
     89 import net.taler.merchantpos.config.ConfigProduct
     90 import net.taler.merchantpos.showPosError
     91 import kotlinx.coroutines.delay
     92 import net.taler.merchantpos.order.RestartState.DISABLED
     93 import androidx.compose.foundation.shape.RoundedCornerShape
     94 
     95 class OrderFragment : Fragment() {
     96 
     97     private val viewModel: MainViewModel by activityViewModels()
     98     private val orderManager by lazy { viewModel.orderManager }
     99     private val paymentManager by lazy { viewModel.paymentManager }
    100 
    101     override fun onCreateView(
    102         inflater: android.view.LayoutInflater,
    103         container: android.view.ViewGroup?,
    104         savedInstanceState: Bundle?,
    105     ) = ComposeView(requireContext()).apply {
    106         setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
    107         setContent {
    108             OrderRoute(
    109                 viewModel = viewModel,
    110                 onNavigate = { destination ->
    111                     (requireActivity() as MainActivity).navigateTo(destination)
    112                 },
    113                 onShowMessage = { message ->
    114                     requireActivity().showPosError(message)
    115                 },
    116             )
    117         }
    118     }
    119 
    120     override fun onStart() {
    121         super.onStart()
    122         if (!viewModel.configManager.config.isValid()) {
    123             (requireActivity() as MainActivity).navigateTo(PosDestination.Config)
    124         } else if (viewModel.configManager.currency == null) {
    125             (requireActivity() as MainActivity).navigateTo(PosDestination.ConfigFetcher)
    126         }
    127     }
    128 }
    129 
    130 @Composable
    131 private fun OrderRoute(
    132     viewModel: MainViewModel,
    133     onNavigate: (PosDestination) -> Unit,
    134     onShowMessage: (String) -> Unit,
    135 ) {
    136     val orderManager = remember { viewModel.orderManager }
    137     val paymentManager = remember { viewModel.paymentManager }
    138     val currentOrderId by orderManager.currentOrderId.observeAsState()
    139     val categories by orderManager.categories.observeAsState(emptyList())
    140     val products by orderManager.products.observeAsState(emptyList())
    141     val currency = viewModel.configManager.currency
    142     val currencySpec = viewModel.configManager.currencySpec
    143 
    144     val orderId = currentOrderId ?: return
    145     val liveOrder = remember(orderId) { orderManager.getOrder(orderId) }
    146     val order by liveOrder.order.observeAsState()
    147     val orderTotal by liveOrder.orderTotal.observeAsState(
    148         Amount.zero(currency ?: "").withSpec(currencySpec),
    149     )
    150     val restartState by liveOrder.restartState.observeAsState(DISABLED)
    151     val modifyAllowed by liveOrder.modifyOrderAllowed.observeAsState(false)
    152     val increaseAllowed by liveOrder.increaseOrderAllowed.observeAsState(false)
    153     val hasNextOrder by orderManager.hasNextOrder(orderId).observeAsState(false)
    154     var selectedProductKey by rememberSaveable(orderId) { mutableStateOf(liveOrder.selectedProductKey) }
    155     var selectedCategoryId by rememberSaveable {
    156         mutableStateOf(categories.firstOrNull { it.selected }?.id)
    157     }
    158     var showCustomDialog by rememberSaveable {
    159         mutableStateOf(false)
    160     }
    161     val reloadingText = stringResource(R.string.toast_reloading)
    162 
    163     LaunchedEffect(Unit) {
    164         while (true) {
    165             delay(30_000)
    166             viewModel.configManager.refreshInventory()
    167         }
    168     }
    169 
    170     LaunchedEffect(order?.products, liveOrder.lastAddedProduct?.id) {
    171         val productsInOrder = order?.products.orEmpty()
    172         val selected = selectedProductKey?.let { key -> productsInOrder.find { it.id == key } }
    173         val nextSelection = liveOrder.lastAddedProduct?.takeIf { added ->
    174             productsInOrder.any { it.id == added.id }
    175         } ?: selected ?: productsInOrder.lastOrNull()
    176         selectedProductKey = nextSelection?.id
    177         liveOrder.selectOrderLine(nextSelection)
    178     }
    179 
    180     LaunchedEffect(categories.map { it.id }) {
    181         if (selectedCategoryId == null) {
    182             selectedCategoryId = categories.firstOrNull { it.selected }?.id
    183         } else if (categories.none { it.id == selectedCategoryId }) {
    184             selectedCategoryId = categories.firstOrNull { it.selected }?.id
    185         }
    186     }
    187 
    188     if (showCustomDialog && currency != null) {
    189         CustomProductDialog(
    190             currency = currency,
    191             currencySpec = currencySpec,
    192             onDismiss = { showCustomDialog = false },
    193             onAdd = { description, amount ->
    194                 val product = ConfigProduct(
    195                     description = description,
    196                     price = amount,
    197                     categories = listOf(Int.MIN_VALUE),
    198                 )
    199                 orderManager.addProduct(orderId, product)
    200                 showCustomDialog = false
    201             },
    202         )
    203     }
    204 
    205     PosTheme {
    206         Box(
    207             modifier = Modifier
    208                 .fillMaxSize()
    209                 .statusBarsPadding(),
    210         ) {
    211             val isTabletLayout = LocalConfiguration.current.smallestScreenWidthDp >= 720
    212             if (isTabletLayout) {
    213                 TabletOrderScreen(
    214                     categories = categories,
    215                     selectedCategoryId = selectedCategoryId,
    216                     products = products,
    217                     order = order,
    218                     increaseAllowed = increaseAllowed,
    219                     modifyAllowed = modifyAllowed,
    220                     orderTotal = orderTotal.toString(),
    221                     selectedProductKey = selectedProductKey,
    222                     onCategorySelected = { category ->
    223                         selectedCategoryId = category.id
    224                         orderManager.setCurrentCategory(category)
    225                     },
    226                     onProductSelected = { product ->
    227                         orderManager.addProduct(orderId, product)
    228                     },
    229                     onSelectProduct = {
    230                         selectedProductKey = it?.id
    231                         liveOrder.selectOrderLine(it)
    232                     },
    233                     onIncrease = { liveOrder.increaseSelectedOrderLine() },
    234                     onDecrease = { liveOrder.decreaseSelectedOrderLine() },
    235                     onAddCustom = { showCustomDialog = true },
    236                     onComplete = {
    237                         val currentOrder = order ?: return@TabletOrderScreen
    238                         paymentManager.createPayment(currentOrder)
    239                         onNavigate(PosDestination.ProcessPayment)
    240                     },
    241                 )
    242             } else {
    243                 PhoneOrderScreen(
    244                     categories = categories,
    245                     selectedCategoryId = selectedCategoryId,
    246                     products = products,
    247                     order = order,
    248                     increaseAllowed = increaseAllowed,
    249                     modifyAllowed = modifyAllowed,
    250                     orderTotal = orderTotal.toString(),
    251                     selectedProductKey = selectedProductKey,
    252                     onCategorySelected = { category ->
    253                         selectedCategoryId = category.id
    254                         orderManager.setCurrentCategory(category)
    255                     },
    256                     onProductSelected = { product ->
    257                         orderManager.addProduct(orderId, product)
    258                     },
    259                     onSelectProduct = {
    260                         selectedProductKey = it?.id
    261                         liveOrder.selectOrderLine(it)
    262                     },
    263                     onIncrease = { liveOrder.increaseSelectedOrderLine() },
    264                     onDecrease = { liveOrder.decreaseSelectedOrderLine() },
    265                     onAddCustom = { showCustomDialog = true },
    266                     onComplete = {
    267                         val currentOrder = order ?: return@PhoneOrderScreen
    268                         paymentManager.createPayment(currentOrder)
    269                         onNavigate(PosDestination.ProcessPayment)
    270                     },
    271                 )
    272             }
    273         }
    274     }
    275 }
    276 
    277 @Composable
    278 private fun TabletOrderScreen(
    279     categories: List<Category>,
    280     selectedCategoryId: Int?,
    281     products: List<ConfigProduct>,
    282     order: Order?,
    283     increaseAllowed: Boolean,
    284     modifyAllowed: Boolean,
    285     orderTotal: String,
    286     selectedProductKey: String?,
    287     onCategorySelected: (Category) -> Unit,
    288     onProductSelected: (ConfigProduct) -> Unit,
    289     onSelectProduct: (ConfigProduct?) -> Unit,
    290     onIncrease: () -> Unit,
    291     onDecrease: () -> Unit,
    292     onAddCustom: () -> Unit,
    293     onComplete: () -> Unit,
    294 ) {
    295     Row(
    296         modifier = Modifier.fillMaxSize(),
    297         horizontalArrangement = Arrangement.spacedBy(0.dp),
    298     ) {
    299         CategoriesPane(
    300             categories = categories,
    301             selectedCategoryId = selectedCategoryId,
    302             modifier = Modifier.weight(0.25f),
    303             compact = false,
    304             onCategorySelected = onCategorySelected,
    305         )
    306         Spacer(
    307             modifier = Modifier
    308                 .width(1.dp)
    309                 .fillMaxHeight()
    310                 .background(MaterialTheme.colorScheme.outlineVariant),
    311         )
    312         ProductsPane(
    313             products = products,
    314             modifier = Modifier.weight(0.50f),
    315             compact = false,
    316             onProductSelected = onProductSelected,
    317         )
    318         Spacer(
    319             modifier = Modifier
    320                 .width(1.dp)
    321                 .fillMaxHeight()
    322                 .background(MaterialTheme.colorScheme.outlineVariant),
    323         )
    324         OrderColumnPane(
    325             order = order,
    326             increaseAllowed = increaseAllowed,
    327             modifyAllowed = modifyAllowed,
    328             orderTotal = orderTotal,
    329             orderIsEmpty = order?.total?.isZero() != false,
    330             selectedProductKey = selectedProductKey,
    331             modifier = Modifier.weight(0.25f),
    332             compact = false,
    333             onSelectProduct = onSelectProduct,
    334             onIncrease = onIncrease,
    335             onDecrease = onDecrease,
    336             onAddCustom = onAddCustom,
    337             onComplete = onComplete,
    338         )
    339     }
    340 }
    341 
    342 @Composable
    343 private fun PhoneOrderScreen(
    344     categories: List<Category>,
    345     selectedCategoryId: Int?,
    346     products: List<ConfigProduct>,
    347     order: Order?,
    348     increaseAllowed: Boolean,
    349     modifyAllowed: Boolean,
    350     orderTotal: String,
    351     selectedProductKey: String?,
    352     onCategorySelected: (Category) -> Unit,
    353     onProductSelected: (ConfigProduct) -> Unit,
    354     onSelectProduct: (ConfigProduct?) -> Unit,
    355     onIncrease: () -> Unit,
    356     onDecrease: () -> Unit,
    357     onAddCustom: () -> Unit,
    358     onComplete: () -> Unit,
    359 ) {
    360     Row(
    361         modifier = Modifier
    362             .fillMaxSize()
    363             .padding(horizontal = 8.dp, vertical = 10.dp),
    364         horizontalArrangement = Arrangement.spacedBy(0.dp),
    365     ) {
    366         CategoriesPane(
    367             categories = categories,
    368             selectedCategoryId = selectedCategoryId,
    369             modifier = Modifier.weight(0.22f),
    370             compact = true,
    371             onCategorySelected = onCategorySelected,
    372         )
    373         Spacer(
    374             modifier = Modifier
    375                 .width(1.dp)
    376                 .fillMaxHeight()
    377                 .background(MaterialTheme.colorScheme.outlineVariant),
    378         )
    379         ProductsPane(
    380             products = products,
    381             modifier = Modifier.weight(0.43f),
    382             compact = true,
    383             onProductSelected = onProductSelected,
    384         )
    385         Spacer(
    386             modifier = Modifier
    387                 .width(1.dp)
    388                 .fillMaxHeight()
    389                 .background(MaterialTheme.colorScheme.outlineVariant),
    390         )
    391         OrderColumnPane(
    392             order = order,
    393             increaseAllowed = increaseAllowed,
    394             modifyAllowed = modifyAllowed,
    395             orderTotal = orderTotal,
    396             orderIsEmpty = order?.total?.isZero() != false,
    397             selectedProductKey = selectedProductKey,
    398             modifier = Modifier.weight(0.35f),
    399             compact = true,
    400             onSelectProduct = onSelectProduct,
    401             onIncrease = onIncrease,
    402             onDecrease = onDecrease,
    403             onAddCustom = onAddCustom,
    404             onComplete = onComplete,
    405         )
    406     }
    407 }
    408 
    409 @Composable
    410 private fun CategoriesPane(
    411     categories: List<Category>,
    412     selectedCategoryId: Int?,
    413     modifier: Modifier = Modifier,
    414     compact: Boolean = false,
    415     onCategorySelected: (Category) -> Unit,
    416 ) {
    417     Surface(modifier = modifier.fillMaxWidth()) {
    418         LazyColumn(
    419             modifier = Modifier
    420                 .fillMaxSize()
    421                 .padding(
    422                     start = if (compact) 4.dp else 8.dp,
    423                     top = if (compact) 8.dp else 12.dp,
    424                     end = if (compact) 4.dp else 8.dp,
    425                 ),
    426             verticalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 8.dp),
    427         ) {
    428             items(categories, key = { it.id }) { category ->
    429                 CategoryButton(
    430                     text = category.localizedName,
    431                     selected = category.id == selectedCategoryId,
    432                     compact = compact,
    433                     onClick = { onCategorySelected(category) },
    434                 )
    435             }
    436         }
    437     }
    438 }
    439 
    440 @Composable
    441 private fun CategoryButton(
    442     text: String,
    443     selected: Boolean,
    444     compact: Boolean = false,
    445     onClick: () -> Unit,
    446 ) {
    447     Surface(
    448         modifier = Modifier
    449             .fillMaxWidth()
    450             .clickable(onClick = onClick),
    451         color = if (selected) {
    452             MaterialTheme.colorScheme.secondary
    453         } else {
    454             MaterialTheme.colorScheme.secondaryContainer
    455         },
    456         contentColor = if (selected) {
    457             MaterialTheme.colorScheme.onSecondary
    458         } else {
    459             MaterialTheme.colorScheme.onSecondaryContainer
    460         },
    461         shape = RoundedCornerShape(30.dp),
    462         border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
    463     ) {
    464         Text(
    465             text = text,
    466             modifier = Modifier
    467                 .fillMaxWidth()
    468                 .padding(
    469                     horizontal = if (compact) 8.dp else 16.dp,
    470                     vertical = if (compact) 8.dp else 12.dp,
    471                 ),
    472             textAlign = TextAlign.Center,
    473             fontWeight = FontWeight.SemiBold,
    474             style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium,
    475         )
    476     }
    477 }
    478 
    479 @Composable
    480 private fun ProductsPane(
    481     products: List<ConfigProduct>,
    482     modifier: Modifier = Modifier,
    483     compact: Boolean = false,
    484     onProductSelected: (ConfigProduct) -> Unit,
    485 ) {
    486     Surface(modifier = modifier.fillMaxWidth()) {
    487         BoxWithConstraints(
    488             modifier = Modifier
    489                 .fillMaxSize()
    490                 .padding(
    491                     start = if (compact) 4.dp else 8.dp,
    492                     top = if (compact) 8.dp else 12.dp,
    493                     end = if (compact) 4.dp else 8.dp,
    494                 ),
    495         ) {
    496             val spacing = if (compact) 6.dp else 8.dp
    497             val minTileWidth = if (compact) 96.dp else 150.dp
    498             val columns = maxOf(1, ((maxWidth + spacing) / (minTileWidth + spacing)).toInt())
    499             val productRows = remember(products, columns) { products.chunked(columns) }
    500 
    501             Column(
    502                 modifier = Modifier.verticalScroll(rememberScrollState()),
    503                 verticalArrangement = Arrangement.spacedBy(spacing),
    504             ) {
    505                 productRows.forEach { rowProducts ->
    506                     val rowHasImage = rowProducts.any { !it.image.isNullOrBlank() }
    507                     Row(
    508                         modifier = Modifier
    509                             .fillMaxWidth()
    510                             .height(IntrinsicSize.Min),
    511                         horizontalArrangement = Arrangement.spacedBy(spacing),
    512                     ) {
    513                         rowProducts.forEach { product ->
    514                             ProductCard(
    515                                 product = product,
    516                                 rowHasImage = rowHasImage,
    517                                 compact = compact,
    518                                 onClick = { onProductSelected(product) },
    519                                 modifier = Modifier
    520                                     .weight(1f)
    521                                     .fillMaxHeight(),
    522                             )
    523                         }
    524                         repeat(columns - rowProducts.size) {
    525                             Spacer(modifier = Modifier.weight(1f))
    526                         }
    527                     }
    528                 }
    529             }
    530         }
    531     }
    532 }
    533 
    534 @Composable
    535 private fun ProductCard(
    536     product: ConfigProduct,
    537     rowHasImage: Boolean,
    538     compact: Boolean = false,
    539     onClick: () -> Unit,
    540     modifier: Modifier = Modifier,
    541 ) {
    542     val cardContainerColor = if (product.availableToSell) {
    543         MaterialTheme.colorScheme.surface
    544     } else {
    545         MaterialTheme.colorScheme.surfaceVariant
    546     }
    547     val cardBorderColor = if (product.availableToSell) {
    548         MaterialTheme.colorScheme.outlineVariant
    549     } else {
    550         MaterialTheme.colorScheme.error.copy(alpha = 0.4f)
    551     }
    552     Card(
    553         modifier = modifier
    554             .fillMaxWidth()
    555             .fillMaxHeight()
    556             .clickable(enabled = product.availableToSell, onClick = onClick),
    557         colors = CardDefaults.cardColors(
    558             containerColor = cardContainerColor,
    559         ),
    560         border = BorderStroke(1.dp, cardBorderColor),
    561         elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
    562     ) {
    563         Column(
    564             modifier = Modifier
    565                 .fillMaxSize()
    566                 .padding(if (compact) 6.dp else 8.dp),
    567             verticalArrangement = Arrangement.spacedBy(if (compact) 3.dp else 4.dp),
    568         ) {
    569             val productBitmap = product.image?.base64Bitmap
    570             val imageSize = if (compact) 40.dp else 64.dp
    571             if (productBitmap != null) {
    572                 Image(
    573                     bitmap = productBitmap.asImageBitmap(),
    574                     contentDescription = product.displayName,
    575                     modifier = Modifier
    576                         .size(imageSize)
    577                         .align(Alignment.CenterHorizontally),
    578                 )
    579             } else if (rowHasImage) {
    580                 Spacer(
    581                     modifier = Modifier
    582                         .height(imageSize)
    583                         .fillMaxWidth(),
    584                 )
    585             }
    586             Text(
    587                 text = product.displayName,
    588                 fontWeight = FontWeight.Bold,
    589                 textAlign = TextAlign.Center,
    590                 maxLines = 2,
    591                 overflow = TextOverflow.Ellipsis,
    592                 modifier = Modifier.fillMaxWidth(),
    593                 style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium,
    594             )
    595             product.displayDescription?.let {
    596                 Text(
    597                     text = it,
    598                     style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodySmall,
    599                     textAlign = TextAlign.Center,
    600                     maxLines = 2,
    601                     overflow = TextOverflow.Ellipsis,
    602                     modifier = Modifier.fillMaxWidth(),
    603                 )
    604             }
    605             Spacer(modifier = Modifier.weight(1f))
    606             Text(
    607                 text = product.displayPrice,
    608                 style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium,
    609                 textAlign = TextAlign.Center,
    610                 modifier = Modifier.fillMaxWidth(),
    611             )
    612             if (!product.availableToSell) {
    613                 Text(
    614                     text = when {
    615                         product.currencyMismatch -> stringResource(R.string.product_wrong_currency)
    616                         product.remainingStock == 0 -> stringResource(R.string.product_out_of_stock)
    617                         else -> stringResource(R.string.product_unavailable)
    618                     },
    619                     color = MaterialTheme.colorScheme.error,
    620                     style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodySmall,
    621                     fontWeight = FontWeight.Bold,
    622                     textAlign = TextAlign.Center,
    623                     modifier = Modifier.fillMaxWidth(),
    624                 )
    625             }
    626         }
    627     }
    628 }
    629 
    630 @Composable
    631 private fun OrderPane(
    632     order: Order?,
    633     selectedProductKey: String?,
    634     modifier: Modifier = Modifier,
    635     compact: Boolean = false,
    636     onSelectProduct: (ConfigProduct?) -> Unit,
    637 ) {
    638     Surface(modifier = modifier.fillMaxWidth()) {
    639         Column(modifier = Modifier.fillMaxSize()) {
    640             LazyColumn(
    641                 modifier = Modifier.weight(1f),
    642             ) {
    643                 items(order?.products.orEmpty(), key = { it.id }) { product ->
    644                     val selected = selectedProductKey == product.id
    645                     OrderRow(
    646                         product = product,
    647                         selected = selected,
    648                         compact = compact,
    649                         onClick = { onSelectProduct(product) },
    650                     )
    651                     HorizontalDivider()
    652                 }
    653             }
    654         }
    655     }
    656 }
    657 
    658 @Composable
    659 private fun OrderRow(
    660     product: ConfigProduct,
    661     selected: Boolean,
    662     compact: Boolean = false,
    663     onClick: () -> Unit,
    664 ) {
    665     val rowPadding = if (compact) 6.dp else 8.dp
    666     val imageSize = if (compact) 24.dp else 32.dp
    667     val countWidth = if (compact) 18.dp else 24.dp
    668     Row(
    669         modifier = Modifier
    670             .fillMaxWidth()
    671             .clickable(onClick = onClick)
    672             .background(
    673                 if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent,
    674             )
    675             .padding(rowPadding),
    676         horizontalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 8.dp),
    677         verticalAlignment = Alignment.Top,
    678     ) {
    679         Text(
    680             text = product.quantity.toString(),
    681             modifier = Modifier.width(countWidth),
    682             style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium,
    683         )
    684         product.image?.base64Bitmap?.let { bitmap ->
    685             Image(
    686                 bitmap = bitmap.asImageBitmap(),
    687                 contentDescription = product.displayName,
    688                 modifier = Modifier.size(imageSize),
    689             )
    690         } ?: Spacer(Modifier.width(imageSize))
    691         Column(modifier = Modifier.weight(1f)) {
    692             Text(
    693                 product.displayName,
    694                 style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium,
    695             )
    696             product.displayDescription?.let {
    697                 Text(it, style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodySmall)
    698             }
    699         }
    700         Text(
    701             product.totalPrice.toString(),
    702             style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium,
    703         )
    704     }
    705 }
    706 
    707 @Composable
    708 private fun OrderActionBar(
    709     modifyAllowed: Boolean,
    710     increaseAllowed: Boolean,
    711     orderTotal: String,
    712     orderIsEmpty: Boolean,
    713     modifier: Modifier = Modifier,
    714     compact: Boolean = false,
    715     onIncrease: () -> Unit,
    716     onDecrease: () -> Unit,
    717     onAddCustom: () -> Unit,
    718     onComplete: () -> Unit,
    719 ) {
    720     Column(
    721         modifier = modifier.fillMaxWidth(),
    722         verticalArrangement = Arrangement.spacedBy(12.dp),
    723     ) {
    724         Row(
    725             modifier = Modifier
    726                 .fillMaxWidth()
    727                 .horizontalScroll(rememberScrollState()),
    728             horizontalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 12.dp),
    729         ) {
    730             Button(onClick = onIncrease, enabled = increaseAllowed, colors = orderControlButtonColors()) {
    731                 Text("+1", style = if (compact) MaterialTheme.typography.labelLarge else MaterialTheme.typography.bodyMedium)
    732             }
    733             Button(onClick = onDecrease, enabled = modifyAllowed, colors = orderControlButtonColors()) {
    734                 Text("-1", style = if (compact) MaterialTheme.typography.labelLarge else MaterialTheme.typography.bodyMedium)
    735             }
    736             Button(onClick = onAddCustom, colors = orderControlButtonColors()) {
    737                 Icon(
    738                     painter = painterResource(R.drawable.ic_dialpad),
    739                     contentDescription = stringResource(R.string.order_custom),
    740                     modifier = Modifier.size(if (compact) 20.dp else 24.dp),
    741                 )
    742             }
    743         }
    744         Button(
    745             onClick = onComplete,
    746             enabled = !orderIsEmpty,
    747             modifier = Modifier
    748                 .fillMaxWidth()
    749                 .height(if (compact) 72.dp else 96.dp),
    750             colors = completeButtonColors(),
    751         ) {
    752             Text(
    753                 if (orderIsEmpty) {
    754                     stringResource(R.string.order_complete)
    755                 } else {
    756                     stringResource(R.string.order_complete_with_amount, orderTotal)
    757                 },
    758                 style = if (compact) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.titleMedium,
    759                 fontWeight = FontWeight.Bold,
    760                 textAlign = TextAlign.Center,
    761             )
    762         }
    763     }
    764 }
    765 
    766 @Composable
    767 private fun OrderColumnPane(
    768     order: Order?,
    769     increaseAllowed: Boolean,
    770     modifyAllowed: Boolean,
    771     orderTotal: String,
    772     orderIsEmpty: Boolean,
    773     selectedProductKey: String?,
    774     modifier: Modifier = Modifier,
    775     compact: Boolean = false,
    776     onSelectProduct: (ConfigProduct?) -> Unit,
    777     onIncrease: () -> Unit,
    778     onDecrease: () -> Unit,
    779     onAddCustom: () -> Unit,
    780     onComplete: () -> Unit,
    781 ) {
    782     Column(
    783         modifier = modifier
    784             .fillMaxSize()
    785             .padding(top = 12.dp, end = 12.dp, bottom = 12.dp),
    786         verticalArrangement = Arrangement.spacedBy(12.dp),
    787     ) {
    788         OrderPane(
    789             order = order,
    790             selectedProductKey = selectedProductKey,
    791             modifier = Modifier.weight(1f),
    792             compact = compact,
    793             onSelectProduct = onSelectProduct,
    794         )
    795         OrderActionBar(
    796             modifyAllowed = modifyAllowed,
    797             increaseAllowed = increaseAllowed,
    798             orderTotal = orderTotal,
    799             orderIsEmpty = orderIsEmpty,
    800             modifier = Modifier.padding(start = 12.dp),
    801             compact = compact,
    802             onIncrease = onIncrease,
    803             onDecrease = onDecrease,
    804             onAddCustom = onAddCustom,
    805             onComplete = onComplete,
    806         )
    807     }
    808 }
    809 
    810 @Composable
    811 private fun orderControlButtonColors() = ButtonDefaults.buttonColors(
    812     containerColor = MaterialTheme.colorScheme.primaryContainer,
    813     contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
    814     disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
    815     disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
    816 )
    817 
    818 @Composable
    819 private fun completeButtonColors() = ButtonDefaults.buttonColors(
    820     containerColor = MaterialTheme.colorScheme.primary,
    821     contentColor = MaterialTheme.colorScheme.onPrimary,
    822     disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
    823     disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
    824 )
    825 
    826 @Composable
    827 private fun CustomProductDialog(
    828     currency: String,
    829     currencySpec: net.taler.common.CurrencySpecification?,
    830     onDismiss: () -> Unit,
    831     onAdd: (String, Amount) -> Unit,
    832     initialDescription: String? = null,
    833     initialAmount: String? = null,
    834 ) {
    835     val defaultDescription = stringResource(R.string.order_custom_product_default)
    836     val invalidAmountText = stringResource(R.string.refund_error_invalid_amount)
    837     var description by rememberSaveable { mutableStateOf(initialDescription ?: defaultDescription) }
    838     var amountText by rememberSaveable { mutableStateOf(initialAmount ?: "") }
    839     var errorText by rememberSaveable { mutableStateOf<String?>(null) }
    840 
    841     AlertDialog(
    842         onDismissRequest = onDismiss,
    843         title = { Text(stringResource(R.string.order_custom)) },
    844         text = {
    845             Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
    846                 OutlinedTextField(
    847                     value = description,
    848                     onValueChange = { description = it },
    849                     label = { Text(stringResource(R.string.order_custom_product)) },
    850                 )
    851                 OutlinedTextField(
    852                     value = amountText,
    853                     onValueChange = {
    854                         amountText = it
    855                         errorText = null
    856                     },
    857                     label = { Text(currency) },
    858                     supportingText = errorText?.let { { Text(it) } },
    859                 )
    860             }
    861         },
    862         confirmButton = {
    863             Button(onClick = {
    864                 val amount = try {
    865                     Amount.fromString(currency, amountText).withSpec(currencySpec)
    866                 } catch (_: AmountParserException) {
    867                     errorText = invalidAmountText
    868                     return@Button
    869                 }
    870                 onAdd(description, amount)
    871             }) {
    872                 Text(stringResource(R.string.order_custom_add_button))
    873             }
    874         },
    875         dismissButton = {
    876             OutlinedButton(onClick = onDismiss) {
    877                 Text(stringResource(R.string.refund_abort))
    878             }
    879         },
    880     )
    881 }
    882 
    883 @Composable
    884 internal fun OrderScreenContent(
    885     viewModel: MainViewModel,
    886     showCustomDialog: Boolean = false,
    887     customDescription: String? = null,
    888     customAmount: String? = null,
    889 ) {
    890     val orderManager = remember { viewModel.orderManager }
    891     val currentOrderId by orderManager.currentOrderId.observeAsState()
    892     val categories by orderManager.categories.observeAsState(emptyList())
    893     val products by orderManager.products.observeAsState(emptyList())
    894     val currency = viewModel.configManager.currency
    895     val currencySpec = viewModel.configManager.currencySpec
    896 
    897     val orderId = currentOrderId ?: return
    898     val liveOrder = remember(orderId) { orderManager.getOrder(orderId) }
    899     val order by liveOrder.order.observeAsState()
    900     val orderTotal by liveOrder.orderTotal.observeAsState(
    901         Amount.zero(currency ?: "").withSpec(currencySpec),
    902     )
    903     val modifyAllowed by liveOrder.modifyOrderAllowed.observeAsState(false)
    904     val increaseAllowed by liveOrder.increaseOrderAllowed.observeAsState(false)
    905     var selectedProductKey by rememberSaveable(orderId) { mutableStateOf(liveOrder.selectedProductKey) }
    906     var selectedCategoryId by rememberSaveable {
    907         mutableStateOf(categories.firstOrNull { it.selected }?.id)
    908     }
    909 
    910     if (showCustomDialog && currency != null) {
    911         CustomProductDialog(
    912             currency = currency,
    913             currencySpec = currencySpec,
    914             onDismiss = {},
    915             onAdd = { _, _ -> },
    916             initialDescription = customDescription,
    917             initialAmount = customAmount,
    918         )
    919     }
    920 
    921     PosTheme {
    922         Box(
    923             modifier = Modifier.fillMaxSize(),
    924         ) {
    925             val isTabletLayout = LocalConfiguration.current.smallestScreenWidthDp >= 720
    926             if (isTabletLayout) {
    927                 TabletOrderScreen(
    928                     categories = categories,
    929                     selectedCategoryId = selectedCategoryId,
    930                     products = products,
    931                     order = order,
    932                     increaseAllowed = increaseAllowed,
    933                     modifyAllowed = modifyAllowed,
    934                     orderTotal = orderTotal.toString(),
    935                     selectedProductKey = selectedProductKey,
    936                     onCategorySelected = { selectedCategoryId = it.id },
    937                     onProductSelected = {},
    938                     onSelectProduct = { selectedProductKey = it?.id },
    939                     onIncrease = {},
    940                     onDecrease = {},
    941                     onAddCustom = {},
    942                     onComplete = {},
    943                 )
    944             } else {
    945                 PhoneOrderScreen(
    946                     categories = categories,
    947                     selectedCategoryId = selectedCategoryId,
    948                     products = products,
    949                     order = order,
    950                     increaseAllowed = increaseAllowed,
    951                     modifyAllowed = modifyAllowed,
    952                     orderTotal = orderTotal.toString(),
    953                     selectedProductKey = selectedProductKey,
    954                     onCategorySelected = { selectedCategoryId = it.id },
    955                     onProductSelected = {},
    956                     onSelectProduct = { selectedProductKey = it?.id },
    957                     onIncrease = {},
    958                     onDecrease = {},
    959                     onAddCustom = {},
    960                     onComplete = {},
    961                 )
    962             }
    963         }
    964     }
    965 }