From 2cc18c70118c66200432bea72fc7587495df1560 Mon Sep 17 00:00:00 2001 From: pavelb Date: Wed, 4 Mar 2026 23:51:23 +0100 Subject: [PATCH] UX improvements: fix nav bar overlap, vault layout overhaul, service autocomplete --- .../deterministic/DeterministicViewModel.kt | 8 +- .../ui/navigation/AppNavigation.kt | 7 +- .../ui/screens/DeterministicScreen.kt | 168 ++++++++++++------ app/src/main/res/values-cs/strings.xml | 5 + app/src/main/res/values/strings.xml | 5 + 5 files changed, 138 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/cz/bugsy/passwordzebra/deterministic/DeterministicViewModel.kt b/app/src/main/java/cz/bugsy/passwordzebra/deterministic/DeterministicViewModel.kt index 9a4ea3f..cd03b42 100644 --- a/app/src/main/java/cz/bugsy/passwordzebra/deterministic/DeterministicViewModel.kt +++ b/app/src/main/java/cz/bugsy/passwordzebra/deterministic/DeterministicViewModel.kt @@ -24,6 +24,7 @@ data class DeterministicState( val biometricEnabled: Boolean = false, val lockTimeoutMinutes: Int = 5, val showRotateWarning: Boolean = false, + val serviceHistory: List = 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() { diff --git a/app/src/main/java/cz/bugsy/passwordzebra/ui/navigation/AppNavigation.kt b/app/src/main/java/cz/bugsy/passwordzebra/ui/navigation/AppNavigation.kt index 0131358..a056676 100644 --- a/app/src/main/java/cz/bugsy/passwordzebra/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/cz/bugsy/passwordzebra/ui/navigation/AppNavigation.kt @@ -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() @@ -122,5 +126,6 @@ fun AppNavigation() { ) } } + } } } diff --git a/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/DeterministicScreen.kt b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/DeterministicScreen.kt index c1df7eb..cae40df 100644 --- a/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/DeterministicScreen.kt +++ b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/DeterministicScreen.kt @@ -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)) } } diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 02c570b..a4e808d 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -32,6 +32,11 @@ 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. Aktualizovat QR zálohu nyní Udělám to později (riskantní) + Bylo heslo kompromitováno? Rotovat! + Rotovat heslo? + Tato akce zvýší čítač pro tuto službu. Předchozí heslo již nebude generováno. + Rotovat + Zrušit Historie služeb diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 94c5b96..aef3fe7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,6 +31,11 @@ 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. Update QR Backup Now I\'ll do it later (risky) + Password compromised? Rotate! + Rotate password? + This will increment the counter for this service. The previous password will no longer be generated. + Rotate + Cancel Service History