feat: improve onboarding

This commit is contained in:
2026-06-07 13:45:14 +02:00
parent 3dc4b63fc5
commit 3f25ee4c41
13 changed files with 671 additions and 193 deletions
+1
View File
@@ -74,6 +74,7 @@ dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.material3)
implementation(libs.androidx.ui) implementation(libs.androidx.ui)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
@@ -25,7 +25,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
import com.bitcointxoko.gudariwallet.service.WalletNotificationService 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.WalletScreen
import com.bitcointxoko.gudariwallet.ui.WalletViewModel import com.bitcointxoko.gudariwallet.ui.WalletViewModel
import com.bitcointxoko.gudariwallet.ui.WalletViewModelFactory import com.bitcointxoko.gudariwallet.ui.WalletViewModelFactory
@@ -37,8 +37,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import androidx.compose.runtime.collectAsState 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 cafe.adriel.lyricist.rememberStrings
import com.bitcointxoko.gudariwallet.i18n.AppStrings
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -129,23 +127,26 @@ class MainActivity : AppCompatActivity() {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
WalletNotificationService.start(this@MainActivity) WalletNotificationService.start(this@MainActivity)
requestNotificationPermissionIfNeeded() requestNotificationPermissionIfNeeded()
promptBatteryOptimizationIfNeeded() // promptBatteryOptimizationIfNeeded()
} }
WalletScreen( WalletScreen(
vm = vm, vm = vm,
nfcVm = nfcVm, nfcVm = nfcVm,
pendingPaymentHash = pendingPaymentHash, pendingPaymentHash = pendingPaymentHash,
pendingPaymentUri = pendingPaymentUri, pendingPaymentUri = pendingPaymentUri,
lyricist = lyricist // ← pass down lyricist = lyricist
) )
} else { } else {
OnboardingScreen( OnboardingScreen(
secretStore = secretStore, secretStore = secretStore,
onComplete = { } onRequestNotificationPermission = { requestNotificationPermissionIfNeeded() }, // ← new
onComplete = {
// WalletNotificationService.start(this@MainActivity)
// No permission/battery calls here — handled in-flow
}
) )
} }
}
} // end ProvideStrings
} }
} }
} }
@@ -307,16 +307,30 @@ val EnHomeStrings = AppStrings(
paymentRowNoMemo = "No memo", paymentRowNoMemo = "No memo",
paymentRowPending = "Pending", paymentRowPending = "Pending",
// ── OnboardingScreen ────────────────────────────────────────────────────── onboarding = OnboardingStrings(
onboardingTitle = "Connect to LNbits", back = "Back",
onboardingSubtitle = "Enter your LNbits server URL and API keys.", next = "Next",
onboardingServerUrl = "Server URL", done = "Done",
onboardingServerUrlHint = "https://bitcointxoko.org", title = "Connect to LNbits",
onboardingInvoiceKey = "Invoice Key (read-only)", subtitle = "Enter your LNbits server URL and API keys.",
onboardingAdminKey = "Admin Key (for sending)", serverUrl = "Server URL",
onboardingHideAdminKey = "Hide", serverUrlHint = "https://bitcointxoko.org",
onboardingShowAdminKey = "Show", invoiceKey = "Invoice Key (read-only)",
onboardingFieldsRequired = "All fields are required.", adminKey = "Admin Key (for sending)",
onboardingConnect = "Connect", 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."
)
) )
@@ -306,15 +306,29 @@ val EsHomeStrings = AppStrings(
paymentRowNoMemo = "Sin nota", paymentRowNoMemo = "Sin nota",
paymentRowPending = "Pendiente", paymentRowPending = "Pendiente",
// ── OnboardingScreen ────────────────────────────────────────────────────── onboarding = OnboardingStrings(
onboardingTitle = "Conectar a LNbits", back = "Back",
onboardingSubtitle = "Introduce la URL de tu servidor LNbits y las claves API.", next = "Next",
onboardingServerUrl = "URL del servidor", done = "Done",
onboardingServerUrlHint = "https://bitcointxoko.org", title = "Connect to LNbits",
onboardingInvoiceKey = "Clave de factura (solo lectura)", subtitle = "Enter your LNbits server URL and API keys.",
onboardingAdminKey = "Clave de administrador (para enviar)", serverUrl = "Server URL",
onboardingHideAdminKey = "Ocultar", serverUrlHint = "https://bitcointxoko.org",
onboardingShowAdminKey = "Mostrar", invoiceKey = "Invoice Key (read-only)",
onboardingFieldsRequired = "Todos los campos son obligatorios.", adminKey = "Admin Key (for sending)",
onboardingConnect = "Conectar", 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."
)
) )
@@ -306,15 +306,29 @@ val EuStrings = AppStrings(
paymentRowNoMemo = "Oharrik gabe", paymentRowNoMemo = "Oharrik gabe",
paymentRowPending = "Zain", paymentRowPending = "Zain",
// ── OnboardingScreen ────────────────────────────────────────────────────── onboarding = OnboardingStrings(
onboardingTitle = "Konektatu LNbits-era", back = "Back",
onboardingSubtitle = "Sartu zure LNbits zerbitzariaren URLa eta API gakoak.", next = "Next",
onboardingServerUrl = "Zerbitzariaren URLa", done = "Done",
onboardingServerUrlHint = "https://bitcointxoko.org", title = "Connect to LNbits",
onboardingInvoiceKey = "Faktura-gakoa (irakurtzeko soilik)", subtitle = "Enter your LNbits server URL and API keys.",
onboardingAdminKey = "Administrazio-gakoa (bidaltzeko)", serverUrl = "Server URL",
onboardingHideAdminKey = "Ezkutatu", serverUrlHint = "https://bitcointxoko.org",
onboardingShowAdminKey = "Erakutsi", invoiceKey = "Invoice Key (read-only)",
onboardingFieldsRequired = "Eremu guztiak beharrezkoak dira.", adminKey = "Admin Key (for sending)",
onboardingConnect = "Konektatu", 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."
)
) )
@@ -303,15 +303,31 @@ data class AppStrings(
val paymentRowNoMemo : String, val paymentRowNoMemo : String,
val paymentRowPending : String, val paymentRowPending : String,
// ── OnboardingScreen ────────────────────────────────────────────────────── val onboarding: OnboardingStrings
val onboardingTitle : String, )
val onboardingSubtitle : String,
val onboardingServerUrl : String, data class OnboardingStrings(
val onboardingServerUrlHint : String, val title: String,
val onboardingInvoiceKey : String, val subtitle: String,
val onboardingAdminKey : String, val serverUrl: String,
val onboardingHideAdminKey : String, val serverUrlHint: String,
val onboardingShowAdminKey : String, val invoiceKey: String,
val onboardingFieldsRequired : String, val adminKey: String,
val onboardingConnect : 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
) )
@@ -18,6 +18,7 @@ interface SecretStore {
suspend fun isOnboarded(): Boolean suspend fun isOnboarded(): Boolean
suspend fun saveCredentials(baseUrl: String, invoiceKey: String, adminKey: String) suspend fun saveCredentials(baseUrl: String, invoiceKey: String, adminKey: String)
suspend fun setOnboarded()
} }
class EncryptedSecretStore(context: Context) : SecretStore { class EncryptedSecretStore(context: Context) : SecretStore {
@@ -53,9 +54,15 @@ class EncryptedSecretStore(context: Context) : SecretStore {
.setBaseUrl(baseUrl) .setBaseUrl(baseUrl)
.setInvoiceKey(invoiceKey) .setInvoiceKey(invoiceKey)
.setAdminKey(adminKey) .setAdminKey(adminKey)
.setIsOnboarded(true)
.build() .build()
} }
Log.i(TAG, "Credentials saved and DataStore updated") Log.i(TAG, "Credentials saved and DataStore updated")
} }
override suspend fun setOnboarded() {
dataStore.updateData { current ->
current.toBuilder()
.setIsOnboarded(true)
.build()
}
}
} }
@@ -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<String?>(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"
}
}
}
@@ -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)
)
}
}
}
@@ -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 */ }
}
}
@@ -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<String?>(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)
}
}
}
}
}
}
@@ -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<String?>(null)
fun saveAndComplete(onComplete: () -> Unit) {
viewModelScope.launch {
secretStore.saveCredentials(baseUrl, invoiceKey, adminKey)
onComplete()
}
}
}
+2
View File
@@ -28,6 +28,7 @@ tink = "1.13.0"
ui = "1.11.2" ui = "1.11.2"
secp256k1-kmp = "0.23.0" secp256k1-kmp = "0.23.0"
lyricist = "1.8.0" lyricist = "1.8.0"
material3 = "1.4.0"
[libraries] [libraries]
androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } 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" } 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 = { module = "cafe.adriel.lyricist:lyricist", version.ref = "lyricist" }
lyricist-processor = { module = "cafe.adriel.lyricist:lyricist-processor", 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }