Skip to content

// project

Doori

Offline-first mileage, travel & expense tracker on one Kotlin codebase across Android, iOS, Wear OS, watchOS & Desktop.

The brief

Doori is an original, fully-offline mileage / travel / expense tracker I designed and built end-to-end in Kotlin & Compose Multiplatform. It runs on Android, iOS, Wear OS, watchOS and Compose Desktop from one shared codebase, offline-first with a real Kotlin/Ktor backend built in and off by default, so the whole thing stays reproducible and reviewable. It's my reference implementation for the architecture I advocate at scale: strict module isolation, a real location engine, a policy/reimbursement layer and a durable submit-outbox, all over local data.

What shipped

  • 52-module clean architecture: 13 feature modules meeting only at the composition root.
  • Real location engine, reimbursement policy engine, durable submit-outbox, and an on-device AI assistant.
Kotlin MultiplatformCompose MultiplatformAndroidiOSWear OSwatchOSDesktopRoom (KMP)Koin
GitHub PaymentsLab-KMP (sibling KMP app) Install it 52 modules · 5 platforms · 159 tests
built on
kmp-build-logic
kmp-toolkit
feeds
kmp-toolkit

no public push in the last 20 events

// case study

The short version

The problem

A reimbursement claim is only as good as the distance it's built on, and raw GPS drifts in urban canyons, tunnels and behind OEM-throttled updates: a policy engine downstream can't approve a payout against a number that noisy.

The decision

Treat GPS as a noisy signal rather than ground truth: jitter suppression, spike detection to reject physically impossible fixes, IMU (accelerometer) fusion and device-tier-adaptive sampling feed a four-bucket distance accumulator, with a deterministic simulated-drive source so the whole pipeline is unit-testable without hardware.

The result

That same location engine now backs a real reimbursement-rate policy engine and a durable submit-outbox, shipping across all five targets from one Kotlin codebase, with a real Kotlin/Ktor server landed on top, sharing typed DTOs with the client, off by default.

5 platforms · one codebase

Watch the two-minute tour ↓
Evidence: That same location engine now backs a real reimbursement-rate policy engine and a durable submit-outbox, shipping across all five targets from one Kotlin codebase, with a real Kotlin/Ktor server landed on top, sharing typed DTOs with the client, off by default.
Go deeper

Analyst view: Doori draws on 9 stack entries and links to 4 outside sources, and shares 2 shared foundations with 3 other builds.

// guided tour

The product, narrated

A recorded walkthrough of the app with sample content. Tap the speaker for the voiceover, or read the captions. In-app figures describe the recorded demo, not live production activity.

Doori — narrated product tour

// shipping

Where you can actually get it

Every line below is checkable. Add the repository in an F-Droid client and the app installs, signed with the same key its listing pins.

  • F-DroidLive in a self-hosted repository. 66 MB, signed, tagged NonFreeDep because location and on-device receipt OCR use Google Play Services components. open
  • GitHub ReleasesSigned APK per tag, for both the Play flavour and the Google-free one. open

// pipeline

The build that put it there

Read live from GitHub Actions and the F-Droid index. Not a badge: the actual runs, what shipped, and the certificate you can check the download against.

reading the pipeline

52

Gradle modules (36 local + 16 composed)

13

isolated feature modules

5

platforms · one codebase

0

backend calls by default, real Ktor server opt-in

// multiplatform

One codebase, every surface

Recorded app screens and, where available, the running build. Sample content inside a screen is separate from the source-linked project metrics.

Android screen (1 of 5)

// in motion

Watch it run

Home & dashboard: live capture

// design notes

How it works

52-module clean architecture (36 local + 16 composed)

Thirteen feature modules that never depend on each other, meeting only at the :app composition root and wired with Koin. A shared commonMain core holds the design system, Room (KMP) + DataStore, and every check-in / hardware-event screen, with platform services behind expect/actual. Convention plugins from my own kmp-build-logic keep every module's build consistent.

Location engine

GPS is treated as a noisy signal: jitter suppression, spike detection to reject impossible fixes, a four-bucket distance accumulator, IMU (accelerometer) fusion and device-tier-adaptive sampling that trades battery against precision by hardware class. A deterministic simulated-drive source makes the whole engine unit-testable without hardware.

Policy & reimbursement engine

A reimbursement-rate engine computes a payout from configurable per-vehicle rate rules, and the approvals flow flags policy violations against those rules. This is the real expense-platform logic a live product needs, implemented entirely against local data rather than stubbed with a snackbar.

