Home
Work
AF-Bookings
OPEN SOURCE CASE STUDY
An airline itinerary built to be read, not decoded.
AF-Bookings is a booking-management app I built to push the current Android stack against a genuinely awkward domain: polymorphic travel segments, partial data, and a user who only wants to know when to leave for the airport. Every claim below is verifiable in the repository: twenty commits, three Gradle modules.
Booking list
Custom Upcoming / Past tab treatment over the adaptive navigation suite. One StateFlow drives loading, success and error, so the empty and failure states are designed, not discovered.
Parallax header
Collapsing header driven from scroll offset with transforms only. Booking reference, departure and trip type read as one glance before the timeline begins.
The timeline, drawn
Solid rail for flights, dotted rail for the transfer, scheduled times struck through against actuals: green when early, red when late. Two segment types, one drawing pass.
Screens from the running app.
THE PROBLEM
A list where every row is a different shape.
A journey is not a list of flights. It is a flight, then a coach transfer, then a layover, then another flight, and they all occupy the same slot in the same scrolling list while carrying completely different fields.
The lazy answer is one fat data class with everything nullable, and a UI full of null checks that silently renders nothing when a new segment type appears. That code passes review and then rots.
The interesting question is whether the type system can carry that variety instead of the UI carrying it, so adding a segment type becomes a compile error in every place that needs updating, rather than a blank row in production.
MODULE GRAPH
Three Gradle modules. Dependencies only point down.
Read the arrows as “depends on”. Nothing points upward, and nothing points into :domain from the framework.
:app
ANDROID
UI layer. Composables, ViewModels, navigation. The only module that knows Android exists at the framework level.
Compose Material 3
Type-safe Navigation
Adaptive Navigation Suite
Coil
DEPENDS ON
:data
Implements the repository interfaces the domain declares. Reads local JSON, parses it, hands back domain models.
AFRepositoryImpl
Kotlinx Serialization
Hilt module
AssetManager
:domain
PURE KOTLIN
Zero Android imports. UseCases, models and repository interfaces, testable on the JVM without an emulator.
UseCases
Booking
Segment (sealed)
Repository interfaces
ALSO DIRECTLY
Why :domain is a separate module
Not for tidiness, for enforcement. A pure-Kotlin module physically cannot import Android, so the boundary is checked by the compiler instead of by code review.
Why :app depends on :domain directly
The UI talks to UseCases, never to repository implementations. :data exists to satisfy an interface, and :app never needs to know which implementation won.
What this costs
Three build files instead of one, and a version catalog to keep them aligned. On a weekend project that is overhead; on a three-year project it is the reason change stays cheap.
DECISIONS
Four decisions worth defending
01
Segments as a sealed interface, not a nullable bag
sealed interface Segment { data class Flight(…) : Segment data class Transfer(…) : Segment }
WHY
A flight and a ground transfer share a slot in the list but almost no fields. The usual shortcut, one data class with everything nullable, pushes the variety into the UI, where it becomes null checks that fail silently.
WHAT IT BUYS
Adding a segment type produces a compile error at every point that must handle it. The type system carries the domain's variety instead of the renderer.
02
AssetManager injected, so Context never leaks into :data
@Provides fun assets( @ApplicationContext c: Context ): AssetManager = c.assets
The data layer needs to read a bundled JSON file, which normally means passing a Context down and quietly giving the data layer a handle on the whole Android framework, and a memory-leak risk with it.
The Hilt module hands over the narrow capability instead of the broad object. :data depends on AssetManager, not on Context, and the boundary stays honest.
03
One StateFlow with explicit Loading / Success / Error
StateFlow<UiState> └─ Loading | Success(data) | Error(cause)
Separate isLoading, data and error fields allow states that should be impossible: loading and errored at once, or data present while an error shows. Every one of those becomes a bug report eventually.
The screen renders exactly one state because only one can exist. Empty and error states get designed rather than discovered.
04
Stateless, preview-ready composables
@Composable fun SegmentRow( segment: Segment, modifier: Modifier = Modifier )
A composable that reaches for a ViewModel can only be seen by running the app. That makes checking a rare layout, a long airport name, a cancelled leg, expensive enough that nobody does it.
Every component renders from parameters alone, so edge cases are visible in the preview pane in seconds instead of in QA in a fortnight.
THE UI WORK
Drawn, not assembled.
Compose stops being a component library the moment the design needs something the library doesn’t have. These three all came down to drawing.
Custom-drawn timeline
Connectors, stops and layover gaps drawn on Canvas rather than faked with borders and spacers, so an itinerary of any shape stays aligned.
Parallax collapsing header
Driven from scroll offset with transforms only, so it holds frame rate on mid-range hardware rather than only on a flagship.
Custom tab treatment
Material 3 tabs restyled beyond what the component exposes, keeping the indicator behaviour but not the default look.
What I’d change next
Swap the local JSON for a real network source. The repository interface already isolates it, so this is a :data change and nothing else, which is the point of the boundary.
Add screenshot tests over the sealed hierarchy. Stateless composables make this nearly free, and it is the cheapest guard against a new segment type quietly breaking a layout.
Run the itinerary screen through TalkBack properly. Custom-drawn content is exactly where semantics get forgotten, and a timeline that reads as one unlabelled blob is a real failure, not a cosmetic one.
More case studies
LOGISTICS · POSTNORD Track & Send Parcels: accessibility, at scale, on-site.
ENTERPRISE · AUTOMOTIVE & LOGISTICS Compose at scale, without a performance regression.
AVIATION / TRAVEL · AIR FRANCE Flying Blue+: owning delivery on a live loyalty app.
Don’t take my word for any of it.
Twenty commits, three modules, and the whole architecture readable in an afternoon. If you’re evaluating me, this is the fastest way to do it.
READ THE SOURCE
EMAIL ME
← Back to abhikr.com
Résumé
© 2026 Abhishek Kumar · abhikr.com