feat: security screen, theme polish, settings navigation, build config

This commit is contained in:
null 2026-06-21 17:25:39 -05:00
parent da8ddf9ed3
commit e3f8b99994
11 changed files with 312 additions and 151 deletions

View File

@ -148,7 +148,9 @@ dependencies {
// Image loading // Image loading
implementation("io.coil-kt:coil-compose:2.7.0") 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") implementation("androidx.biometric:biometric:1.1.0")
// Google Sign-In via Credential Manager // Google Sign-In via Credential Manager

View File

@ -2,19 +2,29 @@ package app.closer
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent 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.layout.fillMaxSize
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface 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.collectAsState
import androidx.compose.runtime.getValue 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.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import app.closer.core.navigation.AppNavigation import app.closer.core.navigation.AppNavigation
import app.closer.domain.repository.AppSettings import app.closer.domain.repository.AppSettings
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.SettingsRepository import app.closer.domain.repository.SettingsRepository
import app.closer.domain.repository.ThemeMode import app.closer.domain.repository.ThemeMode
import app.closer.ui.theme.CloserTheme import app.closer.ui.theme.CloserTheme
@ -22,8 +32,9 @@ import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : AppCompatActivity() {
@Inject lateinit var settingsRepository: SettingsRepository @Inject lateinit var settingsRepository: SettingsRepository
@Inject lateinit var authRepository: AuthRepository
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -36,6 +47,11 @@ class MainActivity : ComponentActivity() {
ThemeMode.DARK -> true ThemeMode.DARK -> true
} }
var sessionVerified by remember { mutableStateOf(false) }
val needsBiometricLock = settings.biometricLoginEnabled
&& authRepository.currentUserId != null
&& !sessionVerified
CloserTheme(darkTheme = useDarkTheme) { CloserTheme(darkTheme = useDarkTheme) {
SideEffect { SideEffect {
WindowCompat.getInsetsController(window, window.decorView).apply { WindowCompat.getInsetsController(window, window.decorView).apply {
@ -47,7 +63,14 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background 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) 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())
}

View File

@ -69,6 +69,7 @@ import app.closer.ui.settings.AppearanceScreen
import app.closer.ui.settings.DeleteAccountScreen import app.closer.ui.settings.DeleteAccountScreen
import app.closer.ui.settings.NotificationSettingsScreen import app.closer.ui.settings.NotificationSettingsScreen
import app.closer.ui.settings.PrivacyScreen import app.closer.ui.settings.PrivacyScreen
import app.closer.ui.settings.SecurityScreen
import app.closer.ui.settings.RelationshipSettingsScreen import app.closer.ui.settings.RelationshipSettingsScreen
import app.closer.ui.settings.SettingsScreen import app.closer.ui.settings.SettingsScreen
import app.closer.ui.settings.SubscriptionScreen import app.closer.ui.settings.SubscriptionScreen
@ -440,6 +441,9 @@ fun AppNavigation(
composable(route = AppRoute.DELETE_ACCOUNT) { composable(route = AppRoute.DELETE_ACCOUNT) {
DeleteAccountScreen(onNavigate = navigateRoute) DeleteAccountScreen(onNavigate = navigateRoute)
} }
composable(route = AppRoute.SECURITY) {
SecurityScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.YOUR_PROGRESS) { composable(route = AppRoute.YOUR_PROGRESS) {
YourProgressScreen( YourProgressScreen(
onBack = navigateBackOrHome onBack = navigateBackOrHome

View File

@ -34,6 +34,7 @@ object AppRoute {
const val SUBSCRIPTION = "subscription" const val SUBSCRIPTION = "subscription"
const val RELATIONSHIP_SETTINGS = "relationship_settings" const val RELATIONSHIP_SETTINGS = "relationship_settings"
const val DELETE_ACCOUNT = "delete_account" const val DELETE_ACCOUNT = "delete_account"
const val SECURITY = "security_settings"
const val WHEEL_HISTORY = "wheel_history" const val WHEEL_HISTORY = "wheel_history"
const val GAME_HISTORY = "game_history" const val GAME_HISTORY = "game_history"
const val THIS_OR_THAT_REPLAY = "this_or_that_replay/{sessionId}" const val THIS_OR_THAT_REPLAY = "this_or_that_replay/{sessionId}"
@ -95,6 +96,7 @@ object AppRoute {
Definition(SUBSCRIPTION, "Subscription", "settings"), Definition(SUBSCRIPTION, "Subscription", "settings"),
Definition(RELATIONSHIP_SETTINGS, "Relationship Settings", "settings"), Definition(RELATIONSHIP_SETTINGS, "Relationship Settings", "settings"),
Definition(DELETE_ACCOUNT, "Delete Account", "settings"), Definition(DELETE_ACCOUNT, "Delete Account", "settings"),
Definition(SECURITY, "Security", "settings"),
Definition(WHEEL_HISTORY, "Wheel History", "wheel"), Definition(WHEEL_HISTORY, "Wheel History", "wheel"),
Definition(GAME_HISTORY, "Past Games", "play"), Definition(GAME_HISTORY, "Past Games", "play"),
Definition(THIS_OR_THAT_REPLAY, "This or That Results", "play"), Definition(THIS_OR_THAT_REPLAY, "This or That Results", "play"),
@ -164,6 +166,7 @@ object AppRoute {
SUBSCRIPTION, SUBSCRIPTION,
RELATIONSHIP_SETTINGS, RELATIONSHIP_SETTINGS,
DELETE_ACCOUNT, DELETE_ACCOUNT,
SECURITY,
CONNECTION_CHALLENGES, CONNECTION_CHALLENGES,
MEMORY_LANE, MEMORY_LANE,
WAITING_FOR_PARTNER, WAITING_FOR_PARTNER,

View File

@ -36,6 +36,7 @@ class SettingsDataStore @Inject constructor(
private val OUTCOME_REMINDER_ENABLED = booleanPreferencesKey("outcome_reminder_enabled") private val OUTCOME_REMINDER_ENABLED = booleanPreferencesKey("outcome_reminder_enabled")
private val OUTCOME_BASELINE_SHOWN_AT = longPreferencesKey("outcome_baseline_shown_at") private val OUTCOME_BASELINE_SHOWN_AT = longPreferencesKey("outcome_baseline_shown_at")
private val OUTCOME_LAST_PROMPTED_DAY = stringPreferencesKey("outcome_last_prompted_day") 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 -> override val settings: Flow<AppSettings> = dataStore.data.map { prefs ->
AppSettings( AppSettings(
@ -55,7 +56,8 @@ class SettingsDataStore @Inject constructor(
themeMode = ThemeMode.fromStorageValue(prefs[THEME_MODE]), themeMode = ThemeMode.fromStorageValue(prefs[THEME_MODE]),
outcomeReminderEnabled = prefs[OUTCOME_REMINDER_ENABLED] ?: true, outcomeReminderEnabled = prefs[OUTCOME_REMINDER_ENABLED] ?: true,
outcomeBaselineShownAt = prefs[OUTCOME_BASELINE_SHOWN_AT] ?: 0L, 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) = override suspend fun setThemeMode(mode: ThemeMode) =
dataStore.edit { it[THEME_MODE] = mode.name }.let {} dataStore.edit { it[THEME_MODE] = mode.name }.let {}
override suspend fun setBiometricLogin(enabled: Boolean) =
dataStore.edit { it[BIOMETRIC_LOGIN] = enabled }.let {}
} }

View File

@ -25,7 +25,8 @@ data class AppSettings(
val themeMode: ThemeMode = ThemeMode.DEVICE, val themeMode: ThemeMode = ThemeMode.DEVICE,
val outcomeReminderEnabled: Boolean = true, val outcomeReminderEnabled: Boolean = true,
val outcomeBaselineShownAt: Long = 0L, val outcomeBaselineShownAt: Long = 0L,
val outcomeLastPromptedDay: String = "" val outcomeLastPromptedDay: String = "",
val biometricLoginEnabled: Boolean = false
) )
interface SettingsRepository { interface SettingsRepository {
@ -41,4 +42,5 @@ interface SettingsRepository {
suspend fun setOutcomeReminderEnabled(enabled: Boolean) suspend fun setOutcomeReminderEnabled(enabled: Boolean)
suspend fun markOutcomeBaselineShown() suspend fun markOutcomeBaselineShown()
suspend fun setOutcomeLastPromptedDay(dayKey: String) suspend fun setOutcomeLastPromptedDay(dayKey: String)
suspend fun setBiometricLogin(enabled: Boolean)
} }

View File

@ -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))
}
}
}

View File

@ -47,10 +47,6 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -62,15 +58,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.core.navigation.ExternalLinks
import app.closer.domain.model.OutcomeDay import app.closer.domain.model.OutcomeDay
import app.closer.ui.components.OutcomeCheckInDialog import app.closer.ui.components.OutcomeCheckInDialog
import app.closer.ui.settings.SettingsDanger import app.closer.ui.settings.SettingsDanger
@ -188,30 +180,6 @@ fun SettingsScreen(
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val snackbar = remember { SnackbarHostState() } 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) { LaunchedEffect(state.navigateTo) {
state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() } 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) { LaunchedEffect(state.outcomeSubmitSuccess) {
if (state.outcomeSubmitSuccess) { if (state.outcomeSubmitSuccess) {
snackbar.showSnackbar("Check-in saved") snackbar.showSnackbar("Check-in saved")
@ -499,57 +441,21 @@ fun SettingsScreen(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// Security section — visible only when a recovery phrase is stored on-device // Security
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)
)
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = SettingsCard) colors = CardDefaults.cardColors(containerColor = SettingsCard)
) { ) {
Column { SettingsRow(
SettingsLegalRow( icon = Icons.Filled.Lock,
label = "Privacy Policy", label = "Security",
onClick = { ExternalLinks.openUrl(context, ExternalLinks.PRIVACY_POLICY) } onClick = { onNavigate(AppRoute.SECURITY) }
) )
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
SettingsLegalRow(
label = "Terms of Service",
onClick = { ExternalLinks.openUrl(context, ExternalLinks.TERMS_OF_SERVICE) }
)
}
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// Account lifecycle — separated from legal links // Account lifecycle
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp), 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
)
}
}

View File

@ -40,9 +40,7 @@ data class SettingsUiState(
val outcomeBaselineDialogDue: Boolean = false, val outcomeBaselineDialogDue: Boolean = false,
val outcomeFollowUpDay: OutcomeDay? = null, val outcomeFollowUpDay: OutcomeDay? = null,
val outcomeSubmitSuccess: Boolean = false, val outcomeSubmitSuccess: Boolean = false,
val outcomeError: String? = null, val outcomeError: String? = null
val hasRecoveryPhrase: Boolean = false,
val recoveryPhrase: String? = null
) )
@HiltViewModel @HiltViewModel
@ -113,8 +111,7 @@ class SettingsViewModel @Inject constructor(
partnerName = partnerName, partnerName = partnerName,
isPaired = couple != null, isPaired = couple != null,
outcomeBaselineDialogDue = outcomeBaselineDialogDue, outcomeBaselineDialogDue = outcomeBaselineDialogDue,
outcomeFollowUpDay = followUpDay, outcomeFollowUpDay = followUpDay
hasRecoveryPhrase = recoveryPhraseStore.load() != null
) )
} }
} }
@ -176,13 +173,6 @@ class SettingsViewModel @Inject constructor(
return if (outcomes.any { it.dayKey == due.first.key }) null else due.first 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() { fun signOut() {
_uiState.update { it.copy(isSigningOut = true) } _uiState.update { it.copy(isSigningOut = true) }
viewModelScope.launch { viewModelScope.launch {

View File

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.Closer" parent="android:Theme.Material.NoActionBar" /> <style name="Theme.Closer" parent="Theme.AppCompat.DayNight.NoActionBar" />
</resources> </resources>

View File

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.Closer" parent="android:Theme.Material.Light.NoActionBar" /> <style name="Theme.Closer" parent="Theme.AppCompat.Light.NoActionBar" />
</resources> </resources>