Add directional nav animations; back on Vault closes app

This commit is contained in:
pavelb 2026-03-06 19:00:14 +01:00
parent 7720cb3e2a
commit 47dda9e910
2 changed files with 86 additions and 23 deletions

View File

@ -21,6 +21,11 @@ import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
@ -40,6 +45,7 @@ private const val ROUTE_EXPORT_IMPORT = "export_import"
private const val ROUTE_SETTINGS = "settings"
private val bottomBarRoutes = setOf(ROUTE_WORD_GENERATOR, ROUTE_DETERMINISTIC)
private val tabRouteOrder = listOf(ROUTE_WORD_GENERATOR, ROUTE_DETERMINISTIC)
@Composable
fun AppNavigation() {
@ -101,10 +107,51 @@ fun AppNavigation() {
val bottomPadding = if (imeVisible) 0.dp else innerPadding.calculateBottomPadding()
Box(modifier = Modifier.padding(bottom = bottomPadding)) {
NavHost(navController = navController, startDestination = ROUTE_WORD_GENERATOR) {
composable(ROUTE_WORD_GENERATOR) {
WordGeneratorScreen()
composable(
ROUTE_WORD_GENERATOR,
enterTransition = {
val fromIdx = tabRouteOrder.indexOf(initialState.destination.route)
val toIdx = tabRouteOrder.indexOf(ROUTE_WORD_GENERATOR)
if (fromIdx != -1) slideInHorizontally(tween(200)) { if (toIdx > fromIdx) it else -it }
else fadeIn(tween(200))
},
exitTransition = {
val toIdx = tabRouteOrder.indexOf(targetState.destination.route)
if (toIdx != -1) slideOutHorizontally(tween(200)) { if (toIdx > 0) -it else it }
else fadeOut(tween(150))
},
popEnterTransition = {
val fromIdx = tabRouteOrder.indexOf(initialState.destination.route)
val toIdx = tabRouteOrder.indexOf(ROUTE_WORD_GENERATOR)
if (fromIdx != -1) slideInHorizontally(tween(200)) { if (toIdx > fromIdx) it else -it }
else fadeIn(tween(200))
},
popExitTransition = { fadeOut(tween(150)) },
) {
WordGeneratorScreen(
onNavigateToSettings = { navController.navigate(ROUTE_SETTINGS) },
)
}
composable(ROUTE_DETERMINISTIC) {
composable(
ROUTE_DETERMINISTIC,
enterTransition = {
val fromIdx = tabRouteOrder.indexOf(initialState.destination.route)
val toIdx = tabRouteOrder.indexOf(ROUTE_DETERMINISTIC)
if (fromIdx != -1) slideInHorizontally(tween(200)) { if (toIdx > fromIdx) it else -it }
else fadeIn(tween(200))
},
exitTransition = {
val toIdx = tabRouteOrder.indexOf(targetState.destination.route)
if (toIdx != -1) slideOutHorizontally(tween(200)) { if (toIdx > 1) -it else it }
else fadeOut(tween(150))
},
popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = {
val toIdx = tabRouteOrder.indexOf(targetState.destination.route)
if (toIdx != -1) slideOutHorizontally(tween(200)) { if (toIdx > 1) -it else it }
else fadeOut(tween(150))
},
) {
DeterministicScreen(
viewModel = detViewModel,
onNavigateToHistory = { navController.navigate(ROUTE_SERVICE_HISTORY) },
@ -112,21 +159,39 @@ fun AppNavigation() {
onNavigateToExport = { navController.navigate(ROUTE_EXPORT_IMPORT) },
)
}
composable(ROUTE_SERVICE_HISTORY) {
composable(
ROUTE_SERVICE_HISTORY,
enterTransition = { slideInHorizontally(tween(300)) { it } },
exitTransition = { fadeOut(tween(200)) },
popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { slideOutHorizontally(tween(300)) { it } },
) {
ServiceHistoryScreen(
viewModel = detViewModel,
onNavigateBack = { navController.popBackStack() },
onServiceSelected = { _ -> navController.popBackStack() },
)
}
composable(ROUTE_SETTINGS) {
composable(
ROUTE_SETTINGS,
enterTransition = { slideInHorizontally(tween(300)) { it } },
exitTransition = { fadeOut(tween(200)) },
popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { slideOutHorizontally(tween(300)) { it } },
) {
SettingsScreen(
viewModel = detViewModel,
onNavigateBack = { navController.popBackStack() },
onNavigateToExportImport = { navController.navigate(ROUTE_EXPORT_IMPORT) },
)
}
composable(ROUTE_EXPORT_IMPORT) {
composable(
ROUTE_EXPORT_IMPORT,
enterTransition = { slideInHorizontally(tween(300)) { it } },
exitTransition = { fadeOut(tween(200)) },
popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { slideOutHorizontally(tween(300)) { it } },
) {
ExportImportScreen(
viewModel = detViewModel,
onNavigateBack = { navController.popBackStack() },

View File

@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -43,6 +43,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -62,6 +63,7 @@ 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.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cz.bugsy.passwordzebra.R
@ -78,6 +80,7 @@ fun DeterministicScreen(
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
BackHandler { (context as? android.app.Activity)?.finish() }
if (state.isLocked) {
LockScreen(viewModel = viewModel)
return
@ -171,13 +174,13 @@ fun DeterministicScreen(
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(32.dp))
// Generated password box — always visible, even when empty
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 80.dp)
.height(150.dp)
.border(
width = 2.dp,
color = MaterialTheme.colorScheme.primary,
@ -211,13 +214,6 @@ fun DeterministicScreen(
color = MaterialTheme.colorScheme.secondary,
)
}
} else {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.det_tap_to_copy),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@ -228,7 +224,7 @@ fun DeterministicScreen(
TextButton(
onClick = { showRotateConfirmDialog = true },
modifier = Modifier.align(Alignment.End),
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFFFD600)),
) {
Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
@ -239,13 +235,15 @@ fun DeterministicScreen(
// 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)
if (state.serviceName.length < 3) emptyList()
else state.serviceHistory.filter {
it.startsWith(state.serviceName, ignoreCase = true)
}
}
val showDropdown = expanded && filteredHistory.size >= 2
ExposedDropdownMenuBox(
expanded = expanded && filteredHistory.isNotEmpty(),
expanded = showDropdown,
onExpandedChange = { expanded = it },
) {
OutlinedTextField(
@ -265,7 +263,7 @@ fun DeterministicScreen(
),
)
ExposedDropdownMenu(
expanded = expanded && filteredHistory.isNotEmpty(),
expanded = showDropdown,
onDismissRequest = { expanded = false },
) {
filteredHistory.forEach { name ->
@ -329,7 +327,7 @@ fun DeterministicScreen(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(horizontal = 16.dp, vertical = 14.dp),
.padding(start = 16.dp, top = 14.dp, end = 16.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
@ -344,7 +342,7 @@ fun DeterministicScreen(
Spacer(Modifier.width(20.dp))
FilledIconButton(
onClick = { viewModel.generate() },
onClick = { keyboardController?.hide(); viewModel.generate() },
modifier = Modifier.size(60.dp),
shape = RoundedCornerShape(16.dp),
) {