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 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<String?>(null)
val pendingNavigateToHistory = mutableStateOf(false)
// Holds a payment URI that arrived via deep link / intent (BTCPay, external apps).
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)
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) {
@@ -87,6 +87,7 @@ fun WalletScreen(
vm : WalletViewModel,
nfcVm : NfcViewModel,
pendingPaymentHash: MutableState<String?>,
pendingNavigateToHistory : MutableState<Boolean>,
pendingPaymentUri : MutableState<String?>,
lyricist : Lyricist<AppStrings>
) {
@@ -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) {
@@ -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
@@ -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)
}
@@ -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,
@@ -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)