taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

MainScreen.kt (21284B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2026 Taler Systems S.A.
      4  *
      5  * GNU Taler is free software; you can redistribute it and/or modify it under the
      6  * terms of the GNU General Public License as published by the Free Software
      7  * Foundation; either version 3, or (at your option) any later version.
      8  *
      9  * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
     10  * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11  * A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
     12  *
     13  * You should have received a copy of the GNU General Public License along with
     14  * GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15  */
     16 
     17 package net.taler.wallet.main
     18 
     19 import android.content.ClipboardManager
     20 import androidx.activity.compose.BackHandler
     21 import androidx.compose.animation.AnimatedContent
     22 import androidx.compose.foundation.layout.fillMaxSize
     23 import androidx.compose.foundation.layout.fillMaxWidth
     24 import androidx.compose.foundation.text.KeyboardOptions
     25 import androidx.compose.material.icons.Icons
     26 import androidx.compose.material.icons.filled.BarChart
     27 import androidx.compose.material.icons.filled.ContentPaste
     28 import androidx.compose.material.icons.filled.Delete
     29 import androidx.compose.material.icons.filled.SelectAll
     30 import androidx.compose.material.icons.filled.Settings
     31 import androidx.compose.material.icons.rounded.Close
     32 import androidx.compose.material3.AlertDialog
     33 import androidx.compose.material3.Button
     34 import androidx.compose.material3.ExperimentalMaterial3Api
     35 import androidx.compose.material3.Icon
     36 import androidx.compose.material3.IconButton
     37 import androidx.compose.material3.NavigationBar
     38 import androidx.compose.material3.NavigationBarItem
     39 import androidx.compose.material3.OutlinedTextField
     40 import androidx.compose.material3.SnackbarHost
     41 import androidx.compose.material3.SnackbarHostState
     42 import androidx.compose.material3.Text
     43 import androidx.compose.material3.TextButton
     44 import androidx.compose.material3.rememberModalBottomSheetState
     45 import androidx.compose.runtime.Composable
     46 import androidx.compose.runtime.LaunchedEffect
     47 import androidx.compose.runtime.getValue
     48 import androidx.compose.runtime.livedata.observeAsState
     49 import androidx.compose.runtime.mutableStateListOf
     50 import androidx.compose.runtime.mutableStateOf
     51 import androidx.compose.runtime.remember
     52 import androidx.compose.runtime.rememberCoroutineScope
     53 import androidx.compose.runtime.saveable.rememberSaveable
     54 import androidx.compose.runtime.setValue
     55 import androidx.compose.ui.Modifier
     56 import androidx.compose.ui.focus.FocusRequester
     57 import androidx.compose.ui.focus.focusRequester
     58 import androidx.compose.ui.platform.LocalContext
     59 import androidx.compose.ui.res.stringResource
     60 import androidx.compose.ui.text.input.ImeAction
     61 import androidx.core.content.getSystemService
     62 import kotlinx.coroutines.flow.first
     63 import kotlinx.coroutines.launch
     64 import net.taler.wallet.NavigateCallback
     65 import net.taler.wallet.R
     66 import net.taler.wallet.WalletDestination
     67 import net.taler.wallet.backend.TalerErrorInfo
     68 import net.taler.wallet.balances.BalanceState
     69 import net.taler.wallet.balances.BalancesComposable
     70 import net.taler.wallet.compose.GlobalScaffold
     71 import net.taler.wallet.compose.collectAsStateLifecycleAware
     72 import net.taler.wallet.settings.SettingsScreen
     73 import net.taler.wallet.transactions.Transaction
     74 import net.taler.wallet.transactions.TransactionMajorState
     75 import net.taler.wallet.transactions.TransactionPayment
     76 import net.taler.wallet.transactions.TransactionPeerPullDebit
     77 import net.taler.wallet.transactions.TransactionPeerPushCredit
     78 import net.taler.wallet.transactions.TransactionState
     79 import net.taler.wallet.transactions.TransactionStateFilter.Nonfinal
     80 import net.taler.wallet.transactions.TransactionsComposable
     81 import net.taler.wallet.transactions.TransactionsResult
     82 
     83 enum class MainTab { ASSETS, SETTINGS }
     84 
     85 @OptIn(ExperimentalMaterial3Api::class)
     86 @Composable
     87 fun MainScreen(
     88     model: MainViewModel,
     89     onNavigate: NavigateCallback,
     90     onScanQr: () -> Unit,
     91     onFulfillPayment: (url: String) -> Unit,
     92     onShowError: (TalerErrorInfo) -> Unit,
     93 ) {
     94     var tab by rememberSaveable { mutableStateOf(MainTab.ASSETS) }
     95     var showUriInput by remember { mutableStateOf(false) }
     96     var showSheet by rememberSaveable { mutableStateOf(false) }
     97     val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
     98     val scope = rememberCoroutineScope()
     99     val snackbarHostState = remember { SnackbarHostState() }
    100 
    101     var selectionMode by remember { mutableStateOf(false) }
    102     val selectedItems = remember { mutableStateListOf<String>() }
    103     var showDeleteDialog by remember { mutableStateOf(false) }
    104 
    105     val context = LocalContext.current
    106     val testWithdrawalMessage = stringResource(R.string.settings_test_withdrawal)
    107     val online by model.networkManager.networkStatus.observeAsState(false)
    108     val networkStatus by model.networkManager.networkStatus.observeAsState(false)
    109     val balanceState by model.balanceManager.state.observeAsState(BalanceState.None)
    110     val viewMode by model.viewMode.collectAsStateLifecycleAware()
    111     val devMode by model.devMode.observeAsState(false)
    112     val txResult by remember(viewMode) {
    113         val v = viewMode as? ViewMode.Transactions
    114         model.transactionManager.transactionsFlow(v?.selectedScope, stateFilter = v?.stateFilter)
    115     }.collectAsStateLifecycleAware()
    116     val actionButtonUsed by remember {
    117         model.settingsManager.getActionButtonUsed(context)
    118     }.collectAsStateLifecycleAware(true)
    119 
    120     if (showUriInput) UriInputDialog(
    121         onDismiss = { showUriInput = false },
    122         handleUri = { uri ->
    123             onNavigate(WalletDestination.HandleUri(uri), true)
    124         },
    125     )
    126 
    127     val tabTitle = when (tab) {
    128         MainTab.ASSETS -> when (viewMode) {
    129             is ViewMode.Transactions -> if (selectionMode) {
    130                 stringResource(R.string.selection_count, selectedItems.size)
    131             } else stringResource(R.string.transactions_title)
    132 
    133             ViewMode.Assets -> if (!balanceState.showWelcome()) {
    134                 stringResource(R.string.balances_title)
    135             } else null
    136         }
    137 
    138         MainTab.SETTINGS -> stringResource(R.string.menu_settings)
    139     }
    140 
    141     GlobalScaffold(
    142         model = model,
    143         modifier = Modifier.fillMaxSize(),
    144         title = tabTitle?.let { { Text(it) } },
    145         navigationIcon = if (selectionMode) {
    146             {
    147                 IconButton(onClick = {
    148                     selectionMode = false
    149                     selectedItems.clear()
    150                 }) {
    151                     Icon(
    152                         imageVector = Icons.Rounded.Close,
    153                         contentDescription = null,
    154                     )
    155                 }
    156             }
    157         } else null,
    158         actions = {
    159             if (selectionMode && txResult is TransactionsResult.Success) {
    160                 IconButton(onClick = {
    161                     selectedItems.clear()
    162                     val successResult = txResult as TransactionsResult.Success
    163                     selectedItems.addAll(successResult.transactions.map { it.transactionId })
    164                 }) {
    165                     Icon(
    166                         imageVector = Icons.Default.SelectAll,
    167                         contentDescription = stringResource(R.string.transactions_select_all),
    168                     )
    169                 }
    170                 IconButton(onClick = {
    171                     showDeleteDialog = true
    172                 }) {
    173                     Icon(
    174                         imageVector = Icons.Default.Delete,
    175                         contentDescription = stringResource(R.string.transactions_delete),
    176                     )
    177                 }
    178             }
    179         },
    180         bottomBar = {
    181             NavigationBar {
    182                 NavigationBarItem(
    183                     icon = { Icon(Icons.Default.BarChart, contentDescription = null) },
    184                     label = { Text(stringResource(R.string.balances_title)) },
    185                     selected = tab == MainTab.ASSETS,
    186                     onClick = {
    187                         tab = MainTab.ASSETS
    188                         if (viewMode !is ViewMode.Assets)
    189                             model.showAssets()
    190                     }
    191                 )
    192 
    193                 TalerActionButton(
    194                     demandAttention = !actionButtonUsed,
    195                     onShowSheet = {
    196                         showSheet = true
    197                     },
    198                     onScanQr = {
    199                         model.settingsManager.saveActionButtonUsed(context)
    200                         onScanQr()
    201                     },
    202                 )
    203 
    204                 NavigationBarItem(
    205                     icon = { Icon(Icons.Default.Settings, contentDescription = null) },
    206                     label = { Text(stringResource(R.string.menu_settings)) },
    207                     selected = tab == MainTab.SETTINGS,
    208                     onClick = { tab = MainTab.SETTINGS },
    209                 )
    210             }
    211         },
    212         snackbarHost = { SnackbarHost(snackbarHostState) },
    213     ) { innerPadding ->
    214         LaunchedEffect(Unit) {
    215             model.balanceManager.loadAssets(model.viewMode.value is ViewMode.Assets)
    216         }
    217 
    218         LaunchedEffect(Unit) {
    219             val viewMode = model.settingsManager.getViewMode(context).first()
    220             model.setViewMode(viewMode)
    221         }
    222 
    223         LaunchedEffect(viewMode) {
    224             model.settingsManager.saveViewMode(context, viewMode)
    225         }
    226 
    227         BackHandler(selectionMode || (tab == MainTab.ASSETS && viewMode !is ViewMode.Assets)) {
    228             if (selectionMode) {
    229                 selectionMode = false
    230                 selectedItems.clear()
    231             } else {
    232                 model.showAssets()
    233             }
    234         }
    235 
    236         if (showDeleteDialog) AlertDialog(
    237             title = { Text(stringResource(R.string.transactions_delete_selected_dialog_title)) },
    238             text = { Text(stringResource(R.string.transactions_delete_selected_dialog_message)) },
    239             onDismissRequest = { showDeleteDialog = false },
    240             confirmButton = {
    241                 TextButton(onClick = {
    242                     model.transactionManager.deleteTransactions(selectedItems) { error ->
    243                         onShowError(error)
    244                     }
    245                     showDeleteDialog = false
    246                     selectionMode = false
    247                     selectedItems.clear()
    248                 }) {
    249                     Text(stringResource(R.string.transactions_delete))
    250                 }
    251             },
    252             dismissButton = {
    253                 TextButton(onClick = {
    254                     showDeleteDialog = false
    255                 }) {
    256                     Text(stringResource(R.string.cancel))
    257                 }
    258             },
    259         )
    260 
    261         when (tab) {
    262             MainTab.ASSETS -> AnimatedContent(
    263                 targetState = viewMode,
    264                 modifier = Modifier.fillMaxSize(),
    265                 label = "MainViewMode",
    266             ) { targetViewMode ->
    267                 when (targetViewMode) {
    268                     is ViewMode.Assets -> BalancesComposable(
    269                         innerPadding = innerPadding,
    270                         state = balanceState,
    271                         devMode = devMode,
    272                         networkStatus = networkStatus,
    273                         onWithdrawMoneyClicked = {
    274                             onNavigate(
    275                                 WalletDestination.HandleUri("taler://withdraw-exchange/exchange.taler-ops.ch/"),
    276                                 true,
    277                             )
    278                         },
    279                         onGetDemoMoneyClicked = {
    280                             model.withdrawManager.withdrawTestBalance()
    281                             scope.launch {
    282                                 snackbarHostState.showSnackbar(testWithdrawalMessage)
    283                             }
    284                         },
    285                         onBalanceClicked = {
    286                             model.showTransactions(it.scopeInfo)
    287                         },
    288                         onPendingClicked = {
    289                             model.showTransactions(it.scopeInfo, Nonfinal)
    290                         },
    291                         onStatementClicked = {
    292                             onNavigate(WalletDestination.DonauStatement(it), false)
    293                         },
    294                         onShowDiscounts = {
    295                             onNavigate(WalletDestination.DiscountList, false)
    296                         },
    297                         onShowPasses = {
    298                             onNavigate(WalletDestination.PassList, false)
    299                         },
    300                     )
    301 
    302                     is ViewMode.Transactions -> {
    303                         val balance = remember(balanceState, targetViewMode) {
    304                             (balanceState as? BalanceState.Success)?.balances?.find {
    305                                 it.scopeInfo == targetViewMode.selectedScope
    306                             }
    307                         }
    308 
    309                         if (balance != null) TransactionsComposable(
    310                             innerPadding = innerPadding,
    311                             viewMode = targetViewMode,
    312                             balance = balance,
    313                             txResult = txResult,
    314                             selectionMode = selectionMode,
    315                             selectedItems = selectedItems,
    316                             onTransactionClick = { tx ->
    317                                 onTransactionClicked(tx, model, onNavigate)
    318                             },
    319                             onShowBalancesClicked = {
    320                                 model.showAssets()
    321                             },
    322                             onToggleSelection = { txId ->
    323                                 if (selectedItems.contains(txId)) {
    324                                     selectedItems.remove(txId)
    325                                     if (selectedItems.isEmpty()) selectionMode = false
    326                                 } else {
    327                                     selectionMode = true
    328                                     selectedItems.add(txId)
    329                                 }
    330                             },
    331                         )
    332                     }
    333                 }
    334             }
    335 
    336             MainTab.SETTINGS -> SettingsScreen(
    337                 model = model,
    338                 innerPadding = innerPadding,
    339                 snackbarHostState = snackbarHostState,
    340                 onNavigate = onNavigate,
    341                 onShowError = onShowError,
    342             )
    343         }
    344 
    345         val disableActions = remember(balanceState, online) {
    346             !online || (balanceState as? BalanceState.Success)?.balances?.isEmpty() ?: true
    347         }
    348 
    349         val selectedScope = (viewMode as? ViewMode.Transactions)?.selectedScope
    350 
    351         val selectedBalance = remember(balanceState, selectedScope) {
    352             val balances = (balanceState as? BalanceState.Success)?.balances
    353             selectedScope?.let {
    354                 balances?.find { it.scopeInfo == selectedScope }
    355             }
    356         }
    357 
    358         TalerActionsModal(
    359             showSheet = showSheet,
    360             sheetState = sheetState,
    361             selectedCurrency = selectedBalance?.currency,
    362             showShopping = selectedBalance?.shoppingUrls?.isNotEmpty() == true,
    363             onDismiss = {
    364                 showSheet = false
    365             },
    366             disableActions = disableActions,
    367             disablePeer = selectedBalance?.disablePeerPayments == true,
    368             onSend = {
    369                 showSheet = false
    370                 model.settingsManager.saveActionButtonUsed(context)
    371                 onNavigate(WalletDestination.OutgoingPush, true)
    372             },
    373             onReceive = {
    374                 showSheet = false
    375                 model.settingsManager.saveActionButtonUsed(context)
    376                 onNavigate(WalletDestination.OutgoingPull, true)
    377             },
    378             onScanQr = {
    379                 showSheet = false
    380                 model.settingsManager.saveActionButtonUsed(context)
    381                 onScanQr()
    382             },
    383             onDeposit = {
    384                 showSheet = false
    385                 model.settingsManager.saveActionButtonUsed(context)
    386                 onNavigate(WalletDestination.Deposit(), true)
    387             },
    388             onWithdraw = {
    389                 showSheet = false
    390                 model.settingsManager.saveActionButtonUsed(context)
    391                 model.withdrawManager.resetWithdrawal()
    392                 onNavigate(WalletDestination.PromptWithdraw(), true)
    393             },
    394             onEnterUri = {
    395                 showSheet = false
    396                 model.settingsManager.saveActionButtonUsed(context)
    397                 showUriInput = true
    398             },
    399             onShoppingDiscovery = {
    400                 showSheet = false
    401                 model.settingsManager.saveActionButtonUsed(context)
    402                 val shoppingUrls = selectedBalance?.shoppingUrls ?: emptyList()
    403                 if (shoppingUrls.size == 1) {
    404                     onFulfillPayment(shoppingUrls[0])
    405                 } else onNavigate(WalletDestination.ExchangeShopping, false)
    406             }
    407         )
    408     }
    409 }
    410 
    411 @Composable
    412 fun UriInputDialog(
    413     onDismiss: () -> Unit,
    414     handleUri: (String) -> Unit,
    415 ) {
    416     val context = LocalContext.current
    417     val invalidUriError = stringResource(R.string.uri_invalid)
    418     val focusRequester = remember { FocusRequester() }
    419     var uriText by remember { mutableStateOf("") }
    420     var error by remember { mutableStateOf<String?>(null) }
    421     val clipboard = context.getSystemService<ClipboardManager>()
    422 
    423     val isValidTalerUri = { uri: String ->
    424         uri.trim().startsWith("taler://", ignoreCase = true) ||
    425                 uri.trim().startsWith("taler+http://", ignoreCase = true) ||
    426                 uri.trim().startsWith("payto://", ignoreCase = true)
    427     }
    428 
    429     val getClipboardContents = {
    430         val item = clipboard?.primaryClip?.getItemAt(0)
    431         if (item?.text != null) {
    432             item.text.toString().trim()
    433         } else if (item?.uri != null) {
    434             item.uri.toString().trim()
    435         } else {
    436             null
    437         }
    438     }
    439 
    440     AlertDialog(
    441         title = { Text(stringResource(R.string.enter_uri)) },
    442         text = {
    443             OutlinedTextField(
    444                 value = uriText,
    445                 onValueChange = {
    446                     uriText = it
    447                     error = null
    448                 },
    449                 modifier = Modifier
    450                     .fillMaxWidth()
    451                     .focusRequester(focusRequester),
    452                 label = { Text(stringResource(R.string.enter_uri_label)) },
    453                 isError = error != null,
    454                 supportingText = error?.let { { Text(it) } },
    455                 placeholder = { Text(stringResource(R.string.enter_uri_prefix)) },
    456                 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
    457                 trailingIcon = {
    458                     IconButton(onClick = {
    459                         getClipboardContents()?.let { uriText = it }
    460                     }) {
    461                         Icon(
    462                             Icons.Default.ContentPaste,
    463                             contentDescription = stringResource(R.string.paste),
    464                         )
    465                     }
    466                 },
    467             )
    468         },
    469         onDismissRequest = onDismiss,
    470         confirmButton = {
    471             Button(onClick = {
    472                 if (isValidTalerUri(uriText)) {
    473                     handleUri(uriText.trim())
    474                     onDismiss()
    475                 } else {
    476                     error = invalidUriError
    477                 }
    478             }) {
    479                 Text(stringResource(R.string.ok))
    480             }
    481         },
    482         dismissButton = {
    483             TextButton(onDismiss) {
    484                 Text(stringResource(R.string.cancel))
    485             }
    486         },
    487     )
    488 
    489     LaunchedEffect(Unit) {
    490         focusRequester.requestFocus()
    491         getClipboardContents()?.let { uri ->
    492             if (isValidTalerUri(uri)) {
    493                 uriText = uri
    494             }
    495         }
    496     }
    497 }
    498 
    499 private fun onTransactionClicked(
    500     tx: Transaction,
    501     model: MainViewModel,
    502     onNavigate: NavigateCallback,
    503 ) {
    504     val showTxDetails = {
    505         model.transactionManager.selectTransaction(tx).invokeOnCompletion {
    506             onNavigate(tx.detailPageNav, false)
    507         }
    508     }
    509 
    510     when (tx.txState) {
    511         // unfinished transactions (dialog)
    512         TransactionState(TransactionMajorState.Dialog) -> when (tx) {
    513 //            is TransactionPayment -> {
    514 //                model.paymentManager.preparePay(tx.transactionId) {}
    515 //                onNavigate(WalletDestination.PromptPayment, true)
    516 //            }
    517 
    518             is TransactionPeerPushCredit -> {
    519                 model.peerManager.preparePeerPushCredit(transactionId = tx.transactionId)
    520                 onNavigate(WalletDestination.PromptPushPayment, true)
    521             }
    522 
    523             is TransactionPeerPullDebit -> {
    524                 model.peerManager.preparePeerPullDebit(transactionId = tx.transactionId)
    525                 onNavigate(WalletDestination.PromptPullPayment, true)
    526             }
    527 
    528             else -> showTxDetails()
    529         }
    530 
    531         else -> showTxDetails()
    532     }
    533 }