Durable submit-outbox

Submitting a track or voucher journals the intent locally and reconciles it deterministically, so a process kill mid-submit never loses a record or double-counts one. Repositories were written to look one implementation-swap away from a real API, and that bet paid off: a real Kotlin/Ktor `:server` module now exists, sharing `:contract` DTOs with the client so the wire format can't drift, with JWT auth guarding every route. Off by default behind one flag, so the offline guarantee above is unchanged until someone flips it.

Five targets, one snapshot model

Beyond Android and iOS phones, the same shared SurfaceSnapshot drives a Wear OS app, a watchOS SwiftUI app and a Compose Desktop window, plus Android Glance + iOS WidgetKit home-screen widgets and an iOS Live Activity / Dynamic Island for an in-progress trip. Each surface has its own design-system skinning but reads the identical shared state.

Offline AI assistant

A chat assistant grounded entirely in local Room data: trips, expenses, cards. Real chunked streaming (not a fake typing animation), persistent history with a 5-minute session-resume window, on-device speech-to-text/text-to-speech, and local usage analytics. No remote LLM, no server, same offline guarantee as the rest of the app.

Super-profile & plugin-composition platform (V24, shipped)

The newest depth wave: a single plugin registry is the app's composition mechanism. TILE / CAPABILITY / VALUE plugins resolve by layering FORCED > USER > PRESET > DEFAULT, editable live from a Master Plugin page with source chips. Four persona presets (Corporate Commuter, Super-App Consumer, Gig Driver, Minimal Guest) reshape hubs, auth flows, tracking behaviour and tunables from one account. Built on top: act-on-behalf session delegation with an app-wide "Acting as" banner, a verification centre with corporate-email/OTP + card KYC, growth surfaces (referral, coupons, scratch rewards), membership (club, subscriptions, incentives), external wallet linking via OTP, and payout identity (masked bank + editable UPI handle + QR). All shipped, with a V25→V37 series landed on top (on-device intelligence, JWT auth, closeout hardening, home cards/advances, What's New), still offline-first by default with the real backend opt-in.

Master search, dynamic forms and document intelligence

A registry-based master search fans one query across every feature module's own search provider from a single results screen, instead of a separate search box per hub. A dynamic form engine drives expense and claim entry: field validation, conditional visibility and GST auto-calc, with AI field suggestions fed by an on-device document-intelligence pipeline that combines on-device AI, text recognition and heuristics for OCR field-fill, doc-type classification and duplicate detection on a scanned receipt, degrading gracefully wherever a model isn't available.

FOSS-safe distribution & quality gates

Dual gms / noGms builds (Google Play + F-Droid) with a dependency-prefix guard that fails the build if proprietary libraries leak into the FOSS flavor. 159 Roborazzi JVM screenshot tests (no emulator, no network) covering phone, watch and desktop, plus Napier logging, detekt, ktlint, Kover and CI.

// architecture

How it's built

52-module architecture, features meet only at :app

Location pipeline: GPS treated as a noisy signal

One shared snapshot → five targets

// under the hood

Tech stack

Language & UI

  • Kotlin
  • Compose Multiplatform
  • Material 3
  • SwiftUI (watchOS)

Data

  • Room (KMP)
  • DataStore
  • Coroutines + Flow
  • Durable submit-outbox

Domain

  • Location engine (jitter · spike · IMU fusion)
  • Reimbursement-rate policy engine
  • Master search (provider registry)
  • Dynamic form engine (GST auto-calc, conditional visibility)
  • On-device document intelligence

Backend (opt-in, off by default)

  • Ktor + Exposed `:server`
  • shared `:contract` DTOs
  • JWT auth

DI & build

  • Koin
  • kmp-build-logic convention plugins
  • AGP
  • Gradle KTS

Maps & platform

  • MapLibre (F-Droid)
  • KrossMap (Play)
  • Glance + WidgetKit widgets
  • Live Activity / Dynamic Island

Quality

  • Roborazzi (159 JVM screenshot tests)
  • detekt
  • ktlint
  • Kover
  • CI

Local database

50 schema version

// directions

Same screen, 5 treatments

Pick a treatment to see it whole. Where one ships a dark counterpart, drag the divider to compare the two faces of it — same layout, same content, only the light changes.

direction approvals — ledger, light
ledgersingle mode — no dark counterpart

// gallery

Screens (63)

Swipe or use the arrows · tap a screen to enlarge

See how everything connects →