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).
*/
@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")
},
)
}
@@ -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
},
)
}
}
@@ -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) {
@@ -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")
},
)
}