taler-android

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

MainFragment.kt (19283B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2025 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
     18 
     19 import android.os.Bundle
     20 import android.view.LayoutInflater
     21 import android.view.View
     22 import android.view.ViewGroup
     23 import android.widget.Toast
     24 import androidx.activity.compose.BackHandler
     25 import androidx.appcompat.app.AppCompatActivity
     26 import androidx.compose.animation.core.Animatable
     27 import androidx.compose.foundation.gestures.Orientation
     28 import androidx.compose.foundation.gestures.draggable
     29 import androidx.compose.foundation.gestures.rememberDraggableState
     30 import androidx.compose.foundation.layout.PaddingValues
     31 import androidx.compose.foundation.layout.WindowInsets
     32 import androidx.compose.foundation.layout.WindowInsetsSides
     33 import androidx.compose.foundation.layout.asPaddingValues
     34 import androidx.compose.foundation.layout.offset
     35 import androidx.compose.foundation.layout.only
     36 import androidx.compose.foundation.layout.padding
     37 import androidx.compose.foundation.layout.requiredSize
     38 import androidx.compose.foundation.layout.size
     39 import androidx.compose.foundation.layout.systemBars
     40 import androidx.compose.foundation.shape.CircleShape
     41 import androidx.compose.material.icons.Icons
     42 import androidx.compose.material.icons.filled.BarChart
     43 import androidx.compose.material.icons.filled.Settings
     44 import androidx.compose.material3.ExperimentalMaterial3Api
     45 import androidx.compose.material3.Icon
     46 import androidx.compose.material3.LargeFloatingActionButton
     47 import androidx.compose.material3.ModalBottomSheet
     48 import androidx.compose.material3.NavigationBar
     49 import androidx.compose.material3.NavigationBarItem
     50 import androidx.compose.material3.PlainTooltip
     51 import androidx.compose.material3.Scaffold
     52 import androidx.compose.material3.SheetState
     53 import androidx.compose.material3.Text
     54 import androidx.compose.material3.TooltipBox
     55 import androidx.compose.material3.TooltipDefaults
     56 import androidx.compose.material3.rememberModalBottomSheetState
     57 import androidx.compose.material3.rememberTooltipState
     58 import androidx.compose.runtime.Composable
     59 import androidx.compose.runtime.LaunchedEffect
     60 import androidx.compose.runtime.getValue
     61 import androidx.compose.runtime.livedata.observeAsState
     62 import androidx.compose.runtime.mutableStateOf
     63 import androidx.compose.runtime.remember
     64 import androidx.compose.runtime.saveable.rememberSaveable
     65 import androidx.compose.runtime.setValue
     66 import androidx.compose.ui.Modifier
     67 import androidx.compose.ui.platform.ComposeView
     68 import androidx.compose.ui.platform.LocalContext
     69 import androidx.compose.ui.res.painterResource
     70 import androidx.compose.ui.res.stringResource
     71 import androidx.compose.ui.unit.IntOffset
     72 import androidx.compose.ui.unit.dp
     73 import androidx.core.os.bundleOf
     74 import androidx.fragment.app.Fragment
     75 import androidx.fragment.app.activityViewModels
     76 import androidx.fragment.compose.AndroidFragment
     77 import androidx.fragment.compose.FragmentState
     78 import androidx.fragment.compose.rememberFragmentState
     79 import androidx.navigation.fragment.findNavController
     80 import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG
     81 import com.google.android.material.snackbar.Snackbar
     82 import kotlinx.coroutines.flow.first
     83 import kotlinx.coroutines.runBlocking
     84 import net.taler.wallet.balances.BalanceState
     85 import net.taler.wallet.compose.DemandAttention
     86 import net.taler.wallet.compose.GridMenu
     87 import net.taler.wallet.compose.GridMenuItem
     88 import net.taler.wallet.compose.TalerSurface
     89 import net.taler.wallet.compose.collectAsStateLifecycleAware
     90 import net.taler.wallet.main.MainComposable
     91 import net.taler.wallet.main.ViewMode
     92 import net.taler.wallet.settings.SettingsFragment
     93 import net.taler.wallet.transactions.Transaction
     94 import net.taler.wallet.transactions.TransactionMajorState
     95 import net.taler.wallet.transactions.TransactionPayment
     96 import net.taler.wallet.transactions.TransactionState
     97 import net.taler.wallet.transactions.TransactionStateFilter.Nonfinal
     98 import kotlin.math.roundToInt
     99 
    100 class MainFragment: Fragment() {
    101 
    102     enum class Tab { ASSETS, SETTINGS }
    103 
    104     private val model: MainViewModel by activityViewModels()
    105 
    106     @OptIn(ExperimentalMaterial3Api::class)
    107     override fun onCreateView(
    108         inflater: LayoutInflater,
    109         container: ViewGroup?,
    110         savedInstanceState: Bundle?
    111     ): View = ComposeView(requireContext()).apply {
    112         setContent {
    113             TalerSurface {
    114                 var tab by rememberSaveable { mutableStateOf(Tab.ASSETS) }
    115                 var showSheet by remember { mutableStateOf(false) }
    116                 val sheetState = rememberModalBottomSheetState()
    117 
    118                 val settingsFragmentState = rememberFragmentState()
    119 
    120                 val context = LocalContext.current
    121                 val online by model.networkManager.networkStatus.observeAsState(false)
    122                 val balanceState by model.balanceManager.state.observeAsState(BalanceState.None)
    123                 val viewMode by model.viewMode.collectAsStateLifecycleAware()
    124                 val devMode by model.devMode.observeAsState(false)
    125                 val txResult by remember(viewMode) {
    126                     val v = viewMode as? ViewMode.Transactions
    127                     model.transactionManager.transactionsFlow(v?.selectedScope, stateFilter = v?.stateFilter)
    128                 }.collectAsStateLifecycleAware()
    129                 val actionButtonUsed by remember { model.settingsManager.getActionButtonUsed(context) }.collectAsStateLifecycleAware(true)
    130 
    131                 Scaffold(
    132                     bottomBar = {
    133                         NavigationBar {
    134                             NavigationBarItem(
    135                                 icon = { Icon(Icons.Default.BarChart, contentDescription = null) },
    136                                 label = { Text(stringResource(R.string.assets_title)) },
    137                                 selected = tab == Tab.ASSETS,
    138                                 onClick = {
    139                                     tab = Tab.ASSETS
    140                                     if (viewMode !is ViewMode.Assets)
    141                                         model.showAssets()
    142                                 }
    143                             )
    144 
    145                             TalerActionButton(
    146                                 demandAttention = !actionButtonUsed,
    147                                 onShowSheet = {
    148                                     showSheet = true
    149                                     model.settingsManager.saveActionButtonUsed(context)
    150                                 },
    151                                 onScanQr = {
    152                                     onScanQr()
    153                                     model.settingsManager.saveActionButtonUsed(context)
    154                                 },
    155                             )
    156 
    157                             NavigationBarItem(
    158                                 icon = { Icon(Icons.Default.Settings, contentDescription = null) },
    159                                 label = { Text(stringResource(R.string.menu_settings)) },
    160                                 selected = tab == Tab.SETTINGS,
    161                                 onClick = { tab = Tab.SETTINGS },
    162                             )
    163                         }
    164                     },
    165                     contentWindowInsets = WindowInsets.systemBars.only(
    166                         WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom
    167                     )
    168                 ) { innerPadding ->
    169                     LaunchedEffect(Unit) {
    170                         val viewMode = model.settingsManager.getViewMode(context).first()
    171                         model.setViewMode(viewMode)
    172                     }
    173 
    174                     LaunchedEffect(tab, viewMode) {
    175                         setTitle(tab, viewMode)
    176                     }
    177 
    178                     BackHandler(viewMode !is ViewMode.Assets) {
    179                         model.showAssets()
    180                     }
    181 
    182                     when (tab) {
    183                         Tab.ASSETS -> MainComposable(
    184                             innerPadding = innerPadding,
    185                             state = balanceState,
    186                             txResult = txResult,
    187                             viewMode = viewMode,
    188                             devMode = devMode,
    189                             onGetDemoMoneyClicked = {
    190                                 model.withdrawManager.withdrawTestBalance()
    191                                 Snackbar.make(
    192                                     requireView(),
    193                                     getString(R.string.settings_test_withdrawal),
    194                                     LENGTH_LONG
    195                                 ).show()
    196                             },
    197                             onBalanceClicked = {
    198                                 model.showTransactions(it.scopeInfo)
    199                             },
    200                             onPendingClicked = {
    201                                 model.showTransactions(it.scopeInfo, Nonfinal)
    202                             },
    203                             onTransactionClicked = { tx ->
    204                                 onTransactionClicked(tx)
    205                             },
    206                             onTransactionsDelete = { txIds ->
    207                                 model.transactionManager.deleteTransactions(txIds) { error ->
    208                                     Toast.makeText(context, error.userFacingMsg, Toast.LENGTH_LONG)
    209                                         .show()
    210                                 }
    211                             },
    212                             onShowBalancesClicked = {
    213                                 model.showAssets()
    214                             },
    215                             onStatementClicked = {
    216                                 findNavController().navigate(
    217                                     R.id.nav_donau_statement,
    218                                     bundleOf("host" to it),
    219                                 )
    220                             }
    221                         )
    222                         Tab.SETTINGS -> SettingsView(
    223                             innerPadding = innerPadding,
    224                             settingsFragmentState = settingsFragmentState,
    225                         )
    226                     }
    227                 }
    228 
    229                 val disableActions = remember(balanceState, online) {
    230                     !online || (balanceState as? BalanceState.Success)?.balances?.isEmpty() ?: true
    231                 }
    232 
    233                 val disablePeer = remember(balanceState, viewMode) {
    234                     val selectedScope = (viewMode as? ViewMode.Transactions)?.selectedScope
    235                     val balances = (balanceState as? BalanceState.Success)?.balances
    236                         ?.filter { !it.disablePeerPayments }
    237                         ?: emptyList()
    238                     selectedScope?.let {
    239                         balances.find { it.scopeInfo == selectedScope } == null
    240                     } ?: balances.isEmpty()
    241                 }
    242 
    243                 TalerActionsModal(
    244                     showSheet = showSheet,
    245                     sheetState = sheetState,
    246                     onDismiss = { showSheet = false },
    247                     disableActions = disableActions,
    248                     disablePeer = disablePeer,
    249                     onSend = this@MainFragment::onSend,
    250                     onReceive = this@MainFragment::onReceive,
    251                     onScanQr = this@MainFragment::onScanQr,
    252                     onDeposit = this@MainFragment::onDeposit,
    253                     onWithdraw = this@MainFragment::onWithdraw,
    254                     onEnterUri = this@MainFragment::onEnterUri,
    255                 )
    256             }
    257         }
    258     }
    259 
    260     private fun onTransactionClicked(tx: Transaction) {
    261         val showTxDetails = {
    262             if (tx.detailPageNav != 0) {
    263                 model.transactionManager.selectTransaction(tx)
    264                 findNavController().navigate(tx.detailPageNav)
    265             }
    266         }
    267 
    268         when (tx.txState) {
    269             // unfinished transactions (dialog)
    270             TransactionState(TransactionMajorState.Dialog) -> when (tx) {
    271                 is TransactionPayment -> {
    272                     model.paymentManager.preparePay(tx.transactionId) {
    273                         findNavController().navigate(R.id.action_global_promptPayment)
    274                     }
    275                 }
    276 
    277                 else -> showTxDetails()
    278             }
    279 
    280             else -> showTxDetails()
    281         }
    282     }
    283 
    284     override fun onStart() {
    285         super.onStart()
    286         model.balanceManager.loadAssets(model.viewMode.value is ViewMode.Assets)
    287     }
    288 
    289     private fun setTitle(tab: Tab, viewMode: ViewMode?) {
    290         (requireActivity() as AppCompatActivity).apply {
    291             supportActionBar?.title = when (tab) {
    292                 Tab.ASSETS -> when(viewMode) {
    293                     is ViewMode.Assets -> getString(R.string.assets_title)
    294                     is ViewMode.Transactions -> getString(R.string.transactions_title)
    295                     null -> getString(R.string.loading)
    296                 }
    297 
    298                 Tab.SETTINGS -> getString(R.string.menu_settings)
    299             }
    300         }
    301     }
    302 
    303     private fun onSend() {
    304         findNavController().navigate(R.id.nav_peer_push)
    305     }
    306 
    307     private fun onReceive() {
    308         findNavController().navigate(R.id.nav_peer_pull)
    309     }
    310 
    311     private fun onDeposit() {
    312         findNavController().navigate(R.id.nav_deposit)
    313     }
    314 
    315     private fun onWithdraw() {
    316         model.withdrawManager.resetWithdrawal()
    317         findNavController().navigate(R.id.promptWithdraw)
    318     }
    319 
    320     private fun onScanQr() {
    321         model.scanCode()
    322     }
    323 
    324     private fun onEnterUri() {
    325         findNavController().navigate(R.id.nav_uri_input)
    326     }
    327 }
    328 
    329 @Composable
    330 fun SettingsView(
    331     innerPadding: PaddingValues,
    332     settingsFragmentState: FragmentState,
    333 ) {
    334     AndroidFragment(
    335         SettingsFragment::class.java,
    336         modifier = Modifier.padding(innerPadding),
    337         fragmentState = settingsFragmentState,
    338     )
    339 }
    340 
    341 @Composable
    342 @OptIn(ExperimentalMaterial3Api::class)
    343 private fun TalerActionButton(
    344     demandAttention: Boolean,
    345     onShowSheet: () -> Unit,
    346     onScanQr: () -> Unit,
    347 ) {
    348     val tooltipState = rememberTooltipState()
    349     TooltipBox(
    350         positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
    351         tooltip = { PlainTooltip { Text(stringResource(R.string.actions)) } },
    352         state = tooltipState,
    353     ) {
    354         val offsetY = remember { Animatable(0f) }
    355         var cancelled by remember { mutableStateOf(false) }
    356 
    357         DemandAttention(demandAttention = demandAttention) {
    358             LargeFloatingActionButton(
    359                 modifier = Modifier
    360                     .requiredSize(86.dp)
    361                     .padding(8.dp)
    362                     .offset { IntOffset(0, offsetY.value.roundToInt() / 6) }
    363                     .draggable(
    364                         orientation = Orientation.Vertical,
    365                         state = rememberDraggableState { delta ->
    366                             runBlocking { offsetY.snapTo(offsetY.value + delta) }
    367                             if (delta > 0) {
    368                                 cancelled = true
    369                             }
    370                         },
    371                         onDragStopped = {
    372                             offsetY.animateTo(0.0f)
    373                             if (!cancelled) {
    374                                 onScanQr()
    375                             }
    376                             cancelled = false
    377                         },
    378                     ),
    379                 shape = CircleShape,
    380                 onClick = { onShowSheet() },
    381             ) {
    382                 if (offsetY.value == 0.0f) {
    383                     Icon(
    384                         painterResource(R.drawable.ic_actions),
    385                         modifier = Modifier.size(38.dp),
    386                         contentDescription = stringResource(R.string.actions),
    387                     )
    388                 } else {
    389                     Icon(
    390                         painterResource(R.drawable.ic_scan_qr),
    391                         contentDescription = stringResource(R.string.actions),
    392                     )
    393                 }
    394             }
    395         }
    396     }
    397 
    398     LaunchedEffect(demandAttention) {
    399         if (demandAttention) {
    400             tooltipState.show()
    401         }
    402     }
    403 }
    404 
    405 @OptIn(ExperimentalMaterial3Api::class)
    406 @Composable
    407 fun TalerActionsModal(
    408     showSheet: Boolean,
    409     sheetState: SheetState,
    410     disableActions: Boolean,
    411     disablePeer: Boolean,
    412     onDismiss: () -> Unit,
    413     onSend: () -> Unit,
    414     onReceive: () -> Unit,
    415     onScanQr: () -> Unit,
    416     onDeposit: () -> Unit,
    417     onWithdraw: () -> Unit,
    418     onEnterUri: () -> Unit,
    419 ) {
    420     if (showSheet) {
    421         ModalBottomSheet(
    422             onDismissRequest = onDismiss,
    423             sheetState = sheetState,
    424         ) {
    425             GridMenu(
    426                 contentPadding = PaddingValues(
    427                     start = 8.dp,
    428                     end = 8.dp,
    429                     bottom = 16.dp + WindowInsets
    430                         .systemBars
    431                         .asPaddingValues()
    432                         .calculateBottomPadding(),
    433                 ),
    434             ) {
    435                 GridMenuItem(
    436                     icon = R.drawable.ic_link,
    437                     title = R.string.enter_uri,
    438                     onClick = { onEnterUri(); onDismiss() },
    439                 )
    440 
    441                 GridMenuItem(
    442                     icon = R.drawable.transaction_deposit,
    443                     title = R.string.send_deposit_button_label,
    444                     onClick = { onDeposit(); onDismiss() },
    445                     enabled = !disableActions
    446                 )
    447 
    448                 GridMenuItem(
    449                     icon = R.drawable.ic_scan_qr,
    450                     title = R.string.button_scan_qr_code_label,
    451                     onClick = { onScanQr(); onDismiss() },
    452                 )
    453 
    454                 GridMenuItem(
    455                     icon = R.drawable.transaction_p2p_incoming,
    456                     title = R.string.transactions_receive_funds,
    457                     onClick = { onReceive(); onDismiss() },
    458                     enabled = !disableActions && !disablePeer,
    459                 )
    460 
    461                 GridMenuItem(
    462                     icon = R.drawable.transaction_withdrawal,
    463                     title = R.string.withdraw_button_label,
    464                     onClick = { onWithdraw(); onDismiss() },
    465                     enabled = !disableActions,
    466                 )
    467 
    468                 GridMenuItem(
    469                     icon = R.drawable.transaction_p2p_outgoing,
    470                     title = R.string.transactions_send_funds,
    471                     onClick = { onSend(); onDismiss() },
    472                     enabled = !disableActions && !disablePeer,
    473                 )
    474             }
    475         }
    476     }
    477 }