TalerDevicesFragment.kt

/*
 * This file is part of GNU Taler
 * (C) 2026 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.devices

import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup

import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
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.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels

import net.taler.merchantpos.BuildConfig
import net.taler.merchantpos.MainViewModel
import net.taler.merchantpos.R
import net.taler.merchantpos.compose.PosOutlinedCard
import net.taler.merchantpos.compose.PosTheme

class TalerDevicesFragment : Fragment()
{
    private val model: MainViewModel by activityViewModels()

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?,
    ): View =
        ComposeView(requireContext()).apply {
            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
            setContent { PosTheme { TalerDevicesScreen(model.talerBluetooth, model.talerUsb) } }
        }
}

internal enum class DeviceStep
{
    Choose,
    Replace,
    Access,
    OpenSettings,
    Pair,
    Find,
    Connect,
    Usb,
    Test,
    Remove,
    RepairAndroid,
    RepairDisplay,
}

@Composable
private fun TalerDevicesScreen(
    bluetooth: TalerBluetoothDevice,
    usb: TalerUsbDevices,
)
{
    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
        Column(
            Modifier
                .widthIn(max = 720.dp)
                .fillMaxWidth()
                .verticalScroll(rememberScrollState())
                .padding(20.dp),
        ) {
            TalerDevicesContent(bluetooth, usb)
        }
    }
}

/** Fixed presentation state for documentation captures; never enables device transports. */
internal data class TalerDevicesPreview(
    val bluetooth: TalerBleState,
    val usb: TalerUsbState = TalerUsbState(),
    val step: DeviceStep? = null,
)

