UX improvements: fix nav bar overlap, vault layout overhaul, service autocomplete
This commit is contained in:
parent
92dfef74bf
commit
eeb308ec63
@ -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() {
|
||||||
|
|||||||
@ -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()
|
||||||
@ -123,4 +127,5 @@ fun AppNavigation() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user