fix: tapping on sync completed notification navigates to history

This commit is contained in:
2026-06-07 18:06:28 +02:00
parent 3b14f2fba9
commit 2574a6e1e6
6 changed files with 134 additions and 22 deletions
@@ -38,6 +38,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import cafe.adriel.lyricist.ProvideStrings import cafe.adriel.lyricist.ProvideStrings
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
import com.bitcointxoko.gudariwallet.util.NotificationConstants
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -70,6 +71,7 @@ class MainActivity : AppCompatActivity() {
// WalletScreen observes this and navigates to the detail screen. // WalletScreen observes this and navigates to the detail screen.
// MutableState so Compose recomposes when it changes. // MutableState so Compose recomposes when it changes.
private val pendingPaymentHash = mutableStateOf<String?>(null) private val pendingPaymentHash = mutableStateOf<String?>(null)
val pendingNavigateToHistory = mutableStateOf(false)
// Holds a payment URI that arrived via deep link / intent (BTCPay, external apps). // Holds a payment URI that arrived via deep link / intent (BTCPay, external apps).
private val pendingPaymentUri = mutableStateOf<String?>(null) private val pendingPaymentUri = mutableStateOf<String?>(null)
@@ -102,6 +104,10 @@ class MainActivity : AppCompatActivity() {
// Read hash from the intent that launched the activity (cold start from notification) // Read hash from the intent that launched the activity (cold start from notification)
pendingPaymentHash.value = pendingPaymentHash.value =
intent.getStringExtra(NotificationHelper.EXTRA_PAYMENT_HASH) 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) // Read payment URI from the intent that launched the activity (cold start from deep link)
pendingPaymentUri.value = IntentUtils.extractPaymentUri(intent) pendingPaymentUri.value = IntentUtils.extractPaymentUri(intent)
@@ -132,6 +138,7 @@ class MainActivity : AppCompatActivity() {
vm = vm, vm = vm,
nfcVm = nfcVm, nfcVm = nfcVm,
pendingPaymentHash = pendingPaymentHash, pendingPaymentHash = pendingPaymentHash,
pendingNavigateToHistory = pendingNavigateToHistory,
pendingPaymentUri = pendingPaymentUri, pendingPaymentUri = pendingPaymentUri,
lyricist = lyricist lyricist = lyricist
) )
@@ -187,6 +194,10 @@ class MainActivity : AppCompatActivity() {
pendingPaymentHash.value = hash pendingPaymentHash.value = hash
} }
if (intent.getBooleanExtra(NotificationConstants.NAVIGATE_TO_HISTORY, false)) {
pendingNavigateToHistory.value = true
}
// Deep link / external app URI // Deep link / external app URI
val uri = IntentUtils.extractPaymentUri(intent) val uri = IntentUtils.extractPaymentUri(intent)
if (uri != null) { if (uri != null) {
@@ -87,6 +87,7 @@ fun WalletScreen(
vm : WalletViewModel, vm : WalletViewModel,
nfcVm : NfcViewModel, nfcVm : NfcViewModel,
pendingPaymentHash: MutableState<String?>, pendingPaymentHash: MutableState<String?>,
pendingNavigateToHistory : MutableState<Boolean>,
pendingPaymentUri : MutableState<String?>, pendingPaymentUri : MutableState<String?>,
lyricist : Lyricist<AppStrings> lyricist : Lyricist<AppStrings>
) { ) {
@@ -141,6 +142,15 @@ fun WalletScreen(
pendingPaymentHash.value = null 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 val activity = context as MainActivity
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -8,6 +8,7 @@ import android.os.PowerManager
import android.provider.Settings import android.provider.Settings
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BatteryFull 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.History
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Visibility 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.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -39,9 +40,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -52,6 +55,7 @@ import androidx.core.net.toUri
import androidx.work.WorkInfo import androidx.work.WorkInfo
import com.bitcointxoko.gudariwallet.i18n.AppStrings import com.bitcointxoko.gudariwallet.i18n.AppStrings
import com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorker import com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorker
import kotlinx.coroutines.launch
// ── Page 0: Welcome ──────────────────────────────────────────────────────────── // ── Page 0: Welcome ────────────────────────────────────────────────────────────
@Composable @Composable
@@ -81,7 +85,7 @@ internal fun WelcomePage(strings: AppStrings) {
} }
} }
// ── Page 1: Server URL ───────────────────────────────────────────────────────── // ── Page 1: Server URL ───────────────────────────────────────────────────────
@Composable @Composable
internal fun ServerUrlPage( internal fun ServerUrlPage(
strings: AppStrings, strings: AppStrings,
@@ -89,9 +93,12 @@ internal fun ServerUrlPage(
onBaseUrl: (String) -> Unit, onBaseUrl: (String) -> Unit,
error: String? error: String?
) { ) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
OnboardingPageScaffold( OnboardingPageScaffold(
title = strings.onboarding.serverUrl, // ← "Server URL" title = strings.onboarding.serverUrl,
subtitle = strings.onboarding.serverUrlHint // ← "https://bitcointxoko.org" subtitle = strings.onboarding.serverUrlHint
) { ) {
OutlinedTextField( OutlinedTextField(
value = baseUrl, value = baseUrl,
@@ -101,13 +108,26 @@ internal fun ServerUrlPage(
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), 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) ErrorText(error)
} }
} }
// ── Page 2: Invoice Key ──────────────────────────────────────────────────────── // ── Page 2: Invoice Key ──────────────────────────────────────────────────────
@Composable @Composable
internal fun InvoiceKeyPage( internal fun InvoiceKeyPage(
strings: AppStrings, strings: AppStrings,
@@ -115,8 +135,11 @@ internal fun InvoiceKeyPage(
onInvoiceKey: (String) -> Unit, onInvoiceKey: (String) -> Unit,
error: String? error: String?
) { ) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
OnboardingPageScaffold( OnboardingPageScaffold(
title = strings.onboarding.invoiceKey, // ← "Invoice Key (read-only)" title = strings.onboarding.invoiceKey,
subtitle = "Used to generate payment requests." subtitle = "Used to generate payment requests."
) { ) {
OutlinedTextField( OutlinedTextField(
@@ -127,13 +150,27 @@ internal fun InvoiceKeyPage(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
visualTransformation = PasswordVisualTransformation(), 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) ErrorText(error)
} }
} }
// ── Page 3: Admin Key ────────────────────────────────────────────────────────── // ── Page 3: Admin Key ────────────────────────────────────────────────────────
@Composable @Composable
internal fun AdminKeyPage( internal fun AdminKeyPage(
strings: AppStrings, strings: AppStrings,
@@ -143,8 +180,11 @@ internal fun AdminKeyPage(
onToggleVis: () -> Unit, onToggleVis: () -> Unit,
error: String? error: String?
) { ) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
OnboardingPageScaffold( OnboardingPageScaffold(
title = strings.onboarding.adminKey, // ← "Admin Key (for sending)" title = strings.onboarding.adminKey,
subtitle = "Required to send payments." subtitle = "Required to send payments."
) { ) {
OutlinedTextField( OutlinedTextField(
@@ -158,6 +198,20 @@ internal fun AdminKeyPage(
else PasswordVisualTransformation(), else PasswordVisualTransformation(),
isError = error != null, isError = error != null,
trailingIcon = { trailingIcon = {
// 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) { IconButton(onClick = onToggleVis) {
Icon( Icon(
imageVector = if (adminVisible) Icons.Default.VisibilityOff imageVector = if (adminVisible) Icons.Default.VisibilityOff
@@ -167,6 +221,7 @@ internal fun AdminKeyPage(
) )
} }
} }
}
) )
ErrorText(error) ErrorText(error)
} }
@@ -348,7 +403,7 @@ fun SyncHistoryPage(
// ── Scheduled (deferred, waiting for delay + network) ───────────── // ── Scheduled (deferred, waiting for delay + network) ─────────────
SyncUiState.Scheduled -> { SyncUiState.Scheduled -> {
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center
@@ -64,6 +64,21 @@ fun OnboardingScreen(
LaunchedEffect(pagerState.currentPage) { error = null } 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 { fun validateCurrentPage(): Boolean {
error = when (pagerState.currentPage) { error = when (pagerState.currentPage) {
PAGE_SERVER_URL -> if (baseUrl.isBlank()) strings.onboarding.fieldsRequired else null PAGE_SERVER_URL -> if (baseUrl.isBlank()) strings.onboarding.fieldsRequired else null
@@ -83,7 +98,9 @@ fun OnboardingScreen(
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
userScrollEnabled = true userScrollEnabled = pagerState.currentPage !in listOf(
PAGE_SERVER_URL, PAGE_INVOICE, PAGE_ADMIN
)
) { page -> ) { page ->
when (page) { when (page) {
PAGE_WELCOME -> WelcomePage(strings) PAGE_WELCOME -> WelcomePage(strings)
@@ -152,6 +169,7 @@ fun OnboardingScreen(
val isLastPage = pagerState.currentPage == PAGE_BATTERY val isLastPage = pagerState.currentPage == PAGE_BATTERY
Button(onClick = { Button(onClick = {
if (!validateCurrentPage()) return@Button
scope.launch { scope.launch {
// Save each field as soon as the user advances past it // Save each field as soon as the user advances past it
when (pagerState.currentPage) { when (pagerState.currentPage) {
@@ -163,7 +181,7 @@ fun OnboardingScreen(
pagerState.animateScrollToPage(pagerState.currentPage + 1) pagerState.animateScrollToPage(pagerState.currentPage + 1)
} }
}) { }) {
Text(if (isLastPage) strings.onboarding.next else strings.onboarding.next) Text(strings.onboarding.next)
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null) Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null)
} }
@@ -6,6 +6,7 @@ object NotificationConstants {
const val CHANNEL_PAYMENTS = "gudari_payments" const val CHANNEL_PAYMENTS = "gudari_payments"
const val CHANNEL_SERVICE_STATUS = "gudari_service" const val CHANNEL_SERVICE_STATUS = "gudari_service"
const val CHANNEL_SYNC = "gudari_sync" const val CHANNEL_SYNC = "gudari_sync"
const val NAVIGATE_TO_HISTORY = "navigate_to_history"
// ── Notification IDs ────────────────────────────────────────────────────── // ── Notification IDs ──────────────────────────────────────────────────────
// ID 1 is reserved for the foreground service — Android requires a stable, // ID 1 is reserved for the foreground service — Android requires a stable,
@@ -106,8 +106,6 @@ object NotificationHelper {
/** /**
* Posts a notification when the one-time historical payment sync completes. * 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) { fun notifySyncComplete(context: Context, paymentCount: Int) {
val title = context.getString(R.string.notif_sync_complete_title) val title = context.getString(R.string.notif_sync_complete_title)
@@ -120,10 +118,12 @@ object NotificationHelper {
.setContentTitle(title) .setContentTitle(title)
.setContentText(text) .setContentText(text)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(syncCompletePendingIntent(context))
.build() .build()
) )
} }
// ── Foreground service notification ─────────────────────────────────────── // ── Foreground service notification ───────────────────────────────────────
fun buildServiceNotification(context: Context): 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) { private fun notify(context: Context, id: Int, notification: Notification) {
runCatching { runCatching {
NotificationManagerCompat.from(context).notify(id, notification) NotificationManagerCompat.from(context).notify(id, notification)