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 }