MainActivity.kt (28074B)
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 18 19 import android.content.Intent 20 import android.content.Intent.ACTION_MAIN 21 import android.content.Intent.CATEGORY_HOME 22 import android.content.Intent.FLAG_ACTIVITY_NEW_TASK 23 import android.net.Uri 24 import android.os.Bundle 25 import android.os.Handler 26 import android.os.Looper 27 import android.util.Log 28 import android.view.View 29 import android.view.ViewGroup.LayoutParams.MATCH_PARENT 30 import android.widget.FrameLayout 31 import android.widget.Toast 32 import android.widget.Toast.LENGTH_SHORT 33 import androidx.activity.compose.BackHandler 34 import androidx.activity.compose.LocalActivity 35 import androidx.activity.compose.setContent 36 import androidx.activity.viewModels 37 import androidx.appcompat.app.AppCompatActivity 38 import androidx.compose.foundation.layout.padding 39 import androidx.compose.foundation.layout.fillMaxSize 40 import androidx.compose.foundation.layout.fillMaxWidth 41 import androidx.compose.foundation.layout.Column 42 import androidx.compose.foundation.layout.Row 43 import androidx.compose.foundation.layout.Arrangement 44 import androidx.compose.foundation.layout.Spacer 45 import androidx.compose.foundation.horizontalScroll 46 import androidx.compose.foundation.layout.heightIn 47 import androidx.compose.foundation.layout.height 48 import androidx.compose.foundation.layout.PaddingValues 49 import androidx.compose.foundation.layout.systemBarsPadding 50 import androidx.compose.foundation.layout.size 51 import androidx.compose.foundation.layout.width 52 import androidx.compose.foundation.rememberScrollState 53 import androidx.compose.foundation.Image 54 import androidx.compose.material.icons.Icons 55 import androidx.compose.material.icons.filled.Menu 56 import androidx.compose.material3.Button 57 import androidx.compose.material3.ButtonDefaults 58 import androidx.compose.material3.DrawerValue 59 import androidx.compose.material3.ExperimentalMaterial3Api 60 import androidx.compose.material3.Icon 61 import androidx.compose.material3.IconButton 62 import androidx.compose.material3.MaterialTheme 63 import androidx.compose.material3.ModalDrawerSheet 64 import androidx.compose.material3.ModalNavigationDrawer 65 import androidx.compose.material3.NavigationDrawerItem 66 import androidx.compose.material3.Scaffold 67 import androidx.compose.material3.Surface 68 import androidx.compose.material3.Text 69 import androidx.compose.material3.rememberDrawerState 70 import androidx.compose.runtime.Composable 71 import androidx.compose.runtime.DisposableEffect 72 import androidx.compose.runtime.LaunchedEffect 73 import androidx.compose.runtime.getValue 74 import androidx.compose.runtime.remember 75 import androidx.compose.runtime.rememberCoroutineScope 76 import androidx.compose.runtime.livedata.observeAsState 77 import androidx.compose.ui.Modifier 78 import androidx.compose.ui.res.painterResource 79 import androidx.compose.ui.platform.LocalContext 80 import androidx.compose.ui.res.stringResource 81 import androidx.compose.ui.unit.dp 82 import androidx.compose.ui.viewinterop.AndroidView 83 import androidx.fragment.app.Fragment 84 import androidx.fragment.app.commitNow 85 import androidx.navigation.NavHostController 86 import androidx.navigation.compose.NavHost 87 import androidx.navigation.compose.composable 88 import androidx.navigation.compose.currentBackStackEntryAsState 89 import androidx.navigation.compose.rememberNavController 90 import kotlinx.coroutines.launch 91 import net.taler.lib.android.TalerNfcService 92 import net.taler.merchantpos.compose.PosTheme 93 import net.taler.merchantpos.config.Config 94 import net.taler.merchantpos.config.ConfigFetcherFragment 95 import net.taler.merchantpos.config.ConfigFragment 96 import net.taler.merchantpos.config.GeneralSettingsFragment 97 import net.taler.merchantpos.history.HistoryFragment 98 import net.taler.merchantpos.order.OrderFragment 99 import net.taler.merchantpos.amount.AmountEntryFragment 100 import net.taler.merchantpos.payment.PaymentSuccessFragment 101 import net.taler.merchantpos.payment.ProcessPaymentFragment 102 import net.taler.merchantpos.refund.RefundFragment 103 import net.taler.merchantpos.refund.RefundUriFragment 104 import net.taler.merchantpos.order.RestartState 105 import net.taler.merchantpos.order.RestartState.DISABLED 106 import net.taler.merchantpos.order.RestartState.UNDO 107 108 class MainActivity : AppCompatActivity() { 109 110 private val model: MainViewModel by viewModels() 111 112 private var navController: NavHostController? = null 113 private var reallyExit = false 114 115 companion object { 116 const val TAG = "taler-pos" 117 } 118 119 override fun onCreate(savedInstanceState: Bundle?) { 120 super.onCreate(savedInstanceState) 121 122 TalerNfcService.startService(this) 123 124 model.paymentManager.payment.observe(this) { payment -> 125 payment?.talerPayUri?.let { 126 TalerNfcService.setUri(this, it) 127 } ?: run { 128 TalerNfcService.clearNdefPayload(this) 129 } 130 } 131 132 model.configManager.sessionExpired.observe(this) { 133 showPosError(R.string.session_expired_toast) 134 navigateTo(PosDestination.Config, clearBackStack = true) 135 } 136 137 setContent { 138 PosTheme { 139 MerchantTerminalApp( 140 viewModel = model, 141 startDestination = determineStartDestination(), 142 onNavControllerReady = { navController = it }, 143 onExitRequested = ::handleExitRequest, 144 ) 145 } 146 } 147 148 handleSetupIntent(intent) 149 } 150 151 override fun onStart() { 152 super.onStart() 153 if (!model.configManager.config.isValid()) { 154 navigateTo(PosDestination.Config, clearBackStack = true) 155 } else if (model.configManager.merchantConfig == null || model.configManager.currency == null) { 156 navigateTo(PosDestination.ConfigFetcher) 157 } else { 158 model.configManager.refreshConfigInBackground() 159 } 160 } 161 162 override fun onResume() { 163 super.onResume() 164 TalerNfcService.setDefaultHandler(this) 165 } 166 167 override fun onPause() { 168 super.onPause() 169 TalerNfcService.unsetDefaultHandler(this) 170 } 171 172 override fun onDestroy() { 173 super.onDestroy() 174 TalerNfcService.stopService(this) 175 } 176 177 override fun onNewIntent(intent: Intent) { 178 super.onNewIntent(intent) 179 handleSetupIntent(intent) 180 } 181 182 fun navigateTo(destination: PosDestination, clearBackStack: Boolean = false) { 183 val controller = navController ?: return 184 controller.navigate(destination.route) { 185 launchSingleTop = true 186 if (clearBackStack) { 187 popUpTo(controller.graph.id) { 188 inclusive = true 189 } 190 } 191 } 192 } 193 194 fun navigateBack() { 195 val controller = navController ?: return 196 if (!controller.popBackStack()) { 197 finish() 198 } 199 } 200 201 fun navigateToInitialOrderScreen() { 202 navigateTo(model.configManager.initialDestination(), clearBackStack = true) 203 } 204 205 fun handleSetupIntent(intent: Intent) { 206 if (intent.action != Intent.ACTION_VIEW) return 207 val data = intent.data ?: return 208 if (data.scheme != "taler-pos") return 209 210 val host = data.host ?: return 211 val pathSegments = data.pathSegments 212 val pathStyleInstance = pathSegments 213 .takeIf { it.size >= 2 && it[0].equals("instances", ignoreCase = true) } 214 ?.get(1) 215 ?.takeIf(String::isNotBlank) 216 val rawFragment = data.fragment?.removePrefix("/")?.trim().orEmpty() 217 val params = rawFragment 218 .takeIf { '=' in it } 219 ?.split('&') 220 ?.associate { part -> 221 part.split('=', limit = 2).let { it[0] to Uri.decode(it.getOrElse(1) { "" }) } 222 } 223 224 val instance = pathStyleInstance ?: params?.get("username") ?: return 225 val token = if (pathStyleInstance != null && params == null) { 226 Uri.decode(rawFragment).takeIf(String::isNotBlank) 227 } else { 228 params?.get("password") 229 } ?: return 230 231 val merchantUrl = Uri.Builder() 232 .scheme("https") 233 .encodedAuthority(host) 234 .appendPath("instances") 235 .appendPath(instance) 236 .build() 237 .toString() 238 239 val newConfig = Config.New( 240 merchantUrl = merchantUrl, 241 accessToken = token, 242 savePassword = true, 243 ) 244 245 Log.d("MainActivity", "Config URL: $merchantUrl") 246 model.configManager.config = newConfig 247 navigateTo(PosDestination.ConfigFetcher) 248 } 249 250 private fun determineStartDestination(): PosDestination { 251 return when { 252 !model.configManager.config.isValid() -> PosDestination.Config 253 model.configManager.merchantConfig == null || model.configManager.currency == null -> 254 PosDestination.ConfigFetcher 255 else -> model.configManager.initialDestination() 256 } 257 } 258 259 private fun handleExitRequest(currentRoute: String?) { 260 if (currentRoute == PosDestination.Order.route || currentRoute == PosDestination.AmountEntry.route) { 261 if (reallyExit) { 262 super.onBackPressedDispatcher.onBackPressed() 263 } else { 264 reallyExit = true 265 Toast.makeText(this, R.string.toast_back_to_exit, LENGTH_SHORT).show() 266 Handler(Looper.getMainLooper()).postDelayed({ reallyExit = false }, 3000) 267 } 268 } else if (currentRoute == PosDestination.Settings.route || currentRoute == PosDestination.Config.route) { 269 if (!model.configManager.config.isValid()) { 270 startActivity(Intent(ACTION_MAIN).apply { 271 addCategory(CATEGORY_HOME) 272 flags = FLAG_ACTIVITY_NEW_TASK 273 }) 274 } else { 275 navigateBack() 276 } 277 } else { 278 navigateBack() 279 } 280 } 281 } 282 283 @OptIn(ExperimentalMaterial3Api::class) 284 @Composable 285 private fun MerchantTerminalApp( 286 viewModel: MainViewModel, 287 startDestination: PosDestination, 288 onNavControllerReady: (NavHostController) -> Unit, 289 onExitRequested: (String?) -> Unit, 290 ) { 291 val navController = rememberNavController() 292 val drawerState = rememberDrawerState(DrawerValue.Closed) 293 val scope = rememberCoroutineScope() 294 val backStackEntry by navController.currentBackStackEntryAsState() 295 val currentRoute = backStackEntry?.destination?.route 296 val currentOrderId by viewModel.orderManager.currentOrderId.observeAsState() 297 val currentOrderLive = remember(currentOrderId) { currentOrderId?.let { viewModel.orderManager.getOrder(it) } } 298 val currentOrderState = currentOrderLive?.order?.observeAsState() 299 val currentOrder = currentOrderState?.value 300 val restartState by currentOrderLive?.restartState?.observeAsState(DISABLED) ?: remember { androidx.compose.runtime.mutableStateOf(DISABLED) } 301 val clearOrderEnabled = 302 restartState != DISABLED || currentOrder?.products?.isNotEmpty() == true 303 val hasPreviousOrder = currentOrderId?.let { viewModel.orderManager.hasPreviousOrder(it) } ?: false 304 val hasNextOrder by currentOrderId?.let { viewModel.orderManager.hasNextOrder(it).observeAsState(false) } 305 ?: remember { androidx.compose.runtime.mutableStateOf(false) } 306 val preferredInitialDestination by viewModel.configManager.initialOrderScreenLiveData.observeAsState( 307 viewModel.configManager.initialOrderScreen 308 ) 309 val context = LocalContext.current 310 val hasValidConfig = viewModel.configManager.config.isValid() 311 val lockMerchantSettingsNavigation = 312 !hasValidConfig && 313 (currentRoute == PosDestination.Settings.route || currentRoute == PosDestination.Config.route) 314 val screenTitle = when (currentRoute) { 315 PosDestination.Order.route -> currentOrder?.let { 316 stringResource(R.string.order_label_title, it.title) 317 } ?: stringResource(R.string.menu_order) 318 else -> stringResource(currentRoute.titleResId()) 319 } 320 val reloadingMessage = stringResource(R.string.toast_reloading) 321 322 val drawerItems = listOf( 323 PosDestination.AmountEntry, 324 PosDestination.Order, 325 PosDestination.History, 326 PosDestination.Settings, 327 ).let { items -> 328 val preferredItem = when (preferredInitialDestination) { 329 net.taler.merchantpos.config.InitialOrderScreen.AmountEntry -> PosDestination.AmountEntry 330 net.taler.merchantpos.config.InitialOrderScreen.Inventory -> PosDestination.Order 331 null -> viewModel.configManager.initialDestination() 332 } 333 if (preferredItem in items) { 334 listOf(preferredItem) + items.filterNot { it == preferredItem } 335 } else { 336 items 337 } 338 } 339 340 LaunchedEffect(navController) { 341 onNavControllerReady(navController) 342 } 343 344 BackHandler { 345 if (drawerState.isOpen) { 346 scope.launch { drawerState.close() } 347 } else { 348 onExitRequested(currentRoute) 349 } 350 } 351 352 ModalNavigationDrawer( 353 drawerState = drawerState, 354 gesturesEnabled = !lockMerchantSettingsNavigation, 355 drawerContent = { 356 ModalDrawerSheet { 357 Column( 358 modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), 359 verticalArrangement = Arrangement.spacedBy(6.dp), 360 ) { 361 Image( 362 painter = painterResource(R.drawable.ic_talerpos_logo), 363 contentDescription = null, 364 modifier = Modifier 365 .fillMaxWidth() 366 .height(72.dp) 367 .padding(horizontal = 8.dp, vertical = 8.dp), 368 ) 369 drawerItems.forEach { item -> 370 NavigationDrawerItem( 371 icon = { 372 Icon( 373 painter = painterResource(item.drawerIconResId()), 374 contentDescription = null, 375 ) 376 }, 377 label = { 378 Text( 379 text = stringResource(item.labelResId()), 380 fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, 381 ) 382 }, 383 selected = currentRoute == item.route, 384 onClick = { 385 scope.launch { drawerState.close() } 386 navController.navigate(item.route) { 387 launchSingleTop = true 388 popUpTo(navController.graph.startDestinationId) { 389 inclusive = false 390 } 391 } 392 }, 393 modifier = Modifier.fillMaxWidth(), 394 shape = MaterialTheme.shapes.large, 395 badge = null, 396 colors = androidx.compose.material3.NavigationDrawerItemDefaults.colors(), 397 ) 398 } 399 } 400 } 401 }, 402 ) { 403 Scaffold( 404 topBar = { 405 if (currentRoute == PosDestination.Order.route) { 406 Surface(shadowElevation = 2.dp) { 407 Row( 408 modifier = Modifier 409 .fillMaxWidth() 410 .systemBarsPadding() 411 .padding(horizontal = 8.dp, vertical = 8.dp), 412 horizontalArrangement = Arrangement.spacedBy(8.dp), 413 verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, 414 ) { 415 if (!lockMerchantSettingsNavigation) { 416 IconButton(onClick = { scope.launch { drawerState.open() } }) { 417 Icon(Icons.Default.Menu, contentDescription = null) 418 } 419 } else { 420 Spacer(modifier = Modifier.size(48.dp)) 421 } 422 Text( 423 text = screenTitle, 424 modifier = Modifier.weight(1f), 425 style = MaterialTheme.typography.titleLarge, 426 ) 427 Row( 428 modifier = Modifier.horizontalScroll(rememberScrollState()), 429 horizontalArrangement = Arrangement.spacedBy(8.dp), 430 ) { 431 Button( 432 onClick = { currentOrderLive?.restartOrUndo() }, 433 enabled = clearOrderEnabled, 434 colors = topBarOrderButtonColors(), 435 modifier = Modifier.heightIn(min = 40.dp), 436 ) { 437 Text( 438 if (restartState == UNDO) { 439 stringResource(R.string.order_undo) 440 } else { 441 stringResource(R.string.order_restart) 442 }, 443 ) 444 } 445 Button( 446 onClick = { if (hasPreviousOrder) viewModel.orderManager.previousOrder() }, 447 enabled = hasPreviousOrder, 448 colors = topBarOrderButtonColors(), 449 modifier = Modifier.heightIn(min = 40.dp), 450 ) { 451 Text(stringResource(R.string.order_previous)) 452 } 453 Button( 454 onClick = { if (hasNextOrder) viewModel.orderManager.nextOrder() }, 455 enabled = hasNextOrder, 456 colors = topBarOrderButtonColors(), 457 modifier = Modifier.heightIn(min = 40.dp), 458 ) { 459 Text(stringResource(R.string.order_next)) 460 } 461 Button( 462 onClick = { 463 viewModel.configManager.reloadConfig() 464 Toast.makeText( 465 context, 466 reloadingMessage, 467 Toast.LENGTH_LONG, 468 ).show() 469 }, 470 colors = topBarOrderButtonColors(), 471 modifier = Modifier.heightIn(min = 40.dp), 472 ) { 473 Text(stringResource(R.string.menu_reload)) 474 } 475 } 476 } 477 } 478 } else if (currentRoute == PosDestination.History.route) { 479 Surface(shadowElevation = 2.dp) { 480 Row( 481 modifier = Modifier 482 .fillMaxWidth() 483 .systemBarsPadding() 484 .padding(horizontal = 8.dp, vertical = 8.dp), 485 horizontalArrangement = Arrangement.spacedBy(8.dp), 486 verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, 487 ) { 488 if (!lockMerchantSettingsNavigation) { 489 IconButton(onClick = { scope.launch { drawerState.open() } }) { 490 Icon(Icons.Default.Menu, contentDescription = null) 491 } 492 } else { 493 Spacer(modifier = Modifier.size(48.dp)) 494 } 495 Text( 496 text = screenTitle, 497 modifier = Modifier.weight(1f), 498 style = MaterialTheme.typography.titleLarge, 499 ) 500 Button( 501 onClick = { viewModel.historyManager.fetchHistory() }, 502 colors = topBarOrderButtonColors(), 503 modifier = Modifier.heightIn(min = 40.dp), 504 ) { 505 Text(stringResource(R.string.history_refresh)) 506 } 507 } 508 } 509 } else { 510 Surface(shadowElevation = 2.dp) { 511 Row( 512 modifier = Modifier 513 .fillMaxWidth() 514 .systemBarsPadding() 515 .padding(horizontal = 8.dp, vertical = 8.dp), 516 horizontalArrangement = Arrangement.spacedBy(8.dp), 517 verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, 518 ) { 519 if (!lockMerchantSettingsNavigation) { 520 IconButton(onClick = { scope.launch { drawerState.open() } }) { 521 Icon(Icons.Default.Menu, contentDescription = null) 522 } 523 } else { 524 Spacer(modifier = Modifier.size(48.dp)) 525 } 526 Text( 527 text = screenTitle, 528 modifier = Modifier.weight(1f), 529 style = MaterialTheme.typography.titleLarge, 530 ) 531 } 532 } 533 } 534 }, 535 ) { innerPadding -> 536 NavHost( 537 navController = navController, 538 startDestination = startDestination.route, 539 modifier = Modifier 540 .fillMaxSize() 541 .padding(innerPadding), 542 ) { 543 composable(PosDestination.AmountEntry.route) { 544 FragmentScreenHost("amount-entry") { AmountEntryFragment() } 545 } 546 composable(PosDestination.Order.route) { 547 FragmentScreenHost("order") { OrderFragment() } 548 } 549 composable(PosDestination.History.route) { 550 FragmentScreenHost("history") { HistoryFragment() } 551 } 552 composable(PosDestination.Settings.route) { 553 FragmentScreenHost("settings") { GeneralSettingsFragment() } 554 } 555 composable(PosDestination.Config.route) { 556 FragmentScreenHost("config") { ConfigFragment() } 557 } 558 composable(PosDestination.ConfigFetcher.route) { 559 FragmentScreenHost("config-fetcher") { ConfigFetcherFragment() } 560 } 561 composable(PosDestination.ProcessPayment.route) { 562 FragmentScreenHost("process-payment") { ProcessPaymentFragment() } 563 } 564 composable(PosDestination.PaymentSuccess.route) { 565 FragmentScreenHost("payment-success") { PaymentSuccessFragment() } 566 } 567 composable(PosDestination.Refund.route) { 568 FragmentScreenHost("refund") { RefundFragment() } 569 } 570 composable(PosDestination.RefundUri.route) { 571 FragmentScreenHost("refund-uri") { RefundUriFragment() } 572 } 573 } 574 } 575 } 576 } 577 578 @Composable 579 private fun topBarOrderButtonColors() = ButtonDefaults.buttonColors( 580 containerColor = MaterialTheme.colorScheme.secondaryContainer, 581 contentColor = MaterialTheme.colorScheme.onSecondaryContainer, 582 ) 583 584 @Composable 585 private fun FragmentScreenHost( 586 routeTag: String, 587 createFragment: () -> Fragment, 588 ) { 589 val activity = LocalActivity.current as? MainActivity ?: return 590 val fragmentManager = activity.supportFragmentManager 591 val containerId = remember(routeTag) { View.generateViewId() } 592 val fragmentTag = remember(routeTag, containerId) { "$routeTag-$containerId" } 593 594 AndroidView( 595 modifier = Modifier.fillMaxSize(), 596 factory = { context -> 597 FrameLayout(context).apply { 598 id = containerId 599 layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) 600 } 601 }, 602 ) 603 604 DisposableEffect(fragmentTag) { 605 if (fragmentManager.findFragmentByTag(fragmentTag) == null) { 606 fragmentManager.commitNow { 607 replace(containerId, createFragment(), fragmentTag) 608 } 609 } 610 onDispose { 611 fragmentManager.findFragmentByTag(fragmentTag)?.let { fragment -> 612 if (fragment.isAdded && !fragmentManager.isStateSaved) { 613 fragmentManager.commitNow { 614 remove(fragment) 615 } 616 } 617 } 618 } 619 } 620 } 621 622 private fun PosDestination.labelResId(): Int = when (this) { 623 PosDestination.AmountEntry -> R.string.menu_amount_entry 624 PosDestination.Order -> R.string.menu_order 625 PosDestination.History -> R.string.menu_history 626 PosDestination.Settings -> R.string.menu_settings 627 else -> R.string.app_name_short 628 } 629 630 private fun PosDestination.drawerIconResId(): Int = when (this) { 631 PosDestination.AmountEntry -> R.drawable.ic_dialpad 632 PosDestination.Order -> R.drawable.ic_move_money_24dp 633 PosDestination.History -> R.drawable.ic_history_black_24dp 634 PosDestination.Settings -> R.drawable.ic_menu_manage 635 else -> R.drawable.ic_move_money_24dp 636 } 637 638 private fun String?.titleResId(): Int = when (this) { 639 PosDestination.AmountEntry.route -> R.string.menu_amount_entry 640 PosDestination.Order.route -> R.string.menu_order 641 PosDestination.History.route -> R.string.menu_history 642 PosDestination.Settings.route -> R.string.menu_settings 643 PosDestination.Config.route -> R.string.config_label 644 PosDestination.ConfigFetcher.route -> R.string.config_fetching_label 645 PosDestination.ProcessPayment.route -> R.string.payment_process_label 646 PosDestination.PaymentSuccess.route -> R.string.payment_received 647 PosDestination.Refund.route, PosDestination.RefundUri.route -> R.string.history_refund 648 else -> R.string.app_name_short 649 }