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 }