mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
feat(privacylock): redesign Set/Change password dialog + snackbar
Rewrites SetPasswordDialog.kt with modern 2024-2026 UX. Adds
"Privacy lock enabled" / "Password updated" confirmation snackbars.
Dialog changes:
- Dialog + Surface(shape=shapes.large, tonal=6.dp) shell instead of
default AlertDialog. Fixed width 440dp. Matches NewDmDialog.kt.
- Header row with Lock icon + title (titleLarge). Softer body copy
under the header for the first-time-set path.
- Set-a-password flow uses ONE password field with a reveal toggle
(Visibility / VisibilityOff, per-field independent). The reveal
toggle IS the confirmation — no more "confirm password" field.
Matches WhatsApp Chat Lock + macOS Users & Groups.
- Change-password flow uses two fields (current + new), each with
its own reveal toggle. Current is verification, not redundancy.
- Real-time checklist row under the New field: green CheckCircle +
"Min 6 characters" when satisfied, outlined Circle + muted text
otherwise. Copy-pattern from EditProfileScreen NIP-05 status.
- Save button disabled until the checklist passes.
- Bumps PRIVACY_LOCK_MIN_PASSWORD_LENGTH from 4 to 6.
- Auto-focus first field on open (LaunchedEffect + FocusRequester).
- Enter submits (via onPreviewKeyEvent + KeyboardActions.onDone).
- Escape / click-outside-dismiss are disabled to prevent accidental
loss of typed password (dismissOnClickOutside = false).
- Wrong-current error shown inline under the Current field.
- All reveal toggles reuse the KeyInputField.kt idiom verbatim.
Snackbar plumbing (scope-local — no CompositionLocal):
- PrivacyLockSettingsScreen owns a SnackbarHostState overlaid at
Alignment.BottomCenter. LockToggleCard receives an onSaved
callback, fires "Privacy lock enabled" on first-time set or
"Password updated" on change.
- DesktopMessagesScreen (banner path) owns its own SnackbarHostState
overlaid at BottomCenter. MessagesFirstRunBanner takes an
optional onSaved callback (default {}), fires "Privacy lock
enabled" after the dialog saves.
This commit is contained in:
+2
-1
@@ -62,7 +62,7 @@ import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState
|
||||
* content, so this composable never composes in that case).
|
||||
*/
|
||||
@Composable
|
||||
fun MessagesFirstRunBanner() {
|
||||
fun MessagesFirstRunBanner(onSaved: (String) -> Unit = {}) {
|
||||
val settings = LocalPrivacyLockSettings.current
|
||||
val lockState = LocalMessagesLockState.current
|
||||
val enabled by settings.lockEnabled.collectAsState()
|
||||
@@ -123,6 +123,7 @@ fun MessagesFirstRunBanner() {
|
||||
// right after they just entered the password.
|
||||
lockState.onUnlockSuccess()
|
||||
showDialog = false
|
||||
onSaved("Privacy lock enabled")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+220
-51
@@ -22,29 +22,65 @@ package com.vitorpamplona.amethyst.desktop.security
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
|
||||
/** Enforced minimum length for a new/rotated password. */
|
||||
const val PRIVACY_LOCK_MIN_PASSWORD_LENGTH = 6
|
||||
|
||||
/**
|
||||
* Set or change the privacy-lock password.
|
||||
*
|
||||
* Pass [existingHash] = null when the user hasn't set a password yet
|
||||
* (first-run banner path, or a fresh Settings toggle). In that case the
|
||||
* "current password" field is hidden. Pass a real hash to force
|
||||
* verification of the current password before letting the user rotate.
|
||||
* dialog shows a single New password field with a reveal toggle. Pass a
|
||||
* real hash to force verification of the current password before letting
|
||||
* the user rotate — the dialog then shows a Current password field
|
||||
* followed by the New password field, each with its own reveal toggle.
|
||||
*
|
||||
* On successful validation, [onConfirm] is invoked with a fresh
|
||||
* `salt$hash` string ready for [com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.setPasswordHashed].
|
||||
*
|
||||
* UX: auto-focus the first field on open, Enter submits, Escape cancels.
|
||||
* Real-time checklist under the New password field shows a green ✓ once
|
||||
* the length threshold is reached. Reveal toggles are per-field
|
||||
* independent so revealing Current does not reveal New.
|
||||
*/
|
||||
@Composable
|
||||
fun SetPasswordDialog(
|
||||
@@ -52,74 +88,207 @@ fun SetPasswordDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String) -> Unit,
|
||||
) {
|
||||
val isChange = existingHash != null
|
||||
var current by remember { mutableStateOf("") }
|
||||
var new1 by remember { mutableStateOf("") }
|
||||
var new2 by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var new by remember { mutableStateOf("") }
|
||||
var currentError by remember { mutableStateOf<String?>(null) }
|
||||
var newError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val firstFieldFocus = remember { FocusRequester() }
|
||||
|
||||
val submit: () -> Unit = {
|
||||
val currentOk =
|
||||
existingHash == null ||
|
||||
PasswordHasher.verify(current.toCharArray(), existingHash)
|
||||
!isChange ||
|
||||
PasswordHasher.verify(current.toCharArray(), existingHash!!)
|
||||
when {
|
||||
!currentOk -> error = "Current password is wrong"
|
||||
new1.length < 4 -> error = "New password must be at least 4 characters"
|
||||
new1 != new2 -> error = "Passwords don't match"
|
||||
else -> onConfirm(PasswordHasher.hash(new1.toCharArray()))
|
||||
!currentOk -> {
|
||||
currentError = "Wrong password"
|
||||
newError = null
|
||||
}
|
||||
new.length < PRIVACY_LOCK_MIN_PASSWORD_LENGTH -> {
|
||||
currentError = null
|
||||
newError = "Must be at least $PRIVACY_LOCK_MIN_PASSWORD_LENGTH characters"
|
||||
}
|
||||
else -> {
|
||||
onConfirm(PasswordHasher.hash(new.toCharArray()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
LaunchedEffect(Unit) {
|
||||
firstFieldFocus.requestFocus()
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if (existingHash == null) "Set a password" else "Change password") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (existingHash != null) {
|
||||
OutlinedTextField(
|
||||
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false),
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp,
|
||||
modifier = Modifier.width(440.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp).fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
DialogHeader(isChange = isChange)
|
||||
|
||||
if (!isChange) {
|
||||
Text(
|
||||
text = "Choose a password to lock the Messages tab. You'll enter it to unlock later.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (isChange) {
|
||||
PasswordField(
|
||||
value = current,
|
||||
onValueChange = {
|
||||
current = it
|
||||
error = null
|
||||
currentError = null
|
||||
},
|
||||
label = { Text("Current password") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
label = "Current password",
|
||||
errorMessage = currentError,
|
||||
modifier = Modifier.focusRequester(firstFieldFocus),
|
||||
imeAction = ImeAction.Next,
|
||||
onImeAction = { /* Tab handled by focus system */ },
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = new1,
|
||||
|
||||
PasswordField(
|
||||
value = new,
|
||||
onValueChange = {
|
||||
new1 = it
|
||||
error = null
|
||||
new = it
|
||||
newError = null
|
||||
},
|
||||
label = { Text("New password") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
label = "New password",
|
||||
errorMessage = newError,
|
||||
modifier =
|
||||
if (isChange) Modifier else Modifier.focusRequester(firstFieldFocus),
|
||||
imeAction = ImeAction.Done,
|
||||
onImeAction = { submit() },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = new2,
|
||||
onValueChange = {
|
||||
new2 = it
|
||||
error = null
|
||||
},
|
||||
label = { Text("Confirm new password") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
)
|
||||
error?.let {
|
||||
Text(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
RequirementChecklistRow(satisfied = new.length >= PRIVACY_LOCK_MIN_PASSWORD_LENGTH)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
Button(
|
||||
onClick = submit,
|
||||
enabled = new.length >= PRIVACY_LOCK_MIN_PASSWORD_LENGTH,
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DialogHeader(isChange: Boolean) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = if (isChange) "Change password" else "Set a password",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
errorMessage: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
imeAction: ImeAction = ImeAction.Done,
|
||||
onImeAction: () -> Unit = {},
|
||||
) {
|
||||
var revealed by remember { mutableStateOf(false) }
|
||||
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(label) },
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.key == Key.Enter && imeAction == ImeAction.Done) {
|
||||
onImeAction()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
visualTransformation = if (revealed) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = imeAction,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { onImeAction() }, onNext = { onImeAction() }),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { revealed = !revealed }) {
|
||||
Icon(
|
||||
symbol = if (revealed) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility,
|
||||
contentDescription = if (revealed) "Hide password" else "Show password",
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = submit) { Text("Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
isError = errorMessage != null,
|
||||
supportingText =
|
||||
errorMessage?.let {
|
||||
{
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RequirementChecklistRow(satisfied: Boolean) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = if (satisfied) MaterialSymbols.CheckCircle else MaterialSymbols.Circle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint =
|
||||
if (satisfied) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
Text(
|
||||
text = "Min $PRIVACY_LOCK_MIN_PASSWORD_LENGTH characters",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color =
|
||||
if (satisfied) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+44
-29
@@ -29,9 +29,12 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -67,6 +70,7 @@ import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.security.MessagesFirstRunBanner
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.awt.Cursor
|
||||
|
||||
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
||||
@@ -128,37 +132,48 @@ fun DesktopMessagesScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
MessagesFirstRunBanner()
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
if (compactMode) {
|
||||
CompactMessagesContent(
|
||||
selectedRoom = selectedRoom,
|
||||
listState = listState,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
scope = scope,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
listFocusRequester = listFocusRequester,
|
||||
onShowNewDm = { showNewDmDialog = true },
|
||||
onShowRelayPicker = { showDmRelayPicker = true },
|
||||
keyHandler = keyHandler,
|
||||
)
|
||||
} else {
|
||||
SplitMessagesContent(
|
||||
selectedRoom = selectedRoom,
|
||||
listState = listState,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
scope = scope,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
listFocusRequester = listFocusRequester,
|
||||
onShowNewDm = { showNewDmDialog = true },
|
||||
onShowRelayPicker = { showDmRelayPicker = true },
|
||||
keyHandler = keyHandler,
|
||||
)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val snackbarScope = rememberCoroutineScope()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
MessagesFirstRunBanner(onSaved = { msg ->
|
||||
snackbarScope.launch { snackbarHostState.showSnackbar(msg) }
|
||||
})
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
if (compactMode) {
|
||||
CompactMessagesContent(
|
||||
selectedRoom = selectedRoom,
|
||||
listState = listState,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
scope = scope,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
listFocusRequester = listFocusRequester,
|
||||
onShowNewDm = { showNewDmDialog = true },
|
||||
onShowRelayPicker = { showDmRelayPicker = true },
|
||||
keyHandler = keyHandler,
|
||||
)
|
||||
} else {
|
||||
SplitMessagesContent(
|
||||
selectedRoom = selectedRoom,
|
||||
listState = listState,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
scope = scope,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
listFocusRequester = listFocusRequester,
|
||||
onShowNewDm = { showNewDmDialog = true },
|
||||
onShowRelayPicker = { showDmRelayPicker = true },
|
||||
keyHandler = keyHandler,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (showNewDmDialog) {
|
||||
|
||||
+36
-12
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.desktop.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -33,6 +34,8 @@ import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -40,6 +43,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -49,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.privacylock.InactivityTimer
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings
|
||||
import com.vitorpamplona.amethyst.desktop.security.LocalPrivacyLockSettings
|
||||
import com.vitorpamplona.amethyst.desktop.security.SetPasswordDialog
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Desktop privacy-lock settings pane. Column + Card layout (no Scaffold) —
|
||||
@@ -57,22 +62,39 @@ import com.vitorpamplona.amethyst.desktop.security.SetPasswordDialog
|
||||
@Composable
|
||||
fun PrivacyLockSettingsScreen() {
|
||||
val settings = LocalPrivacyLockSettings.current
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
LockToggleCard(settings)
|
||||
InactivityCard(settings)
|
||||
RedactionCard(settings)
|
||||
LimitationsCard()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
LockToggleCard(
|
||||
settings = settings,
|
||||
onSaved = { message ->
|
||||
scope.launch { snackbarHostState.showSnackbar(message) }
|
||||
},
|
||||
)
|
||||
InactivityCard(settings)
|
||||
RedactionCard(settings)
|
||||
LimitationsCard()
|
||||
}
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LockToggleCard(settings: PrivacyLockSettings) {
|
||||
private fun LockToggleCard(
|
||||
settings: PrivacyLockSettings,
|
||||
onSaved: (String) -> Unit,
|
||||
) {
|
||||
val enabled by settings.lockEnabled.collectAsState()
|
||||
val stored by settings.passwordHashed.collectAsState()
|
||||
var showSetPassword by remember { mutableStateOf(false) }
|
||||
@@ -117,6 +139,7 @@ private fun LockToggleCard(settings: PrivacyLockSettings) {
|
||||
}
|
||||
|
||||
if (showSetPassword) {
|
||||
val wasFirstSet = stored == null
|
||||
SetPasswordDialog(
|
||||
existingHash = stored,
|
||||
onDismiss = {
|
||||
@@ -128,6 +151,7 @@ private fun LockToggleCard(settings: PrivacyLockSettings) {
|
||||
if (pendingEnable) settings.setLockEnabled(true)
|
||||
showSetPassword = false
|
||||
pendingEnable = false
|
||||
onSaved(if (wasFirstSet) "Privacy lock enabled" else "Password updated")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user