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 92dfef74bf
commit eeb308ec63
5 changed files with 138 additions and 55 deletions

View File

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

View File

@ -1,5 +1,7 @@
package cz.bugsy.passwordzebra.ui.navigation 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.Icons
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -9,6 +11,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel 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) { NavHost(navController = navController, startDestination = ROUTE_WORD_GENERATOR) {
composable(ROUTE_WORD_GENERATOR) { composable(ROUTE_WORD_GENERATOR) {
WordGeneratorScreen() WordGeneratorScreen()
@ -124,3 +128,4 @@ fun AppNavigation() {
} }
} }
} }
}

View File

@ -1,6 +1,5 @@
package cz.bugsy.passwordzebra.ui.screens package cz.bugsy.passwordzebra.ui.screens
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape 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.Icons
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.History 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.Settings
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff 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.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -40,6 +43,9 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -68,6 +74,8 @@ fun DeterministicScreen(
return return
} }
var showRotateConfirmDialog by remember { mutableStateOf(false) }
if (state.showRotateWarning) { if (state.showRotateWarning) {
AlertDialog( AlertDialog(
onDismissRequest = { viewModel.dismissRotateWarning() }, 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( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@ -137,45 +171,7 @@ fun DeterministicScreen(
) { ) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
// Service name // Generated password box — shown at top once available
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
if (state.generatedPassword.isNotEmpty()) { if (state.generatedPassword.isNotEmpty()) {
Box( Box(
modifier = Modifier modifier = Modifier
@ -219,21 +215,89 @@ fun DeterministicScreen(
} }
} }
// Rotate password button — shown only after a password has been generated // Inline rotate button — right-aligned, error color, confirmation dialog
OutlinedButton( TextButton(
onClick = { viewModel.rotatePassword() }, onClick = { showRotateConfirmDialog = true },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.align(Alignment.End),
colors = ButtonDefaults.outlinedButtonColors( colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
contentColor = MaterialTheme.colorScheme.error,
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
) { ) {
Icon(Icons.Default.Warning, contentDescription = null) Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(8.dp)) Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.det_rotate_password)) 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)) 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_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_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_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 --> <!-- Historie služeb -->
<string name="det_history_title">Historie služeb</string> <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_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_go_to_export">Update QR Backup Now</string>
<string name="det_rotate_later">I\'ll do it later (risky)</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 --> <!-- Service history -->
<string name="det_history_title">Service History</string> <string name="det_history_title">Service History</string>