Skip to main content

Overview

Divvy uses Jetpack Compose Navigation with Kotlin serialization for type-safe navigation. This approach eliminates string-based routes and provides compile-time safety for navigation arguments. All app destinations are defined as a sealed interface with serializable data classes:
app/src/main/java/com/example/divvy/ui/navigation/AppDestination.kt
@Serializable
sealed interface AppDestination {

    @Serializable data object Home      : AppDestination
    @Serializable data object Groups    : AppDestination
    @Serializable data object Friends   : AppDestination
    @Serializable data object Ledger    : AppDestination
    @Serializable data object Analytics : AppDestination
    @Serializable data object Profile   : AppDestination

    @Serializable
    data class GroupDetail(val groupId: String) : AppDestination

    @Serializable
    data object ScanReceipt : AppDestination

    @Serializable
    data class SplitExpense(
        val scannedAmount: String = "",
        val scannedDescription: String = "",
        val preselectedGroupId: String = ""
    ) : AppDestination

    @Serializable
    data class AssignItems(
        val groupId: String,
        val amountDisplay: String,
        val description: String
    ) : AppDestination

    @Serializable
    data class SplitByPercentage(
        val groupId: String,
        val amountDisplay: String,
        val description: String
    ) : AppDestination
}
Type Safety: Using @Serializable data classes ensures navigation arguments are type-safe and validated at compile time.

Main Tabs

  • Home - Activity feed and quick actions
  • Groups - Manage expense groups
  • Friends - Friend connections
  • Profile - User account settings

Feature Flows

  • GroupDetail - View group expenses and members
  • ScanReceipt - Camera-based receipt scanning
  • SplitExpense - Choose split method
  • AssignItems - Item-level expense assignment
  • SplitByPercentage - Percentage-based splits

Secondary Screens

  • Ledger - All balances and settlements
  • Analytics - Spending insights
The AppNavHost defines the navigation graph with all composable destinations:
app/src/main/java/com/example/divvy/ui/navigation/AppNavHost.kt
@Composable
fun AppNavHost(
    navController: NavHostController,
    modifier: Modifier = Modifier
) {
    NavHost(
        navController = navController,
        startDestination = AppDestination.Home,
        modifier = modifier
    ) {
        composable<AppDestination.Home> {
            HomeScreen(
                onGroupClick = { id -> 
                    navController.navigate(AppDestination.GroupDetail(id)) 
                },
                onGroupsClick = {
                    navController.navigate(AppDestination.Groups) {
                        popUpTo(navController.graph.findStartDestination().id) { 
                            saveState = true 
                        }
                        launchSingleTop = true
                        restoreState = true
                    }
                },
                onAddExpense = { 
                    navController.navigate(AppDestination.SplitExpense()) 
                },
                onLedgerClick = { 
                    navController.navigate(AppDestination.Ledger) 
                }
            )
        }
        
        composable<AppDestination.Groups> {
            GroupsScreen(
                onGroupClick = { id -> 
                    navController.navigate(AppDestination.GroupDetail(id)) 
                },
                onCreatedGroupNavigate = { id ->
                    navController.navigate(AppDestination.GroupDetail(id))
                }
            )
        }
        
        composable<AppDestination.GroupDetail> { backStack ->
            val dest: AppDestination.GroupDetail = backStack.toRoute()
            GroupDetailScreen(
                groupId = dest.groupId,
                onBack = { navController.popBackStack() },
                onLeaveGroup = {
                    navController.popBackStack(
                        route = AppDestination.Home,
                        inclusive = false
                    )
                },
                onAddExpense = {
                    navController.navigate(
                        AppDestination.SplitExpense(
                            preselectedGroupId = dest.groupId
                        )
                    )
                }
            )
        }
        
        composable<AppDestination.ScanReceipt> {
            ScanReceiptScreen(
                onBack = { navController.popBackStack() },
                onScanComplete = { amount, description ->
                    navController.popBackStack()
                    navController.navigate(
                        AppDestination.SplitExpense(
                            scannedAmount = amount,
                            scannedDescription = description
                        )
                    )
                }
            )
        }
        
        composable<AppDestination.SplitExpense> {
            SplitExpenseScreen(
                onBack = { navController.popBackStack() },
                onNavigateToAssignItems = { groupId, amount, description ->
                    navController.navigate(
                        AppDestination.AssignItems(groupId, amount, description)
                    )
                },
                onNavigateToSplitByPercentage = { groupId, amount, description ->
                    navController.navigate(
                        AppDestination.SplitByPercentage(groupId, amount, description)
                    )
                }
            )
        }
    }
}