@Composable
internal fun TalerDevicesContent(
    bluetooth: TalerBluetoothDevice,
    usb: TalerUsbDevices,
    preview: TalerDevicesPreview? = null,
)
{
    check(preview == null || BuildConfig.DEBUG)
    LaunchedEffect(bluetooth, usb, preview) {
        if (preview != null) return@LaunchedEffect
        // Connection management replaces the old transport opt-in switches.
        usb.setEnabled(true)
        bluetooth.setEnabled(true)
    }
    val observedBle by bluetooth.state.observeAsState(TalerBleState())
    val observedUsb by usb.state.observeAsState(TalerUsbState())
    val ble = preview?.bluetooth ?: observedBle
    val wired = preview?.usb ?: observedUsb
    var step by rememberSaveable { mutableStateOf(preview?.step) }
    var permissionDenied by rememberSaveable { mutableStateOf(false) }
    var addingBluetooth by rememberSaveable { mutableStateOf(false) }
    var usbCheck by rememberSaveable { mutableLongStateOf(-1L) }

    fun close()
    {
        bluetooth.cancelDiscovery()
        // Cancelling an unfinished new pairing must not reconnect in the background.
        if (addingBluetooth &&
            step == DeviceStep.Connect &&
            ble.status != TalerBleStatus.Ready)
        {
            bluetooth.forget()
        }
        addingBluetooth = false
        step = null
    }

    fun checkUsb()
    {
        usbCheck = wired.checkId
        usb.refresh(forceTransfer = true)
    }

    val permission =
        rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
            permissionDenied = result.values.any { !it }
            bluetooth.refresh()
        }
    val systemSettings =
        rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
            bluetooth.refresh()
        }
    LaunchedEffect(step, ble.status) {
        if (preview != null) return@LaunchedEffect
        if (step == DeviceStep.Access &&
            ble.status !in
            setOf(
                TalerBleStatus.PermissionRequired,
                TalerBleStatus.BluetoothOff,
                TalerBleStatus.Unsupported,
                TalerBleStatus.LocationOff,
            ))
        {
            step = DeviceStep.OpenSettings
        }
    }
    DisposableEffect(bluetooth, preview) { onDispose { if (preview == null) bluetooth.cancelDiscovery() } }
    BackHandler(enabled = step != null) { close() }

    Column(
        modifier = Modifier.fillMaxWidth(),
        verticalArrangement = Arrangement.spacedBy(14.dp),
    ) {
        if (ble.selected == null &&
            wired.devices.isEmpty())
        {
            Text(stringResource(R.string.taler_devices_empty), style = MaterialTheme.typography.bodyLarge)
        }
        ble.selected?.let { selected ->
            DeviceCard(selected.name, stringResource(ble.status.label())) {
                Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                    if (ble.status == TalerBleStatus.Ready)
                    {
                        OutlinedButton(
                            onClick = {
                                bluetooth.testDisplay()
                                step = DeviceStep.Test
                            }
                        ) {
                            Text(stringResource(R.string.taler_ble_test))
                        }
                    }
                    else
                    {
                        OutlinedButton(
                            onClick = {
                                if (ble.status == TalerBleStatus.PairingRequired)
                                {
                                    step = DeviceStep.RepairAndroid
                                }
                                else
                                {
                                    bluetooth.refresh()
                                    step = DeviceStep.Connect
                                }
                            }
                        ) { Text(stringResource(R.string.taler_ble_reconnect)) }
                    }
                    TextButton(
                        onClick = { step = DeviceStep.Remove },
                    ) { Text(stringResource(R.string.taler_devices_remove)) }
                }
            }
        }
        wired.devices.forEachIndexed { index, device ->
            DeviceCard(
                stringResource(R.string.taler_usb_device, index + 1),
                stringResource(usbStatus(device.status)),
            ) {
                OutlinedButton(
                    onClick = {
                        step = DeviceStep.Usb
                        checkUsb()
                    }
                ) {
                    Text(stringResource(R.string.taler_usb_refresh))
                }
            }
        }
        Button(onClick = { step = DeviceStep.Choose }, modifier = Modifier.fillMaxWidth()) {
            Text(stringResource(R.string.taler_devices_add))
        }
        OutlinedButton(
            onClick = {
                step = DeviceStep.Usb
                checkUsb()
            },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text(stringResource(R.string.taler_devices_check_usb))
        }
    }
    val current = step ?: return
    val title =
        when (current)
        {
            DeviceStep.Choose ->
            {
                R.string.taler_devices_add
            }

            DeviceStep.Replace ->
            {
                R.string.taler_devices_replace
            }

            DeviceStep.Access ->
            {
                R.string.taler_devices_access
            }

            DeviceStep.OpenSettings ->
            {
                R.string.taler_devices_open_lcd
            }

            DeviceStep.Pair ->
            {
                R.string.taler_devices_pair_lcd
            }

            DeviceStep.Find ->
            {
                R.string.taler_devices_choose_display
            }

            DeviceStep.Connect ->
            {
                if (ble.status ==
                    TalerBleStatus.Ready)
                {
                    R.string.taler_devices_connected
                }
                else
                {
                    R.string.taler_devices_connect
                }
            }

            DeviceStep.Usb ->
            {
                R.string.taler_devices_check_usb
            }

            DeviceStep.Test ->
            {
                R.string.taler_ble_test
            }

            DeviceStep.Remove ->
            {
                R.string.taler_devices_remove
            }

            DeviceStep.RepairAndroid, DeviceStep.RepairDisplay ->
            {
                R.string.taler_devices_pair_again
            }
        }
    AlertDialog(
        onDismissRequest = { close() },
        title = { Text(stringResource(title)) },
        text = {
            Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
                when (current)
                {
                    DeviceStep.Choose ->
                    {
                        OutlinedButton(
                            onClick = {
                                if (ble.selected != null)
                                {
                                    step = DeviceStep.Replace
                                }
                                else
                                {
                                    bluetooth.refresh()
                                    step = DeviceStep.Access
                                }
                            },
                            modifier = Modifier.fillMaxWidth()
                        ) {
                            Text(
                                stringResource(R.string.taler_devices_bluetooth),
                            )
                        }
                        OutlinedButton(
                            onClick = {
                                step = DeviceStep.Usb
                                checkUsb()
                            },
                            modifier = Modifier.fillMaxWidth()
                        ) {
                            Text(stringResource(R.string.taler_devices_usb))
                        }
                    }

                    DeviceStep.Replace ->
                    {
                        Text(stringResource(R.string.taler_devices_replace_hint))
                    }

                    DeviceStep.Access ->
                    {
                        Text(stringResource(ble.status.label()))
                    }

                    DeviceStep.OpenSettings ->
                    {
                        Text(stringResource(R.string.taler_devices_open_lcd_hint))
                    }

                    DeviceStep.Pair ->
                    {
                        Text(stringResource(R.string.taler_devices_pair_lcd_hint))
                    }

                    DeviceStep.Find ->
                    {
                        Text(
                            stringResource(
                                if (ble.status ==
                                    TalerBleStatus.Disconnected)
                                {
                                    R.string.taler_devices_scan_finished
                                }
                                else
                                {
                                    ble.status.label()
                                },
                            ),
                        )
                        ble.candidates.forEach { candidate ->
                            OutlinedButton(
                                onClick = {
                                    addingBluetooth = true
                                    bluetooth.select(candidate)
                                    step =
                                        DeviceStep.Connect
                                },
                                modifier = Modifier.fillMaxWidth()
                            ) {
                                Text("${candidate.name} ${candidate.address.takeLast(5)}")
                            }
                        }
                    }

                    DeviceStep.Connect ->
                    {
                        Text(
                            stringResource(
                                if (ble.status == TalerBleStatus.Ready)
                                {
                                    R.string.taler_devices_connected_hint
                                }
                                else
                                {
                                    ble.status.label()
                                },
                            ),
                        )
                    }

                    DeviceStep.Usb ->
                    {
                        if (wired.unavailable)
                        {
                            Text(stringResource(R.string.taler_usb_failed))
                        }
                        else if (wired.checkId <= usbCheck)
                        {
                            Text(stringResource(R.string.taler_devices_checking))
                        }
                        else if (wired.devices.isEmpty())
                        {
                            Text(stringResource(R.string.taler_usb_not_attached))
                        }
                        else
                        {
                            wired.devices.forEachIndexed { index, device ->
                                Text(stringResource(R.string.taler_usb_device, index + 1))
                                Text(stringResource(usbStatus(device.status)))
                                if (device.status == TalerUsbStatus.PermissionRequired)
                                {
                                    OutlinedButton(onClick = { usb.requestPermission(device.id) }) {
                                        Text(stringResource(R.string.taler_usb_allow_device, index + 1))
                                    }
                                }
                            }
                        }
                    }

                    DeviceStep.Test ->
                    {
                        Text(
                            stringResource(
                                if (ble.hasResetLimitation)
                                {
                                    R.string.taler_ble_reset_limitation
                                }
                                else
                                {
                                    R.string.taler_devices_test_hint
                                },
                            ),
                        )
                    }

                    DeviceStep.Remove ->
                    {
                        Text(stringResource(R.string.taler_ble_forget_explanation))
                    }

                    DeviceStep.RepairAndroid ->
                    {
                        Text(stringResource(R.string.taler_devices_repair_android))
                    }

                    DeviceStep.RepairDisplay ->
                    {
                        Text(stringResource(R.string.taler_devices_repair_lcd))
                    }
                }
            }
        },
        confirmButton = {
            when (current)
            {
                DeviceStep.Choose ->
                {
                    Unit
                }

                DeviceStep.Replace ->
                {
                    DialogAction(R.string.taler_devices_replace) {
                        bluetooth.forget()
                        bluetooth.refresh()
                        step = DeviceStep.Access
                    }
                }

                DeviceStep.Access, DeviceStep.Find, DeviceStep.Connect ->
                {
                    when (ble.status)
                    {
                        TalerBleStatus.PermissionRequired ->
                        {
                            DialogAction(
                                if (permissionDenied)
                                {
                                    R.string.taler_ble_open_settings
                                }
                                else
                                {
                                    R.string.taler_ble_allow
                                },
                            ) {
                                if (permissionDenied)
                                {
                                    systemSettings.launch(
                                        Intent(
                                            Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
                                            Uri.parse("package:net.taler.merchantpos"),
                                        ),
                                    )
                                }
                                else
                                {
                                    permission.launch(bluetooth.requiredPermissions())
                                }
                            }
                        }

                        TalerBleStatus.BluetoothOff, TalerBleStatus.LocationOff ->
                        {
                            DialogAction(R.string.taler_ble_open_settings) {
                                systemSettings.launch(
                                    Intent(
                                        if (ble.status == TalerBleStatus.LocationOff)
                                        {
                                            Settings.ACTION_LOCATION_SOURCE_SETTINGS
                                        }
                                        else
                                        {
                                            Settings.ACTION_BLUETOOTH_SETTINGS
                                        },
                                    ),
                                )
                            }
                        }

                        TalerBleStatus.Unsupported ->
                        {
                            DialogAction(android.R.string.ok) { close() }
                        }

                        TalerBleStatus.Ready ->
                        {
                            DialogAction(R.string.taler_devices_done) { close() }
                        }

                        TalerBleStatus.PairingRequired ->
                        {
                            DialogAction(R.string.taler_devices_pair_again) {
                                step =
                                    DeviceStep.RepairAndroid
                            }
                        }

                        else ->
                        {
                            if (current == DeviceStep.Find &&
                                ble.status != TalerBleStatus.Scanning)
                            {
                                DialogAction(R.string.taler_devices_search_again) { bluetooth.startScan() }
                            }
                            else if (current == DeviceStep.Connect &&
                                ble.status in setOf(TalerBleStatus.Failed, TalerBleStatus.Disconnected))
                            {
                                DialogAction(R.string.taler_ble_reconnect) { bluetooth.refresh() }
                            }
                        }
                    }
                }

                DeviceStep.OpenSettings ->
                {
                    DialogAction(R.string.taler_devices_next) { step = DeviceStep.Pair }
                }

                DeviceStep.Pair ->
                {
                    DialogAction(R.string.taler_ble_scan) {
                        step = DeviceStep.Find
                        bluetooth.startScan()
                    }
                }

                DeviceStep.Usb ->
                {
                    DialogAction(R.string.taler_usb_refresh) { checkUsb() }
                }

                DeviceStep.Test ->
                {
                    DialogAction(R.string.taler_devices_done) { close() }
                }

                DeviceStep.Remove ->
                {
                    DialogAction(R.string.taler_devices_remove) {
                        bluetooth.forget()
                        step = null
                    }
                }

                DeviceStep.RepairAndroid ->
                {
                    DialogAction(R.string.taler_devices_next) { step = DeviceStep.RepairDisplay }
                }

                DeviceStep.RepairDisplay ->
                {
                    DialogAction(R.string.taler_ble_reconnect) {
                        bluetooth.retryPairing()
                        step =
                            DeviceStep.Connect
                    }
                }
            }
        },
        dismissButton = {
            if (current == DeviceStep.RepairAndroid)
            {
                DialogAction(
                    R.string.taler_ble_open_settings,
                ) { systemSettings.launch(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) }
            }
            else if (current != DeviceStep.Test &&
                !(current == DeviceStep.Connect &&
                    ble.status == TalerBleStatus.Ready))
            {
                DialogAction(
                    if (current ==
                        DeviceStep.Usb)
                    {
                        R.string.taler_devices_done
                    }
                    else
                    {
                        android.R.string.cancel
                    },
                ) { close() }
            }
        },
    )
}

@Composable
private fun DialogAction(
    label: Int,
    action: () -> Unit,
)
{
    TextButton(onClick = action) { Text(stringResource(label)) }
}

@Composable
private fun DeviceCard(
    name: String,
    status: String,
    actions: @Composable () -> Unit,
)
{
    PosOutlinedCard { padding ->
        Column(Modifier.padding(padding), verticalArrangement = Arrangement.spacedBy(8.dp)) {
            Text(name, style = MaterialTheme.typography.titleMedium)
            Text(
                status,
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
            )
            actions()
        }
    }
}

private fun usbStatus(status: TalerUsbStatus): Int =
    when (status)
    {
        TalerUsbStatus.Ready -> R.string.payment_device_status_connected
        TalerUsbStatus.PermissionRequired -> R.string.taler_usb_permission_required
        TalerUsbStatus.Failed -> R.string.taler_usb_failed
    }