feat: improve onboarding
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user