taler-android

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

commit 3fcb7e0a28bb96f97026cb33e542bb0be3321b6b
parent cfd3af80964fb2a4a76df2a1c0dfd7453ba74cfd
Author: Iván Ávalos <avalos@disroot.org>
Date:   Tue, 27 Jan 2026 19:14:25 +0100

[wallet] fix #10674 (shopping URLs)

Diffstat:
Mwallet/src/main/java/net/taler/wallet/balances/Balances.kt | 1+
Awallet/src/main/java/net/taler/wallet/compose/NewMenuComponents.kt | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/src/main/java/net/taler/wallet/exchanges/ExchangeShoppingFragment.kt | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/main/MainFragment.kt | 21++++++++++++++-------
Mwallet/src/main/java/net/taler/wallet/main/TalerActionButton.kt | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mwallet/src/main/res/navigation/nav_graph.xml | 9+++++++++
Mwallet/src/main/res/values/strings.xml | 6++++++
7 files changed, 517 insertions(+), 51 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/balances/Balances.kt b/wallet/src/main/java/net/taler/wallet/balances/Balances.kt @@ -29,6 +29,7 @@ data class BalanceItem( val pendingIncoming: Amount, val pendingOutgoing: Amount, val disablePeerPayments: Boolean, + val shoppingUrls: List<String> = emptyList(), ) { val currency: String get() = available.currency } diff --git a/wallet/src/main/java/net/taler/wallet/compose/NewMenuComponents.kt b/wallet/src/main/java/net/taler/wallet/compose/NewMenuComponents.kt @@ -0,0 +1,188 @@ +/** + * Metrolist Project (C) 2026 + * Licensed under GPL-3.0 | See git history for contributors + */ + +package net.taler.wallet.compose + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +// Enhanced Menu Item - Material 3 Expressive Design +@Composable +fun NewMenuItem( + modifier: Modifier = Modifier, + headlineContent: @Composable () -> Unit, + leadingContent: @Composable (() -> Unit)? = null, + trailingContent: @Composable (() -> Unit)? = null, + supportingContent: @Composable (() -> Unit)? = null, + onClick: (() -> Unit)? = null, + enabled: Boolean = true, +) { + ListItem( + headlineContent = headlineContent, + leadingContent = leadingContent, + trailingContent = trailingContent, + supportingContent = supportingContent, + modifier = modifier + .clickable(enabled = enabled) { onClick?.invoke() } + .padding(horizontal = 4.dp), + tonalElevation = 0.dp + ) +} + +// Enhanced Menu Section Header - Material 3 Expressive Design +@Composable +fun NewMenuSectionHeader( + modifier: Modifier = Modifier, + text: String, +) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp + ), + color = MaterialTheme.colorScheme.primary, + modifier = modifier.padding(horizontal = 20.dp, vertical = 12.dp) + ) +} + +// Enhanced Menu Content - Material 3 Expressive Design +@Composable +fun NewMenuContent( + modifier: Modifier = Modifier, + headerContent: @Composable (() -> Unit)? = null, + actionGrid: @Composable (() -> Unit)? = null, + menuItems: @Composable (() -> Unit)? = null, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Header + headerContent?.invoke() + + // Action Grid + actionGrid?.invoke() + + // Divider if both header and actions exist + if (headerContent != null && actionGrid != null) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) + } + + // Menu Items + menuItems?.invoke() + } +} + +@Composable +fun Material3MenuGroup( + items: List<Material3MenuItemData>, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items.forEachIndexed { index, item -> + val shape = when { + items.size == 1 -> RoundedCornerShape(24.dp) + index == 0 -> RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp, bottomStart = 6.dp, bottomEnd = 6.dp) + index == items.size - 1 -> RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 24.dp, bottomEnd = 24.dp) + else -> RoundedCornerShape(6.dp) + } + + Card( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(), + shape = shape, + colors = item.cardColors ?: CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Material3MenuItemRow(item = item) + } + } + } +} + +@Composable +private fun Material3MenuItemRow( + item: Material3MenuItemData, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + enabled = item.onClick != null, + onClick = { item.onClick?.invoke() } + ) + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + item.icon?.let { icon -> + icon() + Spacer(modifier = Modifier.width(16.dp)) + } + + Column( + modifier = Modifier.weight(1f) + ) { + ProvideTextStyle(MaterialTheme.typography.titleMedium) { + item.title() + } + + item.description?.let { desc -> + Spacer(modifier = Modifier.height(2.dp)) + ProvideTextStyle( + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + desc() + } + } + } + item.trailingContent?.let { trailing -> + Spacer(modifier = Modifier.width(8.dp)) + trailing() + } + } +} + +data class Material3MenuItemData( + val icon: (@Composable () -> Unit)? = null, + val title: @Composable () -> Unit, + val description: (@Composable () -> Unit)? = null, + val onClick: (() -> Unit)? = null, + val cardColors: CardColors? = null, + val trailingContent: (@Composable () -> Unit)? = null, +) +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeShoppingFragment.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeShoppingFragment.kt @@ -0,0 +1,194 @@ +/* + * 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/main/MainFragment.kt b/wallet/src/main/java/net/taler/wallet/main/MainFragment.kt @@ -203,28 +203,30 @@ class MainFragment: Fragment() { !online || (balanceState as? BalanceState.Success)?.balances?.isEmpty() ?: true } - val disablePeer = remember(balanceState, viewMode) { - val selectedScope = (viewMode as? ViewMode.Transactions)?.selectedScope + val selectedScope = (viewMode as? ViewMode.Transactions)?.selectedScope + + val selectedBalance = remember(balanceState, selectedScope) { val balances = (balanceState as? BalanceState.Success)?.balances - ?.filter { !it.disablePeerPayments } - ?: emptyList() selectedScope?.let { - balances.find { it.scopeInfo == selectedScope } == null - } ?: balances.isEmpty() + balances?.find { it.scopeInfo == selectedScope } + } } TalerActionsModal( showSheet = showSheet, sheetState = sheetState, + selectedCurrency = selectedBalance?.currency, + showShopping = selectedBalance?.shoppingUrls?.isNotEmpty() == true, onDismiss = { showSheet = false }, disableActions = disableActions, - disablePeer = disablePeer, + 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 = this@MainFragment::onShoppingDiscovery, ) } } @@ -303,6 +305,11 @@ class MainFragment: Fragment() { model.settingsManager.saveActionButtonUsed(requireContext()) findNavController().navigate(R.id.nav_uri_input) } + + private fun onShoppingDiscovery() { + model.settingsManager.saveActionButtonUsed(requireContext()) + findNavController().navigate(R.id.nav_shopping) + } } @Composable diff --git a/wallet/src/main/java/net/taler/wallet/main/TalerActionButton.kt b/wallet/src/main/java/net/taler/wallet/main/TalerActionButton.kt @@ -20,6 +20,8 @@ import androidx.compose.animation.core.Animatable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues @@ -29,15 +31,20 @@ import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LargeFloatingActionButton +import androidx.compose.material3.ListItem import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.PlainTooltip import androidx.compose.material3.SheetState import androidx.compose.material3.Text import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -48,6 +55,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import kotlinx.coroutines.runBlocking @@ -55,6 +63,9 @@ import net.taler.wallet.R import net.taler.wallet.compose.DemandAttention import net.taler.wallet.compose.GridMenu import net.taler.wallet.compose.GridMenuItem +import net.taler.wallet.compose.Material3MenuGroup +import net.taler.wallet.compose.Material3MenuItemData +import net.taler.wallet.compose.TalerSurface import kotlin.math.roundToInt @Composable @@ -126,6 +137,8 @@ fun TalerActionButton( fun TalerActionsModal( showSheet: Boolean, sheetState: SheetState, + selectedCurrency: String? = null, + showShopping: Boolean, disableActions: Boolean, disablePeer: Boolean, onDismiss: () -> Unit, @@ -135,62 +148,108 @@ fun TalerActionsModal( onDeposit: () -> Unit, onWithdraw: () -> Unit, onEnterUri: () -> Unit, + onShoppingDiscovery: () -> Unit, ) { if (showSheet) { ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, ) { - GridMenu( - contentPadding = PaddingValues( - start = 8.dp, - end = 8.dp, - bottom = 16.dp + WindowInsets - .systemBars - .asPaddingValues() - .calculateBottomPadding(), - ), - ) { - GridMenuItem( - icon = R.drawable.ic_link, - title = R.string.enter_uri, - onClick = { onEnterUri(); onDismiss() }, - ) + 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() }, + ) - 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, + ) + } } } } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +fun TalerActionsModalPreview() { + TalerSurface { + TalerActionsModal( + showSheet = true, + sheetState = rememberModalBottomSheetState(), + selectedCurrency = "CHF", + showShopping = true, + disableActions = false, + disablePeer = false, + onDismiss = {}, + onSend = {}, + onReceive = {}, + onScanQr = {}, + onDeposit = {}, + onWithdraw = {}, + onEnterUri = {}, + onShoppingDiscovery = {}, + ) + } } \ No newline at end of file diff --git a/wallet/src/main/res/navigation/nav_graph.xml b/wallet/src/main/res/navigation/nav_graph.xml @@ -347,6 +347,11 @@ app:argType="string" /> </fragment> + <fragment + android:id="@+id/exchangeShopping" + android:name="net.taler.wallet.exchanges.ExchangeShoppingFragment" + android:label="@string/exchange_shopping_title" /> + <action android:id="@+id/action_global_handle_uri" app:destination="@id/handleUri" /> @@ -388,4 +393,8 @@ android:id="@+id/action_global_reviewExchangeTos" app:destination="@id/reviewExchangeTOS" /> + <action + android:id="@+id/nav_shopping" + app:destination="@id/exchangeShopping" /> + </navigation> diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -426,6 +426,12 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="exchange_tos_missing">Terms of service were not provided by payment service</string> <string name="exchange_tos_view">Review terms of service</string> <string name="exchange_tos_error">Error showing terms of service: %1$s</string> + <string name="exchange_unselected">No payment provider is selected</string> + + <!-- Exchange shopping discovery --> + <string name="exchange_shopping_title">Where to pay</string> + <string name="exchange_shopping_label">Where to pay with %1$s</string> + <string name="exchange_shopping_message">Your payment service provider offers the following website(s) with information about where you can pay in %1$s using your Taler wallet:</string> <!-- Losses -->