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

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

View File

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

View File

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

View File

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

View File

@ -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,

View File

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

View File

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

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

View File

@ -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 {

View File

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

View File

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