From 2574a6e1e634d3a69cd9793aae22097b0c98a58a Mon Sep 17 00:00:00 2001 From: rasputin Date: Sun, 7 Jun 2026 18:06:28 +0200 Subject: [PATCH] fix: tapping on sync completed notification navigates to history --- .../bitcointxoko/gudariwallet/MainActivity.kt | 11 +++ .../gudariwallet/ui/WalletScreen.kt | 10 ++ .../ui/onboarding/OnboardingPages.kt | 91 +++++++++++++++---- .../ui/onboarding/OnboardingScreen.kt | 22 ++++- .../util/NotificationConstants.kt | 1 + .../gudariwallet/util/NotificationHelper.kt | 21 ++++- 6 files changed, 134 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt index f3414cf..0d64978 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import cafe.adriel.lyricist.ProvideStrings import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore +import com.bitcointxoko.gudariwallet.util.NotificationConstants class MainActivity : AppCompatActivity() { @@ -70,6 +71,7 @@ class MainActivity : AppCompatActivity() { // WalletScreen observes this and navigates to the detail screen. // MutableState so Compose recomposes when it changes. private val pendingPaymentHash = mutableStateOf(null) + val pendingNavigateToHistory = mutableStateOf(false) // Holds a payment URI that arrived via deep link / intent (BTCPay, external apps). private val pendingPaymentUri = mutableStateOf(null) @@ -102,6 +104,10 @@ class MainActivity : AppCompatActivity() { // Read hash from the intent that launched the activity (cold start from notification) pendingPaymentHash.value = intent.getStringExtra(NotificationHelper.EXTRA_PAYMENT_HASH) + + if (intent.getBooleanExtra(NotificationConstants.NAVIGATE_TO_HISTORY, false)) { + pendingNavigateToHistory.value = true + } // Read payment URI from the intent that launched the activity (cold start from deep link) pendingPaymentUri.value = IntentUtils.extractPaymentUri(intent) @@ -132,6 +138,7 @@ class MainActivity : AppCompatActivity() { vm = vm, nfcVm = nfcVm, pendingPaymentHash = pendingPaymentHash, + pendingNavigateToHistory = pendingNavigateToHistory, pendingPaymentUri = pendingPaymentUri, lyricist = lyricist ) @@ -187,6 +194,10 @@ class MainActivity : AppCompatActivity() { pendingPaymentHash.value = hash } + if (intent.getBooleanExtra(NotificationConstants.NAVIGATE_TO_HISTORY, false)) { + pendingNavigateToHistory.value = true + } + // Deep link / external app URI val uri = IntentUtils.extractPaymentUri(intent) if (uri != null) { diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt index d1d4855..7433f2f 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt @@ -87,6 +87,7 @@ fun WalletScreen( vm : WalletViewModel, nfcVm : NfcViewModel, pendingPaymentHash: MutableState, + pendingNavigateToHistory : MutableState, pendingPaymentUri : MutableState, lyricist : Lyricist ) { @@ -141,6 +142,15 @@ fun WalletScreen( pendingPaymentHash.value = null } } + val navigateToHistory = pendingNavigateToHistory.value + LaunchedEffect(navigateToHistory) { + if (navigateToHistory) { + navController.navigate(ROUTE_HISTORY) { + launchSingleTop = true + } + pendingNavigateToHistory.value = false + } + } val activity = context as MainActivity LaunchedEffect(Unit) { diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingPages.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingPages.kt index c35e80e..a93dd1d 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingPages.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingPages.kt @@ -8,6 +8,7 @@ import android.os.PowerManager import android.provider.Settings 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BatteryFull +import androidx.compose.material.icons.filled.ContentPaste import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Visibility @@ -28,7 +30,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -39,9 +40,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -52,6 +55,7 @@ import androidx.core.net.toUri import androidx.work.WorkInfo import com.bitcointxoko.gudariwallet.i18n.AppStrings import com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorker +import kotlinx.coroutines.launch // ── Page 0: Welcome ──────────────────────────────────────────────────────────── @Composable @@ -81,7 +85,7 @@ internal fun WelcomePage(strings: AppStrings) { } } -// ── Page 1: Server URL ───────────────────────────────────────────────────────── +// ── Page 1: Server URL ─────────────────────────────────────────────────────── @Composable internal fun ServerUrlPage( strings: AppStrings, @@ -89,9 +93,12 @@ internal fun ServerUrlPage( onBaseUrl: (String) -> Unit, error: String? ) { + val clipboard = LocalClipboard.current +val scope = rememberCoroutineScope() + OnboardingPageScaffold( - title = strings.onboarding.serverUrl, // ← "Server URL" - subtitle = strings.onboarding.serverUrlHint // ← "https://bitcointxoko.org" + title = strings.onboarding.serverUrl, + subtitle = strings.onboarding.serverUrlHint ) { OutlinedTextField( value = baseUrl, @@ -101,13 +108,26 @@ internal fun ServerUrlPage( singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - isError = error != null + isError = error != null, + trailingIcon = { + IconButton(onClick = { + scope.launch { + clipboard.getClipEntry() + ?.clipData?.getItemAt(0)?.text?.toString() + ?.let { onBaseUrl(it) }} + }) { + Icon( + imageVector = Icons.Default.ContentPaste, + contentDescription = "Paste server URL" + ) + } + } ) ErrorText(error) } } -// ── Page 2: Invoice Key ──────────────────────────────────────────────────────── +// ── Page 2: Invoice Key ────────────────────────────────────────────────────── @Composable internal fun InvoiceKeyPage( strings: AppStrings, @@ -115,8 +135,11 @@ internal fun InvoiceKeyPage( onInvoiceKey: (String) -> Unit, error: String? ) { + val clipboard = LocalClipboard.current +val scope = rememberCoroutineScope() + OnboardingPageScaffold( - title = strings.onboarding.invoiceKey, // ← "Invoice Key (read-only)" + title = strings.onboarding.invoiceKey, subtitle = "Used to generate payment requests." ) { OutlinedTextField( @@ -127,13 +150,27 @@ internal fun InvoiceKeyPage( modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), visualTransformation = PasswordVisualTransformation(), - isError = error != null + isError = error != null, + trailingIcon = { + IconButton(onClick = { + scope.launch { + clipboard.getClipEntry() + ?.clipData?.getItemAt(0)?.text?.toString() + ?.let { onInvoiceKey(it) } + } + }) { + Icon( + imageVector = Icons.Default.ContentPaste, + contentDescription = "Paste invoice key" + ) + } + } ) ErrorText(error) } } -// ── Page 3: Admin Key ────────────────────────────────────────────────────────── +// ── Page 3: Admin Key ──────────────────────────────────────────────────────── @Composable internal fun AdminKeyPage( strings: AppStrings, @@ -143,8 +180,11 @@ internal fun AdminKeyPage( onToggleVis: () -> Unit, error: String? ) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + OnboardingPageScaffold( - title = strings.onboarding.adminKey, // ← "Admin Key (for sending)" + title = strings.onboarding.adminKey, subtitle = "Required to send payments." ) { OutlinedTextField( @@ -158,13 +198,28 @@ internal fun AdminKeyPage( else PasswordVisualTransformation(), isError = error != null, trailingIcon = { - IconButton(onClick = onToggleVis) { - Icon( - imageVector = if (adminVisible) Icons.Default.VisibilityOff - else Icons.Default.Visibility, - contentDescription = if (adminVisible) strings.onboarding.hideAdminKey - else strings.onboarding.showAdminKey - ) + // Two icons: paste + visibility toggle + Row { + IconButton(onClick = { + scope.launch { + clipboard.getClipEntry() + ?.clipData?.getItemAt(0)?.text?.toString() + ?.let { onAdminKey(it) } + } + }) { + Icon( + imageVector = Icons.Default.ContentPaste, + contentDescription = "Paste admin key" + ) + } + IconButton(onClick = onToggleVis) { + Icon( + imageVector = if (adminVisible) Icons.Default.VisibilityOff + else Icons.Default.Visibility, + contentDescription = if (adminVisible) strings.onboarding.hideAdminKey + else strings.onboarding.showAdminKey + ) + } } } ) @@ -348,7 +403,7 @@ fun SyncHistoryPage( // ── Scheduled (deferred, waiting for delay + network) ───────────── SyncUiState.Scheduled -> { Text( - text = "Sync scheduled — will run when your device is online.", + text = "Sync scheduled — will run when your device is online. You will get a notification when the sync is done. You can already start using the app.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingScreen.kt index 04ffc38..c84d65b 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingScreen.kt @@ -64,6 +64,21 @@ fun OnboardingScreen( LaunchedEffect(pagerState.currentPage) { error = null } + var previousPage by remember { mutableIntStateOf(0) } + LaunchedEffect(pagerState.currentPage) { + val current = pagerState.currentPage + if (current > previousPage) { + // User moved forward — save whatever the previous page owned + when (previousPage) { + PAGE_SERVER_URL -> secretStore.saveBaseUrl(baseUrl) + PAGE_INVOICE -> secretStore.saveInvoiceKey(invoiceKey) + PAGE_ADMIN -> secretStore.saveAdminKey(adminKey) + else -> Unit + } + } + previousPage = current + } + fun validateCurrentPage(): Boolean { error = when (pagerState.currentPage) { PAGE_SERVER_URL -> if (baseUrl.isBlank()) strings.onboarding.fieldsRequired else null @@ -83,7 +98,9 @@ fun OnboardingScreen( HorizontalPager( state = pagerState, modifier = Modifier.weight(1f), - userScrollEnabled = true + userScrollEnabled = pagerState.currentPage !in listOf( + PAGE_SERVER_URL, PAGE_INVOICE, PAGE_ADMIN + ) ) { page -> when (page) { PAGE_WELCOME -> WelcomePage(strings) @@ -152,6 +169,7 @@ fun OnboardingScreen( val isLastPage = pagerState.currentPage == PAGE_BATTERY Button(onClick = { + if (!validateCurrentPage()) return@Button scope.launch { // Save each field as soon as the user advances past it when (pagerState.currentPage) { @@ -163,7 +181,7 @@ fun OnboardingScreen( pagerState.animateScrollToPage(pagerState.currentPage + 1) } }) { - Text(if (isLastPage) strings.onboarding.next else strings.onboarding.next) + Text(strings.onboarding.next) Spacer(Modifier.width(4.dp)) Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null) } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/util/NotificationConstants.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/util/NotificationConstants.kt index b32e410..f709816 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/util/NotificationConstants.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/util/NotificationConstants.kt @@ -6,6 +6,7 @@ object NotificationConstants { const val CHANNEL_PAYMENTS = "gudari_payments" const val CHANNEL_SERVICE_STATUS = "gudari_service" const val CHANNEL_SYNC = "gudari_sync" + const val NAVIGATE_TO_HISTORY = "navigate_to_history" // ── Notification IDs ────────────────────────────────────────────────────── // ID 1 is reserved for the foreground service — Android requires a stable, diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/util/NotificationHelper.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/util/NotificationHelper.kt index 5d88f01..1484783 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/util/NotificationHelper.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/util/NotificationHelper.kt @@ -106,8 +106,6 @@ object NotificationHelper { /** * Posts a notification when the one-time historical payment sync completes. - * No tap destination needed — the user is already in the app or will land - * on the main screen via the default MainActivity launch. */ fun notifySyncComplete(context: Context, paymentCount: Int) { val title = context.getString(R.string.notif_sync_complete_title) @@ -120,10 +118,12 @@ object NotificationHelper { .setContentTitle(title) .setContentText(text) .setAutoCancel(true) + .setContentIntent(syncCompletePendingIntent(context)) .build() ) } + // ── Foreground service notification ─────────────────────────────────────── fun buildServiceNotification(context: Context): Notification { @@ -194,6 +194,23 @@ object NotificationHelper { ) } + /** + * Builds a PendingIntent that opens MainActivity and signals it to navigate + * to the payment history screen via [NotificationConstants.NAVIGATE_TO_HISTORY]. + */ + private fun syncCompletePendingIntent(context: Context): PendingIntent { + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(NotificationConstants.NAVIGATE_TO_HISTORY, true) + } + return PendingIntent.getActivity( + context, + NotificationConstants.NOTIF_ID_SYNC_COMPLETE, // stable, unique requestCode + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + private fun notify(context: Context, id: Int, notification: Notification) { runCatching { NotificationManagerCompat.from(context).notify(id, notification)