MainActivity.kt
/*
* This file is part of GNU Taler
* (C) 2020 Taler Systems S.A.
*
* GNU Taler is free software; you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3, or (at your option) any later version.
*
* GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
package net.taler.merchantpos
import android.content.Intent
import android.content.Intent.ACTION_MAIN
import android.content.Intent.CATEGORY_HOME
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.ActivityInfo
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
import android.widget.Toast
import android.widget.Toast.LENGTH_SHORT
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.Image
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView
import androidx.fragment.app.commitNow
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import net.taler.lib.android.TalerNfcService
import net.taler.merchantpos.amount.AmountEntryFragment
import net.taler.merchantpos.checkout.CheckoutFragment
import net.taler.merchantpos.compose.PosTheme
import net.taler.merchantpos.config.Config
import net.taler.merchantpos.config.ConfigFetcherFragment
import net.taler.merchantpos.config.ConfigFragment
import net.taler.merchantpos.config.GeneralSettingsFragment
import net.taler.merchantpos.config.InitialOrderScreen
import net.taler.merchantpos.config.LocalNetworkPermissionDialog
import net.taler.merchantpos.config.merchantSetupConfig
import net.taler.merchantpos.devices.TalerDevicesFragment
import net.taler.merchantpos.history.HistoryFragment
import net.taler.merchantpos.history.OrderDetailsRoute
import net.taler.merchantpos.order.OrderFragment
import net.taler.merchantpos.order.RestartState.DISABLED
import net.taler.merchantpos.order.RestartState.UNDO
import net.taler.merchantpos.payment.PaymentSuccessFragment
import net.taler.merchantpos.payment.ProcessPaymentFragment
import net.taler.merchantpos.paymentoptions.PaymentOptionDetailFragment
import net.taler.merchantpos.paymentoptions.PaymentOptionsFragment
import net.taler.merchantpos.paymentoptions.PaymentOptionsSummary
import net.taler.merchantpos.paymentoptions.TALER_OPTION_ID
import net.taler.merchantpos.paymentoptions.TerminalStatusIndicator
import net.taler.merchantpos.refund.RefundFragment
import net.taler.merchantpos.refund.RefundUriFragment
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : AppCompatActivity()
{
private val model: MainViewModel by viewModels()
private var navController: NavHostController? = null
private var reallyExit = false
companion object
{
const val TAG = "taler-pos"
}
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
model.paymentOptions.options.forEach { option ->
option.registerLaunchers(this, model.paymentOptions)
}
model.paymentOptions.summary.observe(this) { summary ->
val talerEnabled =
summary.options.any {
it.id == TALER_OPTION_ID && it.enabled
}
model.talerUsb.setTalerEnabled(talerEnabled)
model.talerBluetooth.setTalerEnabled(talerEnabled)
}
model.talerBluetooth.state.observe(this) {
lifecycleScope.launch {
withContext(Dispatchers.IO) { model.paymentOptions.refresh() }
}
}
model.talerUsb.state.observe(this) {
lifecycleScope.launch {
withContext(Dispatchers.IO) { model.paymentOptions.refresh() }
}
}
// Reads payment option status off the main thread: probing a card terminal
// reaches its SDK and Bluetooth, which must not block drawing the till.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
while (true)
{
withContext(Dispatchers.IO) { model.paymentOptions.refresh() }
delay(TimeUnit.SECONDS.toMillis(10))
}
}
}
// Renews refreshable sessions well inside the one hour a SumUp access token
// lasts, so a till is never left waiting for someone who can sign in.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
while (true)
{
model.paymentOptions.maintainSessions()
delay(TimeUnit.MINUTES.toMillis(10))
}
}
}
TalerNfcService.startService(this)
model.paymentManager.payment.observe(this) { payment ->
val paymentNfcUri =
payment
?.takeIf {
!it.claimed && !it.paid && it.error == null
}?.talerPayUri
paymentNfcUri?.let {
TalerNfcService.clearNdefPayload(this)
TalerNfcService.setUri(this, it)
} ?: run {
TalerNfcService.clearNdefPayload(this)
}
}
model.configManager.sessionExpired.observe(this) { event ->
if (event.getIfNotConsumed() == null) return@observe
showPosError(R.string.session_expired_toast)
navigateTo(PosDestination.Config, clearBackStack = true)
}
setContent {
PosTheme {
MerchantTerminalApp(
viewModel = model,
startDestination = determineStartDestination(),
onNavControllerReady = { navController = it },
onExitRequested = ::handleExitRequest,
)
LocalNetworkPermissionDialog(model.localNetworkAccess)
}
}
handleSetupIntent(intent)
}
override fun onStart()
{
super.onStart()
model.talerBluetooth.setForeground(true)
if (!model.configManager.config.isValid())
{
navigateTo(PosDestination.Config, clearBackStack = true)
}
else if (model.configManager.merchantConfig == null ||
model.configManager.currency == null)
{
if (navController?.currentDestination?.route != PosDestination.Config.route)
{
navigateTo(PosDestination.ConfigFetcher)
}
}
else
{
model.configManager.refreshConfigInBackground()
}
}
override fun onResume()
{
super.onResume()
TalerNfcService.setDefaultHandler(this)
}
override fun onPause()
{
super.onPause()
TalerNfcService.unsetDefaultHandler(this)
}
override fun onStop()
{
model.talerUsb.setUri(null)
model.talerBluetooth.setForeground(false)
super.onStop()
}
override fun onDestroy()
{
super.onDestroy()
// the payload outlives the service, so stopping it is no longer enough
TalerNfcService.clearNdefPayload(this)
TalerNfcService.stopService(this)
}
override fun onNewIntent(intent: Intent)
{
super.onNewIntent(intent)
handleSetupIntent(intent)
}
fun navigateTo(
destination: PosDestination,
clearBackStack: Boolean = false,
)
{
val controller = navController ?: return
controller.navigate(destination.route) {
launchSingleTop = true
if (clearBackStack)
{
popUpTo(controller.graph.id) {
inclusive = true
}
}
}
}
fun returnToCheckout()
{
val controller = navController ?: return
if (!controller.popBackStack(PosDestination.Checkout.route, false))
{
controller.navigate(PosDestination.Checkout.route) {
popUpTo(PosDestination.ProcessPayment.route) { inclusive = true }
launchSingleTop = true
}
}
}
fun navigateBack()
{
val controller = navController ?: return
if (!controller.popBackStack())
{
finish()
}
}
fun navigateToInitialOrderScreen()
{
navigateTo(model.configManager.initialDestination(), clearBackStack = true)
}
/**
* Returns to the screen the sale started from once it is paid, however many steps
* the payment took on the way (checkout, Taler, success).
*/
fun returnToOrderEntry()
{
val controller = navController ?: return
val entryRoutes = setOf(PosDestination.Order.route, PosDestination.AmountEntry.route)
while (controller.currentDestination?.route !in entryRoutes)
{
if (!controller.popBackStack())
{
navigateToInitialOrderScreen()
return
}
}
}
fun handleSetupIntent(intent: Intent)
{
if (intent.action != Intent.ACTION_VIEW) return
val data = intent.data ?: return
if (data.scheme != "taler-pos") return
// A payment option may own this link, for example an OAuth authorization
// coming back from the browser.
lifecycleScope.launch {
if (model.paymentOptions.handleRedirect(data)) return@launch
handleMerchantSetupUri(data)
}
}
private fun handleMerchantSetupUri(data: Uri)
{
val newConfig = merchantSetupConfig(data) ?: return
model.localNetworkAccess.retry()
model.configManager.config = newConfig
navigateTo(PosDestination.ConfigFetcher)
}
private fun determineStartDestination(): PosDestination =
when
{
!model.configManager.config.isValid() ->
{
PosDestination.Config
}
model.configManager.merchantConfig == null || model.configManager.currency == null ->
{
PosDestination.ConfigFetcher
}
else ->
{
model.configManager.initialDestination()
}
}
private fun handleExitRequest(currentRoute: String?)
{
if (currentRoute == PosDestination.Order.route ||
currentRoute == PosDestination.AmountEntry.route)
{
if (reallyExit)
{
super.onBackPressedDispatcher.onBackPressed()
}
else
{
reallyExit = true
Toast.makeText(this, R.string.toast_back_to_exit, LENGTH_SHORT).show()
Handler(Looper.getMainLooper()).postDelayed({ reallyExit = false }, 3000)
}
}
else if (currentRoute == PosDestination.Settings.route ||
currentRoute == PosDestination.Config.route)
{
if (!model.configManager.config.isValid())
{
startActivity(
Intent(ACTION_MAIN).apply {
addCategory(CATEGORY_HOME)
flags = FLAG_ACTIVITY_NEW_TASK
},
)
}
else
{
navigateBack()
}
}
else
{
navigateBack()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MerchantTerminalApp(
viewModel: MainViewModel,
startDestination: PosDestination,
onNavControllerReady: (NavHostController) -> Unit,
onExitRequested: (String?) -> Unit,
)
{
val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
val activity = LocalActivity.current
LaunchedEffect(currentRoute) {
if (currentRoute == null) return@LaunchedEffect
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_FULL_USER
}
val currentOrderId by viewModel.orderManager.currentOrderId.observeAsState()
val currentOrderLive = remember(currentOrderId) { currentOrderId?.let { viewModel.orderManager.getOrder(it) } }
val currentOrderState = currentOrderLive?.order?.observeAsState()
val currentOrder = currentOrderState?.value
val restartState by currentOrderLive?.restartState?.observeAsState(DISABLED)
?: remember { mutableStateOf(DISABLED) }
val clearOrderEnabled =
restartState != DISABLED || currentOrder?.products?.isNotEmpty() == true
val hasPreviousOrder = currentOrderId?.let { viewModel.orderManager.hasPreviousOrder(it) } ?: false
val hasNextOrder by currentOrderId?.let { viewModel.orderManager.hasNextOrder(it).observeAsState(false) }
?: remember { mutableStateOf(false) }
val preferredInitialDestination by viewModel.configManager.initialOrderScreenLiveData.observeAsState(
viewModel.configManager.initialOrderScreen,
)
val context = LocalContext.current
val optionsSummary by viewModel.paymentOptions.summary.observeAsState(PaymentOptionsSummary())
val optionActionFailed by viewModel.paymentOptions.actionFailed.observeAsState(false)
val terminalStatusIndicator: @Composable () -> Unit = {
if (viewModel.paymentOptions.hasEnabledTerminal(optionsSummary))
{
TerminalStatusIndicator(
status = viewModel.paymentOptions.terminalStatus(optionsSummary),
states = viewModel.paymentOptions.connectionStates(optionsSummary),
onOpenOptions = {
navController.navigate(PosDestination.PaymentOptions.route) { launchSingleTop = true }
},
)
}
}
if (optionActionFailed)
{
AlertDialog(
onDismissRequest = viewModel.paymentOptions::dismissError,
title = { Text(stringResource(R.string.payment_options_title)) },
text = { Text(stringResource(R.string.terminal_action_failed)) },
confirmButton = {
TextButton(onClick = viewModel.paymentOptions::dismissError) {
Text(stringResource(android.R.string.ok))
}
},
)
}
// Screens pushed on top of a drawer destination need a way back that is not the
// system gesture, so they show a back arrow instead of the drawer button.
val isSubScreen =
currentRoute in
setOf(
PosDestination.Checkout.route,
PosDestination.OrderDetails.route,
PosDestination.Config.route,
PosDestination.PaymentOptions.route,
PosDestination.TalerDevices.route,
PosDestination.PaymentOptionDetail.route,
)
val hasValidConfig = viewModel.configManager.config.isValid()
val lockMerchantSettingsNavigation =
!hasValidConfig &&
(currentRoute == PosDestination.Settings.route || currentRoute == PosDestination.Config.route)
val currentPayment by viewModel.paymentManager.payment.observeAsState()
val screenTitle =
when (currentRoute)
{
PosDestination.ProcessPayment.route ->
{
stringResource(
when
{
currentPayment?.statusLoading == true -> R.string.order_details_loading
currentPayment?.claimed == true -> R.string.checkout_payment_claimed_title
else -> R.string.payment_process_label
},
)
}
PosDestination.Order.route ->
{
currentOrder?.let {
stringResource(R.string.order_label_title, it.title)
} ?: stringResource(R.string.menu_order)
}
PosDestination.PaymentOptionDetail.route ->
{
val optionId =
backStackEntry
?.arguments
?.getString(PosDestination.PaymentOptionDetail.ARG_OPTION_ID)
val option = optionId?.let { viewModel.paymentOptions.option(it) }
stringResource(option?.nameRes ?: R.string.payment_options_title)
}
else ->
{
stringResource(currentRoute.titleResId())
}
}
val reloadingMessage = stringResource(R.string.toast_reloading)
val configuration = LocalConfiguration.current
// Landscape phones have a wide window but still need a compact toolbar.
val compactTopBar = configuration.screenWidthDp < 600 || configuration.screenHeightDp < 480
var orderActionsExpanded by remember { mutableStateOf(false) }
val drawerItems =
listOf(
PosDestination.AmountEntry,
PosDestination.Order,
PosDestination.History,
PosDestination.Settings,
).let { items ->
val preferredItem =
when (preferredInitialDestination)
{
InitialOrderScreen.AmountEntry -> PosDestination.AmountEntry
InitialOrderScreen.Inventory -> PosDestination.Order
null -> viewModel.configManager.initialDestination()
}
if (preferredItem in items)
{
listOf(preferredItem) + items.filterNot { it == preferredItem }
}
else
{
items
}
}
LaunchedEffect(navController) {
onNavControllerReady(navController)
}
BackHandler {
if (drawerState.isOpen)
{
scope.launch { drawerState.close() }
}
else
{
onExitRequested(currentRoute)
}
}
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = !lockMerchantSettingsNavigation,
drawerContent = {
ModalDrawerSheet {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Image(
painter = painterResource(R.drawable.ic_talerpos_logo),
contentDescription = null,
modifier =
Modifier
.fillMaxWidth()
.height(72.dp)
.padding(horizontal = 8.dp, vertical = 8.dp),
)
drawerItems.forEach { item ->
NavigationDrawerItem(
icon = {
Icon(
painter = painterResource(item.drawerIconResId()),
contentDescription = null,
)
},
label = {
Text(
text = stringResource(item.labelResId()),
fontWeight = FontWeight.SemiBold,
)
},
selected = currentRoute == item.route,
onClick = {
scope.launch { drawerState.close() }
navController.navigate(item.route) {
launchSingleTop = true
popUpTo(navController.graph.startDestinationId) {
inclusive = false
}
}
},
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
badge = null,
colors =
NavigationDrawerItemDefaults
.colors(),
)
}
}
}
},
) {
Scaffold(
topBar = {
PosTopBar(
title = screenTitle,
compact = compactTopBar,
navigationIcon = {
if (lockMerchantSettingsNavigation)
{
Spacer(modifier = Modifier.size(48.dp))
}
else if (isSubScreen)
{
IconButton(onClick = { navController.popBackStack() }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.navigation_back),
)
}
}
else
{
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(
Icons.Default.Menu,
contentDescription = stringResource(R.string.navigation_menu_open)
)
}
}
},
) {
when (currentRoute)
{
PosDestination.Order.route ->
{
terminalStatusIndicator()
OrderActionsControl(
compact = compactTopBar,
expanded = orderActionsExpanded,
onExpandedChange = { orderActionsExpanded = it },
clearEnabled = clearOrderEnabled,
previousEnabled = hasPreviousOrder,
nextEnabled = hasNextOrder,
clearLabel =
if (restartState == UNDO)
{
stringResource(R.string.order_undo)
}
else
{
stringResource(R.string.order_restart)
},
onClear = { currentOrderLive?.restartOrUndo() },
onPrevious = { viewModel.orderManager.previousOrder() },
onNext = { viewModel.orderManager.nextOrder() },
onReload = {
viewModel.configManager.reloadConfig()
Toast.makeText(context, reloadingMessage, Toast.LENGTH_LONG).show()
},
)
}
PosDestination.History.route ->
{
Button(
onClick = { viewModel.historyManager.fetchHistory() },
colors = topBarOrderButtonColors(),
modifier = Modifier.heightIn(min = 40.dp),
) {
Text(
stringResource(R.string.history_refresh),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
PosDestination.AmountEntry.route,
PosDestination.Checkout.route,
PosDestination.ProcessPayment.route,
->
{
terminalStatusIndicator()
}
}
}
},
) { innerPadding ->
NavHost(
navController = navController,
startDestination = startDestination.route,
modifier =
Modifier
.fillMaxSize()
.padding(innerPadding),
) {
composable(PosDestination.AmountEntry.route) {
FragmentScreenHost("amount-entry") { AmountEntryFragment() }
}
composable(PosDestination.Order.route) {
FragmentScreenHost("order") { OrderFragment() }
}
composable(PosDestination.History.route) {
FragmentScreenHost("history") { HistoryFragment() }
}
composable(
route = PosDestination.OrderDetails.route,
arguments =
listOf(
navArgument(PosDestination.OrderDetails.ARG_ORDER_ID) {
type = NavType.StringType
},
),
) { entry ->
OrderDetailsRoute(
model = viewModel,
orderId = requireNotNull(entry.arguments?.getString(PosDestination.OrderDetails.ARG_ORDER_ID)),
onDeleted = { navController.popBackStack(PosDestination.History.route, inclusive = false) },
onNavigate = { destination -> navController.navigate(destination.route) },
)
}
composable(PosDestination.Settings.route) {
FragmentScreenHost("settings") { GeneralSettingsFragment() }
}
composable(PosDestination.PaymentOptions.route) {
FragmentScreenHost("payment-options") { PaymentOptionsFragment() }
}
composable(
route = PosDestination.PaymentOptionDetail.route,
arguments =
listOf(
navArgument(PosDestination.PaymentOptionDetail.ARG_OPTION_ID) {
type = NavType.StringType
},
),
) { entry ->
val optionId =
entry.arguments
?.getString(PosDestination.PaymentOptionDetail.ARG_OPTION_ID)
.orEmpty()
FragmentScreenHost("payment-option-$optionId") {
PaymentOptionDetailFragment.create(optionId)
}
}
composable(PosDestination.TalerDevices.route) {
FragmentScreenHost("taler-devices") {
TalerDevicesFragment()
}
}
composable(PosDestination.Config.route) {
FragmentScreenHost("config") { ConfigFragment() }
}
composable(PosDestination.ConfigFetcher.route) {
FragmentScreenHost("config-fetcher") { ConfigFetcherFragment() }
}
composable(PosDestination.Checkout.route) {
FragmentScreenHost("checkout") { CheckoutFragment() }
}
composable(PosDestination.ProcessPayment.route) {
FragmentScreenHost("process-payment") { ProcessPaymentFragment() }
}
composable(PosDestination.PaymentSuccess.route) {
FragmentScreenHost("payment-success") { PaymentSuccessFragment() }
}
composable(PosDestination.Refund.route) {
FragmentScreenHost("refund") { RefundFragment() }
}
composable(PosDestination.RefundUri.route) {
FragmentScreenHost("refund-uri") { RefundUriFragment() }
}
}
}
}
}
@Composable
private fun PosTopBar(
title: String,
compact: Boolean,
navigationIcon: @Composable () -> Unit,
actions: @Composable RowScope.() -> Unit,
)
{
Surface(shadowElevation = 2.dp) {
Row(
modifier =
Modifier
.fillMaxWidth()
// Protect the status bar and camera cutout without adding the bottom
// navigation-bar inset to the header. Scaffold handles it in the body.
.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
).height(if (compact) 56.dp else 64.dp)
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
navigationIcon()
Text(
text = title,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
actions()
}
}
}
@Composable
private fun topBarOrderButtonColors() =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
@Composable
private fun orderActionMenuColors() =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
@Composable
private fun OrderActionMenuButton(
text: String,
enabled: Boolean = true,
onClick: () -> Unit,
)
{
Button(
onClick = onClick,
enabled = enabled,
colors = orderActionMenuColors(),
shape =
RoundedCornerShape(24.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 44.dp),
) {
Text(text, modifier = Modifier.fillMaxWidth())
}
}
@Composable
internal fun OrderActionsControl(
compact: Boolean,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
clearEnabled: Boolean,
previousEnabled: Boolean,
nextEnabled: Boolean,
clearLabel: String,
onClear: () -> Unit,
onPrevious: () -> Unit,
onNext: () -> Unit,
onReload: () -> Unit,
)
{
if (!compact)
{
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = onClear,
enabled = clearEnabled,
colors = topBarOrderButtonColors(),
modifier = Modifier.heightIn(min = 40.dp),
) {
Text(clearLabel)
}
Button(
onClick = onPrevious,
enabled = previousEnabled,
colors = topBarOrderButtonColors(),
modifier = Modifier.heightIn(min = 40.dp),
) {
Text(stringResource(R.string.order_previous))
}
Button(
onClick = onNext,
enabled = nextEnabled,
colors = topBarOrderButtonColors(),
modifier = Modifier.heightIn(min = 40.dp),
) {
Text(stringResource(R.string.order_next))
}
Button(onClick = onReload, colors = topBarOrderButtonColors(), modifier = Modifier.heightIn(min = 40.dp)) {
Text(stringResource(R.string.menu_reload))
}
}
return
}
Box {
Button(
onClick = { onExpandedChange(!expanded) },
colors = orderActionMenuColors(),
shape =
RoundedCornerShape(24.dp),
modifier = Modifier.heightIn(min = 40.dp),
) {
Text(stringResource(R.string.order_actions))
Icon(
imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = null,
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { onExpandedChange(false) }) {
Column(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
OrderActionMenuButton(clearLabel, clearEnabled) {
onExpandedChange(false)
onClear()
}
OrderActionMenuButton(stringResource(R.string.order_previous), previousEnabled) {
onExpandedChange(false)
onPrevious()
}
OrderActionMenuButton(stringResource(R.string.order_next), nextEnabled) {
onExpandedChange(false)
onNext()
}
OrderActionMenuButton(stringResource(R.string.menu_reload)) {
onExpandedChange(false)
onReload()
}
}
}
}
}
@Composable
private fun FragmentScreenHost(
routeTag: String,
createFragment: () -> Fragment,
)
{
val activity = LocalActivity.current as? MainActivity ?: return
val fragmentManager = activity.supportFragmentManager
// FragmentManager restores fragment container IDs across Activity recreation.
// Keep the Compose host's ID in saved state so it can reconnect to that fragment.
val containerId = rememberSaveable(routeTag) { View.generateViewId() }
val fragmentTag = remember(routeTag, containerId) { "$routeTag-$containerId" }
var container by remember(routeTag) { mutableStateOf<FragmentContainerView?>(null) }
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
FragmentContainerView(context).apply {
id = containerId
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
}
},
update = { container = it },
)
val hostedContainer = container
DisposableEffect(fragmentTag, hostedContainer) {
val attachHostFragment = {
if (hostedContainer != null &&
!fragmentManager.isStateSaved)
{
val restored = fragmentManager.findFragmentByTag(fragmentTag)
if (restored == null)
{
fragmentManager.commitNow {
replace(containerId, createFragment(), fragmentTag)
}
}
else if (restored.view?.parent !== hostedContainer)
{
// FragmentManager may restore the Fragment before Compose has recreated
// its AndroidView. Reattaching forces a fresh view into this container.
if (!restored.isDetached)
{
fragmentManager.commitNow {
detach(restored)
}
}
fragmentManager.commitNow {
attach(restored)
}
}
}
}
val lifecycleObserver =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) attachHostFragment()
}
activity.lifecycle.addObserver(lifecycleObserver)
attachHostFragment()
onDispose {
activity.lifecycle.removeObserver(lifecycleObserver)
if (hostedContainer == null) return@onDispose
fragmentManager.findFragmentByTag(fragmentTag)?.let { fragment ->
if (fragment.isAdded &&
!fragmentManager.isStateSaved)
{
fragmentManager.commitNow {
remove(fragment)
}
}
}
}
}
}
private fun PosDestination.labelResId(): Int =
when (this)
{
PosDestination.AmountEntry -> R.string.menu_amount_entry
PosDestination.Order -> R.string.menu_order
PosDestination.History -> R.string.menu_history
PosDestination.Settings -> R.string.menu_settings
else -> R.string.app_name_short
}
private fun PosDestination.drawerIconResId(): Int =
when (this)
{
PosDestination.AmountEntry -> R.drawable.ic_dialpad
PosDestination.Order -> R.drawable.ic_move_money_24dp
PosDestination.History -> R.drawable.ic_history_black_24dp
PosDestination.Settings -> R.drawable.ic_menu_manage
else -> R.drawable.ic_move_money_24dp
}
private fun String?.titleResId(): Int =
when (this)
{
PosDestination.AmountEntry.route -> R.string.menu_amount_entry
PosDestination.Order.route -> R.string.menu_order
PosDestination.History.route -> R.string.menu_history
PosDestination.OrderDetails.route -> R.string.order_details_title
PosDestination.Settings.route -> R.string.menu_settings
PosDestination.PaymentOptions.route -> R.string.payment_options_title
PosDestination.TalerDevices.route -> R.string.taler_devices_title
PosDestination.Config.route -> R.string.config_label
PosDestination.ConfigFetcher.route -> R.string.config_fetching_label
PosDestination.Checkout.route -> R.string.checkout_title
PosDestination.ProcessPayment.route -> R.string.payment_process_label
PosDestination.PaymentSuccess.route -> R.string.payment_received
PosDestination.Refund.route, PosDestination.RefundUri.route -> R.string.history_refund
else -> R.string.app_name_short
}