Skip to content

UsmanAnsari/FinFlow

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

8 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

🏦 FinFlow

FinFlow Logo

Kotlin Android Jetpack Compose Navigation

Architecture Routes

Fintech Onboarding Flow

A focused navigation architecture project demonstrating nested navigation graphs, type-safe typed routes, graph-scoped ViewModel state, back stack management, and animated screen transitions


πŸ“± What This App Does

FinFlow is a multi-step onboarding wizard for a fintech app. A user opens the app, moves through a 5-screen onboarding flow, and lands on a static dashboard with bottom navigation.

All data is static. There is no network layer, no database, and no remote API. The complexity budget of this project is spent entirely on navigation architecture and UI structure.


πŸ“Έ Screenshots

Welcome Choose Account Personal Details ID Verification Success Dashboard
Welcome AccountType PersonalDetails IDVerification Success Dashboard

πŸ› οΈ Tech Stack

Category Technology Why This Choice
Language Kotlin 2.3.21 Serialization plugin, coroutines, null safety
UI Jetpack Compose + Material 3 Declarative UI, slot-based component design
Navigation Navigation Compose 2.9.8 Type-safe routes, nested graph support
State ViewModel + StateFlow Graph-scoped state, lifecycle-aware collection
Serialization Kotlinx Serialization Compile-time route encoding and argument safety
Build Gradle KTS + Version Catalog Centralised dependency management

πŸ—οΈ Navigation Architecture

FinFlow uses two nested navigation graphs. Neither graph knows about the other's internal destinations β€” each owns its screens exclusively.

AppNavHost
    β”‚
    β”œβ”€β”€ OnboardingGraph  (startDestination = Welcome)
    β”‚       β”‚
    β”‚       β”œβ”€β”€ Welcome
    β”‚       β”œβ”€β”€ ChooseAccountType
    β”‚       β”œβ”€β”€ PersonalDetails(accountType: String)
    β”‚       β”œβ”€β”€ IdVerification
    β”‚       └── Success ──► navigate(MainGraph) { popUpTo(OnboardingGraph) { inclusive = true } }
    β”‚
    └── MainGraph  (startDestination = Home)
            β”‚
            β”œβ”€β”€ Home
            β”œβ”€β”€ Cards
            └── Settings

Why Two Graphs Instead of a Flat List of Destinations

A flat NavHost with all destinations at the same level works for simple apps. It breaks down when:

  • You need back stack isolation between features (onboarding should not share a back stack with the main app)
  • You need graph-scoped ViewModel state (a ViewModel scoped to a flat destination is destroyed on navigation away)
  • You need to pop an entire flow in a single operation (popUpTo on a graph destination removes everything inside it atomically)

Two graphs makes these problems structural rather than requiring workarounds at every call site.


πŸ—ΊοΈ Screen Flow

[Welcome]
    β”‚ onGetStarted()
    β–Ό
[Choose Account Type]
    β”‚ onAccountTypeSelected(type: String)
    β”‚ ── saves to OnboardingViewModel
    β”‚ ── navigates to PersonalDetails(accountType = type)
    β–Ό
[Personal Details]
    β”‚ receives accountType as typed route argument
    β”‚ onContinue()
    β–Ό
[ID Verification]
    β”‚ onVerified()
    β–Ό
[Success]
    β”‚ BackHandler active ── back press consumed, does nothing
    β”‚ onEnterApp()
    β”‚ ── popUpTo(OnboardingGraph) { inclusive = true }
    β–Ό
[Dashboard]  ← onboarding graph is gone from back stack
    β”œβ”€β”€ Cards
    └── Settings

πŸ”‘ Key Implementation Decisions

1. Typed Routes with @Serializable

All navigation destinations are defined as @Serializable objects or data classes β€” not as strings.

@Serializable object OnboardingGraph
@Serializable object MainGraph

@Serializable object Welcome
@Serializable object ChooseAccountType
@Serializable data class PersonalDetails(val accountType: String)
@Serializable object IdVerification
@Serializable object Success

@Serializable object Home
@Serializable object Cards
@Serializable object Settings

Why not strings?

String-based routes ("onboarding/personal_details/{accountType}") have no compiler support. A typo compiles silently and crashes at runtime. Argument types are not enforced β€” nothing prevents passing an Int where a String is expected. Renaming a destination requires finding every string reference manually.

Typed routes fail at compile time. The argument contract is enforced by the type system. Renaming a destination propagates automatically via IDE refactoring. PersonalDetails is a data class rather than an object specifically because it carries a constructor argument β€” this distinction makes the argument requirement explicit in the type definition.

2. Graph-Scoped ViewModel

OnboardingViewModel is scoped to OnboardingGraph, not to individual screens.

// Inside any onboarding composable destination
val parentEntry = remember(backStackEntry) {
    navController.getBackStackEntry<OnboardingGraph>()
}
val viewModel: OnboardingViewModel = viewModel(parentEntry)

Why graph-scoped instead of screen-scoped?

A screen-scoped ViewModel (viewModel() with no parent entry) is destroyed when you navigate away from that screen. If ChooseAccountTypeScreen owns its own ViewModel and the user navigates to PersonalDetails then presses back, a new ViewModel instance is created β€” the previously selected account type is gone.

Graph-scoped means the same ViewModel instance is shared across every screen in OnboardingGraph. It is created when the first screen in the graph is entered and destroyed automatically when OnboardingGraph is popped from the back stack β€” which happens at the moment the user enters the main app. No manual cleanup is required.

3. Back Stack Management with popUpTo

The transition from onboarding to the main app:

navController.navigate(MainGraph) {
    popUpTo(OnboardingGraph) { inclusive = true }
}

inclusive = true removes OnboardingGraph itself from the back stack, not just its children. Without this, pressing back from the dashboard would return the user to the Success screen. With it, the entire onboarding graph is removed atomically β€” the back press from the dashboard exits the app cleanly.

The bug this prevents: Without popUpTo, the back stack after completing onboarding looks like:

Bottom of stack
    OnboardingGraph
        Welcome
        ChooseAccountType
        PersonalDetails
        IdVerification
        Success          ← still here
    MainGraph
        Home             ← current
Top of stack

With popUpTo(OnboardingGraph) { inclusive = true }:

Bottom of stack
    MainGraph
        Home             ← current
Top of stack

4. BackHandler on the Success Screen

composable<Success> {
    BackHandler { /* intentionally consumed β€” onboarding is complete */ }
    SuccessScreen(onEnterApp = { ... })
}

The Success screen represents a completed state. Allowing back navigation here would return the user to ID Verification β€” a step that is already done and cannot be meaningfully repeated in the same session. BackHandler intercepts the system back gesture and discards it, making the forward-only nature of the completed flow explicit in code.

5. Symmetric Transitions

Each destination defines four transition parameters β€” not two:

composable<PersonalDetails>(
    enterTransition    = { slideInHorizontally { it } + fadeIn(tween(750)) },
    exitTransition     = { slideOutHorizontally { -it } + fadeOut(tween(750)) },
    popEnterTransition = { slideInHorizontally { -it } + fadeIn(tween(750)) },
    popExitTransition  = { slideOutHorizontally { it } + fadeOut(tween(750)) }
)

enterTransition and exitTransition control forward navigation animation. popEnterTransition and popExitTransition control back navigation animation. Without both pairs, back gestures produce incorrect animations β€” screens appear to slide in from the wrong direction, breaking the spatial model the user builds from forward navigation.

6. Slot Pattern in OnboardingScaffold

Three of six onboarding screens share the same layout skeleton: progress indicator, title, subtitle, scrollable content area, and a bottom CTA area. Rather than duplicating this structure, a single OnboardingScaffold composable owns the layout and exposes two content slots:

@Composable
fun OnboardingScaffold(
    title: String,
    subtitle: String,
    progress: Float,
    content: @Composable ColumnScope.() -> Unit,
    bottomContent: @Composable ColumnScope.() -> Unit
)

Each screen provides only what is unique to it. The progress value advances per screen (0.25 β†’ 0.5 β†’ 0.75 β†’ 1.0) to give the user a sense of position within the flow. This is the same slot pattern used by Material 3's Scaffold, TopAppBar, and Card components.


πŸ“‚ Project Structure

com.yourname.Finflow/
β”‚
β”œβ”€β”€ navigation/
β”‚   β”œβ”€β”€ Routes.kt               ← all @Serializable route definitions
β”‚   β”œβ”€β”€ AppNavHost.kt           ← root NavHost, wires both graphs
β”‚   β”œβ”€β”€ OnboardingNavGraph.kt   ← NavGraphBuilder extension, onboarding flow
β”‚   └── MainNavGraph.kt         ← NavGraphBuilder extension, main app tabs
β”‚
β”œβ”€β”€ ui/
β”‚   β”œβ”€β”€ onboarding/
β”‚   β”‚   β”œβ”€β”€ OnboardingViewModel.kt   ← graph-scoped, StateFlow-backed
β”‚   β”‚   └── screens/
β”‚   β”‚       β”œβ”€β”€ WelcomeScreen.kt
β”‚   β”‚       β”œβ”€β”€ ChooseAccountTypeScreen.kt
β”‚   β”‚       β”œβ”€β”€ PersonalDetailsScreen.kt
β”‚   β”‚       β”œβ”€β”€ IdVerificationScreen.kt
β”‚   β”‚       └── SuccessScreen.kt
β”‚   β”‚
β”‚   β”œβ”€β”€ main/
β”‚   β”‚   └── screens/
β”‚   β”‚       └── HomeScreen.kt
β”‚   β”‚
β”‚   β”œβ”€β”€ components/
β”‚   β”‚   └── OnboardingScaffold.kt    ← shared layout slot component
β”‚   β”‚
β”‚   └── theme/
β”‚       └── Theme.kt

Why each graph is a NavGraphBuilder extension function:

fun NavGraphBuilder.onboardingGraph(navController: NavController) { ... }
fun NavGraphBuilder.mainGraph(navController: NavController) { ... }

Extension functions on NavGraphBuilder mean each graph is defined independently of AppNavHost. In a multi-module production codebase, the onboarding module would register its own graph β€” AppNavHost would call onboardingGraph() without knowing anything about its internal destinations. This project simulates that separation in a single module.


πŸ§ͺ Back Stack Verification Checklist

Manual test cases that verify the navigation architecture is correct:

Test Expected Result Verifies
Complete full onboarding flow Lands on Dashboard popUpTo removes onboarding graph
Press back from Dashboard App exits Onboarding graph is fully removed
Press back on Success screen Nothing happens BackHandler is active
Select account type, navigate to Personal Details, press back Account type still selected on return Graph-scoped ViewModel persists state
Rotate device on Personal Details Account type badge still correct ViewModel survives config change
Press back from Choose Account Type Returns to Welcome Standard back stack behaviour
Complete onboarding, press back multiple times App exits cleanly No orphaned destinations

πŸŽ“ What I Learned

Navigation Architecture

Graphs are architectural boundaries, not cosmetic groupings β€” A nested graph is not just a way to organise a long list of destinations. It creates a lifecycle boundary (ViewModel can be scoped to it), a back stack boundary (popUpTo can remove it atomically), and a module boundary (the graph can live in a separate feature module). Understanding this changes how you think about navigation at the feature level, not just the screen level.

String routes feel fine until they break β€” I built an earlier version with string routes. The first time I renamed a destination, I had three runtime crashes before finding all the string references. Typed routes make this a zero-crash refactor because the compiler flags every reference immediately.

startDestination on a graph vs on a screen are different things β€” The startDestination of the NavHost is the graph that owns the first screen, not the first screen itself. This distinction matters when you need to use popUpTo β€” you can only pop to a destination that is on the back stack, and a graph destination is on the stack even when its children change.

ViewModel Lifecycle and Navigation

ViewModel scope is a deliberate architectural decision β€” There is no single correct scope for a ViewModel. Screen-scoped makes sense for screens that own completely independent state. Graph-scoped makes sense for flows where state is shared across multiple steps. Activity-scoped makes sense for app-wide state like authentication. Getting this wrong produces either state loss bugs (scope too narrow) or memory leaks and stale state (scope too wide).

remember(backStackEntry) matters β€” Without remember, getBackStackEntry is called on every recomposition, potentially returning a different back stack entry as the back stack changes. remember(backStackEntry) ensures the parent entry reference is stable for the lifetime of the current destination.

Compose Transitions

Four parameters, not two β€” Most Navigation Compose examples only show enterTransition and exitTransition. Defining only these produces broken back-navigation animations. popEnterTransition is what the screen you're returning to plays as you navigate back. popExitTransition is what the current screen plays as it leaves during a back press. The four parameters together create a consistent spatial model where forward and backward navigation feel like moving through a physical space.

Extracted transition definitions avoid repetition β€” Defining the same four lambdas on every composable<> block is error-prone. Extracting them to file-level val declarations means changing the transition timing or style in one place updates every destination consistently.

Back Stack as a Data Structure

The back stack is a stack you control, not one that controls you β€” Every navigate() call is a push. Every popBackStack() is a pop. popUpTo is a slice that removes everything above a specific entry. Once you think of the back stack as a data structure with explicit operations, navigation bugs become diagnosable rather than mysterious.

Reproduce bugs intentionally β€” The most durable way to understand popUpTo is to remove it, observe the broken behaviour (onboarding screens reachable from the dashboard), then add it back and verify the fix. The understanding that results from seeing a bug and its fix in the same session outlasts any documentation.


What's Implemented

Feature Status Notes
Typed route definitions βœ… Complete @Serializable objects and data classes
Nested navigation graphs βœ… Complete Onboarding and Main as separate graphs
Argument passing between screens βœ… Complete PersonalDetails(accountType) via typed route
Graph-scoped ViewModel βœ… Complete Scoped to OnboardingGraph
Back stack management βœ… Complete popUpTo clears onboarding on completion
BackHandler on Success βœ… Complete Back press consumed intentionally
Symmetric screen transitions βœ… Complete All four transition parameters defined
Bottom navigation (Main graph) βœ… Complete NavigationBar with state/restore
Slot-based layout component βœ… Complete OnboardingScaffold shared across 5 screens

πŸ‘€ Author

Usman Ali Ansari


Built as a deliberate study of Navigation Compose architecture

About

Fintech Onboarding flow built to demonstrate Navigation Component 2 with nested graphs, typed routes and graph-scoped ViewModel state

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages