commit 0ea0dc8b531a4c3e418a540449db67b7add53cd5 parent fa714d9dfaee7c441037713cce0486aece522601 Author: Martin Schanzenbach <schanzen@gnunet.org> Date: Wed, 15 Jul 2026 10:50:54 +0200 improve general UX and add send taler URI feature for P2P transactions Diffstat:
17 files changed, 500 insertions(+), 85 deletions(-)
diff --git a/wallet/src/main/java/net/taler/wallet/HandleUriScreen.kt b/wallet/src/main/java/net/taler/wallet/HandleUriScreen.kt @@ -126,24 +126,7 @@ fun HandleUriScreen( onNavigate(WalletDestination.PromptPayTemplate(u2), true) } action.startsWith("add-contact/", ignoreCase = true) -> { - val uri = u2.toUri() - val segments = uri.pathSegments - val mailboxBaseUri = segments.getOrNull(0) ?: "" - val mailboxAddress = segments.getOrNull(1) ?: "" - val petname = uri.getQueryParameter("petname") ?: "" - val alias = uri.getQueryParameter("alias") ?: "" - val aliasType = uri.getQueryParameter("aliasType") ?: "" - val source = uri.getQueryParameter("source") ?: u2 - - model.contactManager.addContact( - alias = alias, - aliasType = aliasType, - mailboxBaseUri = mailboxBaseUri, - mailboxAddress = mailboxAddress, - source = source, - petname = petname - ) - onNavigate(WalletDestination.Contacts, true) + onNavigate(WalletDestination.PromptAddContact(u2), true) } action.startsWith("dev-experiment/", ignoreCase = true) -> { model.applyDevExperiment(u2) { error -> diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt b/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt @@ -36,7 +36,9 @@ 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.BackendManager import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.contacts.ContactScreen import net.taler.wallet.mailbox.MailboxScreen import net.taler.wallet.deposit.DepositScreen @@ -47,6 +49,7 @@ 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.contacts.PromptAddContactScreen import net.taler.wallet.main.MainViewModel import net.taler.wallet.payment.PayTemplateScreen import net.taler.wallet.peer.IncomingPullPaymentScreen @@ -369,15 +372,32 @@ fun WalletNavHost( onNavigateBack = onNavigateBack, ) } - composable<WalletDestination.Contacts> { + composable<WalletDestination.Contacts> { backStackEntry -> + val contacts: WalletDestination.Contacts = backStackEntry.toRoute() + val scopeInfo = contacts.scopeInfo?.let { + BackendManager.json.decodeFromString<ScopeInfo>(it) + } ContactScreen( model = model, + sendUri = contacts.sendUri, + scopeInfo = scopeInfo, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + ) + } + composable<WalletDestination.PromptAddContact> { backStackEntry -> + val dest: WalletDestination.PromptAddContact = backStackEntry.toRoute() + PromptAddContactScreen( + model = model, + uriString = dest.uriString, + onNavigate = onNavigate, onNavigateBack = onNavigateBack, ) } composable<WalletDestination.Mailbox> { MailboxScreen( model = model, + onNavigate = onNavigate, onNavigateBack = onNavigateBack, ) } diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt b/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt @@ -88,6 +88,9 @@ sealed interface WalletDestination { data class PromptPayTemplate(val uri: String) : WalletDestination @Serializable + data class PromptAddContact(val uriString: String) : WalletDestination + + @Serializable data class WireTransferDetails( val showQrCodes: Boolean, ) : WalletDestination @@ -105,7 +108,10 @@ sealed interface WalletDestination { data object PerformanceStats : WalletDestination @Serializable - data object Contacts : WalletDestination + data class Contacts( + val sendUri: String? = null, + val scopeInfo: String? = null, + ) : WalletDestination @Serializable data object Mailbox : WalletDestination diff --git a/wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt b/wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material3.Button import androidx.compose.material3.ButtonColors @@ -55,10 +56,10 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.min import androidx.core.content.ContextCompat +import net.taler.lib.android.AnimatedQrCodeComposable import net.taler.lib.android.QrCodeManager import net.taler.lib.android.QrLogoSize import net.taler.lib.android.copyToClipBoard -import net.taler.lib.android.AnimatedQrCodeComposable import net.taler.wallet.R sealed class QrCodeParams { @@ -80,6 +81,8 @@ fun ColumnScope.QrCodeUriComposable( buttonText: String = stringResource(R.string.copy), showContents: Boolean = true, shareAsQrCode: Boolean = false, + onForward: ((String) -> Unit)? = null, + devMode: Boolean = false, inBetween: (@Composable ColumnScope.() -> Unit)? = null, ) { val context = LocalContext.current @@ -175,11 +178,43 @@ fun ColumnScope.QrCodeUriComposable( contentColor = MaterialTheme.colorScheme.onPrimaryContainer ), ) + + if (devMode && onForward != null && (qrData.startsWith("taler://pay-push") || qrData.startsWith("taler://pay-pull"))) { + ForwardButton( + onClick = { onForward(qrData) }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } } } } @Composable +fun ForwardButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + buttonText: String = stringResource(R.string.contacts_title), + colors: ButtonColors = ButtonDefaults.buttonColors(), +) { + Button( + modifier = modifier, + colors = colors, + onClick = onClick, + ) { + Icon( + Icons.AutoMirrored.Filled.Send, + buttonText, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(buttonText) + } +} + +@Composable fun getQrCodeSize(): Dp { val configuration = LocalConfiguration.current val screenHeight = configuration.screenHeightDp.dp diff --git a/wallet/src/main/java/net/taler/wallet/contacts/ContactScreen.kt b/wallet/src/main/java/net/taler/wallet/contacts/ContactScreen.kt @@ -16,6 +16,7 @@ package net.taler.wallet.contacts +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -27,6 +28,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api @@ -51,18 +53,27 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import net.taler.wallet.NavigateCallback import net.taler.wallet.R +import net.taler.wallet.WalletDestination +import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.main.MainViewModel +import net.taler.wallet.transactions.TransactionStateFilter.Nonfinal @OptIn(ExperimentalMaterial3Api::class) @Composable fun ContactScreen( model: MainViewModel, + sendUri: String? = null, + scopeInfo: ScopeInfo? = null, + onNavigate: NavigateCallback, onNavigateBack: () -> Unit, ) { val contactManager = model.contactManager val contactResult by contactManager.contacts.collectAsState() var showAddDialog by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + var contactToConfirm by remember { mutableStateOf<ContactEntry?>(null) } LaunchedEffect(Unit) { contactManager.loadContacts() @@ -71,7 +82,7 @@ fun ContactScreen( Scaffold( topBar = { TopAppBar( - title = { Text("Contacts") }, + title = { Text(if (sendUri != null) "Send to Contact" else "Contacts") }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( @@ -93,18 +104,63 @@ fun ContactScreen( .fillMaxSize() .padding(innerPadding) ) { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { Text("Search contacts...") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + singleLine = true, + ) + when (val res = contactResult) { is ContactResult.Loading -> Text("Loading...", modifier = Modifier.padding(16.dp)) is ContactResult.Error -> Text("Error: ${res.error}", modifier = Modifier.padding(16.dp)) is ContactResult.Success -> { - ContactList(res.contacts) { contact -> - contactManager.deleteContact(contact) + val filteredContacts = res.contacts.filter { contact -> + contact.petname.fuzzyMatches(searchQuery) || + contact.alias.fuzzyMatches(searchQuery) || + contact.aliasType.fuzzyMatches(searchQuery) } + ContactList( + contacts = filteredContacts, + onDelete = { contact -> contactManager.deleteContact(contact) }, + onSelect = if (sendUri != null) { contact -> + contactToConfirm = contact + } else null + ) } } } } + contactToConfirm?.let { contact -> + AlertDialog( + onDismissRequest = { contactToConfirm = null }, + title = { Text("Send to ${contact.petname}") }, + text = { Text("Are you sure you want to send this to ${contact.petname}?") }, + confirmButton = { + TextButton(onClick = { + model.mailboxManager.sendMessage(contact, sendUri!!) + scopeInfo?.let { + model.showTransactions(it, Nonfinal) + } + onNavigate(WalletDestination.Main, true) + contactToConfirm = null + }) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = { contactToConfirm = null }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + if (showAddDialog) { AddContactDialog( onDismiss = { showAddDialog = false }, @@ -161,27 +217,45 @@ fun AddContactDialog( fun ContactList( contacts: List<ContactEntry>, onDelete: (ContactEntry) -> Unit, + onSelect: ((ContactEntry) -> Unit)? = null, ) { if (contacts.isEmpty()) { Text("No contacts yet.", modifier = Modifier.padding(16.dp)) } else { LazyColumn { items(contacts) { contact -> - ContactItem(contact, onDelete) + ContactItem(contact, onDelete, onSelect) HorizontalDivider() } } } } +private fun String.fuzzyMatches(query: String): Boolean { + if (query.isEmpty()) return true + val lowerQuery = query.lowercase() + val lowerTarget = this.lowercase() + var targetIdx = 0 + var queryIdx = 0 + while (targetIdx < lowerTarget.length && queryIdx < lowerQuery.length) { + if (lowerTarget[targetIdx] == lowerQuery[queryIdx]) { + queryIdx++ + } + targetIdx++ + } + return queryIdx == lowerQuery.length +} + @Composable fun ContactItem( contact: ContactEntry, onDelete: (ContactEntry) -> Unit, + onSelect: ((ContactEntry) -> Unit)? = null, ) { Row( modifier = Modifier .fillMaxWidth() + .clickable(enabled = onSelect != null) { onSelect?.invoke(contact) } .padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { diff --git a/wallet/src/main/java/net/taler/wallet/contacts/PromptAddContactScreen.kt b/wallet/src/main/java/net/taler/wallet/contacts/PromptAddContactScreen.kt @@ -0,0 +1,155 @@ +/* + * 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.contacts + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import net.taler.wallet.R +import net.taler.wallet.WalletDestination +import net.taler.wallet.main.MainViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PromptAddContactScreen( + model: MainViewModel, + uriString: String, + onNavigate: (WalletDestination, Boolean) -> Unit, + onNavigateBack: () -> Unit, +) { + val uri = uriString.toUri() + val segments = uri.pathSegments + var mailboxAddressTmp = uriString; + val aliasType = segments.getOrNull(0) ?: "" + mailboxAddressTmp = mailboxAddressTmp.removePrefix("taler://add-contact/") + mailboxAddressTmp = mailboxAddressTmp.removePrefix("""$aliasType/""") + var alias = segments.getOrNull(1) ?: "" + mailboxAddressTmp = mailboxAddressTmp.removePrefix("""$alias/""") + val mailboxAddressFull = mailboxAddressTmp.split("?")[0] + val mailboxAddress = mailboxAddressFull.split("/").last() + val mailboxBaseUri = buildString { + append("https://") + append(mailboxAddressFull.removeSuffix(mailboxAddress)) + } + var petname by remember { mutableStateOf(uri.getQueryParameter("petname") ?: "") } + val source = uriString + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.contact_add_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = stringResource(R.string.contact_add_intro), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(bottom = 16.dp) + ) + + OutlinedTextField( + value = petname, + onValueChange = { petname = it }, + label = { Text(stringResource(R.string.contact_petname)) }, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) + ) + + OutlinedTextField( + value = alias, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.contact_alias)) }, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) + ) + + OutlinedTextField( + value = mailboxAddress, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.contact_mailbox_address)) }, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) + ) + + OutlinedTextField( + value = mailboxBaseUri, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.contact_mailbox_base_uri)) }, + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp) + ) + + Spacer(modifier = Modifier.weight(1f)) + + Button( + onClick = { + model.contactManager.addContact( + alias = alias, + aliasType = aliasType, + mailboxBaseUri = mailboxBaseUri, + mailboxAddress = mailboxAddress, + source = source, + petname = petname + ) + onNavigate(WalletDestination.Contacts(), true) + }, + modifier = Modifier.fillMaxWidth(), + enabled = petname.isNotBlank() + ) { + Text(stringResource(R.string.contact_add_button)) + } + } + } +} diff --git a/wallet/src/main/java/net/taler/wallet/mailbox/MailboxManager.kt b/wallet/src/main/java/net/taler/wallet/mailbox/MailboxManager.kt @@ -18,10 +18,13 @@ package net.taler.wallet.mailbox import android.util.Log import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString import net.taler.common.Timestamp import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.TalerErrorInfo @@ -68,13 +71,34 @@ sealed class MailboxResult { class MailboxManager( private val api: WalletBackendApi, private val scope: CoroutineScope, + private val isDevMode: () -> Boolean, + private val onMessage: ((String) -> Unit)? = null, ) { private val _mailbox = MutableStateFlow<MailboxResult>(MailboxResult.Loading) val mailbox = _mailbox.asStateFlow() + private var started = false + + fun start() { + if (started) return + started = true + loadMailbox() + scope.launch { + mailbox.first { it is MailboxResult.Success } + while (true) { + delay(30000) + if (isDevMode()) { + val currentState = _mailbox.value + if (currentState is MailboxResult.Success) { + refreshMailbox(currentState.config) + } + } + } + } + } + fun loadMailbox(baseUrl: String = "https://mailbox.gnunet.org") { _mailbox.value = MailboxResult.Loading - Log.e(TAG, "Loding mailbox") scope.launch { Log.e(TAG, "Loading mailbox") api.request("getMailbox", GetMailboxResponse.serializer()) { @@ -83,6 +107,7 @@ class MailboxManager( if (null == resp.mailboxConfiguration) { initializeMailbox(baseUrl) } else { + Log.e(TAG, "Got mailbox ${resp.mailboxConfiguration}") getMessages(resp.mailboxConfiguration) } }.onError { @@ -117,12 +142,17 @@ class MailboxManager( } fun refreshMailbox(config: MailboxConfiguration) { + + Log.e(TAG, "Rereshing mailbox ${config}") scope.launch { api.request("refreshMailbox", MailboxMessagesResponse.serializer()) { JSONObject(BackendManager.json.encodeToString(config)) }.onSuccess { response -> - response.messages.forEach { msg -> - addMessage(msg) + if (response.messages.isNotEmpty()) { + response.messages.forEach { msg -> + addMessage(msg) + } + onMessage?.invoke("Received ${response.messages.size} new message(s)") } getMessages(config) }.onError { @@ -152,4 +182,18 @@ class MailboxManager( } } } + + fun sendMessage(contact: net.taler.wallet.contacts.ContactEntry, talerUri: String) { + scope.launch { + api.request<Unit>("sendTalerUriMailboxMessage") { + put("contact", JSONObject(BackendManager.json.encodeToString(contact))) + put("talerUri", talerUri) + }.onSuccess { + Log.i(TAG, "Message sent successfully to ${contact.petname}") + onMessage?.invoke("Message sent to ${contact.petname}") + }.onError { + Log.e(TAG, "Error sending message to ${contact.petname}: $it") + } + } + } } diff --git a/wallet/src/main/java/net/taler/wallet/mailbox/MailboxScreen.kt b/wallet/src/main/java/net/taler/wallet/mailbox/MailboxScreen.kt @@ -16,7 +16,6 @@ package net.taler.wallet.mailbox -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -28,6 +27,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog @@ -48,6 +48,7 @@ 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.rememberCoroutineScope @@ -61,24 +62,31 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import net.taler.lib.android.toAbsoluteTime +import net.taler.wallet.NavigateCallback import net.taler.wallet.R +import net.taler.wallet.WalletDestination import net.taler.wallet.main.MainViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun MailboxScreen( model: MainViewModel, + onNavigate: NavigateCallback, onNavigateBack: () -> Unit, ) { val mailboxManager = model.mailboxManager val mailboxState by mailboxManager.mailbox.collectAsState() val snackbarHostState = remember { SnackbarHostState() } + val showMessageEvent by model.showMessageEvent.observeAsState() val scope = rememberCoroutineScope() val clipboardManager = LocalClipboardManager.current var showUrlDialog by remember { mutableStateOf(false) } + var showAddressDialog by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - mailboxManager.loadMailbox() + LaunchedEffect(showMessageEvent) { + showMessageEvent?.getIfNotConsumed()?.let { msg -> + snackbarHostState.showSnackbar(msg) + } } Scaffold( @@ -94,10 +102,15 @@ fun MailboxScreen( } }, actions = { + val state = mailboxState + if (state is MailboxResult.Success) { + IconButton(onClick = { showAddressDialog = true }) { + Icon(Icons.Default.Info, contentDescription = "Show Mailbox Address") + } + } IconButton(onClick = { showUrlDialog = true }) { Icon(Icons.Default.Settings, contentDescription = "Configure Mailbox") } - val state = mailboxState if (state is MailboxResult.Success) { IconButton(onClick = { mailboxManager.refreshMailbox(state.config) }) { Icon(Icons.Default.Refresh, contentDescription = "Refresh") @@ -117,19 +130,7 @@ fun MailboxScreen( is MailboxResult.Loading -> Text("Loading...", modifier = Modifier.padding(16.dp)) is MailboxResult.Error -> Text("Error: ${res.error}", modifier = Modifier.padding(16.dp)) is MailboxResult.Success -> { - val host = res.config.mailboxBaseUrl.removePrefix("https://").removePrefix("http://").trimEnd('/') - val contactUri = "taler://add-contact/$host/${res.config.hAddress}" - MailboxHeader( - address = contactUri, - onCopy = { - clipboardManager.setText(AnnotatedString(contactUri)) - scope.launch { - snackbarHostState.showSnackbar("Contact URI copied to clipboard") - } - } - ) - HorizontalDivider() - MessageList(res.messages) { message -> + MessageList(res.messages, onNavigate) { message -> mailboxManager.deleteMessage(res.config, message) } } @@ -137,6 +138,36 @@ fun MailboxScreen( } } + if (showAddressDialog) { + val state = mailboxState + if (state is MailboxResult.Success) { + val mailboxAddressURI = "${state.config.mailboxBaseUrl}/${state.config.hAddress}" + AlertDialog( + onDismissRequest = { showAddressDialog = false }, + title = { Text("Your Mailbox Address") }, + text = { + Text(mailboxAddressURI) + }, + confirmButton = { + TextButton(onClick = { + clipboardManager.setText(AnnotatedString(mailboxAddressURI)) + scope.launch { + snackbarHostState.showSnackbar("Mailbox address URI copied to clipboard") + } + showAddressDialog = false + }) { + Text(stringResource(R.string.copy)) + } + }, + dismissButton = { + TextButton(onClick = { showAddressDialog = false }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + } + if (showUrlDialog) { var url by remember { mutableStateOf("https://mailbox.gnunet.org") } AlertDialog( @@ -168,30 +199,9 @@ fun MailboxScreen( } @Composable -fun MailboxHeader( - address: String, - onCopy: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onCopy() } - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text("Your Mailbox Address", style = MaterialTheme.typography.labelMedium) - Text(address, style = MaterialTheme.typography.bodyMedium) - } - IconButton(onClick = onCopy) { - Icon(Icons.Default.ContentCopy, contentDescription = "Copy") - } - } -} - -@Composable fun MessageList( messages: List<MailboxMessageRecord>, + onNavigate: NavigateCallback, onDelete: (MailboxMessageRecord) -> Unit, ) { if (messages.isEmpty()) { @@ -199,7 +209,7 @@ fun MessageList( } else { LazyColumn { items(messages) { message -> - MessageItem(message, onDelete) + MessageItem(message, onNavigate, onDelete) HorizontalDivider() } } @@ -209,9 +219,12 @@ fun MessageList( @Composable fun MessageItem( message: MailboxMessageRecord, + onNavigate: NavigateCallback, onDelete: (MailboxMessageRecord) -> Unit, ) { val context = LocalContext.current + val isPayPush = message.talerUri.startsWith("taler://pay-push") + val isPayPull = message.talerUri.startsWith("taler://pay-pull") Row( modifier = Modifier .fillMaxWidth() @@ -219,7 +232,15 @@ fun MessageItem( verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { - Text(message.talerUri, style = MaterialTheme.typography.bodyMedium) + if (isPayPush || isPayPull) { + Button( + onClick = { onNavigate(WalletDestination.HandleUri(message.talerUri), false) } + ) { + Text(if (isPayPush) "Accept payment" else "Pay invoice") + } + } else { + Text(message.talerUri, style = MaterialTheme.typography.bodyMedium) + } Text( "From: ${message.originMailboxBaseUrl}", style = MaterialTheme.typography.bodySmall diff --git a/wallet/src/main/java/net/taler/wallet/main/MainScreen.kt b/wallet/src/main/java/net/taler/wallet/main/MainScreen.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import net.taler.common.Event import net.taler.wallet.NavigateCallback import net.taler.wallet.R import net.taler.wallet.WalletDestination @@ -107,6 +108,20 @@ fun MainScreen( model.settingsManager.getActionButtonUsed(context) }.collectAsStateLifecycleAware(true) + val showMessageEvent by model.showMessageEvent.observeAsState() + LaunchedEffect(showMessageEvent) { + showMessageEvent?.getIfNotConsumed()?.let { msg -> + snackbarHostState.showSnackbar(msg) + } + } + + 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) { diff --git a/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt @@ -116,7 +116,11 @@ class MainViewModel( val tokenManager: TokenManager = TokenManager(api) val donauManager: DonauManager = DonauManager(api, viewModelScope, exchangeManager) val contactManager: ContactManager = ContactManager(api, viewModelScope) - val mailboxManager: MailboxManager = MailboxManager(api, viewModelScope) + val mailboxManager: MailboxManager = MailboxManager( + api = api, + scope = viewModelScope, + isDevMode = { devMode.value == true }, + ) { showInfoMessage(it) } private val mAuthenticated = MutableStateFlow(false) val authenticated: StateFlow<Boolean> = mAuthenticated diff --git a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullCredit.kt b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullCredit.kt @@ -49,7 +49,9 @@ import net.taler.wallet.transactions.TransactionState fun ColumnScope.TransactionPeerPullCreditComposable( t: TransactionPeerPullCredit, spec: CurrencySpecification?, + devMode: Boolean, onConfirmKyc: (url: String) -> Unit, + onForward: (uri: String) -> Unit, ) { PeerActions(t, onConfirmKyc) @@ -58,6 +60,8 @@ fun ColumnScope.TransactionPeerPullCreditComposable( amount = t.amountRaw.withSpec(spec), talerUri = t.talerUri, instructionResId = R.string.receive_peer_payment_instruction, + devMode = devMode, + onForward = onForward, ) TransactionAmountComposable( @@ -114,7 +118,14 @@ fun TransactionPeerPullCreditPreview(loading: Boolean = false) { )) ) Surface { - TransactionPeerComposable(t, true, null, Modifier, {}) {} + TransactionPeerComposable( + t = t, + devMode = true, + spec = null, + onConfirmKyc = {}, + onForward = {}, + onTransition = {}, + ) } } @@ -122,4 +133,4 @@ fun TransactionPeerPullCreditPreview(loading: Boolean = false) { @Composable fun TransactionPeerPullCreditLoadingPreview() { TransactionPeerPullCreditPreview(loading = true) -} -\ No newline at end of file +} 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,7 +18,6 @@ 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 @@ -91,6 +90,13 @@ fun TransactionPeerPullDebitPreview() { )) ) Surface { - TransactionPeerComposable(t, true, null, Modifier, {}) {} + TransactionPeerComposable( + t = t, + devMode = true, + spec = null, + onConfirmKyc = {}, + onForward = {}, + onTransition = {}, + ) } } 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,7 +19,6 @@ 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 @@ -99,6 +98,13 @@ fun TransactionPeerPushCreditPreview() { )) ) Surface { - TransactionPeerComposable(t, true, null, Modifier, {}) {} + TransactionPeerComposable( + t = t, + devMode = true, + spec = null, + onConfirmKyc = {}, + onForward = {}, + onTransition = {}, + ) } } diff --git a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt @@ -57,12 +57,19 @@ import net.taler.wallet.transactions.TransactionPeerPushDebit import net.taler.wallet.transactions.TransactionState @Composable -fun ColumnScope.TransactionPeerPushDebitComposable(t: TransactionPeerPushDebit, spec: CurrencySpecification?) { +fun ColumnScope.TransactionPeerPushDebitComposable( + t: TransactionPeerPushDebit, + spec: CurrencySpecification?, + devMode: Boolean, + onForward: ((String) -> Unit)? = null, +) { if (t.error == null) PeerQrCode( state = t.txState, amount = t.amountRaw.withSpec(spec), talerUri = t.talerUri, instructionResId = R.string.send_peer_payment_instruction, + devMode = devMode, + onForward = onForward, ) TransactionAmountComposable( @@ -102,6 +109,8 @@ fun ColumnScope.PeerQrCode( amount: Amount, talerUri: String?, instructionResId: Int, + devMode: Boolean, + onForward: ((String) -> Unit)? = null, ) { if (state == TransactionState(Pending) && state.minor != KycRequired) { Text( @@ -119,6 +128,8 @@ fun ColumnScope.PeerQrCode( clipboardLabel = "Push payment", params = QrCodeParams.Taler, buttonText = stringResource(id = R.string.copy), + onForward = onForward, + devMode = devMode, ) { Text( modifier = Modifier @@ -161,7 +172,14 @@ fun TransactionPeerPushDebitPreview(loading: Boolean = false) { ) TalerSurface { - TransactionPeerComposable(t, true, null, Modifier, {}) {} + TransactionPeerComposable( + t = t, + devMode = true, + spec = null, + onConfirmKyc = {}, + onForward = {}, + onTransition = {}, + ) } } @@ -169,4 +187,4 @@ fun TransactionPeerPushDebitPreview(loading: Boolean = false) { @Composable fun TransactionPeerPushDebitLoadingPreview() { TransactionPeerPushDebitPreview(loading = true) -} -\ No newline at end of file +} diff --git a/wallet/src/main/java/net/taler/wallet/settings/SettingsScreen.kt b/wallet/src/main/java/net/taler/wallet/settings/SettingsScreen.kt @@ -243,7 +243,7 @@ fun SettingsScreen( title = "Contacts", summary = "Contacts", icon = Icons.Default.Person, - onClick = { onNavigate(WalletDestination.Contacts, false) } + onClick = { onNavigate(WalletDestination.Contacts(), false) } ) SettingsItem( diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailScreen.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailScreen.kt @@ -67,6 +67,7 @@ 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.BackendManager import net.taler.wallet.backend.TalerErrorCode import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.balances.ScopeInfo @@ -332,7 +333,16 @@ fun TransactionDetailScreen( t = tx, devMode = devMode, spec = rememberCurrencySpec(tx.amountRaw.currency, tx.scopes, exchangeManager::getSpecForCurrency), - onConfirmKyc = { url -> launchInAppBrowser(context, url) } + onConfirmKyc = { url -> launchInAppBrowser(context, url) }, + onForward = { uri -> + val scopeJson = tx.scopes.firstOrNull()?.let { + BackendManager.json.encodeToString(it) + } + onNavigate(WalletDestination.Contacts(uri, scopeJson), false) + }, + onTransition = { + handleTransactionAction(tx, it, model, onNavigateBack) + } ) { handleTransactionAction(tx, it, model, onNavigateBack) } @@ -414,6 +424,7 @@ fun TransactionPeerComposable( spec: CurrencySpecification?, modifier: Modifier = Modifier, onConfirmKyc: (url: String) -> Unit, + onForward: (uri: String) -> Unit, onTransition: (t: TransactionAction) -> Unit, ) { val scrollState = rememberScrollState() @@ -432,10 +443,10 @@ fun TransactionPeerComposable( ) when (t) { - is TransactionPeerPullCredit -> TransactionPeerPullCreditComposable(t, spec, onConfirmKyc) + is TransactionPeerPullCredit -> TransactionPeerPullCreditComposable(t, spec, devMode, onConfirmKyc, onForward) is TransactionPeerPushCredit -> TransactionPeerPushCreditComposable(t, spec, onConfirmKyc) is TransactionPeerPullDebit -> TransactionPeerPullDebitComposable(t, spec) - is TransactionPeerPushDebit -> TransactionPeerPushDebitComposable(t, spec) + is TransactionPeerPushDebit -> TransactionPeerPushDebitComposable(t, spec, devMode, onForward) else -> {} } diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -92,6 +92,14 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <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="contact_add_title">Add Contact</string> + <string name="contact_add_intro">Do you want to add this contact to your wallet?</string> + <string name="contact_petname">Petname</string> + <string name="contact_alias">Alias</string> + <string name="contact_mailbox_address">Mailbox Address</string> + <string name="contact_mailbox_base_uri">Mailbox Base URI</string> + <string name="contact_add_button">Add Contact</string> + <string name="contacts_title">Contacts</string> <string name="wallet">Wallet</string> <string name="warning">Warning</string> <string name="wire_transfer">Wire transfer</string>