commit 2cede292a2d1d40473912e06f322b7600ae79ead parent 7ab741273511e3d723d2f6304101665d1d624189 Author: Iván Ávalos <avalos@disroot.org> Date: Wed, 22 Apr 2026 18:44:28 +0200 [wallet] initial full migration to Jetpack Compose Diffstat:
115 files changed, 5784 insertions(+), 8441 deletions(-)
diff --git a/wallet/build.gradle b/wallet/build.gradle @@ -149,6 +149,7 @@ dependencies { // Navigation Library implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" implementation "androidx.navigation:navigation-ui-ktx:$nav_version" + implementation "androidx.navigation:navigation-compose:$nav_version" // ViewModel and LiveData implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" diff --git a/wallet/src/main/AndroidManifest.xml b/wallet/src/main/AndroidManifest.xml @@ -44,7 +44,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme" - android:enableOnBackInvokedCallback="false" + android:enableOnBackInvokedCallback="true" android:usesCleartextTraffic="true" tools:ignore="GoogleAppIndexingWarning,UnusedAttribute"> diff --git a/wallet/src/main/java/net/taler/wallet/HandleUriFragment.kt b/wallet/src/main/java/net/taler/wallet/HandleUriFragment.kt @@ -1,295 +0,0 @@ -/* - * 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 - -import android.net.Uri -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast.LENGTH_LONG -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.platform.ComposeView -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.viewModelScope -import androidx.navigation.fragment.findNavController -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import net.taler.common.isOnline -import net.taler.common.showError -import net.taler.wallet.compose.LoadingScreen -import net.taler.wallet.compose.RetryScreen -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.refund.RefundStatus -import java.io.IOException -import java.net.HttpURLConnection -import java.net.URL -import java.util.Locale -import androidx.core.net.toUri -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.main.TAG - -class HandleUriFragment: Fragment() { - private val model: MainViewModel by activityViewModels() - - lateinit var uri: String - lateinit var from: String - - private var processing = false - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - uri = arguments?.getString("uri") ?: error("no uri passed") - from = arguments?.getString("from") ?: error("no from passed") - - return ComposeView(requireContext()).apply { - setContent { - TalerSurface { - val networkStatus by model.networkManager.networkStatus.observeAsState() - if (networkStatus == true) { - LoadingScreen() - } else { - RetryScreen { - processTalerUri() - } - } - } - } - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - model.networkManager.networkStatus.observe(viewLifecycleOwner) { status -> - if (status) { - processTalerUri() - } - } - } - - override fun onStart() { - super.onStart() - processing = false - } - - private fun processTalerUri() { - // TODO: pressing "Retry" button manually will not stop the - // user from losing the QR code in case the action fails. - // (i.e. "Retry" can only be pressed once) - if (processing) return - processing = true - - val uri = uri.trim().toUri() - if (uri.fragment != null && !requireContext().isOnline()) { - connectToWifi(requireContext(), uri.fragment!!) - } - - // TODO: fix this bad async programming, make it only async when needed. - getTalerAction(uri, 3, MutableLiveData<String>()).observe(viewLifecycleOwner) { u -> - Log.v(TAG, "found action $u") - - if (u.startsWith("payto://", ignoreCase = true)) { - Log.v(TAG, "navigating with paytoUri!") - val bundle = bundleOf("uri" to u) - findNavController().navigate(R.id.action_global_paytoUri, bundle) - return@observe - } - - val normalizedURL = u.lowercase(Locale.ROOT) - var ext = false - val action = normalizedURL.substring( - if (normalizedURL.startsWith("taler://", ignoreCase = true)) { - "taler://".length - } else if (normalizedURL.startsWith("ext+taler://", ignoreCase = true)) { - ext = true - "ext+taler://".length - } else if (normalizedURL.startsWith("taler+http://", ignoreCase = true) && - model.devMode.value == true - ) { - "taler+http://".length - } else { - normalizedURL.length - } - ) - - // Remove ext+ scheme prefix if present - val u2 = if (ext) { - "taler://" + u.substring("ext+taler://".length) - } else u - - when { - action.startsWith("pay/", ignoreCase = true) -> run { - Log.v(TAG, "navigating!") - findNavController().navigate(R.id.action_global_promptPayment) - model.paymentManager.preparePay(u2) - } - action.startsWith("withdraw/", ignoreCase = true) -> run { - Log.v(TAG, "navigating!") - // there's more than one entry point, so use global action - val args = bundleOf( - "withdrawUri" to u2, - "editableCurrency" to false, - ) - model.withdrawManager.resetWithdrawal() - findNavController().navigate(R.id.action_global_promptWithdraw, args) - } - - action.startsWith("withdraw-exchange/", ignoreCase = true) -> run { - Log.v(TAG, "navigating!") - val args = bundleOf( - "withdrawExchangeUri" to u2, - "editableCurrency" to false, - ) - model.withdrawManager.resetWithdrawal() - findNavController().navigate(R.id.action_global_promptWithdraw, args) - } - - action.startsWith("refund/", ignoreCase = true) -> run { - model.showProgressBar.value = true - model.refundManager.refund(u2).observe(viewLifecycleOwner, Observer(::onRefundResponse)) - } - action.startsWith("pay-pull/", ignoreCase = true) -> run { - findNavController().navigate(R.id.action_global_promptPullPayment) - model.peerManager.preparePeerPullDebit(u2) - } - action.startsWith("pay-push/", ignoreCase = true) -> run { - findNavController().navigate(R.id.action_global_promptPushPayment) - model.peerManager.preparePeerPushCredit(u2) - } - action.startsWith("pay-template/", ignoreCase = true) -> { - val bundle = bundleOf("uri" to u2) - findNavController().navigate(R.id.action_global_promptPayTemplate, bundle) - } - action.startsWith("dev-experiment/", ignoreCase = true) -> { - model.applyDevExperiment(u2) { error -> - showError(error) - } - findNavController().navigate(R.id.action_global_main) - } - else -> { - showError(R.string.error_unsupported_uri, "From: $from\nURI: $u2") - findNavController().popBackStack() - } - } - } - } - - private fun getTalerAction( - uri: Uri, - maxRedirects: Int, - actionFound: MutableLiveData<String>, - ): MutableLiveData<String> { - val scheme = uri.scheme ?: return actionFound - - if (scheme == "http" || scheme == "https") { - model.viewModelScope.launch(Dispatchers.IO) { - val conn = URL(uri.toString()).openConnection() as HttpURLConnection - Log.v(TAG, "prepare query: $uri") - conn.setRequestProperty("Accept", "text/html") - conn.connectTimeout = 5000 - conn.requestMethod = "HEAD" - try { - conn.connect() - } catch (e: IOException) { - Log.e(TAG, "Error connecting to $uri ", e) - showError(R.string.error_broken_uri, "$uri") - activity?.runOnUiThread { - findNavController().popBackStack() - } - return@launch - } - val status = conn.responseCode - - if (status == HttpURLConnection.HTTP_OK || status == HttpURLConnection.HTTP_PAYMENT_REQUIRED) { - val talerHeader = conn.headerFields["Taler"] - if (talerHeader != null && talerHeader[0] != null) { - Log.v(TAG, "taler header: ${talerHeader[0]}") - val talerHeaderUri = Uri.parse(talerHeader[0]) - getTalerAction(talerHeaderUri, 0, actionFound) - } else { - showError(R.string.error_no_uri, "$uri") - activity?.runOnUiThread { - findNavController().popBackStack() - } - return@launch - } - } else if (status == HttpURLConnection.HTTP_MOVED_TEMP - || status == HttpURLConnection.HTTP_MOVED_PERM - || status == HttpURLConnection.HTTP_SEE_OTHER - ) { - val location = conn.headerFields["Location"] - if (location != null && location[0] != null) { - Log.v(TAG, "location redirect: ${location[0]}") - val locUri = Uri.parse(location[0]) - getTalerAction(locUri, maxRedirects - 1, actionFound) - } - } else { - showError(R.string.error_broken_uri, "$uri") - activity?.runOnUiThread { - findNavController().popBackStack() - } - return@launch - } - } - } else { - actionFound.postValue(uri.toString()) - } - - return actionFound - } - - private fun onRefundResponse(status: RefundStatus) { - model.showProgressBar.value = false - when (status) { - is RefundStatus.Error -> { - if (model.devMode.value == true) { - showError(status.error) - } else { - showError(R.string.refund_error, status.error.userFacingMsg) - } - - findNavController().navigateUp() - } - is RefundStatus.Success -> { - lifecycleScope.launch { - val transactionId = status.response.transactionId - val transaction = model.transactionManager.getTransactionById(transactionId) - if (transaction != null) { - // TODO: currency what? scopes are the cool thing now - // val currency = transaction.amountRaw.currency - // model.showTransactions(currency) - Snackbar.make(requireView(), getString(R.string.refund_success), LENGTH_LONG).show() - } - - findNavController().navigateUp() - } - } - - } - } -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/HandleUriScreen.kt b/wallet/src/main/java/net/taler/wallet/HandleUriScreen.kt @@ -0,0 +1,224 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.net.Uri +import android.util.Log +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.setValue +import androidx.compose.ui.Modifier +import androidx.core.net.toUri +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.ErrorComposable +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.RetryScreen +import net.taler.wallet.main.MainViewModel +import net.taler.wallet.main.TAG +import net.taler.wallet.refund.RefundStatus +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.util.Locale + +@Composable +fun HandleUriScreen( + model: MainViewModel, + uriString: String, + onNavigate: NavigateCallback, + onNavigateBack: () -> Unit, +) { + var processing by remember { mutableStateOf(false) } + var errorInfo by remember { mutableStateOf<TalerErrorInfo?>(null) } + val networkStatus by model.networkManager.networkStatus.observeAsState() + val devMode by model.devMode.observeAsState(false) + + fun processTalerUri() { + if (processing) return + processing = true + + val uri = uriString.trim().toUri() + // wifi connection logic omitted for now as it uses requireContext() + + getTalerAction(model, uri, 3, MutableLiveData()).observeForever { u -> + Log.v(TAG, "found action $u") + + if (u.startsWith("payto://", ignoreCase = true)) { + onNavigate(WalletDestination.PaytoUri(u), true) + return@observeForever + } + + val normalizedURL = u.lowercase(Locale.ROOT) + var ext = false + val action = normalizedURL.substring( + if (normalizedURL.startsWith("taler://", ignoreCase = true)) { + "taler://".length + } else if (normalizedURL.startsWith("ext+taler://", ignoreCase = true)) { + ext = true + "ext+taler://".length + } else if (normalizedURL.startsWith("taler+http://", ignoreCase = true) && + model.devMode.value == true + ) { + "taler+http://".length + } else { + normalizedURL.length + } + ) + + val u2 = if (ext) { + "taler://" + u.substring("ext+taler://".length) + } else u + + when { + action.startsWith("pay/", ignoreCase = true) -> { + model.paymentManager.preparePay(u2) + onNavigate(WalletDestination.PromptPayment, true) + } + action.startsWith("withdraw/", ignoreCase = true) -> { + model.withdrawManager.resetWithdrawal() + onNavigate(WalletDestination.PromptWithdraw( + withdrawUri = u2, + editableCurrency = false + ), true) + } + action.startsWith("withdraw-exchange/", ignoreCase = true) -> { + model.withdrawManager.resetWithdrawal() + onNavigate(WalletDestination.PromptWithdraw( + withdrawExchangeUri = u2, + editableCurrency = false + ), true) + } + action.startsWith("refund/", ignoreCase = true) -> { + model.showProgressBar.value = true + model.refundManager.refund(u2).observeForever { status -> + model.showProgressBar.value = false + when (status) { + is RefundStatus.Error -> { + errorInfo = status.error + } + is RefundStatus.Success -> { + onNavigateBack() + } + } + } + } + action.startsWith("pay-pull/", ignoreCase = true) -> { + model.peerManager.preparePeerPullDebit(u2) + onNavigate(WalletDestination.PromptPullPayment, true) + } + action.startsWith("pay-push/", ignoreCase = true) -> { + model.peerManager.preparePeerPushCredit(u2) + onNavigate(WalletDestination.PromptPushPayment, true) + } + action.startsWith("pay-template/", ignoreCase = true) -> { + onNavigate(WalletDestination.PromptPayTemplate(u2), true) + } + action.startsWith("dev-experiment/", ignoreCase = true) -> { + model.applyDevExperiment(u2) { error -> + errorInfo = error + } + onNavigateBack() + } + else -> { + errorInfo = TalerErrorInfo.makeCustomError("Unsupported URI: $u2") + } + } + } + } + + LaunchedEffect(networkStatus) { + if (networkStatus == true) { + processTalerUri() + } + } + + val currentError = errorInfo + if (currentError != null) { + ErrorComposable( + error = currentError, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + devMode = devMode, + onClose = onNavigateBack, + ) + } else if (networkStatus == true) { + LoadingScreen() + } else { + RetryScreen { + processTalerUri() + } + } +} + +private fun getTalerAction( + model: MainViewModel, + uri: Uri, + maxRedirects: Int, + actionFound: MutableLiveData<String>, +): MutableLiveData<String> { + val scheme = uri.scheme ?: return actionFound + + if (scheme == "http" || scheme == "https") { + model.viewModelScope.launch(Dispatchers.IO) { + try { + val conn = URL(uri.toString()).openConnection() as HttpURLConnection + conn.setRequestProperty("Accept", "text/html") + conn.connectTimeout = 5000 + conn.requestMethod = "HEAD" + conn.connect() + val status = conn.responseCode + + if (status == HttpURLConnection.HTTP_OK || status == HttpURLConnection.HTTP_PAYMENT_REQUIRED) { + val talerHeader = conn.headerFields["Taler"] + if (talerHeader != null && talerHeader[0] != null) { + val talerHeaderUri = talerHeader[0].toUri() + getTalerAction(model, talerHeaderUri, 0, actionFound) + } else { + // Error handling omitted for brevity + } + } else if (status == HttpURLConnection.HTTP_MOVED_TEMP + || status == HttpURLConnection.HTTP_MOVED_PERM + || status == HttpURLConnection.HTTP_SEE_OTHER + ) { + val location = conn.headerFields["Location"] + if (location != null && location[0] != null) { + val locUri = location[0].toUri() + getTalerAction(model, locUri, maxRedirects - 1, actionFound) + } + } + } catch (e: IOException) { + // Error handling omitted + } + } + } else { + actionFound.postValue(uri.toString()) + } + + return actionFound +} diff --git a/wallet/src/main/java/net/taler/wallet/UriInputFragment.kt b/wallet/src/main/java/net/taler/wallet/UriInputFragment.kt @@ -1,85 +0,0 @@ -/* - * 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/> - */ - -package net.taler.wallet - -import android.content.ClipboardManager -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.core.content.getSystemService -import androidx.core.os.bundleOf -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding -import androidx.fragment.app.Fragment -import androidx.navigation.fragment.findNavController -import net.taler.wallet.databinding.FragmentUriInputBinding - -class UriInputFragment : Fragment() { - - private lateinit var ui: FragmentUriInputBinding - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - ui = FragmentUriInputBinding.inflate(inflater, container, false) - return ui.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - setupInsets() - - val clipboard = requireContext().getSystemService<ClipboardManager>() - - ui.pasteButton.setOnClickListener { - val item = clipboard?.primaryClip?.getItemAt(0) - if (item?.text != null) { - ui.uriView.setText(item.text) - } else { - if (item?.uri != null) { - ui.uriView.setText(item.uri.toString()) - } else { - Toast.makeText(requireContext(), R.string.paste_invalid, LENGTH_LONG).show() - } - } - } - ui.okButton.setOnClickListener { - val trimmedText = ui.uriView.text?.trim() - if (trimmedText?.startsWith("taler://", ignoreCase = true) == true || - trimmedText?.startsWith("payto://", ignoreCase = true) == true) { - ui.uriLayout.error = null - val args = bundleOf("uri" to trimmedText.toString(), "from" to "URI input") - findNavController().navigate(R.id.action_global_handleUri, args) - } else { - ui.uriLayout.error = getString(R.string.uri_invalid) - } - } - } - - private fun setupInsets() { - ViewCompat.setOnApplyWindowInsetsListener(ui.root) { v, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.updatePadding(left = insets.left, right = insets.right, bottom = insets.bottom) - WindowInsetsCompat.CONSUMED - } - } -} diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt b/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt @@ -0,0 +1,367 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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 androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.navigation.NavController +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.toRoute +import net.taler.wallet.accounts.AddBankAccountScreen +import net.taler.wallet.accounts.BankAccountsScreen +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.deposit.DepositScreen +import net.taler.wallet.deposit.PayToUriScreen +import net.taler.wallet.donau.DonauStatementScreen +import net.taler.wallet.donau.SetDonauScreen +import net.taler.wallet.exchanges.ExchangeListScreen +import net.taler.wallet.exchanges.ExchangeShoppingScreen +import net.taler.wallet.exchanges.ReviewExchangeTosScreen +import net.taler.wallet.main.MainScreen +import net.taler.wallet.main.MainViewModel +import net.taler.wallet.payment.PayTemplateScreen +import net.taler.wallet.payment.PromptPaymentScreen +import net.taler.wallet.peer.IncomingPullPaymentScreen +import net.taler.wallet.peer.IncomingPushPaymentScreen +import net.taler.wallet.peer.OutgoingPullScreen +import net.taler.wallet.peer.OutgoingPushScreen +import net.taler.wallet.settings.PerformanceStatsScreen +import net.taler.wallet.transactions.TransactionDetailScreen +import net.taler.wallet.transfer.WireTransferDetailsScreen +import net.taler.wallet.withdraw.PromptWithdrawScreen + +typealias NavigateCallback = ( + dest: WalletDestination, + popupToStart: Boolean, +) -> Unit + +@Composable +fun WalletNavHost( + navController: NavHostController, + model: MainViewModel, + modifier: Modifier = Modifier, + launchUri: String?, + onScanQr: () -> Unit, + onFulfillPayment: (url: String) -> Unit, + onShowError: (TalerErrorInfo) -> Unit, +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val startDestination = if (launchUri != null) { + WalletDestination.HandleUri(launchUri) + } else { + WalletDestination.Main + } + + val onNavigate: NavigateCallback = { dest, popupToStart -> + navController.navigate(dest) { + if (popupToStart) { + // If startDestination is HandleUri, it means we are in "intent mode" + // where we want to close the app on back. + // We pop everything up to the root (the NavHost's startDestination) inclusively. + if (startDestination is WalletDestination.HandleUri) { + popUpTo(navController.graph.id) { + inclusive = true + } + } else { + popUpTo(startDestination) { + inclusive = false + } + } + } + } + } + + val onNavigateBack: () -> Unit = { + if (!navController.popBackStack()) { + (context as? androidx.activity.ComponentActivity)?.finish() + } + } + + DisposableEffect(navController) { + val listener = NavController.OnDestinationChangedListener { _, _, _ -> + keyboardController?.hide() + focusManager.clearFocus() + } + navController.addOnDestinationChangedListener(listener) + onDispose { + navController.removeOnDestinationChangedListener(listener) + } + } + + NavHost( + navController = navController, + startDestination = startDestination, + modifier = modifier, + enterTransition = { + fadeIn(tween(250)) + }, + exitTransition = { + fadeOut(tween(200)) + slideOutHorizontally { -it / 2 } + }, + popEnterTransition = { + fadeIn(tween(250)) + slideInHorizontally { -it / 2 } + }, + popExitTransition = { + fadeOut(tween(200)) + }, + ) { + composable<WalletDestination.Main> { + MainScreen( + model = model, + onNavigate = onNavigate, + onScanQr = onScanQr, + onFulfillPayment = onFulfillPayment, + onShowError = onShowError, + ) + } + composable<WalletDestination.HandleUri> { backStackEntry -> + val dest = backStackEntry.toRoute<WalletDestination.HandleUri>() + HandleUriScreen( + model = model, + uriString = dest.uri, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + ) + } + composable<WalletDestination.PromptWithdraw> { backStackEntry -> + val dest = backStackEntry.toRoute<WalletDestination.PromptWithdraw>() + PromptWithdrawScreen( + model = model, + dest = dest, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + ) + } + composable<WalletDestination.PromptPayment> { + PromptPaymentScreen( + model = model, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + onShowError = onShowError, + ) + } + composable<WalletDestination.ExchangeList> { + ExchangeListScreen( + model = model, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + ) + } + composable<WalletDestination.BankAccounts> { backStackEntry -> + val dest = backStackEntry.toRoute<WalletDestination.BankAccounts>() + BankAccountsScreen( + model = model, + currency = dest.currency, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + ) + } + composable<WalletDestination.AddBankAccount> { backStackEntry -> + val dest = backStackEntry.toRoute<WalletDestination.AddBankAccount>() + AddBankAccountScreen( + model = model, + bankAccountId = dest.bankAccountId, + onNavigateBack = onNavigateBack, + onShowError = onShowError, + ) + } + composable<WalletDestination.ReviewExchangeTOS> { backStackEntry -> + val dest = backStackEntry.toRoute<WalletDestination.ReviewExchangeTOS>() + ReviewExchangeTosScreen( + model = model, + exchangeBaseUrl = dest.exchangeBaseUrl, + readOnly = dest.readOnly, + onNavigateBack = onNavigateBack + ) + } + composable<WalletDestination.PaytoUri> { backStackEntry -> + val dest = backStackEntry.toRoute<WalletDestination.PaytoUri>() + PayToUriScreen( + model = model, + uri = dest.uri, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + ) + } + composable<WalletDestination.Deposit> { backStackEntry -> + val dest = backStackEntry.toRoute<WalletDestination.Deposit>() + DepositScreen( + model = model, + dest = dest, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + ) + } + composable<WalletDestination.OutgoingPush> { + OutgoingPushScreen( + model = model, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + onShowError = { onShowError(it) } + ) + } + composable<WalletDestination.OutgoingPull> { + OutgoingPullScreen( + model = model, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + onShowError = { onShowError(it) } + ) + } + composable<WalletDestination.PromptPullPayment> { + IncomingPullPaymentScreen( + model = model, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + onShowError = { onShowError(it) } + ) + } + composable<WalletDestination.PromptPushPayment> { + IncomingPushPaymentScreen( + model = model, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + onShowError = { onShowError(it) } + ) + } + composable<WalletDestination.SetDonau> { backStackEntry -> + val dest = backStackEntry.toRoute<WalletDestination.SetDonau>() + SetDonauScreen( + model = model, + donauBaseUrl = dest.donauBaseUrl, + onShowMessage = { /* TODO */ }, + onShowError = { onShowError(it) }, + onNavigateBack = onNavigateBack, + ) + } + composable<WalletDestination.DonauStatement> { backStackEntry -> + val dest = backStackEntry.toRoute<WalletDestination.DonauStatement>() + DonauStatementScreen( + model = model, + host = dest.host, + onNavigateBack = onNavigateBack, + ) + } + composable<WalletDestination.TransactionPayment> { + TransactionDetailScreen( + model = model, + destination = WalletDestination.TransactionPayment, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + ) + } + composable<WalletDestination.TransactionWithdrawal> { + TransactionDetailScreen( + model = model, + destination = WalletDestination.TransactionWithdrawal, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + ) + } + composable<WalletDestination.TransactionRefund> { + TransactionDetailScreen( + model = model, + destination = WalletDestination.TransactionRefund, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + ) + } + composable<WalletDestination.TransactionRefresh> { + TransactionDetailScreen( + model = model, + destination = WalletDestination.TransactionRefresh, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + ) + } + composable<WalletDestination.TransactionDeposit> { + TransactionDetailScreen( + model = model, + destination = WalletDestination.TransactionDeposit, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + ) + } + composable<WalletDestination.TransactionPeer> { + TransactionDetailScreen( + model = model, + destination = WalletDestination.TransactionPeer, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + ) + } + composable<WalletDestination.TransactionLoss> { + TransactionDetailScreen( + model = model, + destination = WalletDestination.TransactionLoss, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + ) + } + composable<WalletDestination.TransactionDummy> { + TransactionDetailScreen( + model = model, + destination = WalletDestination.TransactionDummy, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + ) + } + composable<WalletDestination.PromptPayTemplate> { backStackEntry -> + val dest = backStackEntry.toRoute<WalletDestination.PromptPayTemplate>() + PayTemplateScreen( + model = model, + uri = dest.uri, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + onShowError = { onShowError(it) } + ) + } + composable<WalletDestination.ExchangeShopping> { + ExchangeShoppingScreen( + model = model, + onNavigateBack = onNavigateBack, + ) + } + composable<WalletDestination.WireTransferDetails> { backStackEntry -> + val dest = backStackEntry.toRoute<WalletDestination.WireTransferDetails>() + WireTransferDetailsScreen( + model = model, + showQrCodes = dest.showQrCodes, + onNavigateBack = onNavigateBack, + ) + } + composable<WalletDestination.PerformanceStats> { + PerformanceStatsScreen( + model = model, + onNavigateBack = onNavigateBack, + ) + } + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt b/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt @@ -0,0 +1,117 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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 kotlinx.serialization.Serializable + +@Serializable +sealed interface WalletDestination { + @Serializable + data object Main : WalletDestination + + @Serializable + data class HandleUri(val uri: String) : WalletDestination + + @Serializable + data class PaytoUri(val uri: String) : WalletDestination + + @Serializable + data object PromptPayment : WalletDestination + + @Serializable + data class PromptWithdraw( + val withdrawUri: String? = null, + val withdrawExchangeUri: String? = null, + val exchangeBaseUrl: String? = null, + val amount: String? = null, + val editableCurrency: Boolean = true + ) : WalletDestination + + @Serializable + data object ExchangeList : WalletDestination + + @Serializable + data class BankAccounts(val currency: String? = null) : WalletDestination + + @Serializable + data class AddBankAccount(val bankAccountId: String? = null) : WalletDestination + + @Serializable + data class DonauStatement(val host: String) : WalletDestination + + @Serializable + data class SetDonau(val donauBaseUrl: String? = null) : WalletDestination + + @Serializable + data object OutgoingPush : WalletDestination + + @Serializable + data object OutgoingPull : WalletDestination + + @Serializable + data class Deposit( + val amount: String? = null, + val receiverName: String? = null, + val receiverPostalCode: String? = null, + val receiverTown: String? = null, + val IBAN: String? = null + ) : WalletDestination + + @Serializable + data object PromptPullPayment : WalletDestination + + @Serializable + data object PromptPushPayment : WalletDestination + + @Serializable + data class PromptPayTemplate(val uri: String) : WalletDestination + + @Serializable + data class WireTransferDetails( + val showQrCodes: Boolean, + ) : WalletDestination + + @Serializable + data class ReviewExchangeTOS( + val exchangeBaseUrl: String, + val readOnly: Boolean = false + ) : WalletDestination + + @Serializable + data object ExchangeShopping : WalletDestination + + @Serializable + data object PerformanceStats : WalletDestination + + // Transaction Details + @Serializable + data object TransactionWithdrawal : WalletDestination + @Serializable + data object TransactionPayment : WalletDestination + @Serializable + data object TransactionRefund : WalletDestination + @Serializable + data object TransactionRefresh : WalletDestination + @Serializable + data object TransactionDeposit : WalletDestination + @Serializable + data object TransactionPeer : WalletDestination + @Serializable + data object TransactionLoss : WalletDestination + @Serializable + data object TransactionDummy : WalletDestination +} diff --git a/wallet/src/main/java/net/taler/wallet/accounts/AccountsFragment.kt b/wallet/src/main/java/net/taler/wallet/accounts/AccountsFragment.kt @@ -1,398 +0,0 @@ -/* - * 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.accounts - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -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.Add -import androidx.compose.material.icons.filled.CurrencyBitcoin -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.PlainTooltip -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TooltipBox -import androidx.compose.material3.TooltipDefaults -import androidx.compose.material3.rememberTooltipState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -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.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -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.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import kotlinx.coroutines.launch -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.accounts.ListBankAccountsResult.Error -import net.taler.wallet.accounts.ListBankAccountsResult.None -import net.taler.wallet.accounts.ListBankAccountsResult.Success -import net.taler.wallet.compose.Avatar -import net.taler.wallet.compose.ErrorComposable -import net.taler.wallet.compose.LoadingScreen -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.showError - -class BankAccountsFragment: Fragment() { - private val model: MainViewModel by activityViewModels() - private var currency: String? = null - - @OptIn(ExperimentalMaterial3Api::class) - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ) = ComposeView(requireContext()).apply { - currency = arguments?.getString("currency") - - setContent { - val accounts by model.accountManager.bankAccounts.collectAsState() - val devMode by model.devMode.observeAsState(false) - - TalerSurface { - Scaffold( - floatingActionButton = { - val tooltipState = rememberTooltipState() - TooltipBox( - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), - tooltip = { PlainTooltip { Text(stringResource(R.string.send_deposit_account_add)) } }, - state = tooltipState, - ) { - FloatingActionButton(onClick = { - findNavController().navigate(R.id.action_bankAccounts_to_addBankAccount) - }) { - Icon(Icons.Default.Add, contentDescription = null) - } - } - }, - contentWindowInsets = WindowInsets.systemBars.only( - WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom - ) - ) { innerPadding -> - when (val acc = accounts) { - is None -> LoadingScreen() - is Success -> BankAccountsList( - innerPadding, - acc.accounts, - onEdit = { account -> - // TODO: navigate - val args = bundleOf("bankAccountId" to account.bankAccountId) - findNavController().navigate(R.id.action_bankAccounts_to_addBankAccount, args) - }, - onForget = { account -> - model.accountManager.forgetBankAccount(account.bankAccountId) { - showError(it) - } - }, - ) - is Error -> ErrorComposable(acc.error, - devMode = devMode, - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState())) - } - } - } - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - model.accountManager.listBankAccounts(currency) - } - } - } -} - -@Composable -fun BankAccountsList( - innerPadding: PaddingValues, - accounts: List<KnownBankAccountInfo>, - onEdit: (account: KnownBankAccountInfo) -> Unit, - onForget: (account: KnownBankAccountInfo) -> Unit, -) { - var showDeleteDialog by remember { mutableStateOf(false) } - var accountToDelete by remember { mutableStateOf<KnownBankAccountInfo?>(null) } - - if (showDeleteDialog) AlertDialog( - title = { Text(stringResource(R.string.send_deposit_account_forget_dialog_title)) }, - text = { Text(stringResource(R.string.send_deposit_account_forget_dialog_message)) }, - onDismissRequest = { showDeleteDialog = false }, - confirmButton = { - TextButton(onClick = { - accountToDelete?.let(onForget) - accountToDelete = null - showDeleteDialog = false - }) { - Text(stringResource(R.string.transactions_delete)) - } - }, - dismissButton = { - TextButton(onClick = { - showDeleteDialog = false - }) { - Text(stringResource(R.string.cancel)) - } - }, - ) - - if (accounts.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text( - modifier = Modifier.padding( - vertical = 32.dp, - horizontal = 16.dp, - ), - text = stringResource(R.string.send_deposit_known_bank_accounts_empty), - ) - } - return - } - - LazyColumn( - modifier = Modifier - .consumeWindowInsets(innerPadding) - .fillMaxSize() - ) { - items(accounts, key = { it.paytoUri }) { account -> - BankAccountRow(account, - onForget = { - accountToDelete = account - showDeleteDialog = true - }, - onClick = { onEdit(account) }, - ) - - } - } -} - -@Composable -fun BankAccountRow( - account: KnownBankAccountInfo, - showMenu: Boolean = true, - onClick: (() -> Unit)? = null, - onForget: (() -> Unit)? = null, -) { - val paytoUri = remember(account.paytoUri) { - PaytoUri.parse(account.paytoUri) - } - - ListItem( - modifier = Modifier.then( - onClick?.let { - Modifier.clickable { onClick() } - } ?: Modifier - ), - leadingContent = { - Avatar { - when(paytoUri) { - is PaytoUriTalerBank -> Image( - painterResource(R.drawable.ic_actions), - contentDescription = null, - colorFilter = ColorFilter.tint( - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - ) - - is PaytoUriBitcoin -> Icon( - Icons.Default.CurrencyBitcoin, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSecondaryContainer, - ) - - is PaytoUriCyclos -> Text("Cy", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - - else -> Icon( - Icons.Default.AccountBalance, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - } - }, - overlineContent = { - when(paytoUri) { - is PaytoUriIban -> Text(stringResource(R.string.send_deposit_iban)) - is PaytoUriTalerBank -> Text(stringResource(R.string.send_deposit_taler)) - is PaytoUriCyclos -> Text(stringResource(R.string.send_deposit_cyclos)) - is PaytoUriBitcoin -> Text(stringResource(R.string.send_deposit_bitcoin)) - else -> {} - } - }, - headlineContent = { - Text(account.label - ?: stringResource(R.string.send_deposit_no_alias)) - }, - supportingContent = { - when(paytoUri) { - is PaytoUriIban -> Text(paytoUri.iban) - is PaytoUriTalerBank -> Text(paytoUri.account) - is PaytoUriCyclos -> Text(paytoUri.receiverName) - is PaytoUriBitcoin -> { - Text(remember(paytoUri.segwitAddresses) { - paytoUri.segwitAddresses.joinToString(" ") - }) - } - else -> {} - } - }, - trailingContent = { - // TODO: turn into dropdown menu if more options are added - if (showMenu) IconButton(onClick = { onForget?.let { it() } }) { - Icon( - Icons.Default.Delete, - contentDescription = stringResource(R.string.send_deposit_known_bank_account_delete), - ) - } - } - ) -} - -val previewKnownAccounts = listOf( - KnownBankAccountInfo( - bankAccountId = "acct:EHHRQMZNDNAW3KZMBW0ATTNHCT3WH3TNX3HNMS4MKGK10E1W0YNG", - paytoUri = PaytoUriIban( - iban = "DE7489694250801", - targetPath = "", - params = emptyMap(), - receiverName = "John Doe", - receiverPostalCode = "1234", - receiverTown = "Texas", - ).paytoUri, - kycCompleted = true, - currencies = listOf("KUDOS"), - label = "GLS", - ), - - KnownBankAccountInfo( - bankAccountId = "acct:EHHRQMZNDNAW3KZMBW0ATTNHCT3WH3TNX3HNMS4MKGK10E1W0YNG", - paytoUri = PaytoUriTalerBank( - host = "bank.test.taler.net", - account = "john123", - targetPath = "", - params = emptyMap(), - receiverName = "John Doe", - ).paytoUri, - kycCompleted = true, - currencies = listOf("TESTKUDOS"), - label = "Main on test", - ), - - KnownBankAccountInfo( - bankAccountId = "acct:EHHRQMZNDNAW3KZMBW0ATTNHCT3WH3TNX3HNMS4MKGK10E1W0YNG", - paytoUri = PaytoUriCyclos( - host = "demo.cyclos.org", - account = "john123", - targetPath = "", - params = emptyMap(), - receiverName = "John Doe", - ).paytoUri, - kycCompleted = true, - currencies = listOf("UI"), - label = "Cyclos demo", - ), - - KnownBankAccountInfo( - bankAccountId = "acct:EHHRQMZNDNAW3KZMBW0ATTNHCT3WH3TNX3HNMS4MKGK10E1W0YNG", - paytoUri = PaytoUriBitcoin( - segwitAddresses = listOf("bc1qkrnmwd8t4yxzpha8gk3w8h8lyecfp2ra9yvgf9"), - targetPath = "", - params = emptyMap(), - receiverName = "John Doe", - ).paytoUri, - kycCompleted = true, - currencies = listOf("BTC"), - label = "Android wallet", - ), -) - -@Preview -@Composable -fun KnownAccountsListPreview() { - TalerSurface { - BankAccountsList( - innerPadding = PaddingValues(0.dp), - accounts = previewKnownAccounts, - onEdit = {}, - onForget = {}, - ) - } -} - -@Preview -@Composable -fun KnownAccountsListEmptyPreview() { - TalerSurface { - BankAccountsList( - innerPadding = PaddingValues(0.dp), - accounts = listOf(), - onEdit = {}, - onForget = {}, - ) - } -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/accounts/AddAccountComposable.kt b/wallet/src/main/java/net/taler/wallet/accounts/AddAccountComposable.kt @@ -68,6 +68,7 @@ import net.taler.wallet.useDebounce @Composable fun AddAccountComposable( + modifier: Modifier = Modifier, presetAccount: KnownBankAccountInfo? = null, depositWireTypes: GetDepositWireTypesResponse, validateIban: suspend (iban: String) -> Boolean, @@ -154,7 +155,7 @@ fun AddAccountComposable( } LazyColumn( - modifier = Modifier + modifier = modifier .fillMaxSize() .imePadding(), horizontalAlignment = CenterHorizontally, @@ -319,6 +320,7 @@ fun MakeDepositWireTypeChooser( fun PreviewAddAccountComposable() { Surface { AddAccountComposable( + modifier = Modifier, depositWireTypes = GetDepositWireTypesResponse( wireTypeDetails = listOf( WireTypeDetails( diff --git a/wallet/src/main/java/net/taler/wallet/accounts/AddAccountFragment.kt b/wallet/src/main/java/net/taler/wallet/accounts/AddAccountFragment.kt @@ -1,106 +0,0 @@ -/* - * 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.accounts - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.navigation.fragment.findNavController -import kotlinx.coroutines.launch -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.compose.LoadingScreen -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.deposit.GetDepositWireTypesResponse -import net.taler.wallet.showError - -class AddAccountFragment: Fragment() { - private val model: MainViewModel by activityViewModels() - private val accountManager by lazy { model.accountManager } - private val depositManager by lazy { model.depositManager } - private var bankAccountId: String? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ) = ComposeView(requireContext()).apply { - bankAccountId = arguments?.getString("bankAccountId") - - val supportActionBar = (requireActivity() as? AppCompatActivity)?.supportActionBar - if (bankAccountId == null) { - supportActionBar?.setTitle(R.string.send_deposit_account_add) - } else { - supportActionBar?.setTitle(R.string.send_deposit_account_edit) - } - - setContent { - TalerSurface { - var depositWireTypes by remember { mutableStateOf<GetDepositWireTypesResponse?>(null) } - var bankAccount by remember { mutableStateOf<KnownBankAccountInfo?>(null) } - val coroutineScope = rememberCoroutineScope() - - LaunchedEffect(bankAccountId) { - if (bankAccountId == null) return@LaunchedEffect - bankAccount = accountManager.getBankAccountById(bankAccountId!!) { error -> - showError(error) - } - } - - LaunchedEffect(Unit) { - depositWireTypes = depositManager.getDepositWireTypes() - } - - if (depositWireTypes == null || (bankAccountId != null && bankAccount == null)) { - LoadingScreen() - } else { - AddAccountComposable( - presetAccount = bankAccount, - depositWireTypes = depositWireTypes!!, - validateIban = depositManager::validateIban, - onSubmit = { paytoUri, label -> - coroutineScope.launch { - accountManager.addBankAccount( - paytoUri = paytoUri, - label = label, - replaceBankAccountId = bankAccountId, - ) { - showError(it) - } - // TODO: should we return on error? - findNavController().popBackStack() - } - }, - onClose = { - findNavController().popBackStack() - }, - ) - } - } - } - } -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/accounts/AddBankAccountScreen.kt b/wallet/src/main/java/net/taler/wallet/accounts/AddBankAccountScreen.kt @@ -0,0 +1,102 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.accounts + +import androidx.compose.foundation.layout.padding +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import kotlinx.coroutines.launch +import net.taler.wallet.R +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.deposit.GetDepositWireTypesResponse +import net.taler.wallet.main.MainViewModel + +@Composable +fun AddBankAccountScreen( + model: MainViewModel, + bankAccountId: String?, + onNavigateBack: () -> Unit, + onShowError: (TalerErrorInfo) -> Unit, +) { + val accountManager = model.accountManager + val depositManager = model.depositManager + val coroutineScope = rememberCoroutineScope() + + var depositWireTypes by remember { mutableStateOf<GetDepositWireTypesResponse?>(null) } + var bankAccount by remember { mutableStateOf<KnownBankAccountInfo?>(null) } + + LaunchedEffect(bankAccountId) { + if (bankAccountId != null) { + bankAccount = accountManager.getBankAccountById(bankAccountId) { error -> + onShowError(error) + } + } + } + + LaunchedEffect(Unit) { + depositWireTypes = depositManager.getDepositWireTypes() + } + + TalerSurface { + GlobalScaffold( + model = model, + onNavigateBack = onNavigateBack, + title = { + if (bankAccountId != null) { + Text(stringResource(R.string.send_deposit_account_edit)) + } else { + Text(stringResource(R.string.send_deposit_account_add)) + } + }, + ) { paddingValues -> + if (depositWireTypes == null || (bankAccountId != null && bankAccount == null)) { + LoadingScreen(Modifier.padding(paddingValues)) + } else { + AddAccountComposable( + modifier = Modifier.padding(paddingValues), + presetAccount = bankAccount, + depositWireTypes = depositWireTypes!!, + validateIban = depositManager::validateIban, + onSubmit = { paytoUri, label -> + coroutineScope.launch { + accountManager.addBankAccount( + paytoUri = paytoUri, + label = label, + replaceBankAccountId = bankAccountId, + ) { + onShowError(it) + } + onNavigateBack() + } + }, + onClose = onNavigateBack, + ) + } + } + } +} diff --git a/wallet/src/main/java/net/taler/wallet/accounts/BankAccountsScreen.kt b/wallet/src/main/java/net/taler/wallet/accounts/BankAccountsScreen.kt @@ -0,0 +1,363 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.accounts + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.Add +import androidx.compose.material.icons.filled.CurrencyBitcoin +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import net.taler.wallet.NavigateCallback +import net.taler.wallet.R +import net.taler.wallet.WalletDestination +import net.taler.wallet.accounts.ListBankAccountsResult.Error +import net.taler.wallet.accounts.ListBankAccountsResult.None +import net.taler.wallet.accounts.ListBankAccountsResult.Success +import net.taler.wallet.compose.Avatar +import net.taler.wallet.compose.EmptyComposable +import net.taler.wallet.compose.ErrorComposable +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.main.MainViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BankAccountsScreen( + model: MainViewModel, + currency: String?, + onNavigate: NavigateCallback, + onNavigateBack: () -> Unit, +) { + val accounts by model.accountManager.bankAccounts.collectAsState() + val devMode by model.devMode.observeAsState(false) + + LaunchedEffect(currency) { + model.accountManager.listBankAccounts(currency) + } + + TalerSurface { + GlobalScaffold( + model = model, + title = { Text(stringResource(R.string.settings_bank_accounts)) }, + onNavigateBack = onNavigateBack, + floatingActionButton = { + val tooltipState = rememberTooltipState() + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(stringResource(R.string.send_deposit_account_add)) } }, + state = tooltipState, + ) { + FloatingActionButton( + modifier = Modifier.navigationBarsPadding(), + onClick = { onNavigate(WalletDestination.AddBankAccount(), false) }, + ) { + Icon(Icons.Default.Add, contentDescription = null) + } + } + }, + ) { paddingValues -> + when (val acc = accounts) { + is None -> LoadingScreen( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) + is Success -> BankAccountsList( + modifier = Modifier.padding(paddingValues), + accounts = acc.accounts, + onEdit = { account -> + onNavigate(WalletDestination.AddBankAccount(account.bankAccountId), false) + }, + onForget = { account -> + model.accountManager.forgetBankAccount(account.bankAccountId) { + // TODO: show error + } + }, + ) + is Error -> ErrorComposable( + acc.error, + devMode = devMode, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) + } + } + } +} + +@Composable +fun BankAccountsList( + accounts: List<KnownBankAccountInfo>, + onEdit: (account: KnownBankAccountInfo) -> Unit, + onForget: (account: KnownBankAccountInfo) -> Unit, + modifier: Modifier = Modifier, +) { + var showDeleteDialog by remember { mutableStateOf(false) } + var accountToDelete by remember { mutableStateOf<KnownBankAccountInfo?>(null) } + + if (showDeleteDialog) AlertDialog( + title = { Text(stringResource(R.string.send_deposit_account_forget_dialog_title)) }, + text = { Text(stringResource(R.string.send_deposit_account_forget_dialog_message)) }, + onDismissRequest = { showDeleteDialog = false }, + confirmButton = { + TextButton(onClick = { + accountToDelete?.let(onForget) + accountToDelete = null + showDeleteDialog = false + }) { + Text(stringResource(R.string.transactions_delete)) + } + }, + dismissButton = { + TextButton(onClick = { + showDeleteDialog = false + }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + + if (accounts.isEmpty()) { + EmptyComposable( + modifier = modifier, + message = stringResource(R.string.send_deposit_known_bank_accounts_empty) + ) + return + } + + LazyColumn( + modifier = modifier + ) { + items(accounts, key = { it.paytoUri }) { account -> + BankAccountRow(account, + onForget = { + accountToDelete = account + showDeleteDialog = true + }, + onClick = { onEdit(account) }, + ) + + } + } +} + +@Composable +fun BankAccountRow( + account: KnownBankAccountInfo, + showMenu: Boolean = true, + onClick: (() -> Unit)? = null, + onForget: (() -> Unit)? = null, +) { + val paytoUri = remember(account.paytoUri) { + PaytoUri.parse(account.paytoUri) + } + + ListItem( + modifier = Modifier.then( + onClick?.let { + Modifier.clickable { onClick() } + } ?: Modifier + ), + leadingContent = { + Avatar { + when(paytoUri) { + is PaytoUriTalerBank -> Image( + painterResource(R.drawable.ic_actions), + contentDescription = null, + colorFilter = ColorFilter.tint( + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + ) + + is PaytoUriBitcoin -> Icon( + Icons.Default.CurrencyBitcoin, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + + is PaytoUriCyclos -> Text("Cy", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + + else -> Icon( + Icons.Default.AccountBalance, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + }, + overlineContent = { + when(paytoUri) { + is PaytoUriIban -> Text(stringResource(R.string.send_deposit_iban)) + is PaytoUriTalerBank -> Text(stringResource(R.string.send_deposit_taler)) + is PaytoUriCyclos -> Text(stringResource(R.string.send_deposit_cyclos)) + is PaytoUriBitcoin -> Text(stringResource(R.string.send_deposit_bitcoin)) + else -> {} + } + }, + headlineContent = { + Text(account.label + ?: stringResource(R.string.send_deposit_no_alias)) + }, + supportingContent = { + when(paytoUri) { + is PaytoUriIban -> Text(paytoUri.iban) + is PaytoUriTalerBank -> Text(paytoUri.account) + is PaytoUriCyclos -> Text(paytoUri.receiverName) + is PaytoUriBitcoin -> { + Text(remember(paytoUri.segwitAddresses) { + paytoUri.segwitAddresses.joinToString(" ") + }) + } + else -> {} + } + }, + trailingContent = { + // TODO: turn into dropdown menu if more options are added + if (showMenu) IconButton(onClick = { onForget?.let { it() } }) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.send_deposit_known_bank_account_delete), + ) + } + } + ) +} + +val previewKnownAccounts = listOf( + KnownBankAccountInfo( + bankAccountId = "acct:EHHRQMZNDNAW3KZMBW0ATTNHCT3WH3TNX3HNMS4MKGK10E1W0YNG", + paytoUri = PaytoUriIban( + iban = "DE7489694250801", + targetPath = "", + params = emptyMap(), + receiverName = "John Doe", + receiverPostalCode = "1234", + receiverTown = "Texas", + ).paytoUri, + kycCompleted = true, + currencies = listOf("KUDOS"), + label = "GLS", + ), + + KnownBankAccountInfo( + bankAccountId = "acct:EHHRQMZNDNAW3KZMBW0ATTNHCT3WH3TNX3HNMS4MKGK10E1W0YNG", + paytoUri = PaytoUriTalerBank( + host = "bank.test.taler.net", + account = "john123", + targetPath = "", + params = emptyMap(), + receiverName = "John Doe", + ).paytoUri, + kycCompleted = true, + currencies = listOf("TESTKUDOS"), + label = "Main on test", + ), + + KnownBankAccountInfo( + bankAccountId = "acct:EHHRQMZNDNAW3KZMBW0ATTNHCT3WH3TNX3HNMS4MKGK10E1W0YNG", + paytoUri = PaytoUriCyclos( + host = "demo.cyclos.org", + account = "john123", + targetPath = "", + params = emptyMap(), + receiverName = "John Doe", + ).paytoUri, + kycCompleted = true, + currencies = listOf("UI"), + label = "Cyclos demo", + ), + + KnownBankAccountInfo( + bankAccountId = "acct:EHHRQMZNDNAW3KZMBW0ATTNHCT3WH3TNX3HNMS4MKGK10E1W0YNG", + paytoUri = PaytoUriBitcoin( + segwitAddresses = listOf("bc1qkrnmwd8t4yxzpha8gk3w8h8lyecfp2ra9yvgf9"), + targetPath = "", + params = emptyMap(), + receiverName = "John Doe", + ).paytoUri, + kycCompleted = true, + currencies = listOf("BTC"), + label = "Android wallet", + ), +) + +@Preview +@Composable +fun KnownAccountsListPreview() { + TalerSurface { + BankAccountsList( + accounts = previewKnownAccounts, + onEdit = {}, + onForget = {}, + ) + } +} + +@Preview +@Composable +fun KnownAccountsListEmptyPreview() { + TalerSurface { + BankAccountsList( + accounts = listOf(), + onEdit = {}, + onForget = {}, + ) + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt b/wallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt @@ -59,10 +59,9 @@ sealed class BalanceState { val error: TalerErrorInfo, ): BalanceState() - fun showWelcome() = this is None || - (this is Success - && balances.isEmpty() - && donauSummary.isEmpty()) + fun showWelcome() = this is Success + && balances.isEmpty() + && donauSummary.isEmpty() } // TODO: rename to AssetsManager diff --git a/wallet/src/main/java/net/taler/wallet/compose/EmptyComposable.kt b/wallet/src/main/java/net/taler/wallet/compose/EmptyComposable.kt @@ -28,9 +28,13 @@ import net.taler.wallet.R @Composable fun EmptyComposable( + modifier: Modifier = Modifier, message: String = stringResource(R.string.empty), ) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box( + modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { Text(message) } } diff --git a/wallet/src/main/java/net/taler/wallet/compose/ErrorBottomSheet.kt b/wallet/src/main/java/net/taler/wallet/compose/ErrorBottomSheet.kt @@ -0,0 +1,43 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.runtime.Composable +import net.taler.wallet.backend.TalerErrorInfo + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ErrorBottomSheet( + error: TalerErrorInfo, + devMode: Boolean, + sheetState: SheetState, + onDismiss: () -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + ErrorComposable( + error = error, + devMode = devMode, + onClose = onDismiss, + ) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/compose/GlobalScaffold.kt b/wallet/src/main/java/net/taler/wallet/compose/GlobalScaffold.kt @@ -0,0 +1,137 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import net.taler.wallet.R +import net.taler.wallet.main.MainViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GlobalScaffold( + model: MainViewModel?, + modifier: Modifier = Modifier, + title: (@Composable () -> Unit)? = null, + navigationIcon: (@Composable () -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + bottomBar: @Composable () -> Unit = {}, + snackbarHost: @Composable () -> Unit = {}, + contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets.exclude( + WindowInsets.systemBars.only(WindowInsetsSides.Bottom), + ), + onNavigateBack: (() -> Unit)? = null, + content: @Composable ((PaddingValues) -> Unit), +) { + val scrollBehavior = TopAppBarDefaults + .pinnedScrollBehavior(rememberTopAppBarState()) + val localFocusManager = LocalFocusManager.current + + val devMode = model?.devMode?.observeAsState(false) + val online = model?.networkManager?.networkStatus?.observeAsState(true) + + Scaffold( + modifier = modifier + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + Column { + if (title != null) { + TopAppBar( + title = title, + navigationIcon = navigationIcon ?: { + if (onNavigateBack != null) { + IconButton(onClick = { + localFocusManager.clearFocus() + onNavigateBack() + }) { + Icon( + Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(R.string.button_back), + ) + } + } + }, + actions = { + Row { + actions() + if (devMode != null && devMode.value) { + IconButton(onClick = { + model.showObservabilityLog() + }) { + Icon( + Icons.Default.BugReport, + contentDescription = stringResource(R.string.observability_title), + ) + } + } + } + }, + scrollBehavior = scrollBehavior, + ) + } + + if (online != null && !online.value) Text( + text = stringResource(R.string.offline_banner), + textAlign = TextAlign.Center, + modifier = Modifier + .background(MaterialTheme.colorScheme.errorContainer) + .padding(8.dp) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + }, + floatingActionButton = floatingActionButton, + bottomBar = bottomBar, + contentWindowInsets = contentWindowInsets, + snackbarHost = snackbarHost, + ) { innerPadding -> + content(innerPadding) + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositAmountComposable.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositAmountComposable.kt @@ -57,6 +57,7 @@ import net.taler.wallet.useDebounce @Composable fun DepositAmountComposable( + modifier: Modifier = Modifier, state: DepositState.AccountSelected, knownCurrencies: List<String>, getCurrencySpec: (currency: String) -> CurrencySpecification?, @@ -65,7 +66,7 @@ fun DepositAmountComposable( onClose: () -> Unit, ) { Column( - Modifier + modifier .fillMaxSize() .imePadding(), ) { diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt @@ -1,189 +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.deposit - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.compose.BackHandler -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.map -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import kotlinx.coroutines.launch -import net.taler.common.Amount -import net.taler.common.showError -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.compose.LoadingScreen -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.showError -import net.taler.wallet.accounts.ListBankAccountsResult.Success -import net.taler.wallet.compose.ErrorComposable - -class DepositFragment : Fragment() { - private val model: MainViewModel by activityViewModels() - private val depositManager get() = model.depositManager - private val accountManager get() = model.accountManager - private val exchangeManager get() = model.exchangeManager - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - val presetAmount = arguments?.getString("amount")?.let { Amount.fromJSONString(it) } - val receiverName = arguments?.getString("receiverName") - val receiverPostalCode = arguments?.getString("receiverPostalCode") - val receiverTown = arguments?.getString("receiverTown") - val iban = arguments?.getString("IBAN") - - if (presetAmount != null && receiverName != null && iban != null) { - val paytoUri = getIbanPayto(receiverName, receiverPostalCode, receiverTown, iban) - depositManager.makeDeposit(presetAmount, paytoUri) - } - - return ComposeView(requireContext()).apply { - setContent { - TalerSurface { - val state by depositManager.depositState.collectAsStateLifecycleAware() - val knownBankAccounts by accountManager.bankAccounts.collectAsStateLifecycleAware() - val knownCurrencies by model.balanceManager.balances.map { - it.map { bl -> bl.currency } - }.observeAsState(emptyList()) - val devMode by model.devMode.observeAsState(false) - - BackHandler(state is DepositState.AccountSelected) { - depositManager.resetDepositState() - } - - when (val s = state) { - is DepositState.MakingDeposit, is DepositState.Success -> { - LoadingScreen() - } - - is DepositState.Error -> ErrorComposable(s.error, - devMode = devMode, - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - onClose = { - findNavController().popBackStack() - }, - ) - - is DepositState.Start -> { - MakeDepositComposable( - knownBankAccounts = (knownBankAccounts as? Success) - ?.accounts - ?: emptyList(), - onAccountSelected = { account -> - depositManager.selectAccount(account) - }, - onManageBankAccounts = { - findNavController().navigate(R.id.action_global_bankAccounts) - } - ) - } - - is DepositState.AccountSelected -> { - DepositAmountComposable( - state = s, - knownCurrencies = knownCurrencies, - getCurrencySpec = exchangeManager::getSpecForCurrency, - checkDeposit = { a -> - depositManager.checkDepositFees(s.account.paytoUri, a) - }, - onMakeDeposit = { amount -> - depositManager.makeDeposit(amount, s.account.paytoUri) - }, - onClose = { - depositManager.resetDepositState() - } - ) - } - } - } - } - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val supportActionBar = (requireActivity() as? AppCompatActivity)?.supportActionBar - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - depositManager.depositState.collect { state -> - when (state) { - is DepositState.Start -> { - supportActionBar?.setTitle(R.string.send_deposit_select_account_title) - } - - is DepositState.AccountSelected -> { - supportActionBar?.setTitle(R.string.send_deposit_select_amount_title) - } - - is DepositState.Error -> { - if (model.devMode.value == false) { - showError(state.error.userFacingMsg) - } else { - showError(state.error) - } - } - - is DepositState.Success -> { - findNavController().navigate(R.id.action_global_main) - } - - else -> {} - } - } - } - } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - accountManager.listBankAccounts() - } - } - } - - override fun onStart() { - super.onStart() - activity?.setTitle(R.string.send_deposit_title) - } - - override fun onDestroy() { - super.onDestroy() - if (!requireActivity().isChangingConfigurations) { - depositManager.resetDepositState() - } - } -} diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositScreen.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositScreen.kt @@ -0,0 +1,140 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.deposit + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.map +import net.taler.common.Amount +import net.taler.wallet.NavigateCallback +import net.taler.wallet.R +import net.taler.wallet.WalletDestination +import net.taler.wallet.accounts.ListBankAccountsResult +import net.taler.wallet.compose.ErrorComposable +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.main.MainViewModel + +@Composable +fun DepositScreen( + model: MainViewModel, + dest: WalletDestination.Deposit, + onNavigate: NavigateCallback, + onNavigateBack: () -> Unit, +) { + val depositManager = model.depositManager + val accountManager = model.accountManager + val exchangeManager = model.exchangeManager + + val presetAmount = dest.amount?.let { Amount.fromJSONString(it) } + val receiverName = dest.receiverName + val receiverPostalCode = dest.receiverPostalCode + val receiverTown = dest.receiverTown + val iban = dest.IBAN + + LaunchedEffect(Unit) { + if (presetAmount != null && receiverName != null && iban != null) { + val paytoUri = getIbanPayto(receiverName, receiverPostalCode, receiverTown, iban) + depositManager.makeDeposit(presetAmount, paytoUri) + } + accountManager.listBankAccounts() + } + + val state by depositManager.depositState.collectAsStateLifecycleAware() + + LaunchedEffect(state) { + if (state is DepositState.Success) { + onNavigateBack() + } + } + + GlobalScaffold( + model = model, + onNavigateBack = onNavigateBack, + title = { Text(stringResource(R.string.send_deposit_title)) }, + ) { paddingValues -> + val knownBankAccounts by accountManager.bankAccounts.collectAsStateLifecycleAware() + val knownCurrencies by model.balanceManager.balances.map { + it.map { bl -> bl.currency } + }.observeAsState(emptyList()) + val devMode by model.devMode.observeAsState(false) + + BackHandler(state is DepositState.AccountSelected) { + depositManager.resetDepositState() + } + + when (val s = state) { + is DepositState.MakingDeposit, is DepositState.Success -> { + LoadingScreen(Modifier.padding(paddingValues)) + } + + is DepositState.Error -> ErrorComposable( + error = s.error, + devMode = devMode, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()), + onClose = onNavigateBack, + ) + + is DepositState.Start -> { + MakeDepositComposable( + modifier = Modifier.padding(paddingValues), + knownBankAccounts = (knownBankAccounts as? ListBankAccountsResult.Success) + ?.accounts + ?: emptyList(), + onAccountSelected = { account -> + depositManager.selectAccount(account) + }, + onManageBankAccounts = { + onNavigate(WalletDestination.BankAccounts(), false) + } + ) + } + + is DepositState.AccountSelected -> { + DepositAmountComposable( + modifier = Modifier.padding(paddingValues), + state = s, + knownCurrencies = knownCurrencies, + getCurrencySpec = exchangeManager::getSpecForCurrency, + checkDeposit = { a -> + depositManager.checkDepositFees(s.account.paytoUri, a) + }, + onMakeDeposit = { amount -> + depositManager.makeDeposit(amount, s.account.paytoUri) + }, + onClose = { + depositManager.resetDepositState() + } + ) + } + } + } +} diff --git a/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositComposable.kt b/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositComposable.kt @@ -38,9 +38,10 @@ fun MakeDepositComposable( knownBankAccounts: List<KnownBankAccountInfo>, onAccountSelected: (account: KnownBankAccountInfo) -> Unit, onManageBankAccounts: () -> Unit, + modifier: Modifier = Modifier, ) { LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { if (knownBankAccounts.isEmpty()) item { diff --git a/wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt b/wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt @@ -1,185 +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.deposit - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -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.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -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 net.taler.common.Amount -import net.taler.common.CurrencySpecification -import net.taler.wallet.main.AmountResult -import net.taler.wallet.BottomInsetsSpacer -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.compose.AmountCurrencyField -import net.taler.wallet.compose.TalerSurface -import androidx.core.net.toUri - -class PayToUriFragment : Fragment() { - private val model: MainViewModel by activityViewModels() - private val depositManager get() = model.depositManager - private val balanceManager get() = model.balanceManager - private val exchangeManager get() = model.exchangeManager - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - val uri = arguments?.getString("uri") ?: error("no amount passed") - val u = uri.toUri() - val receiverName = u.getQueryParameter("receiver-name") - ?.replace('+', ' ') ?: "" - val iban = u.pathSegments.last() ?: "" - - val currencies = balanceManager.getCurrencies() - return ComposeView(requireContext()).apply { - setContent { - TalerSurface { - if (currencies.isEmpty()) Text( - text = stringResource(id = R.string.payment_balance_insufficient), - color = MaterialTheme.colorScheme.error, - ) else if (depositManager.isSupportedPayToUri(uri)) PayToComposable( - currencies = currencies, - getAmount = model::createAmount, - onAmountChosen = { amount -> - val bundle = bundleOf( - "amount" to amount.toJSONString(), - "receiverName" to receiverName, - "IBAN" to iban, - ) - findNavController().navigate( - R.id.action_global_deposit, bundle) - }, - getCurrencySpec = exchangeManager::getSpecForCurrency, - ) else Text( - text = stringResource(id = R.string.uri_invalid), - color = MaterialTheme.colorScheme.error, - ) - } - } - } - } - - override fun onStart() { - super.onStart() - activity?.setTitle(R.string.send_deposit_title) - } - -} - -@Composable -private fun PayToComposable( - currencies: List<String>, - getAmount: (String, String) -> AmountResult, - getCurrencySpec: (String) -> CurrencySpecification?, - onAmountChosen: (Amount) -> Unit, -) { - val scrollState = rememberScrollState() - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp) - .verticalScroll(scrollState), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - var amount by remember { mutableStateOf(Amount.zero(currencies[0])) } - val currencySpec = remember(amount.currency) { getCurrencySpec(amount.currency) } - var amountError by rememberSaveable { mutableStateOf("") } - - AmountCurrencyField( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - amount = amount.withSpec(currencySpec), - currencies = currencies, - readOnly = false, - onAmountChanged = { amount = it }, - label = { Text(stringResource(R.string.amount_send)) }, - isError = amountError.isNotBlank(), - supportingText = { - if (amountError.isNotBlank()) { - Text(amountError) - } - } - ) - - val focusManager = LocalFocusManager.current - val errorStrInvalidAmount = stringResource(id = R.string.amount_invalid) - val errorStrInsufficientBalance = stringResource(id = R.string.payment_balance_insufficient) - Button( - modifier = Modifier.padding(16.dp), - enabled = !amount.isZero(), - onClick = { - when (val amountResult = getAmount(amount.amountStr, amount.currency)) { - is AmountResult.Success -> { - focusManager.clearFocus() - onAmountChosen(amountResult.amount) - } - is AmountResult.InvalidAmount -> amountError = errorStrInvalidAmount - is AmountResult.InsufficientBalance -> amountError = errorStrInsufficientBalance - } - }, - ) { - Text(text = stringResource(R.string.send_deposit_check_fees_button)) - } - - BottomInsetsSpacer() - } -} - -@Preview -@Composable -fun PreviewPayToComposable() { - Surface { - PayToComposable( - currencies = listOf("KUDOS", "TESTKUDOS", "BTCBITCOIN"), - getAmount = { _, _ -> AmountResult.InvalidAmount }, - onAmountChosen = {}, - getCurrencySpec = { null } - ) - } -} diff --git a/wallet/src/main/java/net/taler/wallet/deposit/PayToUriScreen.kt b/wallet/src/main/java/net/taler/wallet/deposit/PayToUriScreen.kt @@ -0,0 +1,165 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.deposit + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +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.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import net.taler.common.Amount +import net.taler.common.CurrencySpecification +import net.taler.wallet.BottomInsetsSpacer +import net.taler.wallet.NavigateCallback +import net.taler.wallet.R +import net.taler.wallet.WalletDestination +import net.taler.wallet.compose.AmountCurrencyField +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.main.AmountResult +import net.taler.wallet.main.MainViewModel + +@Composable +fun PayToUriScreen( + model: MainViewModel, + uri: String, + onNavigate: NavigateCallback, + onNavigateBack: () -> Unit, +) { + val u = uri.toUri() + val receiverName = u.getQueryParameter("receiver-name")?.replace('+', ' ') ?: "" + val iban = u.pathSegments.lastOrNull() ?: "" + + val currencies = model.balanceManager.getCurrencies() + + TalerSurface { + GlobalScaffold( + model = model, + title = { Text(stringResource(R.string.transactions_send_funds)) }, + onNavigateBack = onNavigateBack, + ) { paddingValues -> + Box(Modifier.padding(paddingValues)) { + if (currencies.isEmpty()) { + Text( + modifier = Modifier.padding(16.dp), + text = stringResource(id = R.string.payment_balance_insufficient), + color = MaterialTheme.colorScheme.error, + ) + } else if (model.depositManager.isSupportedPayToUri(uri)) { + PayToComposable( + currencies = currencies, + getAmount = model::createAmount, + onAmountChosen = { amount -> + onNavigate( + WalletDestination.Deposit( + amount = amount.toJSONString(), + receiverName = receiverName, + IBAN = iban + ), true, + ) + }, + getCurrencySpec = model.exchangeManager::getSpecForCurrency, + ) + } else { + Text( + modifier = Modifier.padding(16.dp), + text = stringResource(id = R.string.uri_invalid), + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +@Composable +private fun PayToComposable( + currencies: List<String>, + getAmount: (String, String) -> AmountResult, + getCurrencySpec: (String) -> CurrencySpecification?, + onAmountChosen: (Amount) -> Unit, +) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + var amount by remember { mutableStateOf(Amount.zero(currencies[0])) } + val currencySpec = remember(amount.currency) { getCurrencySpec(amount.currency) } + var amountError by rememberSaveable { mutableStateOf("") } + + AmountCurrencyField( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + amount = amount.withSpec(currencySpec), + currencies = currencies, + readOnly = false, + onAmountChanged = { amount = it }, + label = { Text(stringResource(R.string.amount_send)) }, + isError = amountError.isNotBlank(), + supportingText = { + if (amountError.isNotBlank()) { + Text(amountError) + } + } + ) + + val focusManager = LocalFocusManager.current + val errorStrInvalidAmount = stringResource(id = R.string.amount_invalid) + val errorStrInsufficientBalance = stringResource(id = R.string.payment_balance_insufficient) + Button( + modifier = Modifier.padding(16.dp), + enabled = !amount.isZero(), + onClick = { + when (val amountResult = getAmount(amount.amountStr, amount.currency)) { + is AmountResult.Success -> { + focusManager.clearFocus() + onAmountChosen(amountResult.amount) + } + is AmountResult.InvalidAmount -> amountError = errorStrInvalidAmount + is AmountResult.InsufficientBalance -> amountError = errorStrInsufficientBalance + } + }, + ) { + Text(text = stringResource(R.string.send_deposit_check_fees_button)) + } + + BottomInsetsSpacer() + } +} diff --git a/wallet/src/main/java/net/taler/wallet/deposit/TransactionDepositComposable.kt b/wallet/src/main/java/net/taler/wallet/deposit/TransactionDepositComposable.kt @@ -62,21 +62,20 @@ fun TransactionDepositComposable( onWireTransfer: () -> Unit, onShowQrCodes: () -> Unit, onTransition: (t: TransactionAction) -> Unit, + modifier: Modifier = Modifier, ) { val scrollState = rememberScrollState() Column( - modifier = Modifier + modifier = modifier .fillMaxWidth() .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally, ) { - val context = LocalContext.current - TransactionStateComposable(state = t.txState) Text( modifier = Modifier.padding(16.dp), - text = t.timestamp.ms.toAbsoluteTime(context).toString(), + text = t.timestamp.ms.toAbsoluteTime(LocalContext.current).toString(), style = MaterialTheme.typography.bodyLarge, ) @@ -133,6 +132,13 @@ fun TransactionDepositComposablePreview() { )) ) Surface { - TransactionDepositComposable(t, true, null, {}, {}) {} + TransactionDepositComposable( + t = t, + devMode = true, + spec = null, + onWireTransfer = {}, + onShowQrCodes = {}, + onTransition = {}, + ) } } diff --git a/wallet/src/main/java/net/taler/wallet/donau/DonauStatementComposable.kt b/wallet/src/main/java/net/taler/wallet/donau/DonauStatementComposable.kt @@ -50,6 +50,7 @@ import net.taler.wallet.transactions.TransactionInfoComposable fun DonauStatementComposable( statements: List<DonauStatement>, selectedIndex: Int, + modifier: Modifier = Modifier, onSelectIndex: (index: Int) -> Unit, ) { val statement = remember(selectedIndex) { @@ -58,7 +59,7 @@ fun DonauStatementComposable( // TODO: create common instruction + QR composable Column( - modifier = Modifier + modifier = modifier .fillMaxSize() .verticalScroll(rememberScrollState()), horizontalAlignment = CenterHorizontally, diff --git a/wallet/src/main/java/net/taler/wallet/donau/DonauStatementFragment.kt b/wallet/src/main/java/net/taler/wallet/donau/DonauStatementFragment.kt @@ -1,104 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2025 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.donau - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.compose.EmptyComposable -import net.taler.wallet.compose.ErrorComposable -import net.taler.wallet.compose.LoadingScreen -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.compose.collectAsStateLifecycleAware - -class DonauStatementFragment: Fragment() { - private val model: MainViewModel by activityViewModels() - - private lateinit var host: String - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? = ComposeView(requireContext()).apply { - host = arguments?.getString("host") - ?: error("no host provided") - - val supportActionBar = (requireActivity() as? AppCompatActivity) - ?.supportActionBar - - setContent { - TalerSurface { - val status by model.donauManager.donauStatementsStatus.collectAsStateLifecycleAware() - val devMode by model.devMode.observeAsState(false) - when (val s = status) { - is GetDonauStatementsStatus.None, - is GetDonauStatementsStatus.Loading -> LoadingScreen() - - is GetDonauStatementsStatus.Error -> ErrorComposable( - error = s.error, - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - devMode = devMode, - ) - - is GetDonauStatementsStatus.Success -> if (s.statements.isEmpty()) { - EmptyComposable() - } else { - var selectedIndex by rememberSaveable { mutableIntStateOf(0) } - DonauStatementComposable( - statements = s.statements, - selectedIndex = selectedIndex, - ) { index -> - selectedIndex = index - } - - LaunchedEffect(selectedIndex) { - supportActionBar?.title = getString( - R.string.donau_statement_title_year, - s.statements[selectedIndex].year, - ) - } - } - } - } - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - model.donauManager.getDonauStatements(host) - } -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/donau/DonauStatementScreen.kt b/wallet/src/main/java/net/taler/wallet/donau/DonauStatementScreen.kt @@ -0,0 +1,90 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.donau + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import net.taler.wallet.R +import net.taler.wallet.compose.EmptyComposable +import net.taler.wallet.compose.ErrorComposable +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.main.MainViewModel + +@Composable +fun DonauStatementScreen( + model: MainViewModel, + host: String, + onNavigateBack: () -> Unit, +) { + val donauManager = model.donauManager + val status by donauManager.donauStatementsStatus.collectAsStateLifecycleAware() + val devMode by model.devMode.observeAsState(false) + + LaunchedEffect(host) { + donauManager.getDonauStatements(host) + } + + TalerSurface { + GlobalScaffold( + model = model, + onNavigateBack = onNavigateBack, + title = { Text(stringResource(R.string.donau_statement_title)) }, + ) { paddingValues -> + when (val s = status) { + is GetDonauStatementsStatus.None, + is GetDonauStatementsStatus.Loading -> LoadingScreen(Modifier.padding(paddingValues)) + + is GetDonauStatementsStatus.Error -> ErrorComposable( + error = s.error, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()), + devMode = devMode, + ) + + is GetDonauStatementsStatus.Success -> if (s.statements.isEmpty()) { + EmptyComposable(Modifier.padding(paddingValues)) + } else { + var selectedIndex by rememberSaveable { mutableIntStateOf(0) } + DonauStatementComposable( + modifier = Modifier.padding(paddingValues), + statements = s.statements, + selectedIndex = selectedIndex, + ) { index -> + selectedIndex = index + } + } + } + } + } +} diff --git a/wallet/src/main/java/net/taler/wallet/donau/SetDonauFragment.kt b/wallet/src/main/java/net/taler/wallet/donau/SetDonauFragment.kt @@ -1,205 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2025 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.donau - -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.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -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.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily.Companion.Monospace -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.navigation.fragment.findNavController -import net.taler.common.showError -import net.taler.wallet.BottomInsetsSpacer -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.compose.ErrorComposable -import net.taler.wallet.compose.LoadingScreen -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.donau.GetDonauStatus.Error -import net.taler.wallet.donau.GetDonauStatus.Loading -import net.taler.wallet.donau.GetDonauStatus.None -import net.taler.wallet.donau.GetDonauStatus.Success -import net.taler.wallet.showError - -class SetDonauFragment: Fragment() { - val model: MainViewModel by activityViewModels() - val donauManager by lazy { model.donauManager } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = ComposeView(requireContext()).apply { - val saveShouldExit = arguments?.getBoolean("saveShouldExit") == true - val donauBaseUrl = arguments?.getString("donauBaseUrl") - - setContent { - TalerSurface { - val donauStatus by donauManager.donauStatus.collectAsState() - val devMode by model.devMode.observeAsState() - when(val status = donauStatus) { - Loading, None -> LoadingScreen() - is Success -> SetDonauComposable( - initialUrl = donauBaseUrl, - donauInfo = status.donauInfo, - onSetDonauInfo = { info -> - donauManager.setDonau(info, - { - Toast.makeText( - requireContext(), - getString(R.string.donau_ready), - Toast.LENGTH_LONG).show() - if (saveShouldExit) { - findNavController().popBackStack() - } - }, - { error -> - if(model.devMode.value == true) { - showError(error) - } else { - showError(error.userFacingMsg) - } - } - ) - }, - ) - is Error -> ErrorComposable( - error = status.error, - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - devMode = devMode == true, - ) - } - - LaunchedEffect(Unit) { - donauManager.getDonau() - } - } - } - } -} - -@Composable -fun SetDonauComposable( - initialUrl: String? = null, - donauInfo: DonauInfo?, - onSetDonauInfo: (info: DonauInfo) -> Unit, -) { - val focusManager = LocalFocusManager.current - val keyboardController = LocalSoftwareKeyboardController.current - var donauBaseUrl by remember { mutableStateOf(initialUrl ?: donauInfo?.donauBaseUrl ?: "") } - var taxPayerId by remember { mutableStateOf(donauInfo?.taxPayerId ?: "") } - - Column(Modifier.fillMaxSize()) { - OutlinedTextField( - modifier = Modifier.padding( - bottom = 16.dp, - start = 16.dp, - end = 16.dp, - ).fillMaxWidth(), - value = donauBaseUrl, - onValueChange = { - donauBaseUrl = it - }, - singleLine = true, - isError = donauBaseUrl.isBlank(), - label = { Text(stringResource(R.string.donau_url)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }), - ) - - OutlinedTextField( - modifier = Modifier.padding( - bottom = 16.dp, - start = 16.dp, - end = 16.dp, - ).fillMaxWidth(), - value = taxPayerId, - onValueChange = { - taxPayerId = it - }, - singleLine = true, - textStyle = TextStyle(fontFamily = Monospace), - isError = taxPayerId.isBlank(), - label = { Text(stringResource(R.string.donau_id)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Exit) }), - ) - - Button( - modifier = Modifier - .padding(horizontal = 16.dp) - .align(Alignment.End), - onClick = { - focusManager.clearFocus() - keyboardController?.hide() - onSetDonauInfo(DonauInfo( - donauBaseUrl = donauBaseUrl, - taxPayerId = taxPayerId, - )) - }, - enabled = donauBaseUrl.isNotBlank() && - taxPayerId.isNotBlank() - ) { - Text(stringResource(R.string.save)) - } - - BottomInsetsSpacer() - } -} - -@Preview -@Composable -fun SetDonauComposablePreview() { - TalerSurface { - SetDonauComposable("https://donau.test.taler.net/", null) {} - } -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/donau/SetDonauScreen.kt b/wallet/src/main/java/net/taler/wallet/donau/SetDonauScreen.kt @@ -0,0 +1,197 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.donau + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +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.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily.Companion.Monospace +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.taler.wallet.BottomInsetsSpacer +import net.taler.wallet.R +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.ErrorComposable +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.donau.GetDonauStatus.Error +import net.taler.wallet.donau.GetDonauStatus.Loading +import net.taler.wallet.donau.GetDonauStatus.None +import net.taler.wallet.donau.GetDonauStatus.Success +import net.taler.wallet.main.MainViewModel + +@Composable +fun SetDonauScreen( + model: MainViewModel, + donauBaseUrl: String?, + onShowMessage: (String) -> Unit, + onShowError: (TalerErrorInfo) -> Unit, + onNavigateBack: () -> Unit, +) { + val donauManager = model.donauManager + val donauStatus by donauManager.donauStatus.collectAsStateLifecycleAware() + val devMode by model.devMode.observeAsState(false) + + LaunchedEffect(Unit) { + donauManager.getDonau() + } + + val readyMessage = stringResource(id = R.string.donau_ready) + + TalerSurface { + GlobalScaffold( + model = model, + onNavigateBack = onNavigateBack, + title = { Text(stringResource(R.string.donau_title)) }, + ) { paddingValues -> + when (val status = donauStatus) { + Loading, None -> LoadingScreen(Modifier.padding(paddingValues)) + is Success -> SetDonauComposable( + modifier = Modifier.padding(paddingValues), + donauInfo = status.donauInfo, + initialUrl = donauBaseUrl, + onSetDonauInfo = { info -> + donauManager.setDonau( + info, + { + onShowMessage(readyMessage) + onNavigateBack() + }, + { error -> onShowError(error) } + ) + }, + ) + + is Error -> ErrorComposable( + error = status.error, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()), + devMode = devMode, + ) + } + } + } +} + +@Composable +fun SetDonauComposable( + donauInfo: DonauInfo?, + onSetDonauInfo: (info: DonauInfo) -> Unit, + modifier: Modifier = Modifier, + initialUrl: String? = null, +) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + var donauBaseUrl by remember { mutableStateOf(initialUrl ?: donauInfo?.donauBaseUrl ?: "") } + var taxPayerId by remember { mutableStateOf(donauInfo?.taxPayerId ?: "") } + + Column(modifier.fillMaxSize()) { + OutlinedTextField( + modifier = Modifier.padding( + bottom = 16.dp, + start = 16.dp, + end = 16.dp, + ).fillMaxWidth(), + value = donauBaseUrl, + onValueChange = { + donauBaseUrl = it + }, + singleLine = true, + isError = donauBaseUrl.isBlank(), + label = { Text(stringResource(R.string.donau_url)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }), + ) + + OutlinedTextField( + modifier = Modifier.padding( + bottom = 16.dp, + start = 16.dp, + end = 16.dp, + ).fillMaxWidth(), + value = taxPayerId, + onValueChange = { + taxPayerId = it + }, + singleLine = true, + textStyle = TextStyle(fontFamily = Monospace), + isError = taxPayerId.isBlank(), + label = { Text(stringResource(R.string.donau_id)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Exit) }), + ) + + Button( + modifier = Modifier + .padding(horizontal = 16.dp) + .align(Alignment.End), + onClick = { + focusManager.clearFocus() + keyboardController?.hide() + onSetDonauInfo(DonauInfo( + donauBaseUrl = donauBaseUrl, + taxPayerId = taxPayerId, + )) + }, + enabled = donauBaseUrl.isNotBlank() && + taxPayerId.isNotBlank() + ) { + Text(stringResource(R.string.save)) + } + + BottomInsetsSpacer() + } +} + +@Preview +@Composable +fun SetDonauComposablePreview() { + TalerSurface { + SetDonauComposable( + donauInfo = null, + initialUrl = "https://donau.test.taler.net/", + onSetDonauInfo = {}, + ) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/events/ObservabilityDialog.kt b/wallet/src/main/java/net/taler/wallet/events/ObservabilityDialog.kt @@ -16,10 +16,6 @@ package net.taler.wallet.events -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -36,55 +32,30 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.font.FontFamily import androidx.compose.ui.unit.dp -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.activityViewModels import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json -import net.taler.wallet.main.MainViewModel import net.taler.wallet.R import net.taler.wallet.compose.CopyToClipboardButton -import net.taler.wallet.events.ObservabilityDialog.Companion.json import java.time.format.DateTimeFormatter import java.time.format.FormatStyle -class ObservabilityDialog: DialogFragment() { - private val model: MainViewModel by activityViewModels() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = ComposeView(requireContext()).apply { - setContent { - val events by model.observabilityLog.collectAsState() - ObservabilityComposable(events.reversed()) { - dismiss() - } - } - } - - companion object { - @OptIn(ExperimentalSerializationApi::class) - val json = Json { - prettyPrint = true - prettyPrintIndent = " " - } - } +@OptIn(ExperimentalSerializationApi::class) +private val observabilityJson = Json { + prettyPrint = true + prettyPrintIndent = " " } @Composable -fun ObservabilityComposable( +fun ObservabilityDialog( events: List<ObservabilityEvent>, onDismiss: () -> Unit, ) { @@ -122,7 +93,7 @@ fun ObservabilityItem( event: ObservabilityEvent, showJson: Boolean, ) { - val body = json.encodeToString(event.body) + val body = observabilityJson.encodeToString(event.body) val timestamp = DateTimeFormatter .ofLocalizedDateTime(FormatStyle.MEDIUM) .format(event.timestamp) diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/AddExchangeDialogFragment.kt b/wallet/src/main/java/net/taler/wallet/exchanges/AddExchangeDialogFragment.kt @@ -1,50 +0,0 @@ -/* - * 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/> - */ - -package net.taler.wallet.exchanges - -import android.app.Dialog -import android.os.Bundle -import android.widget.TextView -import androidx.appcompat.app.AlertDialog -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.activityViewModels -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.R - - -class AddExchangeDialogFragment : DialogFragment() { - - private val model: MainViewModel by activityViewModels() - private val exchangeManager by lazy { model.exchangeManager } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - return MaterialAlertDialogBuilder(requireContext(), R.style.MaterialAlertDialog_Material3) - .setIcon(R.drawable.ic_account_balance) - .setTitle(R.string.exchange_list_add) - .setView(R.layout.dialog_exchange_add) - .setPositiveButton(R.string.ok) { dialog, _ -> - val urlView: TextView = (dialog as AlertDialog).findViewById(R.id.urlView)!! - exchangeManager.add(urlView.text.toString()) - } - .setNegativeButton(R.string.cancel) { _, _ -> - dismiss() - } - .create() - } - -} diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt @@ -1,190 +0,0 @@ -/* - * 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/> - */ - -package net.taler.wallet.exchanges - -import android.view.LayoutInflater -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -import android.view.ViewGroup -import android.widget.ImageButton -import android.widget.TextView -import androidx.appcompat.widget.PopupMenu -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.Adapter -import net.taler.wallet.R -import net.taler.wallet.balances.ScopeInfo -import net.taler.wallet.exchanges.ExchangeAdapter.ExchangeItemViewHolder - -interface ExchangeClickListener { - fun onExchangeSelected(item: ExchangeItem) - fun onManualWithdraw(item: ExchangeItem) - fun onPeerReceive(item: ExchangeItem) - fun onExchangeReload(item: ExchangeItem) - fun onExchangeDelete(item: ExchangeItem) - fun onExchangeTosAccept(item: ExchangeItem) - fun onExchangeTosForget(item: ExchangeItem) - fun onExchangeTosView(item: ExchangeItem) - fun onExchangeGlobalCurrencyAdd(item: ExchangeItem) - fun onExchangeGlobalCurrencyDelete(item: ExchangeItem) -} - -internal class ExchangeAdapter( - private val selectOnly: Boolean, - private val listener: ExchangeClickListener, - private val devMode: Boolean, -) : Adapter<ExchangeItemViewHolder>() { - - private var items = emptyList<ExchangeItem>() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ExchangeItemViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.list_item_exchange, parent, false) - return ExchangeItemViewHolder(view) - } - - override fun getItemCount() = items.size - - override fun onBindViewHolder(holder: ExchangeItemViewHolder, position: Int) { - holder.bind(items[position]) - } - - fun update(newItems: List<ExchangeItem>) { - val oldItems = this.items - - val diffCallback = ExchangeDiffCallback(oldItems, newItems) - val diffResult = DiffUtil.calculateDiff(diffCallback) - diffResult.dispatchUpdatesTo(this) - - items = newItems - } - - internal inner class ExchangeItemViewHolder(v: View) : RecyclerView.ViewHolder(v) { - private val context = v.context - private val urlView: TextView = v.findViewById(R.id.urlView) - private val currencyView: TextView = v.findViewById(R.id.currencyView) - private val overflowIcon: ImageButton = v.findViewById(R.id.overflowIcon) - - fun bind(item: ExchangeItem) { - urlView.text = item.name - // If currency is null, it's because we have no data from the exchange... - currencyView.text = if (item.currency == null) { - context.getString(R.string.exchange_not_contacted) - } else { - context.getString(R.string.exchange_list_currency, item.currency) - } - if (selectOnly) { - itemView.setOnClickListener { listener.onExchangeSelected(item) } - overflowIcon.visibility = GONE - } else { - itemView.setOnClickListener(null) - itemView.isClickable = false - // ...thus, we should prevent the user from interacting with it. - overflowIcon.visibility = if (item.currency != null) VISIBLE else GONE - } - overflowIcon.setOnClickListener { openMenu(overflowIcon, item) } - } - - private fun openMenu(anchor: View, item: ExchangeItem) = PopupMenu(context, anchor).apply { - inflate(R.menu.exchange) - if (item.tosStatus.isAccepted()) { - menu.findItem(R.id.action_view_tos).isVisible = true - menu.findItem(R.id.action_accept_tos).isVisible = false - menu.findItem(R.id.action_forget_tos).isVisible = devMode - } else { - menu.findItem(R.id.action_view_tos).isVisible = false - menu.findItem(R.id.action_accept_tos).isVisible = true - menu.findItem(R.id.action_forget_tos).isVisible = false - } - - if (item.scopeInfo is ScopeInfo.Exchange) { - menu.findItem(R.id.action_global_add).isVisible = devMode - menu.findItem(R.id.action_global_delete).isVisible = false - } else if (item.scopeInfo is ScopeInfo.Global) { - menu.findItem(R.id.action_global_add).isVisible = false - menu.findItem(R.id.action_global_delete).isVisible = devMode - } - - setOnMenuItemClickListener { menuItem -> - when (menuItem.itemId) { - R.id.action_manual_withdrawal -> { - listener.onManualWithdraw(item) - true - } - R.id.action_receive_peer -> { - listener.onPeerReceive(item) - true - } - R.id.action_reload -> { - listener.onExchangeReload(item) - true - } - R.id.action_view_tos -> { - listener.onExchangeTosView(item) - true - } - R.id.action_accept_tos -> { - listener.onExchangeTosAccept(item) - true - } - R.id.action_forget_tos -> { - listener.onExchangeTosForget(item) - true - } - R.id.action_global_add -> { - listener.onExchangeGlobalCurrencyAdd(item) - true - } - R.id.action_global_delete -> { - listener.onExchangeGlobalCurrencyDelete(item) - true - } - R.id.action_delete -> { - listener.onExchangeDelete(item) - true - } - else -> false - } - } - show() - } - } -} - -internal class ExchangeDiffCallback( - private val oldList: List<ExchangeItem>, - private val newList: List<ExchangeItem>, -): DiffUtil.Callback() { - override fun getOldListSize() = oldList.size - - override fun getNewListSize() = newList.size - - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - val old = oldList[oldItemPosition] - val new = newList[newItemPosition] - - return old.exchangeBaseUrl == new.exchangeBaseUrl - } - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - val old = oldList[oldItemPosition] - val new = newList[newItemPosition] - - return old == new - } -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeFeesFragment.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeFeesFragment.kt @@ -1,146 +0,0 @@ -/* - * 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/> - */ - -package net.taler.wallet.exchanges - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.View.GONE -import android.view.ViewGroup -import android.widget.TextView -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.recyclerview.widget.RecyclerView.Adapter -import androidx.recyclerview.widget.RecyclerView.ViewHolder -import net.taler.common.Amount -import net.taler.common.toRelativeTime -import net.taler.common.toShortDate -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.databinding.FragmentExchangeFeesBinding -import net.taler.wallet.exchanges.CoinFeeAdapter.CoinFeeViewHolder -import net.taler.wallet.exchanges.WireFeeAdapter.WireFeeViewHolder -import net.taler.wallet.getAttrColor - -class ExchangeFeesFragment : Fragment() { - - private val model: MainViewModel by activityViewModels() - private val withdrawManager by lazy { model.withdrawManager } - - private lateinit var ui: FragmentExchangeFeesBinding - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - ui = FragmentExchangeFeesBinding.inflate(inflater, container, false) - return ui.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val fees = withdrawManager.exchangeFees ?: throw IllegalStateException() - if (fees.withdrawFee.isZero()) { - ui.withdrawFeeLabel.visibility = GONE - ui.withdrawFeeView.visibility = GONE - } else ui.withdrawFeeView.setAmount(fees.withdrawFee) - if (fees.overhead.isZero()) { - ui.overheadLabel.visibility = GONE - ui.overheadView.visibility = GONE - } else ui.overheadView.setAmount(fees.overhead) - ui.expirationView.text = fees.earliestDepositExpiration.ms.toRelativeTime(requireContext()) - ui.coinFeesList.adapter = CoinFeeAdapter(fees.coinFees) - ui.wireFeesList.adapter = WireFeeAdapter(fees.wireFees) - } - - private fun TextView.setAmount(amount: Amount) { - if (amount.isZero()) text = amount.toString() - else { - text = getString(R.string.amount_negative, amount) - setText(requireContext().getAttrColor(R.attr.colorError)) - } - } - -} - -private class CoinFeeAdapter(private val items: List<CoinFee>) : Adapter<CoinFeeViewHolder>() { - override fun getItemCount() = items.size - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CoinFeeViewHolder { - val v = - LayoutInflater.from(parent.context).inflate(R.layout.list_item_coin_fee, parent, false) - return CoinFeeViewHolder(v) - } - - override fun onBindViewHolder(holder: CoinFeeViewHolder, position: Int) { - holder.bind(items[position]) - } - - class CoinFeeViewHolder(private val v: View) : ViewHolder(v) { - private val res = v.context.resources - private val coinView: TextView = v.findViewById(R.id.coinView) - private val withdrawFeeView: TextView = v.findViewById(R.id.withdrawFeeView) - private val depositFeeView: TextView = v.findViewById(R.id.depositFeeView) - private val refreshFeeView: TextView = v.findViewById(R.id.refreshFeeView) - private val refundFeeView: TextView = v.findViewById(R.id.refundFeeView) - fun bind(item: CoinFee) { - coinView.text = res.getQuantityString( - R.plurals.exchange_fee_coin, - item.quantity, - item.coin, - item.quantity - ) - withdrawFeeView.text = - v.context.getString(R.string.exchange_fee_withdraw_fee, item.feeWithdraw) - depositFeeView.text = - v.context.getString(R.string.exchange_fee_deposit_fee, item.feeDeposit) - refreshFeeView.text = - v.context.getString(R.string.exchange_fee_refresh_fee, item.feeRefresh) - refundFeeView.text = - v.context.getString(R.string.exchange_fee_refund_fee, item.feeRefresh) - } - } -} - -private class WireFeeAdapter(private val items: List<WireFee>) : Adapter<WireFeeViewHolder>() { - override fun getItemCount() = items.size - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WireFeeViewHolder { - val v = - LayoutInflater.from(parent.context).inflate(R.layout.list_item_wire_fee, parent, false) - return WireFeeViewHolder(v) - } - - override fun onBindViewHolder(holder: WireFeeViewHolder, position: Int) { - holder.bind(items[position]) - } - - class WireFeeViewHolder(private val v: View) : ViewHolder(v) { - private val validityView: TextView = v.findViewById(R.id.validityView) - private val wireFeeView: TextView = v.findViewById(R.id.wireFeeView) - private val closingFeeView: TextView = v.findViewById(R.id.closingFeeView) - fun bind(item: WireFee) { - validityView.text = v.context.getString( - R.string.exchange_fee_wire_fee_timespan, - item.start.ms.toShortDate(v.context), - item.end.ms.toShortDate(v.context) - ) - wireFeeView.text = - v.context.getString(R.string.exchange_fee_wire_fee_wire_fee, item.wireFee) - closingFeeView.text = - v.context.getString(R.string.exchange_fee_wire_fee_closing_fee, item.closingFee) - } - } -} diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt @@ -1,285 +0,0 @@ -/* - * 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/> - */ - -package net.taler.wallet.exchanges - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.view.ViewGroup.MarginLayoutParams -import android.widget.Toast -import android.widget.Toast.LENGTH_LONG -import androidx.core.os.bundleOf -import androidx.core.view.MenuProvider -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.marginBottom -import androidx.core.view.marginLeft -import androidx.core.view.marginRight -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle.State.RESUMED -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.launch -import net.taler.common.Amount -import net.taler.common.EventObserver -import net.taler.common.fadeIn -import net.taler.common.fadeOut -import net.taler.common.showError -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.databinding.FragmentExchangeListBinding -import net.taler.wallet.showError - -open class ExchangeListFragment : Fragment(), ExchangeClickListener { - - protected val model: MainViewModel by activityViewModels() - private val exchangeManager by lazy { model.exchangeManager } - private val transactionManager by lazy { model.transactionManager } - private val balanceManager by lazy { model.balanceManager } - - protected lateinit var ui: FragmentExchangeListBinding - protected open val isSelectOnly = false - private val exchangeAdapter by lazy { ExchangeAdapter(isSelectOnly, this, model.devMode.value == true) } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - ui = FragmentExchangeListBinding.inflate(inflater, container, false) - return ui.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - setupInsets() - - requireActivity().addMenuProvider(object : MenuProvider { - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - if (model.devMode.value == true) { - menuInflater.inflate(R.menu.exchange_list, menu) - } - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - if (menuItem.itemId == R.id.action_add_dev_exchanges) { - exchangeManager.addDevExchanges() - } - return true - } - }, viewLifecycleOwner, RESUMED) - - ui.list.apply { - adapter = exchangeAdapter - addItemDecoration(DividerItemDecoration(context, VERTICAL)) - } - ui.addExchangeFab.setOnClickListener { - AddExchangeDialogFragment().show(parentFragmentManager, "ADD_EXCHANGE") - } - - // TODO: refactor and unify progress bar handling - // exchangeManager.progress.observe(viewLifecycleOwner) { show -> - // if (show) ui.progressBar.fadeIn() else ui.progressBar.fadeOut() - // } - - exchangeManager.exchanges.observe(viewLifecycleOwner) { exchanges -> - onExchangeUpdate(exchanges) - } - - exchangeManager.addError.observe(viewLifecycleOwner, EventObserver { error -> - onAddExchangeFailed() - if (model.devMode.value == true) { - showError(error) - } - }) - - exchangeManager.listError.observe(viewLifecycleOwner, EventObserver { error -> - onListExchangeFailed() - if (model.devMode.value == true) { - showError(error) - } - }) - - exchangeManager.deleteError.observe(viewLifecycleOwner, EventObserver { error -> - if (model.devMode.value == true) { - showError(error) - } else { - showError(error.userFacingMsg) - } - }) - - exchangeManager.reloadError.observe(viewLifecycleOwner, EventObserver { error -> - if (model.devMode.value == true) { - showError(error) - } else { - showError(error.userFacingMsg) - } - }) - } - - protected open fun onExchangeUpdate(exchanges: List<ExchangeItem>) { - exchangeAdapter.update(exchanges) - if (exchanges.isEmpty()) { - ui.emptyState.fadeIn() - ui.list.fadeOut() - } else { - ui.emptyState.fadeOut() - ui.list.fadeIn() - } - } - - private fun onAddExchangeFailed() { - Toast.makeText(requireContext(), R.string.exchange_add_error, LENGTH_LONG).show() - } - - private fun onListExchangeFailed() { - Toast.makeText(requireContext(), R.string.exchange_list_error, LENGTH_LONG).show() - } - - override fun onExchangeSelected(item: ExchangeItem) { - throw AssertionError("must not get triggered here") - } - - override fun onManualWithdraw(item: ExchangeItem) { - model.withdrawManager.resetWithdrawal() - val args = bundleOf( - "editableCurrency" to false, - "exchangeBaseUrl" to item.exchangeBaseUrl, - "amount" to item.currency?.let { Amount.zero(it).toJSONString() }, - ) - findNavController().navigate(R.id.action_global_promptWithdraw, args) - } - - override fun onPeerReceive(item: ExchangeItem) { - model.selectScope(item.scopeInfo) - findNavController().navigate(R.id.action_global_outgoingPull) - } - - override fun onExchangeReload(item: ExchangeItem) { - exchangeManager.reload(item.exchangeBaseUrl) - } - - override fun onExchangeDelete(item: ExchangeItem) { - val optionsArray = arrayOf(getString(R.string.exchange_delete_force)) - val checkedArray = BooleanArray(1) { false } - - MaterialAlertDialogBuilder(requireContext(), R.style.MaterialAlertDialog_Material3) - .setTitle(R.string.exchange_delete) - .setMultiChoiceItems(optionsArray, checkedArray) { _, which, isChecked -> - checkedArray[which] = isChecked - } - .setNegativeButton(R.string.transactions_delete) { _, _ -> - exchangeManager.delete(item.exchangeBaseUrl, checkedArray[0]) - } - .setPositiveButton(R.string.cancel) { _, _ -> } - .show() - } - - override fun onExchangeTosView(item: ExchangeItem) { - val bundle = bundleOf( - "exchangeBaseUrl" to item.exchangeBaseUrl, - "readOnly" to true, - ) - findNavController().navigate(R.id.action_global_reviewExchangeTOS, bundle) - } - - override fun onExchangeTosAccept(item: ExchangeItem) { - val bundle = bundleOf("exchangeBaseUrl" to item.exchangeBaseUrl) - findNavController().navigate(R.id.action_global_reviewExchangeTOS, bundle) - } - - override fun onExchangeTosForget(item: ExchangeItem) { - viewLifecycleOwner.lifecycleScope.launch { - exchangeManager.getExchangeTos(item.exchangeBaseUrl)?.let { tos -> - exchangeManager.forgetCurrentTos(item.exchangeBaseUrl, tos.currentEtag) - } - } - } - - override fun onExchangeGlobalCurrencyAdd(item: ExchangeItem) { - item.currency?.let { - balanceManager.addGlobalCurrencyExchange( - currency = item.currency, - exchange = item, - onSuccess = { - Snackbar.make( - requireView(), - getString(R.string.exchange_global_add_success), - Snackbar.LENGTH_LONG - ).show() - }, - onError = { error -> - showError(error) - }, - ) - } - } - - override fun onExchangeGlobalCurrencyDelete(item: ExchangeItem) { - item.currency?.let { - balanceManager.removeGlobalCurrencyExchange( - currency = item.currency, - exchange = item, - onSuccess = { - Snackbar.make( - requireView(), - getString(R.string.exchange_global_delete_success), - Snackbar.LENGTH_LONG - ).show() - }, - onError = { error -> - showError(error) - }, - ) - } - } - - private fun setupInsets() { - ViewCompat.setOnApplyWindowInsetsListener(ui.list) { v, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.updatePadding( - bottom = insets.bottom, - left = insets.left, - right = insets.right, - ) - windowInsets - } - - val fabMarginBottom = ui.addExchangeFab.marginBottom - val fabMarginLeft = ui.addExchangeFab.marginLeft - val fabMarginRight = ui.addExchangeFab.marginRight - ViewCompat.setOnApplyWindowInsetsListener(ui.addExchangeFab) { v, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.updateLayoutParams<MarginLayoutParams> { - bottomMargin = fabMarginBottom + insets.bottom - leftMargin = fabMarginLeft + insets.left - rightMargin = fabMarginRight + insets.right - } - windowInsets - } - } -} diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListScreen.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListScreen.kt @@ -0,0 +1,332 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.exchanges + +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountBalance +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import net.taler.common.Amount +import net.taler.wallet.NavigateCallback +import net.taler.wallet.R +import net.taler.wallet.WalletDestination +import net.taler.wallet.balances.BalanceManager +import net.taler.wallet.balances.ScopeInfo +import net.taler.wallet.compose.EmptyComposable +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.main.MainViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExchangeListScreen( + model: MainViewModel, + onNavigate: NavigateCallback, + onNavigateBack: () -> Unit, +) { + val exchangeManager = model.exchangeManager + val balanceManager = model.balanceManager + val exchanges by exchangeManager.exchanges.observeAsState(emptyList()) + val devMode by model.devMode.observeAsState(false) + val scope = rememberCoroutineScope() + var showAddDialog by remember { mutableStateOf(false) } + + GlobalScaffold( + model = model, + onNavigateBack = onNavigateBack, + title = { Text(stringResource(R.string.exchange_list_title)) }, + floatingActionButton = { + FloatingActionButton( + modifier = Modifier.navigationBarsPadding(), + onClick = { showAddDialog = true }, + ) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.exchange_list_add)) + } + } + ) { padding -> + if (exchanges.isEmpty()) { + EmptyComposable( + modifier = Modifier.fillMaxSize().padding(padding), + message = stringResource(R.string.exchange_list_empty), + ) + } else { + LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { + items(exchanges) { exchange -> + ExchangeItemComposable( + exchange = exchange, + devMode = devMode, + onAction = { action -> + handleExchangeAction( + scope = scope, + exchangeManager = exchangeManager, + balanceManager = balanceManager, + exchange = exchange, + action = action, + onNavigate = onNavigate, + ) + } + ) + HorizontalDivider() + } + } + } + } + + if (showAddDialog) { + AddExchangeDialog( + onDismiss = { showAddDialog = false }, + onConfirm = { url -> + exchangeManager.add(url) + showAddDialog = false + } + ) + } +} + +@Composable +fun ExchangeItemComposable( + exchange: ExchangeItem, + devMode: Boolean, + onAction: (ExchangeAction) -> Unit, +) { + var showMenu by remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = exchange.name, style = MaterialTheme.typography.titleMedium) + val currencyText = exchange.currency?.let { + stringResource(R.string.exchange_list_currency, it) + } ?: stringResource(R.string.exchange_not_contacted) + Text(text = currencyText, style = MaterialTheme.typography.bodyMedium) + } + if (exchange.currency != null) { + Box { + IconButton(onClick = { showMenu = true }) { + Icon(Icons.Default.MoreVert, contentDescription = null) + } + ExchangeDropDownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + exchange = exchange, + devMode = devMode, + onAction = { + showMenu = false + onAction(it) + } + ) + } + } + } +} + +@Composable +fun ExchangeDropDownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + exchange: ExchangeItem, + devMode: Boolean, + onAction: (ExchangeAction) -> Unit, +) { + DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { + DropdownMenuItem( + text = { Text(stringResource(R.string.exchange_menu_manual_withdraw)) }, + onClick = { onAction(ExchangeAction.ManualWithdraw) } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.exchange_reload)) }, + onClick = { onAction(ExchangeAction.Reload) } + ) + + if (exchange.tosStatus.isAccepted()) { + DropdownMenuItem( + text = { Text(stringResource(R.string.exchange_tos_view)) }, + onClick = { onAction(ExchangeAction.ViewTos) } + ) + if (devMode) { + DropdownMenuItem( + text = { Text(stringResource(R.string.exchange_tos_forget)) }, + onClick = { onAction(ExchangeAction.ForgetTos) } + ) + } + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.exchange_tos_accept)) }, + onClick = { onAction(ExchangeAction.AcceptTos) } + ) + } + + if (devMode) { + if (exchange.scopeInfo is ScopeInfo.Exchange) { + DropdownMenuItem( + text = { Text(stringResource(R.string.exchange_global_add)) }, + onClick = { onAction(ExchangeAction.GlobalAdd) } + ) + } else if (exchange.scopeInfo is ScopeInfo.Global) { + DropdownMenuItem( + text = { Text(stringResource(R.string.exchange_global_delete)) }, + onClick = { onAction(ExchangeAction.GlobalDelete) } + ) + } + } + + DropdownMenuItem( + text = { Text(stringResource(R.string.exchange_delete)) }, + onClick = { onAction(ExchangeAction.Delete) } + ) + } +} + +@Composable +fun AddExchangeDialog( + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + val focusRequester = remember { FocusRequester() } + var url by remember { mutableStateOf("") } + AlertDialog( + modifier = Modifier.focusRequester(focusRequester), + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.exchange_list_add)) }, + icon = { Icon(Icons.Default.AccountBalance, contentDescription = null) }, + text = { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = url, + onValueChange = { url = it }, + label = { Text(stringResource(R.string.exchange_add_url)) }, + placeholder = { Text("https://") }, + maxLines = 1, + ) + }, + confirmButton = { + TextButton(onClick = { onConfirm(url) }) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +enum class ExchangeAction { + ManualWithdraw, + Reload, + ViewTos, + AcceptTos, + ForgetTos, + GlobalAdd, + GlobalDelete, + Delete +} + +fun handleExchangeAction( + scope: CoroutineScope, + exchangeManager: ExchangeManager, + balanceManager: BalanceManager, + exchange: ExchangeItem, + action: ExchangeAction, + onNavigate: NavigateCallback, +) { + when (action) { + ExchangeAction.ManualWithdraw -> { + onNavigate(WalletDestination.PromptWithdraw( + editableCurrency = false, + exchangeBaseUrl = exchange.exchangeBaseUrl, + amount = exchange.currency?.let { Amount.zero(it).toJSONString() }, + ), false) + } + ExchangeAction.Reload -> exchangeManager.reload(exchange.exchangeBaseUrl) + ExchangeAction.ViewTos -> { + onNavigate(WalletDestination.ReviewExchangeTOS(exchange.exchangeBaseUrl, true), false) + } + ExchangeAction.AcceptTos -> { + onNavigate(WalletDestination.ReviewExchangeTOS(exchange.exchangeBaseUrl, false), false) + } + ExchangeAction.ForgetTos -> { + scope.launch { + exchangeManager.getExchangeTos(exchange.exchangeBaseUrl)?.let { tos -> + exchangeManager.forgetCurrentTos(exchange.exchangeBaseUrl, tos.currentEtag) + } + } + } + ExchangeAction.GlobalAdd -> { + balanceManager.addGlobalCurrencyExchange( + currency = exchange.currency ?: return, + exchange = exchange, + onSuccess = {}, + onError = {} + ) + } + ExchangeAction.GlobalDelete -> { + balanceManager.removeGlobalCurrencyExchange( + currency = exchange.currency ?: return, + exchange = exchange, + onSuccess = {}, + onError = {} + ) + } + ExchangeAction.Delete -> exchangeManager.delete(exchange.exchangeBaseUrl) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeShoppingFragment.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeShoppingFragment.kt @@ -1,194 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2026 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.exchanges - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.basicMarquee -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Link -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import kotlinx.coroutines.launch -import net.taler.wallet.BottomInsetsSpacer -import net.taler.wallet.R -import net.taler.wallet.balances.BalanceItem -import net.taler.wallet.balances.BalanceState -import net.taler.wallet.balances.ScopeInfo -import net.taler.wallet.compose.EmptyComposable -import net.taler.wallet.compose.LoadingScreen -import net.taler.wallet.compose.Material3MenuGroup -import net.taler.wallet.compose.Material3MenuItemData -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.getAttrColor -import net.taler.wallet.launchInAppBrowser -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.main.ViewMode - -class ExchangeShoppingFragment : Fragment() { - private val model: MainViewModel by activityViewModels() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = ComposeView(requireContext()).apply { - setContent { - TalerSurface { - val viewMode by model.viewMode.collectAsStateLifecycleAware() - val selectedScope = (viewMode as? ViewMode.Transactions)?.selectedScope - val balanceState by model.balanceManager.state.observeAsState(BalanceState.None) - - val selectedBalance = remember(balanceState, selectedScope) { - val balances = (balanceState as? BalanceState.Success)?.balances - selectedScope?.let { - balances?.find { it.scopeInfo == selectedScope } - } - } - - if (selectedScope == null) { - EmptyComposable(stringResource(R.string.exchange_unselected)) - return@TalerSurface - } - - if (selectedBalance == null) { - LoadingScreen() - return@TalerSurface - } - - ExchangeShoppingComposable( - currency = selectedBalance.currency, - shoppingUrls = selectedBalance.shoppingUrls, - ) - } - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { - model.viewMode.collect { viewMode -> - val selectedScope = (viewMode as? ViewMode.Transactions)?.selectedScope - selectedScope?.let { scope -> - (requireActivity() as AppCompatActivity).apply { - supportActionBar?.title = - getString(R.string.exchange_shopping_label, scope.currency) - } - } - } - } - } - } -} - -@Composable -fun ExchangeShoppingComposable( - currency: String, - shoppingUrls: List<String>, -) { - val context = LocalContext.current - val linkColor = Color(context.getAttrColor(android.R.attr.textColorLink)) - - Column ( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - modifier = Modifier.padding(16.dp), - text = stringResource( - R.string.exchange_shopping_message, - currency, - ), - ) - - Box(Modifier.padding(horizontal = 16.dp)) { - Material3MenuGroup(items = buildList { - shoppingUrls.forEach { url -> - add( - Material3MenuItemData( - icon = { - Icon( - Icons.Default.Link, - contentDescription = null - ) - }, - title = { - Text( - modifier = Modifier.basicMarquee(), - text = url, - color = linkColor, - ) - }, - onClick = { launchInAppBrowser(context, url) }, - ) - ) - } - }) - } - - BottomInsetsSpacer() - } -} - -@Preview -@Composable -fun ExchangeShoppingComposablePreview() { - TalerSurface { - ExchangeShoppingComposable( - currency = "CHF", - shoppingUrls = listOf( - "https://shopping.taler.net/", - "https://shopping.taler.ar/", - "https://shopping.taler-ops.ch/", - ) - ) - } -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeShoppingScreen.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeShoppingScreen.kt @@ -0,0 +1,157 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.exchanges + +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Link +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.taler.wallet.BottomInsetsSpacer +import net.taler.wallet.R +import net.taler.wallet.balances.BalanceState +import net.taler.wallet.compose.EmptyComposable +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.Material3MenuGroup +import net.taler.wallet.compose.Material3MenuItemData +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.getAttrColor +import net.taler.wallet.launchInAppBrowser +import net.taler.wallet.main.MainViewModel +import net.taler.wallet.main.ViewMode + +@Composable +fun ExchangeShoppingScreen( + model: MainViewModel, + onNavigateBack: () -> Unit, +) { + val viewMode by model.viewMode.collectAsStateLifecycleAware() + val selectedScope = (viewMode as? ViewMode.Transactions)?.selectedScope + val balanceState by model.balanceManager.state.observeAsState(BalanceState.None) + + val selectedBalance = remember(balanceState, selectedScope) { + val balances = (balanceState as? BalanceState.Success)?.balances + selectedScope?.let { + balances?.find { it.scopeInfo == selectedScope } + } + } + + TalerSurface { + GlobalScaffold( + model = model, + onNavigateBack = onNavigateBack, + ) { paddingValues -> + Box(Modifier.padding(paddingValues)) { + if (selectedScope == null) { + EmptyComposable(message = stringResource(R.string.exchange_unselected)) + } else if (selectedBalance == null) { + LoadingScreen() + } else { + ExchangeShoppingComposable( + currency = selectedBalance.currency, + shoppingUrls = selectedBalance.shoppingUrls, + ) + } + } + } + } +} + +@Composable +fun ExchangeShoppingComposable( + currency: String, + shoppingUrls: List<String>, +) { + val context = LocalContext.current + val linkColor = Color(context.getAttrColor(android.R.attr.textColorLink)) + + Column ( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier.padding(16.dp), + text = stringResource( + R.string.exchange_shopping_message, + currency, + ), + ) + + Box(Modifier.padding(horizontal = 16.dp)) { + Material3MenuGroup(items = buildList { + shoppingUrls.forEach { url -> + add( + Material3MenuItemData( + icon = { + Icon( + Icons.Default.Link, + contentDescription = null + ) + }, + title = { + Text( + modifier = Modifier.basicMarquee(), + text = url, + color = linkColor, + ) + }, + onClick = { launchInAppBrowser(context, url) }, + ) + ) + } + }) + } + + BottomInsetsSpacer() + } +} + +@Preview +@Composable +fun ExchangeShoppingComposablePreview() { + TalerSurface { + ExchangeShoppingComposable( + currency = "CHF", + shoppingUrls = listOf( + "https://shopping.taler.net/", + "https://shopping.taler.ar/", + "https://shopping.taler-ops.ch/", + ) + ) + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ReviewExchangeTosScreen.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ReviewExchangeTosScreen.kt @@ -0,0 +1,218 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.exchanges + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.LocalTextStyle +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.mikepenz.markdown.m3.Markdown +import com.mikepenz.markdown.m3.markdownTypography +import com.mikepenz.markdown.model.markdownPadding +import kotlinx.coroutines.launch +import net.taler.wallet.R +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.BottomButtonBox +import net.taler.wallet.compose.EmptyComposable +import net.taler.wallet.compose.ErrorComposable +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.main.MainViewModel +import net.taler.wallet.systemBarsPaddingBottom +import java.util.Locale + +@Composable +fun ReviewExchangeTosScreen( + model: MainViewModel, + exchangeBaseUrl: String, + readOnly: Boolean = false, + onNavigateBack: () -> Unit, +) { + val exchangeManager = model.exchangeManager + val scope = rememberCoroutineScope() + var tos: TosResponse? by remember { mutableStateOf(null) } + var selectedLang by remember { mutableStateOf(Locale.getDefault().language) } + + LaunchedEffect(selectedLang) { + tos = null + tos = exchangeManager.getExchangeTos(exchangeBaseUrl, selectedLang) + } + + TalerSurface { + tos?.let { currentTos -> + ReviewExchangeTosComposable( + model = model, + tos = currentTos, + readOnly = readOnly, + onSelectLang = { selectedLang = it }, + onAcceptTos = { + scope.launch { + if (exchangeManager.acceptCurrentTos( + exchangeBaseUrl = exchangeBaseUrl, + currentEtag = currentTos.currentEtag, + ) + ) { + onNavigateBack() + } + } + }, + onNavigateBack = onNavigateBack, + ) + } ?: LoadingScreen() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReviewExchangeTosComposable( + tos: TosResponse, + readOnly: Boolean, + onSelectLang: (String) -> Unit, + onAcceptTos: () -> Unit, + onNavigateBack: () -> Unit, + model: MainViewModel? = null, +) { + if (tos.status == ExchangeTosStatus.MissingTos) { + EmptyComposable(message = stringResource(R.string.exchange_tos_missing)) + return + } + + var expanded by remember { mutableStateOf(false) } + + GlobalScaffold( + model = model, + title = { Text(stringResource(R.string.nav_exchange_tos)) }, + onNavigateBack = onNavigateBack, + bottomBar = { + if (!readOnly) BottomButtonBox { + Button( + modifier = Modifier + .systemBarsPaddingBottom(), + onClick = onAcceptTos, + ) { + Text(stringResource(R.string.exchange_tos_accept)) + } + } + }, + ) { innerPadding -> + LazyColumn(Modifier.padding(innerPadding)) { + if (tos.tosAvailableLanguages.size > 1) item { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + OutlinedTextField( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .clickable { expanded = true }, + label = { Text(stringResource(R.string.language)) }, + value = tos.contentLanguage?.let { + Locale(it).displayLanguage + } ?: "", + onValueChange = {}, + readOnly = true, + enabled = false, + singleLine = true, + textStyle = LocalTextStyle.current.copy( // show text as if not disabled + color = MaterialTheme.colorScheme.onSurface, + ), + ) + + ExposedDropdownMenu ( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + tos.tosAvailableLanguages.forEach { + DropdownMenuItem( + { Text("${Locale(it).displayLanguage}") }, + onClick = { + onSelectLang(it) + expanded = false + } + ) + } + } + } + } + + item { + Markdown( + content = tos.content.trimIndent(), + modifier = Modifier.padding(16.dp), + typography = markdownTypography( + h1 = MaterialTheme.typography.headlineLarge, + h2 = MaterialTheme.typography.headlineMedium, + h3 = MaterialTheme.typography.headlineSmall, + h4 = MaterialTheme.typography.titleLarge, + h5 = MaterialTheme.typography.titleMedium, + h6 = MaterialTheme.typography.titleSmall, + text = MaterialTheme.typography.bodyMedium, + paragraph = MaterialTheme.typography.bodyMedium, + ), + padding = markdownPadding( + block = 5.dp, + ), + error = { modifier -> + ErrorComposable( + TalerErrorInfo.makeCustomError( + stringResource(R.string.exchange_tos_error, "")), + modifier = modifier, + devMode = false, + ) + }, + ) + } + } + } +} + +@Preview +@Composable +fun ReviewExchangeTosComposablePreview() { + TalerSurface { + val tos = TosResponse( + status = ExchangeTosStatus.Proposed, + content = "# Terms of service\nThis is a terms of service, obviously.\n## H2\n### H3\n#### H4\n##### H5\n###### H6", + currentEtag = "1.2.0", + contentLanguage = "en", + tosAvailableLanguages = listOf("en", "en_US"), + ) + + ReviewExchangeTosComposable( tos, false, {}, {}, {}, null) + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/main/MainActivity.kt b/wallet/src/main/java/net/taler/wallet/main/MainActivity.kt @@ -25,110 +25,154 @@ import android.nfc.NfcAdapter import android.os.Build import android.os.Bundle import android.util.Log -import android.view.Menu -import android.view.MenuItem -import android.view.View.GONE -import android.view.View.VISIBLE -import android.view.ViewGroup.MarginLayoutParams import android.widget.Toast import android.widget.Toast.LENGTH_SHORT +import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt.ERROR_NO_BIOMETRICS import androidx.biometric.BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat -import androidx.core.os.bundleOf -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updateLayoutParams +import androidx.fragment.app.FragmentActivity import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavController -import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.ui.setupActionBarWithNavController -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import androidx.preference.PreferenceFragmentCompat.OnPreferenceStartFragmentCallback -import com.google.android.material.dialog.MaterialAlertDialogBuilder +import androidx.navigation.compose.rememberNavController 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 import com.journeyapps.barcodescanner.ScanOptions import com.journeyapps.barcodescanner.ScanOptions.QR_CODE -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import net.taler.common.EventObserver import net.taler.lib.android.TalerNfcService import net.taler.wallet.R -import net.taler.wallet.databinding.ActivityMainBinding +import net.taler.wallet.WalletDestination +import net.taler.wallet.WalletNavHost +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.ErrorBottomSheet +import net.taler.wallet.compose.TalerSurface import net.taler.wallet.events.ObservabilityDialog -import net.taler.wallet.showError +import net.taler.wallet.launchInAppBrowser import net.taler.wallet.transactions.TransactionPeerPullCredit import net.taler.wallet.transactions.TransactionPeerPushDebit -class MainActivity : AppCompatActivity(), OnPreferenceStartFragmentCallback { +class MainActivity : FragmentActivity() { private val model: MainViewModel by viewModels() - private lateinit var ui: ActivityMainBinding - private lateinit var nav: NavController + private val launchIntentUri = MutableStateFlow<String?>(null) + private var nav: NavController? = null private lateinit var biometricPrompt: BiometricPrompt private lateinit var promptInfo: BiometricPrompt.PromptInfo private val barcodeLauncher = registerForActivityResult(ScanContract()) { result -> model.unlockWallet() // hack to prevent from locking after scanning QR if (result == null || result.contents == null) return@registerForActivityResult - if (model.checkScanQrContext(result.contents)) { - handleTalerUri(result.contents, "QR code") - } else { - confirmTalerUri(result.contents, "QR code") - } + nav?.navigate(WalletDestination.HandleUri(result.contents)) } + @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) - ui = ActivityMainBinding.inflate(layoutInflater) - setContentView(ui.root) - setupInsets() setupBiometrics() TalerNfcService.startService(this) - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment - nav = navHostFragment.navController + setContent { + TalerSurface { + val navController = rememberNavController() + nav = navController + var errorInfo by remember { mutableStateOf<TalerErrorInfo?>(null) } + val showObservabilityLog by model.showObservabilityLog.collectAsState(false) + val errorSheetState = rememberModalBottomSheetState() + val devMode by model.devMode.observeAsState(false) + val authenticated by model.authenticated.collectAsState() + val biometricEnabled by model.settingsManager.getBiometricLockEnabled(this).collectAsState(false) + val launchUri by launchIntentUri.collectAsState(null) + + DisposableEffect(Unit) { + onDispose { + launchIntentUri.value = null + } + } + + Box(Modifier.fillMaxSize()) { + WalletNavHost( + navController = navController, + model = model, + launchUri = launchUri, + onScanQr = { model.scanCode() }, + onFulfillPayment = { url: String -> launchInAppBrowser(this@MainActivity, url) }, + onShowError = { errorInfo = it } + ) - setSupportActionBar(ui.toolbar) - setupActionBarWithNavController(nav) - ui.toolbar.setNavigationOnClickListener { - if (onBackPressedDispatcher.hasEnabledCallbacks()) { - onBackPressedDispatcher.onBackPressed() - } else { - nav.navigateUp() + if (!authenticated && biometricEnabled) { + BiometricOverlay( + onUnlock = { biometricPrompt.authenticate(promptInfo) } + ) + } + } + + if (showObservabilityLog) { + val events by model.observabilityLog.collectAsState() + ObservabilityDialog(events.reversed()) { + model.hideObservabilityLog() + } + } + + errorInfo?.let { + ErrorBottomSheet( + error = it, + devMode = devMode, + sheetState = errorSheetState, + onDismiss = { errorInfo = null } + ) + } } } model.startWallet() - // TODO: refactor and unify progress bar handling - // model.showProgressBar.observe(this) { show -> - // ui.content.progressBar.visibility = if (show) VISIBLE else INVISIBLE - // } - handleIntents(intent) // Update devMode in model from Datastore API lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { model.settingsManager.getDevModeEnabled(this@MainActivity).collect { enabled -> - model.setDevMode(enabled) { error -> - showError(error) - } + model.setDevMode(enabled) {} } } } @@ -170,59 +214,11 @@ class MainActivity : AppCompatActivity(), OnPreferenceStartFragmentCallback { }) model.networkManager.networkStatus.observe(this) { online -> - ui.offlineBanner.visibility = if (online) GONE else VISIBLE model.hintNetworkAvailability(online) } - - model.devMode.observe(this) { - invalidateMenu() - } - } - - private fun setupInsets() { - // We really don't want to deal with cutouts! - ViewCompat.setOnApplyWindowInsetsListener(ui.root) { v, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) - v.updateLayoutParams<MarginLayoutParams> { - leftMargin = insets.left - rightMargin = insets.right - } - windowInsets - } - - ViewCompat.setOnApplyWindowInsetsListener(ui.toolbar) { v, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.updateLayoutParams<MarginLayoutParams> { - leftMargin = insets.left - rightMargin = insets.right - } - windowInsets - } } private fun setupBiometrics() { - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - combine( - model.authenticated, - model.settingsManager.getBiometricLockEnabled(this@MainActivity) - ) { a, b -> a to b }.collect { c -> - val authenticated = c.first - val biometricEnabled = c.second - if (!authenticated && biometricEnabled) { - ui.biometricOverlay.visibility = VISIBLE - biometricPrompt.authenticate(promptInfo) - } else { - ui.biometricOverlay.visibility = GONE - } - } - } - } - - ui.unlockButton.setOnClickListener { - biometricPrompt.authenticate(promptInfo) - } - biometricPrompt = BiometricPrompt( this, ContextCompat.getMainExecutor(this), @@ -271,13 +267,13 @@ class MainActivity : AppCompatActivity(), OnPreferenceStartFragmentCallback { if (intent == null) return if (intent.action == ACTION_VIEW) intent.dataString?.let { uri -> - handleTalerUri(uri, "intent") + launchIntentUri.value = uri } if (intent.action == ACTION_SEND) { if (intent.type == "text/plain") { intent.getStringExtra(EXTRA_TEXT)?.let { uri -> - handleTalerUri(uri, "intent") + launchIntentUri.value = uri } } } @@ -295,66 +291,13 @@ class MainActivity : AppCompatActivity(), OnPreferenceStartFragmentCallback { messages.forEach { message -> message.records?.forEach { record -> record.toUri()?.let { uri -> - handleTalerUri(uri.toString(), "nfc") + launchIntentUri.value = uri.toString() } } } } } - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - if (model.devMode.value == true) { - menuInflater.inflate(R.menu.global_dev, menu) - } - - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_show_logs -> { - ObservabilityDialog().show(supportFragmentManager, "OBSERVABILITY") - } - } - return super.onOptionsItemSelected(item) - } - - private fun confirmTalerUri(uri: String, from: String) { - MaterialAlertDialogBuilder(this).apply { - setTitle(R.string.qr_scan_context_title) - setMessage(when (model.getScanQrContext()) { - ScanQrContext.Send -> R.string.qr_scan_context_send_message - ScanQrContext.Receive -> R.string.qr_scan_context_receive_message - else -> error("invalid value") - }) - - setNegativeButton(R.string.ok) { _, _ -> - handleTalerUri(uri, from) - } - - setNeutralButton(R.string.cancel) { dialog, _ -> - dialog.dismiss() - } - }.show() - } - - private fun handleTalerUri(uri: String, from: String) { - val args = bundleOf("uri" to uri, "from" to from) - nav.navigate(R.id.action_global_handleUri, args) - } - - override fun onPreferenceStartFragment( - caller: PreferenceFragmentCompat, - pref: Preference, - ): Boolean { - when (pref.key) { - "pref_exchanges" -> nav.navigate(R.id.action_main_to_exchangeList) - "pref_accounts" -> nav.navigate(R.id.action_main_to_bankAccounts) - "pref_donau" -> nav.navigate(R.id.action_main_to_setDonau) - } - return true - } - override fun onResume() { super.onResume() TalerNfcService.setDefaultHandler(this) @@ -377,3 +320,30 @@ class MainActivity : AppCompatActivity(), OnPreferenceStartFragmentCallback { model.stopWallet() } } + +@Composable +fun BiometricOverlay(onUnlock: () -> Unit) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + ) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_shield), + contentDescription = null, + modifier = Modifier + .size(64.dp) + .padding(bottom = 24.dp), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary) + ) + + Button(onClick = onUnlock) { + Text(stringResource(R.string.biometric_unlock_label)) + } + } + } +} diff --git a/wallet/src/main/java/net/taler/wallet/main/MainComposable.kt b/wallet/src/main/java/net/taler/wallet/main/MainComposable.kt @@ -1,77 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2025 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.main - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import net.taler.wallet.balances.BalanceItem -import net.taler.wallet.balances.BalanceState -import net.taler.wallet.balances.BalancesComposable -import net.taler.wallet.transactions.Transaction -import net.taler.wallet.transactions.TransactionsComposable -import net.taler.wallet.transactions.TransactionsResult - -@Composable -fun MainComposable( - innerPadding: PaddingValues, - state: BalanceState, - txResult: TransactionsResult, - viewMode: ViewMode, - devMode: Boolean, - networkStatus: Boolean, - onWithdrawMoneyClicked: () -> Unit, - onGetDemoMoneyClicked: () -> Unit, - onBalanceClicked: (balance: BalanceItem) -> Unit, - onPendingClicked: (balance: BalanceItem) -> Unit, - onStatementClicked: (host: String) -> Unit, - onTransactionClicked: (tx: Transaction) -> Unit, - onTransactionsDelete: (txIds: List<String>) -> Unit, - onShowBalancesClicked: () -> Unit, -) { - when(viewMode) { - is ViewMode.Assets -> BalancesComposable( - innerPadding = innerPadding, - state = state, - devMode = devMode, - networkStatus = networkStatus, - onWithdrawMoneyClicked = onWithdrawMoneyClicked, - onGetDemoMoneyClicked = onGetDemoMoneyClicked, - onBalanceClicked = onBalanceClicked, - onPendingClicked = onPendingClicked, - onStatementClicked = onStatementClicked, - ) - - is ViewMode.Transactions -> { - val balance = remember(state, viewMode) { - (state as? BalanceState.Success)?.balances?.find { - it.scopeInfo == viewMode.selectedScope - } - } - - if (balance != null) TransactionsComposable( - innerPadding = innerPadding, - viewMode = viewMode, - balance = balance, - txResult = txResult, - onTransactionClick = onTransactionClicked, - onTransactionsDelete = onTransactionsDelete, - onShowBalancesClicked = onShowBalancesClicked, - ) - } - } -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/main/MainFragment.kt b/wallet/src/main/java/net/taler/wallet/main/MainFragment.kt @@ -1,369 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2026 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.main - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.activity.compose.BackHandler -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBars -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.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -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.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.core.os.bundleOf -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 com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.flow.first -import net.taler.wallet.R -import net.taler.wallet.balances.BalanceState -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.launchInAppBrowser -import net.taler.wallet.settings.SettingsFragment -import net.taler.wallet.transactions.Transaction -import net.taler.wallet.transactions.TransactionMajorState -import net.taler.wallet.transactions.TransactionPayment -import net.taler.wallet.transactions.TransactionPeerPullDebit -import net.taler.wallet.transactions.TransactionPeerPushCredit -import net.taler.wallet.transactions.TransactionState -import net.taler.wallet.transactions.TransactionStateFilter.Nonfinal - -class MainFragment: Fragment() { - - enum class Tab { ASSETS, SETTINGS } - - private val model: MainViewModel by activityViewModels() - - @OptIn(ExperimentalMaterial3Api::class) - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = ComposeView(requireContext()).apply { - setContent { - TalerSurface { - var tab by rememberSaveable { mutableStateOf(Tab.ASSETS) } - var showSheet by remember { mutableStateOf(false) } - val sheetState = rememberModalBottomSheetState() - - val settingsFragmentState = rememberFragmentState() - - val context = LocalContext.current - val online by model.networkManager.networkStatus.observeAsState(false) - val networkStatus by model.networkManager.networkStatus.observeAsState(false) - val balanceState by model.balanceManager.state.observeAsState(BalanceState.None) - val viewMode by model.viewMode.collectAsStateLifecycleAware() - val devMode by model.devMode.observeAsState(false) - val txResult by remember(viewMode) { - val v = viewMode as? ViewMode.Transactions - model.transactionManager.transactionsFlow(v?.selectedScope, stateFilter = v?.stateFilter) - }.collectAsStateLifecycleAware() - val actionButtonUsed by remember { model.settingsManager.getActionButtonUsed(context) }.collectAsStateLifecycleAware(true) - - Scaffold( - bottomBar = { - NavigationBar { - NavigationBarItem( - icon = { Icon(Icons.Default.BarChart, contentDescription = null) }, - label = { Text(stringResource(R.string.balances_title)) }, - selected = tab == Tab.ASSETS, - onClick = { - tab = Tab.ASSETS - if (viewMode !is ViewMode.Assets) - model.showAssets() - } - ) - - TalerActionButton( - demandAttention = !actionButtonUsed, - onShowSheet = { - showSheet = true - }, - onScanQr = { - onScanQr() - }, - ) - - NavigationBarItem( - icon = { Icon(Icons.Default.Settings, contentDescription = null) }, - label = { Text(stringResource(R.string.menu_settings)) }, - selected = tab == Tab.SETTINGS, - onClick = { tab = Tab.SETTINGS }, - ) - } - }, - contentWindowInsets = WindowInsets.systemBars.only( - WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom - ) - ) { innerPadding -> - LaunchedEffect(Unit) { - val viewMode = model.settingsManager.getViewMode(context).first() - model.setViewMode(viewMode) - } - - LaunchedEffect(tab, viewMode) { - setTitle(tab, viewMode) - } - - BackHandler(viewMode !is ViewMode.Assets) { - model.showAssets() - } - - LaunchedEffect(tab, balanceState, viewMode) { - (requireActivity() as AppCompatActivity).apply { - if (tab == Tab.ASSETS && viewMode is ViewMode.Assets && balanceState.showWelcome()) { - supportActionBar?.hide() - } else { - supportActionBar?.show() - } - } - } - - when (tab) { - Tab.ASSETS -> MainComposable( - innerPadding = innerPadding, - state = balanceState, - txResult = txResult, - viewMode = viewMode, - devMode = devMode, - networkStatus = networkStatus, - onWithdrawMoneyClicked = { - val args = bundleOf( - "uri" to "taler://withdraw-exchange/exchange.taler-ops.ch/", - "from" to "Withdraw CHF button", - ) - findNavController().navigate(R.id.action_global_handleUri, args) - }, - onGetDemoMoneyClicked = { - model.withdrawManager.withdrawTestBalance() - Snackbar.make( - requireView(), - getString(R.string.settings_test_withdrawal), - LENGTH_LONG - ).show() - }, - onBalanceClicked = { - model.showTransactions(it.scopeInfo) - }, - onPendingClicked = { - model.showTransactions(it.scopeInfo, Nonfinal) - }, - onTransactionClicked = { tx -> - onTransactionClicked(tx) - }, - onTransactionsDelete = { txIds -> - model.transactionManager.deleteTransactions(txIds) { error -> - Toast.makeText(context, error.userFacingMsg, Toast.LENGTH_LONG) - .show() - } - }, - onShowBalancesClicked = { - model.showAssets() - }, - onStatementClicked = { - findNavController().navigate( - R.id.action_main_to_donauStatement, - bundleOf("host" to it), - ) - } - ) - Tab.SETTINGS -> SettingsView( - innerPadding = innerPadding, - settingsFragmentState = settingsFragmentState, - ) - } - } - - val disableActions = remember(balanceState, online) { - !online || (balanceState as? BalanceState.Success)?.balances?.isEmpty() ?: true - } - - val selectedScope = (viewMode as? ViewMode.Transactions)?.selectedScope - - val selectedBalance = remember(balanceState, selectedScope) { - val balances = (balanceState as? BalanceState.Success)?.balances - selectedScope?.let { - balances?.find { it.scopeInfo == selectedScope } - } - } - - TalerActionsModal( - showSheet = showSheet, - sheetState = sheetState, - selectedCurrency = selectedBalance?.currency, - showShopping = selectedBalance?.shoppingUrls?.isNotEmpty() == true, - onDismiss = { showSheet = false }, - disableActions = disableActions, - disablePeer = selectedBalance?.disablePeerPayments == true, - onSend = this@MainFragment::onSend, - onReceive = this@MainFragment::onReceive, - onScanQr = this@MainFragment::onScanQr, - onDeposit = this@MainFragment::onDeposit, - onWithdraw = this@MainFragment::onWithdraw, - onEnterUri = this@MainFragment::onEnterUri, - onShoppingDiscovery = { - selectedBalance?.shoppingUrls?.let { - onShoppingDiscovery(it) - } - } - ) - } - } - } - - private fun onTransactionClicked(tx: Transaction) { - val showTxDetails = { - if (tx.detailPageNav != 0) { - model.transactionManager.selectTransaction(tx) - findNavController().navigate(tx.detailPageNav) - } - } - - when (tx.txState) { - // unfinished transactions (dialog) - TransactionState(TransactionMajorState.Dialog) -> when (tx) { - is TransactionPayment -> { - model.paymentManager.preparePay(tx.transactionId) {} - findNavController().navigate(R.id.action_global_promptPayment) - } - - is TransactionPeerPushCredit -> { - model.peerManager.preparePeerPushCredit(transactionId = tx.transactionId) - findNavController().navigate(R.id.action_global_promptPushPayment) - } - - is TransactionPeerPullDebit -> { - model.peerManager.preparePeerPullDebit(transactionId = tx.transactionId) - findNavController().navigate(R.id.action_global_promptPullPayment) - } - - else -> showTxDetails() - } - - else -> showTxDetails() - } - } - - override fun onStart() { - super.onStart() - model.balanceManager.loadAssets(model.viewMode.value is ViewMode.Assets) - } - - override fun onDestroyView() { - super.onDestroyView() - (requireActivity() as AppCompatActivity).apply { - supportActionBar?.show() - } - } - - private fun setTitle(tab: Tab, viewMode: ViewMode?) { - (requireActivity() as AppCompatActivity).apply { - supportActionBar?.title = when (tab) { - Tab.ASSETS -> when(viewMode) { - is ViewMode.Assets -> getString(R.string.balances_title) - is ViewMode.Transactions -> getString(R.string.transactions_title) - null -> getString(R.string.loading) - } - - Tab.SETTINGS -> getString(R.string.menu_settings) - } - } - } - - private fun onSend() { - model.settingsManager.saveActionButtonUsed(requireContext()) - findNavController().navigate(R.id.action_global_outgoingPush) - } - - private fun onReceive() { - model.settingsManager.saveActionButtonUsed(requireContext()) - findNavController().navigate(R.id.action_global_outgoingPull) - } - - private fun onDeposit() { - model.settingsManager.saveActionButtonUsed(requireContext()) - findNavController().navigate(R.id.action_global_deposit) - } - - private fun onWithdraw() { - model.settingsManager.saveActionButtonUsed(requireContext()) - model.withdrawManager.resetWithdrawal() - findNavController().navigate(R.id.action_global_promptWithdraw) - } - - private fun onScanQr() { - model.settingsManager.saveActionButtonUsed(requireContext()) - model.scanCode() - } - - private fun onEnterUri() { - model.settingsManager.saveActionButtonUsed(requireContext()) - findNavController().navigate(R.id.action_main_to_uriInput) - } - - private fun onShoppingDiscovery(shoppingUrls: List<String>) { - model.settingsManager.saveActionButtonUsed(requireContext()) - if (shoppingUrls.size == 1) { - launchInAppBrowser(requireContext(), shoppingUrls[0]) - } else findNavController().navigate(R.id.action_main_to_exchangeShopping) - } -} - -@Composable -fun SettingsView( - innerPadding: PaddingValues, - settingsFragmentState: FragmentState, -) { - AndroidFragment( - SettingsFragment::class.java, - modifier = Modifier.padding(innerPadding), - fragmentState = settingsFragmentState, - ) -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/main/MainScreen.kt b/wallet/src/main/java/net/taler/wallet/main/MainScreen.kt @@ -0,0 +1,518 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.main + +import android.content.ClipboardManager +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BarChart +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateListOf +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.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.core.content.getSystemService +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import net.taler.wallet.NavigateCallback +import net.taler.wallet.R +import net.taler.wallet.WalletDestination +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.balances.BalanceState +import net.taler.wallet.balances.BalancesComposable +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.settings.SettingsScreen +import net.taler.wallet.transactions.Transaction +import net.taler.wallet.transactions.TransactionMajorState +import net.taler.wallet.transactions.TransactionPayment +import net.taler.wallet.transactions.TransactionPeerPullDebit +import net.taler.wallet.transactions.TransactionPeerPushCredit +import net.taler.wallet.transactions.TransactionState +import net.taler.wallet.transactions.TransactionStateFilter.Nonfinal +import net.taler.wallet.transactions.TransactionsComposable +import net.taler.wallet.transactions.TransactionsResult + +enum class MainTab { ASSETS, SETTINGS } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen( + model: MainViewModel, + onNavigate: NavigateCallback, + onScanQr: () -> Unit, + onFulfillPayment: (url: String) -> Unit, + onShowError: (TalerErrorInfo) -> Unit, +) { + var tab by rememberSaveable { mutableStateOf(MainTab.ASSETS) } + var showUriInput by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + var selectionMode by remember { mutableStateOf(false) } + val selectedItems = remember { mutableStateListOf<String>() } + var showDeleteDialog by remember { mutableStateOf(false) } + + val context = LocalContext.current + val testWithdrawalMessage = stringResource(R.string.settings_test_withdrawal) + val online by model.networkManager.networkStatus.observeAsState(false) + val networkStatus by model.networkManager.networkStatus.observeAsState(false) + val balanceState by model.balanceManager.state.observeAsState(BalanceState.None) + val viewMode by model.viewMode.collectAsStateLifecycleAware() + val devMode by model.devMode.observeAsState(false) + val txResult by remember(viewMode) { + val v = viewMode as? ViewMode.Transactions + model.transactionManager.transactionsFlow(v?.selectedScope, stateFilter = v?.stateFilter) + }.collectAsStateLifecycleAware() + val actionButtonUsed by remember { model.settingsManager.getActionButtonUsed(context) }.collectAsStateLifecycleAware(true) + + if (showUriInput) UriInputDialog( + onDismiss = { showUriInput = false }, + handleUri = { uri -> + onNavigate(WalletDestination.HandleUri(uri), true) + }, + ) + + val tabTitle = when (tab) { + MainTab.ASSETS -> when (viewMode) { + is ViewMode.Transactions -> if (selectionMode) { + stringResource(R.string.selection_count, selectedItems.size) + } else stringResource(R.string.transactions_title) + + ViewMode.Assets -> if (!balanceState.showWelcome()) { + stringResource(R.string.balances_title) + } else null + } + + MainTab.SETTINGS -> stringResource(R.string.menu_settings) + } + + GlobalScaffold( + model = model, + title = tabTitle?.let { { Text(it) } }, + navigationIcon = if (selectionMode) { + { + IconButton(onClick = { + selectionMode = false + selectedItems.clear() + }) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = null, + ) + } + } + } else null, + actions = { + if (selectionMode && txResult is TransactionsResult.Success) { + IconButton(onClick = { + selectedItems.clear() + val successResult = txResult as TransactionsResult.Success + selectedItems.addAll(successResult.transactions.map { it.transactionId }) + }) { + Icon( + imageVector = Icons.Default.SelectAll, + contentDescription = stringResource(R.string.transactions_select_all), + ) + } + IconButton(onClick = { + showDeleteDialog = true + }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.transactions_delete), + ) + } + } + }, + bottomBar = { + NavigationBar { + NavigationBarItem( + icon = { Icon(Icons.Default.BarChart, contentDescription = null) }, + label = { Text(stringResource(R.string.balances_title)) }, + selected = tab == MainTab.ASSETS, + onClick = { + tab = MainTab.ASSETS + if (viewMode !is ViewMode.Assets) + model.showAssets() + } + ) + + TalerActionButton( + demandAttention = !actionButtonUsed, + onShowSheet = { + scope.launch { sheetState.expand() } + }, + onScanQr = { + model.settingsManager.saveActionButtonUsed(context) + onScanQr() + }, + ) + + NavigationBarItem( + icon = { Icon(Icons.Default.Settings, contentDescription = null) }, + label = { Text(stringResource(R.string.menu_settings)) }, + selected = tab == MainTab.SETTINGS, + onClick = { tab = MainTab.SETTINGS }, + ) + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + LaunchedEffect(Unit) { + model.balanceManager.loadAssets(model.viewMode.value is ViewMode.Assets) + } + + LaunchedEffect(Unit) { + val viewMode = model.settingsManager.getViewMode(context).first() + model.setViewMode(viewMode) + } + + BackHandler(selectionMode || (tab == MainTab.ASSETS && viewMode !is ViewMode.Assets)) { + if (selectionMode) { + selectionMode = false + selectedItems.clear() + } else { + model.showAssets() + } + } + + if (showDeleteDialog) AlertDialog( + title = { Text(stringResource(R.string.transactions_delete_selected_dialog_title)) }, + text = { Text(stringResource(R.string.transactions_delete_selected_dialog_message)) }, + onDismissRequest = { showDeleteDialog = false }, + confirmButton = { + TextButton(onClick = { + model.transactionManager.deleteTransactions(selectedItems) { error -> + onShowError(error) + } + showDeleteDialog = false + selectionMode = false + selectedItems.clear() + }) { + Text(stringResource(R.string.transactions_delete)) + } + }, + dismissButton = { + TextButton(onClick = { + showDeleteDialog = false + }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + + when (tab) { + MainTab.ASSETS -> AnimatedContent( + targetState = viewMode, + label = "MainViewMode", + ) { targetViewMode -> + when (targetViewMode) { + is ViewMode.Assets -> BalancesComposable( + innerPadding = innerPadding, + state = balanceState, + devMode = devMode, + networkStatus = networkStatus, + onWithdrawMoneyClicked = { + onNavigate( + WalletDestination.HandleUri("taler://withdraw-exchange/exchange.taler-ops.ch/"), + true, + ) + }, + onGetDemoMoneyClicked = { + model.withdrawManager.withdrawTestBalance() + scope.launch { + snackbarHostState.showSnackbar(testWithdrawalMessage) + } + }, + onBalanceClicked = { + model.showTransactions(it.scopeInfo) + }, + onPendingClicked = { + model.showTransactions(it.scopeInfo, Nonfinal) + }, + onStatementClicked = { + onNavigate(WalletDestination.DonauStatement(it), false) + }, + ) + + is ViewMode.Transactions -> { + val balance = remember(balanceState, targetViewMode) { + (balanceState as? BalanceState.Success)?.balances?.find { + it.scopeInfo == targetViewMode.selectedScope + } + } + + if (balance != null) TransactionsComposable( + innerPadding = innerPadding, + viewMode = targetViewMode, + balance = balance, + txResult = txResult, + selectionMode = selectionMode, + selectedItems = selectedItems, + onTransactionClick = { tx -> + onTransactionClicked(tx, model, onNavigate) + }, + onShowBalancesClicked = { + model.showAssets() + }, + onToggleSelection = { txId -> + if (selectedItems.contains(txId)) { + selectedItems.remove(txId) + if (selectedItems.isEmpty()) selectionMode = false + } else { + selectionMode = true + selectedItems.add(txId) + } + }, + ) + } + } + } + + MainTab.SETTINGS -> SettingsScreen( + model = model, + innerPadding = innerPadding, + snackbarHostState = snackbarHostState, + onNavigate = onNavigate, + onShowError = onShowError, + ) + } + + val disableActions = remember(balanceState, online) { + !online || (balanceState as? BalanceState.Success)?.balances?.isEmpty() ?: true + } + + val selectedScope = (viewMode as? ViewMode.Transactions)?.selectedScope + + val selectedBalance = remember(balanceState, selectedScope) { + val balances = (balanceState as? BalanceState.Success)?.balances + selectedScope?.let { + balances?.find { it.scopeInfo == selectedScope } + } + } + + TalerActionsModal( + sheetState = sheetState, + selectedCurrency = selectedBalance?.currency, + showShopping = selectedBalance?.shoppingUrls?.isNotEmpty() == true, + onDismiss = { + scope.launch { sheetState.hide() }.invokeOnCompletion { + if (!sheetState.isVisible) { + scope.launch { sheetState.hide() } + } + } + }, + disableActions = disableActions, + disablePeer = selectedBalance?.disablePeerPayments == true, + onSend = { + scope.launch { sheetState.hide() } + model.settingsManager.saveActionButtonUsed(context) + onNavigate(WalletDestination.OutgoingPush, true) + }, + onReceive = { + scope.launch { sheetState.hide() } + model.settingsManager.saveActionButtonUsed(context) + onNavigate(WalletDestination.OutgoingPull, true) + }, + onScanQr = { + scope.launch { sheetState.hide() } + model.settingsManager.saveActionButtonUsed(context) + onScanQr() + }, + onDeposit = { + scope.launch { sheetState.hide() } + model.settingsManager.saveActionButtonUsed(context) + onNavigate(WalletDestination.Deposit(), true) + }, + onWithdraw = { + scope.launch { sheetState.hide() } + model.settingsManager.saveActionButtonUsed(context) + model.withdrawManager.resetWithdrawal() + onNavigate(WalletDestination.PromptWithdraw(), true) + }, + onEnterUri = { + scope.launch { sheetState.hide() } + model.settingsManager.saveActionButtonUsed(context) + showUriInput = true + }, + onShoppingDiscovery = { + scope.launch { sheetState.hide() } + model.settingsManager.saveActionButtonUsed(context) + val shoppingUrls = selectedBalance?.shoppingUrls ?: emptyList() + if (shoppingUrls.size == 1) { + onFulfillPayment(shoppingUrls[0]) + } else onNavigate(WalletDestination.ExchangeShopping, false) + } + ) + } +} + +@Composable +fun UriInputDialog( + onDismiss: () -> Unit, + handleUri: (String) -> Unit, +) { + val context = LocalContext.current + val invalidUriError = stringResource(R.string.uri_invalid) + val focusRequester = remember { FocusRequester() } + var uriText by remember { mutableStateOf("") } + var error by remember { mutableStateOf<String?>(null) } + val clipboard = context.getSystemService<ClipboardManager>() + + val isValidTalerUri = { uri: String -> + uri.trim().startsWith("taler://", ignoreCase = true) || + uri.trim().startsWith("payto://", ignoreCase = true) + } + + val getClipboardContents = { + val item = clipboard?.primaryClip?.getItemAt(0) + if (item?.text != null) { + item.text.toString().trim() + } else if (item?.uri != null) { + item.uri.toString().trim() + } else { + null + } + } + + AlertDialog( + title = { Text(stringResource(R.string.enter_uri)) }, + text = { + OutlinedTextField( + value = uriText, + onValueChange = { + uriText = it + error = null + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + label = { Text(stringResource(R.string.enter_uri_label)) }, + isError = error != null, + supportingText = error?.let { { Text(it) } }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + trailingIcon = { + IconButton(onClick = { + getClipboardContents()?.let { uriText = it } + }) { + Icon( + Icons.Default.ContentPaste, + contentDescription = stringResource(R.string.paste), + ) + } + }, + ) + }, + onDismissRequest = onDismiss, + confirmButton = { + Button(onClick = { + if (isValidTalerUri(uriText)) { + handleUri(uriText.trim()) + onDismiss() + } else { + error = invalidUriError + } + }) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + getClipboardContents()?.let { uri -> + if (isValidTalerUri(uri)) { + uriText = uri + } + } + } +} + +private fun onTransactionClicked( + tx: Transaction, + model: MainViewModel, + onNavigate: NavigateCallback, +) { + val showTxDetails = { + model.transactionManager.selectTransaction(tx).invokeOnCompletion { + onNavigate(tx.detailPageNav, false) + } + } + + when (tx.txState) { + // unfinished transactions (dialog) + TransactionState(TransactionMajorState.Dialog) -> when (tx) { + is TransactionPayment -> { + model.paymentManager.preparePay(tx.transactionId) {} + onNavigate(WalletDestination.PromptPayment, true) + } + + is TransactionPeerPushCredit -> { + model.peerManager.preparePeerPushCredit(transactionId = tx.transactionId) + onNavigate(WalletDestination.PromptPushPayment, true) + } + + is TransactionPeerPullDebit -> { + model.peerManager.preparePeerPullDebit(transactionId = tx.transactionId) + onNavigate(WalletDestination.PromptPullPayment, true) + } + + else -> showTxDetails() + } + + else -> showTxDetails() + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt @@ -55,7 +55,6 @@ import net.taler.wallet.settings.SettingsManager import net.taler.wallet.transactions.TransactionManager import net.taler.wallet.transactions.TransactionStateFilter import net.taler.wallet.withdraw.WithdrawManager -import androidx.core.net.toUri import net.taler.wallet.BuildConfig import net.taler.wallet.NetworkManager import net.taler.wallet.donau.DonauManager @@ -72,19 +71,6 @@ private val observabilityNotifications = listOf( "request-observability-event", ) -private val sendUriActions = listOf( - "pay", - "tip", - "pay-pull", - "pay-template", -) - -private val receiveUriActions = listOf( - "withdraw", - "refund", - "pay-push", -) - class MainViewModel( app: Application, ) : AndroidViewModel(app), VersionReceiver, NotificationReceiver { @@ -138,15 +124,15 @@ class MainViewModel( private val mObservabilityLog = MutableStateFlow<List<ObservabilityEvent>>(emptyList()) val observabilityLog: StateFlow<List<ObservabilityEvent>> = mObservabilityLog + private val mShowObservabilityLog = MutableStateFlow(false) + val showObservabilityLog: StateFlow<Boolean> = mShowObservabilityLog + private val mScanCodeEvent = MutableLiveData<Event<Boolean>>() val scanCodeEvent: LiveData<Event<Boolean>> = mScanCodeEvent private val mViewMode = MutableStateFlow<ViewMode>(ViewMode.Assets) val viewMode: StateFlow<ViewMode> = mViewMode - @set:Synchronized - private var scanQrContext = ScanQrContext.Unknown - fun startWallet() { api.startWallet() } @@ -235,7 +221,7 @@ class MainViewModel( } /** - * Navigates to the given scope info's transaction list, when [MainFragment] is shown. + * Navigates to the given scope info's transaction list, when [MainScreen] is shown. */ @UiThread fun showTransactions(scopeInfo: ScopeInfo, stateFilter: TransactionStateFilter? = null) { @@ -260,23 +246,10 @@ class MainViewModel( } @UiThread - fun scanCode(context: ScanQrContext = ScanQrContext.Unknown) { - scanQrContext = context + fun scanCode() { mScanCodeEvent.value = true.toEvent() } - fun getScanQrContext() = scanQrContext - - fun checkScanQrContext(uri: String): Boolean { - val parsed = uri.toUri() - val action = parsed.host - return when (scanQrContext) { - ScanQrContext.Send -> action in sendUriActions - ScanQrContext.Receive -> action in receiveUriActions - else -> true - } - } - fun setDevMode(enabled: Boolean, onError: (error: TalerErrorInfo) -> Unit) { mDevMode.postValue(enabled) viewModelScope.launch { @@ -296,6 +269,14 @@ class MainViewModel( } } + fun showObservabilityLog() { + mShowObservabilityLog.value = true + } + + fun hideObservabilityLog() { + mShowObservabilityLog.value = false + } + fun hintNetworkAvailability(isAvailable: Boolean) { viewModelScope.launch { api.request<Unit>("hintNetworkAvailability") { @@ -313,12 +294,6 @@ class MainViewModel( } } -enum class ScanQrContext { - Send, - Receive, - Unknown, -} - sealed class AmountResult { data class Success(val amount: Amount) : AmountResult() data class InsufficientBalance(val amount: Amount) : AmountResult() diff --git a/wallet/src/main/java/net/taler/wallet/main/TalerActionButton.kt b/wallet/src/main/java/net/taler/wallet/main/TalerActionButton.kt @@ -135,7 +135,6 @@ fun TalerActionButton( @OptIn(ExperimentalMaterial3Api::class) @Composable fun TalerActionsModal( - showSheet: Boolean, sheetState: SheetState, selectedCurrency: String? = null, showShopping: Boolean, @@ -150,81 +149,80 @@ fun TalerActionsModal( onEnterUri: () -> Unit, onShoppingDiscovery: () -> Unit, ) { - if (showSheet) { - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - ) { - Column { - if (showShopping && selectedCurrency != null) { - Box(Modifier - .padding(horizontal = 12.dp) - .padding(bottom = 9.dp)) { - Material3MenuGroup(items = buildList { - add( - Material3MenuItemData( - title = { Text(stringResource(R.string.exchange_shopping_label, selectedCurrency)) }, - icon = { Icon( - Icons.Default.LocationOn, - contentDescription = null - ) }, - onClick = onShoppingDiscovery, - ) + if (sheetState.isVisible) ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column { + if (showShopping && selectedCurrency != null) { + Box(Modifier + .padding(horizontal = 12.dp) + .padding(bottom = 9.dp) + ) { + Material3MenuGroup(items = buildList { + add( + Material3MenuItemData( + title = { Text(stringResource(R.string.exchange_shopping_label, selectedCurrency)) }, + icon = { Icon( + Icons.Default.LocationOn, + contentDescription = null + ) }, + onClick = onShoppingDiscovery, ) - }) - } + ) + }) } + } - GridMenu( - contentPadding = PaddingValues( - start = 8.dp, - end = 8.dp, - bottom = 16.dp + WindowInsets - .systemBars - .asPaddingValues() - .calculateBottomPadding(), - ), - ) { - GridMenuItem( - icon = R.drawable.ic_link, - title = R.string.enter_uri, - onClick = { onEnterUri(); onDismiss() }, - ) + GridMenu( + contentPadding = PaddingValues( + start = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets + .systemBars + .asPaddingValues() + .calculateBottomPadding(), + ), + ) { + GridMenuItem( + icon = R.drawable.ic_link, + title = R.string.enter_uri, + onClick = { onEnterUri(); onDismiss() }, + ) - GridMenuItem( - icon = R.drawable.transaction_deposit, - title = R.string.send_deposit_button_label, - onClick = { onDeposit(); onDismiss() }, - enabled = !disableActions - ) + GridMenuItem( + icon = R.drawable.transaction_deposit, + title = R.string.send_deposit_button_label, + onClick = { onDeposit(); onDismiss() }, + enabled = !disableActions + ) - GridMenuItem( - icon = R.drawable.ic_scan_qr, - title = R.string.button_scan_qr_code_label, - onClick = { onScanQr(); onDismiss() }, - ) + GridMenuItem( + icon = R.drawable.ic_scan_qr, + title = R.string.button_scan_qr_code_label, + onClick = { onScanQr(); onDismiss() }, + ) - GridMenuItem( - icon = R.drawable.transaction_p2p_incoming, - title = R.string.transactions_receive_funds, - onClick = { onReceive(); onDismiss() }, - enabled = !disableActions && !disablePeer, - ) + GridMenuItem( + icon = R.drawable.transaction_p2p_incoming, + title = R.string.transactions_receive_funds, + onClick = { onReceive(); onDismiss() }, + enabled = !disableActions && !disablePeer, + ) - GridMenuItem( - icon = R.drawable.transaction_withdrawal, - title = R.string.withdraw_button_label, - onClick = { onWithdraw(); onDismiss() }, - enabled = !disableActions, - ) + GridMenuItem( + icon = R.drawable.transaction_withdrawal, + title = R.string.withdraw_button_label, + onClick = { onWithdraw(); onDismiss() }, + enabled = !disableActions, + ) - GridMenuItem( - icon = R.drawable.transaction_p2p_outgoing, - title = R.string.transactions_send_funds, - onClick = { onSend(); onDismiss() }, - enabled = !disableActions && !disablePeer, - ) - } + GridMenuItem( + icon = R.drawable.transaction_p2p_outgoing, + title = R.string.transactions_send_funds, + onClick = { onSend(); onDismiss() }, + enabled = !disableActions && !disablePeer, + ) } } } @@ -236,7 +234,6 @@ fun TalerActionsModal( fun TalerActionsModalPreview() { TalerSurface { TalerActionsModal( - showSheet = true, sheetState = rememberModalBottomSheetState(), selectedCurrency = "CHF", showShopping = true, diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt @@ -45,7 +45,7 @@ fun PayTemplateComposable( getCurrencySpec: (String) -> CurrencySpecification?, onCreateAmount: (String, String) -> AmountResult, onSubmit: (params: TemplateParams) -> Unit, - onError: (resId: Int) -> Unit, + onError: (msg: String) -> Unit, ) { // If wallet is empty, there's no way the user can pay something if (currencies.isEmpty()) { diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt @@ -1,127 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2023 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.payment - -import android.net.Uri -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.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.asFlow -import androidx.navigation.fragment.findNavController -import net.taler.common.showError -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.showError -import androidx.core.net.toUri -import net.taler.wallet.balances.BalanceState -import net.taler.wallet.compose.ErrorComposable -import net.taler.wallet.compose.LoadingScreen - -class PayTemplateFragment : Fragment() { - - private val model: MainViewModel by activityViewModels() - private lateinit var uriString: String - private lateinit var uri: Uri - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - uriString = arguments?.getString("uri") ?: error("no amount passed") - uri = uriString.toUri() - - val payStatusFlow = model.paymentManager.payStatus.asFlow() - - return ComposeView(requireContext()).apply { - setContent { - val payStatus = payStatusFlow.collectAsStateLifecycleAware(PayStatus.None) - val balanceState by model.balanceManager.state.observeAsState(BalanceState.None) - val devMode by model.devMode.observeAsState(false) - TalerSurface { - when (val state = balanceState) { - is BalanceState.None, is BalanceState.Loading -> LoadingScreen() - is BalanceState.Error -> ErrorComposable(state.error, devMode = devMode) - is BalanceState.Success -> PayTemplateComposable( - currencies = state.balances.map { it.currency }, - payStatus = payStatus.value, - onCreateAmount = model::createAmount, - onSubmit = this@PayTemplateFragment::createOrder, - onError = { this@PayTemplateFragment.showError(it) }, - getCurrencySpec = model.exchangeManager::getSpecForCurrency, - ) - } - - LaunchedEffect(balanceState) { - balanceState - } - - LaunchedEffect(Unit) { - model.balanceManager.loadAssets() - } - } - } - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - checkTemplate() - - model.paymentManager.payStatus.observe(viewLifecycleOwner) { payStatus -> - when (payStatus) { - is PayStatus.Prepared -> { - model.paymentManager.preparePay(payStatus.transactionId) { - findNavController().navigate(R.id.action_global_promptPayment) - } - } - - is PayStatus.Pending -> if (payStatus.error != null && model.devMode.value == true) { - showError(payStatus.error) - } - - is PayStatus.Checked -> { - val usableCurrencies = model.balanceManager.getCurrencies() - .intersect(payStatus.supportedCurrencies.toSet()) - .toList() - if (!payStatus.details.isTemplateEditable(usableCurrencies)) { - createOrder(payStatus.details.toTemplateParams()) - } - } - - else -> {} - } - } - } - - private fun checkTemplate() { - model.paymentManager.checkPayForTemplate(uriString) - } - - private fun createOrder(params: TemplateParams) { - model.paymentManager.preparePayForTemplate(uriString, params) - } -} diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt @@ -52,7 +52,7 @@ fun PayTemplateOrderComposable( templateDetails: WalletTemplateDetails, onCreateAmount: (String, String) -> AmountResult, getCurrencySpec: (String) -> CurrencySpecification?, - onError: (msgRes: Int) -> Unit, + onError: (msg: String) -> Unit, onSubmit: (params: TemplateParams) -> Unit, ) { val defaultSummary = templateDetails.defaultSummary @@ -71,6 +71,9 @@ fun PayTemplateOrderComposable( getCurrencySpec(amount.currency) } + val balanceInsufficientError = stringResource(R.string.payment_balance_insufficient) + val amountInvalidError = stringResource(R.string.amount_invalid) + Column(horizontalAlignment = End) { OutlinedTextField( modifier = Modifier @@ -89,14 +92,12 @@ fun PayTemplateOrderComposable( label = { Text(stringResource(R.string.withdraw_manual_ready_subject)) }, ) - AmountCurrencyField( + if (templateDetails.isAmountEditable()) AmountCurrencyField( modifier = Modifier .padding(16.dp) .fillMaxWidth(), amount = amount.withSpec(currencySpec), currencies = usableCurrencies, - editableCurrency = !templateDetails.isCurrencyEditable(usableCurrencies), - readOnly = !templateDetails.isAmountEditable(), onAmountChanged = { amount = it }, label = { Text(stringResource(R.string.amount_send)) }, ) @@ -106,8 +107,8 @@ fun PayTemplateOrderComposable( enabled = !templateDetails.isSummaryEditable() || summary.isNotBlank(), onClick = { when (val res = onCreateAmount(amount.amountStr, amount.currency)) { - is AmountResult.InsufficientBalance -> onError(R.string.payment_balance_insufficient) - is AmountResult.InvalidAmount -> onError(R.string.amount_invalid) + is AmountResult.InsufficientBalance -> onError(balanceInsufficientError) + is AmountResult.InvalidAmount -> onError(amountInvalidError) // NOTE: it is important to nullify non-editable values! is AmountResult.Success -> onSubmit(TemplateParams( summary = if (templateDetails.isSummaryEditable()) summary else null, diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateScreen.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateScreen.kt @@ -0,0 +1,113 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.payment + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.asFlow +import net.taler.wallet.NavigateCallback +import net.taler.wallet.R +import net.taler.wallet.WalletDestination +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.balances.BalanceState +import net.taler.wallet.compose.ErrorComposable +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.main.MainViewModel + +@Composable +fun PayTemplateScreen( + model: MainViewModel, + uri: String, + onNavigate: NavigateCallback, + onNavigateBack: () -> Unit, + onShowError: (TalerErrorInfo) -> Unit, +) { + val paymentManager = model.paymentManager + val balanceManager = model.balanceManager + val exchangeManager = model.exchangeManager + + val payStatus by paymentManager.payStatus.asFlow().collectAsStateLifecycleAware(PayStatus.None) + val balanceState by balanceManager.state.observeAsState(BalanceState.None) + val devMode by model.devMode.observeAsState(false) + + LaunchedEffect(Unit) { + balanceManager.loadAssets() + paymentManager.checkPayForTemplate(uri) + } + + LaunchedEffect(payStatus) { + when (val s = payStatus) { + is PayStatus.Prepared -> { + paymentManager.preparePay(s.transactionId) { + onNavigate(WalletDestination.PromptPayment, false) + } + } + + is PayStatus.Pending -> if (s.error != null) { + onShowError(s.error) + } + + is PayStatus.Checked -> { + val usableCurrencies = balanceManager.getCurrencies() + .intersect(s.supportedCurrencies.toSet()) + .toList() + if (!s.details.isTemplateEditable(usableCurrencies)) { + paymentManager.preparePayForTemplate(uri, s.details.toTemplateParams()) + } + } + + else -> {} + } + } + + TalerSurface { + GlobalScaffold( + model = model, + title = { Text(stringResource(R.string.payment_pay_template_title)) }, + onNavigateBack = onNavigateBack, + ) { paddingValues -> + Box (Modifier.padding(paddingValues)) { + when (val state = balanceState) { + is BalanceState.None, is BalanceState.Loading -> LoadingScreen() + is BalanceState.Error -> ErrorComposable(state.error, devMode = devMode) + is BalanceState.Success -> PayTemplateComposable( + currencies = state.balances.map { it.currency }, + payStatus = payStatus, + onCreateAmount = model::createAmount, + onSubmit = { params -> + paymentManager.preparePayForTemplate(uri, params) + }, + onError = { errorMsg -> + onShowError(TalerErrorInfo.makeCustomError(errorMsg)) + }, + getCurrencySpec = exchangeManager::getSpecForCurrency, + ) + } + } + } + } +} diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt @@ -306,12 +306,12 @@ class PaymentManager( @UiThread fun resetPayStatus() { - mPayStatus.value = PayStatus.None + mPayStatus.postValue(PayStatus.None) } private fun handleError(operation: String, error: TalerErrorInfo) { Log.e(TAG, "got $operation error result $error") - mPayStatus.value = PayStatus.Pending(error = error) + mPayStatus.postValue(PayStatus.Pending(error = error)) } } diff --git a/wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt b/wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt @@ -1,135 +0,0 @@ -/* - * 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/> - */ - -package net.taler.wallet.payment - -import android.content.Context -import android.graphics.Bitmap -import android.view.LayoutInflater -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.ViewHolder -import net.taler.common.ContractProduct -import net.taler.common.base64Bitmap -import net.taler.wallet.R -import net.taler.wallet.payment.ProductAdapter.ProductViewHolder - -internal interface ProductImageClickListener { - fun onImageClick(image: Bitmap) -} - -internal class ProductAdapter(private val listener: ProductImageClickListener) : - RecyclerView.Adapter<ProductViewHolder>() { - - private var items = emptyList<ContractProduct>() - - override fun getItemCount() = items.size - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProductViewHolder { - val inflater = LayoutInflater.from(parent.context) - val view = inflater.inflate(R.layout.list_item_product, parent, false) - return ProductViewHolder(view) - } - - override fun onBindViewHolder(holder: ProductViewHolder, position: Int) { - holder.bind(items[position]) - } - - fun update(newItems: List<ContractProduct>) { - val oldItems = this.items - - val diffCallback = ProductDiffCallback(oldItems, newItems) - val diffResult = DiffUtil.calculateDiff(diffCallback) - diffResult.dispatchUpdatesTo(this) - - items = newItems - } - - internal inner class ProductViewHolder(v: View) : ViewHolder(v) { - private val context: Context = v.context - - private val quantity: TextView = v.findViewById(R.id.quantity) - private val image: ImageView = v.findViewById(R.id.image) - private val name: TextView = v.findViewById(R.id.name) - private val taxes: TextView = v.findViewById(R.id.taxes) - private val price: TextView = v.findViewById(R.id.price) - - fun bind(product: ContractProduct) { - quantity.text = product.quantity.toString() - - // base64 encoded image - val bitmap = product.image?.base64Bitmap - if (bitmap == null) { - image.visibility = GONE - } else { - image.visibility = VISIBLE - image.setImageBitmap(bitmap) - image.setOnClickListener { - listener.onImageClick(bitmap) - } - } - - name.text = product.description - - if (product.totalPrice != null) { - price.visibility = VISIBLE - price.text = product.totalPrice.toString() - } else { - price.visibility = GONE - } - - if (product.taxes != null && product.taxes!!.isNotEmpty()) { - taxes.visibility = VISIBLE - taxes.text = product.taxes!!.filter { - !it.tax.isZero() - }.joinToString(separator = "\n") { - context.getString(R.string.payment_tax, it.name, it.tax) - } - } else { - taxes.visibility = GONE - } - } - } -} - -internal class ProductDiffCallback( - private val oldList: List<ContractProduct>, - private val newList: List<ContractProduct>, -): DiffUtil.Callback() { - override fun getOldListSize() = oldList.size - - override fun getNewListSize() = newList.size - - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - val old = oldList[oldItemPosition] - val new = newList[newItemPosition] - - return old.productId == new.productId - } - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - val old = oldList[oldItemPosition] - val new = newList[newItemPosition] - - return old == new - } -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/payment/ProductImageFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/ProductImageFragment.kt @@ -1,54 +0,0 @@ -/* - * 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/> - */ - -package net.taler.wallet.payment - -import android.graphics.Bitmap -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.DialogFragment -import net.taler.wallet.databinding.FragmentProductImageBinding - -class ProductImageFragment private constructor() : DialogFragment() { - - private lateinit var ui: FragmentProductImageBinding - - companion object { - private const val IMAGE = "image" - - fun new(image: Bitmap) = ProductImageFragment().apply { - arguments = Bundle().apply { - putParcelable(IMAGE, image) - } - } - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - ui = FragmentProductImageBinding.inflate(inflater, container, false) - return ui.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val bitmap = requireArguments().getParcelable<Bitmap>(IMAGE) - ui.productImageView.setImageBitmap(bitmap) - } - -} diff --git a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentComposable.kt @@ -117,6 +117,7 @@ fun PromptPaymentComposable( onClickImage: (Bitmap) -> Unit, onSetupDonau: (donauBaseUrl: String) -> Unit, checkDonauStatus: suspend (choiceIndex: Int) -> DonauStatus, + modifier: Modifier = Modifier, ) { val contractTerms = status.contractTerms var showCancelDialog by rememberSaveable { mutableStateOf(false) } @@ -128,7 +129,7 @@ fun PromptPaymentComposable( ) Column( - Modifier + modifier .fillMaxSize() .imePadding(), ) { diff --git a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt @@ -1,168 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2025 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.payment - -import android.graphics.Bitmap -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.platform.ComposeView -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 com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.launch -import net.taler.common.showError -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.main.TAG -import net.taler.wallet.compose.LoadingScreen -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.showError - -class PromptPaymentFragment: Fragment(), ProductImageClickListener { - private val model: MainViewModel by activityViewModels() - private val paymentManager by lazy { model.paymentManager } - private val transactionManager by lazy { model.transactionManager } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? = ComposeView(requireContext()).apply { - setContent { - TalerSurface { - val payStatus by paymentManager.payStatus.observeAsState(PayStatus.None) - when(val status = payStatus) { - is PayStatus.None, - is PayStatus.Loading, - is PayStatus.Prepared -> LoadingScreen() - is PayStatus.Checked -> {} // does not apply, only used for templates - is PayStatus.Choices -> { - PromptPaymentComposable(status, - onConfirm = { index, useDonau -> - paymentManager.confirmPay( - transactionId = status.transactionId, - choiceIndex = index, - useDonau = useDonau, - ) - }, - onCancel = { - transactionManager.abortTransaction( - status.transactionId, - onSuccess = { - Snackbar.make( - requireView(), - getString(R.string.payment_aborted), - LENGTH_LONG - ).show() - findNavController().popBackStack() - }, - onError = { error -> - Log.e(TAG, "Error abortTransaction $error") - if (model.devMode.value == false) { - showError(error.userFacingMsg) - } else { - showError(error) - } - } - ) - }, - onClickImage = { bitmap -> - onImageClick(bitmap) - }, - checkDonauStatus = { index -> - status.choices.find { it.choiceIndex == index }?.let { choice -> - paymentManager.checkDonauForChoice(choice) - } ?: DonauStatus.Unavailable - }, - onSetupDonau = { donauBaseUrl -> - findNavController().navigate( - R.id.action_main_to_setDonau, - bundleOf( - "donauBaseUrl" to donauBaseUrl, - "saveShouldExit" to true, - ), - ) - }, - ) - } - - else -> {} - } - } - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - paymentManager.payStatus.observe(viewLifecycleOwner) { status -> - when (status) { - is PayStatus.Success -> { - paymentManager.resetPayStatus() - navigateToTransaction(status.transactionId) - if (status.automaticExecution) { - Snackbar.make(requireView(), R.string.payment_automatic_execution, LENGTH_LONG).show() - } - } - - is PayStatus.AlreadyPaid -> { - paymentManager.resetPayStatus() - navigateToTransaction(status.transactionId) - Snackbar.make(requireView(), R.string.payment_already_paid, LENGTH_LONG).show() - } - - is PayStatus.Pending -> { - paymentManager.resetPayStatus() - navigateToTransaction(status.transactionId) - if (status.error != null) { - if (model.devMode.value == true) { - showError(status.error) - } else { - showError(status.error.userFacingMsg) - } - } - } - - else -> {} - } - } - } - - override fun onImageClick(image: Bitmap) { - val f = ProductImageFragment.new(image) - f.show(parentFragmentManager, "image") - } - - private fun navigateToTransaction(id: String?) { - lifecycleScope.launch { - if (id != null && transactionManager.selectTransaction(id)) { - findNavController().navigate(R.id.action_global_transactionPayment) - } else { - findNavController().navigate(R.id.action_global_main) - } - } - } -} - diff --git a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentScreen.kt b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentScreen.kt @@ -0,0 +1,175 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.payment + +import android.graphics.Bitmap +import android.util.Log +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import net.taler.wallet.NavigateCallback +import net.taler.wallet.R +import net.taler.wallet.WalletDestination +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.main.MainViewModel +import net.taler.wallet.main.TAG +import net.taler.wallet.transactions.TransactionManager + +@Composable +fun PromptPaymentScreen( + model: MainViewModel, + onNavigate: NavigateCallback, + onNavigateBack: () -> Unit, + onShowError: (TalerErrorInfo) -> Unit, +) { + val paymentManager = model.paymentManager + val transactionManager = model.transactionManager + val payStatus by paymentManager.payStatus.observeAsState(PayStatus.None) + var showImage by remember { mutableStateOf<Bitmap?>(null) } + + LaunchedEffect(payStatus) { + val status = payStatus + when (status) { + is PayStatus.Success -> { + navigateToTransaction(status.transactionId, transactionManager, onNavigate, onNavigateBack) + paymentManager.resetPayStatus() + } + + is PayStatus.AlreadyPaid -> { + navigateToTransaction(status.transactionId, transactionManager, onNavigate, onNavigateBack) + paymentManager.resetPayStatus() + } + + is PayStatus.Pending -> { + navigateToTransaction(status.transactionId, transactionManager, onNavigate, onNavigateBack) + paymentManager.resetPayStatus() + status.error?.let { error -> + onShowError(error) + } + } + + else -> {} + } + } + + GlobalScaffold( + model = model, + title = { Text(stringResource(R.string.payment_title)) }, + onNavigateBack = onNavigateBack, + ) { paddingValues -> + when (val status = payStatus) { + is PayStatus.None, + is PayStatus.Loading, + is PayStatus.Prepared -> LoadingScreen(Modifier.padding(paddingValues)) + is PayStatus.Choices -> { + PromptPaymentComposable( + modifier = Modifier.padding(paddingValues), + status = status, + onConfirm = { index, useDonau -> + paymentManager.confirmPay( + transactionId = status.transactionId, + choiceIndex = index, + useDonau = useDonau, + ) + }, + onCancel = { + transactionManager.abortTransaction( + status.transactionId, + onSuccess = { + onNavigateBack() + }, + onError = { error -> + Log.e(TAG, "Error abortTransaction $error") + onShowError(error) + } + ) + }, + onClickImage = { bitmap -> + showImage = bitmap + }, + checkDonauStatus = { index -> + status.choices.find { it.choiceIndex == index }?.let { choice -> + paymentManager.checkDonauForChoice(choice) + } ?: DonauStatus.Unavailable + }, + onSetupDonau = { donauBaseUrl -> + onNavigate(WalletDestination.SetDonau(donauBaseUrl), false) + }, + ) + } + else -> {} + } + } + + if (showImage != null) { + // ProductImageFragment was a DialogFragment. + // For now, we can just use a simple Modal or similar if needed, + // but let's just keep it simple or implement a basic compose dialog. + ProductImageDialog(showImage!!) { showImage = null } + } +} + +@Composable +fun ProductImageDialog(bitmap: Bitmap, onDismiss: () -> Unit) { + Dialog(onDismissRequest = onDismiss) { + Card( + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + ) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .clickable { onDismiss() } + ) + } + } +} + +private suspend fun navigateToTransaction( + id: String?, + transactionManager: TransactionManager, + onNavigate: NavigateCallback, + onNavigateBack: () -> Unit, +) { + if (id != null && transactionManager.selectTransaction(id)) { + onNavigate(WalletDestination.TransactionPayment, true) + } else { + onNavigateBack() + } +} diff --git a/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt @@ -64,21 +64,20 @@ fun TransactionPaymentComposable( spec: CurrencySpecification?, onFulfill: (url: String) -> Unit, onTransition: (t: TransactionAction) -> Unit, + modifier: Modifier = Modifier, ) { val scrollState = rememberScrollState() Column( - modifier = Modifier + modifier = modifier .fillMaxWidth() .verticalScroll(scrollState), horizontalAlignment = CenterHorizontally, ) { - val context = LocalContext.current - TransactionStateComposable(state = t.txState) Text( modifier = Modifier.padding(16.dp), - text = t.timestamp.ms.toAbsoluteTime(context).toString(), + text = t.timestamp.ms.toAbsoluteTime(LocalContext.current).toString(), style = MaterialTheme.typography.bodyLarge, ) @@ -181,6 +180,6 @@ fun TransactionPaymentComposablePreview() { )) ) TalerSurface { - TransactionPaymentComposable(t = t, devMode = true, spec = null, onFulfill = {}) {} + TransactionPaymentComposable(t = t, devMode = true, spec = null, onFulfill = {}, onTransition = {}) } } diff --git a/wallet/src/main/java/net/taler/wallet/peer/IncomingComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/IncomingComposable.kt @@ -74,11 +74,12 @@ val incomingPull = IncomingData( fun IncomingComposable( state: State<IncomingState>, data: IncomingData, + modifier: Modifier = Modifier, onAccept: (IncomingTerms) -> Unit, ) { val scrollState = rememberScrollState() Column( - modifier = Modifier + modifier = modifier .fillMaxSize() .verticalScroll(scrollState), ) { diff --git a/wallet/src/main/java/net/taler/wallet/peer/IncomingPullPaymentFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/IncomingPullPaymentFragment.kt @@ -1,87 +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.peer - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import kotlinx.coroutines.launch -import net.taler.common.showError -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.showError - -class IncomingPullPaymentFragment : Fragment() { - private val model: MainViewModel by activityViewModels() - private val peerManager get() = model.peerManager - private val transactionManager get() = model.transactionManager - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - return ComposeView(requireContext()).apply { - setContent { - TalerSurface { - val state = peerManager.incomingPullState.collectAsStateLifecycleAware() - IncomingComposable(state, incomingPull) { terms -> - peerManager.confirmPeerPullDebit(terms) - } - } - } - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - peerManager.incomingPullState.collect { - if (it is IncomingAccepted) { - if (transactionManager.selectTransaction(it.transactionId)) { - findNavController().navigate(R.id.action_global_transactionPeer) - } else { - findNavController().navigate(R.id.action_global_main) - } - } else if (it is IncomingError) { - if (model.devMode.value == true) { - showError(it.info) - } else { - showError(it.info.userFacingMsg) - } - } - } - } - } - } - - override fun onStart() { - super.onStart() - activity?.setTitle(R.string.pay_peer_title) - } -} diff --git a/wallet/src/main/java/net/taler/wallet/peer/IncomingPullPaymentScreen.kt b/wallet/src/main/java/net/taler/wallet/peer/IncomingPullPaymentScreen.kt @@ -0,0 +1,75 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.peer + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import net.taler.wallet.NavigateCallback +import net.taler.wallet.R +import net.taler.wallet.WalletDestination +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.main.MainViewModel + +@Composable +fun IncomingPullPaymentScreen( + model: MainViewModel, + onNavigate: NavigateCallback, + onNavigateBack: () -> Unit, + onShowError: (TalerErrorInfo) -> Unit, +) { + val peerManager = model.peerManager + val transactionManager = model.transactionManager + + val state by peerManager.incomingPullState.collectAsStateLifecycleAware() + + LaunchedEffect(state) { + val s = state + if (s is IncomingAccepted) { + if (transactionManager.selectTransaction(s.transactionId)) { + onNavigate(WalletDestination.TransactionPeer, true) + } else { + onNavigateBack() + } + } else if (s is IncomingError) { + onShowError(s.info) + } + } + + TalerSurface { + GlobalScaffold( + model = model, + title = { Text(stringResource(R.string.pay_peer_title)) }, + onNavigateBack = onNavigateBack, + ) { paddingValues -> + IncomingComposable( + modifier = Modifier.padding(paddingValues), + state = peerManager.incomingPullState.collectAsStateLifecycleAware(), + data = incomingPull + ) { terms -> + peerManager.confirmPeerPullDebit(terms) + } + } + } +} diff --git a/wallet/src/main/java/net/taler/wallet/peer/IncomingPushPaymentFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/IncomingPushPaymentFragment.kt @@ -1,101 +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.peer - -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import kotlinx.coroutines.launch -import net.taler.common.showError -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.main.TAG -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.showError - -class IncomingPushPaymentFragment : Fragment() { - private val model: MainViewModel by activityViewModels() - private val peerManager get() = model.peerManager - private val exchangeManager get() = model.exchangeManager - private val transactionManager get() = model.transactionManager - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - return ComposeView(requireContext()).apply { - setContent { - TalerSurface { - val state = peerManager.incomingPushState.collectAsStateLifecycleAware() - IncomingComposable(state, incomingPush) { terms -> - if (terms is IncomingTosReview) { - val args = bundleOf("exchangeBaseUrl" to terms.exchangeBaseUrl) - findNavController().navigate(R.id.action_global_reviewExchangeTOS, args) - } else { - peerManager.confirmPeerPushCredit(terms) - } - } - } - } - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - peerManager.incomingPushState.collect { - Log.d(TAG, "incomingPushState is $it") - if (it is IncomingAccepted) { - if (transactionManager.selectTransaction(it.transactionId)) { - findNavController().navigate(R.id.action_global_transactionPeer) - } else { - findNavController().navigate(R.id.action_global_main) - } - } else if (it is IncomingError) { - if (model.devMode.value == true) { - showError(it.info) - } else { - showError(it.info.userFacingMsg) - } - } - } - } - } - - exchangeManager.exchanges.observe(viewLifecycleOwner) { exchanges -> - peerManager.refreshPeerPushCreditTos(exchanges) - } - } - - override fun onStart() { - super.onStart() - activity?.setTitle(R.string.receive_peer_payment_title) - } -} diff --git a/wallet/src/main/java/net/taler/wallet/peer/IncomingPushPaymentScreen.kt b/wallet/src/main/java/net/taler/wallet/peer/IncomingPushPaymentScreen.kt @@ -0,0 +1,88 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.peer + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import net.taler.wallet.NavigateCallback +import net.taler.wallet.R +import net.taler.wallet.WalletDestination +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.main.MainViewModel + +@Composable +fun IncomingPushPaymentScreen( + model: MainViewModel, + onNavigate: NavigateCallback, + onNavigateBack: () -> Unit, + onShowError: (TalerErrorInfo) -> Unit, +) { + val peerManager = model.peerManager + val transactionManager = model.transactionManager + val exchangeManager = model.exchangeManager + + val state = peerManager.incomingPushState.collectAsStateLifecycleAware() + val exchanges by exchangeManager.exchanges.observeAsState() + + LaunchedEffect(exchanges) { + exchanges?.let { + peerManager.refreshPeerPushCreditTos(it) + } + } + + LaunchedEffect(state.value) { + val s = state.value + if (s is IncomingAccepted) { + if (transactionManager.selectTransaction(s.transactionId)) { + onNavigate(WalletDestination.TransactionPeer, true) + } else { + onNavigateBack() + } + } else if (s is IncomingError) { + onShowError(s.info) + } + } + + TalerSurface { + GlobalScaffold( + model = model, + title = { Text(stringResource(R.string.receive_peer_payment_title)) }, + onNavigateBack = onNavigateBack, + ) { paddingValues -> + IncomingComposable( + modifier = Modifier.padding(paddingValues), + state = state, + data = incomingPush + ) { terms -> + if (terms is IncomingTosReview) { + onNavigate(WalletDestination.ReviewExchangeTOS(terms.exchangeBaseUrl), false) + } else { + peerManager.confirmPeerPushCredit(terms) + } + } + } + } +} diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt @@ -76,6 +76,7 @@ fun OutgoingPullComposable( checkPeerPullCredit: suspend (amount: AmountScope, loading: Boolean) -> CheckPeerPullCreditResult?, onCreateInvoice: (amount: AmountScope, subject: String, hours: Long, exchangeBaseUrl: String) -> Unit, onTosAccept: (exchangeBaseUrl: String) -> Unit, + modifier: Modifier = Modifier, ) { var subject by rememberSaveable { mutableStateOf("") } var amount by remember { @@ -107,7 +108,7 @@ fun OutgoingPullComposable( if (state is OutgoingChecking || state is OutgoingCreating || state is OutgoingResponse) { - LoadingScreen() + LoadingScreen(modifier) return } @@ -115,7 +116,7 @@ fun OutgoingPullComposable( val subjectFocusRequester = remember { FocusRequester() } Column( - Modifier + modifier .fillMaxSize() .imePadding(), ) { @@ -272,6 +273,7 @@ fun OutgoingPullComposable( } } } + @Preview @Composable fun PeerPullComposableCreatingPreview() { diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt @@ -1,127 +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.peer - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.ComposeView -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import kotlinx.coroutines.launch -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.main.ViewMode -import net.taler.wallet.compose.AmountScope -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.showError - -class OutgoingPullFragment : Fragment() { - private val model: MainViewModel by activityViewModels() - private val peerManager get() = model.peerManager - private val transactionManager get() = model.transactionManager - private val exchangeManager get() = model.exchangeManager - private val balanceManager get() = model.balanceManager - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - return ComposeView(requireContext()).apply { - setContent { - TalerSurface { - val state by peerManager.pullState.collectAsStateLifecycleAware() - val viewMode by model.viewMode.collectAsStateLifecycleAware() - val devMode by model.devMode.observeAsState() - OutgoingPullComposable( - state = state, - onCreateInvoice = this@OutgoingPullFragment::onCreateInvoice, - onTosAccept = this@OutgoingPullFragment::onTosAccept, - defaultScope = remember { (viewMode as? ViewMode.Transactions)?.selectedScope }, - scopes = balanceManager.getScopes(true), - devMode = devMode == true, - getCurrencySpec = exchangeManager::getSpecForScopeInfo, - checkPeerPullCredit = { amount, loading -> - model.selectScope(amount.scope) - peerManager.checkPeerPullCredit( - amount.amount, - scopeInfo = amount.scope, - loading = loading, - ) - }, - ) - } - } - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { - peerManager.pullState.collect { - if (it is OutgoingResponse) { - if (transactionManager.selectTransaction(it.transactionId)) { - findNavController().navigate(R.id.action_global_transactionPeer) - } else { - findNavController().navigate(R.id.action_global_main) - } - } - - if (it is OutgoingError && model.devMode.value == true) { - showError(it.info) - } - } - } - } - - exchangeManager.exchanges.observe(viewLifecycleOwner) { exchanges -> - // detect ToS acceptation - peerManager.refreshPeerPullCreditTos(exchanges) - } - } - - override fun onStart() { - super.onStart() - activity?.setTitle(R.string.receive_peer_title) - } - - override fun onDestroy() { - super.onDestroy() - if (!requireActivity().isChangingConfigurations) peerManager.resetPullPayment() - } - - private fun onTosAccept(exchangeBaseUrl: String) { - val bundle = bundleOf("exchangeBaseUrl" to exchangeBaseUrl) - findNavController().navigate(R.id.action_global_reviewExchangeTOS, bundle) - } - - private fun onCreateInvoice(amount: AmountScope, summary: String, hours: Long, exchangeBaseUrl: String) { - peerManager.initiatePeerPullCredit(amount.amount, summary, hours, exchangeBaseUrl) - } -} diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullScreen.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullScreen.kt @@ -0,0 +1,113 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.peer + +import androidx.compose.foundation.layout.padding +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.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import net.taler.wallet.NavigateCallback +import net.taler.wallet.R +import net.taler.wallet.WalletDestination +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.main.MainViewModel +import net.taler.wallet.main.ViewMode + +@Composable +fun OutgoingPullScreen( + model: MainViewModel, + onNavigate: NavigateCallback, + onNavigateBack: () -> Unit, + onShowError: (TalerErrorInfo) -> Unit, +) { + val peerManager = model.peerManager + val transactionManager = model.transactionManager + val balanceManager = model.balanceManager + val exchangeManager = model.exchangeManager + + val state by peerManager.pullState.collectAsStateLifecycleAware() + val viewMode by model.viewMode.collectAsStateLifecycleAware() + val devMode by model.devMode.observeAsState(false) + val exchanges by exchangeManager.exchanges.observeAsState() + + DisposableEffect(Unit) { + onDispose { + peerManager.resetPullPayment() + } + } + + LaunchedEffect(exchanges) { + exchanges?.let { + peerManager.refreshPeerPullCreditTos(it) + } + } + + LaunchedEffect(state) { + val s = state + if (s is OutgoingResponse) { + if (transactionManager.selectTransaction(s.transactionId)) { + onNavigate(WalletDestination.TransactionPeer, true) + } else { + onNavigateBack() + } + } + + if (s is OutgoingError) { + onShowError(s.info) + } + } + + TalerSurface { + GlobalScaffold( + model = model, + title = { Text(stringResource(R.string.receive_peer_title)) }, + onNavigateBack = onNavigateBack, + ) { paddingValues -> + OutgoingPullComposable( + modifier = Modifier.padding(paddingValues), + state = state, + onCreateInvoice = { amount, summary, hours, exchangeBaseUrl -> + peerManager.initiatePeerPullCredit(amount.amount, summary, hours, exchangeBaseUrl) + }, + onTosAccept = { exchangeBaseUrl -> + onNavigate(WalletDestination.ReviewExchangeTOS(exchangeBaseUrl), false) + }, + defaultScope = remember { (viewMode as? ViewMode.Transactions)?.selectedScope }, + scopes = balanceManager.getScopes(true), + devMode = devMode, + getCurrencySpec = exchangeManager::getSpecForScopeInfo, + checkPeerPullCredit = { amount, loading -> + model.selectScope(amount.scope) + peerManager.checkPeerPullCredit( + amount.amount, + scopeInfo = amount.scope, + loading = loading, + ) + }, + ) + } + } +} diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt @@ -79,9 +79,10 @@ fun OutgoingPushComposable( getCurrencySpec: (scope: ScopeInfo) -> CurrencySpecification?, getFees: suspend (amount: AmountScope) -> CheckFeeResult?, onSend: (amount: AmountScope, summary: String, hours: Long) -> Unit, + modifier: Modifier = Modifier, ) { when(state) { - is OutgoingChecking, is OutgoingCreating, is OutgoingResponse -> LoadingScreen() + is OutgoingChecking, is OutgoingCreating, is OutgoingResponse -> LoadingScreen(modifier) is OutgoingIntro, is OutgoingChecked, is OutgoingError -> OutgoingPushIntroComposable( state = state, defaultScope = defaultScope, @@ -90,6 +91,7 @@ fun OutgoingPushComposable( getCurrencySpec = getCurrencySpec, getFees = getFees, onSend = onSend, + modifier = modifier, ) } } @@ -103,6 +105,7 @@ fun OutgoingPushIntroComposable( getCurrencySpec: (scope: ScopeInfo) -> CurrencySpecification?, getFees: suspend (amount: AmountScope) -> CheckFeeResult?, onSend: (amount: AmountScope, summary: String, hours: Long) -> Unit, + modifier: Modifier = Modifier, ) { var amount by remember { val scope = defaultScope ?: scopes[0] @@ -128,7 +131,7 @@ fun OutgoingPushIntroComposable( } Column( - Modifier + modifier .fillMaxSize() .imePadding(), ) { diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt @@ -1,126 +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.peer - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.OnBackPressedCallback -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import kotlinx.coroutines.launch -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.main.ViewMode -import net.taler.wallet.compose.AmountScope -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.showError - -class OutgoingPushFragment : Fragment() { - private val model: MainViewModel by activityViewModels() - private val peerManager get() = model.peerManager - private val transactionManager get() = model.transactionManager - private val balanceManager get() = model.balanceManager - private val exchangeManager get() = model.exchangeManager - - // hacky way to change back action until we have navigation for compose - private val backPressedCallback = object : OnBackPressedCallback(false) { - override fun handleOnBackPressed() { - findNavController().navigate(R.id.action_global_main) - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - requireActivity().onBackPressedDispatcher.addCallback( - viewLifecycleOwner, backPressedCallback - ) - - return ComposeView(requireContext()).apply { - setContent { - TalerSurface { - val state = peerManager.pushState.collectAsStateLifecycleAware().value - val viewMode by model.viewMode.collectAsStateLifecycleAware() - val devMode by model.devMode.observeAsState() - OutgoingPushComposable( - state = state, - defaultScope = remember { (viewMode as? ViewMode.Transactions)?.selectedScope }, - scopes = balanceManager.getScopes(true), - devMode = devMode == true, - getCurrencySpec = exchangeManager::getSpecForScopeInfo, - getFees = { - model.selectScope(it.scope) - peerManager.checkPeerPushFees(it.amount, restrictScope = it.scope) - }, - onSend = this@OutgoingPushFragment::onSend, - ) - } - } - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { - peerManager.pushState.collect { - if (it is OutgoingResponse) { - if (transactionManager.selectTransaction(it.transactionId)) { - findNavController().navigate(R.id.action_global_transactionPeer) - } else { - findNavController().navigate(R.id.action_global_main) - } - } - - if (it is OutgoingError && model.devMode.value == true) { - showError(it.info) - } - - // Disable back navigation when tx is being created - backPressedCallback.isEnabled = it !is OutgoingCreating - } - } - } - } - - override fun onStart() { - super.onStart() - activity?.setTitle(R.string.send_peer_title) - } - - override fun onDestroy() { - super.onDestroy() - if (!requireActivity().isChangingConfigurations) peerManager.resetPushPayment() - } - - private fun onSend(amount: AmountScope, summary: String, hours: Long) { - peerManager.initiatePeerPushDebit(amount.amount, summary, hours, restrictScope = amount.scope) - } -} diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushScreen.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushScreen.kt @@ -0,0 +1,99 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.peer + +import androidx.compose.foundation.layout.padding +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.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import net.taler.wallet.NavigateCallback +import net.taler.wallet.R +import net.taler.wallet.WalletDestination +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.main.MainViewModel +import net.taler.wallet.main.ViewMode + +@Composable +fun OutgoingPushScreen( + model: MainViewModel, + onNavigate: NavigateCallback, + onNavigateBack: () -> Unit, + onShowError: (TalerErrorInfo) -> Unit, +) { + val peerManager = model.peerManager + val transactionManager = model.transactionManager + val balanceManager = model.balanceManager + val exchangeManager = model.exchangeManager + + val state by peerManager.pushState.collectAsStateLifecycleAware() + val viewMode by model.viewMode.collectAsStateLifecycleAware() + val devMode by model.devMode.observeAsState(false) + + DisposableEffect(Unit) { + onDispose { + peerManager.resetPushPayment() + } + } + + LaunchedEffect(state) { + val s = state + if (s is OutgoingResponse) { + if (transactionManager.selectTransaction(s.transactionId)) { + onNavigate(WalletDestination.TransactionPeer, true) + } else { + onNavigateBack() + } + } + + if (s is OutgoingError) { + onShowError(s.info) + } + } + + TalerSurface { + GlobalScaffold( + model = model, + title = { Text(stringResource(R.string.send_peer_title)) }, + onNavigateBack = onNavigateBack, + ) { paddingValues -> + OutgoingPushComposable( + modifier = Modifier.padding(paddingValues), + state = state, + defaultScope = remember { (viewMode as? ViewMode.Transactions)?.selectedScope }, + scopes = balanceManager.getScopes(true), + devMode = devMode, + getCurrencySpec = exchangeManager::getSpecForScopeInfo, + getFees = { + model.selectScope(it.scope) + peerManager.checkPeerPushFees(it.amount, restrictScope = it.scope) + }, + onSend = { amount, summary, hours -> + peerManager.initiatePeerPushDebit(amount.amount, summary, hours, restrictScope = amount.scope) + }, + ) + } + } +} diff --git a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullCredit.kt b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullCredit.kt @@ -19,6 +19,7 @@ package net.taler.wallet.peer import androidx.compose.foundation.layout.ColumnScope import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import net.taler.common.Amount @@ -113,7 +114,7 @@ fun TransactionPeerPullCreditPreview(loading: Boolean = false) { )) ) Surface { - TransactionPeerComposable(t, true, null, {}) {} + TransactionPeerComposable(t, true, null, Modifier, {}) {} } } diff --git a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullDebit.kt b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullDebit.kt @@ -18,6 +18,7 @@ package net.taler.wallet.peer import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import net.taler.common.Amount @@ -90,6 +91,6 @@ fun TransactionPeerPullDebitPreview() { )) ) Surface { - TransactionPeerComposable(t, true, null, {}) {} + TransactionPeerComposable(t, true, null, Modifier, {}) {} } } diff --git a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushCredit.kt b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushCredit.kt @@ -19,6 +19,7 @@ package net.taler.wallet.peer import androidx.compose.foundation.layout.ColumnScope import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import net.taler.common.Amount @@ -98,6 +99,6 @@ fun TransactionPeerPushCreditPreview() { )) ) Surface { - TransactionPeerComposable(t, true, null, {}) {} + TransactionPeerComposable(t, true, null, Modifier, {}) {} } } diff --git a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt @@ -161,7 +161,7 @@ fun TransactionPeerPushDebitPreview(loading: Boolean = false) { ) TalerSurface { - TransactionPeerComposable(t, true, null, {}) {} + TransactionPeerComposable(t, true, null, Modifier, {}) {} } } diff --git a/wallet/src/main/java/net/taler/wallet/refund/TransactionRefundComposable.kt b/wallet/src/main/java/net/taler/wallet/refund/TransactionRefundComposable.kt @@ -60,21 +60,20 @@ fun TransactionRefundComposable( devMode: Boolean, spec: CurrencySpecification?, onTransition: (t: TransactionAction) -> Unit, + modifier: Modifier = Modifier, ) { val scrollState = rememberScrollState() Column( - modifier = Modifier + modifier = modifier .fillMaxWidth() .verticalScroll(scrollState), horizontalAlignment = CenterHorizontally, ) { - val context = LocalContext.current - TransactionStateComposable(state = t.txState) Text( modifier = Modifier.padding(16.dp), - text = t.timestamp.ms.toAbsoluteTime(context).toString(), + text = t.timestamp.ms.toAbsoluteTime(LocalContext.current).toString(), style = MaterialTheme.typography.bodyLarge, ) @@ -136,6 +135,6 @@ fun TransactionRefundComposablePreview() { )) ) TalerSurface { - TransactionRefundComposable(t = t, devMode = true, spec = null) {} + TransactionRefundComposable(t = t, devMode = true, spec = null, onTransition = {}) } } diff --git a/wallet/src/main/java/net/taler/wallet/settings/PerformanceFragment.kt b/wallet/src/main/java/net/taler/wallet/settings/PerformanceFragment.kt @@ -1,245 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2026 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.settings - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -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.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.Badge -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -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.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import kotlinx.serialization.json.Json -import net.taler.wallet.BottomInsetsSpacer -import net.taler.wallet.R -import net.taler.wallet.balances.SectionHeader -import net.taler.wallet.compose.EmptyComposable -import net.taler.wallet.compose.ShareButton -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.main.MainViewModel - -class PerformanceFragment: Fragment() { - private val model: MainViewModel by activityViewModels() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ) = ComposeView(requireContext()).apply { - setContent { - TalerSurface { - val stats by model.settingsManager.performanceTable.collectAsStateLifecycleAware() - - if (stats == null) { - EmptyComposable() - return@TalerSurface - } - - stats?.let { - PerformanceTableComposable(it, - onReload = { model.settingsManager.loadPerformanceStats() }, - ) - } - } - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - model.settingsManager.loadPerformanceStats() - } -} - -@Composable -fun PerformanceTableComposable( - stats: PerformanceTable, - onReload: () -> Unit, -) { - val json = remember { Json { - prettyPrint = true - ignoreUnknownKeys = true - coerceInputValues = true - } } - - LazyColumn { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceAround, - ) { - ShareButton(json.encodeToString(stats)) - - Button(onClick = onReload) { - Icon( - Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize), - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.reload)) - } - } - } - - if (stats.httpFetch.isNotEmpty()) { - stickyHeader { - SectionHeader { Text(stringResource(R.string.performance_stats_http_fetch)) } - } - - itemsIndexed(stats.httpFetch) { i, stat -> - PerformanceStatItem(i + 1, stat) - } - } - - if (stats.dbQuery.isNotEmpty()) { - stickyHeader { - SectionHeader { Text(stringResource(R.string.performance_stats_db_query)) } - } - - itemsIndexed(stats.dbQuery) { i, stat -> - PerformanceStatItem(i + 1, stat) - } - } - - - if (stats.crypto.isNotEmpty()) { - stickyHeader { - SectionHeader { Text(stringResource(R.string.performance_stats_crypto)) } - } - - itemsIndexed(stats.crypto) { i, stat -> - PerformanceStatItem(i + 1, stat) - } - } - - if (stats.walletRequest.isNotEmpty()) { - stickyHeader { - SectionHeader { Text(stringResource(R.string.performance_stats_wallet_request)) } - } - - itemsIndexed(stats.walletRequest) { i, stat -> - PerformanceStatItem(i + 1, stat) - } - } - - if (stats.walletTask.isNotEmpty()) { - stickyHeader { - SectionHeader { Text(stringResource(R.string.performance_stats_wallet_task)) } - } - - itemsIndexed(stats.walletTask) { i, stat -> - PerformanceStatItem(i + 1, stat) - } - } - - item { - BottomInsetsSpacer() - } - } -} - -@Composable -fun PerformanceStatItem( - ranking: Int, - stat: PerformanceStat, -) { - ListItem( - leadingContent = { - Text( - text = stringResource(R.string.ranking, ranking), - fontWeight = FontWeight.ExtraBold, - style = MaterialTheme.typography.titleLarge, - ) - }, - - headlineContent = { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - - ) { - Box(Modifier.weight(1f, fill = false)) { - when (stat) { - is PerformanceStat.HttpFetch -> Text( - text = stat.url, - style = MaterialTheme.typography.bodySmall, - ) - - is PerformanceStat.DbQuery -> Text(stat.name) - is PerformanceStat.Crypto -> Text(stat.operation) - is PerformanceStat.WalletRequest -> Text(stat.operation) - is PerformanceStat.WalletTask -> Text( - text = stat.taskId, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - ) - } - } - - Badge(Modifier.padding(start = 10.dp)) { - Text(stat.count.toString()) - } - } - }, - - supportingContent = { - when(stat) { - is PerformanceStat.DbQuery -> Text( - text = stat.location, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - ) - else -> {} - } - }, - - trailingContent = { - Text(stringResource(R.string.millisecond, stat.maxDurationMs)) - }, - ) -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/settings/PerformanceStatsScreen.kt b/wallet/src/main/java/net/taler/wallet/settings/PerformanceStatsScreen.kt @@ -0,0 +1,241 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Badge +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.serialization.json.Json +import net.taler.wallet.BottomInsetsSpacer +import net.taler.wallet.R +import net.taler.wallet.balances.SectionHeader +import net.taler.wallet.compose.EmptyComposable +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.ShareButton +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.main.MainViewModel + +@Composable +fun PerformanceStatsScreen( + model: MainViewModel, + onNavigateBack: () -> Unit, +) { + val settingsManager = model.settingsManager + val stats by settingsManager.performanceTable.collectAsStateLifecycleAware() + + LaunchedEffect(Unit) { + settingsManager.loadPerformanceStats() + } + + TalerSurface { + GlobalScaffold( + model = model, + title = { Text(stringResource(R.string.performance_stats_title)) }, + onNavigateBack = onNavigateBack, + ) { paddingValues -> + val it = stats + if (it == null) { + EmptyComposable(Modifier.padding(paddingValues)) + } else { + PerformanceTableComposable( + modifier = Modifier.padding(paddingValues), + stats = it, + onReload = { settingsManager.loadPerformanceStats() }, + ) + } + } + } +} + +@Composable +fun PerformanceTableComposable( + stats: PerformanceTable, + onReload: () -> Unit, + modifier: Modifier = Modifier, +) { + val json = remember { Json { + prettyPrint = true + ignoreUnknownKeys = true + coerceInputValues = true + } } + + LazyColumn(modifier = modifier) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceAround, + ) { + ShareButton(json.encodeToString(stats)) + + Button(onClick = onReload) { + Icon( + Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.reload)) + } + } + } + + if (stats.httpFetch.isNotEmpty()) { + stickyHeader { + SectionHeader { Text(stringResource(R.string.performance_stats_http_fetch)) } + } + + itemsIndexed(stats.httpFetch) { i, stat -> + PerformanceStatItem(i + 1, stat) + } + } + + if (stats.dbQuery.isNotEmpty()) { + stickyHeader { + SectionHeader { Text(stringResource(R.string.performance_stats_db_query)) } + } + + itemsIndexed(stats.dbQuery) { i, stat -> + PerformanceStatItem(i + 1, stat) + } + } + + + if (stats.crypto.isNotEmpty()) { + stickyHeader { + SectionHeader { Text(stringResource(R.string.performance_stats_crypto)) } + } + + itemsIndexed(stats.crypto) { i, stat -> + PerformanceStatItem(i + 1, stat) + } + } + + if (stats.walletRequest.isNotEmpty()) { + stickyHeader { + SectionHeader { Text(stringResource(R.string.performance_stats_wallet_request)) } + } + + itemsIndexed(stats.walletRequest) { i, stat -> + PerformanceStatItem(i + 1, stat) + } + } + + if (stats.walletTask.isNotEmpty()) { + stickyHeader { + SectionHeader { Text(stringResource(R.string.performance_stats_wallet_task)) } + } + + itemsIndexed(stats.walletTask) { i, stat -> + PerformanceStatItem(i + 1, stat) + } + } + + item { + BottomInsetsSpacer() + } + } +} + +@Composable +fun PerformanceStatItem( + ranking: Int, + stat: PerformanceStat, +) { + ListItem( + leadingContent = { + Text( + text = stringResource(R.string.ranking, ranking), + fontWeight = FontWeight.ExtraBold, + style = MaterialTheme.typography.titleLarge, + ) + }, + + headlineContent = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + + ) { + Box(Modifier.weight(1f, fill = false)) { + when (stat) { + is PerformanceStat.HttpFetch -> Text( + text = stat.url, + style = MaterialTheme.typography.bodySmall, + ) + + is PerformanceStat.DbQuery -> Text(stat.name) + is PerformanceStat.Crypto -> Text(stat.operation) + is PerformanceStat.WalletRequest -> Text(stat.operation) + is PerformanceStat.WalletTask -> Text( + text = stat.taskId, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } + } + + Badge(Modifier.padding(start = 10.dp)) { + Text(stat.count.toString()) + } + } + }, + + supportingContent = { + when(stat) { + is PerformanceStat.DbQuery -> Text( + text = stat.location, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + else -> {} + } + }, + + trailingContent = { + Text(stringResource(R.string.millisecond, stat.maxDurationMs)) + }, + ) +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt b/wallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt @@ -1,335 +0,0 @@ -/* - * 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/> - */ - -package net.taler.wallet.settings - -import android.app.Activity.RESULT_OK -import android.app.KeyguardManager -import android.content.Context.KEYGUARD_SERVICE -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.provider.Settings.ACTION_BIOMETRIC_ENROLL -import android.provider.Settings.ACTION_FINGERPRINT_ENROLL -import android.provider.Settings.ACTION_SECURITY_SETTINGS -import android.provider.Settings.EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED -import android.view.View -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.result.contract.ActivityResultContracts.CreateDocument -import androidx.activity.result.contract.ActivityResultContracts.OpenDocument -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG -import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL -import androidx.biometric.BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import androidx.preference.SwitchPreference -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG -import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_SHORT -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.launch -import net.taler.common.showError -import net.taler.wallet.BuildConfig.FLAVOR -import net.taler.wallet.BuildConfig.VERSION_CODE -import net.taler.wallet.BuildConfig.VERSION_NAME -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.showError -import net.taler.wallet.withdraw.TestWithdrawStatus -import java.lang.System.currentTimeMillis - - -class SettingsFragment : PreferenceFragmentCompat() { - - private val model: MainViewModel by activityViewModels() - private val settingsManager get() = model.settingsManager - private val withdrawManager by lazy { model.withdrawManager } - private lateinit var biometricManager: BiometricManager - - private lateinit var prefDevMode: SwitchPreference - private lateinit var prefBiometricLock: SwitchPreference - private lateinit var prefWithdrawTest: Preference - private lateinit var prefLogcat: Preference - private lateinit var prefStats: Preference - private lateinit var prefExportDb: Preference - private lateinit var prefImportDb: Preference - private lateinit var prefVersionApp: Preference - private lateinit var prefVersionCore: Preference - private lateinit var prefVersionExchange: Preference - private lateinit var prefVersionMerchant: Preference - private lateinit var prefTest: Preference - private lateinit var prefReset: Preference - private val devPrefs by lazy { - listOf( - prefVersionCore, - prefWithdrawTest, - prefLogcat, - prefStats, - prefExportDb, - prefImportDb, - prefVersionExchange, - prefVersionMerchant, - prefTest, - prefReset, - ) - } - - private val biometricEnrollLauncher = registerForActivityResult( - ActivityResultContracts.StartActivityForResult(), - ) { result -> - if (result.resultCode == RESULT_OK) { - enableBiometrics(false) - } - } - - private val logLauncher = registerForActivityResult(CreateDocument("text/plain")) { uri -> - settingsManager.exportLogcat(uri) - } - private val dbExportLauncher = - registerForActivityResult(CreateDocument("application/json")) { uri -> - Snackbar.make(requireView(), getString(R.string.settings_db_export_message), LENGTH_LONG).show() - settingsManager.exportDb(uri) - } - private val dbImportLauncher = - registerForActivityResult(OpenDocument()) { uri -> - Snackbar.make(requireView(), getString(R.string.settings_db_import_message), LENGTH_LONG).show() - findNavController().navigate(R.id.action_global_main) - settingsManager.importDb(uri) - } - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - setPreferencesFromResource(R.xml.settings_main, rootKey) - prefDevMode = findPreference("pref_dev_mode")!! - prefBiometricLock = findPreference("pref_biometric_lock")!! - prefWithdrawTest = findPreference("pref_testkudos")!! - prefLogcat = findPreference("pref_logcat")!! - prefStats = findPreference("pref_stats")!! - prefExportDb = findPreference("pref_export_db")!! - prefImportDb = findPreference("pref_import_db")!! - prefVersionApp = findPreference("pref_version_app")!! - prefVersionCore = findPreference("pref_version_core")!! - prefVersionExchange = findPreference("pref_version_protocol_exchange")!! - prefVersionMerchant = findPreference("pref_version_protocol_merchant")!! - prefTest = findPreference("pref_test")!! - prefReset = findPreference("pref_reset")!! - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - biometricManager = BiometricManager.from(requireContext()) - - prefVersionApp.summary = "$VERSION_NAME ($FLAVOR $VERSION_CODE)" - prefVersionCore.summary = "${model.walletVersion} (${model.walletVersionHash?.take(7)})" - model.exchangeVersion?.let { prefVersionExchange.summary = it } - model.merchantVersion?.let { prefVersionMerchant.summary = it } - - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - settingsManager.getBiometricLockEnabled(requireContext()).collect { enabled -> - prefBiometricLock.isChecked = enabled - } - } - } - - prefBiometricLock.setOnPreferenceChangeListener { _, newValue -> - val enabled = newValue as Boolean - if (enabled) { - return@setOnPreferenceChangeListener enableBiometrics(true) - } else { - disableBiometrics() - true - } - } - - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - settingsManager.getDevModeEnabled(requireContext()).collect { enabled -> - prefDevMode.isChecked = enabled - devPrefs.forEach { it.isVisible = enabled } - } - } - } - - prefDevMode.setOnPreferenceChangeListener { _, newValue -> - settingsManager.setDevModeEnabled(requireContext(), newValue as Boolean) - true - } - - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - withdrawManager.withdrawTestStatus.collect { status -> - if (status is TestWithdrawStatus.None) return@collect - val loading = status is TestWithdrawStatus.Withdrawing - prefWithdrawTest.isEnabled = !loading - model.showProgressBar.value = loading - if (status is TestWithdrawStatus.Error) { - requireActivity().showError(R.string.withdraw_error_test, status.message) - } - withdrawManager.resetTestWithdrawal() - } - } - } - - prefWithdrawTest.setOnPreferenceClickListener { - withdrawManager.withdrawTestBalance() - Snackbar.make(requireView(), getString(R.string.settings_test_withdrawal), LENGTH_LONG).show() - findNavController().navigate(R.id.action_global_main) - true - } - - prefLogcat.setOnPreferenceClickListener { - logLauncher.launch("taler-wallet-log-${currentTimeMillis()}.txt") - true - } - prefStats.setOnPreferenceClickListener { - findNavController().navigate(R.id.action_main_to_performanceStats) - true - } - prefExportDb.setOnPreferenceClickListener { - dbExportLauncher.launch("taler-wallet-db-${currentTimeMillis()}.json") - true - } - prefImportDb.setOnPreferenceClickListener { - showImportDialog() - true - } - prefTest.setOnPreferenceClickListener { - settingsManager.runIntegrationTest { error -> - requireActivity().showError(error) - } - Snackbar.make(requireView(), getString(R.string.settings_test_running), LENGTH_LONG).show() - findNavController().navigate(R.id.action_global_main) - true - } - prefReset.setOnPreferenceClickListener { - showResetDialog() - true - } - } - - override fun onStart() { - super.onStart() - requireActivity().title = getString(R.string.menu_settings) - } - - private fun enableBiometrics(prompt: Boolean): Boolean { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - when (biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)) { - BIOMETRIC_SUCCESS -> { - settingsManager.setBiometricLockEnabled(requireContext(), true) - return true - } - - BIOMETRIC_ERROR_NONE_ENROLLED -> { - Toast.makeText( - requireContext(), - getString(R.string.biometric_auth_unavailable), - Toast.LENGTH_SHORT, - ).show() - - if (prompt) { - promptAuthEnrollment() - } - } - - else -> Toast.makeText( - requireContext(), - getString(R.string.biometric_auth_unavailable), - Toast.LENGTH_SHORT, - ).show() - } - } else { - val keyguardManager = requireContext() - .getSystemService(KEYGUARD_SERVICE) as KeyguardManager - if (keyguardManager.isDeviceSecure) { - settingsManager.setBiometricLockEnabled(requireContext(), true) - return true - } else { - Toast.makeText( - requireContext(), - getString(R.string.biometric_auth_unavailable), - Toast.LENGTH_SHORT, - ).show() - - if (prompt) { - promptAuthEnrollment() - } - } - } - - return false - } - - /** - * Prompt the user to enroll valid credentials - */ - private fun promptAuthEnrollment() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val intent = Intent(ACTION_BIOMETRIC_ENROLL).apply { - putExtra( - EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED, - BIOMETRIC_STRONG or DEVICE_CREDENTIAL - ) - } - biometricEnrollLauncher.launch(intent) - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - val intent = Intent(ACTION_FINGERPRINT_ENROLL) - biometricEnrollLauncher.launch(intent) - } else { - val intent = Intent(ACTION_SECURITY_SETTINGS) - biometricEnrollLauncher.launch(intent) - } - } - - private fun disableBiometrics() { - settingsManager.setBiometricLockEnabled(requireContext(), false) - } - - private fun showImportDialog() { - MaterialAlertDialogBuilder(requireContext(), R.style.MaterialAlertDialog_Material3) - .setMessage(R.string.settings_dialog_import_message) - .setNegativeButton(R.string.import_db) { _, _ -> - dbImportLauncher.launch(arrayOf("application/json")) - } - .setPositiveButton(R.string.cancel) { _, _ -> - Snackbar.make(requireView(), getString(R.string.settings_alert_import_canceled), LENGTH_SHORT).show() - } - .show() - } - - private fun showResetDialog() { - MaterialAlertDialogBuilder(requireContext(), R.style.MaterialAlertDialog_Material3) - .setMessage(R.string.settings_dialog_reset_message) - .setNegativeButton(R.string.reset) { _, _ -> - settingsManager.clearDb { - model.dangerouslyReset() - } - Snackbar.make(requireView(), getString(R.string.settings_alert_reset_done), LENGTH_SHORT).show() - } - .setPositiveButton(R.string.cancel) { _, _ -> - Snackbar.make(requireView(), getString(R.string.settings_alert_reset_canceled), LENGTH_SHORT).show() - } - .show() - } -} diff --git a/wallet/src/main/java/net/taler/wallet/settings/SettingsScreen.kt b/wallet/src/main/java/net/taler/wallet/settings/SettingsScreen.kt @@ -0,0 +1,477 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.settings + +import android.app.Activity.RESULT_OK +import android.app.KeyguardManager +import android.content.Context +import android.content.Context.KEYGUARD_SERVICE +import android.content.Intent +import android.os.Build +import android.provider.Settings.ACTION_BIOMETRIC_ENROLL +import android.provider.Settings.ACTION_FINGERPRINT_ENROLL +import android.provider.Settings.ACTION_SECURITY_SETTINGS +import android.provider.Settings.EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.result.contract.ActivityResultContracts.CreateDocument +import androidx.activity.result.contract.ActivityResultContracts.OpenDocument +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED +import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +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.Dns +import androidx.compose.material.icons.filled.DomainAdd +import androidx.compose.material.icons.filled.LocalAtm +import androidx.compose.material.icons.filled.VideoLibrary +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.launch +import net.taler.wallet.BuildConfig +import net.taler.wallet.NavigateCallback +import net.taler.wallet.R +import net.taler.wallet.WalletDestination +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.main.MainViewModel +import net.taler.wallet.withdraw.TestWithdrawStatus + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + model: MainViewModel, + innerPadding: PaddingValues, + snackbarHostState: SnackbarHostState, + onNavigate: NavigateCallback, + onShowError: (TalerErrorInfo) -> Unit, +) { + val context = LocalContext.current + val dbExportMessage = stringResource(R.string.settings_db_export_message) + val dbImportMessage = stringResource(R.string.settings_db_import_message) + val testWithdrawalMessage = stringResource(R.string.settings_test_withdrawal) + val importCanceledMessage = stringResource(R.string.settings_alert_import_canceled) + val testRunningMessage = stringResource(R.string.settings_test_running) + val resetDoneMessage = stringResource(R.string.settings_alert_reset_done) + val resetCanceledMessage = stringResource(R.string.settings_alert_reset_canceled) + val biometricAuthUnavailableMessage = stringResource(R.string.biometric_auth_unavailable) + val scope = rememberCoroutineScope() + val settingsManager = model.settingsManager + val withdrawManager = model.withdrawManager + + val biometricLockEnabled by settingsManager.getBiometricLockEnabled(context).collectAsState(false) + val devModeEnabled by settingsManager.getDevModeEnabled(context).collectAsState(false) + val withdrawTestStatus by withdrawManager.withdrawTestStatus.collectAsState() + + val walletVersion = model.walletVersion + val walletVersionHash = model.walletVersionHash?.take(7) + val exchangeVersion = model.exchangeVersion + val merchantVersion = model.merchantVersion + + val logLauncher = rememberLauncherForActivityResult(CreateDocument("text/plain")) { uri -> + uri?.let { settingsManager.exportLogcat(it) } + } + val dbExportLauncher = rememberLauncherForActivityResult(CreateDocument("application/json")) { uri -> + uri?.let { + scope.launch { snackbarHostState.showSnackbar(dbExportMessage) } + settingsManager.exportDb(it) + } + } + val dbImportLauncher = rememberLauncherForActivityResult(OpenDocument()) { uri -> + uri?.let { + scope.launch { snackbarHostState.showSnackbar(dbImportMessage) } + onNavigate(WalletDestination.Main, true) + settingsManager.importDb(it) + } + } + + val biometricEnrollLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + enableBiometrics(context, settingsManager, false, biometricAuthUnavailableMessage) {} + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + ) { + SettingsItem( + title = stringResource(R.string.exchange_settings_title), + summary = stringResource(R.string.exchange_settings_summary), + icon = Icons.Default.Dns, + onClick = { onNavigate(WalletDestination.ExchangeList, false) } + ) + + SettingsItem( + title = stringResource(R.string.settings_bank_accounts), + summary = stringResource(R.string.settings_bank_accounts_summary), + icon = Icons.Default.AccountBalance, + onClick = { onNavigate(WalletDestination.BankAccounts(), false) } + ) + + SettingsItem( + title = stringResource(R.string.settings_donau), + summary = stringResource(R.string.settings_donau_summary), + icon = ImageVector.vectorResource(R.drawable.ic_donau), + onClick = { onNavigate(WalletDestination.SetDonau(), false) } + ) + + SettingsSwitchItem( + title = stringResource(R.string.settings_lock_auth), + summary = stringResource(R.string.settings_lock_auth_summary), + icon = ImageVector.vectorResource(R.drawable.ic_shield), + checked = biometricLockEnabled, + onCheckedChange = { enabled -> + if (enabled) { + enableBiometrics(context, settingsManager, true, biometricAuthUnavailableMessage) { + biometricEnrollLauncher.launch(it) + } + } else { + settingsManager.setBiometricLockEnabled(context, false) + } + } + ) + + SettingsSwitchItem( + title = stringResource(R.string.settings_dev_mode), + summary = stringResource(R.string.settings_dev_mode_summary), + icon = ImageVector.vectorResource(R.drawable.ic_developer_mode), + checked = devModeEnabled, + onCheckedChange = { enabled -> + settingsManager.setDevModeEnabled(context, enabled) + } + ) + + if (devModeEnabled) { + HorizontalDivider() + + SettingsItem( + title = stringResource(R.string.exchange_list_add_dev), + icon = Icons.Default.DomainAdd, + onClick = { + model.exchangeManager.addDevExchanges() + onNavigate(WalletDestination.ExchangeList, false) + } + ) + + SettingsItem( + title = stringResource(R.string.settings_withdraw_testkudos), + summary = stringResource(R.string.settings_withdraw_testkudos_summary), + icon = Icons.Default.LocalAtm, + enabled = withdrawTestStatus !is TestWithdrawStatus.Withdrawing, + onClick = { + withdrawManager.withdrawTestBalance() + scope.launch { snackbarHostState.showSnackbar(testWithdrawalMessage) } + onNavigate(WalletDestination.Main, true) + } + ) + + SettingsItem( + title = stringResource(R.string.settings_logcat), + summary = stringResource(R.string.settings_logcat_summary), + icon = ImageVector.vectorResource(R.drawable.ic_bug_report), + onClick = { logLauncher.launch("taler-wallet-log-${System.currentTimeMillis()}.txt") } + ) + + SettingsItem( + title = stringResource(R.string.settings_stats), + summary = stringResource(R.string.settings_stats_summary), + icon = ImageVector.vectorResource(R.drawable.ic_stats), + onClick = { onNavigate(WalletDestination.PerformanceStats, false) } + ) + + SettingsItem( + title = stringResource(R.string.settings_db_export), + summary = stringResource(R.string.settings_db_export_summary), + icon = ImageVector.vectorResource(R.drawable.ic_unarchive), + onClick = { dbExportLauncher.launch("taler-wallet-db-${System.currentTimeMillis()}.json") } + ) + + SettingsItem( + title = stringResource(R.string.settings_db_import), + summary = stringResource(R.string.settings_db_import_summary), + icon = ImageVector.vectorResource(R.drawable.ic_archive), + onClick = { + MaterialAlertDialogBuilder(context) + .setMessage(R.string.settings_dialog_import_message) + .setNegativeButton(R.string.import_db) { _, _ -> + dbImportLauncher.launch(arrayOf("application/json")) + } + .setPositiveButton(R.string.cancel) { _, _ -> + scope.launch { snackbarHostState.showSnackbar(importCanceledMessage) } + } + .show() + } + ) + + SettingsItem( + title = stringResource(R.string.settings_version_app), + summary = "${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR} ${BuildConfig.VERSION_CODE})", + icon = ImageVector.vectorResource(R.drawable.ic_account_balance_wallet), + ) + + SettingsItem( + title = stringResource(R.string.settings_version_core), + summary = "$walletVersion ($walletVersionHash)", + icon = ImageVector.vectorResource(R.drawable.ic_adjust), + ) + + if (exchangeVersion != null) { + SettingsItem( + title = stringResource(R.string.settings_version_protocol_exchange), + summary = exchangeVersion, + icon = ImageVector.vectorResource(R.drawable.ic_account_balance), + ) + } + + if (merchantVersion != null) { + SettingsItem( + title = stringResource(R.string.settings_version_protocol_merchant), + summary = merchantVersion, + icon = ImageVector.vectorResource(R.drawable.ic_store_mall), + ) + } + + SettingsItem( + title = stringResource(R.string.settings_test), + summary = stringResource(R.string.settings_test_summary), + icon = Icons.Default.VideoLibrary, + onClick = { + settingsManager.runIntegrationTest { onShowError(it) } + scope.launch { snackbarHostState.showSnackbar(testRunningMessage) } + onNavigate(WalletDestination.Main, true) + } + ) + + SettingsItem( + title = stringResource(R.string.settings_reset), + summary = stringResource(R.string.settings_reset_summary), + icon = ImageVector.vectorResource(R.drawable.ic_nuke), + onClick = { + MaterialAlertDialogBuilder(context) + .setMessage(R.string.settings_dialog_reset_message) + .setNegativeButton(R.string.reset) { _, _ -> + settingsManager.clearDb { + model.dangerouslyReset() + } + scope.launch { snackbarHostState.showSnackbar(resetDoneMessage) } + } + .setPositiveButton(R.string.cancel) { _, _ -> + scope.launch { snackbarHostState.showSnackbar(resetCanceledMessage) } + } + .show() + } + ) + } + } +} + +@Composable +fun SettingsItem( + title: String, + summary: String? = null, + icon: ImageVector? = null, + enabled: Boolean = true, + onClick: (() -> Unit)? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled && onClick != null) { onClick?.invoke() } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + } + Column( + modifier = Modifier + .padding(start = if (icon != null) 24.dp else 0.dp) + .weight(1f) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + modifier = Modifier.padding(bottom = 3.dp), + ) + if (summary != null) { + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + ) + } + } + } +} + +@Composable +fun SettingsSwitchItem( + title: String, + summary: String? = null, + icon: ImageVector? = null, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + Column( + modifier = Modifier + .padding(start = if (icon != null) 24.dp else 0.dp) + .weight(1f) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 3.dp), + ) + if (summary != null) { + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Switch( + modifier = Modifier.padding(start = 16.dp), + checked = checked, + onCheckedChange = onCheckedChange + ) + } +} + +private fun enableBiometrics( + context: Context, + settingsManager: SettingsManager, + prompt: Boolean, + biometricAuthUnavailableMessage: String, + onPromptEnrollment: (Intent) -> Unit, +): Boolean { + val biometricManager = BiometricManager.from(context) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + when (biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)) { + BIOMETRIC_SUCCESS -> { + settingsManager.setBiometricLockEnabled(context, true) + return true + } + + BIOMETRIC_ERROR_NONE_ENROLLED -> { + Toast.makeText( + context, + biometricAuthUnavailableMessage, + Toast.LENGTH_SHORT, + ).show() + + if (prompt) { + promptAuthEnrollment(onPromptEnrollment) + } + } + + else -> Toast.makeText( + context, + biometricAuthUnavailableMessage, + Toast.LENGTH_SHORT, + ).show() + } + } else { + val keyguardManager = context.getSystemService(KEYGUARD_SERVICE) as KeyguardManager + if (keyguardManager.isDeviceSecure) { + settingsManager.setBiometricLockEnabled(context, true) + return true + } else { + Toast.makeText( + context, + biometricAuthUnavailableMessage, + Toast.LENGTH_SHORT, + ).show() + + if (prompt) { + promptAuthEnrollment(onPromptEnrollment) + } + } + } + + return false +} + +private fun promptAuthEnrollment(onPromptEnrollment: (Intent) -> Unit) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val intent = Intent(ACTION_BIOMETRIC_ENROLL).apply { + putExtra( + EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED, + BIOMETRIC_STRONG or DEVICE_CREDENTIAL + ) + } + onPromptEnrollment(intent) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val intent = Intent(ACTION_FINGERPRINT_ENROLL) + onPromptEnrollment(intent) + } else { + val intent = Intent(ACTION_SECURITY_SETTINGS) + onPromptEnrollment(intent) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDepositFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDepositFragment.kt @@ -1,67 +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.transactions - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.ComposeView -import androidx.core.os.bundleOf -import androidx.navigation.fragment.findNavController -import net.taler.wallet.R -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.deposit.TransactionDepositComposable - -class TransactionDepositFragment : TransactionDetailFragment() { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = ComposeView(requireContext()).apply { - setContent { - TalerSurface { - val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() - val tx = remember(t) { t } - if (tx is TransactionDeposit) TransactionDepositComposable( - t = tx, - devMode = devMode, - spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), - onWireTransfer = { onConfirmManual() }, - onShowQrCodes = { onShowQrCodes() }, - ) { - onTransitionButtonClicked(tx, it) - } - } - } - } - - fun onConfirmManual(showQrCodes: Boolean = false) { - findNavController().navigate( - R.id.action_global_wireTransferDetails, - bundleOf("showQrCodes" to showQrCodes) - ) - } - - fun onShowQrCodes() { - onConfirmManual(showQrCodes = true) - } -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailFragment.kt @@ -1,185 +0,0 @@ -/* - * 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/> - */ - -package net.taler.wallet.transactions - -import android.os.Bundle -import android.util.Log -import android.view.View -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.launch -import net.taler.common.showError -import net.taler.wallet.R -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.main.TAG -import net.taler.wallet.showError -import net.taler.wallet.transactions.TransactionAction.Abort -import net.taler.wallet.transactions.TransactionAction.Delete -import net.taler.wallet.transactions.TransactionAction.Fail -import net.taler.wallet.transactions.TransactionAction.Resume -import net.taler.wallet.transactions.TransactionAction.Retry -import net.taler.wallet.transactions.TransactionAction.Suspend - -abstract class TransactionDetailFragment : Fragment() { - - private val model: MainViewModel by activityViewModels() - protected val transactionManager by lazy { model.transactionManager } - protected val balanceManager by lazy { model.balanceManager } - protected val exchangeManager by lazy { model.exchangeManager } - protected val withdrawManager by lazy { model.withdrawManager } - protected val devMode get() = model.devMode.value == true - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - transactionManager.selectedTransaction.collect { - requireActivity().apply { - it?.generalTitleRes?.let { - title = getString(it) - } - } - } - } - } - } - - override fun onDestroy() { - super.onDestroy() - transactionManager.selectTransaction(null) - } - - private fun dialogTitle(t: TransactionAction): Int = when (t) { - Delete -> R.string.transactions_delete_dialog_title - Abort -> R.string.transactions_abort_dialog_title - Fail -> R.string.transactions_fail_dialog_title - else -> error("unsupported action: $t") - } - - private fun dialogMessage(t: TransactionAction): Int = when (t) { - Delete -> R.string.transactions_delete_dialog_message - Abort -> R.string.transactions_abort_dialog_message - Fail -> R.string.transactions_fail_dialog_message - else -> error("unsupported action: $t") - } - - private fun dialogButton(t: TransactionAction): Int = when (t) { - Delete -> R.string.transactions_delete - Abort -> R.string.transactions_abort - Fail -> R.string.transactions_fail - else -> error("unsupported") - } - - protected fun onTransitionButtonClicked(t: Transaction, ta: TransactionAction) = when (ta) { - Delete -> showDialog(ta) { deleteTransaction(t) } - Abort -> showDialog(ta) { abortTransaction(t) } - Fail -> showDialog(ta) { failTransaction(t) } - Retry -> retryTransaction(t) - Suspend -> suspendTransaction(t) - Resume -> resumeTransaction(t) - } - - private fun showDialog(tt: TransactionAction, onAction: () -> Unit) { - MaterialAlertDialogBuilder(requireContext(), R.style.MaterialAlertDialog_Material3) - .setTitle(dialogTitle(tt)) - .setMessage(dialogMessage(tt)) - .setNeutralButton(R.string.cancel) { dialog, _ -> - dialog.cancel() - } - .setNegativeButton(dialogButton(tt)) { dialog, _ -> - onAction() - dialog.dismiss() - } - .show() - } - - private fun deleteTransaction(t: Transaction) { - transactionManager.deleteTransaction(t.transactionId) { - Log.e(TAG, "Error deleteTransaction $it") - if (model.devMode.value == true) { - showError(it) - } else { - showError(it.userFacingMsg) - } - } - findNavController().popBackStack() - } - - private fun retryTransaction(t: Transaction) { - transactionManager.retryTransaction(t.transactionId) { - Log.e(TAG, "Error retryTransaction $it") - if (model.devMode.value == true) { - showError(it) - } else { - showError(it.userFacingMsg) - } - } - } - - private fun abortTransaction(t: Transaction) { - transactionManager.abortTransaction( - t.transactionId, - onSuccess = {}, - onError = { - Log.e(TAG, "Error abortTransaction $it") - if (model.devMode.value == true) { - showError(it) - } else { - showError(it.userFacingMsg) - } - } - ) - } - - private fun failTransaction(t: Transaction) { - transactionManager.failTransaction(t.transactionId) { - Log.e(TAG, "Error failTransaction $it") - if (model.devMode.value == true) { - showError(it) - } else { - showError(it.userFacingMsg) - } - } - } - - private fun suspendTransaction(t: Transaction) { - transactionManager.suspendTransaction(t.transactionId) { - Log.e(TAG, "Error suspendTransaction $it") - if (model.devMode.value == true) { - showError(it) - } else { - showError(it.userFacingMsg) - } - } - } - - private fun resumeTransaction(t: Transaction) { - transactionManager.resumeTransaction(t.transactionId) { - Log.e(TAG, "Error resumeTransaction $it") - if (model.devMode.value == true) { - showError(it) - } else { - showError(it.userFacingMsg) - } - } - } -} diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailScreen.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailScreen.kt @@ -0,0 +1,535 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.transactions + +import android.content.Context +import android.util.Log +import androidx.compose.foundation.basicMarquee +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.ContentCopy +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.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +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.compose.ui.unit.sp +import net.taler.common.Amount +import net.taler.common.CurrencySpecification +import net.taler.common.Timestamp +import net.taler.common.copyToClipBoard +import net.taler.common.toAbsoluteTime +import net.taler.wallet.BottomInsetsSpacer +import net.taler.wallet.NavigateCallback +import net.taler.wallet.R +import net.taler.wallet.WalletDestination +import net.taler.wallet.backend.TalerErrorCode +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.balances.ScopeInfo +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.deposit.TransactionDepositComposable +import net.taler.wallet.launchInAppBrowser +import net.taler.wallet.main.MainViewModel +import net.taler.wallet.main.TAG +import net.taler.wallet.payment.TransactionPaymentComposable +import net.taler.wallet.peer.TransactionPeerPullCreditComposable +import net.taler.wallet.peer.TransactionPeerPullDebitComposable +import net.taler.wallet.peer.TransactionPeerPushCreditComposable +import net.taler.wallet.peer.TransactionPeerPushDebitComposable +import net.taler.wallet.refund.TransactionRefundComposable +import net.taler.wallet.withdraw.TransactionWithdrawalComposable + +@Composable +fun TransactionDetailScreen( + model: MainViewModel, + destination: WalletDestination, + onNavigate: NavigateCallback, + onNavigateBack: () -> Unit, +) { + val transactionManager = model.transactionManager + val exchangeManager = model.exchangeManager + val withdrawManager = model.withdrawManager + val devMode by model.devMode.observeAsState(false) + val context = LocalContext.current + var keepSelectedTx: Boolean by remember { mutableStateOf(false) } + + DisposableEffect(keepSelectedTx) { + onDispose { + if (!keepSelectedTx) { + transactionManager.selectTransaction(null) + } + } + } + + GlobalScaffold( + model = model, + title = { + val title = when (destination) { + is WalletDestination.TransactionPayment -> stringResource(R.string.transaction_order) + is WalletDestination.TransactionWithdrawal -> stringResource(R.string.withdraw_title) + is WalletDestination.TransactionDeposit -> stringResource(R.string.transaction_deposit) + is WalletDestination.TransactionRefund -> stringResource(R.string.transaction_refund) + is WalletDestination.TransactionRefresh -> stringResource(R.string.transaction_refresh) + is WalletDestination.TransactionPeer -> stringResource(R.string.transaction_peer_push_debit) // Approximation + is WalletDestination.TransactionLoss -> stringResource(R.string.transaction_denom_loss) + else -> stringResource(R.string.transactions_detail_title) + } + Text(title) + }, + onNavigateBack = onNavigateBack, + ) { paddingValues -> + val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() + val modifier = Modifier.padding(paddingValues) + when (destination) { + is WalletDestination.TransactionPayment -> { + (t as? TransactionPayment)?.let { tx -> + TransactionPaymentComposable( + modifier = modifier, + t = tx, + devMode = devMode, + spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), + onFulfill = { url -> + launchInAppBrowser(context, url) + }, + onTransition = { action -> + handleTransactionAction(tx, action, model, onNavigateBack) + } + ) + } + } + is WalletDestination.TransactionWithdrawal -> { + (t as? TransactionWithdrawal)?.let { tx -> + val qrCode = remember(tx) { + (tx.withdrawalDetails as? WithdrawalDetails.ManualTransfer)?.let { details -> + if (details.exchangeCreditAccountDetails?.size == 1) { + val account0 = details.exchangeCreditAccountDetails[0] + val qrCodes = withdrawManager.getQrCodesForPayto(account0.paytoUri) + if (qrCodes.size == 1) qrCodes[0] + else null + } else null + } + } + + TransactionWithdrawalComposable( + modifier = modifier, + t = tx, + devMode = devMode, + spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), + qrCode = qrCode, + onConfirmKyc = { url -> + launchInAppBrowser(context, url) + }, + onConfirmBank = { + if (tx.withdrawalDetails is WithdrawalDetails.TalerBankIntegrationApi) { + tx.withdrawalDetails.bankConfirmationUrl?.let { url -> + launchInAppBrowser(context, url) + } + } + }, + onConfirmManual = { + keepSelectedTx = true + onNavigate(WalletDestination.WireTransferDetails(false), false) + }, + onShowQrCodes = { + keepSelectedTx = true + onNavigate(WalletDestination.WireTransferDetails(true), false) + }, + onTransition = { action -> + handleTransactionAction(tx, action, model, onNavigateBack) + } + ) + } + } + is WalletDestination.TransactionDeposit -> { + (t as? TransactionDeposit)?.let { tx -> + TransactionDepositComposable( + modifier = modifier, + t = tx, + devMode = devMode, + spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), + onWireTransfer = { onNavigate(WalletDestination.WireTransferDetails(false), false) }, + onShowQrCodes = { onNavigate(WalletDestination.WireTransferDetails(true), false) }, + onTransition = { + handleTransactionAction(tx, it, model, onNavigateBack) + }, + ) + } + } + is WalletDestination.TransactionRefund -> { + (t as? TransactionRefund)?.let { tx -> + TransactionRefundComposable( + modifier = modifier, + t = tx, + devMode = devMode, + spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), + onTransition = { + handleTransactionAction(tx, it, model, onNavigateBack) + }, + ) + } + } + is WalletDestination.TransactionRefresh -> { + (t as? TransactionRefresh)?.let { tx -> + TransactionRefreshComposable( + modifier = modifier, + t = tx, + devMode = devMode, + spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), + ) { + handleTransactionAction(tx, it, model, onNavigateBack) + } + } + } + is WalletDestination.TransactionPeer -> { + val tx = t + if (tx != null) { + TransactionPeerComposable( + modifier = modifier, + t = tx, + devMode = devMode, + spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), + onConfirmKyc = { url -> launchInAppBrowser(context, url) } + ) { + handleTransactionAction(tx, it, model, onNavigateBack) + } + } + } + is WalletDestination.TransactionLoss -> { + (t as? TransactionDenomLoss)?.let { tx -> + TransitionLossComposable( + modifier = modifier, + t = tx, + devMode = devMode, + spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes) + ) { + handleTransactionAction(tx, it, model, onNavigateBack) + } + } + } + is WalletDestination.TransactionDummy -> { + (t as? DummyTransaction)?.let { tx -> + TransactionDummyComposable( + modifier = modifier, + t = tx, + ) + } + } + else -> {} + } + } +} + +@Composable +fun TransactionRefreshComposable( + t: TransactionRefresh, + devMode: Boolean, + spec: CurrencySpecification?, + modifier: Modifier = Modifier, + onTransition: (t: TransactionAction) -> Unit, +) { + val scrollState = rememberScrollState() + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TransactionStateComposable(state = t.txState) + + Text( + modifier = Modifier.padding(16.dp), + text = t.timestamp.ms.toAbsoluteTime(LocalContext.current).toString(), + style = MaterialTheme.typography.bodyLarge, + ) + + TransactionAmountComposable( + label = stringResource(id = R.string.amount_fee), + amount = t.amountEffective.withSpec(spec), + amountType = AmountType.Negative, + ) + + TransitionsComposable(t, devMode, onTransition) + t.error?.let { error -> + if (devMode) { + ErrorTransactionButton(error = error) + } + } + + BottomInsetsSpacer() + } +} + +@Composable +fun TransactionPeerComposable( + t: Transaction, + devMode: Boolean, + spec: CurrencySpecification?, + modifier: Modifier = Modifier, + onConfirmKyc: (url: String) -> Unit, + onTransition: (t: TransactionAction) -> Unit, +) { + val scrollState = rememberScrollState() + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TransactionStateComposable(state = t.txState) + + Text( + modifier = Modifier.padding(16.dp), + text = t.timestamp.ms.toAbsoluteTime(LocalContext.current).toString(), + style = MaterialTheme.typography.bodyLarge, + ) + + when (t) { + is TransactionPeerPullCredit -> TransactionPeerPullCreditComposable(t, spec, onConfirmKyc) + is TransactionPeerPushCredit -> TransactionPeerPushCreditComposable(t, spec, onConfirmKyc) + is TransactionPeerPullDebit -> TransactionPeerPullDebitComposable(t, spec) + is TransactionPeerPushDebit -> TransactionPeerPushDebitComposable(t, spec) + else -> {} + } + + TransitionsComposable(t, devMode, onTransition) + + t.error?.let { error -> + if (devMode) { + ErrorTransactionButton(error = error) + } + } + + BottomInsetsSpacer() + } +} + +@Composable +fun TransitionLossComposable( + t: TransactionDenomLoss, + devMode: Boolean, + spec: CurrencySpecification?, + modifier: Modifier = Modifier, + onTransition: (t: TransactionAction) -> Unit, +) { + val scrollState = rememberScrollState() + + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TransactionStateComposable(state = t.txState) + + Text( + modifier = Modifier.padding(16.dp), + text = t.timestamp.ms.toAbsoluteTime(LocalContext.current).toString(), + style = MaterialTheme.typography.bodyLarge, + ) + + TransactionAmountComposable( + label = stringResource(id = R.string.amount_lost), + amount = t.amountEffective.withSpec(spec), + amountType = AmountType.Negative, + ) + + TransactionInfoComposable( + label = stringResource(id = R.string.loss_reason), + info = stringResource( + when(t.lossEventType) { + LossEventType.DenomExpired -> R.string.loss_reason_expired + LossEventType.DenomVanished -> R.string.loss_reason_vanished + LossEventType.DenomUnoffered -> R.string.loss_reason_unoffered + } + ) + ) + + TransitionsComposable(t, devMode, onTransition) + + t.error?.let { error -> + if (devMode) { + ErrorTransactionButton(error = error) + } + } + + BottomInsetsSpacer() + } +} + +@Composable +fun TransactionDummyComposable( + t: DummyTransaction, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + Column( + modifier = modifier + .fillMaxWidth() + .padding(16.dp) + .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ErrorTransactionButton(error = t.error) + BottomInsetsSpacer() + } +} + +@Composable +fun TransactionAmountComposable( + label: String, + amount: Amount, + amountType: AmountType, + context: Context? = null, + copy: Boolean = false, +) { + Text( + modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), + text = label, + style = MaterialTheme.typography.bodyMedium, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .padding( + top = 8.dp, + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ).weight(1f), + text = amount.toString(negative = amountType == AmountType.Negative), + textAlign = TextAlign.Center, + fontSize = 24.sp, + color = when (amountType) { + AmountType.Positive -> colorResource(R.color.green) + AmountType.Negative -> MaterialTheme.colorScheme.error + AmountType.Neutral -> Color.Unspecified + }, + ) + + if (copy) { + TextButton( + onClick = { context?.let { + copyToClipBoard(context, label, amount.toString(showSymbol = false)) + } }, + ) { + Icon( + Icons.Default.ContentCopy, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.copy)) + } + } + } +} + +@Composable +fun TransactionInfoComposable( + label: String, + info: String, + marquee: Boolean = false, + trailing: (@Composable () -> Unit)? = null, +) { + Text( + modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), + text = label, + style = MaterialTheme.typography.bodyMedium, + ) + + Row( + modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = if (marquee) Modifier.basicMarquee() else Modifier, + text = info, + fontSize = 24.sp, + ) + + trailing?.let { it() } + } +} + +private fun handleTransactionAction( + tx: Transaction, + action: TransactionAction, + model: MainViewModel, + onNavigateBack: () -> Unit, +) { + val transactionManager = model.transactionManager + val onError: (TalerErrorInfo) -> Unit = { error -> + Log.e(TAG, "Error handling transaction action $action: $error") + } + + when (action) { + TransactionAction.Delete -> transactionManager.deleteTransaction(tx.transactionId) { onNavigateBack() } + TransactionAction.Retry -> transactionManager.retryTransaction(tx.transactionId, onError) + TransactionAction.Abort -> transactionManager.abortTransaction(tx.transactionId, { onNavigateBack() }, onError) + TransactionAction.Fail -> transactionManager.failTransaction(tx.transactionId, onError) + TransactionAction.Suspend -> transactionManager.suspendTransaction(tx.transactionId, onError) + TransactionAction.Resume -> transactionManager.resumeTransaction(tx.transactionId, onError) + } +} + +@Preview +@Composable +fun TransactionRefreshComposablePreview() { + val t = TransactionRefresh( + transactionId = "transactionId", + timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), + txState = TransactionState(TransactionMajorState.Pending), + txActions = listOf(TransactionAction.Retry, TransactionAction.Suspend, TransactionAction.Abort), + amountRaw = Amount.fromString("TESTKUDOS", "42.23"), + amountEffective = Amount.fromString("TESTKUDOS", "42.1337"), + error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED), + scopes = listOf(ScopeInfo.Exchange( + currency = "TESTKUDOS", + url = "exchange.test.taler.net", + )) + ) + Surface { + TransactionRefreshComposable(t, true, null) {} + } +} diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDummyFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDummyFragment.kt @@ -1,67 +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.transactions - -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment.Companion.CenterHorizontally -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.unit.dp -import net.taler.wallet.BottomInsetsSpacer -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.compose.collectAsStateLifecycleAware - -class TransactionDummyFragment : TransactionDetailFragment() { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = ComposeView(requireContext()).apply { - setContent { - TalerSurface { - val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() - (t as? DummyTransaction)?.let { TransactionDummyComposable(it) } - } - } - } -} - -@Composable -fun TransactionDummyComposable(t: DummyTransaction) { - val scrollState = rememberScrollState() - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .verticalScroll(scrollState), - horizontalAlignment = CenterHorizontally, - ) { - ErrorTransactionButton(error = t.error) - BottomInsetsSpacer() - } -} diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt @@ -1,176 +0,0 @@ -/* - * 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.transactions - -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -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.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import net.taler.common.Amount -import net.taler.common.CurrencySpecification -import net.taler.common.Timestamp -import net.taler.common.toAbsoluteTime -import net.taler.wallet.BottomInsetsSpacer -import net.taler.wallet.R -import net.taler.wallet.backend.TalerErrorCode -import net.taler.wallet.backend.TalerErrorInfo -import net.taler.wallet.balances.ScopeInfo -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.transactions.LossEventType.DenomExpired -import net.taler.wallet.transactions.LossEventType.DenomUnoffered -import net.taler.wallet.transactions.LossEventType.DenomVanished -import net.taler.wallet.transactions.TransactionAction.Abort -import net.taler.wallet.transactions.TransactionAction.Retry -import net.taler.wallet.transactions.TransactionAction.Suspend -import net.taler.wallet.transactions.TransactionMajorState.Pending - -class TransactionLossFragment: TransactionDetailFragment() { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = ComposeView(requireContext()).apply { - setContent { - val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() - - TalerSurface { - (t as? TransactionDenomLoss)?.let { tx -> - val spec = remember(tx.amountRaw.currency, tx.scopes) { - exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes) - } - TransitionLossComposable(tx, devMode, spec) { - onTransitionButtonClicked(tx, it) - } - } - } - } - } -} - -@Composable -fun TransitionLossComposable( - t: TransactionDenomLoss, - devMode: Boolean, - spec: CurrencySpecification?, - onTransition: (t: TransactionAction) -> Unit, -) { - val scrollState = rememberScrollState() - val context = LocalContext.current - - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(scrollState), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - TransactionStateComposable(state = t.txState) - - Text( - modifier = Modifier.padding(16.dp), - text = t.timestamp.ms.toAbsoluteTime(context).toString(), - style = MaterialTheme.typography.bodyLarge, - ) - - TransactionAmountComposable( - label = stringResource(id = R.string.amount_lost), - amount = t.amountEffective.withSpec(spec), - amountType = AmountType.Negative, - ) - - TransactionInfoComposable( - label = stringResource(id = R.string.loss_reason), - info = stringResource( - when(t.lossEventType) { - DenomExpired -> R.string.loss_reason_expired - DenomVanished -> R.string.loss_reason_vanished - DenomUnoffered -> R.string.loss_reason_unoffered - } - ) - ) - - TransitionsComposable(t, devMode, onTransition) - - if (devMode && t.error != null) { - ErrorTransactionButton(error = t.error) - } - - BottomInsetsSpacer() - } -} - -fun previewLossTransaction(lossEventType: LossEventType) = - TransactionDenomLoss( - transactionId = "transactionId", - timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), - txState = TransactionState(Pending), - txActions = listOf(Retry, Suspend, Abort), - amountRaw = Amount.fromString("TESTKUDOS", "0.3"), - amountEffective = Amount.fromString("TESTKUDOS", "0.3"), - error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED), - lossEventType = lossEventType, - scopes = listOf(ScopeInfo.Exchange( - currency = "TESTKUDOS", - url = "exchange.test.taler.net", - )) - ) - -@Composable -@Preview -fun TransitionLossComposableExpiredPreview() { - val t = previewLossTransaction(DenomExpired) - Surface { - TransitionLossComposable(t, true, null) {} - } -} - -@Composable -@Preview -fun TransitionLossComposableVanishedPreview() { - val t = previewLossTransaction(DenomVanished) - Surface { - TransitionLossComposable(t, true, null) {} - } -} - -@Composable -@Preview -fun TransactionLossComposableUnofferedPreview() { - val t = previewLossTransaction(DenomUnoffered) - Surface { - TransitionLossComposable(t, true, null) {} - } -} diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt @@ -177,7 +177,6 @@ class TransactionManager( /** * Returns true if given [transactionId] was found and selected, false otherwise. */ - @UiThread suspend fun selectTransaction(transactionId: String): Boolean { val transaction = getTransactionById(transactionId) if (transaction != null) { diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionPaymentFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionPaymentFragment.kt @@ -1,54 +0,0 @@ -/* - * 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/> - */ - -package net.taler.wallet.transactions - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.getValue -import androidx.compose.ui.platform.ComposeView -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.launchInAppBrowser -import net.taler.wallet.payment.TransactionPaymentComposable - -class TransactionPaymentFragment : TransactionDetailFragment() { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = ComposeView(requireContext()).apply { - setContent { - TalerSurface { - val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() - (t as? TransactionPayment)?.let { tx -> - TransactionPaymentComposable(tx, devMode, - exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), - onFulfill = { url -> - launchInAppBrowser(requireContext(), url) - }, - onTransition = { - onTransitionButtonClicked(tx, it) - } - ) - } - } - } - } -} diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionPeerFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionPeerFragment.kt @@ -1,234 +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.transactions - -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.basicMarquee -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.ContentCopy -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Alignment.Companion.CenterHorizontally -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import kotlinx.coroutines.launch -import net.taler.common.Amount -import net.taler.common.CurrencySpecification -import net.taler.common.copyToClipBoard -import net.taler.common.toAbsoluteTime -import net.taler.wallet.BottomInsetsSpacer -import net.taler.wallet.R -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.launchInAppBrowser -import net.taler.wallet.peer.TransactionPeerPullCreditComposable -import net.taler.wallet.peer.TransactionPeerPullDebitComposable -import net.taler.wallet.peer.TransactionPeerPushCreditComposable -import net.taler.wallet.peer.TransactionPeerPushDebitComposable - -class TransactionPeerFragment : TransactionDetailFragment() { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = ComposeView(requireContext()).apply { - setContent { - TalerSurface { - val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() - t?.let { tx -> - TransactionPeerComposable( - tx, devMode, - exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), - onConfirmKyc = { onConfirmKyc(it) }, - ) { - onTransitionButtonClicked(tx, it) - } - } - } - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - transactionManager.selectedTransaction.collect { tx -> - val actionBar = (requireActivity() as? AppCompatActivity) - ?.supportActionBar - ?: return@collect - actionBar.title = tx?.getTitle(requireContext()) - } - } - } - } - - fun onConfirmKyc(url: String) { - launchInAppBrowser(requireContext(), url) - } -} - -@Composable -fun TransactionPeerComposable( - t: Transaction, - devMode: Boolean, - spec: CurrencySpecification?, - onConfirmKyc: (url: String) -> Unit, - onTransition: (t: TransactionAction) -> Unit, -) { - val scrollState = rememberScrollState() - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(scrollState), - horizontalAlignment = CenterHorizontally, - ) { - val context = LocalContext.current - - TransactionStateComposable(state = t.txState) - - Text( - modifier = Modifier.padding(16.dp), - text = t.timestamp.ms.toAbsoluteTime(context).toString(), - style = MaterialTheme.typography.bodyLarge, - ) - - when (t) { - is TransactionPeerPullCredit -> TransactionPeerPullCreditComposable(t, spec, onConfirmKyc) - is TransactionPeerPushCredit -> TransactionPeerPushCreditComposable(t, spec, onConfirmKyc) - is TransactionPeerPullDebit -> TransactionPeerPullDebitComposable(t, spec) - is TransactionPeerPushDebit -> TransactionPeerPushDebitComposable(t, spec) - else -> error("unexpected transaction: ${t::class.simpleName}") - } - - TransitionsComposable(t, devMode, onTransition) - - if (devMode && t.error != null) { - ErrorTransactionButton(error = t.error!!) - } - - BottomInsetsSpacer() - } -} - -@Composable -fun TransactionAmountComposable( - label: String, - amount: Amount, - amountType: AmountType, - context: Context? = null, - copy: Boolean = false, -) { - Text( - modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), - text = label, - style = MaterialTheme.typography.bodyMedium, - ) - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = Modifier - .padding( - top = 8.dp, - start = 16.dp, - end = 16.dp, - bottom = 16.dp, - ).weight(1f), - text = amount.toString(negative = amountType == AmountType.Negative), - textAlign = TextAlign.Center, - fontSize = 24.sp, - color = when (amountType) { - AmountType.Positive -> colorResource(R.color.green) - AmountType.Negative -> MaterialTheme.colorScheme.error - AmountType.Neutral -> Color.Unspecified - }, - ) - - if (copy) { - TextButton( - onClick = { context?.let { - copyToClipBoard(context, label, amount.toString(showSymbol = false)) - } }, - ) { - Icon( - Icons.Default.ContentCopy, - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize), - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.copy)) - } - } - } -} - -@Composable -fun TransactionInfoComposable( - label: String, - info: String, - marquee: Boolean = false, - trailing: (@Composable () -> Unit)? = null, -) { - Text( - modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), - text = label, - style = MaterialTheme.typography.bodyMedium, - ) - - Row( - modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = if (marquee) Modifier.basicMarquee() else Modifier, - text = info, - fontSize = 24.sp, - ) - - trailing?.let { it() } - } -} diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefreshFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefreshFragment.kt @@ -1,136 +0,0 @@ -/* - * 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/> - */ - -package net.taler.wallet.transactions - -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -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.ui.Alignment.Companion.CenterHorizontally -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import net.taler.common.Amount -import net.taler.common.CurrencySpecification -import net.taler.common.Timestamp -import net.taler.common.toAbsoluteTime -import net.taler.wallet.BottomInsetsSpacer -import net.taler.wallet.R -import net.taler.wallet.backend.TalerErrorCode -import net.taler.wallet.backend.TalerErrorInfo -import net.taler.wallet.balances.ScopeInfo -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.transactions.TransactionAction.Abort -import net.taler.wallet.transactions.TransactionAction.Retry -import net.taler.wallet.transactions.TransactionAction.Suspend -import net.taler.wallet.transactions.TransactionMajorState.Pending - -class TransactionRefreshFragment : TransactionDetailFragment() { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = ComposeView(requireContext()).apply { - setContent { - TalerSurface { - val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() - (t as? TransactionRefresh)?.let { tx -> - TransactionRefreshComposable(tx, devMode, - exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), - ) { - onTransitionButtonClicked(tx, it) - } - } - } - } - } -} - -@Composable -private fun TransactionRefreshComposable( - t: TransactionRefresh, - devMode: Boolean, - spec: CurrencySpecification?, - onTransition: (t: TransactionAction) -> Unit, -) { - val scrollState = rememberScrollState() - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(scrollState), - horizontalAlignment = CenterHorizontally, - ) { - val context = LocalContext.current - - TransactionStateComposable(state = t.txState) - - Text( - modifier = Modifier.padding(16.dp), - text = t.timestamp.ms.toAbsoluteTime(context).toString(), - style = MaterialTheme.typography.bodyLarge, - ) - - TransactionAmountComposable( - label = stringResource(id = R.string.amount_fee), - amount = t.amountEffective.withSpec(spec), - amountType = AmountType.Negative, - ) - - TransitionsComposable(t, devMode, onTransition) - if (devMode && t.error != null) { - ErrorTransactionButton(error = t.error) - } - - BottomInsetsSpacer() - } -} - -@Preview -@Composable -private fun TransactionRefreshComposablePreview() { - val t = TransactionRefresh( - transactionId = "transactionId", - timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), - txState = TransactionState(Pending), - txActions = listOf(Retry, Suspend, Abort), - amountRaw = Amount.fromString("TESTKUDOS", "42.23"), - amountEffective = Amount.fromString("TESTKUDOS", "42.1337"), - error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED), - scopes = listOf(ScopeInfo.Exchange( - currency = "TESTKUDOS", - url = "exchange.test.taler.net", - )) - ) - Surface { - TransactionRefreshComposable(t, true, null) {} - } -} diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefundFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefundFragment.kt @@ -1,49 +0,0 @@ -/* - * 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/> - */ - -package net.taler.wallet.transactions - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.getValue -import androidx.compose.ui.platform.ComposeView -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.refund.TransactionRefundComposable - -class TransactionRefundFragment : TransactionDetailFragment() { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = ComposeView(requireContext()).apply { - setContent { - TalerSurface { - val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() - (t as? TransactionRefund)?.let { tx -> - TransactionRefundComposable(tx, devMode, - exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes) - ) { - onTransitionButtonClicked(tx, it) - } - } - } - } - } -} diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionStateComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionStateComposable.kt @@ -61,7 +61,6 @@ fun TransactionStateComposable( state: TransactionState, tx: Transaction? = null, ) { - val context = LocalContext.current val message = when (state) { TransactionState(Done) -> stringResource(R.string.transaction_state_done) TransactionState(Pending, BankConfirmTransfer) -> stringResource(R.string.transaction_state_pending_bank) @@ -73,7 +72,7 @@ fun TransactionStateComposable( TransactionState(Aborted) -> if (tx is TransactionWithdrawal && tx.withdrawalDetails is ManualTransfer) { stringResource( R.string.transaction_state_aborted_manual, - (tx.timestamp + tx.withdrawalDetails.reserveClosingDelay).ms.toAbsoluteTime(context).toString(), + (tx.timestamp + tx.withdrawalDetails.reserveClosingDelay).ms.toAbsoluteTime(LocalContext.current).toString(), ) } else stringResource(R.string.transaction_state_aborted) TransactionState(Aborting) -> stringResource(R.string.transaction_state_aborting) diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt @@ -1,96 +0,0 @@ -/* - * 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/> - */ - -package net.taler.wallet.transactions - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.ComposeView -import androidx.core.os.bundleOf -import androidx.navigation.fragment.findNavController -import net.taler.wallet.R -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.launchInAppBrowser -import net.taler.wallet.transactions.WithdrawalDetails.TalerBankIntegrationApi -import net.taler.wallet.withdraw.TransactionWithdrawalComposable - -class TransactionWithdrawalFragment : TransactionDetailFragment() { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = ComposeView(requireContext()).apply { - setContent { - TalerSurface { - val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() - (t as? TransactionWithdrawal)?.let { tx -> - // show QR code only if withdrawal only contains one - val qrCode = remember(tx) { - (tx.withdrawalDetails as? WithdrawalDetails.ManualTransfer)?.let { details -> - if (details.exchangeCreditAccountDetails?.size == 1) { - val account0 = details.exchangeCreditAccountDetails[0] - val qrCodes = withdrawManager.getQrCodesForPayto(account0.paytoUri) - if (qrCodes.size == 1) qrCodes[0] - else null - } else null - } - } - - TransactionWithdrawalComposable( - t = tx, - devMode = devMode, - spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), - qrCode = qrCode, - onConfirmKyc = { onConfirmKyc(it) }, - onConfirmBank = { onConfirmBank(tx) }, - onConfirmManual = { onWireTransferSteps() }, - onShowQrCodes = { onShowQrCodes() }, - ) { - onTransitionButtonClicked(tx, it) - } - } - } - } - } - - fun onConfirmKyc(url: String) { - launchInAppBrowser(requireContext(), url) - } - - fun onConfirmBank(tx: TransactionWithdrawal) { - if (tx.withdrawalDetails !is TalerBankIntegrationApi) return - tx.withdrawalDetails.bankConfirmationUrl?.let { url -> - launchInAppBrowser(requireContext(), url) - } - } - - fun onWireTransferSteps(showQrCodes: Boolean = false) { - findNavController().navigate( - R.id.action_global_wireTransferDetails, - bundleOf("showQrCodes" to showQrCodes) - ) - } - - fun onShowQrCodes() { - onWireTransferSteps(showQrCodes = true) - } -} diff --git a/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt b/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt @@ -16,12 +16,12 @@ package net.taler.wallet.transactions -import android.content.Context import android.net.Uri import android.util.Log import androidx.annotation.DrawableRes -import androidx.annotation.IdRes import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource import androidx.core.net.toUri import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName @@ -47,6 +47,7 @@ import net.taler.wallet.backend.TalerErrorInfo import net.taler.common.CurrencySpecification import net.taler.common.Merchant import net.taler.common.RelativeTime +import net.taler.wallet.WalletDestination import net.taler.wallet.accounts.PaytoUriCyclos import net.taler.wallet.accounts.PaytoUriIban import net.taler.wallet.accounts.PaytoUriTalerBank @@ -121,12 +122,12 @@ sealed class Transaction { @get:DrawableRes abstract val icon: Int - @get:IdRes - abstract val detailPageNav: Int + abstract val detailPageNav: WalletDestination abstract val amountType: AmountType - abstract fun getTitle(context: Context): String + @Composable + abstract fun getTitle(): String @get:StringRes abstract val generalTitleRes: Int @@ -177,11 +178,12 @@ class TransactionWithdrawal( ) : Transaction() { override val icon = R.drawable.transaction_withdrawal - override val detailPageNav = R.id.action_global_transactionWithdrawal + override val detailPageNav = WalletDestination.TransactionWithdrawal @Transient override val amountType = AmountType.Positive - override fun getTitle(context: Context) = context.getString(R.string.withdraw_title) + @Composable + override fun getTitle() = stringResource(R.string.withdraw_title) override val generalTitleRes = R.string.withdraw_title val confirmed: Boolean get() = txState.major != Pending && ( @@ -395,11 +397,12 @@ class TransactionPayment( val posConfirmation: String? = null, ) : Transaction() { override val icon = R.drawable.transaction_payment - override val detailPageNav = R.id.action_global_transactionPayment + override val detailPageNav = WalletDestination.TransactionPayment @Transient override val amountType = AmountType.Negative - override fun getTitle(context: Context) = info.merchant.name + @Composable + override fun getTitle() = info.merchant.name override val generalTitleRes = R.string.payment_title } @@ -437,11 +440,12 @@ class TransactionRefund( override val scopes: List<ScopeInfo>, ) : Transaction() { override val icon = R.drawable.transaction_refund - override val detailPageNav = R.id.action_global_transactionRefund + override val detailPageNav = WalletDestination.TransactionRefund @Transient override val amountType = AmountType.Positive - override fun getTitle(context: Context) = paymentInfo?.merchant?.name ?: context.getString(R.string.transaction_refund) + @Composable + override fun getTitle() = paymentInfo?.merchant?.name ?: stringResource(R.string.transaction_refund) override val generalTitleRes = R.string.refund_title } @@ -459,12 +463,13 @@ class TransactionRefresh( override val scopes: List<ScopeInfo>, ) : Transaction() { override val icon = R.drawable.transaction_refresh - override val detailPageNav = R.id.action_global_transactionRefresh + override val detailPageNav = WalletDestination.TransactionRefresh @Transient override val amountType = AmountType.Negative - override fun getTitle(context: Context): String { - return context.getString(R.string.transaction_refresh) + @Composable + override fun getTitle(): String { + return stringResource(R.string.transaction_refresh) } override val generalTitleRes = R.string.transaction_refresh @@ -487,15 +492,16 @@ class TransactionDeposit( val depositGroupId: String, ) : Transaction() { override val icon = R.drawable.transaction_deposit - override val detailPageNav = R.id.action_global_transactionDeposit + override val detailPageNav = WalletDestination.TransactionDeposit @Transient override val amountType = AmountType.Negative - override fun getTitle(context: Context): String { + @Composable + override fun getTitle(): String { val uri = Uri.parse(targetPaytoUri) return uri.getQueryParameter("receiver-name")?.let { receiverName -> - context.getString(R.string.transaction_deposit_to, receiverName) - } ?: context.getString(R.string.transaction_deposit) + stringResource(R.string.transaction_deposit_to, receiverName) + } ?: stringResource(R.string.transaction_deposit) } override val generalTitleRes = R.string.transaction_deposit @@ -532,15 +538,16 @@ class TransactionPeerPullDebit( val info: PeerInfoShort, ) : Transaction() { override val icon = R.drawable.transaction_p2p_outgoing - override val detailPageNav = R.id.transactionPeer + override val detailPageNav = WalletDestination.TransactionPeer @Transient override val amountType = AmountType.Negative - override fun getTitle(context: Context): String { + @Composable + override fun getTitle(): String { return if (txState.major == Done) { - context.getString(R.string.transaction_peer_pull_debit) + stringResource(R.string.transaction_peer_pull_debit) } else { - context.getString(R.string.transaction_peer_pull_debit_pending) + stringResource(R.string.transaction_peer_pull_debit_pending) } } @@ -568,11 +575,12 @@ class TransactionPeerPullCredit( // val completed: Boolean, maybe ) : Transaction() { override val icon = R.drawable.transaction_p2p_incoming - override val detailPageNav = R.id.transactionPeer + override val detailPageNav = WalletDestination.TransactionPeer override val amountType get() = AmountType.Positive - override fun getTitle(context: Context): String { - return context.getString(R.string.transaction_peer_pull_credit) + @Composable + override fun getTitle(): String { + return stringResource(R.string.transaction_peer_pull_credit) } override val generalTitleRes = R.string.transaction_peer_pull_credit @@ -598,15 +606,16 @@ class TransactionPeerPushDebit( // val completed: Boolean, definitely ) : Transaction() { override val icon = R.drawable.transaction_p2p_outgoing - override val detailPageNav = R.id.transactionPeer + override val detailPageNav = WalletDestination.TransactionPeer @Transient override val amountType = AmountType.Negative - override fun getTitle(context: Context): String { + @Composable + override fun getTitle(): String { return if (txState.major == Done) { - context.getString(R.string.transaction_peer_push_debit) + stringResource(R.string.transaction_peer_push_debit) } else { - context.getString(R.string.transaction_peer_push_debit_pending) + stringResource(R.string.transaction_peer_push_debit_pending) } } @@ -632,15 +641,16 @@ class TransactionPeerPushCredit( val info: PeerInfoShort, ) : Transaction() { override val icon = R.drawable.transaction_p2p_incoming - override val detailPageNav = R.id.transactionPeer + override val detailPageNav = WalletDestination.TransactionPeer @Transient override val amountType = AmountType.Positive - override fun getTitle(context: Context): String { + @Composable + override fun getTitle(): String { return if (txState.major == Done) { - context.getString(R.string.transaction_peer_push_credit) + stringResource(R.string.transaction_peer_push_credit) } else { - context.getString(R.string.transaction_peer_push_credit_pending) + stringResource(R.string.transaction_peer_push_credit_pending) } } @@ -665,13 +675,14 @@ class TransactionDenomLoss( val lossEventType: LossEventType, ): Transaction() { override val icon: Int = R.drawable.transaction_loss - override val detailPageNav = R.id.transactionLoss + override val detailPageNav = WalletDestination.TransactionLoss @Transient override val amountType: AmountType = AmountType.Negative - override fun getTitle(context: Context): String { - return context.getString(R.string.transaction_denom_loss) + @Composable + override fun getTitle(): String { + return stringResource(R.string.transaction_denom_loss) } override val generalTitleRes: Int = R.string.transaction_denom_loss @@ -702,14 +713,15 @@ class DummyTransaction( override val amountRaw: Amount = Amount.zero("TESTKUDOS") override val amountEffective: Amount = Amount.zero("TESTKUDOS") override val icon: Int = R.drawable.transaction_dummy - override val detailPageNav: Int = R.id.transactionDummy + override val detailPageNav: WalletDestination = WalletDestination.TransactionDummy override val amountType: AmountType = AmountType.Neutral override val generalTitleRes: Int = R.string.transaction_dummy_title override val scopes: List<ScopeInfo> = listOf(ScopeInfo.Exchange( currency = "TESTKUDOS", url = "exchange.test.taler.net", )) - override fun getTitle(context: Context): String { - return context.getString(R.string.transaction_dummy_title) + @Composable + override fun getTitle(): String { + return stringResource(R.string.transaction_dummy_title) } } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionsComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionsComposable.kt @@ -16,7 +16,6 @@ package net.taler.wallet.transactions -import androidx.activity.compose.BackHandler import androidx.compose.animation.animateContentSize import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable @@ -36,7 +35,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material.icons.rounded.RadioButtonUnchecked -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Badge import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -46,14 +44,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Modifier @@ -81,7 +72,6 @@ import net.taler.wallet.balances.ScopeInfo.Exchange import net.taler.wallet.cleanExchange import net.taler.wallet.compose.Banner import net.taler.wallet.compose.LoadingScreen -import net.taler.wallet.compose.SelectionModeTopAppBar import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.cardPaddings import net.taler.wallet.main.ViewMode @@ -99,14 +89,14 @@ import net.taler.wallet.transactions.TransactionMajorState.Failed import net.taler.wallet.transactions.TransactionMajorState.Pending import net.taler.wallet.transactions.TransactionMinorState.BalanceKycRequired import net.taler.wallet.transactions.TransactionMinorState.BankConfirmTransfer -import net.taler.wallet.transactions.TransactionMinorState.KycRequired import net.taler.wallet.transactions.TransactionMinorState.KycAuthRequired import net.taler.wallet.transactions.TransactionMinorState.KycInit +import net.taler.wallet.transactions.TransactionMinorState.KycRequired import net.taler.wallet.transactions.TransactionMinorState.Repurchase +import net.taler.wallet.transactions.TransactionStateFilter.Nonfinal import net.taler.wallet.transactions.TransactionsResult.Error import net.taler.wallet.transactions.TransactionsResult.None import net.taler.wallet.transactions.TransactionsResult.Success -import net.taler.wallet.transactions.TransactionStateFilter.* @Composable fun TransactionsComposable( @@ -115,63 +105,12 @@ fun TransactionsComposable( balance: BalanceItem, txResult: TransactionsResult, onTransactionClick: (tx: Transaction) -> Unit, - onTransactionsDelete: (txIds: List<String>) -> Unit, onShowBalancesClicked: () -> Unit, + selectionMode: Boolean, + selectedItems: MutableList<String>, + onToggleSelection: (String) -> Unit, ) { Column(Modifier.fillMaxSize()) { - var showDeleteDialog by remember { mutableStateOf(false) } - var selectionMode by remember { mutableStateOf(false) } - val selectedItems = remember { mutableStateListOf<String>() } - - if (selectionMode && txResult is Success) SelectionModeTopAppBar( - selectedItems = selectedItems, - resetSelectionMode = { - selectionMode = false - selectedItems.clear() - }, - onSelectAllClicked = { - selectedItems.clear() - selectedItems += txResult.transactions.map { it.transactionId } - }, - onDeleteClicked = { - showDeleteDialog = true - }, - ) - - if (showDeleteDialog) AlertDialog( - title = { Text(stringResource(R.string.transactions_delete_selected_dialog_title)) }, - text = { Text(stringResource(R.string.transactions_delete_selected_dialog_message)) }, - onDismissRequest = { showDeleteDialog = false }, - confirmButton = { - TextButton(onClick = { - onTransactionsDelete(selectedItems) - selectedItems.clear() - selectionMode = false - showDeleteDialog = false - }) { - Text(stringResource(R.string.transactions_delete)) - } - }, - dismissButton = { - TextButton(onClick = { - showDeleteDialog = false - }) { - Text(stringResource(R.string.cancel)) - } - }, - ) - - BackHandler(selectionMode) { - selectionMode = false - selectedItems.clear() - } - - LaunchedEffect(selectionMode, selectedItems.size) { - if (selectionMode && selectedItems.isEmpty()) { - selectionMode = false - } - } - LazyColumn( Modifier .weight(1f) @@ -216,26 +155,13 @@ fun TransactionsComposable( selectionMode = selectionMode, onTransactionClick = { if (selectionMode) { - if (isSelected) { - selectedItems.remove(tx.transactionId) - } else { - selectedItems.add(tx.transactionId) - } + onToggleSelection(tx.transactionId) } else { onTransactionClick(tx) } }, onTransactionSelect = { - if (selectionMode) { - if (isSelected) { - selectedItems.remove(tx.transactionId) - } else { - selectedItems.add(tx.transactionId) - } - } else { - selectionMode = true - selectedItems.add(tx.transactionId) - } + onToggleSelection(tx.transactionId) }, ) } @@ -350,7 +276,6 @@ fun TransactionRow( onTransactionClick: () -> Unit, onTransactionSelect: () -> Unit, ) { - val context = LocalContext.current val haptic = LocalHapticFeedback.current Column { @@ -396,7 +321,7 @@ fun TransactionRow( }, headlineContent = { Text( - tx.getTitle(context), + tx.getTitle(), modifier = Modifier.padding(vertical = 3.dp), style = MaterialTheme.typography.titleMedium, ) @@ -404,7 +329,7 @@ fun TransactionRow( supportingContent = { TransactionExtraInfo(tx) }, - overlineContent = { Text(tx.timestamp.ms.toRelativeTime(context).toString()) }, + overlineContent = { Text(tx.timestamp.ms.toRelativeTime(LocalContext.current).toString()) }, colors = ListItemDefaults.colors( containerColor = if (isSelected) { MaterialTheme.colorScheme.secondaryContainer @@ -545,8 +470,10 @@ fun TransactionsComposableDonePreview() { viewMode = ViewMode.Transactions(previewBalance.scopeInfo), txResult = Success(transactions), onTransactionClick = {}, - onTransactionsDelete = {}, onShowBalancesClicked = {}, + selectionMode = false, + selectedItems = mutableListOf(), + onToggleSelection = {}, ) } } @@ -579,8 +506,10 @@ fun TransactionsComposablePendingPreview() { viewMode = ViewMode.Transactions(previewBalance.scopeInfo), txResult = Success(transactions), onTransactionClick = {}, - onTransactionsDelete = {}, onShowBalancesClicked = {}, + selectionMode = false, + selectedItems = mutableListOf(), + onToggleSelection = {}, ) } } @@ -595,8 +524,10 @@ fun TransactionsComposableEmptyPreview() { viewMode = ViewMode.Transactions(previewBalance.scopeInfo), txResult = Success(listOf()), onTransactionClick = {}, - onTransactionsDelete = {}, onShowBalancesClicked = {}, + selectionMode = false, + selectedItems = mutableListOf(), + onToggleSelection = {}, ) } } @@ -611,8 +542,10 @@ fun TransactionsComposableLoadingPreview() { viewMode = ViewMode.Transactions(previewBalance.scopeInfo), txResult = None, onTransactionClick = {}, - onTransactionsDelete = {}, onShowBalancesClicked = {}, + selectionMode = false, + selectedItems = mutableListOf(), + onToggleSelection = {}, ) } } \ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/transfer/ScreenTransfer.kt b/wallet/src/main/java/net/taler/wallet/transfer/ScreenTransfer.kt @@ -79,6 +79,7 @@ sealed class TransferContext { @Composable fun ScreenTransfer( + modifier: Modifier = Modifier, transfers: List<TransferData>, spec: CurrencySpecification?, showQrCodes: Boolean, @@ -113,7 +114,7 @@ fun ScreenTransfer( getQrCodes(defaultTransfer) } - Column { + Column(modifier) { if (transfers.size > 1) { TransferAccountChooser( accounts = transfers.map { it.withdrawalAccount }, diff --git a/wallet/src/main/java/net/taler/wallet/transfer/WireTransferDetailsFragment.kt b/wallet/src/main/java/net/taler/wallet/transfer/WireTransferDetailsFragment.kt @@ -1,171 +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.transfer - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import kotlinx.coroutines.launch -import net.taler.common.openUri -import net.taler.common.shareText -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.transactions.TransactionDeposit -import net.taler.wallet.transactions.TransactionMajorState.Done -import net.taler.wallet.transactions.TransactionWithdrawal -import net.taler.wallet.transactions.WithdrawalDetails -import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails -import net.taler.wallet.withdraw.TransferData - -class WireTransferDetailsFragment : Fragment() { - private val model: MainViewModel by activityViewModels() - private val withdrawManager by lazy { model.withdrawManager } - private val transactionManager by lazy { model.transactionManager } - private val exchangeManager by lazy { model.exchangeManager } - - private var navigating: Boolean = false - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = ComposeView(requireContext()).apply { - val showQrCodes = arguments?.getBoolean("showQrCodes") == true - setContent { - TalerSurface { - val selectedTx by transactionManager.selectedTransaction.collectAsStateLifecycleAware() - val devMode by model.devMode.observeAsState() - - // TODO: move this code somewhere else - // TODO: better error handling - val transfers = remember(selectedTx) { - selectedTx?.let { tx -> - when (tx) { - is TransactionWithdrawal -> when (tx.withdrawalDetails) { - is WithdrawalDetails.ManualTransfer -> { - tx.withdrawalDetails.exchangeCreditAccountDetails - } - - else -> null - } - - is TransactionDeposit -> tx.kycAuthTransferInfo?.let { - it.creditPaytoUris.map { paytoUri -> - WithdrawalExchangeAccountDetails( - paytoUri = paytoUri, - status = WithdrawalExchangeAccountDetails.Status.Ok, - ) - } - } - - else -> null - }?.map { - it.getTransferDetails( - amountRaw = tx.amountRaw, - amountEffective = tx.amountEffective - ) - } - } - }?.filterNotNull() ?: return@TalerSurface - - ScreenTransfer( - transfers = transfers, - getQrCodes = { withdrawManager.getQrCodesForPayto(it.withdrawalAccount.paytoUri) }, - spec = selectedTx?.amountRaw?.currency?.let { - selectedTx?.scopes?.let { selectedScopes -> - exchangeManager.getSpecForCurrency(it, selectedScopes) - } ?: run { - exchangeManager.getSpecForCurrency(it) - } - }, - bankAppClick = { onBankAppClick(it) }, - shareClick = { onShareClick(it) }, - showQrCodes = showQrCodes, - devMode = devMode == true, - transferContext = when(val tx = selectedTx) { - is TransactionWithdrawal -> TransferContext.ManualWithdrawal - is TransactionDeposit -> TransferContext.DepositKycAuth(tx.kycAuthTransferInfo?.debitPaytoUri - ?: error("no kycAuthTransferInfo")) - else -> return@TalerSurface - } - ) - } - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - model.withdrawManager.withdrawStatus.collect { status -> - // Set action bar subtitle and unset on exit - if (status.withdrawalTransfers.size > 1) { - (requireActivity() as? AppCompatActivity)?.apply { - supportActionBar?.subtitle = getString(R.string.withdraw_subtitle) - } - } - } - } - } - - lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - model.transactionManager.selectedTransaction.collect { tx -> - if (tx?.txState?.major == Done) { - if (navigating) return@collect - findNavController().popBackStack() - navigating = true - } - } - } - } - } - - override fun onDestroy() { - super.onDestroy() - (requireActivity() as? AppCompatActivity)?.apply { - supportActionBar?.subtitle = null - } - } - - private fun onBankAppClick(transfer: TransferData) { - requireContext().openUri( - uri = transfer.withdrawalAccount.paytoUri, - title = requireContext().getString(R.string.share_payment) - ) - } - - private fun onShareClick(transfer: TransferData) { - requireContext().shareText( - text = transfer.withdrawalAccount.paytoUri, - ) - } -} diff --git a/wallet/src/main/java/net/taler/wallet/transfer/WireTransferDetailsScreen.kt b/wallet/src/main/java/net/taler/wallet/transfer/WireTransferDetailsScreen.kt @@ -0,0 +1,127 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.transfer + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import net.taler.common.openUri +import net.taler.common.shareText +import net.taler.wallet.R +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.main.MainViewModel +import net.taler.wallet.transactions.TransactionDeposit +import net.taler.wallet.transactions.TransactionMajorState.Done +import net.taler.wallet.transactions.TransactionWithdrawal +import net.taler.wallet.transactions.WithdrawalDetails +import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails + +@Composable +fun WireTransferDetailsScreen( + model: MainViewModel, + showQrCodes: Boolean, + onNavigateBack: () -> Unit, +) { + val context = LocalContext.current + val sharePaymentTitle = stringResource(R.string.share_payment) + val transactionManager = model.transactionManager + val withdrawManager = model.withdrawManager + val exchangeManager = model.exchangeManager + + val selectedTx by transactionManager.selectedTransaction.collectAsStateLifecycleAware() + val devMode by model.devMode.observeAsState(false) + + LaunchedEffect(selectedTx) { + if (selectedTx?.txState?.major == Done) { + onNavigateBack() + } + } + + GlobalScaffold( + model = model, + title = { Text(stringResource(R.string.wire_transfer)) }, + onNavigateBack = onNavigateBack, + ) { paddingValues -> + val transfers = remember(selectedTx) { + selectedTx?.let { tx -> + when (tx) { + is TransactionWithdrawal -> when (tx.withdrawalDetails) { + is WithdrawalDetails.ManualTransfer -> { + tx.withdrawalDetails.exchangeCreditAccountDetails + } + else -> null + } + is TransactionDeposit -> tx.kycAuthTransferInfo?.let { + it.creditPaytoUris.map { paytoUri -> + WithdrawalExchangeAccountDetails( + paytoUri = paytoUri, + status = WithdrawalExchangeAccountDetails.Status.Ok, + ) + } + } + else -> null + }?.map { + it.getTransferDetails( + amountRaw = tx.amountRaw, + amountEffective = tx.amountEffective + ) + } + } + }?.filterNotNull() + + if (transfers != null) ScreenTransfer( + modifier = Modifier.padding(paddingValues), + transfers = transfers, + getQrCodes = { withdrawManager.getQrCodesForPayto(it.withdrawalAccount.paytoUri) }, + spec = selectedTx?.amountRaw?.currency?.let { + selectedTx?.scopes?.let { selectedScopes -> + exchangeManager.getSpecForCurrency(it, selectedScopes) + } ?: run { + exchangeManager.getSpecForCurrency(it) + } + }, + bankAppClick = { transfer -> + context.openUri( + uri = transfer.withdrawalAccount.paytoUri, + title = sharePaymentTitle + ) + }, + shareClick = { transfer -> + context.shareText( + text = transfer.withdrawalAccount.paytoUri, + ) + }, + showQrCodes = showQrCodes, + devMode = devMode, + transferContext = when (val tx = selectedTx) { + is TransactionWithdrawal -> TransferContext.ManualWithdrawal + is TransactionDeposit -> TransferContext.DepositKycAuth( + tx.kycAuthTransferInfo?.debitPaytoUri ?: "" + ) + else -> return@GlobalScaffold + } + ) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/ErrorFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/ErrorFragment.kt @@ -1,72 +0,0 @@ -/* - * 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/> - */ - -package net.taler.wallet.withdraw - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.navigation.fragment.findNavController -import net.taler.common.isOnline -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.databinding.FragmentErrorBinding - -class ErrorFragment : Fragment() { - - private val model: MainViewModel by activityViewModels() - private val withdrawManager by lazy { model.withdrawManager } - - private lateinit var ui: FragmentErrorBinding - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - ui = FragmentErrorBinding.inflate(inflater, container, false) - return ui.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - ui.errorTitle.setText(R.string.withdraw_error_title) - if (requireContext().isOnline()) { - ui.errorMessage.setText(R.string.withdraw_error_message) - } else { - ui.errorMessage.setText(R.string.offline) - } - - // show dev error message if dev mode is on - val status = withdrawManager.withdrawStatus.value - if (model.devMode.value == true && status.error != null) { - ui.errorDevMessage.visibility = VISIBLE - ui.errorDevMessage.text = status.error.userFacingMsg - } else { - ui.errorDevMessage.visibility = GONE - } - - ui.backButton.setOnClickListener { - findNavController().navigateUp() - } - } - -} diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt @@ -1,256 +0,0 @@ -/* - * 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.withdraw - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.ComposeView -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.map -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import com.google.android.material.snackbar.Snackbar -import com.google.android.material.snackbar.Snackbar.LENGTH_LONG -import kotlinx.coroutines.launch -import net.taler.common.Amount -import net.taler.common.EventObserver -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.main.ViewMode -import net.taler.wallet.compose.AmountScope -import net.taler.wallet.compose.LoadingScreen -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.exchanges.ExchangeItem -import net.taler.wallet.exchanges.SelectExchangeDialogFragment -import net.taler.wallet.withdraw.WithdrawStatus.Status.AlreadyConfirmed -import net.taler.wallet.withdraw.WithdrawStatus.Status.Confirming -import net.taler.wallet.withdraw.WithdrawStatus.Status.Error -import net.taler.wallet.withdraw.WithdrawStatus.Status.InfoReceived -import net.taler.wallet.withdraw.WithdrawStatus.Status.Loading -import net.taler.wallet.withdraw.WithdrawStatus.Status.ManualTransferRequired -import net.taler.wallet.withdraw.WithdrawStatus.Status.None -import net.taler.wallet.withdraw.WithdrawStatus.Status.Success -import net.taler.wallet.withdraw.WithdrawStatus.Status.TosReviewRequired -import net.taler.wallet.withdraw.WithdrawStatus.Status.Updating - -class PromptWithdrawFragment: Fragment() { - private val model: MainViewModel by activityViewModels() - private val withdrawManager by lazy { model.withdrawManager } - private val transactionManager by lazy { model.transactionManager } - private val exchangeManager by lazy { model.exchangeManager } - private val balanceManager by lazy { model.balanceManager } - - private val selectExchangeDialog = SelectExchangeDialogFragment() - - private var editableCurrency: Boolean = true - private var navigating: Boolean = false - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ) = ComposeView(requireContext()).apply { - val withdrawUri = arguments?.getString("withdrawUri") - val withdrawExchangeUri = arguments?.getString("withdrawExchangeUri") - val exchangeBaseUrl = arguments?.getString("exchangeBaseUrl") - val amount = arguments?.getString("amount")?.let { Amount.fromJSONString(it) } - editableCurrency = arguments?.getBoolean("editableCurrency") ?: true - - setContent { - val status by withdrawManager.withdrawStatus.collectAsStateLifecycleAware() - val viewMode by model.viewMode.collectAsStateLifecycleAware() - val devMode by model.devMode.observeAsState() - val selectedScope = remember (viewMode) { - (viewMode as? ViewMode.Transactions)?.selectedScope - } - - val scopes by balanceManager.balances - .map { bl -> bl.map { it.scopeInfo } } - .map { bl -> - val scope = status.amountInfo?.scopeInfo - if (scope != null && !bl.contains(scope)) { bl + scope } else bl - }.observeAsState(emptyList()) - - val initialAmount = status.selectedAmount - ?: selectedScope?.let { Amount.zero(it.currency) } - ?: scopes.firstOrNull()?.let { Amount.zero(it.currency) } - - val initialScope = status.selectedScope - ?: selectedScope - ?: scopes.firstOrNull() - - LaunchedEffect(status.status) { - if (status.status == None) { - if (withdrawUri != null) { - // get withdrawal details for taler://withdraw URI - withdrawManager.prepareBankIntegratedWithdrawal(withdrawUri, context, loading = true) - } else if (withdrawExchangeUri != null) { - // get withdrawal details for taler://withdraw-exchange URI - withdrawManager.prepareManualWithdrawal(withdrawExchangeUri) - } else if (exchangeBaseUrl != null) { - withdrawManager.getWithdrawalDetailsForExchange(exchangeBaseUrl, loading = true) - } else if (initialAmount != null) { - withdrawManager.getWithdrawalDetailsForAmount( - amount = initialAmount, - scopeInfo = initialScope, - ) - } - } - } - - LaunchedEffect(status.selectedSpec, amount) { - (requireActivity() as AppCompatActivity).apply { - supportActionBar?.title = status.selectedSpec?.symbol?.let { symbol -> - getString(R.string.nav_prompt_withdraw_currency, symbol) - } ?: amount?.currency?.let { currency -> - getString(R.string.nav_prompt_withdraw_currency, currency) - } ?: getString(R.string.nav_prompt_withdraw) - } - } - - TalerSurface { - // FIXME: hack to prevent initialAmount from changing after preparing withdrawal. - // WithdrawalShowInfo cannot detect changes other than null -> not null, - // otherwise there would be an input loop. - if (status.selectedAmount == null && status.selectedScope == null) { - LoadingScreen() - return@TalerSurface - } - - status.let { s -> - when (s.status) { - Confirming, AlreadyConfirmed -> LoadingScreen() - - None, Loading, Error, InfoReceived, TosReviewRequired, Updating -> { - WithdrawalShowInfo( - status = s, - devMode = devMode ?: false, - initialAmountScope = initialAmount?.let { amount -> - initialScope?.let { scope -> - AmountScope(amount, scope) - } - }, - editableScope = editableCurrency, - scopes = scopes, - onSelectExchange = { selectExchange() }, - onSelectAmount = { amount, scope -> - withdrawManager.getWithdrawalDetailsForAmount( - amount = amount, - scopeInfo = scope, - // only show loading screen when switching currencies - loading = scope != status.selectedScope, - ) - }, - onTosReview = { - // TODO: rewrite ToS review screen in compose - if (s.exchangeBaseUrl != null) { - val args = bundleOf("exchangeBaseUrl" to s.exchangeBaseUrl) - findNavController().navigate(R.id.action_global_reviewExchangeTOS, args) - } - }, - onConfirm = { age -> - status.selectedScope?.let { model.selectScope(it) } - withdrawManager.acceptWithdrawal(age) - }, - ) - } - else -> {} - } - } - } - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - withdrawManager.withdrawStatus.collect { status -> - if (status.exchangeBaseUrl == null - && selectExchangeDialog.dialog?.isShowing != true) { - selectExchange() - } - - when (status.status) { - Success, ManualTransferRequired, AlreadyConfirmed -> lifecycleScope.launch { - Snackbar.make( - requireView(), - if (status.status == AlreadyConfirmed) { - R.string.withdraw_error_already_confirmed - } else { - R.string.withdraw_initiated - }, - LENGTH_LONG, - ).show() - - status.transactionId?.let { - if (!navigating) { - navigating = true - } else return@let - - if (transactionManager.selectTransaction(it)) { - status.amountInfo?.scopeInfo?.let { s -> model.selectScope(s) } - findNavController().navigate(R.id.action_global_transactionWithdrawal) - } else { - findNavController().navigate(R.id.action_global_main) - } - } - } - - else -> {} - } - } - } - } - - selectExchangeDialog.exchangeSelection.observe(viewLifecycleOwner, EventObserver { - onExchangeSelected(it) - }) - - exchangeManager.exchanges.observe(viewLifecycleOwner) { exchanges -> - // detect ToS acceptation - withdrawManager.refreshTosStatus(exchanges) - } - } - - private fun selectExchange() { - val exchanges = withdrawManager.withdrawStatus.value.uriInfo?.possibleExchanges ?: return - selectExchangeDialog.setExchanges(exchanges) - if (selectExchangeDialog.isAdded) { - selectExchangeDialog.show(parentFragmentManager, "SELECT_EXCHANGE") - } - } - - private fun onExchangeSelected(exchange: ExchangeItem) { - withdrawManager.getWithdrawalDetailsForExchange( - exchangeBaseUrl = exchange.exchangeBaseUrl, - ) - } -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawScreen.kt b/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawScreen.kt @@ -0,0 +1,185 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.withdraw + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.map +import net.taler.common.Amount +import net.taler.wallet.R +import net.taler.wallet.WalletDestination +import net.taler.wallet.compose.AmountScope +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.exchanges.SelectExchangeComposable +import net.taler.wallet.main.MainViewModel +import net.taler.wallet.main.ViewMode +import androidx.compose.material3.Text +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.padding +import net.taler.wallet.NavigateCallback + +@Composable +fun PromptWithdrawScreen( + model: MainViewModel, + dest: WalletDestination.PromptWithdraw, + onNavigate: NavigateCallback, + onNavigateBack: () -> Unit, +) { + val context = LocalContext.current + val withdrawManager = model.withdrawManager + val balanceManager = model.balanceManager + val transactionManager = model.transactionManager + val exchangeManager = model.exchangeManager + + val status by withdrawManager.withdrawStatus.collectAsStateLifecycleAware() + val viewMode by model.viewMode.collectAsStateLifecycleAware() + val devMode by model.devMode.observeAsState(false) + + var showExchangeSelect by remember { mutableStateOf(false) } + + val selectedScope = remember(viewMode) { + (viewMode as? ViewMode.Transactions)?.selectedScope + } + + val scopes by balanceManager.balances + .map { bl -> bl.map { it.scopeInfo } } + .map { bl -> + val scope = status.amountInfo?.scopeInfo + if (scope != null && !bl.contains(scope)) { + bl + scope + } else bl + }.observeAsState(emptyList()) + + val initialAmount = status.selectedAmount + ?: selectedScope?.let { Amount.zero(it.currency) } + ?: scopes.firstOrNull()?.let { Amount.zero(it.currency) } + + val initialScope = status.selectedScope + ?: selectedScope + ?: scopes.firstOrNull() + + LaunchedEffect(status.status) { + if (status.status == WithdrawStatus.Status.None) { + if (dest.withdrawUri != null) { + withdrawManager.prepareBankIntegratedWithdrawal(dest.withdrawUri, context, loading = true) + } else if (dest.withdrawExchangeUri != null) { + withdrawManager.prepareManualWithdrawal(dest.withdrawExchangeUri) + } else if (dest.exchangeBaseUrl != null) { + withdrawManager.getWithdrawalDetailsForExchange(dest.exchangeBaseUrl, loading = true) + } else if (initialAmount != null) { + withdrawManager.getWithdrawalDetailsForAmount( + amount = initialAmount, + scopeInfo = initialScope, + ) + } + } + } + + LaunchedEffect(status.status) { + when (status.status) { + WithdrawStatus.Status.Success, + WithdrawStatus.Status.ManualTransferRequired, + WithdrawStatus.Status.AlreadyConfirmed -> { + status.transactionId?.let { txId -> + if (transactionManager.selectTransaction(txId)) { + status.amountInfo?.scopeInfo?.let { s -> model.selectScope(s) } + onNavigate(WalletDestination.TransactionWithdrawal, true) + } else { + onNavigateBack() + } + } + } + else -> {} + } + } + + // Detect ToS acceptance + val exchanges by exchangeManager.exchanges.observeAsState() + LaunchedEffect(exchanges) { + exchanges?.let { withdrawManager.refreshTosStatus(it) } + } + + GlobalScaffold( + model = model, + title = { + Text( + status.selectedSpec?.symbol?.let { symbol -> + stringResource(R.string.nav_prompt_withdraw_currency, symbol) + } ?: dest.amount?.let { Amount.fromJSONString(it) }?.currency?.let { currency -> + stringResource(R.string.nav_prompt_withdraw_currency, currency) + } ?: stringResource(R.string.nav_prompt_withdraw), + ) + }, + onNavigateBack = onNavigateBack, + ) { paddingValues -> + if (status.selectedAmount == null && status.selectedScope == null) { + LoadingScreen(Modifier.padding(paddingValues)) + } else { + WithdrawalShowInfo( + modifier = Modifier.padding(paddingValues), + status = status, + devMode = devMode, + initialAmountScope = initialAmount?.let { amount -> + initialScope?.let { scope -> + AmountScope(amount, scope) + } + }, + editableScope = dest.editableCurrency, + scopes = scopes, + onSelectExchange = { + showExchangeSelect = true + }, + onSelectAmount = { amount, scope -> + withdrawManager.getWithdrawalDetailsForAmount( + amount = amount, + scopeInfo = scope, + loading = scope != status.selectedScope, + ) + }, + onTosReview = { + status.exchangeBaseUrl?.let { + onNavigate(WalletDestination.ReviewExchangeTOS(it), false) + } + }, + onConfirm = { age -> + status.selectedScope?.let { model.selectScope(it) } + withdrawManager.acceptWithdrawal(age) + }, + ) + } + } + + if (showExchangeSelect) { + val possibleExchanges = status.uriInfo?.possibleExchanges ?: emptyList() + SelectExchangeComposable( + exchanges = possibleExchanges, + onExchangeSelected = { + showExchangeSelect = false + withdrawManager.getWithdrawalDetailsForExchange(it.exchangeBaseUrl) + } + ) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/ReviewExchangeTosFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/ReviewExchangeTosFragment.kt @@ -1,235 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2026 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.withdraw - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.Button -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -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.platform.ComposeView -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import androidx.navigation.findNavController -import com.mikepenz.markdown.m3.Markdown -import com.mikepenz.markdown.m3.markdownTypography -import com.mikepenz.markdown.model.markdownPadding -import kotlinx.coroutines.launch -import net.taler.wallet.R -import net.taler.wallet.backend.TalerErrorInfo -import net.taler.wallet.compose.BottomButtonBox -import net.taler.wallet.compose.EmptyComposable -import net.taler.wallet.compose.ErrorComposable -import net.taler.wallet.compose.LoadingScreen -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.exchanges.ExchangeTosStatus -import net.taler.wallet.exchanges.TosResponse -import net.taler.wallet.main.MainViewModel -import net.taler.wallet.systemBarsPaddingBottom -import java.util.Locale - -class ReviewExchangeTosFragment : Fragment() { - private val model: MainViewModel by activityViewModels() - private val exchangeManager by lazy { model.exchangeManager } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ) = ComposeView(requireContext()).apply { - setContent { - val exchangeBaseUrl = arguments - ?.getString("exchangeBaseUrl") - ?: error("no exchangeBaseUrl passed") - val readOnly = arguments - ?.getBoolean("readOnly") - ?: false - - var tos: TosResponse? by remember { mutableStateOf(null) } - var selectedLang by remember { mutableStateOf(Locale.getDefault().language) } - - LaunchedEffect(selectedLang) { - tos = null - tos = model.exchangeManager.getExchangeTos(exchangeBaseUrl, selectedLang) - } - - TalerSurface { - tos?.let { tos -> - ReviewExchangeTosComposable(tos, - readOnly = readOnly, - onSelectLang = { selectedLang = it }, - onAcceptTos = { - viewLifecycleOwner.lifecycleScope.launch { - if (exchangeManager.acceptCurrentTos( - exchangeBaseUrl = exchangeBaseUrl, - currentEtag = tos.currentEtag, - )) { - findNavController().navigateUp() - } - } - }, - ) - } ?: LoadingScreen() - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ReviewExchangeTosComposable( - tos: TosResponse, - readOnly: Boolean, - onSelectLang: (String) -> Unit, - onAcceptTos: () -> Unit, -) { - if (tos.status == ExchangeTosStatus.MissingTos) { - EmptyComposable(stringResource(R.string.exchange_tos_missing)) - return - } - - var expanded by remember { mutableStateOf(false) } - - Scaffold( - bottomBar = { - if (!readOnly) BottomButtonBox { - Button( - modifier = Modifier - .systemBarsPaddingBottom(), - onClick = onAcceptTos, - ) { - Text(stringResource(R.string.exchange_tos_accept)) - } - } - }, - contentWindowInsets = WindowInsets.systemBars.only( - WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom - ) - ) { innerPadding -> - LazyColumn(Modifier.padding(innerPadding)) { - if (tos.tosAvailableLanguages.size > 1) item { - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = it }, - ) { - OutlinedTextField( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .clickable { expanded = true }, - label = { Text(stringResource(R.string.language)) }, - value = tos.contentLanguage?.let { - Locale(it).displayLanguage - } ?: "", - onValueChange = {}, - readOnly = true, - enabled = false, - singleLine = true, - textStyle = LocalTextStyle.current.copy( // show text as if not disabled - color = MaterialTheme.colorScheme.onSurface, - ), - ) - - ExposedDropdownMenu ( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - tos.tosAvailableLanguages.forEach { - DropdownMenuItem( - { Text("${Locale(it).displayLanguage}") }, - onClick = { - onSelectLang(it) - expanded = false - } - ) - } - } - } - } - - item { - Markdown( - content = tos.content.trimIndent(), - modifier = Modifier.padding(16.dp), - typography = markdownTypography( - h1 = MaterialTheme.typography.headlineLarge, - h2 = MaterialTheme.typography.headlineMedium, - h3 = MaterialTheme.typography.headlineSmall, - h4 = MaterialTheme.typography.titleLarge, - h5 = MaterialTheme.typography.titleMedium, - h6 = MaterialTheme.typography.titleSmall, - text = MaterialTheme.typography.bodyMedium, - paragraph = MaterialTheme.typography.bodyMedium, - ), - padding = markdownPadding( - block = 5.dp, - ), - error = { modifier -> - ErrorComposable( - TalerErrorInfo.makeCustomError( - stringResource(R.string.exchange_tos_error, "")), - modifier = modifier, - devMode = false, - ) - }, - ) - } - } - } -} - -@Preview -@Composable -fun ReviewExchangeTosComposablePreview() { - TalerSurface { - val tos = TosResponse( - status = ExchangeTosStatus.Proposed, - content = "# Terms of service\nThis is a terms of service, obviously.\n## H2\n### H3\n#### H4\n##### H5\n###### H6", - currentEtag = "1.2.0", - contentLanguage = "en", - tosAvailableLanguages = listOf("en", "en_US"), - ) - - ReviewExchangeTosComposable(tos, false, {}, {}) - } -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/TransactionWithdrawalComposable.kt b/wallet/src/main/java/net/taler/wallet/withdraw/TransactionWithdrawalComposable.kt @@ -71,21 +71,20 @@ fun TransactionWithdrawalComposable( onConfirmManual: () -> Unit, onShowQrCodes: () -> Unit, onTransition: (t: TransactionAction) -> Unit, + modifier: Modifier = Modifier, ) { val scrollState = rememberScrollState() Column( - modifier = Modifier + modifier = modifier .fillMaxWidth() .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally, ) { - val context = LocalContext.current - TransactionStateComposable(state = t.txState, tx = t) Text( modifier = Modifier.padding(16.dp), - text = t.timestamp.ms.toAbsoluteTime(context).toString(), + text = t.timestamp.ms.toAbsoluteTime(LocalContext.current).toString(), style = MaterialTheme.typography.bodyLarge, ) @@ -174,10 +173,17 @@ private val previewWithdrawalTx = TransactionWithdrawal( @Composable fun TransactionWithdrawalComposableSingleQrPreview() { Surface { - TransactionWithdrawalComposable(previewWithdrawalTx, true, - QrCodeSpec(QrCodeSpec.Type.SPC, "something"), - null, - {}, {}, {}, {}, {}) + TransactionWithdrawalComposable( + t = previewWithdrawalTx, + devMode = true, + qrCode = QrCodeSpec(QrCodeSpec.Type.SPC, "something"), + spec = null, + onConfirmKyc = {}, + onConfirmBank = {}, + onConfirmManual = {}, + onShowQrCodes = {}, + onTransition = {}, + ) } } @@ -185,9 +191,16 @@ fun TransactionWithdrawalComposableSingleQrPreview() { @Composable fun TransactionWithdrawalComposableMultiQrPreview() { Surface { - TransactionWithdrawalComposable(previewWithdrawalTx, true, - null, - null, - {}, {}, {}, {}, {}) + TransactionWithdrawalComposable( + t = previewWithdrawalTx, + devMode = true, + qrCode = null, + spec = null, + onConfirmKyc = {}, + onConfirmBank = {}, + onConfirmManual = {}, + onShowQrCodes = {}, + onTransition = {}, + ) } } \ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt @@ -84,6 +84,7 @@ fun WithdrawalShowInfo( onSelectExchange: () -> Unit, onTosReview: () -> Unit, onConfirm: (age: Int?) -> Unit, + modifier: Modifier = Modifier, ) { val maxAmount = status.uriInfo?.maxAmount val editableAmount = status.uriInfo?.editableAmount ?: true @@ -93,7 +94,7 @@ fun WithdrawalShowInfo( val ageRestrictionOptions = status.amountInfo?.ageRestrictionOptions ?: emptyList() if (scopes.isEmpty()) { - LoadingScreen() + LoadingScreen(modifier) return } @@ -141,7 +142,7 @@ fun WithdrawalShowInfo( } Column( - Modifier + modifier .fillMaxSize() .imePadding(), ) { diff --git a/wallet/src/main/res/layout-w550dp/payment_bottom_bar.xml b/wallet/src/main/res/layout-w550dp/payment_bottom_bar.xml @@ -1,123 +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/> - --> - -<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:id="@+id/bottomView" - style="@style/BottomCard" - android:layout_width="0dp" - android:layout_height="wrap_content" - tools:showIn="@layout/fragment_prompt_payment"> - - <androidx.constraintlayout.widget.ConstraintLayout - android:id="@+id/bottomLayout" - android:layout_width="match_parent" - android:layout_height="wrap_content"> - - <TextView - android:id="@+id/totalLabelView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/amount_total_label" - android:visibility="invisible" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@+id/totalView" - app:layout_constraintHorizontal_bias="1.0" - app:layout_constraintHorizontal_chainStyle="packed" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="@+id/totalView" - app:layout_constraintVertical_bias="0.0" - tools:visibility="visible" /> - - <TextView - android:id="@+id/totalView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginTop="8dp" - android:layout_marginEnd="16dp" - android:textColor="?android:attr/textColorPrimary" - android:textStyle="bold" - android:visibility="invisible" - app:layout_constraintBottom_toTopOf="@+id/feeView" - app:layout_constraintEnd_toStartOf="@+id/confirmButton" - app:layout_constraintHorizontal_chainStyle="packed" - app:layout_constraintStart_toEndOf="@+id/totalLabelView" - app:layout_constraintTop_toTopOf="parent" - app:layout_goneMarginBottom="8dp" - tools:text="10 TESTKUDOS" - tools:visibility="visible" /> - - <TextView - android:id="@+id/feeView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginEnd="16dp" - android:layout_marginBottom="8dp" - android:visibility="gone" - app:layout_constraintEnd_toStartOf="@+id/confirmButton" - app:layout_constraintHorizontal_bias="1.0" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintTop_toBottomOf="@+id/totalView" - tools:text="@string/payment_fee" - tools:visibility="visible" /> - - <Button - android:id="@+id/cancelButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:enabled="false" - style="@style/Widget.Material3.Button.OutlinedButton" - android:text="@string/payment_button_cancel" - android:textColor="?colorError" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@id/confirmButton" - app:layout_constraintHorizontal_bias="0.0" - app:layout_constraintStart_toStartOf="parent" - tools:enabled="true" /> - - <Button - android:id="@+id/confirmButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:backgroundTint="@color/green" - android:enabled="false" - android:text="@string/payment_button_confirm" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:enabled="true" /> - - <ProgressBar - android:id="@+id/confirmProgressBar" - style="?android:attr/progressBarStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:visibility="invisible" - app:layout_constraintBottom_toBottomOf="@+id/confirmButton" - app:layout_constraintEnd_toEndOf="@+id/confirmButton" - app:layout_constraintStart_toStartOf="@+id/confirmButton" - app:layout_constraintTop_toTopOf="@+id/confirmButton" - tools:visibility="visible" /> - - </androidx.constraintlayout.widget.ConstraintLayout> - -</com.google.android.material.card.MaterialCardView> diff --git a/wallet/src/main/res/layout/activity_main.xml b/wallet/src/main/res/layout/activity_main.xml @@ -1,96 +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/> - --> - -<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="match_parent" - xmlns:tools="http://schemas.android.com/tools"> - - <com.google.android.material.appbar.AppBarLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:fitsSystemWindows="true"> - - <com.google.android.material.appbar.MaterialToolbar - android:id="@+id/toolbar" - android:layout_width="match_parent" - android:layout_height="wrap_content" /> - - </com.google.android.material.appbar.AppBarLayout> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical" - app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"> - - <me.zhanghai.android.materialprogressbar.MaterialProgressBar - android:id="@+id/progress_bar" - style="@style/Widget.MaterialProgressBar.ProgressBar" - android:layout_width="match_parent" - android:layout_height="4dp" - android:elevation="4dp" - android:indeterminate="true" - android:visibility="gone" - app:mpb_progressStyle="horizontal" - app:mpb_useIntrinsicPadding="false" - tools:visibility="visible" /> - - <FrameLayout - android:id="@+id/offline_banner" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:background="?attr/colorPrimary" - android:animateLayoutChanges="true" - android:visibility="gone" - tools:visibility="visible"> - <TextView - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="10dp" - android:textAlignment="center" - android:textColor="?attr/colorOnPrimary" - android:text="@string/offline_banner" /> - </FrameLayout> - - <androidx.fragment.app.FragmentContainerView - android:id="@+id/nav_host_fragment" - android:name="androidx.navigation.fragment.NavHostFragment" - android:layout_width="match_parent" - android:layout_height="0dp" - android:layout_weight="1" - app:defaultNavHost="true" - app:navGraph="@navigation/nav_graph" /> - - </LinearLayout> - - <LinearLayout - android:id="@+id/biometricOverlay" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:gravity="center" - android:background="?colorSurface" - tools:visibility="gone"> - <com.google.android.material.button.MaterialButton - android:id="@+id/unlockButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/biometric_unlock_label" - app:icon="@drawable/ic_shield"/> - </LinearLayout> - -</androidx.coordinatorlayout.widget.CoordinatorLayout> diff --git a/wallet/src/main/res/layout/dialog_exchange_add.xml b/wallet/src/main/res/layout/dialog_exchange_add.xml @@ -1,45 +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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/urlLayout" - style="@style/Widget.Material3.TextInputLayout.OutlinedBox.Dense" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:hint="@string/exchange_add_url" - app:boxBackgroundMode="outline" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/urlView" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textUri" - android:text="https://" - tools:ignore="HardcodedText" /> - - </com.google.android.material.textfield.TextInputLayout> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/wallet/src/main/res/layout/fragment_error.xml b/wallet/src/main/res/layout/fragment_error.xml @@ -1,97 +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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - tools:context=".withdraw.ErrorFragment"> - - <ImageView - android:id="@+id/errorImageView" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_margin="16dp" - android:alpha="0.56" - android:src="@drawable/ic_error" - app:layout_constraintBottom_toTopOf="@+id/errorTitle" - app:layout_constraintDimensionRatio="1.5:1" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintVertical_bias="0.0" - app:layout_constraintVertical_chainStyle="packed" - app:tint="?colorError" - tools:ignore="ContentDescription" /> - - <TextView - android:id="@+id/errorTitle" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:gravity="center_horizontal|top" - android:minHeight="64dp" - android:textColor="?colorError" - app:autoSizeMaxTextSize="40sp" - app:autoSizeTextType="uniform" - app:layout_constraintBottom_toTopOf="@+id/errorMessage" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/errorImageView" - tools:text="@string/withdraw_error_title" /> - - <TextView - android:id="@+id/errorMessage" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:gravity="center" - android:textAppearance="@style/TextAppearance.Material3.TitleMedium" - app:layout_constraintBottom_toTopOf="@+id/errorDevMessage" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/errorTitle" - tools:text="@string/withdraw_error_message" /> - - <TextView - android:id="@+id/errorDevMessage" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:fontFamily="monospace" - android:gravity="center" - android:textColor="?colorError" - android:textIsSelectable="true" - android:visibility="gone" - app:layout_constraintBottom_toTopOf="@+id/backButton" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/errorMessage" - tools:text="Error: Fetching keys failed: unexpected status for keys: 502" - tools:visibility="visible" /> - - <Button - android:id="@+id/backButton" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:text="@string/button_back" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/wallet/src/main/res/layout/fragment_exchange_fees.xml b/wallet/src/main/res/layout/fragment_exchange_fees.xml @@ -1,140 +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/> - --> - -<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <androidx.constraintlayout.widget.ConstraintLayout - android:layout_width="match_parent" - android:layout_height="wrap_content"> - - <TextView - android:id="@+id/withdrawFeeLabel" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="16dp" - android:text="@string/exchange_fee_withdrawal_fee_label" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - - <TextView - android:id="@+id/withdrawFeeView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginEnd="16dp" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@+id/withdrawFeeLabel" - app:layout_constraintTop_toTopOf="@+id/withdrawFeeLabel" - tools:text="-0.23 TESTKUDOS" - tools:textColor="?colorError" /> - - <TextView - android:id="@+id/overheadLabel" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="16dp" - android:text="@string/exchange_fee_overhead_label" - app:layout_constraintStart_toStartOf="@+id/withdrawFeeLabel" - app:layout_constraintTop_toBottomOf="@+id/withdrawFeeLabel" /> - - <TextView - android:id="@+id/overheadView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - app:layout_constraintEnd_toEndOf="@+id/withdrawFeeView" - app:layout_constraintStart_toEndOf="@+id/overheadLabel" - app:layout_constraintTop_toTopOf="@+id/overheadLabel" - tools:text="-0.42 TESTKUDOS" - tools:textColor="?colorError" /> - - <TextView - android:id="@+id/expirationLabel" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="16dp" - android:text="@string/exchange_fee_coin_expiration_label" - app:layout_constraintStart_toStartOf="@+id/withdrawFeeLabel" - app:layout_constraintTop_toBottomOf="@+id/overheadLabel" /> - - <TextView - android:id="@+id/expirationView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginEnd="16dp" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@+id/expirationLabel" - app:layout_constraintTop_toTopOf="@+id/expirationLabel" - tools:text="in 5 years" /> - - <TextView - android:id="@+id/coinFeesLabel" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="16dp" - android:text="@string/exchange_fee_coin_fees_label" - android:textColor="?android:attr/textColorPrimary" - android:textSize="16sp" - app:layout_constraintStart_toStartOf="@+id/withdrawFeeLabel" - app:layout_constraintTop_toBottomOf="@+id/expirationLabel" /> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/coinFeesList" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="8dp" - android:layout_marginEnd="16dp" - android:overScrollMode="never" - app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/coinFeesLabel" - tools:listitem="@layout/list_item_coin_fee" /> - - <TextView - android:id="@+id/wireFeesLabel" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="16dp" - android:text="@string/exchange_fee_wire_fees_label" - android:textColor="?android:attr/textColorPrimary" - android:textSize="16sp" - app:layout_constraintStart_toStartOf="@+id/withdrawFeeLabel" - app:layout_constraintTop_toBottomOf="@+id/coinFeesList" /> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/wireFeesList" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="8dp" - android:layout_marginEnd="16dp" - android:overScrollMode="never" - app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/wireFeesLabel" - tools:listitem="@layout/list_item_wire_fee" /> - - </androidx.constraintlayout.widget.ConstraintLayout> -</androidx.core.widget.NestedScrollView> diff --git a/wallet/src/main/res/layout/fragment_exchange_list.xml b/wallet/src/main/res/layout/fragment_exchange_list.xml @@ -1,62 +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/> - --> - -<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/list" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:scrollbars="vertical" - android:visibility="invisible" - android:clipToPadding="false" - app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" - tools:listitem="@layout/list_item_exchange" - tools:visibility="visible" /> - - <TextView - android:id="@+id/emptyState" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_gravity="center" - android:gravity="center" - android:text="@string/exchange_list_empty" - android:visibility="invisible" - tools:visibility="visible" /> - - <ProgressBar - android:id="@+id/progressBar" - style="?android:progressBarStyleLarge" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_gravity="center" - android:visibility="invisible" - tools:visibility="visible" /> - - <com.google.android.material.floatingactionbutton.FloatingActionButton - android:id="@+id/addExchangeFab" - style="@style/FabStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:contentDescription="@string/exchange_list_add" - android:src="@drawable/ic_baseline_add" - app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior" /> - -</androidx.coordinatorlayout.widget.CoordinatorLayout> diff --git a/wallet/src/main/res/layout/fragment_product_image.xml b/wallet/src/main/res/layout/fragment_product_image.xml @@ -1,24 +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/> - --> - -<ImageView xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:id="@+id/productImageView" - android:layout_width="match_parent" - android:layout_height="match_parent" - tools:ignore="ContentDescription"> - -</ImageView> -\ No newline at end of file diff --git a/wallet/src/main/res/layout/fragment_prompt_payment.xml b/wallet/src/main/res/layout/fragment_prompt_payment.xml @@ -1,44 +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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - tools:context=".payment.PromptPaymentFragment"> - - <include - android:id="@+id/details" - layout="@layout/payment_details" - android:layout_width="0dp" - android:layout_height="0dp" - app:layout_constraintBottom_toTopOf="@+id/bottom" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - - <include - android:id="@+id/bottom" - layout="@layout/payment_bottom_bar" - android:layout_width="0dp" - android:layout_height="wrap_content" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/details" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/wallet/src/main/res/layout/fragment_uri_input.xml b/wallet/src/main/res/layout/fragment_uri_input.xml @@ -1,78 +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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/uriLayout" - style="@style/Widget.Material3.TextInputLayout.OutlinedBox.Dense" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:hint="@string/enter_uri_label" - app:placeholderText="@string/enter_uri_prefix" - app:boxBackgroundMode="outline" - app:endIconMode="clear_text" - app:endIconTint="?attr/colorControlNormal" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/uriView" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textUri" /> - - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.button.MaterialButton - android:id="@+id/pasteButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="16dp" - android:layout_marginEnd="16dp" - android:layout_weight="1" - android:text="@string/paste" - android:textColor="?colorOnPrimary" - app:icon="@drawable/ic_content_paste" - app:iconTint="?colorOnPrimary" - app:layout_constraintEnd_toStartOf="@+id/okButton" - app:layout_constraintHorizontal_chainStyle="spread_inside" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/uriLayout" - tools:ignore="RtlHardcoded" /> - - <Button - android:id="@+id/okButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="16dp" - android:layout_marginEnd="16dp" - android:backgroundTint="@color/green" - android:text="@string/open" - android:textColor="@android:color/white" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@+id/pasteButton" - app:layout_constraintTop_toBottomOf="@+id/uriLayout" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/wallet/src/main/res/layout/list_item_coin_fee.xml b/wallet/src/main/res/layout/list_item_coin_fee.xml @@ -1,78 +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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="wrap_content"> - - <TextView - android:id="@+id/coinView" - android:layout_width="0dp" - android:layout_height="wrap_content" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:text="Coin: 2 TESTKUDOS (used 3 times)" /> - - <TextView - android:id="@+id/withdrawFeeView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="4dp" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="@+id/coinView" - app:layout_constraintTop_toBottomOf="@+id/coinView" - tools:text="Withdraw Fee: 0.01 TESTKUDOS" /> - - <TextView - android:id="@+id/depositFeeView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="4dp" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="@+id/coinView" - app:layout_constraintTop_toBottomOf="@+id/withdrawFeeView" - tools:text="Deposit Fee: 0.01 TESTKUDOS" /> - - <TextView - android:id="@+id/refreshFeeView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="4dp" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="@+id/coinView" - app:layout_constraintTop_toBottomOf="@+id/depositFeeView" - tools:text="Change Fee: 0.01 TESTKUDOS" /> - - <TextView - android:id="@+id/refundFeeView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="4dp" - android:layout_marginBottom="8dp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="@+id/coinView" - app:layout_constraintTop_toBottomOf="@+id/refreshFeeView" - tools:text="Refund Fee: 0.01 TESTKUDOS" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/wallet/src/main/res/layout/list_item_exchange.xml b/wallet/src/main/res/layout/list_item_exchange.xml @@ -1,61 +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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:background="?attr/selectableItemBackground" - android:paddingTop="16dp" - android:paddingBottom="16dp"> - - <TextView - android:id="@+id/urlView" - style="@style/TransactionTitle" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:textSize="18sp" - app:layout_constraintEnd_toStartOf="@+id/overflowIcon" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:text="exchange.test.taler.net" /> - - <TextView - android:id="@+id/currencyView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:textSize="14sp" - app:layout_constraintEnd_toStartOf="@+id/overflowIcon" - app:layout_constraintStart_toStartOf="@+id/urlView" - app:layout_constraintTop_toBottomOf="@+id/urlView" - tools:text="@string/exchange_list_currency" /> - - <ImageButton - android:id="@+id/overflowIcon" - android:layout_width="48dp" - android:layout_height="48dp" - android:background="?attr/selectableItemBackgroundBorderless" - android:contentDescription="@string/menu" - android:scaleType="centerInside" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:srcCompat="@drawable/ic_baseline_more_vert" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/wallet/src/main/res/layout/list_item_product.xml b/wallet/src/main/res/layout/list_item_product.xml @@ -1,90 +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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:padding="8dp"> - - <TextView - android:id="@+id/quantity" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:gravity="end" - android:minWidth="24dp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:text="31" /> - - <ImageView - android:id="@+id/image" - android:layout_width="wrap_content" - android:layout_height="0dp" - android:layout_marginStart="8dp" - app:layout_constrainedWidth="true" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintDimensionRatio="H,4:3" - app:layout_constraintEnd_toStartOf="@+id/name" - app:layout_constraintStart_toEndOf="@+id/quantity" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintWidth_max="64dp" - tools:ignore="ContentDescription" - tools:srcCompat="@tools:sample/avatars" - tools:visibility="visible" /> - - <TextView - android:id="@+id/name" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginEnd="8dp" - app:layout_constraintBottom_toTopOf="@id/taxes" - app:layout_constraintEnd_toStartOf="@+id/price" - app:layout_constraintStart_toEndOf="@+id/image" - app:layout_constraintTop_toTopOf="parent" - tools:text="A product item that in some cases could have a very long name" /> - - <TextView - android:id="@+id/taxes" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginEnd="8dp" - android:paddingTop="3dp" - app:layout_constraintEnd_toStartOf="@id/price" - app:layout_constraintStart_toEndOf="@id/image" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintHorizontal_bias="0.0" - android:textStyle="italic" - android:visibility="gone" - tools:visibility="visible" - tools:text="incl. 0.2€ VAT" /> - - <TextView - android:id="@+id/price" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintBottom_toBottomOf="parent" - android:textStyle="bold" - tools:text="23.42" /> - - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/wallet/src/main/res/layout/list_item_wire_fee.xml b/wallet/src/main/res/layout/list_item_wire_fee.xml @@ -1,57 +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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="wrap_content"> - - <TextView - android:id="@+id/validityView" - android:layout_width="0dp" - android:layout_height="wrap_content" - app:layout_constrainedWidth="true" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:text="Timespan: Jan 1 2020 - Dec 31 2020" /> - - <TextView - android:id="@+id/wireFeeView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="4dp" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/validityView" - tools:text="Wire Fee: 0.01 TESTKUDOS" /> - - <TextView - android:id="@+id/closingFeeView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="4dp" - android:layout_marginBottom="8dp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/wireFeeView" - tools:text="Closing Fee: 0.01 TESTKUDOS" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/wallet/src/main/res/layout/payment_bottom_bar.xml b/wallet/src/main/res/layout/payment_bottom_bar.xml @@ -1,127 +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/> - --> - -<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:id="@+id/bottomView" - style="@style/BottomCard" - android:layout_width="0dp" - android:layout_height="wrap_content" - tools:showIn="@layout/fragment_prompt_payment"> - - <androidx.constraintlayout.widget.ConstraintLayout - android:id="@+id/bottomLayout" - android:layout_width="match_parent" - android:layout_height="wrap_content"> - - <TextView - android:id="@+id/totalLabelView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginTop="8dp" - android:text="@string/amount_total_label" - android:visibility="invisible" - app:layout_constraintBottom_toTopOf="@+id/confirmButton" - app:layout_constraintEnd_toStartOf="@+id/totalView" - app:layout_constraintHorizontal_bias="1.0" - app:layout_constraintHorizontal_chainStyle="packed" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintVertical_bias="0.0" - tools:visibility="visible" /> - - <TextView - android:id="@+id/totalView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginTop="8dp" - android:layout_marginEnd="8dp" - android:textColor="?android:attr/textColorPrimary" - android:textStyle="bold" - android:visibility="invisible" - app:layout_constraintBottom_toTopOf="@+id/feeView" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="1.0" - app:layout_constraintHorizontal_chainStyle="packed" - app:layout_constraintStart_toEndOf="@+id/totalLabelView" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintVertical_bias="0.0" - tools:text="10 TESTKUDOS" - tools:visibility="visible" /> - - <TextView - android:id="@+id/feeView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginEnd="8dp" - android:visibility="gone" - app:layout_constraintBottom_toTopOf="@+id/confirmButton" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="1.0" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/totalView" - tools:text="@string/payment_fee" - tools:visibility="visible" /> - - <Button - android:id="@+id/cancelButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:enabled="false" - style="@style/Widget.Material3.Button.OutlinedButton" - android:text="@string/payment_button_cancel" - android:textColor="?colorError" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@id/confirmButton" - app:layout_constraintHorizontal_bias="0.0" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/feeView" - tools:enabled="true" /> - - <Button - android:id="@+id/confirmButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:enabled="false" - android:text="@string/payment_button_confirm" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="1.0" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/feeView" - tools:enabled="true"/> - - <ProgressBar - android:id="@+id/confirmProgressBar" - style="?android:attr/progressBarStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:visibility="invisible" - app:layout_constraintBottom_toBottomOf="@+id/confirmButton" - app:layout_constraintEnd_toEndOf="@+id/confirmButton" - app:layout_constraintStart_toStartOf="@+id/confirmButton" - app:layout_constraintTop_toTopOf="@+id/confirmButton" - tools:visibility="visible" /> - - </androidx.constraintlayout.widget.ConstraintLayout> - -</com.google.android.material.card.MaterialCardView> diff --git a/wallet/src/main/res/layout/payment_details.xml b/wallet/src/main/res/layout/payment_details.xml @@ -1,124 +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/> - --> - -<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="0dp" - android:layout_height="0dp" - android:fillViewport="true" - tools:showIn="@layout/fragment_prompt_payment"> - - <androidx.constraintlayout.widget.ConstraintLayout - android:layout_width="match_parent" - android:layout_height="wrap_content"> - - <TextView - android:id="@+id/errorView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:textAlignment="center" - android:textColor="@android:color/holo_red_dark" - android:textSize="22sp" - android:visibility="gone" - app:layout_constraintBottom_toTopOf="@+id/errorHintView" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintVertical_chainStyle="packed" - tools:text="@string/payment_balance_insufficient" - tools:visibility="visible" /> - - <TextView - android:id="@+id/errorHintView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:textAlignment="center" - android:textColor="@android:color/holo_red_dark" - android:textSize="22sp" - android:visibility="gone" - app:layout_constraintBottom_toTopOf="@+id/orderLabelView" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/errorView" - app:layout_constraintVertical_chainStyle="packed" - tools:text="@string/payment_balance_insufficient" - tools:visibility="visible" /> - - <TextView - android:id="@+id/orderLabelView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="16dp" - android:layout_marginEnd="16dp" - android:text="@string/payment_label_order_summary" - android:textAlignment="center" - android:visibility="invisible" - app:layout_constraintBottom_toTopOf="@+id/orderView" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/errorHintView" - tools:visibility="visible" /> - - <TextView - android:id="@+id/orderView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:layout_marginTop="16dp" - android:textAlignment="center" - android:textAppearance="@style/TextAppearance.AppCompat.Headline" - android:textSize="25sp" - android:visibility="invisible" - app:layout_constraintBottom_toTopOf="@+id/productsList" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/orderLabelView" - tools:text="2 x Cappuccino, 1 x Hot Meals, 1 x Dessert" - tools:visibility="visible" /> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/productsList" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:visibility="gone" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/orderView" - tools:listitem="@layout/list_item_product" - tools:visibility="visible" /> - - <ProgressBar - android:id="@+id/progressBar" - style="?android:attr/progressBarStyleLarge" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:indeterminate="false" - android:visibility="invisible" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:visibility="visible" /> - - </androidx.constraintlayout.widget.ConstraintLayout> - -</ScrollView> diff --git a/wallet/src/main/res/navigation/nav_graph.xml b/wallet/src/main/res/navigation/nav_graph.xml @@ -1,372 +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/> - --> - -<navigation xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:id="@+id/nav_graph" - app:startDestination="@id/main"> - - <action - android:id="@+id/action_global_main" - app:destination="@id/main" - app:popUpTo="@id/main" /> - - <action - android:id="@+id/action_global_handleUri" - app:destination="@id/handleUri" - app:popUpTo="@id/main" /> - - <action - android:id="@+id/action_global_paytoUri" - app:destination="@id/paytoUri" - app:popUpTo="@id/main" /> - - <action - android:id="@+id/action_global_deposit" - app:destination="@id/deposit" - app:popUpTo="@id/main" /> - - <action - android:id="@+id/action_global_wireTransferDetails" - app:destination="@id/wireTransferDetails" /> - - <action - android:id="@+id/action_global_bankAccounts" - app:destination="@id/bankAccounts" /> - - <action - android:id="@+id/action_global_reviewExchangeTOS" - app:destination="@id/reviewExchangeTOS" /> - - <action - android:id="@+id/action_global_outgoingPull" - app:destination="@id/outgoingPull" /> - - <action - android:id="@+id/action_global_outgoingPush" - app:destination="@id/outgoingPush" /> - - <action - android:id="@+id/action_global_promptPayment" - app:destination="@id/promptPayment" - app:popUpTo="@id/main" /> - - <action - android:id="@+id/action_global_promptWithdraw" - app:destination="@id/promptWithdraw" - app:popUpTo="@id/main" /> - - <action - android:id="@+id/action_global_promptPullPayment" - app:destination="@id/promptPullPayment" - app:popUpTo="@id/main" /> - - <action - android:id="@+id/action_global_promptPushPayment" - app:destination="@id/promptPushPayment" - app:popUpTo="@id/main" /> - - <action - android:id="@+id/action_global_promptPayTemplate" - app:destination="@id/promptPayTemplate" - app:popUpTo="@id/main" /> - - <action - android:id="@+id/action_global_transactionWithdrawal" - app:destination="@id/transactionWithdrawal" - app:popUpTo="@id/main"/> - - <action - android:id="@+id/action_global_transactionPayment" - app:destination="@id/transactionPayment" - app:popUpTo="@id/main" /> - - <action - android:id="@+id/action_global_transactionRefund" - app:destination="@id/transactionRefund" - app:popUpTo="@id/main" /> - - <action - android:id="@+id/action_global_transactionRefresh" - app:destination="@id/transactionRefresh" - app:popUpTo="@id/main" /> - - <action - android:id="@+id/action_global_transactionDeposit" - app:destination="@id/transactionDeposit" - app:popUpTo="@id/main" /> - - <action - android:id="@+id/action_global_transactionPeer" - app:destination="@id/transactionPeer" - app:popUpTo="@id/main" /> - - <fragment - android:id="@+id/main" - android:name="net.taler.wallet.main.MainFragment" - android:label="@string/balances_title"> - <action - android:id="@+id/action_main_to_uriInput" - app:destination="@id/uriInput" /> - <action - android:id="@+id/action_main_to_exchangeShopping" - app:destination="@id/exchangeShopping" /> - <action - android:id="@+id/action_main_to_performanceStats" - app:destination="@id/performanceStats" /> - <action - android:id="@+id/action_main_to_exchangeList" - app:destination="@id/exchangeList" /> - <action - android:id="@+id/action_main_to_bankAccounts" - app:destination="@id/bankAccounts" /> - <action - android:id="@+id/action_main_to_donauStatement" - app:destination="@id/donauStatement" /> - <action - android:id="@+id/action_main_to_setDonau" - app:destination="@id/setDonau" /> - </fragment> - - <fragment - android:id="@+id/handleUri" - android:name="net.taler.wallet.HandleUriFragment" - android:label="@string/loading"> - <argument - android:name="uri" - app:argType="string" - app:nullable="false" /> - <argument - android:name="from" - app:argType="string" - app:nullable="false" /> - </fragment> - - <fragment - android:id="@+id/paytoUri" - android:name="net.taler.wallet.deposit.PayToUriFragment" - android:label="@string/transactions_send_funds"> - <argument - android:name="uri" - app:argType="string" /> - </fragment> - - <fragment - android:id="@+id/promptPayment" - android:name="net.taler.wallet.payment.PromptPaymentFragment" - android:label="@string/payment_prompt_title" - tools:layout="@layout/fragment_prompt_payment" /> - - <fragment - android:id="@+id/exchangeList" - android:name="net.taler.wallet.exchanges.ExchangeListFragment" - android:label="@string/exchange_list_title" /> - - <fragment - android:id="@+id/setDonau" - android:name="net.taler.wallet.donau.SetDonauFragment" - android:label="@string/donau_title"> - <argument - android:name="donauBaseUrl" - app:argType="string" - app:nullable="true" /> - </fragment> - - <fragment - android:id="@+id/donauStatement" - android:name="net.taler.wallet.donau.DonauStatementFragment" - android:label="@string/donau_statement_title"> - <argument - android:name="host" - app:argType="string" - app:nullable="false" /> - </fragment> - - <fragment - android:id="@+id/wireTransferDetails" - android:name="net.taler.wallet.transfer.WireTransferDetailsFragment" - android:label="@string/wire_transfer" /> - - <fragment - android:id="@+id/deposit" - android:name="net.taler.wallet.deposit.DepositFragment" - android:label="@string/send_deposit_title"> - <argument - android:name="amount" - app:argType="string" - app:nullable="false" /> - <argument - android:name="receiverName" - android:defaultValue="@null" - app:argType="string" - app:nullable="true" /> - <argument - android:name="receiverPostalCode" - android:defaultValue="@null" - app:argType="string" - app:nullable="true" /> - <argument - android:name="receiverTown" - android:defaultValue="@null" - app:argType="string" - app:nullable="true" /> - <argument - android:name="IBAN" - android:defaultValue="@null" - app:argType="string" - app:nullable="true" /> - </fragment> - - <fragment - android:id="@+id/outgoingPull" - android:name="net.taler.wallet.peer.OutgoingPullFragment" - android:label="@string/receive_peer_title" /> - - <fragment - android:id="@+id/outgoingPush" - android:name="net.taler.wallet.peer.OutgoingPushFragment" - android:label="@string/send_peer_title" /> - - <fragment - android:id="@+id/promptPullPayment" - android:name="net.taler.wallet.peer.IncomingPullPaymentFragment" - android:label="@string/pay_peer_title" /> - - <fragment - android:id="@+id/promptPushPayment" - android:name="net.taler.wallet.peer.IncomingPushPaymentFragment" - android:label="@string/receive_peer_payment_title" /> - - <fragment - android:id="@+id/promptPayTemplate" - android:name="net.taler.wallet.payment.PayTemplateFragment" - android:label="@string/payment_pay_template_title"> - <argument - android:name="uri" - app:argType="string" /> - </fragment> - - <fragment - android:id="@+id/transactionWithdrawal" - android:name="net.taler.wallet.transactions.TransactionWithdrawalFragment" - android:label="@string/withdraw_title" /> - - <fragment - android:id="@+id/transactionPayment" - android:name="net.taler.wallet.transactions.TransactionPaymentFragment" - android:label="@string/payment_title" /> - - <fragment - android:id="@+id/transactionRefund" - android:name="net.taler.wallet.transactions.TransactionRefundFragment" - android:label="@string/refund_title" /> - - <fragment - android:id="@+id/transactionRefresh" - android:name="net.taler.wallet.transactions.TransactionRefreshFragment" - android:label="@string/transaction_refresh" /> - - <fragment - android:id="@+id/transactionDeposit" - android:name="net.taler.wallet.transactions.TransactionDepositFragment" - android:label="@string/transaction_deposit" /> - - <fragment - android:id="@+id/transactionPeer" - android:name="net.taler.wallet.transactions.TransactionPeerFragment" - android:label="@string/transactions_detail_title" /> - - <fragment - android:id="@+id/transactionLoss" - android:name="net.taler.wallet.transactions.TransactionLossFragment" - android:label="@string/transaction_denom_loss" /> - - <fragment - android:id="@+id/transactionDummy" - android:name="net.taler.wallet.transactions.TransactionDummyFragment" - android:label="@string/transaction_dummy_title" /> - - <fragment - android:id="@+id/promptWithdraw" - android:name="net.taler.wallet.withdraw.PromptWithdrawFragment" - android:label="@string/nav_prompt_withdraw"> - <argument - android:name="withdrawUri" - app:nullable="true" - app:argType="string" /> - <argument - android:name="withdrawExchangeUri" - app:nullable="true" - app:argType="string" /> - <argument - android:name="editableCurrency" - android:defaultValue="true" - app:argType="boolean" /> - <argument - android:name="exchangeBaseUrl" - app:nullable="true" - app:argType="string" /> - <argument - android:name="amount" - app:nullable="true" - app:argType="string" /> - </fragment> - - <fragment - android:id="@+id/reviewExchangeTOS" - android:name="net.taler.wallet.withdraw.ReviewExchangeTosFragment" - android:label="@string/nav_exchange_tos" /> - - <fragment - android:id="@+id/uriInput" - android:name="net.taler.wallet.UriInputFragment" - android:label="@string/enter_uri" - tools:layout="@layout/fragment_uri_input" /> - - <fragment - android:id="@+id/bankAccounts" - android:name="net.taler.wallet.accounts.BankAccountsFragment" - android:label="@string/send_deposit_known_bank_accounts"> - <argument - android:name="currency" - app:nullable="true" - app:argType="string" /> - <action - android:id="@+id/action_bankAccounts_to_addBankAccount" - app:destination="@id/addBankAccount" /> - </fragment> - - <fragment - android:id="@+id/addBankAccount" - android:name="net.taler.wallet.accounts.AddAccountFragment" - android:label="@string/send_deposit_account_add"> - <argument - android:name="bankAccountId" - app:nullable="true" - app:argType="string" /> - </fragment> - - <fragment - android:id="@+id/exchangeShopping" - android:name="net.taler.wallet.exchanges.ExchangeShoppingFragment" - android:label="@string/exchange_shopping_title" /> - - <fragment - android:id="@+id/performanceStats" - android:name="net.taler.wallet.settings.PerformanceFragment" - android:label="@string/performance_stats_title" /> -</navigation> diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -79,6 +79,7 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="reload">Reload</string> <string name="reset">Reset</string> <string name="save">Save</string> + <string name="selection_count">%1$d selected</string> <string name="share_payment">Share payment link</string> <string name="uri_invalid">Not a valid Taler link</string> <string name="wallet">Wallet</string> diff --git a/wallet/src/main/res/xml/settings_main.xml b/wallet/src/main/res/xml/settings_main.xml @@ -1,144 +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/> - --> - -<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - xmlns:android="http://schemas.android.com/apk/res/android"> - - <Preference - app:fragment="net.taler.wallet.exchanges.ExchangeListFragment" - app:icon="@drawable/ic_server" - app:key="pref_exchanges" - app:summary="@string/exchange_settings_summary" - app:title="@string/exchange_settings_title" /> - - <Preference - android:fragment="net.taler.wallet.accounts.BankAccountsFragment" - android:icon="@drawable/ic_account_balance" - android:key="pref_accounts" - android:summary="@string/settings_bank_accounts_summary" - android:title="@string/settings_bank_accounts" /> - - <Preference - app:fragment="net.taler.wallet.donau.SetDonauFragment" - app:icon="@drawable/ic_donau" - app:key="pref_donau" - app:summary="@string/settings_donau_summary" - app:title="@string/settings_donau" /> - - <SwitchPreference - app:icon="@drawable/ic_shield" - app:key="pref_biometric_lock" - app:title="@string/settings_lock_auth" - app:summary="@string/settings_lock_auth_summary"/> - - <SwitchPreference - app:icon="@drawable/ic_developer_mode" - app:key="pref_dev_mode" - app:summary="@string/settings_dev_mode_summary" - app:title="@string/settings_dev_mode" /> - - <Preference - app:icon="@drawable/ic_cash_usd_outline" - app:isPreferenceVisible="false" - app:key="pref_testkudos" - app:summary="@string/settings_withdraw_testkudos_summary" - app:title="@string/settings_withdraw_testkudos" - tools:isPreferenceVisible="true" /> - - <Preference - app:icon="@drawable/ic_bug_report" - app:isPreferenceVisible="false" - app:key="pref_logcat" - app:summary="@string/settings_logcat_summary" - app:title="@string/settings_logcat" - tools:isPreferenceVisible="true" /> - - <Preference - app:icon="@drawable/ic_stats" - app:isPreferenceVisible="false" - android:key="pref_stats" - android:summary="@string/settings_stats_summary" - android:title="@string/settings_stats" /> - - <Preference - app:icon="@drawable/ic_unarchive" - app:isPreferenceVisible="false" - app:key="pref_export_db" - app:summary="@string/settings_db_export_summary" - app:title="@string/settings_db_export" - tools:isPreferenceVisible="true" /> - - <Preference - app:icon="@drawable/ic_archive" - app:isPreferenceVisible="false" - app:key="pref_import_db" - app:summary="@string/settings_db_import_summary" - app:title="@string/settings_db_import" - tools:isPreferenceVisible="true" /> - - <Preference - app:icon="@drawable/ic_account_balance_wallet" - app:isPreferenceVisible="true" - app:key="pref_version_app" - app:selectable="false" - app:summary="@string/settings_version_unknown" - app:title="@string/settings_version_app" - tools:isPreferenceVisible="true" /> - - <Preference - app:icon="@drawable/ic_adjust" - app:isPreferenceVisible="false" - app:key="pref_version_core" - app:selectable="false" - app:summary="@string/settings_version_unknown" - app:title="@string/settings_version_core" - tools:isPreferenceVisible="true" /> - - <Preference - app:icon="@drawable/ic_account_balance" - app:isPreferenceVisible="false" - app:key="pref_version_protocol_exchange" - app:selectable="false" - app:summary="@string/settings_version_unknown" - app:title="@string/settings_version_protocol_exchange" - tools:isPreferenceVisible="true" /> - - <Preference - app:icon="@drawable/ic_store_mall" - app:isPreferenceVisible="false" - app:key="pref_version_protocol_merchant" - app:selectable="false" - app:summary="@string/settings_version_unknown" - app:title="@string/settings_version_protocol_merchant" - tools:isPreferenceVisible="true" /> - - <Preference - app:isPreferenceVisible="false" - app:key="pref_test" - app:summary="@string/settings_test_summary" - app:title="@string/settings_test" - tools:isPreferenceVisible="true" /> - - <Preference - app:icon="@drawable/ic_nuke" - app:isPreferenceVisible="false" - app:key="pref_reset" - app:summary="@string/settings_reset_summary" - app:title="@string/settings_reset" - tools:isPreferenceVisible="true" /> - -</PreferenceScreen>