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 }