Skip to main content

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​

FilePurpose
ui/navigation/MainNavigation.ktScreen sealed class, NavHost setup, all composable routes
ui/navigation/NavigationBar.ktCustom bottom navigation bar component
ui/navigation/ProfilePreloadEntryPoint.ktHilt entry point for preloading user profile
ui/handlers/DeepLinkHandler.ktParses deep link URIs into navigation results
ui/handlers/PushNotificationHandler.ktConverts push notification taps into pending navigation
MainActivity.ktEntry 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 patternArgumentsTypeScreen
mapβ€”β€”Map (start destination)
devicesβ€”β€”Devices list
activityβ€”β€”Notifications feed
settingsβ€”β€”Settings hub
device_detail/{deviceId}deviceId: IntPathDevice detail
history/{deviceId}/{deviceName}deviceId: Int, deviceName: StringPathLocation history
sharing/{deviceId}/{deviceName}/{isOwner}deviceId: Int, deviceName: String, isOwner: BooleanPathDevice sharing
geofences/{deviceId}/{deviceName}deviceId: Int, deviceName: StringPathGeofence list
geofence_editor/{deviceId}/{deviceName}?id={geofenceId}deviceId: Int, deviceName: String, geofenceId: Int (optional, default -1)Path + QueryGeofence create/edit
commands/{deviceId}/{model}deviceId: Int, model: String (URL-encoded)PathCommands history
device_settings/{deviceId}deviceId: IntPathDevice settings
device_notifications/{deviceId}deviceId: IntPathPer-device notification config
subscription_plans?current={current}&active={active}&period={period}current: Int, active: Int, period: String (all optional)QuerySubscription 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


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 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 animateColorAsState with 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
}

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 links flow through a use-case pipeline: URI β†’ ParseDeepLinkUseCase β†’ HandleDeepLinkUseCase β†’ DeepLinkResult β†’ DeepLinkHandlerResult.

ResultAction
ShowEmailVerifiedDialogNavigate 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
NoActionIgnore

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​

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:

SourceKey formatExample key
Foreground (FCMService)FCMService.EXTRA_* constantsEXTRA_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:

ViewModelShared acrossPurpose
DevicesViewModelMap, DevicesList, DeviceDetail, Sharing, Invites, DeviceSettings, GeofencesListSingle source of truth for device list, selection, and WebSocket connection
NotificationViewModelActivity tabNotification 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.