Navigation Architecture
The Visla GPS Android app uses Jetpack Compose Navigation for all in-app routing. Navigation is defined declaratively through a sealed class of screen routes and a single NavHost composable that maps routes to screens.
Key Filesβ
| File | Purpose |
|---|---|
ui/navigation/MainNavigation.kt | Screen sealed class, NavHost setup, all composable routes |
ui/navigation/NavigationBar.kt | Custom bottom navigation bar component |
ui/navigation/ProfilePreloadEntryPoint.kt | Hilt entry point for preloading user profile |
ui/handlers/DeepLinkHandler.kt | Parses deep link URIs into navigation results |
ui/handlers/PushNotificationHandler.kt | Converts push notification taps into pending navigation |
MainActivity.kt | Entry point β wires handlers, auth flow, and MainNavigation |
Screen Sealed Classβ
All routes are defined as objects in a single sealed class Screen. Each screen carries its route string, a title string resource ID, and a Material icon.
sealed class Screen(
val route: String,
val titleResId: Int,
val icon: ImageVector
) {
// Bottom nav tabs
object Map : Screen("map", R.string.nav_map, Icons.Filled.Map)
object Devices : Screen("devices", R.string.nav_devices, Icons.Filled.Router)
object Activity : Screen("activity", R.string.nav_notifications, Icons.Filled.Notifications)
object Settings : Screen("settings", R.string.nav_settings, Icons.Filled.Settings)
// Device screens (parameterised)
object DeviceDetail : Screen("device_detail/{deviceId}", ...)
object History : Screen("history/{deviceId}/{deviceName}", ...)
object Sharing : Screen("sharing/{deviceId}/{deviceName}/{isOwner}", ...)
object Geofences : Screen("geofences/{deviceId}", ...)
object Commands : Screen("commands/{deviceId}", ...)
object GeofenceEditor : Screen("geofence_editor/{deviceId}?id={geofenceId}", ...)
object DeviceSettings : Screen("device_settings/{deviceId}", ...)
object DeviceNotifications: Screen("device_notifications/{deviceId}", ...)
// Settings sub-screens
object EditProfile : Screen("edit_profile", ...)
object ChangePassword : Screen("change_password", ...)
object DeleteAccount : Screen("delete_account", ...)
object TwoFactorSetup : Screen("two_factor_setup", ...)
object NotificationSettings : Screen("notification_settings", ...)
object Invites : Screen("invites", ...)
object ForgotPassword : Screen("forgot_password", ...)
object TermsOfService : Screen("terms_of_service", ...)
object PrivacyPolicy : Screen("privacy_policy", ...)
object Support : Screen("support", ...)
}
Route Tableβ
| Route pattern | Arguments | Type | Screen |
|---|---|---|---|
map | β | β | Map (start destination) |
devices | β | β | Devices list |
activity | β | β | Notifications feed |
settings | β | β | Settings hub |
device_detail/{deviceId} | deviceId: Int | Path | Device detail |
history/{deviceId}/{deviceName} | deviceId: Int, deviceName: String | Path | Location history |
sharing/{deviceId}/{deviceName}/{isOwner} | deviceId: Int, deviceName: String, isOwner: Boolean | Path | Device sharing |
geofences/{deviceId}/{deviceName} | deviceId: Int, deviceName: String | Path | Geofence list |
geofence_editor/{deviceId}/{deviceName}?id={geofenceId} | deviceId: Int, deviceName: String, geofenceId: Int (optional, default -1) | Path + Query | Geofence create/edit |
commands/{deviceId}/{model} | deviceId: Int, model: String (URL-encoded) | Path | Commands history |
device_settings/{deviceId} | deviceId: Int | Path | Device settings |
device_notifications/{deviceId} | deviceId: Int | Path | Per-device notification config |
subscription_plans?current={current}&active={active}&period={period} | current: Int, active: Int, period: String (all optional) | Query | Subscription plans |
edit_profile | β | β | Edit profile |
change_password | β | β | Change password |
delete_account | β | β | Delete account |
two_factor_setup | β | β | 2FA setup |
notification_settings | β | β | Notification sounds |
notification_archive | β | β | Notification archive |
invites | β | β | Pending invites |
set_password | β | β | Set password (OAuth users) |
subscription_management | β | β | Manage subscription |
terms_of_service | β | β | Terms of Service |
privacy_policy | β | β | Privacy Policy |
support | β | β | Support page |
forgot_password | β | β | Forgot password |
Navigation Graph Diagramβ
Bottom Navigationβ
The bottom bar is always visible and contains four tabs:
val bottomNavItems = listOf(
Screen.Map, // Map tab
Screen.Devices, // Devices tab
Screen.Activity, // Notifications tab
Screen.Settings // Settings tab
)
NavigationBar Componentβ
NavigationBar is a custom composable (not the Material 3 NavigationBar) that renders each tab as an icon + label column inside a Row. It:
- Highlights the selected tab with an animated tint and a pill-shaped background (
RoundedCornerShape(50)). - Adapts colours to light/dark theme via
VislaTheme.colors. - Uses
animateColorAsStatewith a 200 ms tween for smooth transitions. - Handles gesture navigation padding with
Modifier.navigationBarsPadding().
@Composable
fun NavigationBar(
items: List<Screen>,
currentRoute: String?,
onItemClick: (Screen) -> Unit,
modifier: Modifier = Modifier
)
Tab Selection & Back Stackβ
When a bottom tab is tapped, navigation uses popUpTo + saveState + restoreState to maintain independent back stacks per tab:
onItemClick = { screen ->
val route = if (screen == Screen.Settings) {
SETTINGS_GRAPH_ROUTE // "settings_graph"
} else {
screen.route
}
navController.navigate(route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
The Settings tab navigates to the nested graph route (settings_graph) rather than the settings composable route directly. This allows all settings sub-screens to share one back stack that is saved/restored as a unit.
Current Tab Detectionβ
The currently selected tab is determined by walking the destination hierarchy, not by simple route string comparison. This ensures the Settings tab stays highlighted when the user is on a sub-screen like Edit Profile:
currentRoute = navBackStackEntry?.destination?.let { dest ->
bottomNavItems.firstOrNull { screen ->
dest.hierarchy.any {
it.route?.startsWith(screen.route.split("?")[0]) == true
}
}?.route
}
NavHost Setupβ
The NavHost is created inside a Scaffold with the bottom bar. Transitions are disabled for instant screen swaps:
NavHost(
navController = navController,
startDestination = "map",
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None },
popEnterTransition = { EnterTransition.None },
popExitTransition = { ExitTransition.None },
sizeTransform = { null },
) {
composable(route = Screen.Map.route) { MapScreen(...) }
composable(Screen.Devices.route) { DevicesListScreen(...) }
composable(Screen.Activity.route) { NotificationScreen(...) }
navigation(route = SETTINGS_GRAPH_ROUTE, startDestination = Screen.Settings.route) {
composable(Screen.Settings.route) { SettingsScreen(...) }
composable("edit_profile") { EditProfileScreen(...) }
// ... other settings sub-screens
}
// Shared detail screens at top level
composable("device_detail/{deviceId}", ...) { DeviceDetailScreen(...) }
composable("history/{deviceId}/{deviceName}", ...) { HistoryScreen(...) }
// ...
}
Argument Passingβ
Route arguments are declared with navArgument and extracted from backStackEntry.arguments:
composable(
route = "device_detail/{deviceId}",
arguments = listOf(
navArgument("deviceId") { type = NavType.IntType }
)
) { backStackEntry ->
val deviceId = backStackEntry.arguments?.getInt("deviceId") ?: 0
DeviceDetailScreen(deviceId = deviceId, ...)
}
Optional query parameters use defaultValue:
composable(
route = "geofence_editor/{deviceId}/{deviceName}?id={geofenceId}",
arguments = listOf(
navArgument("deviceId") { type = NavType.IntType },
navArgument("deviceName") { type = NavType.StringType },
navArgument("geofenceId") {
type = NavType.IntType
defaultValue = -1 // -1 means "create new"
}
)
) { backStackEntry ->
val geofenceId = backStackEntry.arguments?.getInt("geofenceId") ?: -1
GeofenceEditorScreen(
geofenceId = if (geofenceId > 0) geofenceId else null,
...
)
}
String arguments that may contain special characters (like device model names) are URL-encoded before navigation and decoded on arrival:
// Navigate
val encodedModel = java.net.URLEncoder.encode(device.model ?: "", "UTF-8")
navController.navigate("commands/${device.id}/$encodedModel")
// Receive
val model = java.net.URLDecoder.decode(
backStackEntry.arguments?.getString("model") ?: "", "UTF-8"
)
Nested Navigation Graph β Settingsβ
The settings tab uses a nested navigation graph (navigation()) to group all settings-related screens under a single graph route:
private const val SETTINGS_GRAPH_ROUTE = "settings_graph"
navigation(
route = SETTINGS_GRAPH_ROUTE,
startDestination = Screen.Settings.route // "settings"
) {
composable(Screen.Settings.route) { SettingsScreen(...) }
composable("edit_profile") { EditProfileScreen(onBack = { navController.popBackStack() }) }
composable("change_password") { ChangePasswordScreen(onBack = { navController.popBackStack() }) }
composable("delete_account") { DeleteAccountScreen(...) }
composable("two_factor_setup") { TwoFactorSetupScreen(...) }
composable("notification_settings") { NotificationSettingsScreen(...) }
composable("notification_archive") { NotificationArchiveScreen(...) }
composable("invites") { InvitesListScreen(...) }
composable("terms_of_service") { LegalDocumentScreen(documentType = "terms", ...) }
composable("privacy_policy") { LegalDocumentScreen(documentType = "privacy", ...) }
composable("support") { SupportScreen(...) }
composable("set_password") { SetPasswordScreen(...) }
composable("subscription_management") { SubscriptionManagementScreen(...) }
composable("subscription_plans?...") { SubscriptionScreen(...) }
}
All settings sub-screens navigate back with navController.popBackStack(). The nested graph means tapping the Settings bottom tab restores the entire sub-stack (e.g. Settings β Edit Profile) rather than always returning to the root.
Full-Screen vs Standard Layoutβ
The Map screen draws behind the system status bar for an immersive map experience. All other screens receive standard status bar insets:
val isFullscreenContent = currentRoute == "map"
Scaffold(
containerColor = if (isFullscreenContent)
Color.Transparent
else
VislaTheme.colors.background,
contentWindowInsets = if (isFullscreenContent)
WindowInsets(0) // no insets β map draws edge-to-edge
else
WindowInsets.statusBars, // standard padding below status bar
bottomBar = { NavigationBar(...) }
)
The Map screen is responsible for applying its own status bar padding to overlay controls (search bar, buttons) so they don't collide with the system UI.
Deep Link Handlingβ
Deep links flow through a use-case pipeline: URI β ParseDeepLinkUseCase β HandleDeepLinkUseCase β DeepLinkResult β DeepLinkHandlerResult.
Supported Deep Link Typesβ
| Result | Action |
|---|---|
ShowEmailVerifiedDialog | Navigate to login screen |
VerifyEmailToken(token) | Trigger email verification via AuthNavigationIntent |
OAuthLoginComplete(accessToken) | Store token, navigate to main screen |
NavigateToPasswordReset(token) | Navigate to reset password flow |
NavigateToDeviceClaim(token) | Navigate to device claim (TODO) |
ShowErrorDialog(message) | Show verification error dialog |
NoAction | Ignore |
Flowβ
Deep links are processed in both onCreate (cold start) and onNewIntent (app already running).
Push Notification β Navigationβ
When a user taps a push notification, the app navigates to the Map screen centred on the relevant device.
Flowβ
Navigation Executionβ
Inside MainNavigation, a LaunchedEffect observes pendingNavigation from the repository. When a pending navigation arrives, it navigates to the Map tab:
LaunchedEffect(pendingNavigation) {
val pending = pendingNavigation ?: return@LaunchedEffect
kotlinx.coroutines.delay(300) // wait for NavHost readiness on cold start
navController.navigate("map") {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = false // fresh map state to show notification context
}
}
The same mechanism is used when tapping a notification in the Activity (notifications) tab β it sets pending navigation and switches to the Map tab.
Intent Data Extractionβ
The handler supports two payload formats:
| Source | Key format | Example key |
|---|---|---|
| Foreground (FCMService) | FCMService.EXTRA_* constants | EXTRA_DEVICE_ID, EXTRA_LATITUDE |
| Background (FCM data) | Raw string keys | "device_id", "latitude" |
Back Stack Managementβ
Bottom Tab Switchingβ
navController.navigate(route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true // save outgoing tab's back stack
}
launchSingleTop = true // don't create duplicate destinations
restoreState = true // restore incoming tab's back stack
}
This implements the standard Android multi-back-stack pattern where each tab maintains its own navigation history.
Notification-Triggered Navigationβ
navController.navigate("map") {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = false // intentionally false β ensures fresh map state
}
restoreState = false is deliberate: when navigating from a notification, the map should show the notification's device/location context rather than restoring whatever the user was previously viewing.
Detail Screen Back Navigationβ
All detail screens (device detail, history, sharing, etc.) use simple popBackStack():
onBack = { navController.popBackStack() }
Shared ViewModelsβ
Two ViewModels are scoped to the MainNavigation composable and shared across screens:
| ViewModel | Shared across | Purpose |
|---|---|---|
DevicesViewModel | Map, DevicesList, DeviceDetail, Sharing, Invites, DeviceSettings, GeofencesList | Single source of truth for device list, selection, and WebSocket connection |
NotificationViewModel | Activity tab | Notification feed state |
The DevicesViewModel is explicitly shared so that device selection on the Map tab, refreshes triggered from Sharing/Invites, and WebSocket reconnections all operate on the same state.
Profile Preloadingβ
MainNavigation preloads the user profile on first composition to prevent a flash of placeholder data when the user navigates to Settings:
LaunchedEffect(Unit) {
val interactor = EntryPointAccessors.fromApplication(
context.applicationContext,
ProfilePreloadEntryPoint::class.java
).userProfileInteractor()
interactor.loadProfile()
}
This uses a Hilt @EntryPoint interface rather than a ViewModel because it's a fire-and-forget side effect that doesn't need lifecycle management.
Design Decisionsβ
Why Compose Navigation over Fragment-based Navigationβ
The entire UI is built with Jetpack Compose. Using Compose Navigation keeps the stack Compose-native β no Fragment containers, no XML navigation graphs, and route strings provide type-safe-enough navigation without the ceremony of Safe Args. The sealed Screen class adds compile-time safety on top.
Why a Sealed Class for Screenβ
A sealed class centralises all route definitions in one place. Adding a new screen requires adding a new object to Screen, which makes it impossible to reference an undefined route. The route, titleResId, and icon properties eliminate scattered string literals and ensure every screen has the metadata needed for the navigation bar.
Why a Nested Graph for Settingsβ
Settings has 13+ sub-screens. Grouping them in a navigation() block gives the Settings tab its own back stack that is saved and restored as a unit when switching tabs. Without the nested graph, navigating to e.g. Edit Profile and then switching to Map would lose the Settings β Edit Profile stack.
Why the Bottom Bar Is Always Visibleβ
The bottom bar is rendered unconditionally (no route-based hiding). This is a deliberate UX decision: users can always switch tabs regardless of depth in the navigation stack. Detail screens stack on top of the tab content but the bar remains accessible.
Why Transitions Are Disabledβ
All enter/exit transitions are set to None. This gives instant screen swaps that feel snappy on the map-centric GPS app, avoiding animation jank when switching between data-heavy screens.