Passing Arguments

Navigation arguments are passed as constructor parameters:
// Navigate to group detail with group ID
navController.navigate(
    AppDestination.GroupDetail(groupId = "abc123")
)

Retrieving Arguments

Arguments are retrieved using toRoute() extension function:
composable<AppDestination.GroupDetail> { backStack ->
    val dest: AppDestination.GroupDetail = backStack.toRoute()
    GroupDetailScreen(
        groupId = dest.groupId,  // Type-safe access
        onBack = { navController.popBackStack() }
    )
}

ViewModel Factory with Arguments

For ViewModels that need navigation arguments, use Hilt’s assisted injection:
app/src/main/java/com/example/divvy/ui/navigation/AppNavHost.kt
composable<AppDestination.SplitByPercentage> { backStack ->
    val dest: AppDestination.SplitByPercentage = backStack.toRoute()
    val viewModel = hiltViewModel<
        SplitByPercentageViewModel, 
        SplitByPercentageViewModel.Factory
    >(
        creationCallback = { factory ->
            factory.create(
                dest.groupId, 
                dest.amountDisplay, 
                dest.description
            )
        }
    )
    SplitByPercentageScreen(
        viewModel = viewModel,
        onBack = { navController.popBackStack() },
        onDone = {
            navController.popBackStack(
                route = AppDestination.Home,
                inclusive = false
            )
        }
    )
}
The ViewModel factory pattern allows Hilt to inject dependencies while accepting navigation arguments.

Bottom Navigation

The bottom navigation bar provides quick access to main tabs:
app/src/main/java/com/example/divvy/ui/navigation/BottomNavigationBar.kt
@Composable
fun BottomNavigationBar(navController: NavController) {
    val navBackStackEntry by navController.currentBackStackEntryAsState()
    val currentDestination = navBackStackEntry?.destination

    Box(
        modifier = Modifier.fillMaxWidth().height(80.dp),
        contentAlignment = Alignment.BottomCenter
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .height(64.dp)
                .background(MaterialTheme.colorScheme.surface),
            horizontalArrangement = Arrangement.SpaceAround
        ) {
            NavItem(
                icon = Icons.Rounded.Home,
                label = "Home",
                isSelected = currentDestination?.hasRoute<AppDestination.Home>() == true,
                onClick = {
                    navController.navigate(AppDestination.Home) {
                        popUpTo(navController.graph.findStartDestination().id) { 
                            saveState = true 
                        }
                        launchSingleTop = true
                        restoreState = true
                    }
                }
            )
            
            NavItem(
                icon = Icons.Rounded.Group,
                label = "Groups",
                isSelected = currentDestination?.hasRoute<AppDestination.Groups>() == true,
                onClick = {
                    navController.navigate(AppDestination.Groups) {
                        popUpTo(navController.graph.findStartDestination().id) { 
                            saveState = true 
                        }
                        launchSingleTop = true
                        restoreState = true
                    }
                }
            )
            
            Spacer(modifier = Modifier.size(48.dp)) // Space for FAB
            
            NavItem(
                icon = Icons.Rounded.People,
                label = "Friends",
                isSelected = currentDestination?.hasRoute<AppDestination.Friends>() == true,
                onClick = { /* ... */ }
            )
            
            NavItem(
                icon = Icons.Rounded.Person,
                label = "Account",
                isSelected = currentDestination?.hasRoute<AppDestination.Profile>() == true,
                onClick = { /* ... */ }
            )
        }
        
        // Center FAB for receipt scanning
        Box(
            modifier = Modifier
                .align(Alignment.TopCenter)
                .offset(y = (-20).dp)
                .size(56.dp)
                .clip(CircleShape)
                .background(MaterialTheme.colorScheme.primary)
                .clickable { navController.navigate(AppDestination.ScanReceipt) },
            contentAlignment = Alignment.Center
        ) {
            Icon(
                imageVector = Icons.Default.CameraAlt,
                contentDescription = "Scan Receipt"
            )
        }
    }
}
popUpTo(navController.graph.findStartDestination().id) { 
    saveState = true 
}
Preserves the state when navigating away from a tab

