feat: security screen, theme polish, settings navigation, build config
This commit is contained in:
parent
b720f0cf14
commit
e2fd65f070
|
|
@ -148,7 +148,9 @@ dependencies {
|
|||
// Image loading
|
||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||
|
||||
// Biometric auth — used in Settings to gate recovery phrase reveal
|
||||
// AppCompat — required by BiometricPrompt (needs FragmentActivity base)
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
// Biometric auth — recovery phrase reveal + app-level login lock
|
||||
implementation("androidx.biometric:biometric:1.1.0")
|
||||
|
||||
// Google Sign-In via Credential Manager
|
||||
|
|
|
|||
|
|
@ -2,19 +2,29 @@ package app.closer
|
|||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import app.closer.core.navigation.AppNavigation
|
||||
import app.closer.domain.repository.AppSettings
|
||||
import app.closer.domain.repository.AuthRepository
|
||||
import app.closer.domain.repository.SettingsRepository
|
||||
import app.closer.domain.repository.ThemeMode
|
||||
import app.closer.ui.theme.CloserTheme
|
||||
|
|
@ -22,8 +32,9 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
class MainActivity : AppCompatActivity() {
|
||||
@Inject lateinit var settingsRepository: SettingsRepository
|
||||
@Inject lateinit var authRepository: AuthRepository
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
|
@ -36,6 +47,11 @@ class MainActivity : ComponentActivity() {
|
|||
ThemeMode.DARK -> true
|
||||
}
|
||||
|
||||
var sessionVerified by remember { mutableStateOf(false) }
|
||||
val needsBiometricLock = settings.biometricLoginEnabled
|
||||
&& authRepository.currentUserId != null
|
||||
&& !sessionVerified
|
||||
|
||||
CloserTheme(darkTheme = useDarkTheme) {
|
||||
SideEffect {
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
|
|
@ -47,7 +63,14 @@ class MainActivity : ComponentActivity() {
|
|||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
AppNavigation()
|
||||
if (needsBiometricLock) {
|
||||
BiometricLockScreen(
|
||||
activity = this@MainActivity,
|
||||
onUnlocked = { sessionVerified = true }
|
||||
)
|
||||
} else {
|
||||
AppNavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -58,3 +81,28 @@ class MainActivity : ComponentActivity() {
|
|||
setIntent(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BiometricLockScreen(activity: AppCompatActivity, onUnlocked: () -> Unit) {
|
||||
LaunchedEffect(Unit) {
|
||||
BiometricPrompt(
|
||||
activity,
|
||||
ContextCompat.getMainExecutor(activity),
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
onUnlocked()
|
||||
}
|
||||
}
|
||||
).authenticate(
|
||||
BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle("Unlock Closer")
|
||||
.setSubtitle("Verify it's you to continue")
|
||||
.setAllowedAuthenticators(
|
||||
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ import app.closer.ui.settings.AppearanceScreen
|
|||
import app.closer.ui.settings.DeleteAccountScreen
|
||||
import app.closer.ui.settings.NotificationSettingsScreen
|
||||
import app.closer.ui.settings.PrivacyScreen
|
||||
import app.closer.ui.settings.SecurityScreen
|
||||
import app.closer.ui.settings.RelationshipSettingsScreen
|
||||
import app.closer.ui.settings.SettingsScreen
|
||||
import app.closer.ui.settings.SubscriptionScreen
|
||||
|
|
@ -440,6 +441,9 @@ fun AppNavigation(
|
|||
composable(route = AppRoute.DELETE_ACCOUNT) {
|
||||
DeleteAccountScreen(onNavigate = navigateRoute)
|
||||
}
|
||||
composable(route = AppRoute.SECURITY) {
|
||||
SecurityScreen(onNavigate = navigateRoute)
|
||||
}
|
||||
composable(route = AppRoute.YOUR_PROGRESS) {
|
||||
YourProgressScreen(
|
||||
onBack = navigateBackOrHome
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ object AppRoute {
|
|||
const val SUBSCRIPTION = "subscription"
|
||||
const val RELATIONSHIP_SETTINGS = "relationship_settings"
|
||||
const val DELETE_ACCOUNT = "delete_account"
|
||||
const val SECURITY = "security_settings"
|
||||
const val WHEEL_HISTORY = "wheel_history"
|
||||
const val GAME_HISTORY = "game_history"
|
||||
const val THIS_OR_THAT_REPLAY = "this_or_that_replay/{sessionId}"
|
||||
|
|
@ -95,6 +96,7 @@ object AppRoute {
|
|||
Definition(SUBSCRIPTION, "Subscription", "settings"),
|
||||
Definition(RELATIONSHIP_SETTINGS, "Relationship Settings", "settings"),
|
||||
Definition(DELETE_ACCOUNT, "Delete Account", "settings"),
|
||||
Definition(SECURITY, "Security", "settings"),
|
||||
Definition(WHEEL_HISTORY, "Wheel History", "wheel"),
|
||||
Definition(GAME_HISTORY, "Past Games", "play"),
|
||||
Definition(THIS_OR_THAT_REPLAY, "This or That Results", "play"),
|
||||
|
|
@ -164,6 +166,7 @@ object AppRoute {
|
|||
SUBSCRIPTION,
|
||||
RELATIONSHIP_SETTINGS,
|
||||
DELETE_ACCOUNT,
|
||||
SECURITY,
|
||||
CONNECTION_CHALLENGES,
|
||||
MEMORY_LANE,
|
||||
WAITING_FOR_PARTNER,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ class SettingsDataStore @Inject constructor(
|
|||
private val OUTCOME_REMINDER_ENABLED = booleanPreferencesKey("outcome_reminder_enabled")
|
||||
private val OUTCOME_BASELINE_SHOWN_AT = longPreferencesKey("outcome_baseline_shown_at")
|
||||
private val OUTCOME_LAST_PROMPTED_DAY = stringPreferencesKey("outcome_last_prompted_day")
|
||||
private val BIOMETRIC_LOGIN = booleanPreferencesKey("biometric_login")
|
||||
|
||||
override val settings: Flow<AppSettings> = dataStore.data.map { prefs ->
|
||||
AppSettings(
|
||||
|
|
@ -55,7 +56,8 @@ class SettingsDataStore @Inject constructor(
|
|||
themeMode = ThemeMode.fromStorageValue(prefs[THEME_MODE]),
|
||||
outcomeReminderEnabled = prefs[OUTCOME_REMINDER_ENABLED] ?: true,
|
||||
outcomeBaselineShownAt = prefs[OUTCOME_BASELINE_SHOWN_AT] ?: 0L,
|
||||
outcomeLastPromptedDay = prefs[OUTCOME_LAST_PROMPTED_DAY] ?: ""
|
||||
outcomeLastPromptedDay = prefs[OUTCOME_LAST_PROMPTED_DAY] ?: "",
|
||||
biometricLoginEnabled = prefs[BIOMETRIC_LOGIN] ?: false
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -97,4 +99,7 @@ class SettingsDataStore @Inject constructor(
|
|||
|
||||
override suspend fun setThemeMode(mode: ThemeMode) =
|
||||
dataStore.edit { it[THEME_MODE] = mode.name }.let {}
|
||||
|
||||
override suspend fun setBiometricLogin(enabled: Boolean) =
|
||||
dataStore.edit { it[BIOMETRIC_LOGIN] = enabled }.let {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ data class AppSettings(
|
|||
val themeMode: ThemeMode = ThemeMode.DEVICE,
|
||||
val outcomeReminderEnabled: Boolean = true,
|
||||
val outcomeBaselineShownAt: Long = 0L,
|
||||
val outcomeLastPromptedDay: String = ""
|
||||
val outcomeLastPromptedDay: String = "",
|
||||
val biometricLoginEnabled: Boolean = false
|
||||
)
|
||||
|
||||
interface SettingsRepository {
|
||||
|
|
@ -41,4 +42,5 @@ interface SettingsRepository {
|
|||
suspend fun setOutcomeReminderEnabled(enabled: Boolean)
|
||||
suspend fun markOutcomeBaselineShown()
|
||||
suspend fun setOutcomeLastPromptedDay(dayKey: String)
|
||||
suspend fun setBiometricLogin(enabled: Boolean)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,230 @@
|
|||
package app.closer.ui.settings
|
||||
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Fingerprint
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.data.local.RecoveryPhraseStore
|
||||
import app.closer.domain.repository.SettingsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SecurityUiState(
|
||||
val biometricLoginEnabled: Boolean = false,
|
||||
val hasRecoveryPhrase: Boolean = false,
|
||||
val recoveryPhrase: String? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class SecurityViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val recoveryPhraseStore: RecoveryPhraseStore
|
||||
) : ViewModel() {
|
||||
|
||||
private val _recoveryPhrase = MutableStateFlow<String?>(null)
|
||||
|
||||
val uiState: StateFlow<SecurityUiState> = combine(
|
||||
settingsRepository.settings,
|
||||
_recoveryPhrase
|
||||
) { settings, phrase ->
|
||||
SecurityUiState(
|
||||
biometricLoginEnabled = settings.biometricLoginEnabled,
|
||||
hasRecoveryPhrase = recoveryPhraseStore.load() != null,
|
||||
recoveryPhrase = phrase
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SecurityUiState())
|
||||
|
||||
fun setBiometricLogin(enabled: Boolean) {
|
||||
viewModelScope.launch { settingsRepository.setBiometricLogin(enabled) }
|
||||
}
|
||||
|
||||
fun revealRecoveryPhrase() {
|
||||
_recoveryPhrase.update { recoveryPhraseStore.load() }
|
||||
}
|
||||
|
||||
fun hideRecoveryPhrase() {
|
||||
_recoveryPhrase.update { null }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SecurityScreen(
|
||||
onNavigate: (String) -> Unit = {},
|
||||
viewModel: SecurityViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
fun launchBiometricForPhrase() {
|
||||
val activity = context as? FragmentActivity ?: return
|
||||
BiometricPrompt(
|
||||
activity,
|
||||
ContextCompat.getMainExecutor(context),
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
viewModel.revealRecoveryPhrase()
|
||||
}
|
||||
}
|
||||
).authenticate(
|
||||
BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle("Recovery phrase")
|
||||
.setDescription("Authenticate to view your recovery phrase")
|
||||
.setAllowedAuthenticators(
|
||||
androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||
androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
state.recoveryPhrase?.let { phrase ->
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::hideRecoveryPhrase,
|
||||
title = { Text("Recovery phrase", color = SettingsInk) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
"Write this down and keep it somewhere safe. You'll need it to restore your encrypted history if both devices are lost.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = SettingsMuted
|
||||
)
|
||||
Text(
|
||||
phrase,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = SettingsInk
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = viewModel::hideRecoveryPhrase) { Text("Done") }
|
||||
},
|
||||
containerColor = SettingsSoft
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSubpage(title = "Security", onBack = { onNavigate("back") }) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.safeDrawingPadding()
|
||||
.navigationBarsPadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = SettingsCard)
|
||||
) {
|
||||
Column {
|
||||
// Biometric login toggle
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.Fingerprint, contentDescription = null, tint = SettingsMuted)
|
||||
Text(
|
||||
text = "Biometric login",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = SettingsInk,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Switch(
|
||||
checked = state.biometricLoginEnabled,
|
||||
onCheckedChange = { viewModel.setBiometricLogin(it) }
|
||||
)
|
||||
}
|
||||
|
||||
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
|
||||
|
||||
// Recovery phrase row — greyed out until a phrase is stored on-device
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (state.hasRecoveryPhrase)
|
||||
Modifier.clickable { launchBiometricForPhrase() }
|
||||
else Modifier
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp)
|
||||
.alpha(if (state.hasRecoveryPhrase) 1f else 0.35f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.Lock, contentDescription = null, tint = SettingsMuted)
|
||||
Text(
|
||||
text = "Recovery phrase",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = SettingsInk,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.hasRecoveryPhrase) {
|
||||
Text(
|
||||
text = "Recovery phrase becomes available after you invite your partner.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = SettingsMuted,
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -47,10 +47,6 @@ import androidx.compose.material3.SnackbarHostState
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
|
|
@ -62,15 +58,11 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.core.navigation.ExternalLinks
|
||||
import app.closer.domain.model.OutcomeDay
|
||||
import app.closer.ui.components.OutcomeCheckInDialog
|
||||
import app.closer.ui.settings.SettingsDanger
|
||||
|
|
@ -188,30 +180,6 @@ fun SettingsScreen(
|
|||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
val context = LocalContext.current
|
||||
|
||||
fun launchBiometric() {
|
||||
val activity = context as? FragmentActivity ?: return
|
||||
BiometricPrompt(
|
||||
activity,
|
||||
ContextCompat.getMainExecutor(context),
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
viewModel.revealRecoveryPhrase()
|
||||
}
|
||||
}
|
||||
).authenticate(
|
||||
BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle("Recovery phrase")
|
||||
.setDescription("Authenticate to view your recovery phrase")
|
||||
.setAllowedAuthenticators(
|
||||
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(state.navigateTo) {
|
||||
state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() }
|
||||
}
|
||||
|
|
@ -293,32 +261,6 @@ fun SettingsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
state.recoveryPhrase?.let { phrase ->
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::hideRecoveryPhrase,
|
||||
title = { Text("Recovery phrase", color = SettingsInk) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
"Write this down and keep it somewhere safe. You'll need it to restore your encrypted history if both devices are lost.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = SettingsMuted
|
||||
)
|
||||
Text(
|
||||
phrase,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = SettingsInk
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = viewModel::hideRecoveryPhrase) { Text("Done") }
|
||||
},
|
||||
containerColor = SettingsSoft
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(state.outcomeSubmitSuccess) {
|
||||
if (state.outcomeSubmitSuccess) {
|
||||
snackbar.showSnackbar("Check-in saved")
|
||||
|
|
@ -499,57 +441,21 @@ fun SettingsScreen(
|
|||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Security section — visible only when a recovery phrase is stored on-device
|
||||
if (state.hasRecoveryPhrase) {
|
||||
Text(
|
||||
text = "Security",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = SettingsMuted,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||
)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = SettingsCard)
|
||||
) {
|
||||
SettingsRow(
|
||||
icon = Icons.Filled.Lock,
|
||||
label = "Recovery phrase",
|
||||
onClick = { launchBiometric() }
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Legal
|
||||
Text(
|
||||
text = "Legal",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = SettingsMuted,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||
)
|
||||
|
||||
// Security
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = SettingsCard)
|
||||
) {
|
||||
Column {
|
||||
SettingsLegalRow(
|
||||
label = "Privacy Policy",
|
||||
onClick = { ExternalLinks.openUrl(context, ExternalLinks.PRIVACY_POLICY) }
|
||||
)
|
||||
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
|
||||
SettingsLegalRow(
|
||||
label = "Terms of Service",
|
||||
onClick = { ExternalLinks.openUrl(context, ExternalLinks.TERMS_OF_SERVICE) }
|
||||
)
|
||||
}
|
||||
SettingsRow(
|
||||
icon = Icons.Filled.Lock,
|
||||
label = "Security",
|
||||
onClick = { onNavigate(AppRoute.SECURITY) }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Account lifecycle — separated from legal links
|
||||
// Account lifecycle
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
|
|
@ -622,32 +528,3 @@ private fun SettingsRow(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsLegalRow(
|
||||
label: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = SettingsInk,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowForwardIos,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = SettingsMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,9 +40,7 @@ data class SettingsUiState(
|
|||
val outcomeBaselineDialogDue: Boolean = false,
|
||||
val outcomeFollowUpDay: OutcomeDay? = null,
|
||||
val outcomeSubmitSuccess: Boolean = false,
|
||||
val outcomeError: String? = null,
|
||||
val hasRecoveryPhrase: Boolean = false,
|
||||
val recoveryPhrase: String? = null
|
||||
val outcomeError: String? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
|
|
@ -113,8 +111,7 @@ class SettingsViewModel @Inject constructor(
|
|||
partnerName = partnerName,
|
||||
isPaired = couple != null,
|
||||
outcomeBaselineDialogDue = outcomeBaselineDialogDue,
|
||||
outcomeFollowUpDay = followUpDay,
|
||||
hasRecoveryPhrase = recoveryPhraseStore.load() != null
|
||||
outcomeFollowUpDay = followUpDay
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -176,13 +173,6 @@ class SettingsViewModel @Inject constructor(
|
|||
return if (outcomes.any { it.dayKey == due.first.key }) null else due.first
|
||||
}
|
||||
|
||||
fun revealRecoveryPhrase() {
|
||||
val phrase = recoveryPhraseStore.load() ?: return
|
||||
_uiState.update { it.copy(recoveryPhrase = phrase) }
|
||||
}
|
||||
|
||||
fun hideRecoveryPhrase() = _uiState.update { it.copy(recoveryPhrase = null) }
|
||||
|
||||
fun signOut() {
|
||||
_uiState.update { it.copy(isSigningOut = true) }
|
||||
viewModelScope.launch {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Closer" parent="android:Theme.Material.NoActionBar" />
|
||||
<style name="Theme.Closer" parent="Theme.AppCompat.DayNight.NoActionBar" />
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Closer" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
<style name="Theme.Closer" parent="Theme.AppCompat.Light.NoActionBar" />
|
||||
</resources>
|
||||
|
|
|
|||
Loading…
Reference in New Issue