From a871caa517e54a2356782925de7c5ad448a34d2d Mon Sep 17 00:00:00 2001 From: Pavel Baksy Date: Fri, 6 Mar 2026 19:00:14 +0100 Subject: [PATCH] Add directional nav animations; back on Vault closes app --- .../ui/navigation/AppNavigation.kt | 77 +++++++++++++++++-- .../ui/screens/DeterministicScreen.kt | 32 ++++---- 2 files changed, 86 insertions(+), 23 deletions(-) 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 235a93f..c0e2d38 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 @@ -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() }, 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 a1b5e1d..d33c3fe 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 @@ -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), ) {