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
|
// 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
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue