commit f19571c9cbacc38789490d3c33c77ea2d26a595d parent a6dee98a4bb2aa0e2d6da514e7d35800a8e3fd31 Author: Iván Ávalos <avalos@disroot.org> Date: Thu, 10 Oct 2024 17:53:16 +0200 [wallet] WIP: unified UI/UX Diffstat:
39 files changed, 1246 insertions(+), 1169 deletions(-)
diff --git a/wallet/build.gradle b/wallet/build.gradle @@ -143,6 +143,8 @@ dependencies { implementation "com.google.accompanist:accompanist-themeadapter-material3:0.28.0" implementation 'androidx.activity:activity-compose:1.9.1' implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.ui:ui-viewbinding' + implementation "androidx.fragment:fragment-compose:1.8.4" debugImplementation 'androidx.compose.ui:ui-tooling' // Lists and Selection diff --git a/wallet/src/main/java/net/taler/wallet/HandleUriFragment.kt b/wallet/src/main/java/net/taler/wallet/HandleUriFragment.kt @@ -119,8 +119,9 @@ class HandleUriFragment: Fragment() { action.startsWith("withdraw/", ignoreCase = true) -> { Log.v(TAG, "navigating!") // there's more than one entry point, so use global action - findNavController().navigate(R.id.action_handleUri_to_promptWithdraw) model.withdrawManager.getWithdrawalDetails(u2) + val args = bundleOf("editableCurrency" to false) + findNavController().navigate(R.id.action_handleUri_to_promptWithdraw, args) } action.startsWith("withdraw-exchange/", ignoreCase = true) -> { diff --git a/wallet/src/main/java/net/taler/wallet/MainActivity.kt b/wallet/src/main/java/net/taler/wallet/MainActivity.kt @@ -28,20 +28,17 @@ import android.view.Menu import android.view.MenuItem import android.view.View.GONE import android.view.View.VISIBLE -import android.widget.TextView import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.os.bundleOf import androidx.core.view.GravityCompat.START import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.setupWithNavController import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat.OnPreferenceStartFragmentCallback import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.navigation.NavigationView.OnNavigationItemSelectedListener import com.google.zxing.client.android.Intents.Scan.MIXED_SCAN import com.google.zxing.client.android.Intents.Scan.SCAN_TYPE import com.journeyapps.barcodescanner.ScanContract @@ -49,16 +46,12 @@ import com.journeyapps.barcodescanner.ScanOptions import com.journeyapps.barcodescanner.ScanOptions.QR_CODE import net.taler.common.EventObserver import net.taler.lib.android.TalerNfcService -import net.taler.wallet.BuildConfig.VERSION_CODE -import net.taler.wallet.BuildConfig.VERSION_NAME import net.taler.wallet.databinding.ActivityMainBinding import net.taler.wallet.events.ObservabilityDialog import net.taler.wallet.transactions.TransactionPeerPullCredit import net.taler.wallet.transactions.TransactionPeerPushDebit -class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener, - OnPreferenceStartFragmentCallback { - +class MainActivity : AppCompatActivity(), OnPreferenceStartFragmentCallback { private val model: MainViewModel by viewModels() private lateinit var ui: ActivityMainBinding @@ -81,33 +74,15 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener, val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment nav = navHostFragment.navController - ui.navView.setupWithNavController(nav) - ui.navView.setNavigationItemSelectedListener(this) - if (savedInstanceState == null) { - ui.navView.menu.getItem(0).isChecked = true - } setSupportActionBar(ui.content.toolbar) - val appBarConfiguration = AppBarConfiguration( - setOf(R.id.nav_main, R.id.nav_settings), - ui.drawerLayout - ) - ui.content.toolbar.setupWithNavController(nav, appBarConfiguration) + ui.content.toolbar.setupWithNavController(nav) // TODO: refactor and unify progress bar handling // model.showProgressBar.observe(this) { show -> // ui.content.progressBar.visibility = if (show) VISIBLE else INVISIBLE // } - val versionView: TextView = ui.navView.getHeaderView(0).findViewById(R.id.versionView) - @SuppressLint("SetTextI18n") - versionView.text = "$VERSION_NAME ($VERSION_CODE)" - - // Uncomment if any dev options are added in the future - // model.devMode.observe(this) { enabled -> - // ui.navView.menu.findItem(R.id.nav_dev).isVisible = enabled - // } - handleIntents(intent) model.transactionManager.selectedTransaction.observe(this) { tx -> @@ -147,7 +122,13 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener, @Deprecated("Deprecated in Java") override fun onBackPressed() { if (ui.drawerLayout.isDrawerOpen(START)) ui.drawerLayout.closeDrawer(START) - else super.onBackPressed() + else if (nav.currentDestination?.id == R.id.nav_main) { + if (model.transactionManager.selectedScope.value != null) { + model.transactionManager.selectedScope.value = null + } + } else { + super.onBackPressed() + } } override fun onNewIntent(intent: Intent) { @@ -190,15 +171,6 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener, return super.onCreateOptionsMenu(menu) } - override fun onNavigationItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.nav_home -> nav.navigate(R.id.nav_main) - R.id.nav_settings -> nav.navigate(R.id.nav_settings) - } - ui.drawerLayout.closeDrawer(START) - return true - } - override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_show_logs -> { @@ -237,7 +209,7 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener, pref: Preference, ): Boolean { when (pref.key) { - "pref_exchanges" -> nav.navigate(R.id.action_nav_settings_to_nav_settings_exchanges) + "pref_exchanges" -> nav.navigate(R.id.nav_settings_exchanges) } return true } diff --git a/wallet/src/main/java/net/taler/wallet/MainFragment.kt b/wallet/src/main/java/net/taler/wallet/MainFragment.kt @@ -1,6 +1,6 @@ /* * This file is part of GNU Taler - * (C) 2020 Taler Systems S.A. + * (C) 2024 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 @@ -20,54 +20,167 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BarChart +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LargeFloatingActionButton +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.fragment.compose.AndroidFragment +import androidx.fragment.compose.FragmentState +import androidx.fragment.compose.rememberFragmentState import androidx.navigation.fragment.findNavController -import net.taler.common.EventObserver -import net.taler.wallet.ScopeMode.MULTI -import net.taler.wallet.ScopeMode.SINGLE import net.taler.wallet.balances.BalanceState -import net.taler.wallet.balances.BalanceState.Success import net.taler.wallet.balances.BalancesFragment -import net.taler.wallet.databinding.FragmentMainBinding +import net.taler.wallet.balances.ScopeInfo +import net.taler.wallet.compose.DemandAttention +import net.taler.wallet.compose.GridMenu +import net.taler.wallet.compose.GridMenuItem +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.settings.SettingsFragment import net.taler.wallet.transactions.TransactionsFragment +import net.taler.wallet.withdraw.WithdrawalError -enum class ScopeMode { SINGLE, MULTI } +class MainFragment: Fragment() { -class MainFragment : Fragment() { + enum class Tab { BALANCES, SETTINGS } private val model: MainViewModel by activityViewModels() - private var scopeMode: ScopeMode? = null - - private lateinit var ui: FragmentMainBinding + @OptIn(ExperimentalMaterial3Api::class) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - ui = FragmentMainBinding.inflate(inflater, container, false) - return ui.root - } + savedInstanceState: Bundle? + ): View = ComposeView(requireContext()).apply { + setContent { + TalerSurface { + var selectedTab by rememberSaveable { mutableStateOf(Tab.BALANCES) } + var showSheet by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState() - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - model.balanceManager.state.observe(viewLifecycleOwner) { - onBalancesChanged(it) - } - model.transactionsEvent.observe(viewLifecycleOwner, EventObserver { scopeInfo -> - // we only need to navigate to a dedicated list, when in multi-scope mode - if (scopeMode == MULTI) { - model.transactionManager.selectedScope = scopeInfo - findNavController().navigate(R.id.action_nav_main_to_nav_transactions) - } - }) + val balancesFragmentState = rememberFragmentState() + val transactionsFragmentState = rememberFragmentState() + val settingsFragmentState = rememberFragmentState() - ui.mainFab.setOnClickListener { - model.scanCode() - } - ui.mainFab.setOnLongClickListener { - findNavController().navigate(R.id.action_nav_main_to_nav_uri_input) - true + Scaffold( + bottomBar = { + NavigationBar { + NavigationBarItem( + icon = { Icon(Icons.Default.BarChart, contentDescription = null) }, + label = { Text(stringResource(R.string.balances_title)) }, + selected = selectedTab == Tab.BALANCES, + onClick = { selectedTab = Tab.BALANCES }, + ) + + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(stringResource(R.string.actions)) } }, + state = rememberTooltipState(), + ) { + DemandAttention { + LargeFloatingActionButton( + modifier = Modifier + .requiredSize(86.dp) + .padding(8.dp) + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { }, + onDragStopped = { onScanQr() }, + ), + shape = CircleShape, + onClick = { showSheet = true }, + ) { + Icon( + painterResource(R.drawable.ic_actions), + modifier = Modifier.size(38.dp), + contentDescription = stringResource(R.string.actions), + ) + } + } + } + + NavigationBarItem( + icon = { Icon(Icons.Default.Settings, contentDescription = null) }, + label = { Text(stringResource(R.string.menu_settings)) }, + selected = selectedTab == Tab.SETTINGS, + onClick = { selectedTab = Tab.SETTINGS }, + ) + } + } + ) { innerPadding -> + val balanceState by model.balanceManager.state.observeAsState(BalanceState.None) + val selectedScope by model.transactionManager.selectedScope.observeAsState() + Box( + Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + when (selectedTab) { + Tab.BALANCES -> BalancesView( + state = balanceState, + selectedScope = selectedScope, + balancesFragmentState = balancesFragmentState, + transactionsFragmentState = transactionsFragmentState, + ) + Tab.SETTINGS -> SettingsView( + settingsFragmentState = settingsFragmentState, + ) + } + } + } + + TalerActionsModal( + showSheet = showSheet, + sheetState = sheetState, + onDismiss = { showSheet = false }, + onSend = this@MainFragment::onSend, + onReceive = this@MainFragment::onReceive, + onScanQr = this@MainFragment::onScanQr, + onDeposit = this@MainFragment::onDeposit, + onWithdraw = this@MainFragment::onWithdraw, + onEnterUri = this@MainFragment::onEnterUri, + ) + } } } @@ -76,22 +189,133 @@ class MainFragment : Fragment() { model.balanceManager.loadBalances() } - private fun onBalancesChanged(state: BalanceState) { - if (state !is Success) return - val balances = state.balances - val mode = if (balances.size == 1) SINGLE else MULTI - if (scopeMode != mode) { - val f = if (mode == SINGLE) { - model.transactionManager.selectedScope = balances[0].scopeInfo - TransactionsFragment() - } else { - BalancesFragment() - } - scopeMode = mode - childFragmentManager.beginTransaction() - .replace(R.id.mainFragmentContainer, f, mode.name) - .commitNow() + private fun onSend() { + findNavController().navigate(R.id.nav_peer_push) + } + + private fun onReceive() { + findNavController().navigate(R.id.nav_peer_pull) + } + + private fun onDeposit() { + findNavController().navigate(R.id.nav_deposit) + } + + private fun onWithdraw() { + model.withdrawManager.resetWithdrawal() + findNavController().navigate(R.id.promptWithdraw) + } + + private fun onScanQr() { + model.scanCode() + } + + private fun onEnterUri() { + findNavController().navigate(R.id.nav_uri_input) + } +} + +@Composable +fun BalancesView( + selectedScope: ScopeInfo? = null, + state: BalanceState, + balancesFragmentState: FragmentState, + transactionsFragmentState: FragmentState, +) { + when (state) { + is BalanceState.None -> {} + is BalanceState.Loading -> LoadingScreen() + is BalanceState.Error -> WithdrawalError(state.error) + is BalanceState.Success -> { + if (selectedScope == null) AndroidFragment( + BalancesFragment::class.java, + modifier = Modifier.fillMaxSize(), + fragmentState = balancesFragmentState + ) else AndroidFragment( + TransactionsFragment::class.java, + modifier = Modifier.fillMaxSize(), + fragmentState = transactionsFragmentState, + ) } } +} +@Composable +fun SettingsView( + settingsFragmentState: FragmentState, +) { + AndroidFragment( + SettingsFragment::class.java, + modifier = Modifier.fillMaxSize(), + fragmentState = settingsFragmentState, + ) } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TalerActionsModal( + showSheet: Boolean, + sheetState: SheetState, + onDismiss: () -> Unit, + onSend: () -> Unit, + onReceive: () -> Unit, + onScanQr: () -> Unit, + onDeposit: () -> Unit, + onWithdraw: () -> Unit, + onEnterUri: () -> Unit, +) { + if (showSheet) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + GridMenu( + contentPadding = PaddingValues( + start = 8.dp, + end = 8.dp, + bottom = 16.dp + WindowInsets + .systemBars + .asPaddingValues() + .calculateBottomPadding(), + ), + ) { + GridMenuItem( + icon = R.drawable.transaction_p2p_outgoing, + title = R.string.transactions_send_funds, + onClick = onSend, + ) + + GridMenuItem( + icon = R.drawable.transaction_p2p_incoming, + title = R.string.transactions_receive_funds, + onClick = onReceive, + ) + + GridMenuItem( + icon = R.drawable.ic_scan_qr, + title = R.string.button_scan_qr_code_label, + onClick = onScanQr, + ) + + GridMenuItem( + icon = R.drawable.transaction_deposit, + title = R.string.send_deposit_button_label, + onClick = onDeposit, + ) + + GridMenuItem( + icon = R.drawable.transaction_withdrawal, + title = R.string.withdraw_button_label, + onClick = onWithdraw, + ) + + GridMenuItem( + icon = R.drawable.ic_link, + title = R.string.enter_uri_label, + onClick = onEnterUri, + ) + } + } + } +} + diff --git a/wallet/src/main/java/net/taler/wallet/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/MainViewModel.kt @@ -174,7 +174,7 @@ class MainViewModel( */ @UiThread fun showTransactions(scopeInfo: ScopeInfo) { - mTransactionsEvent.value = scopeInfo.toEvent() + transactionManager.selectedScope.value = scopeInfo } @UiThread diff --git a/wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt b/wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt @@ -1,253 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2022 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.wallet - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import android.widget.Toast.LENGTH_LONG -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountBalance -import androidx.compose.material.icons.filled.AccountBalanceWallet -import androidx.compose.material.icons.filled.QrCodeScanner -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -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.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import net.taler.common.Amount -import net.taler.common.CurrencySpecification -import net.taler.wallet.compose.AmountInputField -import net.taler.wallet.compose.DEFAULT_INPUT_DECIMALS -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.exchanges.ExchangeItem -import net.taler.wallet.withdraw.WithdrawalDetailsForUri - -class ReceiveFundsFragment : Fragment() { - private val model: MainViewModel by activityViewModels() - private val exchangeManager get() = model.exchangeManager - private val withdrawManager get() = model.withdrawManager - private val balanceManager get() = model.balanceManager - private val peerManager get() = model.peerManager - private val scopeInfo get() = model.transactionManager.selectedScope ?: error("No scope selected") - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = ComposeView(requireContext()).apply { - setContent { - TalerSurface { - ReceiveFundsIntro( - scopeInfo.currency, - balanceManager.getSpecForScopeInfo(scopeInfo), - this@ReceiveFundsFragment::onManualWithdraw, - this@ReceiveFundsFragment::onPeerPull, - this@ReceiveFundsFragment::onScanQr, - ) - } - } - } - - override fun onStart() { - super.onStart() - activity?.setTitle(getString(R.string.transactions_receive_funds_title, scopeInfo.currency)) - } - - private fun onManualWithdraw(amount: Amount) { - // TODO give some UI feedback while we wait for exchanges to load (quick enough for now) - lifecycleScope.launchWhenResumed { - // we need to set the exchange first, we want to withdraw from - exchangeManager.findExchangeForCurrency(amount.currency).collect { exchange -> - onExchangeRetrieved(exchange, amount) - } - } - } - - private fun onExchangeRetrieved(exchange: ExchangeItem?, amount: Amount) { - if (exchange == null) { - Toast.makeText(requireContext(), "No exchange available", LENGTH_LONG).show() - return - } - - // now that we have the exchange, we can navigate - exchangeManager.withdrawalExchange = exchange - withdrawManager.resetWithdrawal() - withdrawManager.getWithdrawalDetails( - exchangeBaseUrl = exchange.exchangeBaseUrl, - uriInfo = WithdrawalDetailsForUri( - amount = amount, - currency = amount.currency, - ), - amount = amount, - ) - findNavController().navigate(R.id.action_receiveFunds_to_nav_prompt_withdraw) - } - - private fun onPeerPull(amount: Amount) { - val bundle = bundleOf("amount" to amount.toJSONString()) - peerManager.checkPeerPullCredit(amount) - findNavController().navigate(R.id.action_receiveFunds_to_nav_peer_pull, bundle) - } - - private fun onScanQr() { - model.scanCode(ScanQrContext.Receive) - } -} - -@Composable -private fun ReceiveFundsIntro( - currency: String, - spec: CurrencySpecification?, - onManualWithdraw: (Amount) -> Unit, - onPeerPull: (Amount) -> Unit, - onScanQr: () -> Unit, -) { - val scrollState = rememberScrollState() - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(scrollState), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - var text by rememberSaveable { mutableStateOf("0") } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(16.dp), - ) { - AmountInputField( - modifier = Modifier - .weight(1f) - .padding(end = 16.dp), - value = text, - onValueChange = { input -> - text = input - }, - label = { Text(stringResource(R.string.amount_receive)) }, - numberOfDecimals = spec?.numFractionalInputDigits ?: DEFAULT_INPUT_DECIMALS, - ) - Text( - modifier = Modifier, - text = spec?.symbol ?: currency, - softWrap = false, - style = MaterialTheme.typography.titleLarge, - ) - } - Text( - modifier = Modifier.padding(horizontal = 16.dp), - text = stringResource(R.string.receive_intro), - style = MaterialTheme.typography.titleLarge, - ) - Column(modifier = Modifier.padding(16.dp)) { - val amount: Amount? = remember(currency, text) { - getAmount(currency, text) - } - - Button( - modifier = Modifier.fillMaxWidth(), - enabled = amount?.isZero() == false, - onClick = { - amount?.let { onManualWithdraw(it) } - }, - ) { - Icon( - Icons.Default.AccountBalance, - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize), - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(text = stringResource(R.string.receive_withdraw)) - } - - Button( - modifier = Modifier.fillMaxWidth(), - enabled = amount?.isZero() == false, - onClick = { - amount?.let { onPeerPull(it) } - }, - ) { - Icon( - Icons.Default.AccountBalanceWallet, - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize), - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(text = stringResource(R.string.receive_peer)) - } - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - text = stringResource(id = R.string.or), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyLarge, - ) - - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { onScanQr() }, - ) { - Icon( - Icons.Default.QrCodeScanner, - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize), - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(text = stringResource(R.string.button_scan_qr_code_label)) - } - } - } -} - -@Preview -@Composable -fun PreviewReceiveFundsIntro() { - Surface { - ReceiveFundsIntro("TESTKUDOS", null, {}, {}) {} - } -} diff --git a/wallet/src/main/java/net/taler/wallet/SendFundsFragment.kt b/wallet/src/main/java/net/taler/wallet/SendFundsFragment.kt @@ -1,312 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2022 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.wallet - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountBalance -import androidx.compose.material.icons.filled.AccountBalanceWallet -import androidx.compose.material.icons.filled.CurrencyBitcoin -import androidx.compose.material.icons.filled.QrCodeScanner -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -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.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.navigation.fragment.findNavController -import kotlinx.coroutines.launch -import net.taler.common.Amount -import net.taler.common.CurrencySpecification -import net.taler.wallet.compose.AmountInputField -import net.taler.wallet.compose.DEFAULT_INPUT_DECIMALS -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.peer.CheckFeeResult - -class SendFundsFragment : Fragment() { - private val model: MainViewModel by activityViewModels() - private val balanceManager get() = model.balanceManager - private val peerManager get() = model.peerManager - private val scopeInfo get() = model.transactionManager.selectedScope ?: error("No scope selected") - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = ComposeView(requireContext()).apply { - setContent { - TalerSurface { - SendFundsIntro( - currency = scopeInfo.currency, - spec = balanceManager.getSpecForScopeInfo(scopeInfo), - checkFees = this@SendFundsFragment::checkFees, - onDeposit = this@SendFundsFragment::onDeposit, - onPeerPush = this@SendFundsFragment::onPeerPush, - onScanQr = this@SendFundsFragment::onScanQr, - ) - } - } - } - - override fun onStart() { - super.onStart() - activity?.setTitle(getString(R.string.transactions_send_funds_title, scopeInfo.currency)) - } - - private suspend fun checkFees(amount: Amount): CheckFeeResult { - return peerManager.checkPeerPushFees(amount) - } - - private fun onDeposit(amount: Amount) { - val bundle = bundleOf("amount" to amount.toJSONString()) - findNavController().navigate(R.id.action_sendFunds_to_nav_deposit, bundle) - } - - private fun onPeerPush(amount: Amount) { - val bundle = bundleOf("amount" to amount.toJSONString()) - peerManager.checkPeerPushDebit(amount) - findNavController().navigate(R.id.action_sendFunds_to_nav_peer_push, bundle) - } - - private fun onScanQr() { - model.scanCode(ScanQrContext.Send) - } -} - -@Composable -private fun SendFundsIntro( - currency: String, - spec: CurrencySpecification?, - checkFees: suspend (amount: Amount) -> CheckFeeResult, - onDeposit: (Amount) -> Unit, - onPeerPush: (Amount) -> Unit, - onScanQr: () -> Unit, -) { - val scrollState = rememberScrollState() - val coroutineScope = rememberCoroutineScope() - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(scrollState), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - var text by rememberSaveable { mutableStateOf("0") } - val amount: Amount? = remember(currency, text) { - getAmount(currency, text) - } - - var fees by remember { mutableStateOf<CheckFeeResult>(CheckFeeResult.None) } - val insufficientBalance: Boolean = remember(fees) { - fees is CheckFeeResult.InsufficientBalance - } - - val maxAmount = remember(amount, fees) { - (fees as? CheckFeeResult.InsufficientBalance)?.maxAmountEffective - } - - val calculateFees = { input: String -> - fees = CheckFeeResult.None - getAmount(currency, input)?.let { amount -> - coroutineScope.launch { - checkFees(amount).let { - fees = it - } - } - } - } - - text.useDebounce( - delayMillis = 150L, - coroutineScope = coroutineScope, - ) { - calculateFees(it) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 8.dp), - ) { - - AmountInputField( - modifier = Modifier - .weight(1f) - .padding(end = 16.dp), - value = text, - onValueChange = { input -> - text = input - }, - label = { Text(stringResource(R.string.amount_send)) }, - supportingText = { - if (insufficientBalance) { - if (maxAmount != null) { - Text(stringResource( - R.string.payment_balance_insufficient_max, - maxAmount.withSpec(spec).toString(), - )) - } else { - Text(stringResource(R.string.payment_balance_insufficient)) - } - } - }, - isError = insufficientBalance, - numberOfDecimals = spec?.numFractionalInputDigits ?: DEFAULT_INPUT_DECIMALS, - ) - - Text( - modifier = Modifier, - text = spec?.symbol ?: currency, - softWrap = false, - style = MaterialTheme.typography.titleLarge, - ) - } - - // Render fees dynamically - if (fees is CheckFeeResult.Success) { - val success = fees as CheckFeeResult.Success - if (success.amountEffective > success.amountRaw) { - val fee = success.amountEffective - success.amountRaw - if (!fee.isZero()) { - Text( - modifier = Modifier.padding(bottom = 16.dp), - text = stringResource(id = R.string.payment_fee, fee.withSpec(spec)), - softWrap = false, - color = MaterialTheme.colorScheme.error, - ) - } - } - } - - Text( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - text = stringResource(R.string.send_intro), - style = MaterialTheme.typography.titleLarge, - ) - - Column(modifier = Modifier.padding(16.dp)) { - - Button( - modifier = Modifier.fillMaxWidth(), - enabled = !insufficientBalance && amount?.isZero() == false, - onClick = { amount?.let { onDeposit(it) } }, - ) { - Icon( - if (currency == CURRENCY_BTC) { - Icons.Default.CurrencyBitcoin - } else { - Icons.Default.AccountBalance - }, - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize), - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(text = if (currency == CURRENCY_BTC) { - stringResource(R.string.send_deposit_bitcoin) - } else { - stringResource(R.string.send_deposit) - }) - } - - Button( - modifier = Modifier.fillMaxWidth(), - enabled = !insufficientBalance && amount?.isZero() == false, - onClick = { amount?.let { onPeerPush(it) } }, - ) { - Icon( - Icons.Default.AccountBalanceWallet, - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize), - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(text = if (currency == CURRENCY_BTC) { - stringResource(R.string.send_peer_bitcoin) - } else { - stringResource(R.string.send_peer) - }) - } - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - text = stringResource(id = R.string.or), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyLarge, - ) - - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { onScanQr() }, - ) { - Icon( - Icons.Default.QrCodeScanner, - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize), - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(text = stringResource(R.string.button_scan_qr_code_label)) - } - } - } -} - -@Preview -@Composable -fun PreviewSendFundsIntro() { - Surface { - SendFundsIntro( - currency = "TESTKUDOS", - spec = null, - checkFees = { - CheckFeeResult.InsufficientBalance( - maxAmountRaw = Amount.fromJSONString("TESTKUDOS:10"), - maxAmountEffective = Amount.fromJSONString("TESTKUDOS:10.2"), - ) - }, - onDeposit = {}, - onScanQr = {}, - onPeerPush = {}, - ) - } -} diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt b/wallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt @@ -70,7 +70,10 @@ class BalanceManager( @UiThread fun loadBalances() { - mState.value = BalanceState.Loading + if (mState.value == BalanceState.None) { + mState.value = BalanceState.Loading + } + scope.launch { val response = api.request("getBalances", BalanceResponse.serializer()) response.onError { diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalancesFragment.kt b/wallet/src/main/java/net/taler/wallet/balances/BalancesFragment.kt @@ -31,6 +31,7 @@ import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL import net.taler.common.fadeIn import net.taler.common.showError import net.taler.wallet.MainViewModel +import net.taler.wallet.R import net.taler.wallet.balances.BalanceState.Error import net.taler.wallet.balances.BalanceState.Loading import net.taler.wallet.balances.BalanceState.None @@ -70,6 +71,11 @@ class BalancesFragment : Fragment(), } } + override fun onStart() { + super.onStart() + requireActivity().title = getString(R.string.balances_title) + } + private fun onBalancesChanged(state: BalanceState) { model.showProgressBar.value = false when (state) { diff --git a/wallet/src/main/java/net/taler/wallet/compose/DemandAttention.kt b/wallet/src/main/java/net/taler/wallet/compose/DemandAttention.kt @@ -0,0 +1,62 @@ +/* + * This file is part of GNU Taler + * (C) 2024 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.wallet.compose + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.repeatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay + +@Composable +fun DemandAttention(content: @Composable () -> Unit) { + val offsetX = remember { Animatable(0f) } + LaunchedEffect(Unit) { + delay(400) + + offsetX.animateTo( + targetValue = -5f, + animationSpec = tween(90, easing = LinearEasing), + ) + + offsetX.animateTo( + targetValue = 5f, + animationSpec = repeatable( + iterations = 5, + animation = tween(80, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ) + ) + + offsetX.animateTo( + targetValue = 0f, + animationSpec = tween(90, easing = LinearEasing), + ) + } + + Box(Modifier.offset(offsetX.value.dp, 0.dp)) { + content() + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/compose/GridMenu.kt b/wallet/src/main/java/net/taler/wallet/compose/GridMenu.kt @@ -0,0 +1,119 @@ +/* + * This file is part of GNU Taler + * (C) 2024 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.wallet.compose + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ShapeDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +// Source: https://github.com/z-huang/InnerTune + +val GridMenuItemHeight = 96.dp + +@Composable +fun GridMenu( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + content: LazyGridScope.() -> Unit, +) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 100.dp), + modifier = modifier, + contentPadding = contentPadding, + content = content, + ) +} + +fun LazyGridScope.GridMenuItem( + modifier: Modifier = Modifier, + @DrawableRes icon: Int, + @StringRes title: Int, + enabled: Boolean = true, + onClick: () -> Unit, +) = GridMenuItem( + modifier = modifier, + icon = { + Icon( + painter = painterResource(icon), + contentDescription = null, + ) + }, + title = title, + enabled = enabled, + onClick = onClick, +) + +fun LazyGridScope.GridMenuItem( + modifier: Modifier = Modifier, + icon: @Composable BoxScope.() -> Unit, + @StringRes title: Int, + enabled: Boolean = true, + onClick: () -> Unit, +) { + item { + Column( + modifier = + modifier + .clip(ShapeDefaults.Large) + .height(GridMenuItemHeight) + .clickable( + enabled = enabled, + onClick = onClick, + ).alpha(if (enabled) 1f else 0.5f) + .padding(12.dp), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + content = icon, + ) + Text( + text = stringResource(title), + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + maxLines = 2, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt @@ -20,16 +20,12 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import net.taler.common.Amount import net.taler.common.showError import net.taler.wallet.CURRENCY_BTC @@ -50,30 +46,26 @@ class DepositFragment : Fragment() { container: ViewGroup?, savedInstanceState: Bundle?, ): View { - val amount = arguments?.getString("amount")?.let { - Amount.fromJSONString(it) - } ?: error("no amount passed") - val scopeInfo = transactionManager.selectedScope + val presetAmount = arguments?.getString("amount")?.let { Amount.fromJSONString(it) } + val scopeInfo = transactionManager.selectedScope.value val spec = scopeInfo?.let { balanceManager.getSpecForScopeInfo(it) } val receiverName = arguments?.getString("receiverName") val iban = arguments?.getString("IBAN") - if (receiverName != null && iban != null) { + if (presetAmount != null && receiverName != null && iban != null) { val paytoUri = getIbanPayto(receiverName, iban) - depositManager.makeDeposit(amount, paytoUri) + depositManager.makeDeposit(presetAmount, paytoUri) } return ComposeView(requireContext()).apply { setContent { TalerSurface { val state = depositManager.depositState.collectAsStateLifecycleAware() - val wireTypes = remember { mutableStateListOf<WireType>() } - val talerBankHostnames = remember { mutableStateListOf<String>() } - val coroutine = rememberCoroutineScope() - if (amount.currency == CURRENCY_BTC) MakeBitcoinDepositComposable( + // TODO: refactor Bitcoin as wire method + if (presetAmount?.currency == CURRENCY_BTC) MakeBitcoinDepositComposable( state = state.value, - amount = amount.withSpec(spec), + amount = presetAmount.withSpec(spec), bitcoinAddress = null, onMakeDeposit = { amount, bitcoinAddress -> val paytoUri = getBitcoinPayto(bitcoinAddress) @@ -81,30 +73,19 @@ class DepositFragment : Fragment() { }, ) else MakeDepositComposable( state = state.value, - supportedWireTypes = wireTypes, - talerBankHostnames = talerBankHostnames, - amount = amount.withSpec(spec), + defaultCurrency = scopeInfo?.currency, + currencies = balanceManager.getCurrencies(), + getCurrencySpec = { runBlocking { balanceManager.getSpecForCurrency(it) } }, + checkDeposit = { a, p -> runBlocking { depositManager.checkDepositFees(p, a) } }, + getDepositWireTypes = { currency -> + runBlocking { depositManager.getDepositWireTypesForCurrency(currency) } + }, presetName = receiverName, presetIban = iban, validateIban = depositManager::validateIban, onMakeDeposit = depositManager::makeDeposit, onClose = { findNavController().popBackStack() }, ) - - LaunchedEffect(Unit) { - coroutine.launch { - scopeInfo?.let { scopeInfo -> - depositManager - .getDepositWireTypesForCurrency(scopeInfo) - ?.let { result -> - wireTypes.addAll(result.wireTypes) - talerBankHostnames.addAll(result.wireTypeDetails.flatMap { - it.talerBankHostnames - }.distinct()) - } - } - } - } } } } diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt @@ -26,12 +26,15 @@ import kotlinx.coroutines.launch import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import net.taler.common.Amount import net.taler.wallet.TAG import net.taler.wallet.accounts.PaytoUriBitcoin import net.taler.wallet.accounts.PaytoUriIban import net.taler.wallet.accounts.PaytoUriTalerBank import net.taler.wallet.backend.BackendManager +import net.taler.wallet.backend.TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE import net.taler.wallet.backend.WalletBackendApi import net.taler.wallet.balances.ScopeInfo import org.json.JSONObject @@ -51,47 +54,46 @@ class DepositManager( return u.pathSegments.size >= 1 } - fun makeDeposit(amount: Amount, uri: String) { - if (depositState.value is DepositState.FeesChecked) makeDeposit( - paytoUri = uri, - amount = amount, - totalDepositCost = depositState.value.totalDepositCost - ?: Amount.zero(amount.currency), - effectiveDepositAmount = depositState.value.effectiveDepositAmount - ?: Amount.zero(amount.currency), - ) else { - prepareDeposit(uri, amount) - } - } + suspend fun checkDepositFees(paytoUri: String, amount: Amount): CheckDepositResult { + var response: CheckDepositResult = CheckDepositResult.None - private fun prepareDeposit(paytoUri: String, amount: Amount) { - mDepositState.value = DepositState.CheckingFees - scope.launch { - api.request("prepareDeposit", PrepareDepositResponse.serializer()) { - put("depositPaytoUri", paytoUri) - put("amount", amount.toJSONString()) - }.onError { - Log.e(TAG, "Error prepareDeposit $it") - mDepositState.value = DepositState.Error(it) - }.onSuccess { - mDepositState.value = DepositState.FeesChecked( - totalDepositCost = it.totalDepositCost, - effectiveDepositAmount = it.effectiveDepositAmount, - ) + api.request("checkDeposit", CheckDepositResponse.serializer()) { + put("depositPaytoUri", paytoUri) + put("amount", amount.toJSONString()) + }.onSuccess { + response = CheckDepositResult.Success( + totalDepositCost = it.totalDepositCost, + effectiveDepositAmount = it.effectiveDepositAmount, + kycSoftLimit = it.kycSoftLimit, + kycHardLimit = it.kycHardLimit, + kycExchanges = it.kycExchanges, + ) + }.onError { error -> + Log.e(TAG, "Error prepareDeposit $error") + if (error.code == WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE) { + error.extra["insufficientBalanceDetails"]?.let { details -> + val maxAmountRaw = details.jsonObject["balanceAvailable"]?.let { amount -> + Amount.fromJSONString(amount.jsonPrimitive.content) + } + + val maxAmountEffective = details.jsonObject["maxEffectiveSpendAmount"]?.let { amount -> + Amount.fromJSONString(amount.jsonPrimitive.content) + } ?: maxAmountRaw + + response = CheckDepositResult.InsufficientBalance( + maxAmountEffective = maxAmountEffective, + maxAmountRaw = maxAmountRaw, + ) + } } } + + return response } - private fun makeDeposit( - paytoUri: String, - amount: Amount, - totalDepositCost: Amount, - effectiveDepositAmount: Amount, - ) { - mDepositState.value = DepositState.MakingDeposit( - totalDepositCost = totalDepositCost, - effectiveDepositAmount = effectiveDepositAmount, - ) + fun makeDeposit(amount: Amount, paytoUri: String) { + mDepositState.value = DepositState.MakingDeposit + scope.launch { api.request("createDepositGroup", CreateDepositGroupResponse.serializer()) { put("depositPaytoUri", paytoUri) @@ -123,11 +125,11 @@ class DepositManager( return response } - suspend fun getDepositWireTypesForCurrency(scopeInfo: ScopeInfo): GetDepositWireTypesForCurrencyResponse? { + suspend fun getDepositWireTypesForCurrency(currency: String, scopeInfo: ScopeInfo? = null): GetDepositWireTypesForCurrencyResponse? { var result: GetDepositWireTypesForCurrencyResponse? = null api.request("getDepositWireTypesForCurrency", GetDepositWireTypesForCurrencyResponse.serializer()) { - put("currency", scopeInfo.currency) - put("scopeInfo", JSONObject(BackendManager.json.encodeToString(scopeInfo))) + scopeInfo?.let { put("scopeInfo", JSONObject(BackendManager.json.encodeToString(it))) } + put("currency", currency) }.onError { Log.e(TAG, "Error getDepositWireTypesForCurrency $it") }.onSuccess { @@ -162,12 +164,33 @@ data class ValidateIbanResponse( ) @Serializable -data class PrepareDepositResponse( +data class CheckDepositResponse( val totalDepositCost: Amount, val effectiveDepositAmount: Amount, + val kycSoftLimit: Amount? = null, + val kycHardLimit: Amount? = null, + val kycExchanges: List<String>? = null, ) @Serializable +sealed class CheckDepositResult { + data object None: CheckDepositResult() + + data class InsufficientBalance( + val maxAmountEffective: Amount?, + val maxAmountRaw: Amount?, + ): CheckDepositResult() + + data class Success( + val totalDepositCost: Amount, + val effectiveDepositAmount: Amount, + val kycSoftLimit: Amount? = null, + val kycHardLimit: Amount? = null, + val kycExchanges: List<String>? = null, + ): CheckDepositResult() +} + +@Serializable data class CreateDepositGroupResponse( val depositGroupId: String, val transactionId: String, diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositState.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositState.kt @@ -26,24 +26,15 @@ sealed class DepositState { data object Start : DepositState() - data object CheckingFees : DepositState() - data class FeesChecked( override val totalDepositCost: Amount, override val effectiveDepositAmount: Amount, ) : DepositState() { override val showFees = true } - - data class MakingDeposit( - override val totalDepositCost: Amount, - override val effectiveDepositAmount: Amount, - ) : DepositState() { - override val showFees = true - } + data object MakingDeposit : DepositState() data object Success : DepositState() data class Error(val error: TalerErrorInfo) : DepositState() - } diff --git a/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositComposable.kt b/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositComposable.kt @@ -18,6 +18,7 @@ package net.taler.wallet.deposit import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState @@ -29,6 +30,8 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -44,34 +47,34 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import net.taler.common.Amount +import net.taler.common.CurrencySpecification import net.taler.wallet.R import net.taler.wallet.backend.TalerErrorCode import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.AmountInputField +import net.taler.wallet.compose.DEFAULT_INPUT_DECIMALS +import net.taler.wallet.getAmount import net.taler.wallet.peer.OutgoingError import net.taler.wallet.peer.PeerErrorComposable import net.taler.wallet.transactions.AmountType.Negative import net.taler.wallet.transactions.AmountType.Positive import net.taler.wallet.transactions.TransactionAmountComposable +import net.taler.wallet.useDebounce @Composable fun MakeDepositComposable( state: DepositState, - supportedWireTypes: List<WireType>, - talerBankHostnames: List<String>, - amount: Amount, + defaultCurrency: String?, + currencies: List<String>, + getCurrencySpec: (currency: String) -> CurrencySpecification?, + checkDeposit: (amount: Amount, paytoUri: String) -> CheckDepositResult, + getDepositWireTypes: (currency: String) -> GetDepositWireTypesForCurrencyResponse?, presetName: String? = null, presetIban: String? = null, validateIban: suspend (iban: String) -> Boolean, onMakeDeposit: (Amount, String) -> Unit, onClose: () -> Unit, ) { - if (supportedWireTypes.isEmpty()) { - return MakeDepositErrorComposable( - message = stringResource(R.string.send_deposit_no_methods_error), - onClose = onClose, - ) - } - val scrollState = rememberScrollState() Column( modifier = Modifier @@ -79,21 +82,22 @@ fun MakeDepositComposable( .verticalScroll(scrollState), horizontalAlignment = CenterHorizontally, ) { - var selectedWireType by remember { - mutableStateOf(supportedWireTypes.first()) - } + // Amount/currency stuff + // TODO: use scopeInfo instead of currency! + var selectedCurrency by rememberSaveable { mutableStateOf(defaultCurrency ?: currencies[0]) } + val selectedSpec: CurrencySpecification? = getCurrencySpec(selectedCurrency) + var text by rememberSaveable { mutableStateOf("0") } + val amount = remember(selectedCurrency, text) { getAmount(selectedCurrency, text) } + var checkResult by remember { mutableStateOf<CheckDepositResult>(CheckDepositResult.None) } - if (supportedWireTypes.size > 1) { - MakeDepositWireTypeChooser( - supportedWireTypes = supportedWireTypes, - selectedWireType = selectedWireType, - onSelectWireType = { - selectedWireType = it - } - ) - } + // TODO: make getDepositWireTypes asynchronous! + val depositWireTypes = remember(selectedCurrency) { getDepositWireTypes(selectedCurrency) } + val supportedWireTypes = remember(depositWireTypes) { depositWireTypes?.wireTypes ?: emptyList() } + val talerBankHostnames = remember(depositWireTypes) { depositWireTypes?.wireTypeDetails?.flatMap { it.talerBankHostnames }?.distinct() ?: emptyList() } + var selectedWireType by remember { mutableStateOf(supportedWireTypes.firstOrNull()) } - var formError by rememberSaveable { mutableStateOf(false) } + // payto:// stuff + var formError by rememberSaveable { mutableStateOf(true) } // TODO: do an initial validation! var ibanName by rememberSaveable { mutableStateOf(presetName ?: "") } var ibanIban by rememberSaveable { mutableStateOf(presetIban ?: "") } var talerName by rememberSaveable { mutableStateOf(presetName ?: "") } @@ -106,6 +110,91 @@ fun MakeDepositComposable( else -> null } + // reset forms and selected wire type when switching currency + DisposableEffect(selectedCurrency) { + selectedWireType = supportedWireTypes.first() + formError = true + ibanName = presetName ?: "" + ibanIban = presetIban ?: "" + talerName = presetName ?: "" + talerHost = talerBankHostnames.firstOrNull() ?: "" + talerAccount = "" + onDispose { } + } + + // TODO: make checkDeposit asynchronous! + amount.useDebounce { + if (amount != null && paytoUri != null) { + // TODO: handle insufficient balance! + // TODO: handle KYC limits! + checkResult = checkDeposit(amount, paytoUri) + } + } + + paytoUri.useDebounce { + if (amount != null && paytoUri != null) { + checkResult = checkDeposit(amount, paytoUri) + } + } + + LaunchedEffect(Unit) { + if (amount != null && paytoUri != null) { + checkResult = checkDeposit(amount, paytoUri) + } + } + + if (supportedWireTypes.isEmpty()) { + return@Column MakeDepositErrorComposable( + message = stringResource(R.string.send_deposit_no_methods_error), + onClose = onClose, + ) + } + + if (selectedWireType != null && supportedWireTypes.size > 1) { + MakeDepositWireTypeChooser( + supportedWireTypes = supportedWireTypes, + selectedWireType = selectedWireType!!, + onSelectWireType = { + selectedWireType = it + } + ) + } + + Row(Modifier.padding( + start = 16.dp, + top = 16.dp, + end = 16.dp, + )) { + AmountInputField( + modifier = Modifier + .weight(1f, true) + .padding(end = 16.dp), + value = text, + onValueChange = { input -> + text = input + }, + label = { Text(stringResource(R.string.amount_deposit)) }, + numberOfDecimals = selectedSpec?.numFractionalInputDigits ?: DEFAULT_INPUT_DECIMALS, + isError = checkResult is CheckDepositResult.InsufficientBalance, + supportingText = { (checkResult as? CheckDepositResult.InsufficientBalance)?.let { res -> + if (res.maxAmountRaw != null) { + Text(stringResource( + R.string.payment_balance_insufficient_max, + res.maxAmountRaw.withSpec(selectedSpec), + )) + } + } } + ) + + CurrencyDropdown( + modifier = Modifier.weight(1f), + currencies = currencies, + onCurrencyChanged = { selectedCurrency = it }, + initialCurrency = defaultCurrency, + readOnly = false, + ) + } + when(selectedWireType) { WireType.IBAN -> { var ibanError by rememberSaveable { mutableStateOf(false) } @@ -114,7 +203,6 @@ fun MakeDepositComposable( MakeDepositIBAN( name = ibanName, iban = ibanIban, - state = state, ibanError = ibanError, onFormEdited = { name, iban -> ibanName = name @@ -133,7 +221,6 @@ fun MakeDepositComposable( host = talerHost, account = talerAccount, supportedHosts = talerBankHostnames, - state = state, onFormEdited = { name, host, account -> talerName = name talerHost = host @@ -147,18 +234,16 @@ fun MakeDepositComposable( else -> {} } - TransactionAmountComposable( - label = stringResource(R.string.amount_chosen), - amount = amount, - amountType = Positive, - ) - AnimatedVisibility(visible = state.showFees) { + AnimatedVisibility(visible = amount != null && checkResult is CheckDepositResult.Success) { + if (amount == null) return@AnimatedVisibility + val res = checkResult as? CheckDepositResult.Success ?: return@AnimatedVisibility + Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = CenterHorizontally, ) { - val totalAmount = state.totalDepositCost ?: amount - val effectiveAmount = state.effectiveDepositAmount ?: Amount.zero(amount.currency) + val totalAmount = res.totalDepositCost + val effectiveAmount = res.effectiveDepositAmount if (totalAmount > effectiveAmount) { val fee = totalAmount - effectiveAmount @@ -189,18 +274,15 @@ fun MakeDepositComposable( val focusManager = LocalFocusManager.current Button( modifier = Modifier.padding(16.dp), - enabled = !formError, + enabled = checkResult is CheckDepositResult.Success && !formError, onClick = { focusManager.clearFocus() - paytoUri?.let { onMakeDeposit(amount, it) } + if (paytoUri != null && amount != null) { + onMakeDeposit(amount, paytoUri) + } }, ) { - Text( - text = stringResource( - if (state is DepositState.FeesChecked) R.string.send_deposit_create_button - else R.string.send_deposit_check_fees_button - ) - ) + Text(stringResource(R.string.send_deposit_create_button)) } } } @@ -263,9 +345,14 @@ fun PreviewMakeDepositComposable() { ) MakeDepositComposable( state = state, - supportedWireTypes = listOf(WireType.TalerBank, WireType.IBAN), - talerBankHostnames = listOf("bank.demo.taler.net", "bank.test.taler.net"), - amount = Amount.fromString("TESTKUDOS", "42.23"), + defaultCurrency = "KUDOS", + currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), + getCurrencySpec = { null }, + getDepositWireTypes = { GetDepositWireTypesForCurrencyResponse(listOf(), listOf())}, + checkDeposit = { _, _ -> CheckDepositResult.Success( + totalDepositCost = Amount.fromJSONString("KUDOS:10"), + effectiveDepositAmount = Amount.fromJSONString("KUDOS:12"), + ) }, validateIban = { true }, onMakeDeposit = { _, _ -> }, onClose = {}, diff --git a/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositIBAN.kt b/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositIBAN.kt @@ -22,11 +22,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -34,21 +30,16 @@ import net.taler.wallet.R @Composable fun MakeDepositIBAN( - state: DepositState, name: String, iban: String, ibanError: Boolean, onFormEdited: (name: String, iban: String) -> Unit ) { - val focusRequester = remember { FocusRequester() } - OutlinedTextField( modifier = Modifier .padding(16.dp) - .focusRequester(focusRequester) .fillMaxWidth(), value = name, - enabled = !state.showFees, onValueChange = { input -> onFormEdited(input, iban) }, @@ -64,20 +55,14 @@ fun MakeDepositIBAN( } ) - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - OutlinedTextField( modifier = Modifier .padding(horizontal = 16.dp) .fillMaxWidth(), value = iban, singleLine = true, - enabled = !state.showFees, onValueChange = { input -> onFormEdited(name, input.uppercase()) - }, isError = ibanError, supportingText = { diff --git a/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositTaler.kt b/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositTaler.kt @@ -26,14 +26,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -42,22 +39,17 @@ import net.taler.wallet.R @OptIn(ExperimentalMaterial3Api::class) @Composable fun MakeDepositTaler( - state: DepositState, supportedHosts: List<String>, name: String, host: String, account: String, onFormEdited: (name: String, host: String, account: String) -> Unit ) { - val focusRequester = remember { FocusRequester() } - OutlinedTextField( modifier = Modifier .padding(16.dp) - .focusRequester(focusRequester) .fillMaxWidth(), value = name, - enabled = !state.showFees, onValueChange = { input -> onFormEdited(input, host, account) }, @@ -73,10 +65,6 @@ fun MakeDepositTaler( } ) - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - var expanded by remember { mutableStateOf(false) } ExposedDropdownMenuBox( expanded = expanded, @@ -88,7 +76,6 @@ fun MakeDepositTaler( .fillMaxWidth() .menuAnchor(), readOnly = true, - enabled = !state.showFees, value = host, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, onValueChange = {}, @@ -124,7 +111,6 @@ fun MakeDepositTaler( .fillMaxWidth(), value = account, singleLine = true, - enabled = !state.showFees, onValueChange = { input -> onFormEdited(name, host, input) }, diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt @@ -157,8 +157,8 @@ open class ExchangeListFragment : Fragment(), ExchangeClickListener { } override fun onPeerReceive(item: ExchangeItem) { - transactionManager.selectedScope = item.scopeInfo - findNavController().navigate(R.id.action_global_receiveFunds) + transactionManager.selectedScope.value = item.scopeInfo + findNavController().navigate(R.id.nav_peer_pull) } override fun onExchangeReload(item: ExchangeItem) { diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt @@ -19,6 +19,7 @@ package net.taler.wallet.peer 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 @@ -50,21 +51,28 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlinx.serialization.json.JsonPrimitive import net.taler.common.Amount +import net.taler.common.CurrencySpecification import net.taler.wallet.R import net.taler.wallet.backend.TalerErrorCode import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.cleanExchange +import net.taler.wallet.compose.AmountInputField +import net.taler.wallet.compose.DEFAULT_INPUT_DECIMALS import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.deposit.CurrencyDropdown import net.taler.wallet.exchanges.ExchangeTosStatus -import net.taler.wallet.transactions.AmountType -import net.taler.wallet.transactions.TransactionAmountComposable +import net.taler.wallet.getAmount import net.taler.wallet.transactions.TransactionInfoComposable +import net.taler.wallet.useDebounce import kotlin.random.Random @Composable fun OutgoingPullComposable( - amount: Amount, state: OutgoingState, + defaultCurrency: String?, + currencies: List<String>, + getCurrencySpec: (currency: String) -> CurrencySpecification?, + checkPeerPullCredit: (amount: Amount) -> CheckPeerPullCreditResult?, onCreateInvoice: (amount: Amount, subject: String, hours: Long, exchangeBaseUrl: String) -> Unit, onTosAccept: (exchangeBaseUrl: String) -> Unit, onClose: () -> Unit, @@ -72,8 +80,10 @@ fun OutgoingPullComposable( when(state) { is OutgoingChecking, is OutgoingCreating, is OutgoingResponse -> PeerCreatingComposable() is OutgoingIntro, is OutgoingChecked -> OutgoingPullIntroComposable( - amount = amount, - state = state, + defaultCurrency = defaultCurrency, + currencies = currencies, + getCurrencySpec = getCurrencySpec, + checkPeerPullCredit = checkPeerPullCredit, onCreateInvoice = onCreateInvoice, onTosAccept = onTosAccept, ) @@ -97,8 +107,10 @@ fun PeerCreatingComposable() { @Composable fun OutgoingPullIntroComposable( - amount: Amount, - state: OutgoingState, + defaultCurrency: String?, + currencies: List<String>, + getCurrencySpec: (currency: String) -> CurrencySpecification?, + checkPeerPullCredit: (amount: Amount) -> CheckPeerPullCreditResult?, onCreateInvoice: (amount: Amount, subject: String, hours: Long, exchangeBaseUrl: String) -> Unit, onTosAccept: (exchangeBaseUrl: String) -> Unit, ) { @@ -113,6 +125,43 @@ fun OutgoingPullIntroComposable( var subject by rememberSaveable { mutableStateOf("") } val focusRequester = remember { FocusRequester() } + var selectedCurrency by rememberSaveable { mutableStateOf(defaultCurrency ?: currencies[0]) } + val selectedSpec: CurrencySpecification? = getCurrencySpec(selectedCurrency) + var text by rememberSaveable { mutableStateOf("0") } + val amount = remember(selectedCurrency, text) { getAmount(selectedCurrency, text) } + var checkResult by remember { mutableStateOf<CheckPeerPullCreditResult?>(null) } + + // TODO: make checkPeerPullCredit asynchronous! + amount.useDebounce { + checkResult = amount?.let { checkPeerPullCredit(it) } + } + + LaunchedEffect(Unit) { + checkResult = amount?.let { checkPeerPullCredit(it) } + } + + Row(Modifier.padding(bottom = 16.dp)) { + AmountInputField( + modifier = Modifier + .weight(1f, true) + .padding(end = 16.dp), + value = text, + onValueChange = { input -> + text = input + }, + label = { Text(stringResource(R.string.amount_receive)) }, + numberOfDecimals = selectedSpec?.numFractionalInputDigits ?: DEFAULT_INPUT_DECIMALS, + ) + + CurrencyDropdown( + modifier = Modifier.weight(1f), + currencies = currencies, + onCurrencyChanged = { selectedCurrency = it }, + initialCurrency = defaultCurrency, + readOnly = false, + ) + } + OutlinedTextField( modifier = Modifier .fillMaxWidth() @@ -147,27 +196,26 @@ fun OutgoingPullIntroComposable( textAlign = TextAlign.End, ) - TransactionAmountComposable( - label = stringResource(id = R.string.amount_chosen), - amount = amount, - amountType = AmountType.Positive, - ) + val res = checkResult + if (res != null) { + if (res.amountEffective > res.amountRaw) { + val fee = res.amountEffective - res.amountRaw + Text( + modifier = Modifier.padding(vertical = 16.dp), + text = stringResource(id = R.string.payment_fee, fee.withSpec(selectedSpec)), + softWrap = false, + color = MaterialTheme.colorScheme.error, + ) + } + } - if (state is OutgoingChecked && state.amountRaw > state.amountEffective) { - val fee = state.amountRaw - state.amountEffective - TransactionAmountComposable( - label = stringResource(id = R.string.amount_fee), - amount = fee.withSpec(amount.spec), - amountType = AmountType.Negative, + checkResult?.exchangeBaseUrl?.let { exchangeBaseUrl -> + TransactionInfoComposable( + label = stringResource(id = R.string.withdraw_exchange), + info = cleanExchange(exchangeBaseUrl), ) } - val exchangeBaseUrl = (state as? OutgoingChecked)?.exchangeBaseUrl - TransactionInfoComposable( - label = stringResource(id = R.string.withdraw_exchange), - info = if (exchangeBaseUrl == null) "" else cleanExchange(exchangeBaseUrl), - ) - Text( modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), text = stringResource(R.string.send_peer_expiration_period), @@ -185,18 +233,20 @@ fun OutgoingPullIntroComposable( Button( modifier = Modifier.padding(16.dp), - enabled = subject.isNotBlank() && (state is OutgoingChecked), + enabled = subject.isNotBlank() && res != null, onClick = { - val ex = exchangeBaseUrl ?: error("clickable without exchange") - if (state.tosStatus == ExchangeTosStatus.Accepted) onCreateInvoice( - amount, - subject, - hours, - ex - ) else onTosAccept(ex) + val ex = res?.exchangeBaseUrl ?: error("clickable without exchange") + if (res.tosStatus == ExchangeTosStatus.Accepted) amount?.let { + onCreateInvoice( + amount, + subject, + hours, + ex + ) + } else onTosAccept(ex) }, ) { - if (state is OutgoingChecked && state.tosStatus != ExchangeTosStatus.Accepted) { + if (checkResult != null && checkResult?.tosStatus != ExchangeTosStatus.Accepted) { Text(text = stringResource(R.string.exchange_tos_accept)) } else { Text(text = stringResource(R.string.receive_peer_create_button)) @@ -238,8 +288,11 @@ fun PeerErrorComposable(state: OutgoingError, onClose: () -> Unit) { fun PeerPullComposableCreatingPreview() { TalerSurface { OutgoingPullComposable( - amount = Amount.fromString("TESTKUDOS", "42.23"), state = OutgoingCreating, + defaultCurrency = "KUDOS", + currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), + getCurrencySpec = { null }, + checkPeerPullCredit = { null }, onCreateInvoice = { _, _, _, _ -> }, onTosAccept = {}, onClose = {}, @@ -252,8 +305,11 @@ fun PeerPullComposableCreatingPreview() { fun PeerPullComposableCheckingPreview() { TalerSurface { OutgoingPullComposable( - amount = Amount.fromString("TESTKUDOS", "42.23"), state = if (Random.nextBoolean()) OutgoingIntro else OutgoingChecking, + defaultCurrency = "KUDOS", + currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), + getCurrencySpec = { null }, + checkPeerPullCredit = { null }, onCreateInvoice = { _, _, _, _ -> }, onTosAccept = {}, onClose = {}, @@ -268,8 +324,11 @@ fun PeerPullComposableCheckedPreview() { val amountRaw = Amount.fromString("TESTKUDOS", "42.42") val amountEffective = Amount.fromString("TESTKUDOS", "42.23") OutgoingPullComposable( - amount = Amount.fromString("TESTKUDOS", "42.23"), state = OutgoingChecked(amountRaw, amountEffective, "https://exchange.demo.taler.net/", ExchangeTosStatus.Accepted), + defaultCurrency = "KUDOS", + currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), + getCurrencySpec = { null }, + checkPeerPullCredit = { null }, onCreateInvoice = { _, _, _, _ -> }, onTosAccept = {}, onClose = {}, @@ -284,8 +343,11 @@ fun PeerPullComposableErrorPreview() { val json = mapOf("foo" to JsonPrimitive("bar")) val state = OutgoingError(TalerErrorInfo(TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED, "hint", "message", json)) OutgoingPullComposable( - amount = Amount.fromString("TESTKUDOS", "42.23"), state = state, + defaultCurrency = "KUDOS", + currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), + getCurrencySpec = { null }, + checkPeerPullCredit = { null }, onCreateInvoice = { _, _, _, _ -> }, onTosAccept = {}, onClose = {}, diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt @@ -29,6 +29,7 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import net.taler.common.Amount import net.taler.wallet.MainViewModel import net.taler.wallet.R @@ -48,21 +49,21 @@ class OutgoingPullFragment : Fragment() { container: ViewGroup?, savedInstanceState: Bundle?, ): View { - val amount = arguments?.getString("amount")?.let { - Amount.fromJSONString(it) - } ?: error("no amount passed") - val scopeInfo = transactionManager.selectedScope - val spec = scopeInfo?.let { balanceManager.getSpecForScopeInfo(it) } - return ComposeView(requireContext()).apply { setContent { TalerSurface { val state = peerManager.pullState.collectAsStateLifecycleAware().value OutgoingPullComposable( - amount = amount.withSpec(spec), state = state, onCreateInvoice = this@OutgoingPullFragment::onCreateInvoice, onTosAccept = this@OutgoingPullFragment::onTosAccept, + defaultCurrency = transactionManager.selectedScope.value?.currency, + currencies = balanceManager.getCurrencies(), + getCurrencySpec = balanceManager::getSpecForCurrency, + checkPeerPullCredit = { amount -> + // TODO: make this async!!! + runBlocking { peerManager.checkPeerPullCredit(amount) } + }, onClose = { findNavController().navigate(R.id.action_nav_peer_pull_to_nav_main) } diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt @@ -17,6 +17,7 @@ package net.taler.wallet.peer import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState @@ -44,25 +45,39 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlinx.serialization.json.JsonPrimitive import net.taler.common.Amount +import net.taler.common.CurrencySpecification import net.taler.wallet.R import net.taler.wallet.backend.TalerErrorCode import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.AmountInputField +import net.taler.wallet.compose.DEFAULT_INPUT_DECIMALS import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.deposit.CurrencyDropdown import net.taler.wallet.exchanges.ExchangeTosStatus +import net.taler.wallet.getAmount +import net.taler.wallet.peer.CheckFeeResult.InsufficientBalance +import net.taler.wallet.peer.CheckFeeResult.None +import net.taler.wallet.peer.CheckFeeResult.Success +import net.taler.wallet.useDebounce import kotlin.random.Random @Composable fun OutgoingPushComposable( state: OutgoingState, - amount: Amount, + defaultCurrency: String?, + currencies: List<String>, + getCurrencySpec: (currency: String) -> CurrencySpecification?, + getFees: (amount: Amount) -> CheckFeeResult?, onSend: (amount: Amount, summary: String, hours: Long) -> Unit, onClose: () -> Unit, ) { when(state) { is OutgoingChecking, is OutgoingCreating, is OutgoingResponse -> PeerCreatingComposable() is OutgoingIntro, is OutgoingChecked -> OutgoingPushIntroComposable( - amount = amount, - state = state, + defaultCurrency = defaultCurrency, + currencies = currencies, + getCurrencySpec = getCurrencySpec, + getFees = getFees, onSend = onSend, ) is OutgoingError -> PeerErrorComposable(state, onClose) @@ -71,8 +86,10 @@ fun OutgoingPushComposable( @Composable fun OutgoingPushIntroComposable( - state: OutgoingState, - amount: Amount, + defaultCurrency: String?, + currencies: List<String>, + getCurrencySpec: (currency: String) -> CurrencySpecification?, + getFees: (amount: Amount) -> CheckFeeResult?, onSend: (amount: Amount, summary: String, hours: Long) -> Unit, ) { val scrollState = rememberScrollState() @@ -83,23 +100,67 @@ fun OutgoingPushIntroComposable( .verticalScroll(scrollState), horizontalAlignment = CenterHorizontally, ) { - Text( - modifier = Modifier.padding(vertical = 16.dp), - text = amount.toString(), - softWrap = false, - style = MaterialTheme.typography.titleLarge, - ) + var selectedCurrency by rememberSaveable { mutableStateOf(defaultCurrency ?: currencies[0]) } + val selectedSpec: CurrencySpecification? = getCurrencySpec(selectedCurrency) + var text by rememberSaveable { mutableStateOf("0") } + val amount = remember(selectedCurrency, text) { getAmount(selectedCurrency, text) } + var feeResult by remember { mutableStateOf<CheckFeeResult>(None) } + + // TODO: make getFees asynchronous! + amount.useDebounce { + feeResult = amount?.let { getFees(amount) } ?: None + } + + LaunchedEffect(Unit) { + feeResult = amount?.let { getFees(amount) } ?: None + } + + Row(Modifier.padding(bottom = 16.dp)) { + AmountInputField( + modifier = Modifier + .weight(1f, true) + .padding(end = 16.dp), + value = text, + onValueChange = { input -> + text = input + }, + label = { Text(stringResource(R.string.amount_send)) }, + numberOfDecimals = selectedSpec?.numFractionalInputDigits ?: DEFAULT_INPUT_DECIMALS, + ) - if (state is OutgoingChecked && state.amountEffective > state.amountRaw) { - val fee = state.amountEffective - state.amountRaw - Text( - modifier = Modifier.padding(vertical = 16.dp), - text = stringResource(id = R.string.payment_fee, fee.withSpec(amount.spec)), - softWrap = false, - color = MaterialTheme.colorScheme.error, + CurrencyDropdown( + modifier = Modifier.weight(1f), + currencies = currencies, + onCurrencyChanged = { selectedCurrency = it }, + initialCurrency = defaultCurrency, + readOnly = false, ) } + when(val res = feeResult) { + is Success -> if (res.amountEffective > res.amountRaw) { + val fee = res.amountEffective - res.amountRaw + Text( + modifier = Modifier.padding(vertical = 16.dp), + text = stringResource(id = R.string.payment_fee, fee.withSpec(selectedSpec)), + softWrap = false, + color = MaterialTheme.colorScheme.error, + ) + } + + is InsufficientBalance -> if (res.maxAmountRaw != null) { + Text( + modifier = Modifier.padding(vertical = 16.dp), + text = stringResource( + R.string.payment_balance_insufficient_max, + res.maxAmountRaw.withSpec(selectedSpec), + ), + ) + } + + else -> {} + } + var subject by rememberSaveable { mutableStateOf("") } val focusRequester = remember { FocusRequester() } OutlinedTextField( @@ -152,8 +213,8 @@ fun OutgoingPushIntroComposable( ) { hours = it } Button( - enabled = state is OutgoingChecked && subject.isNotBlank(), - onClick = { onSend(amount, subject, hours) }, + enabled = feeResult is Success && subject.isNotBlank(), + onClick = { amount?.let { onSend(it, subject, hours) } }, ) { Text(text = stringResource(R.string.send_peer_create_button)) } @@ -165,8 +226,14 @@ fun OutgoingPushIntroComposable( fun PeerPushComposableCreatingPreview() { TalerSurface { OutgoingPushComposable( - amount = Amount.fromString("TESTKUDOS", "42.23"), state = OutgoingCreating, + defaultCurrency = "KUDOS", + currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), + getCurrencySpec = { null }, + getFees = { Success( + amountEffective = Amount.fromJSONString("KUDOS:10"), + amountRaw = Amount.fromJSONString("KUDOS:12"), + ) }, onSend = { _, _, _ -> }, onClose = {}, ) @@ -180,7 +247,13 @@ fun PeerPushComposableCheckingPreview() { val state = if (Random.nextBoolean()) OutgoingIntro else OutgoingChecking OutgoingPushComposable( state = state, - amount = Amount.fromString("TESTKUDOS", "42.23"), + defaultCurrency = "KUDOS", + currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), + getCurrencySpec = { null }, + getFees = { Success( + amountEffective = Amount.fromJSONString("KUDOS:10"), + amountRaw = Amount.fromJSONString("KUDOS:12"), + ) }, onSend = { _, _, _ -> }, onClose = {}, ) @@ -196,7 +269,13 @@ fun PeerPushComposableCheckedPreview() { val state = OutgoingChecked(amountRaw, amountEffective, "https://exchange.demo.taler.net", ExchangeTosStatus.Accepted) OutgoingPushComposable( state = state, - amount = amountEffective, + defaultCurrency = "KUDOS", + currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), + getCurrencySpec = { null }, + getFees = { Success( + amountEffective = Amount.fromJSONString("KUDOS:10"), + amountRaw = Amount.fromJSONString("KUDOS:12"), + ) }, onSend = { _, _, _ -> }, onClose = {}, ) @@ -210,8 +289,14 @@ fun PeerPushComposableErrorPreview() { val json = mapOf("foo" to JsonPrimitive("bar")) val state = OutgoingError(TalerErrorInfo(TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED, "hint", "message", json)) OutgoingPushComposable( - amount = Amount.fromString("TESTKUDOS", "42.23"), state = state, + defaultCurrency = "KUDOS", + currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), + getCurrencySpec = { null }, + getFees = { Success( + amountEffective = Amount.fromJSONString("KUDOS:10"), + amountRaw = Amount.fromJSONString("KUDOS:12"), + ) }, onSend = { _, _, _ -> }, onClose = {}, ) diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt @@ -30,6 +30,7 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.findNavController import androidx.navigation.fragment.findNavController import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import net.taler.common.Amount import net.taler.wallet.MainViewModel import net.taler.wallet.R @@ -55,12 +56,6 @@ class OutgoingPushFragment : Fragment() { container: ViewGroup?, savedInstanceState: Bundle?, ): View { - val amount = arguments?.getString("amount")?.let { - Amount.fromJSONString(it) - } ?: error("no amount passed") - val scopeInfo = transactionManager.selectedScope - val spec = scopeInfo?.let { balanceManager.getSpecForScopeInfo(it) } - requireActivity().onBackPressedDispatcher.addCallback( viewLifecycleOwner, backPressedCallback ) @@ -70,8 +65,14 @@ class OutgoingPushFragment : Fragment() { TalerSurface { val state = peerManager.pushState.collectAsStateLifecycleAware().value OutgoingPushComposable( - amount = amount.withSpec(spec), state = state, + defaultCurrency = transactionManager.selectedScope.value?.currency, + currencies = balanceManager.getCurrencies(), + getCurrencySpec = balanceManager::getSpecForCurrency, + getFees = { fees -> + // TODO: make this async!!! + runBlocking { peerManager.checkPeerPushFees(fees) } + }, onSend = this@OutgoingPushFragment::onSend, onClose = { findNavController().navigate(R.id.action_nav_peer_push_to_nav_main) diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingState.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingState.kt @@ -52,6 +52,14 @@ data class CheckPeerPullCreditResponse( ) @Serializable +data class CheckPeerPullCreditResult( + val exchangeBaseUrl: String, + val amountRaw: Amount, + val amountEffective: Amount, + val tosStatus: ExchangeTosStatus?, +) + +@Serializable data class InitiatePeerPullPaymentResponse( val transactionId: String, ) diff --git a/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt b/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt @@ -31,7 +31,6 @@ import kotlinx.serialization.json.jsonPrimitive import net.taler.common.Amount import net.taler.common.Timestamp import net.taler.wallet.TAG -import net.taler.wallet.backend.TalerErrorCode.UNKNOWN import net.taler.wallet.backend.TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi @@ -77,31 +76,25 @@ class PeerManager( private val _incomingPushState = MutableStateFlow<IncomingState>(IncomingChecking) val incomingPushState: StateFlow<IncomingState> = _incomingPushState - fun checkPeerPullCredit(amount: Amount) { - _outgoingPullState.value = OutgoingChecking - scope.launch(Dispatchers.IO) { - val exchangeItem = exchangeManager.findExchange(amount.currency) - if (exchangeItem == null) { - _outgoingPullState.value = OutgoingError( - TalerErrorInfo(UNKNOWN, "No exchange found for ${amount.currency}") - ) - return@launch - } - api.request("checkPeerPullCredit", CheckPeerPullCreditResponse.serializer()) { - put("exchangeBaseUrl", exchangeItem.exchangeBaseUrl) - put("amount", amount.toJSONString()) - }.onSuccess { - _outgoingPullState.value = OutgoingChecked( - amountRaw = it.amountRaw, - amountEffective = it.amountEffective, - exchangeBaseUrl = exchangeItem.exchangeBaseUrl, - tosStatus = exchangeItem.tosStatus, - ) - }.onError { error -> - Log.e(TAG, "got checkPeerPullCredit error result $error") - _outgoingPullState.value = OutgoingError(error) - } + suspend fun checkPeerPullCredit(amount: Amount, exchangeBaseUrl: String? = null): CheckPeerPullCreditResult? { + var response: CheckPeerPullCreditResult? = null + val exchangeItem = exchangeManager.findExchange(amount.currency) ?: return null + + api.request("checkPeerPullCredit", CheckPeerPullCreditResponse.serializer()) { + exchangeBaseUrl?.let { put("exchangeBaseUrl", it) } + put("amount", amount.toJSONString()) + }.onSuccess { + response = CheckPeerPullCreditResult( + amountEffective = it.amountEffective, + amountRaw = it.amountRaw, + exchangeBaseUrl = it.exchangeBaseUrl, + tosStatus = exchangeItem.tosStatus, + ) + }.onError { error -> + Log.e(TAG, "got checkPeerPullCredit error result $error") } + + return response } fun initiatePeerPullCredit(amount: Amount, summary: String, expirationHours: Long, exchangeBaseUrl: String) { @@ -128,28 +121,6 @@ class PeerManager( _outgoingPullState.value = OutgoingIntro } - fun checkPeerPushDebit(amount: Amount) { - _outgoingPushState.value = OutgoingChecking - scope.launch(Dispatchers.IO) { - api.request("checkPeerPushDebit", CheckPeerPushDebitResponse.serializer()) { - put("amount", amount.toJSONString()) - }.onSuccess { response -> - scope.launch { - val exchangeItem = exchangeManager.findExchangeByUrl(response.exchangeBaseUrl) - _outgoingPushState.value = OutgoingChecked( - amountRaw = response.amountRaw, - amountEffective = response.amountEffective, - exchangeBaseUrl = response.exchangeBaseUrl, - tosStatus = exchangeItem?.tosStatus, - ) - } - }.onError { error -> - Log.e(TAG, "got checkPeerPushDebit error result $error") - _outgoingPushState.value = OutgoingError(error) - } - } - } - suspend fun checkPeerPushFees(amount: Amount, exchangeBaseUrl: String? = null): CheckFeeResult { var response: CheckFeeResult = CheckFeeResult.None diff --git a/wallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt b/wallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt @@ -169,6 +169,11 @@ class SettingsFragment : PreferenceFragmentCompat() { } } + override fun onStart() { + super.onStart() + requireActivity().title = getString(R.string.menu_settings) + } + private fun showImportDialog() { MaterialAlertDialogBuilder(requireContext(), R.style.MaterialAlertDialog_Material3) .setMessage(R.string.settings_dialog_import_message) diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt @@ -54,7 +54,7 @@ import net.taler.wallet.transactions.TransactionAction.Suspend import net.taler.wallet.transactions.TransactionMajorState.Pending class TransactionLossFragment: TransactionDetailFragment() { - val scope get() = transactionManager.selectedScope + val scope get() = transactionManager.selectedScope.value override fun onCreateView( inflater: LayoutInflater, diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt @@ -49,7 +49,8 @@ class TransactionManager( // FIXME if the app gets killed, this will not be restored and thus be unexpected null // we should keep this in a savable, maybe using Hilt and SavedStateViewModel - var selectedScope: ScopeInfo? = null + // var selectedScope: ScopeInfo? = null + val selectedScope: MutableLiveData<ScopeInfo?> = MutableLiveData(null) val searchQuery = MutableLiveData<String>(null) private val mSelectedTransaction = MutableLiveData<Transaction?>(null) @@ -60,14 +61,14 @@ class TransactionManager( @UiThread get() = searchQuery.switchMap { query -> val scopeInfo = selectedScope - check(scopeInfo != null) { "Did not select scope before getting transactions" } + check(scopeInfo.value != null) { "Did not select scope before getting transactions" } loadTransactions(query) - mTransactions[scopeInfo]!! // non-null because filled in [loadTransactions] + mTransactions[scopeInfo.value]!! // non-null because filled in [loadTransactions] } @UiThread fun loadTransactions(searchQuery: String? = null) = scope.launch { - val scopeInfo = selectedScope ?: return@launch + val scopeInfo = selectedScope.value ?: return@launch val liveData = mTransactions.getOrPut(scopeInfo) { MutableLiveData() } if (searchQuery == null && allTransactions.containsKey(scopeInfo)) { liveData.value = TransactionsResult.Success(allTransactions[scopeInfo]!!) @@ -217,7 +218,7 @@ class TransactionManager( } fun deleteTransactions(transactionIds: List<String>, onError: (it: TalerErrorInfo) -> Unit) { - allTransactions[selectedScope]?.filter { transaction -> + allTransactions[selectedScope.value]?.filter { transaction -> transaction.transactionId in transactionIds }?.forEach { toBeDeletedTx -> if (Delete in toBeDeletedTx.txActions) { diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt @@ -86,7 +86,7 @@ class TransactionWithdrawalFragment : TransactionDetailFragment(), ActionListene amountRaw = tx.amountRaw, amountEffective = tx.amountEffective, withdrawalAccountList = tx.withdrawalDetails.exchangeCreditAccountDetails, - scopeInfo = transactionManager.selectedScope ?: ScopeInfo.Exchange( + scopeInfo = transactionManager.selectedScope.value ?: ScopeInfo.Exchange( currency = tx.amountRaw.currency, url = tx.exchangeBaseUrl, ), diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionsFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionsFragment.kt @@ -24,9 +24,9 @@ import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View -import android.view.View.INVISIBLE +import android.view.View.GONE +import android.view.View.VISIBLE import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView.OnQueryTextListener import androidx.fragment.app.Fragment @@ -63,7 +63,7 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode. private lateinit var ui: FragmentTransactionsBinding private val transactionAdapter by lazy { TransactionAdapter(this) } - private val scopeInfo by lazy { transactionManager.selectedScope!! } + private val scopeInfo by lazy { transactionManager.selectedScope.value!! } private var tracker: SelectionTracker<String>? = null private var actionMode: ActionMode? = null @@ -85,6 +85,7 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode. adapter = transactionAdapter addItemDecoration(DividerItemDecoration(context, VERTICAL)) } + val tracker = SelectionTracker.Builder( "transaction-selection-id", ui.list, @@ -115,15 +116,35 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode. balanceManager.state.observe(viewLifecycleOwner) { state -> if (state !is Success) return@observe val balances = state.balances - // hide extra fab when in single currency mode (uses MainFragment's FAB) - if (balances.size == 1) ui.mainFab.visibility = INVISIBLE balances.find { it.scopeInfo == scopeInfo }?.let { balance -> + val spec = balanceManager.getSpecForScopeInfo(scopeInfo) + ui.actionsBar.amount.text = balance.available.toString(showSymbol = false) + ui.actionsBar.currencyLabel.text = if (spec != null) { + if (spec.symbol != null && spec.name != spec.symbol) { + // Name (symbol) + getString(R.string.transactions_currency, spec.name, spec.symbol) + } else if (spec.name != balance.currency) { + // Name (currency string) + getString(R.string.transactions_currency, spec.name, balance.currency) + } else balance.currency + } else balance.currency + + if (balance.scopeInfo is ScopeInfo.Exchange) { + ui.actionsBar.exchangeLabel.text = cleanExchange(balance.scopeInfo.url) + ui.actionsBar.exchangeLabel.visibility = VISIBLE + } else { + ui.actionsBar.exchangeLabel.visibility = GONE + } transactionAdapter.update(updatedCurrencySpec = balance.available.spec) } } + ui.actionsBar.currencyCard.setOnClickListener { + requireActivity().onBackPressed() + } + // TODO: refactor and unify progress bar handling // transactionManager.progress.observe(viewLifecycleOwner) { show -> // if (show) ui.progressBar.fadeIn() else ui.progressBar.fadeOut() @@ -136,23 +157,11 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode. networkManager.networkStatus.observe(viewLifecycleOwner) { state -> transactionAdapter.update(updatedNetworkAvailable = state) } + } - ui.actionsBar.sendButton.setOnClickListener { - findNavController().navigate(R.id.sendFunds) - } - - ui.actionsBar.receiveButton.setOnClickListener { - findNavController().navigate(R.id.action_global_receiveFunds) - } - - ui.mainFab.setOnClickListener { - model.scanCode() - } - - ui.mainFab.setOnLongClickListener { - findNavController().navigate(R.id.action_nav_transactions_to_nav_uri_input) - true - } + override fun onStart() { + super.onStart() + requireActivity().title = getString(R.string.transactions_title) } override fun onSaveInstanceState(outState: Bundle) { @@ -166,13 +175,6 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode. setupSearch(menu.findItem(R.id.action_search)) } - override fun onStart() { - super.onStart() - requireActivity().title = getString(R.string.transactions_detail_title_currency, scopeInfo.currency) - (requireActivity() as AppCompatActivity).supportActionBar?.subtitle = - (scopeInfo as? ScopeInfo.Exchange)?.url?.let { cleanExchange(it) } - } - private fun setupSearch(item: MenuItem) { item.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { override fun onMenuItemActionExpand(item: MenuItem) = true @@ -280,11 +282,6 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode. return true } - override fun onStop() { - super.onStop() - (requireActivity() as AppCompatActivity).supportActionBar?.subtitle = null - } - override fun onDestroyActionMode(mode: ActionMode) { tracker?.clearSelection() actionMode = null @@ -295,5 +292,4 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode. actionMode?.title = num } } - } diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt @@ -20,6 +20,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -79,6 +80,7 @@ import net.taler.wallet.compose.DEFAULT_INPUT_DECIMALS import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.deposit.CurrencyDropdown import net.taler.wallet.exchanges.ExchangeItem import net.taler.wallet.exchanges.ExchangeTosStatus import net.taler.wallet.exchanges.SelectExchangeDialogFragment @@ -99,6 +101,7 @@ class PromptWithdrawFragment: Fragment() { private val selectExchangeDialog = SelectExchangeDialogFragment() + private var editableCurrency: Boolean = true private var startup: Boolean = true private var navigating: Boolean = false @@ -107,6 +110,8 @@ class PromptWithdrawFragment: Fragment() { container: ViewGroup?, savedInstanceState: Bundle? ) = ComposeView(requireContext()).apply { + editableCurrency = arguments?.getBoolean("editableCurrency") ?: true + setContent { val status by withdrawManager.withdrawStatus.collectAsStateLifecycleAware() val coroutineScope = rememberCoroutineScope() @@ -120,9 +125,9 @@ class PromptWithdrawFragment: Fragment() { } when (s.status) { - None, Loading -> LoadingScreen() + Loading -> LoadingScreen() - InfoReceived, TosReviewRequired, Updating -> { + None, InfoReceived, TosReviewRequired, Updating -> { val spec = remember(s) { defaultExchange?.scopeInfo?.let { scopeInfo -> balanceManager.getSpecForScopeInfo(scopeInfo) @@ -131,20 +136,26 @@ class PromptWithdrawFragment: Fragment() { } } + val currencies = balanceManager.getCurrencies() + + // TODO: use scopeInfo instead of currency! WithdrawalShowInfo( status = s, - currency = s.currency ?: error("no currency specified"), + defaultCurrency = s.currency + ?: transactionManager.selectedScope.value?.currency + ?: currencies.firstOrNull() + ?: error("no default currency specified"), + editableCurrency = editableCurrency, + currencies = currencies, spec = spec, onSelectExchange = { selectExchange() }, onSelectAmount = { amount -> - if (s.exchangeBaseUrl != null) { - withdrawManager.getWithdrawalDetails( - amount = amount, - exchangeBaseUrl = s.exchangeBaseUrl, - loading = false, - ) + if (s.status == None) { + getInitialDetails(amount) + } else { + getUpdatedDetails(s, amount) } }, onTosReview = { @@ -194,7 +205,8 @@ class PromptWithdrawFragment: Fragment() { withdrawManager.getWithdrawalDetails( amount = status.amountInfo?.amountRaw ?: status.uriInfo?.amount, exchangeBaseUrl = status.exchangeBaseUrl ?: status.uriInfo?.defaultExchangeBaseUrl, - loading = true, + // don't show loading screen when withdrawal is not from QR/URI + loading = !editableCurrency, ) } @@ -239,6 +251,62 @@ class PromptWithdrawFragment: Fragment() { } } + // TODO: move to manager, maybe? + private fun getInitialDetails(amount: Amount) { + viewLifecycleOwner.lifecycleScope.launch { + exchangeManager.findExchangeForCurrency(amount.currency).collect { exchange -> + if (exchange == null) { + Toast.makeText(requireContext(), "No exchange available", Toast.LENGTH_LONG).show() + return@collect + } + + exchangeManager.withdrawalExchange = exchange + withdrawManager.getWithdrawalDetails( + exchangeBaseUrl = exchange.exchangeBaseUrl, + uriInfo = WithdrawalDetailsForUri( + amount = amount, + currency = amount.currency, + editableAmount = true, + ), + amount = amount, + loading = false, + ) + } + } + } + + // TODO: move to manager, maybe? + private fun getUpdatedDetails(s: WithdrawStatus, amount: Amount) { + val oldAmount = s.amountInfo?.amountRaw ?: s.uriInfo?.amount + if (oldAmount == amount) return + + if (oldAmount?.currency == amount.currency) { + withdrawManager.getWithdrawalDetails( + amount = amount, + exchangeBaseUrl = s.exchangeBaseUrl, + loading = false, + ) + return + } + + viewLifecycleOwner.lifecycleScope.launch { + exchangeManager.findExchangeForCurrency(amount.currency).collect { exchange -> + if (exchange == null) { + Toast.makeText(requireContext(), "No exchange available", Toast.LENGTH_LONG) + .show() + return@collect + } + + exchangeManager.withdrawalExchange = exchange + withdrawManager.getWithdrawalDetails( + amount = amount, + exchangeBaseUrl = exchange.exchangeBaseUrl, + loading = false, + ) + } + } + } + private fun selectExchange() { val exchanges = withdrawManager.withdrawStatus.value.uriInfo?.possibleExchanges ?: return selectExchangeDialog.setExchanges(exchanges) @@ -255,7 +323,9 @@ class PromptWithdrawFragment: Fragment() { @Composable fun WithdrawalShowInfo( status: WithdrawStatus, - currency: String, + defaultCurrency: String, + editableCurrency: Boolean, + currencies: List<String>, spec: CurrencySpecification?, onSelectAmount: (amount: Amount) -> Unit, onSelectExchange: () -> Unit, @@ -264,8 +334,8 @@ fun WithdrawalShowInfo( ) { val defaultAmount = status.amountInfo?.amountRaw ?: status.uriInfo?.amount val maxAmount = status.uriInfo?.maxAmount - val editableAmount = status.uriInfo?.editableAmount ?: false - val wireFee = status.uriInfo?.wireFee ?: Amount.zero(currency) + val editableAmount = status.uriInfo?.editableAmount ?: editableCurrency + val wireFee = status.uriInfo?.wireFee ?: Amount.zero(defaultCurrency) val exchange = status.exchangeBaseUrl val possibleExchanges = status.uriInfo?.possibleExchanges ?: emptyList() val ageRestrictionOptions = status.amountInfo?.ageRestrictionOptions ?: emptyList() @@ -296,8 +366,9 @@ fun WithdrawalShowInfo( if (editableAmount) { WithdrawAmountComposable( defaultAmount = defaultAmount?.withSpec(spec), + editableCurrency = editableCurrency, + currencies = currencies, maxAmount = maxAmount?.withSpec(spec), - currency = currency, spec = spec, onAmountChanged = { amount, err -> selectedAmount = amount @@ -445,32 +516,35 @@ fun WithdrawalError( @Composable fun WithdrawAmountComposable( defaultAmount: Amount?, + editableCurrency: Boolean, + currencies: List<String>, maxAmount: Amount?, - currency: String, spec: CurrencySpecification?, onAmountChanged: (amount: Amount?, error: Boolean) -> Unit, ) { var text by remember { mutableStateOf(defaultAmount?.amountStr ?: "0") } + val currency = remember(defaultAmount, currencies) { defaultAmount?.currency ?: currencies[0] } val amount = remember(currency, text) { getAmount(currency, text) } val insufficientBalance = remember(amount, maxAmount) { amount?.let { maxAmount == null || it > maxAmount } == true } Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 16.dp), + modifier = Modifier + .padding(top = 16.dp) + .padding(horizontal = 16.dp), ) { AmountInputField( modifier = Modifier - .weight(1f) - .padding(16.dp), + .padding(end = 16.dp) + .weight(1f), value = text, onValueChange = { value -> text = value // Update selected amount getAmount(currency, text)?.let { - onAmountChanged(it, maxAmount == null || it > maxAmount) + onAmountChanged(it, maxAmount != null && it > maxAmount) } ?: onAmountChanged(null, true) }, label = { Text(stringResource(R.string.amount_withdraw)) }, @@ -483,11 +557,17 @@ fun WithdrawAmountComposable( numberOfDecimals = spec?.numFractionalInputDigits ?: DEFAULT_INPUT_DECIMALS, ) - Text( - modifier = Modifier, - text = spec?.symbol ?: currency, - softWrap = false, - style = MaterialTheme.typography.titleLarge, + CurrencyDropdown( + modifier = Modifier.weight(1f), + currencies = currencies, + onCurrencyChanged = { value -> + // Update selected amount + getAmount(value, text)?.let { + onAmountChanged(it, maxAmount != null && it > maxAmount) + } ?: onAmountChanged(null, true) + }, + initialCurrency = currency, + readOnly = !editableCurrency, ) } } @@ -540,7 +620,9 @@ fun WithdrawalShowInfoPreview() { ), ) ), - currency = "KUDOS", + defaultCurrency = "KUDOS", + editableCurrency = true, + currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), spec = null, onSelectExchange = {}, onSelectAmount = {}, diff --git a/wallet/src/main/res/drawable/ic_actions.xml b/wallet/src/main/res/drawable/ic_actions.xml @@ -0,0 +1,44 @@ +<!-- + ~ This file is part of GNU Taler + ~ (C) 2024 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/> + --> + +<vector + xmlns:android="http://schemas.android.com/apk/res/android" + android:height="50dp" + android:viewportHeight="200" + android:viewportWidth="200" + android:width="50dp"> + + <path + android:fillColor="#ff0000" + android:fillType="evenOdd" + android:pathData="M57.6,43.4c-25.5,4.3 -44.9,28 -44.9,56.5 0,31.5 23.9,57.2 53.3,57.2s53.3,-25.6 53.3,-57.2c0,-15.4 -5.7,-29.3 -14.9,-39.6c1.6,-1.9 6.3,-4.8 6.4,-4.6 10,11.6 16.1,27.2 16.1,44.2 0,36 -27.3,65.3 -60.9,65.3 -33.6,0 -60.9,-29.3 -60.9,-65.3s27.3,-65.3 60.9,-65.3c1.7,0 5.7,0.3 5.5,0.4 -4.3,2.3 -9.7,5.4 -13.9,8.5"/> + + <path + android:fillColor="#ff0000" + android:fillType="evenOdd" + android:pathData="M60.8,149.8c-13.4,-12 -22,-29.9 -22,-50 0,-36 27.4,-65.2 61.1,-65.2 1.5,0 3,0.1 4.5,0.2a67.6,67.6 0,0 0,-13.4 8.6c-25.4,4.5 -44.7,28.1 -44.7,56.4 0,21.3 11,40 27.3,49.8a45.9,45.9 0,0 1,-12.7 0.3z"/> + + <path + android:fillColor="#ff0000" + android:fillType="evenOdd" + android:pathData="M142.4,156.6c25.5,-4.3 44.9,-28 44.9,-56.5 -0,-31.5 -23.9,-57.2 -53.3,-57.2s-53.3,25.6 -53.3,57.2c-0,15.4 5.7,29.3 14.9,39.6c-1.6,1.9 -6.3,4.8 -6.4,4.6 -10,-11.6 -16.1,-27.2 -16.1,-44.2 -0,-36 27.3,-65.3 60.9,-65.3 33.6,-0 60.9,29.3 60.9,65.3s-27.3,65.3 -60.9,65.3c-1.7,-0 -5.7,-0.3 -5.5,-0.4 4.3,-2.3 9.7,-5.4 13.9,-8.5"/> + + <path + android:fillColor="#ff0000" + android:fillType="evenOdd" + android:pathData="M139.2,50.2c13.4,12 22,29.9 22,50 -0,36 -27.4,65.2 -61.1,65.2 -1.5,-0 -3,-0.1 -4.5,-0.2a67.6,67.6 0,0 0,13.4 -8.6c25.4,-4.5 44.7,-28.1 44.7,-56.4 -0,-21.3 -11,-40 -27.3,-49.8a45.9,45.9 45,0 1,12.7 -0.3z"/> + +</vector> diff --git a/wallet/src/main/res/drawable/ic_dropdown.xml b/wallet/src/main/res/drawable/ic_dropdown.xml @@ -0,0 +1,27 @@ +<!-- + ~ This file is part of GNU Taler + ~ (C) 2024 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/> + --> + +<vector + xmlns:android="http://schemas.android.com/apk/res/android" + android:height="24dp" + android:tint="?attr/colorControlNormal" + android:viewportHeight="24" + android:viewportWidth="24" + android:width="24dp"> + + <path android:fillColor="@android:color/white" android:pathData="M7,10l5,5 5,-5z"/> + +</vector> diff --git a/wallet/src/main/res/drawable/ic_link.xml b/wallet/src/main/res/drawable/ic_link.xml @@ -0,0 +1,5 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"> + + <path android:fillColor="@android:color/white" android:pathData="M3.9,12c0,-1.71 1.39,-3.1 3.1,-3.1h4L11,7L7,7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5h4v-1.9L7,15.1c-1.71,0 -3.1,-1.39 -3.1,-3.1zM8,13h8v-2L8,11v2zM17,7h-4v1.9h4c1.71,0 3.1,1.39 3.1,3.1s-1.39,3.1 -3.1,3.1h-4L13,17h4c2.76,0 5,-2.24 5,-5s-2.24,-5 -5,-5z"/> + +</vector> diff --git a/wallet/src/main/res/layout/activity_main.xml b/wallet/src/main/res/layout/activity_main.xml @@ -29,13 +29,4 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> - <com.google.android.material.navigation.NavigationView - android:id="@+id/nav_view" - android:layout_width="wrap_content" - android:layout_height="match_parent" - android:layout_gravity="start" - android:fitsSystemWindows="false" - app:headerLayout="@layout/nav_header_main" - app:menu="@menu/activity_main_drawer" /> - </androidx.drawerlayout.widget.DrawerLayout> diff --git a/wallet/src/main/res/layout/balance_actions.xml b/wallet/src/main/res/layout/balance_actions.xml @@ -20,42 +20,75 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> - <com.google.android.material.button.MaterialButton - android:id="@+id/sendButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="10dp" - android:layout_marginVertical="10dp" - android:paddingHorizontal="18dp" - android:text="@string/transactions_send_funds" - app:icon="@drawable/ic_funds_send" - tools:ignore="MissingConstraints" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/receiveButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginHorizontal="10dp" - android:layout_marginVertical="10dp" - android:paddingHorizontal="18dp" - android:text="@string/transactions_receive_funds" - app:icon="@drawable/ic_funds_receive" - tools:ignore="MissingConstraints" /> - - <androidx.constraintlayout.helper.widget.Flow + <com.google.android.material.card.MaterialCardView + android:id="@+id/currencyCard" android:layout_width="0dp" android:layout_height="wrap_content" - app:constraint_referenced_ids="sendButton,receiveButton" - android:paddingHorizontal="10dp" - app:flow_horizontalGap="10dp" - app:flow_horizontalBias="0" - app:flow_horizontalAlign="start" - app:flow_horizontalStyle="packed" - app:flow_wrapMode="chain" - app:layout_constraintTop_toTopOf="parent" + android:layout_margin="9dp" + app:contentPadding="15dp" + app:contentPaddingLeft="18dp" + android:elevation="10dp" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toStartOf="@id/amountBarrier" - app:layout_constraintBottom_toBottomOf="@id/topBarrier"/> + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toTopOf="@id/topBarrier" + style="@style/Widget.Material3.CardView.Outlined" + android:clickable="true" + android:focusable="true" + android:background="?selectableItemBackground"> + <androidx.constraintlayout.widget.ConstraintLayout + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <TextView + android:id="@+id/currencyLabel" + android:layout_width="0dp" + android:layout_height="wrap_content" + style="@style/TextAppearance.Material3.TitleMedium" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toStartOf="@id/dropdownIcon" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toTopOf="@id/exchangeLabel" + tools:text="Euro (€)"/> + + <TextView + android:id="@+id/exchangeLabel" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:paddingTop="3dp" + style="@style/TextAppearance.Material3.BodySmall" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toStartOf="@id/dropdownIcon" + app:layout_constraintBottom_toBottomOf="parent" + android:visibility="gone" + tools:text="exchange.demo.taler.net" + tools:visibility="visible"/> + +<!-- <ImageButton--> +<!-- android:id="@+id/backButton"--> +<!-- android:layout_width="wrap_content"--> +<!-- android:layout_height="wrap_content"--> +<!-- android:padding="6dp"--> +<!-- android:layout_marginStart="9dp"--> +<!-- android:src="@drawable/ic_dropdown"--> +<!-- app:layout_constraintStart_toStartOf="parent"--> +<!-- app:layout_constraintTop_toTopOf="parent"--> +<!-- app:layout_constraintBottom_toBottomOf="parent"--> +<!-- android:contentDescription="@string/button_back"--> +<!-- android:background="?android:selectableItemBackground"/>--> + + <ImageView + android:id="@+id/dropdownIcon" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:src="@drawable/ic_dropdown" + android:contentDescription="@string/button_back" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" /> + + </androidx.constraintlayout.widget.ConstraintLayout> + </com.google.android.material.card.MaterialCardView> <androidx.constraintlayout.widget.Barrier android:id="@+id/amountBarrier" @@ -104,7 +137,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" app:barrierDirection="bottom" - app:constraint_referenced_ids="sendButton,receiveButton,amountLayout" /> + app:constraint_referenced_ids="currencyCard" /> <com.google.android.material.divider.MaterialDivider android:id="@+id/divider" diff --git a/wallet/src/main/res/layout/fragment_transactions.xml b/wallet/src/main/res/layout/fragment_transactions.xml @@ -84,16 +84,4 @@ app:layout_constraintTop_toTopOf="parent" tools:visibility="visible" /> - <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton - android:id="@+id/mainFab" - style="@style/FabStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:contentDescription="@string/button_scan_qr_code" - android:text="@string/button_scan_qr_code_label" - app:icon="@drawable/ic_scan_qr" - app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" /> - </androidx.constraintlayout.widget.ConstraintLayout> diff --git a/wallet/src/main/res/menu/activity_main_drawer.xml b/wallet/src/main/res/menu/activity_main_drawer.xml @@ -1,47 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<menu xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - tools:showIn="@layout/activity_main"> - - <group - android:id="@+id/nav_group_main" - android:checkableBehavior="single"> - <item - android:id="@+id/nav_home" - android:icon="@drawable/ic_account_balance_wallet" - android:title="@string/balances_title" - tools:checked="true" /> - <item - android:id="@+id/nav_settings" - android:icon="@drawable/ic_settings" - android:title="@string/menu_settings" /> - </group> - - <item - android:id="@+id/nav_dev" - android:title="@string/settings_dev_mode"> - <menu> - <group - android:id="@+id/nav_group_dev" - android:checkableBehavior="single"> - <!-- Future dev options go here --> - </group> - </menu> - </item> - -</menu> diff --git a/wallet/src/main/res/navigation/nav_graph.xml b/wallet/src/main/res/navigation/nav_graph.xml @@ -26,9 +26,6 @@ android:label="@string/balances_title" tools:layout="@layout/fragment_balances"> <action - android:id="@+id/action_nav_main_to_nav_transactions" - app:destination="@id/nav_transactions" /> - <action android:id="@+id/action_nav_main_to_nav_uri_input" app:destination="@id/nav_uri_input" /> </fragment> @@ -47,16 +44,6 @@ app:nullable="false" /> <action - android:id="@+id/action_handleUri_to_receiveFunds" - app:destination="@id/receiveFunds" - app:popUpTo="@id/nav_main" /> - - <action - android:id="@+id/action_handleUri_to_sendFunds" - app:destination="@id/sendFunds" - app:popUpTo="@id/nav_main" /> - - <action android:id="@+id/action_handleUri_to_manualWithdrawal" app:destination="@id/nav_exchange_manual_withdrawal" app:popUpTo="@id/nav_main" /> @@ -88,30 +75,6 @@ </fragment> <fragment - android:id="@+id/receiveFunds" - android:name="net.taler.wallet.ReceiveFundsFragment" - android:label="@string/transactions_receive_funds"> - <action - android:id="@+id/action_receiveFunds_to_nav_prompt_withdraw" - app:destination="@id/promptWithdraw" /> - <action - android:id="@+id/action_receiveFunds_to_nav_peer_pull" - app:destination="@id/nav_peer_pull" /> - </fragment> - - <fragment - android:id="@+id/sendFunds" - android:name="net.taler.wallet.SendFundsFragment" - android:label="@string/transactions_send_funds"> - <action - android:id="@+id/action_sendFunds_to_nav_deposit" - app:destination="@id/nav_deposit" /> - <action - android:id="@+id/action_sendFunds_to_nav_peer_push" - app:destination="@id/nav_peer_push" /> - </fragment> - - <fragment android:id="@+id/nav_payto_uri" android:name="net.taler.wallet.deposit.PayToUriFragment" android:label="@string/transactions_send_funds"> @@ -139,15 +102,6 @@ </fragment> <fragment - android:id="@+id/nav_settings" - android:name="net.taler.wallet.settings.SettingsFragment" - android:label="@string/menu_settings"> - <action - android:id="@+id/action_nav_settings_to_nav_settings_exchanges" - app:destination="@id/nav_settings_exchanges" /> - </fragment> - - <fragment android:id="@+id/nav_settings_exchanges" android:name="net.taler.wallet.exchanges.ExchangeListFragment" android:label="@string/exchange_list_title"> @@ -279,16 +233,6 @@ </fragment> <fragment - android:id="@+id/nav_transactions" - android:name="net.taler.wallet.transactions.TransactionsFragment" - android:label="@string/transactions_title" - tools:layout="@layout/fragment_transactions"> - <action - android:id="@+id/action_nav_transactions_to_nav_uri_input" - app:destination="@id/nav_uri_input" /> - </fragment> - - <fragment android:id="@+id/nav_transactions_detail_withdrawal" android:name="net.taler.wallet.transactions.TransactionWithdrawalFragment" android:label="@string/transactions_detail_title"> @@ -336,6 +280,10 @@ android:id="@+id/promptWithdraw" android:name="net.taler.wallet.withdraw.PromptWithdrawFragment" android:label="@string/nav_prompt_withdraw"> + <argument + android:name="editableCurrency" + android:defaultValue="true" + app:argType="boolean" /> <action android:id="@+id/action_promptWithdraw_to_nav_main" app:destination="@id/nav_main" @@ -382,14 +330,6 @@ app:destination="@id/handleUri" /> <action - android:id="@+id/action_global_receiveFunds" - app:destination="@id/receiveFunds" /> - - <action - android:id="@+id/action_global_sendFunds" - app:destination="@id/sendFunds" /> - - <action android:id="@+id/action_handleUri_to_promptWithdraw" app:destination="@id/promptWithdraw" app:popUpTo="@id/nav_main" /> diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -48,6 +48,7 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <!-- General --> + <string name="actions">Actions</string> <string name="button_back">Go Back</string> <string name="button_scan_qr_code">Scan Taler QR Code</string> <string name="button_scan_qr_code_label">Scan QR code</string> @@ -58,6 +59,7 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="currency">Currency</string> <string name="edit">Edit</string> <string name="enter_uri">Enter taler:// URI</string> + <string name="enter_uri_label">Enter URI</string> <string name="import_db">Import</string> <string name="loading">Loading</string> <string name="menu">Menu</string> @@ -89,6 +91,7 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="amount_chosen">Chosen amount</string> <string name="amount_conversion">Conversion</string> + <string name="amount_deposit">Amount to deposit</string> <string name="amount_effective">Effective amount</string> <string name="amount_fee">Fee</string> <string name="amount_invalid">Amount invalid</string> @@ -150,6 +153,8 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="transactions_abort_dialog_message">Are you sure you want to abort this transaction? Funds still in transit might get lost.</string> <string name="transactions_abort_dialog_title">Abort Transaction</string> <string name="transactions_balance">Balance</string> + <!-- Currency name (symbol) --> + <string name="transactions_currency">%1$s (%2$s)</string> <string name="transactions_delete">Delete</string> <string name="transactions_delete_dialog_message">Are you sure you want to remove this transaction from your wallet?</string> <string name="transactions_delete_dialog_title">Delete Transaction</string> @@ -163,12 +168,10 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="transactions_fail_dialog_message">Are you sure you abandon this transaction? Funds still in transit WILL GET LOST.</string> <string name="transactions_fail_dialog_title">Abandon transaction</string> <string name="transactions_receive_funds">Receive</string> - <string name="transactions_receive_funds_title">Receive %1$s</string> <string name="transactions_resume">Resume</string> <string name="transactions_retry">Retry</string> <string name="transactions_select_all">Select All</string> <string name="transactions_send_funds">Send</string> - <string name="transactions_send_funds_title">Send %1$s</string> <string name="transactions_suspend">Suspend</string> <string name="transactions_title">Transactions</string> @@ -215,6 +218,7 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="send_deposit_bitcoin">To a Bitcoin wallet</string> <string name="send_deposit_bitcoin_address">Bitcoin address</string> <string name="send_deposit_bitcoin_create_button">Transfer Bitcoin</string> + <string name="send_deposit_button_label">Deposit</string> <string name="send_deposit_check_fees_button">Check fees</string> <string name="send_deposit_create_button">Make deposit</string> <string name="send_deposit_host">Host</string> @@ -246,6 +250,7 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="withdraw_amount_error">Enter valid amount</string> <string name="withdraw_button_confirm">Confirm Withdraw</string> <string name="withdraw_button_confirm_bank">Authorize in bank</string> + <string name="withdraw_button_label">Withdraw</string> <string name="withdraw_button_tos">Review Terms</string> <string name="withdraw_error_message">Withdrawing is currently not possible. Please try again later!</string> <string name="withdraw_error_test">Error withdrawing TESTKUDOS</string>