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
    }