Deep Linking

Divvy supports deep linking for group invitations through Supabase Auth:

Auth Redirect Configuration

app/src/main/java/com/example/divvy/di/NetworkModule.kt
install(Auth) {
    scheme = "com.example.divvy"
    host = "auth"
    defaultExternalAuthAction = ExternalAuthAction.CUSTOM_TABS
}

Redirect URL Pattern

com.example.divvy://auth
This URL must be configured in Supabase Auth settings to enable:
  • Google OAuth sign-in redirects
  • Group invitation deep links
  • Password reset flows
Ensure the redirect URL is added to your Supabase project’s Auth settings under “Redirect URLs”.

AndroidManifest Configuration

<activity android:name=".MainActivity">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data
            android:scheme="com.example.divvy"
            android:host="auth" />
    </intent-filter>
</activity>

Creating an Expense

Group Management

Back Stack Management

Pop to Destination

// Return to Home after completing expense
navController.popBackStack(
    route = AppDestination.Home,
    inclusive = false  // Keep Home in the stack
)

Pop Current Screen

// Simple back navigation
navController.popBackStack()

Clear Back Stack

// Navigate to Home and clear everything else
navController.navigate(AppDestination.Home) {
    popUpTo(0) { inclusive = true }
    launchSingleTop = true
}

Testing Navigation

@Test
fun navigateToGroupDetail_displaysGroupId() {
    val navController = TestNavHostController(context)
    navController.setGraph(R.navigation.app_nav_graph)
    
    composeTestRule.setContent {
        AppNavHost(navController = navController)
    }
    
    // Navigate to group detail
    navController.navigate(AppDestination.GroupDetail("group123"))
    
    // Verify navigation
    assertEquals(
        AppDestination.GroupDetail("group123"),
        navController.currentBackStackEntry?.toRoute()
    )
}

Best Practices

1

Use Type-Safe Routes

Always use AppDestination sealed interface instead of string routes
2

Validate Arguments

Use non-nullable types for required arguments, nullable types for optional ones
3

Handle Back Navigation

Always provide onBack callbacks in screen composables
4

Save Tab State

Use saveState and restoreState for bottom navigation tabs
5

Avoid Deep Stacks

Pop back to main screens after completing flows (like expense creation)
Don’t pass complex objects through navigation. Use IDs and fetch data in ViewModels using repositories.

Common Patterns

Conditional Navigation

LaunchedEffect(uiState.createCompletedGroupId) {
    val groupId = uiState.createCompletedGroupId ?: return@LaunchedEffect
    onCreatedGroupNavigate(groupId)
    viewModel.onCreateNavigationHandled()
}

Multi-Step Flows

// Step 1: Split Expense
SplitExpenseScreen(
    onNavigateToAssignItems = { groupId, amount, description ->
        navController.navigate(
            AppDestination.AssignItems(groupId, amount, description)
        )
    }
)

// Step 2: Assign Items
AssignItemsScreen(
    onDone = {
        navController.popBackStack(
            route = AppDestination.Home,
            inclusive = false
        )
    }
)

Next Steps

MVVM Pattern

Learn how navigation integrates with ViewModels

Architecture Overview

Return to architecture overview

Build docs developers (and LLMs) love