From 3f25ee4c41e59e1ba485a8e0f7a083311de61390 Mon Sep 17 00:00:00 2001 From: rasputin Date: Sun, 7 Jun 2026 13:45:14 +0200 Subject: [PATCH] feat: improve onboarding --- app/build.gradle.kts | 1 + .../bitcointxoko/gudariwallet/MainActivity.kt | 19 +- .../gudariwallet/i18n/EnStrings.kt | 36 ++- .../gudariwallet/i18n/EsStrings.kt | 36 ++- .../gudariwallet/i18n/EuStrings.kt | 36 ++- .../bitcointxoko/gudariwallet/i18n/Strings.kt | 38 ++- .../gudariwallet/security/SecretStore.kt | 9 +- .../gudariwallet/ui/OnboardingScreen.kt | 139 --------- .../ui/onboarding/OnboardingComponents.kt | 92 ++++++ .../ui/onboarding/OnboardingPages.kt | 275 ++++++++++++++++++ .../ui/onboarding/OnboardingScreen.kt | 157 ++++++++++ .../ui/onboarding/OnboardingViewModel.kt | 24 ++ gradle/libs.versions.toml | 2 + 13 files changed, 671 insertions(+), 193 deletions(-) delete mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/OnboardingScreen.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingComponents.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingPages.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingScreen.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ca95122..71f222e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -74,6 +74,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.material3) implementation(libs.androidx.ui) testImplementation(libs.junit) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt index e4e1dd9..37a42c2 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt @@ -25,7 +25,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore import com.bitcointxoko.gudariwallet.service.WalletNotificationService -import com.bitcointxoko.gudariwallet.ui.OnboardingScreen +import com.bitcointxoko.gudariwallet.ui.onboarding.OnboardingScreen import com.bitcointxoko.gudariwallet.ui.WalletScreen import com.bitcointxoko.gudariwallet.ui.WalletViewModel import com.bitcointxoko.gudariwallet.ui.WalletViewModelFactory @@ -37,8 +37,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import cafe.adriel.lyricist.ProvideStrings -import cafe.adriel.lyricist.rememberStrings -import com.bitcointxoko.gudariwallet.i18n.AppStrings class MainActivity : AppCompatActivity() { @@ -129,23 +127,26 @@ class MainActivity : AppCompatActivity() { LaunchedEffect(Unit) { WalletNotificationService.start(this@MainActivity) requestNotificationPermissionIfNeeded() - promptBatteryOptimizationIfNeeded() + // promptBatteryOptimizationIfNeeded() } WalletScreen( vm = vm, nfcVm = nfcVm, pendingPaymentHash = pendingPaymentHash, pendingPaymentUri = pendingPaymentUri, - lyricist = lyricist // ← pass down + lyricist = lyricist ) } else { OnboardingScreen( - secretStore = secretStore, - onComplete = { } + secretStore = secretStore, + onRequestNotificationPermission = { requestNotificationPermissionIfNeeded() }, // ← new + onComplete = { + // WalletNotificationService.start(this@MainActivity) + // No permission/battery calls here — handled in-flow + } ) } - - } // end ProvideStrings + } } } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EnStrings.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EnStrings.kt index f9e2647..5f15189 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EnStrings.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EnStrings.kt @@ -307,16 +307,30 @@ val EnHomeStrings = AppStrings( paymentRowNoMemo = "No memo", paymentRowPending = "Pending", - // ── OnboardingScreen ────────────────────────────────────────────────────── - onboardingTitle = "Connect to LNbits", - onboardingSubtitle = "Enter your LNbits server URL and API keys.", - onboardingServerUrl = "Server URL", - onboardingServerUrlHint = "https://bitcointxoko.org", - onboardingInvoiceKey = "Invoice Key (read-only)", - onboardingAdminKey = "Admin Key (for sending)", - onboardingHideAdminKey = "Hide", - onboardingShowAdminKey = "Show", - onboardingFieldsRequired = "All fields are required.", - onboardingConnect = "Connect", + onboarding = OnboardingStrings( + back = "Back", + next = "Next", + done = "Done", + title = "Connect to LNbits", + subtitle = "Enter your LNbits server URL and API keys.", + serverUrl = "Server URL", + serverUrlHint = "https://bitcointxoko.org", + invoiceKey = "Invoice Key (read-only)", + adminKey = "Admin Key (for sending)", + hideAdminKey = "Hide", + showAdminKey = "Show", + fieldsRequired = "All fields are required.", + connect = "Connect", + notificationsTitle = "Stay in the loop", + notificationsSubtitle = "Get notified instantly when you receive a payment.", + allowNotifications = "Allow Notifications", + notificationsNote = "You can change this later in system Settings.", + notificationsAutoGranted = "Notifications are enabled automatically on your device.", + batteryTitle = "Background activity", + batterySubtitle = "Allow Gudari Wallet to run unrestricted so notifications arrive even when the app is closed.", + openSettings = "Open Settings", + batteryNote = "Tap 'Battery' → 'Unrestricted'. You can skip this and change it later.", + batteryAlreadyUnrestricted = "You're all set — no restrictions detected." + ) ) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EsStrings.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EsStrings.kt index 5d46d53..db89209 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EsStrings.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EsStrings.kt @@ -306,15 +306,29 @@ val EsHomeStrings = AppStrings( paymentRowNoMemo = "Sin nota", paymentRowPending = "Pendiente", - // ── OnboardingScreen ────────────────────────────────────────────────────── - onboardingTitle = "Conectar a LNbits", - onboardingSubtitle = "Introduce la URL de tu servidor LNbits y las claves API.", - onboardingServerUrl = "URL del servidor", - onboardingServerUrlHint = "https://bitcointxoko.org", - onboardingInvoiceKey = "Clave de factura (solo lectura)", - onboardingAdminKey = "Clave de administrador (para enviar)", - onboardingHideAdminKey = "Ocultar", - onboardingShowAdminKey = "Mostrar", - onboardingFieldsRequired = "Todos los campos son obligatorios.", - onboardingConnect = "Conectar", + onboarding = OnboardingStrings( + back = "Back", + next = "Next", + done = "Done", + title = "Connect to LNbits", + subtitle = "Enter your LNbits server URL and API keys.", + serverUrl = "Server URL", + serverUrlHint = "https://bitcointxoko.org", + invoiceKey = "Invoice Key (read-only)", + adminKey = "Admin Key (for sending)", + hideAdminKey = "Hide", + showAdminKey = "Show", + fieldsRequired = "All fields are required.", + connect = "Connect", + notificationsTitle = "Stay in the loop", + notificationsSubtitle = "Get notified instantly when you receive a payment.", + allowNotifications = "Allow Notifications", + notificationsNote = "You can change this later in system Settings.", + notificationsAutoGranted = "Notifications are enabled automatically on your device.", + batteryTitle = "Background activity", + batterySubtitle = "Allow Gudari Wallet to run unrestricted so notifications arrive even when the app is closed.", + openSettings = "Open Settings", + batteryNote = "Tap 'Battery' → 'Unrestricted'. You can skip this and change it later.", + batteryAlreadyUnrestricted = "You're all set — no restrictions detected." + ) ) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EuStrings.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EuStrings.kt index 469e118..69ecae1 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EuStrings.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EuStrings.kt @@ -306,15 +306,29 @@ val EuStrings = AppStrings( paymentRowNoMemo = "Oharrik gabe", paymentRowPending = "Zain", - // ── OnboardingScreen ────────────────────────────────────────────────────── - onboardingTitle = "Konektatu LNbits-era", - onboardingSubtitle = "Sartu zure LNbits zerbitzariaren URLa eta API gakoak.", - onboardingServerUrl = "Zerbitzariaren URLa", - onboardingServerUrlHint = "https://bitcointxoko.org", - onboardingInvoiceKey = "Faktura-gakoa (irakurtzeko soilik)", - onboardingAdminKey = "Administrazio-gakoa (bidaltzeko)", - onboardingHideAdminKey = "Ezkutatu", - onboardingShowAdminKey = "Erakutsi", - onboardingFieldsRequired = "Eremu guztiak beharrezkoak dira.", - onboardingConnect = "Konektatu", + onboarding = OnboardingStrings( + back = "Back", + next = "Next", + done = "Done", + title = "Connect to LNbits", + subtitle = "Enter your LNbits server URL and API keys.", + serverUrl = "Server URL", + serverUrlHint = "https://bitcointxoko.org", + invoiceKey = "Invoice Key (read-only)", + adminKey = "Admin Key (for sending)", + hideAdminKey = "Hide", + showAdminKey = "Show", + fieldsRequired = "All fields are required.", + connect = "Connect", + notificationsTitle = "Stay in the loop", + notificationsSubtitle = "Get notified instantly when you receive a payment.", + allowNotifications = "Allow Notifications", + notificationsNote = "You can change this later in system Settings.", + notificationsAutoGranted = "Notifications are enabled automatically on your device.", + batteryTitle = "Background activity", + batterySubtitle = "Allow Gudari Wallet to run unrestricted so notifications arrive even when the app is closed.", + openSettings = "Open Settings", + batteryNote = "Tap 'Battery' → 'Unrestricted'. You can skip this and change it later.", + batteryAlreadyUnrestricted = "You're all set — no restrictions detected." + ) ) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/Strings.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/Strings.kt index 079e8b3..3c6dafc 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/Strings.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/Strings.kt @@ -303,15 +303,31 @@ data class AppStrings( val paymentRowNoMemo : String, val paymentRowPending : String, - // ── OnboardingScreen ────────────────────────────────────────────────────── - val onboardingTitle : String, - val onboardingSubtitle : String, - val onboardingServerUrl : String, - val onboardingServerUrlHint : String, - val onboardingInvoiceKey : String, - val onboardingAdminKey : String, - val onboardingHideAdminKey : String, - val onboardingShowAdminKey : String, - val onboardingFieldsRequired : String, - val onboardingConnect : String, + val onboarding: OnboardingStrings ) + +data class OnboardingStrings( + val title: String, + val subtitle: String, + val serverUrl: String, + val serverUrlHint: String, + val invoiceKey: String, + val adminKey: String, + val showAdminKey: String, + val hideAdminKey: String, + val connect: String, + val fieldsRequired: String, + val back: String, + val next: String, + val done: String, + val notificationsTitle: String, + val notificationsSubtitle: String, + val allowNotifications: String, + val notificationsNote: String, + val notificationsAutoGranted: String, + val batteryTitle: String, + val batterySubtitle: String, + val openSettings: String, + val batteryNote: String, + val batteryAlreadyUnrestricted: String +) \ No newline at end of file diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/security/SecretStore.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/security/SecretStore.kt index 0e4ef27..346cf8d 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/security/SecretStore.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/security/SecretStore.kt @@ -18,6 +18,7 @@ interface SecretStore { suspend fun isOnboarded(): Boolean suspend fun saveCredentials(baseUrl: String, invoiceKey: String, adminKey: String) + suspend fun setOnboarded() } class EncryptedSecretStore(context: Context) : SecretStore { @@ -53,9 +54,15 @@ class EncryptedSecretStore(context: Context) : SecretStore { .setBaseUrl(baseUrl) .setInvoiceKey(invoiceKey) .setAdminKey(adminKey) - .setIsOnboarded(true) .build() } Log.i(TAG, "Credentials saved and DataStore updated") } + override suspend fun setOnboarded() { + dataStore.updateData { current -> + current.toBuilder() + .setIsOnboarded(true) + .build() + } + } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/OnboardingScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/OnboardingScreen.kt deleted file mode 100644 index ce15b86..0000000 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/OnboardingScreen.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.bitcointxoko.gudariwallet.ui - -import androidx.compose.foundation.layout.Arrangement -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.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -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.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import com.bitcointxoko.gudariwallet.LocalAppStrings -import com.bitcointxoko.gudariwallet.security.SecretStore -import kotlinx.coroutines.launch - -@Composable -fun OnboardingScreen( - secretStore: SecretStore, - onComplete : () -> Unit -) { - val strings = LocalAppStrings.current - - var baseUrl by remember { mutableStateOf("https://bitcointxoko.org") } - var invoiceKey by remember { mutableStateOf("") } - var adminKey by remember { mutableStateOf("") } - var adminVisible by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - - Column( - modifier = Modifier - .fillMaxSize() - .padding(32.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = strings.onboardingTitle, // ← "Connect to LNbits" - style = MaterialTheme.typography.headlineMedium - ) - Spacer(Modifier.height(8.dp)) - Text( - text = strings.onboardingSubtitle, // ← "Enter your LNbits server URL and API keys." - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(32.dp)) - - OutlinedTextField( - value = baseUrl, - onValueChange = { baseUrl = it }, - label = { Text(strings.onboardingServerUrl) }, // ← "Server URL" - placeholder = { Text(strings.onboardingServerUrlHint) }, // ← "https://bitcointxoko.org" - singleLine = true, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri) - ) - Spacer(Modifier.height(16.dp)) - - OutlinedTextField( - value = invoiceKey, - onValueChange = { invoiceKey = it }, - label = { Text(strings.onboardingInvoiceKey) }, // ← "Invoice Key (read-only)" - singleLine = true, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - visualTransformation = PasswordVisualTransformation() - ) - Spacer(Modifier.height(16.dp)) - - OutlinedTextField( - value = adminKey, - onValueChange = { adminKey = it }, - label = { Text(strings.onboardingAdminKey) }, // ← "Admin Key (for sending)" - singleLine = true, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - visualTransformation = if (adminVisible) VisualTransformation.None - else PasswordVisualTransformation(), - trailingIcon = { - IconButton(onClick = { adminVisible = !adminVisible }) { - Icon( - imageVector = if (adminVisible) Icons.Default.VisibilityOff - else Icons.Default.Visibility, - contentDescription = if (adminVisible) strings.onboardingHideAdminKey // ← "Hide" - else strings.onboardingShowAdminKey // ← "Show" - ) - } - } - ) - - error?.let { - Spacer(Modifier.height(8.dp)) - Text( - text = it, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall - ) - } - - Spacer(Modifier.height(32.dp)) - - Button( - onClick = { - if (baseUrl.isBlank() || invoiceKey.isBlank() || adminKey.isBlank()) { - error = strings.onboardingFieldsRequired // ← "All fields are required." - return@Button - } - scope.launch { - secretStore.saveCredentials(baseUrl, invoiceKey, adminKey) - onComplete() - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(strings.onboardingConnect) // ← "Connect" - } - } -} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingComponents.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingComponents.kt new file mode 100644 index 0000000..8b49f74 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingComponents.kt @@ -0,0 +1,92 @@ +package com.bitcointxoko.gudariwallet.ui.onboarding + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp + +// ── Reusable page shell ──────────────────────────────────────────────────────── +@Composable +internal fun OnboardingPageScaffold( + title: String, + subtitle: String, + content: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = title, style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(8.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(32.dp)) + content() + } +} + +// ── Inline error text ────────────────────────────────────────────────────────── +@Composable +internal fun ErrorText(error: String?) { + if (error != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } +} + +// ── Dot indicator ────────────────────────────────────────────────────────────── +@Composable +internal fun PagerDotIndicator( + pagerState: PagerState, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + repeat(pagerState.pageCount) { index -> + val isSelected = pagerState.currentPage == index + val color by animateColorAsState( + targetValue = if (isSelected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), + label = "dotColor" + ) + val size = if (isSelected) 10.dp else 8.dp + Box( + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(color) + ) + } + } +} 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 new file mode 100644 index 0000000..5cc2d33 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingPages.kt @@ -0,0 +1,275 @@ +package com.bitcointxoko.gudariwallet.ui.onboarding + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.os.Build +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +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.Notifications +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import com.bitcointxoko.gudariwallet.i18n.AppStrings + +// ── Page 0: Welcome ──────────────────────────────────────────────────────────── +@Composable +internal fun WelcomePage(strings: AppStrings) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Replace with your actual illustration / logo + Text(text = "⚡", style = MaterialTheme.typography.displayLarge) + Spacer(Modifier.height(24.dp)) + Text( + text = strings.onboarding.title, // ← "Connect to LNbits" + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(8.dp)) + Text( + text = strings.onboarding.subtitle, // ← "Enter your LNbits server URL and API keys." + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } +} + +// ── Page 1: Server URL ───────────────────────────────────────────────────────── +@Composable +internal fun ServerUrlPage( + strings: AppStrings, + baseUrl: String, + onBaseUrl: (String) -> Unit, + error: String? +) { + OnboardingPageScaffold( + title = strings.onboarding.serverUrl, // ← "Server URL" + subtitle = strings.onboarding.serverUrlHint // ← "https://bitcointxoko.org" + ) { + OutlinedTextField( + value = baseUrl, + onValueChange = onBaseUrl, + label = { Text(strings.onboarding.serverUrl) }, + placeholder = { Text(strings.onboarding.serverUrlHint) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + isError = error != null + ) + ErrorText(error) + } +} + +// ── Page 2: Invoice Key ──────────────────────────────────────────────────────── +@Composable +internal fun InvoiceKeyPage( + strings: AppStrings, + invoiceKey: String, + onInvoiceKey: (String) -> Unit, + error: String? +) { + OnboardingPageScaffold( + title = strings.onboarding.invoiceKey, // ← "Invoice Key (read-only)" + subtitle = "Used to generate payment requests." + ) { + OutlinedTextField( + value = invoiceKey, + onValueChange = onInvoiceKey, + label = { Text(strings.onboarding.invoiceKey) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + visualTransformation = PasswordVisualTransformation(), + isError = error != null + ) + ErrorText(error) + } +} + +// ── Page 3: Admin Key ────────────────────────────────────────────────────────── +@Composable +internal fun AdminKeyPage( + strings: AppStrings, + adminKey: String, + onAdminKey: (String) -> Unit, + adminVisible: Boolean, + onToggleVis: () -> Unit, + error: String? +) { + OnboardingPageScaffold( + title = strings.onboarding.adminKey, // ← "Admin Key (for sending)" + subtitle = "Required to send payments." + ) { + OutlinedTextField( + value = adminKey, + onValueChange = onAdminKey, + label = { Text(strings.onboarding.adminKey) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + visualTransformation = if (adminVisible) VisualTransformation.None + 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 + ) + } + } + ) + ErrorText(error) + } +} + +// ── Page 4: Notifications ────────────────────────────────────────────────────── +@Composable +fun NotificationsPage( + strings: AppStrings, + onRequestNotificationPermission: () -> Unit +) { + // On API < 33, the permission doesn't exist — show a reduced UI + val needsPermission = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + + OnboardingPageScaffold( + title = strings.onboarding.notificationsTitle, // ← "Stay in the loop" + subtitle = strings.onboarding.notificationsSubtitle // ← "Get notified instantly when you receive a payment." + ) { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = null, + modifier = Modifier.size(72.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.height(32.dp)) + + if (needsPermission) { + Button( + onClick = onRequestNotificationPermission, + modifier = Modifier.fillMaxWidth() + ) { + Text(strings.onboarding.allowNotifications) // ← "Allow Notifications" + } + Spacer(Modifier.height(8.dp)) + Text( + text = strings.onboarding.notificationsNote, + // ← "You can change this later in system Settings." + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } else { + // API < 33: permission granted implicitly, nothing to request + Text( + text = strings.onboarding.notificationsAutoGranted, + // ← "Notifications are enabled automatically on your device." + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } +} + +// ── Page 5: Battery optimisation ────────────────────────────────────────────── +@Composable +fun BatteryPage(strings: AppStrings) { + val context = LocalContext.current + val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager + val alreadyUnrestricted = pm.isIgnoringBatteryOptimizations(context.packageName) + + OnboardingPageScaffold( + title = strings.onboarding.batteryTitle, // ← "Background activity" + subtitle = strings.onboarding.batterySubtitle // ← "Allow Gudari Wallet to run unrestricted so notifications arrive even when the app is closed." + ) { + Icon( + imageVector = Icons.Default.BatteryFull, + contentDescription = null, + modifier = Modifier.size(72.dp), + tint = if (alreadyUnrestricted) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(32.dp)) + + if (alreadyUnrestricted) { + Text( + text = strings.onboarding.batteryAlreadyUnrestricted, + // ← "You're all set — no restrictions detected." + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center + ) + } else { + Button( + onClick = { openBatterySettings(context) }, + modifier = Modifier.fillMaxWidth() + ) { + Text(strings.onboarding.openSettings) // ← "Open Settings" + } + Spacer(Modifier.height(8.dp)) + Text( + text = strings.onboarding.batteryNote, + // ← "Tap 'Battery' → 'Unrestricted'. You can skip this and change it later." + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } +} + +// ── Battery settings helper (moved from MainActivity) ───────────────────────── +private fun openBatterySettings(context: Context) { + val appDetailsIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = "package:${context.packageName}".toUri() + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + try { + context.startActivity(appDetailsIntent) + } catch (_: ActivityNotFoundException) { + try { + context.startActivity( + Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + ) + } catch (_: ActivityNotFoundException) { /* nothing to do */ } + } +} 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 new file mode 100644 index 0000000..e8294f9 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingScreen.kt @@ -0,0 +1,157 @@ +package com.bitcointxoko.gudariwallet.ui.onboarding + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.bitcointxoko.gudariwallet.LocalAppStrings +import com.bitcointxoko.gudariwallet.security.SecretStore +import kotlinx.coroutines.launch + +private const val PAGE_WELCOME = 0 +private const val PAGE_SERVER_URL = 1 +private const val PAGE_INVOICE = 2 +private const val PAGE_ADMIN = 3 +private const val PAGE_NOTIFICATIONS = 4 +private const val PAGE_BATTERY = 5 +private const val PAGE_COUNT = 6 + + +@Composable +fun OnboardingScreen( + secretStore: SecretStore, + onRequestNotificationPermission: () -> Unit, // ← new parameter + onComplete: () -> Unit +) { + val strings = LocalAppStrings.current + val scope = rememberCoroutineScope() + + var baseUrl by remember { mutableStateOf("https://bitcointxoko.org") } + var invoiceKey by remember { mutableStateOf("") } + var adminKey by remember { mutableStateOf("") } + var adminVisible by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + // Track whether credentials have been saved (so we don't save twice on back/forward) + var credentialsSaved by remember { mutableStateOf(false) } + + val pagerState = rememberPagerState(pageCount = { PAGE_COUNT }) + + LaunchedEffect(pagerState.currentPage) { error = null } + + fun validateCurrentPage(): Boolean { + error = when (pagerState.currentPage) { + PAGE_SERVER_URL -> if (baseUrl.isBlank()) strings.onboarding.fieldsRequired else null + PAGE_INVOICE -> if (invoiceKey.isBlank()) strings.onboarding.fieldsRequired else null + PAGE_ADMIN -> if (adminKey.isBlank()) strings.onboarding.fieldsRequired else null + else -> null // PAGE_NOTIFICATIONS and PAGE_BATTERY always pass + } + return error == null + } + + Scaffold { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(1f), + // Prevent swiping back past the admin page once credentials are saved, + // and prevent skipping the permission pages entirely + userScrollEnabled = true + ) { page -> + when (page) { + PAGE_WELCOME -> WelcomePage(strings) + PAGE_SERVER_URL -> ServerUrlPage(strings, baseUrl, { baseUrl = it }, error) + PAGE_INVOICE -> InvoiceKeyPage(strings, invoiceKey, { invoiceKey = it }, error) + PAGE_ADMIN -> AdminKeyPage( + strings = strings, + adminKey = adminKey, + onAdminKey = { adminKey = it }, + adminVisible = adminVisible, + onToggleVis = { adminVisible = !adminVisible }, + error = error + ) + PAGE_NOTIFICATIONS -> NotificationsPage( + strings = strings, + onRequestNotificationPermission = onRequestNotificationPermission + ) + PAGE_BATTERY -> BatteryPage(strings) + } + } + + PagerDotIndicator( + pagerState = pagerState, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(vertical = 12.dp) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + if (pagerState.currentPage > PAGE_WELCOME) { + OutlinedButton(onClick = { + scope.launch { + pagerState.animateScrollToPage(pagerState.currentPage - 1) + } + }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + Spacer(Modifier.width(4.dp)) + Text(strings.onboarding.back) + } + } else { + Spacer(Modifier.width(1.dp)) + } + + val isLastPage = pagerState.currentPage == PAGE_BATTERY + + Button(onClick = { + if (!validateCurrentPage()) return@Button + + when (pagerState.currentPage) { + PAGE_ADMIN -> { + if (!credentialsSaved) { + scope.launch { + secretStore.saveCredentials(baseUrl, invoiceKey, adminKey) + credentialsSaved = true + pagerState.animateScrollToPage(PAGE_NOTIFICATIONS) + } + } else { + scope.launch { pagerState.animateScrollToPage(PAGE_NOTIFICATIONS) } + } + } + PAGE_BATTERY -> { + scope.launch { + secretStore.setOnboarded() + } + } + else -> scope.launch { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + } + }) { + Text(if (isLastPage) strings.onboarding.done else strings.onboarding.next) + if (!isLastPage) { + Spacer(Modifier.width(4.dp)) + Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null) + } + } + } + } + } +} + diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingViewModel.kt new file mode 100644 index 0000000..af90156 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/onboarding/OnboardingViewModel.kt @@ -0,0 +1,24 @@ +package com.bitcointxoko.gudariwallet.ui.onboarding + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bitcointxoko.gudariwallet.security.SecretStore +import kotlinx.coroutines.launch + +class OnboardingViewModel(private val secretStore: SecretStore) : ViewModel() { + var baseUrl by mutableStateOf("https://bitcointxoko.org") + var invoiceKey by mutableStateOf("") + var adminKey by mutableStateOf("") + var adminKeyVisible by mutableStateOf(false) + var error by mutableStateOf(null) + + fun saveAndComplete(onComplete: () -> Unit) { + viewModelScope.launch { + secretStore.saveCredentials(baseUrl, invoiceKey, adminKey) + onComplete() + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b656d5d..c1ae03b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ tink = "1.13.0" ui = "1.11.2" secp256k1-kmp = "0.23.0" lyricist = "1.8.0" +material3 = "1.4.0" [libraries] androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } @@ -71,6 +72,7 @@ androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" } secp256k1-kmp-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1-kmp" } lyricist = { module = "cafe.adriel.lyricist:lyricist", version.ref = "lyricist" } lyricist-processor = { module = "cafe.adriel.lyricist:lyricist-processor", version.ref = "lyricist" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }