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:
nrobi144
2026-07-01 11:35:52 +03:00
parent d216d22c3e
commit 292f8a0c78
4 changed files with 302 additions and 93 deletions
@@ -62,7 +62,7 @@ import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState
* content, so this composable never composes in that case). * content, so this composable never composes in that case).
*/ */
@Composable @Composable
fun MessagesFirstRunBanner() { fun MessagesFirstRunBanner(onSaved: (String) -> Unit = {}) {
val settings = LocalPrivacyLockSettings.current val settings = LocalPrivacyLockSettings.current
val lockState = LocalMessagesLockState.current val lockState = LocalMessagesLockState.current
val enabled by settings.lockEnabled.collectAsState() val enabled by settings.lockEnabled.collectAsState()
@@ -123,6 +123,7 @@ fun MessagesFirstRunBanner() {
// right after they just entered the password. // right after they just entered the password.
lockState.onUnlockSuccess() lockState.onUnlockSuccess()
showDialog = false showDialog = false
onSaved("Privacy lock enabled")
}, },
) )
} }
@@ -22,29 +22,65 @@ package com.vitorpamplona.amethyst.desktop.security
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp 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. * Set or change the privacy-lock password.
* *
* Pass [existingHash] = null when the user hasn't set a password yet * 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 * (first-run banner path, or a fresh Settings toggle). In that case the
* "current password" field is hidden. Pass a real hash to force * dialog shows a single New password field with a reveal toggle. Pass a
* verification of the current password before letting the user rotate. * 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 * On successful validation, [onConfirm] is invoked with a fresh
* `salt$hash` string ready for [com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.setPasswordHashed]. * `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 @Composable
fun SetPasswordDialog( fun SetPasswordDialog(
@@ -52,74 +88,207 @@ fun SetPasswordDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConfirm: (String) -> Unit, onConfirm: (String) -> Unit,
) { ) {
val isChange = existingHash != null
var current by remember { mutableStateOf("") } var current by remember { mutableStateOf("") }
var new1 by remember { mutableStateOf("") } var new by remember { mutableStateOf("") }
var new2 by remember { mutableStateOf("") } var currentError by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) } var newError by remember { mutableStateOf<String?>(null) }
val firstFieldFocus = remember { FocusRequester() }
val submit: () -> Unit = { val submit: () -> Unit = {
val currentOk = val currentOk =
existingHash == null || !isChange ||
PasswordHasher.verify(current.toCharArray(), existingHash) PasswordHasher.verify(current.toCharArray(), existingHash!!)
when { when {
!currentOk -> error = "Current password is wrong" !currentOk -> {
new1.length < 4 -> error = "New password must be at least 4 characters" currentError = "Wrong password"
new1 != new2 -> error = "Passwords don't match" newError = null
else -> onConfirm(PasswordHasher.hash(new1.toCharArray())) }
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, onDismissRequest = onDismiss,
title = { Text(if (existingHash == null) "Set a password" else "Change password") }, properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false),
text = { ) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Surface(
if (existingHash != null) { shape = MaterialTheme.shapes.large,
OutlinedTextField( 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, value = current,
onValueChange = { onValueChange = {
current = it current = it
error = null currentError = null
}, },
label = { Text("Current password") }, label = "Current password",
singleLine = true, errorMessage = currentError,
visualTransformation = PasswordVisualTransformation(), modifier = Modifier.focusRequester(firstFieldFocus),
imeAction = ImeAction.Next,
onImeAction = { /* Tab handled by focus system */ },
) )
} }
OutlinedTextField(
value = new1, PasswordField(
value = new,
onValueChange = { onValueChange = {
new1 = it new = it
error = null newError = null
}, },
label = { Text("New password") }, label = "New password",
singleLine = true, errorMessage = newError,
visualTransformation = PasswordVisualTransformation(), modifier =
if (isChange) Modifier else Modifier.focusRequester(firstFieldFocus),
imeAction = ImeAction.Done,
onImeAction = { submit() },
) )
OutlinedTextField(
value = new2, RequirementChecklistRow(satisfied = new.length >= PRIVACY_LOCK_MIN_PASSWORD_LENGTH)
onValueChange = {
new2 = it Row(
error = null modifier = Modifier.fillMaxWidth(),
}, horizontalArrangement = Arrangement.End,
label = { Text("Confirm new password") }, ) {
singleLine = true, TextButton(onClick = onDismiss) { Text("Cancel") }
visualTransformation = PasswordVisualTransformation(), Button(
) onClick = submit,
error?.let { enabled = new.length >= PRIVACY_LOCK_MIN_PASSWORD_LENGTH,
Text( ) {
text = it, Text("Save")
color = MaterialTheme.colorScheme.error, }
style = MaterialTheme.typography.bodySmall,
)
} }
} }
}
}
}
@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 = { isError = errorMessage != null,
TextButton(onClick = submit) { Text("Save") } supportingText =
}, errorMessage?.let {
dismissButton = { {
TextButton(onClick = onDismiss) { Text("Cancel") } 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
},
)
}
}
@@ -29,9 +29,12 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState 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.amethyst.desktop.security.MessagesFirstRunBanner
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.awt.Cursor import java.awt.Cursor
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
@@ -128,37 +132,48 @@ fun DesktopMessagesScreen(
} }
} }
Column(modifier = Modifier.fillMaxSize()) { val snackbarHostState = remember { SnackbarHostState() }
MessagesFirstRunBanner() val snackbarScope = rememberCoroutineScope()
Box(modifier = Modifier.weight(1f)) {
if (compactMode) { Box(modifier = Modifier.fillMaxSize()) {
CompactMessagesContent( Column(modifier = Modifier.fillMaxSize()) {
selectedRoom = selectedRoom, MessagesFirstRunBanner(onSaved = { msg ->
listState = listState, snackbarScope.launch { snackbarHostState.showSnackbar(msg) }
account = account, })
cacheProvider = cacheProvider, Box(modifier = Modifier.weight(1f)) {
scope = scope, if (compactMode) {
onNavigateToProfile = onNavigateToProfile, CompactMessagesContent(
listFocusRequester = listFocusRequester, selectedRoom = selectedRoom,
onShowNewDm = { showNewDmDialog = true }, listState = listState,
onShowRelayPicker = { showDmRelayPicker = true }, account = account,
keyHandler = keyHandler, cacheProvider = cacheProvider,
) scope = scope,
} else { onNavigateToProfile = onNavigateToProfile,
SplitMessagesContent( listFocusRequester = listFocusRequester,
selectedRoom = selectedRoom, onShowNewDm = { showNewDmDialog = true },
listState = listState, onShowRelayPicker = { showDmRelayPicker = true },
account = account, keyHandler = keyHandler,
cacheProvider = cacheProvider, )
scope = scope, } else {
onNavigateToProfile = onNavigateToProfile, SplitMessagesContent(
listFocusRequester = listFocusRequester, selectedRoom = selectedRoom,
onShowNewDm = { showNewDmDialog = true }, listState = listState,
onShowRelayPicker = { showDmRelayPicker = true }, account = account,
keyHandler = keyHandler, 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) { if (showNewDmDialog) {
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.desktop.ui.settings package com.vitorpamplona.amethyst.desktop.ui.settings
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -33,6 +34,8 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -40,6 +43,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.commons.privacylock.PrivacyLockSettings
import com.vitorpamplona.amethyst.desktop.security.LocalPrivacyLockSettings import com.vitorpamplona.amethyst.desktop.security.LocalPrivacyLockSettings
import com.vitorpamplona.amethyst.desktop.security.SetPasswordDialog import com.vitorpamplona.amethyst.desktop.security.SetPasswordDialog
import kotlinx.coroutines.launch
/** /**
* Desktop privacy-lock settings pane. Column + Card layout (no Scaffold) — * Desktop privacy-lock settings pane. Column + Card layout (no Scaffold) —
@@ -57,22 +62,39 @@ import com.vitorpamplona.amethyst.desktop.security.SetPasswordDialog
@Composable @Composable
fun PrivacyLockSettingsScreen() { fun PrivacyLockSettingsScreen() {
val settings = LocalPrivacyLockSettings.current val settings = LocalPrivacyLockSettings.current
Column( val snackbarHostState = remember { SnackbarHostState() }
modifier = val scope = rememberCoroutineScope()
Modifier
.fillMaxWidth() Box(modifier = Modifier.fillMaxWidth()) {
.padding(16.dp), Column(
verticalArrangement = Arrangement.spacedBy(16.dp), modifier =
) { Modifier
LockToggleCard(settings) .fillMaxWidth()
InactivityCard(settings) .padding(16.dp),
RedactionCard(settings) verticalArrangement = Arrangement.spacedBy(16.dp),
LimitationsCard() ) {
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 @Composable
private fun LockToggleCard(settings: PrivacyLockSettings) { private fun LockToggleCard(
settings: PrivacyLockSettings,
onSaved: (String) -> Unit,
) {
val enabled by settings.lockEnabled.collectAsState() val enabled by settings.lockEnabled.collectAsState()
val stored by settings.passwordHashed.collectAsState() val stored by settings.passwordHashed.collectAsState()
var showSetPassword by remember { mutableStateOf(false) } var showSetPassword by remember { mutableStateOf(false) }
@@ -117,6 +139,7 @@ private fun LockToggleCard(settings: PrivacyLockSettings) {
} }
if (showSetPassword) { if (showSetPassword) {
val wasFirstSet = stored == null
SetPasswordDialog( SetPasswordDialog(
existingHash = stored, existingHash = stored,
onDismiss = { onDismiss = {
@@ -128,6 +151,7 @@ private fun LockToggleCard(settings: PrivacyLockSettings) {
if (pendingEnable) settings.setLockEnabled(true) if (pendingEnable) settings.setLockEnabled(true)
showSetPassword = false showSetPassword = false
pendingEnable = false pendingEnable = false
onSaved(if (wasFirstSet) "Privacy lock enabled" else "Password updated")
}, },
) )
} }