Add biometric lock and rate limit handling
Biometric lock: opt-in Face ID/Touch ID/fingerprint app lock with toggle in ProfileScreen. Locks on background, requires auth on foreground return. Platform implementations: BiometricPrompt (Android), LAContext (iOS). Rate limit: 429 responses parsed with Retry-After header, user-friendly error messages in all 10 locales, retry plugin respects 429. ErrorMessageParser updated for both iOS Swift and KMM.
This commit is contained in:
@@ -421,6 +421,8 @@
|
||||
<string name="error_network">Netzwerkfehler. Uberprufen Sie Ihre Verbindung.</string>
|
||||
<string name="error_timeout">Zeituberschreitung. Bitte versuchen Sie es erneut.</string>
|
||||
<string name="error_server">Serverfehler. Bitte versuchen Sie es spater erneut.</string>
|
||||
<string name="error_rate_limit">Zu viele Anfragen. Bitte versuchen Sie es spater erneut.</string>
|
||||
<string name="error_rate_limit_with_delay">Zu viele Anfragen. Bitte versuchen Sie es in %1$d Sekunden erneut.</string>
|
||||
<string name="error_unauthorized">Sitzung abgelaufen. Bitte melden Sie sich erneut an.</string>
|
||||
<string name="error_not_found">Nicht gefunden</string>
|
||||
<string name="error_invalid_input">Bitte uberprufen Sie Ihre Eingabe</string>
|
||||
|
||||
@@ -421,6 +421,8 @@
|
||||
<string name="error_network">Error de red. Verifica tu conexion.</string>
|
||||
<string name="error_timeout">Tiempo de espera agotado. Intenta de nuevo.</string>
|
||||
<string name="error_server">Error del servidor. Intenta mas tarde.</string>
|
||||
<string name="error_rate_limit">Demasiadas solicitudes. Intenta de nuevo mas tarde.</string>
|
||||
<string name="error_rate_limit_with_delay">Demasiadas solicitudes. Intenta de nuevo en %1$d segundos.</string>
|
||||
<string name="error_unauthorized">Sesion expirada. Inicia sesion de nuevo.</string>
|
||||
<string name="error_not_found">No encontrado</string>
|
||||
<string name="error_invalid_input">Verifica los datos ingresados</string>
|
||||
|
||||
@@ -421,6 +421,8 @@
|
||||
<string name="error_network">Erreur reseau. Verifiez votre connexion.</string>
|
||||
<string name="error_timeout">Delai d\'attente depasse. Veuillez reessayer.</string>
|
||||
<string name="error_server">Erreur serveur. Veuillez reessayer plus tard.</string>
|
||||
<string name="error_rate_limit">Trop de requetes. Veuillez reessayer plus tard.</string>
|
||||
<string name="error_rate_limit_with_delay">Trop de requetes. Veuillez reessayer dans %1$d secondes.</string>
|
||||
<string name="error_unauthorized">Session expiree. Veuillez vous reconnecter.</string>
|
||||
<string name="error_not_found">Non trouve</string>
|
||||
<string name="error_invalid_input">Veuillez verifier vos donnees</string>
|
||||
|
||||
@@ -421,6 +421,8 @@
|
||||
<string name="error_network">Errore di rete. Controlla la tua connessione.</string>
|
||||
<string name="error_timeout">Richiesta scaduta. Riprova.</string>
|
||||
<string name="error_server">Errore del server. Riprova più tardi.</string>
|
||||
<string name="error_rate_limit">Troppe richieste. Riprova più tardi.</string>
|
||||
<string name="error_rate_limit_with_delay">Troppe richieste. Riprova tra %1$d secondi.</string>
|
||||
<string name="error_unauthorized">Sessione scaduta. Effettua nuovamente l\'accesso.</string>
|
||||
<string name="error_not_found">Non trovato</string>
|
||||
<string name="error_invalid_input">Controlla i tuoi dati inseriti</string>
|
||||
|
||||
@@ -421,6 +421,8 @@
|
||||
<string name="error_network">ネットワークエラー。接続を確認してください。</string>
|
||||
<string name="error_timeout">リクエストがタイムアウトしました。もう一度お試しください。</string>
|
||||
<string name="error_server">サーバーエラー。後でもう一度お試しください。</string>
|
||||
<string name="error_rate_limit">リクエストが多すぎます。しばらくしてからもう一度お試しください。</string>
|
||||
<string name="error_rate_limit_with_delay">リクエストが多すぎます。%1$d秒後にもう一度お試しください。</string>
|
||||
<string name="error_unauthorized">セッションが期限切れです。再度ログインしてください。</string>
|
||||
<string name="error_not_found">見つかりません</string>
|
||||
<string name="error_invalid_input">入力内容を確認してください</string>
|
||||
|
||||
@@ -421,6 +421,8 @@
|
||||
<string name="error_network">네트워크 오류가 발생했습니다. 연결을 확인하세요.</string>
|
||||
<string name="error_timeout">요청 시간이 초과되었습니다. 다시 시도해 주세요.</string>
|
||||
<string name="error_server">서버 오류가 발생했습니다. 나중에 다시 시도해 주세요.</string>
|
||||
<string name="error_rate_limit">요청이 너무 많습니다. 나중에 다시 시도해 주세요.</string>
|
||||
<string name="error_rate_limit_with_delay">요청이 너무 많습니다. %1$d초 후에 다시 시도해 주세요.</string>
|
||||
<string name="error_unauthorized">세션이 만료되었습니다. 다시 로그인해 주세요.</string>
|
||||
<string name="error_not_found">찾을 수 없음</string>
|
||||
<string name="error_invalid_input">입력 내용을 확인해 주세요</string>
|
||||
|
||||
@@ -421,6 +421,8 @@
|
||||
<string name="error_network">Netwerkfout. Controleer uw verbinding.</string>
|
||||
<string name="error_timeout">Verzoek verlopen. Probeer het opnieuw.</string>
|
||||
<string name="error_server">Serverfout. Probeer het later opnieuw.</string>
|
||||
<string name="error_rate_limit">Te veel verzoeken. Probeer het later opnieuw.</string>
|
||||
<string name="error_rate_limit_with_delay">Te veel verzoeken. Probeer het over %1$d seconden opnieuw.</string>
|
||||
<string name="error_unauthorized">Sessie verlopen. Log opnieuw in.</string>
|
||||
<string name="error_not_found">Niet gevonden</string>
|
||||
<string name="error_invalid_input">Controleer uw invoer</string>
|
||||
|
||||
@@ -421,6 +421,8 @@
|
||||
<string name="error_network">Erro de rede. Verifique sua conexao.</string>
|
||||
<string name="error_timeout">Tempo esgotado. Tente novamente.</string>
|
||||
<string name="error_server">Erro do servidor. Tente mais tarde.</string>
|
||||
<string name="error_rate_limit">Muitas solicitacoes. Tente novamente mais tarde.</string>
|
||||
<string name="error_rate_limit_with_delay">Muitas solicitacoes. Tente novamente em %1$d segundos.</string>
|
||||
<string name="error_unauthorized">Sessao expirada. Entre novamente.</string>
|
||||
<string name="error_not_found">Nao encontrado</string>
|
||||
<string name="error_invalid_input">Verifique os dados informados</string>
|
||||
|
||||
@@ -421,6 +421,8 @@
|
||||
<string name="error_network">网络错误,请检查您的连接。</string>
|
||||
<string name="error_timeout">请求超时,请重试。</string>
|
||||
<string name="error_server">服务器错误,请稍后重试。</string>
|
||||
<string name="error_rate_limit">请求过多,请稍后重试。</string>
|
||||
<string name="error_rate_limit_with_delay">请求过多,请在%1$d秒后重试。</string>
|
||||
<string name="error_unauthorized">会话已过期,请重新登录。</string>
|
||||
<string name="error_not_found">未找到</string>
|
||||
<string name="error_invalid_input">请检查您的输入</string>
|
||||
|
||||
@@ -654,6 +654,8 @@
|
||||
<string name="error_network">Network error. Please check your connection.</string>
|
||||
<string name="error_timeout">Request timed out. Please try again.</string>
|
||||
<string name="error_server">Server error. Please try again later.</string>
|
||||
<string name="error_rate_limit">Too many requests. Please try again later.</string>
|
||||
<string name="error_rate_limit_with_delay">Too many requests. Please try again in %1$d seconds.</string>
|
||||
<string name="error_unauthorized">Session expired. Please log in again.</string>
|
||||
<string name="error_not_found">Not found</string>
|
||||
<string name="error_invalid_input">Please check your input</string>
|
||||
@@ -810,4 +812,15 @@
|
||||
<string name="onboarding_subscription_start_trial">Start 7-Day Free Trial</string>
|
||||
<string name="onboarding_subscription_continue_free">Continue with Free</string>
|
||||
<string name="onboarding_subscription_trial_terms">7-day free trial, then %1$s. Cancel anytime.</string>
|
||||
|
||||
<!-- Biometric Lock -->
|
||||
<string name="biometric_lock_title">App Locked</string>
|
||||
<string name="biometric_lock_description">Authenticate to unlock honeyDue</string>
|
||||
<string name="biometric_lock_setting_title">Biometric Lock</string>
|
||||
<string name="biometric_lock_setting_subtitle">Require authentication when opening the app</string>
|
||||
<string name="biometric_prompt_title">Unlock honeyDue</string>
|
||||
<string name="biometric_prompt_subtitle">Verify your identity to continue</string>
|
||||
<string name="biometric_unlock_button">Unlock with Biometrics</string>
|
||||
<string name="biometric_auth_failed">Authentication failed</string>
|
||||
<string name="biometric_not_available">Biometric authentication is not available on this device</string>
|
||||
</resources>
|
||||
|
||||
@@ -60,6 +60,12 @@ import com.tt.honeyDue.network.APILayer
|
||||
import com.tt.honeyDue.platform.ContractorImportHandler
|
||||
import com.tt.honeyDue.platform.PlatformUpgradeScreen
|
||||
import com.tt.honeyDue.platform.ResidenceImportHandler
|
||||
import com.tt.honeyDue.storage.BiometricPreference
|
||||
import com.tt.honeyDue.ui.screens.BiometricLockScreen
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
|
||||
import honeydue.composeapp.generated.resources.Res
|
||||
import honeydue.composeapp.generated.resources.compose_multiplatform
|
||||
@@ -82,6 +88,28 @@ fun App(
|
||||
var hasCompletedOnboarding by remember { mutableStateOf(DataManager.hasCompletedOnboarding.value) }
|
||||
val navController = rememberNavController()
|
||||
|
||||
// Biometric lock state - starts locked if biometric is enabled and user is logged in
|
||||
var isLocked by rememberSaveable {
|
||||
mutableStateOf(BiometricPreference.isBiometricEnabled() && DataManager.authToken.value != null)
|
||||
}
|
||||
|
||||
// Lock the app when returning from background
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP) {
|
||||
// App going to background - lock if biometric is enabled
|
||||
if (BiometricPreference.isBiometricEnabled() && DataManager.authToken.value != null) {
|
||||
isLocked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle navigation from notification tap
|
||||
// Note: The actual navigation to the task column happens in MainScreen -> AllTasksScreen
|
||||
// We just need to ensure the user is on MainRoute when a task navigation is requested
|
||||
@@ -156,6 +184,7 @@ fun App(
|
||||
else -> MainRoute
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
@@ -692,6 +721,14 @@ fun App(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Biometric lock overlay - shown when app is locked
|
||||
if (isLocked && isLoggedIn && isVerified) {
|
||||
BiometricLockScreen(
|
||||
onUnlocked = { isLocked = false }
|
||||
)
|
||||
}
|
||||
} // close Box
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -52,11 +52,12 @@ fun HttpClientConfig<*>.installCommonPlugins() {
|
||||
gzip()
|
||||
}
|
||||
|
||||
// Task 2: Retry with exponential backoff for server errors and IO exceptions
|
||||
// Task 2: Retry with exponential backoff for server errors, IO exceptions,
|
||||
// and 429 rate-limit responses (using the Retry-After header for delay).
|
||||
install(HttpRequestRetry) {
|
||||
maxRetries = 3
|
||||
retryIf { _, response ->
|
||||
response.status.value in 500..599
|
||||
response.status.value in 500..599 || response.status.value == 429
|
||||
}
|
||||
retryOnExceptionIf { _, cause ->
|
||||
// Retry on network-level IO errors (connection resets, timeouts, etc.)
|
||||
@@ -67,8 +68,8 @@ fun HttpClientConfig<*>.installCommonPlugins() {
|
||||
cause is kotlinx.io.IOException
|
||||
}
|
||||
exponentialDelay(
|
||||
base = 1000.0, // 1 second base
|
||||
maxDelayMs = 10000 // 10 second max
|
||||
base = 1000.0,
|
||||
maxDelayMs = 10_000
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,19 @@ object ErrorParser {
|
||||
* Parses error response from the backend.
|
||||
* The backend returns: {"error": "message"}
|
||||
* Falls back to detail field or field-specific errors if present.
|
||||
* For 429 responses, includes the Retry-After value in the message.
|
||||
*/
|
||||
suspend fun parseError(response: HttpResponse): String {
|
||||
// Handle 429 rate-limit responses with Retry-After header
|
||||
if (response.status.value == 429) {
|
||||
val retryAfter = response.headers["Retry-After"]?.toLongOrNull()
|
||||
return if (retryAfter != null) {
|
||||
"Too many requests. Please try again in $retryAfter seconds."
|
||||
} else {
|
||||
"Too many requests. Please try again later."
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
val errorResponse = response.body<ErrorResponse>()
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* Result of a biometric authentication attempt.
|
||||
*/
|
||||
sealed class BiometricResult {
|
||||
/** Authentication succeeded */
|
||||
data object Success : BiometricResult()
|
||||
/** Authentication failed (wrong biometric, cancelled, etc.) */
|
||||
data class Failed(val message: String) : BiometricResult()
|
||||
/** Biometric hardware not available on this device */
|
||||
data object NotAvailable : BiometricResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for biometric authentication operations.
|
||||
*/
|
||||
interface BiometricAuthPerformer {
|
||||
/** Check if biometric authentication is available on this device */
|
||||
fun isBiometricAvailable(): Boolean
|
||||
|
||||
/** Trigger biometric authentication with the given reason string */
|
||||
fun authenticate(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onResult: (BiometricResult) -> Unit
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember a biometric auth performer for the current platform.
|
||||
* Follows the same expect/actual composable pattern as rememberHapticFeedback.
|
||||
*/
|
||||
@Composable
|
||||
expect fun rememberBiometricAuth(): BiometricAuthPerformer
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.tt.honeyDue.storage
|
||||
|
||||
/**
|
||||
* Cross-platform biometric preference storage for persisting biometric lock setting.
|
||||
* Uses platform-specific implementations (SharedPreferences on Android, UserDefaults on iOS).
|
||||
* Follows the same pattern as ThemeStorage / ThemeStorageManager.
|
||||
*/
|
||||
object BiometricPreference {
|
||||
private var manager: BiometricPreferenceManager? = null
|
||||
|
||||
fun initialize(biometricManager: BiometricPreferenceManager) {
|
||||
manager = biometricManager
|
||||
}
|
||||
|
||||
fun isBiometricEnabled(): Boolean {
|
||||
return manager?.isBiometricEnabled() ?: false
|
||||
}
|
||||
|
||||
fun setBiometricEnabled(enabled: Boolean) {
|
||||
manager?.setBiometricEnabled(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform-specific biometric preference storage interface.
|
||||
* Each platform implements this using their native storage mechanisms.
|
||||
*/
|
||||
expect class BiometricPreferenceManager {
|
||||
fun isBiometricEnabled(): Boolean
|
||||
fun setBiometricEnabled(enabled: Boolean)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.tt.honeyDue.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Fingerprint
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tt.honeyDue.platform.BiometricResult
|
||||
import com.tt.honeyDue.platform.rememberBiometricAuth
|
||||
import com.tt.honeyDue.ui.theme.*
|
||||
import honeydue.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
* Lock screen shown when the app returns to foreground with biometric lock enabled.
|
||||
* Displays app logo and triggers biometric authentication.
|
||||
* Follows existing design system (OrganicDesign, theme colors).
|
||||
*/
|
||||
@Composable
|
||||
fun BiometricLockScreen(
|
||||
onUnlocked: () -> Unit
|
||||
) {
|
||||
val biometricAuth = rememberBiometricAuth()
|
||||
var authError by remember { mutableStateOf<String?>(null) }
|
||||
val promptTitle = stringResource(Res.string.biometric_prompt_title)
|
||||
val promptSubtitle = stringResource(Res.string.biometric_prompt_subtitle)
|
||||
|
||||
// Auto-trigger biometric prompt on appear
|
||||
LaunchedEffect(Unit) {
|
||||
biometricAuth.authenticate(
|
||||
title = promptTitle,
|
||||
subtitle = promptSubtitle
|
||||
) { result ->
|
||||
when (result) {
|
||||
is BiometricResult.Success -> onUnlocked()
|
||||
is BiometricResult.Failed -> authError = result.message
|
||||
is BiometricResult.NotAvailable -> onUnlocked() // Fallback: unlock if biometric unavailable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
WarmGradientBackground {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
// Lock icon
|
||||
OrganicIconContainer(
|
||||
icon = Icons.Default.Lock,
|
||||
size = 96.dp,
|
||||
iconSize = 56.dp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.biometric_lock_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.biometric_lock_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
if (authError != null) {
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
|
||||
|
||||
Text(
|
||||
text = authError ?: "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
|
||||
|
||||
// Retry button
|
||||
Button(
|
||||
onClick = {
|
||||
authError = null
|
||||
biometricAuth.authenticate(
|
||||
title = promptTitle,
|
||||
subtitle = promptSubtitle
|
||||
) { result ->
|
||||
when (result) {
|
||||
is BiometricResult.Success -> onUnlocked()
|
||||
is BiometricResult.Failed -> authError = result.message
|
||||
is BiometricResult.NotAvailable -> onUnlocked()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Fingerprint,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(OrganicSpacing.sm))
|
||||
Text(
|
||||
text = stringResource(Res.string.biometric_unlock_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,9 @@ import com.tt.honeyDue.ui.subscription.UpgradePromptDialog
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.tt.honeyDue.analytics.PostHogAnalytics
|
||||
import com.tt.honeyDue.analytics.AnalyticsEvents
|
||||
import com.tt.honeyDue.platform.BiometricResult
|
||||
import com.tt.honeyDue.platform.rememberBiometricAuth
|
||||
import com.tt.honeyDue.storage.BiometricPreference
|
||||
import honeydue.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@@ -57,6 +60,10 @@ fun ProfileScreen(
|
||||
var showUpgradePrompt by remember { mutableStateOf(false) }
|
||||
var showDeleteAccountDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val biometricAuth = rememberBiometricAuth()
|
||||
val isBiometricAvailable = remember { biometricAuth.isBiometricAvailable() }
|
||||
var isBiometricEnabled by remember { mutableStateOf(BiometricPreference.isBiometricEnabled()) }
|
||||
|
||||
val updateState by viewModel.updateProfileState.collectAsState()
|
||||
val deleteAccountState by viewModel.deleteAccountState.collectAsState()
|
||||
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
|
||||
@@ -306,6 +313,63 @@ fun ProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Biometric Lock Section - only show if device supports biometrics
|
||||
if (isBiometricAvailable) {
|
||||
val biometricPromptTitle = stringResource(Res.string.biometric_prompt_title)
|
||||
val biometricPromptSubtitle = stringResource(Res.string.biometric_prompt_subtitle)
|
||||
|
||||
OrganicCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.naturalShadow()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(OrganicSpacing.lg),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.xs)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.biometric_lock_setting_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.biometric_lock_setting_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = isBiometricEnabled,
|
||||
onCheckedChange = { newValue ->
|
||||
// Authenticate before toggling
|
||||
biometricAuth.authenticate(
|
||||
title = biometricPromptTitle,
|
||||
subtitle = biometricPromptSubtitle
|
||||
) { result ->
|
||||
when (result) {
|
||||
is BiometricResult.Success -> {
|
||||
BiometricPreference.setBiometricEnabled(newValue)
|
||||
isBiometricEnabled = newValue
|
||||
}
|
||||
is BiometricResult.Failed,
|
||||
is BiometricResult.NotAvailable -> {
|
||||
// Auth failed, don't change the toggle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Contact Support Section
|
||||
val uriHandler = LocalUriHandler.current
|
||||
OrganicCard(
|
||||
|
||||
Reference in New Issue
Block a user