UX improvements: fix nav bar overlap, vault layout overhaul, service autocomplete

This commit is contained in:
Pavel Baksy 2026-03-04 23:51:23 +01:00
parent 063e12c06b
commit a0b269e4bf
5 changed files with 138 additions and 55 deletions

View File

@ -24,6 +24,7 @@ data class DeterministicState(
val biometricEnabled: Boolean = false,
val lockTimeoutMinutes: Int = 5,
val showRotateWarning: Boolean = false,
val serviceHistory: List<String> = emptyList(),
) {
// CharArray doesn't support structural equality; override manually
override fun equals(other: Any?): Boolean {
@ -37,7 +38,8 @@ data class DeterministicState(
clipboardTimerSeconds == other.clipboardTimerSeconds &&
biometricEnabled == other.biometricEnabled &&
lockTimeoutMinutes == other.lockTimeoutMinutes &&
showRotateWarning == other.showRotateWarning
showRotateWarning == other.showRotateWarning &&
serviceHistory == other.serviceHistory
}
override fun hashCode(): Int {
@ -50,6 +52,7 @@ data class DeterministicState(
result = 31 * result + biometricEnabled.hashCode()
result = 31 * result + lockTimeoutMinutes
result = 31 * result + showRotateWarning.hashCode()
result = 31 * result + serviceHistory.hashCode()
return result
}
}
@ -72,6 +75,7 @@ class DeterministicViewModel(app: Application) : AndroidViewModel(app) {
it.copy(
biometricEnabled = settingsPrefs.getBoolean("biometric_enabled", false),
lockTimeoutMinutes = settingsPrefs.getInt("lock_timeout_minutes", 5),
serviceHistory = historyRepository.getNames(),
)
}
}
@ -99,7 +103,7 @@ class DeterministicViewModel(app: Application) : AndroidViewModel(app) {
)
historyRepository.upsert(s.serviceName)
counterRepository.upsert(s.serviceName, counter)
_state.update { it.copy(generatedPassword = password) }
_state.update { it.copy(generatedPassword = password, serviceHistory = historyRepository.getNames()) }
}
fun rotatePassword() {

View File

@ -1,5 +1,7 @@
package cz.bugsy.passwordzebra.ui.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Icon
@ -9,6 +11,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
@ -88,7 +91,8 @@ fun AppNavigation() {
}
}
},
) { _ ->
) { innerPadding ->
Box(modifier = Modifier.padding(bottom = innerPadding.calculateBottomPadding())) {
NavHost(navController = navController, startDestination = ROUTE_WORD_GENERATOR) {
composable(ROUTE_WORD_GENERATOR) {
WordGeneratorScreen()
@ -124,3 +128,4 @@ fun AppNavigation() {
}
}
}
}

View File

@ -1,6 +1,5 @@
package cz.bugsy.passwordzebra.ui.screens
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -12,6 +11,7 @@ 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.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@ -20,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Password
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
@ -27,11 +28,13 @@ import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@ -40,6 +43,9 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.platform.LocalContext
@ -68,6 +74,8 @@ fun DeterministicScreen(
return
}
var showRotateConfirmDialog by remember { mutableStateOf(false) }
if (state.showRotateWarning) {
AlertDialog(
onDismissRequest = { viewModel.dismissRotateWarning() },
@ -112,6 +120,32 @@ fun DeterministicScreen(
)
}
if (showRotateConfirmDialog) {
AlertDialog(
onDismissRequest = { showRotateConfirmDialog = false },
title = { Text(stringResource(R.string.det_rotate_confirm_title)) },
text = { Text(stringResource(R.string.det_rotate_confirm_body)) },
confirmButton = {
Button(
onClick = {
showRotateConfirmDialog = false
viewModel.rotatePassword()
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringResource(R.string.det_rotate_confirm_ok))
}
},
dismissButton = {
TextButton(onClick = { showRotateConfirmDialog = false }) {
Text(stringResource(R.string.det_rotate_confirm_cancel))
}
},
)
}
Scaffold(
topBar = {
TopAppBar(
@ -137,45 +171,7 @@ fun DeterministicScreen(
) {
Spacer(modifier = Modifier.height(4.dp))
// Service name
OutlinedTextField(
value = state.serviceName,
onValueChange = { viewModel.updateServiceName(it) },
label = { Text(stringResource(R.string.det_service_name)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
// Master password
OutlinedTextField(
value = String(state.masterPassword),
onValueChange = { viewModel.updateMasterPassword(it.toCharArray()) },
label = { Text(stringResource(R.string.det_master_password)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (state.masterPasswordVisible) VisualTransformation.None
else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
IconButton(onClick = { viewModel.toggleMasterPasswordVisible() }) {
Icon(
imageVector = if (state.masterPasswordVisible) Icons.Default.VisibilityOff
else Icons.Default.Visibility,
contentDescription = stringResource(R.string.det_toggle_pw_visibility),
)
}
},
)
// Generate button
Button(
onClick = { viewModel.generate() },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.det_generate))
}
// Generated password box
// Generated password box — shown at top once available
if (state.generatedPassword.isNotEmpty()) {
Box(
modifier = Modifier
@ -219,21 +215,89 @@ fun DeterministicScreen(
}
}
// Rotate password button — shown only after a password has been generated
OutlinedButton(
onClick = { viewModel.rotatePassword() },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
// Inline rotate button — right-aligned, error color, confirmation dialog
TextButton(
onClick = { showRotateConfirmDialog = true },
modifier = Modifier.align(Alignment.End),
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Icon(Icons.Default.Warning, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.det_rotate_password))
Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.det_rotate_inline_label))
}
}
// Service name with autocomplete from history
var expanded by remember { mutableStateOf(false) }
val filteredHistory = remember(state.serviceHistory, state.serviceName) {
state.serviceHistory.filter {
state.serviceName.isBlank() || it.contains(state.serviceName, ignoreCase = true)
}
}
ExposedDropdownMenuBox(
expanded = expanded && filteredHistory.isNotEmpty(),
onExpandedChange = { expanded = it },
) {
OutlinedTextField(
value = state.serviceName,
onValueChange = { viewModel.updateServiceName(it); expanded = true },
label = { Text(stringResource(R.string.det_service_name)) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
singleLine = true,
)
ExposedDropdownMenu(
expanded = expanded && filteredHistory.isNotEmpty(),
onDismissRequest = { expanded = false },
) {
filteredHistory.forEach { name ->
DropdownMenuItem(
text = { Text(name) },
onClick = { viewModel.updateServiceName(name); expanded = false },
)
}
}
}
Spacer(Modifier.height(8.dp))
// Master password
OutlinedTextField(
value = String(state.masterPassword),
onValueChange = { viewModel.updateMasterPassword(it.toCharArray()) },
label = { Text(stringResource(R.string.det_master_password)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (state.masterPasswordVisible) VisualTransformation.None
else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
IconButton(onClick = { viewModel.toggleMasterPasswordVisible() }) {
Icon(
imageVector = if (state.masterPasswordVisible) Icons.Default.VisibilityOff
else Icons.Default.Visibility,
contentDescription = stringResource(R.string.det_toggle_pw_visibility),
)
}
},
)
// Square icon generate button
FilledIconButton(
onClick = { viewModel.generate() },
modifier = Modifier
.align(Alignment.CenterHorizontally)
.size(60.dp),
shape = RoundedCornerShape(16.dp),
) {
Icon(
imageVector = Icons.Filled.Password,
contentDescription = stringResource(R.string.det_generate),
)
}
Spacer(modifier = Modifier.height(16.dp))
}
}

View File

@ -32,6 +32,11 @@
<string name="det_rotate_warning_body">Pro tuto službu bylo vygenerováno nové heslo.\n\nVáš záložní QR kód tuto rotaci nezachycuje. Pokud ztratíte zařízení bez aktualizace QR kódu, TOTO HESLO BUDE TRVALE ZTRACENO — žádným jiným způsobem ho nelze obnovit.\n\nOstatní hesla nejsou dotčena.</string>
<string name="det_rotate_go_to_export">Aktualizovat QR zálohu nyní</string>
<string name="det_rotate_later">Udělám to později (riskantní)</string>
<string name="det_rotate_inline_label">Bylo heslo kompromitováno? Rotovat!</string>
<string name="det_rotate_confirm_title">Rotovat heslo?</string>
<string name="det_rotate_confirm_body">Tato akce zvýší čítač pro tuto službu. Předchozí heslo již nebude generováno.</string>
<string name="det_rotate_confirm_ok">Rotovat</string>
<string name="det_rotate_confirm_cancel">Zrušit</string>
<!-- Historie služeb -->
<string name="det_history_title">Historie služeb</string>

View File

@ -31,6 +31,11 @@
<string name="det_rotate_warning_body">A new password has been generated for this service.\n\nYour QR backup no longer reflects this rotation. If you lose this device without updating the QR code, THIS PASSWORD WILL BE PERMANENTLY LOST — it cannot be recovered any other way.\n\nYour other passwords are not affected.</string>
<string name="det_rotate_go_to_export">Update QR Backup Now</string>
<string name="det_rotate_later">I\'ll do it later (risky)</string>
<string name="det_rotate_inline_label">Password compromised? Rotate!</string>
<string name="det_rotate_confirm_title">Rotate password?</string>
<string name="det_rotate_confirm_body">This will increment the counter for this service. The previous password will no longer be generated.</string>
<string name="det_rotate_confirm_ok">Rotate</string>
<string name="det_rotate_confirm_cancel">Cancel</string>
<!-- Service history -->
<string name="det_history_title">Service History</string>