MVP Factory
case-study kotlin-multiplatform mobile architecture

Case Study: Building a Cross-Platform Health App with Kotlin Multiplatform

KW
Krystian Wiewior · · 7 min read

Building a cross-platform app that feels native on every platform is hard. Building it with clean architecture, full test coverage, and a scalable module structure? That’s where most teams give up.

Here’s how we built HealthyDesk — a health & wellness app for desk workers — from zero to production on Android, iOS, and Desktop using Kotlin Multiplatform.

The Problem

Millions of people sit at desks 8+ hours a day. Most know they should take breaks and stretch, but they forget. Existing apps are either too simple (just a timer) or too complex (full workout plans). There was room for something in between — a smart break timer with guided exercises, tailored for desk workers.

The Goal

Build an MVP that:

  • Runs on Android, iOS, and Desktop from one codebase
  • Has a smart work/break timer with customizable intervals
  • Includes 25 guided exercises with body area and position filters
  • Tracks daily stats (work time, breaks taken, exercises done)
  • Supports 6 languages and dark mode out of the box
  • Ships with production-grade architecture — not a prototype

Tech Stack

LayerTechnology
UICompose Multiplatform + Material 3
ArchitectureClean Architecture + MVI (Model-View-Intent)
DIKoin 4.0 with split modules
DatabaseSQLDelight (shared SQL across platforms)
PreferencesDataStore with named qualifiers
NavigationNavigation Compose with type-safe routes
AnalyticsFirebase Analytics + Crashlytics (GitLive KMP SDK)
NotificationsCustom KMP notification library
LanguageKotlin 2.1.0, Compose MP 1.7.3
TargetsAndroid (SDK 24-35), iOS 16+, JVM Desktop

Architecture: 20+ Modules, Zero Spaghetti

This isn’t a single-module app with everything dumped into one folder. HealthyDesk uses a multi-module architecture with clear boundaries:

composeApp/          — App shell, platform entry points
core/
  common/            — BaseViewModel, Result types, utilities
  model/             — Domain models (Exercise, WorkSession, etc.)
  domain/            — Repository interfaces + 17 use cases
  data/              — Repository implementations, mappers
  database/          — SQLDelight schemas, DatabaseFactory
  designsystem/      — Theme tokens, 8 shared components
  navigation/        — Type-safe route definitions
  testing/           — Fakes and test data

feature/
  timer/             — Work/break timer with MVI
  breakSession/      — Break flow with exercise countdown
  exercises/         — Exercise list with filters
  stats/             — Daily statistics with animated cards
  settings/          — Preferences and configuration
  onboarding/        — 3-page intro flow
  reminders/         — Custom daily reminder scheduling

libs/
  kmpnotifier/       — Platform notification/timer/sound services

infra/
  firebase-analytics/    — AnalyticsTracker + platform actuals
  firebase-crashlytics/  — CrashReporter + platform actuals

Each module has one responsibility. Features don’t depend on each other. The domain layer has zero platform dependencies. This means:

  • Adding a new feature = adding a new module
  • Replacing SQLDelight with Room = change one module
  • Removing Firebase = swap two infra modules

That’s not theoretical. That’s how it actually works.

What “80% Shared Code” Actually Means

When people say “shared code” in KMP, they usually mean the business logic. We went further:

LayerSharedPlatform-specific
Domain logic (use cases, models)100%0%
Data layer (repos, DB, prefs)100%0%
UI (screens, components, theme)95%5% (status bar, system UI)
Navigation100%0%
Notifications & timer service40%60% (platform APIs)
Analytics & crash reporting30%70% (Firebase SDK bindings)

The only truly platform-specific code is where it has to be: notifications, foreground services, Firebase SDK initialization, and system UI integration. Everything else — including the entire UI — is shared Compose Multiplatform code.

MVI Pattern: Predictable State, Zero Surprises

Every feature follows the same MVI pattern:

  • State — immutable data class describing the screen
  • Intent — sealed interface of user actions
  • Effect — one-time side effects (navigation, toasts)
data class TimerState(
    val phase: TimerPhase,
    val remainingSeconds: Long,
    val todayStats: DailyStats,
    val isRunning: Boolean,
)

sealed interface TimerIntent {
    data object StartTimer : TimerIntent
    data object PauseTimer : TimerIntent
    data object SkipBreak : TimerIntent
    data object StartExercise : TimerIntent
}

This means every screen is a pure function of state. No hidden side effects. No mystery re-renders. Easy to test, easy to debug.

DI: Split Modules, Not a God Object

Dependency injection started as a single AppModule. By v0.3, it became unmaintainable. We split it into focused modules:

  • DataModule — repositories, data sources
  • UseCaseModule — all 17 use cases
  • InfraModule — database, DataStore, preferences
  • FeatureModule — ViewModels for each feature
  • Platform modulesAndroidPlatformModule, IosPlatformModule, DesktopPlatformModule

Each platform module binds platform-specific implementations (notifications, analytics, crash reporting) to shared interfaces. Desktop gets NoOp implementations where native APIs don’t exist.

Key Features Built

Smart Timer

Configurable work/break intervals with auto-transition. The timer runs as a foreground service on Android (survives app backgrounding), uses background tasks on iOS, and runs in-process on Desktop.

25 Exercises with Filters

Every exercise is tagged with:

  • Body area (neck, shoulders, back, wrists, legs, full body)
  • Position (sitting, standing, lying, any)
  • Safety tags (not suitable for certain conditions)

Users filter by what they can do at their desk right now.

6 Languages

English, Polish, German, Spanish, French, and Portuguese — with full RTL support ready for Arabic in v2.

Daily Statistics

Animated cards showing work time, breaks taken, exercises completed, and streaks. Data stored locally via SQLDelight with aggregation views.

Custom Reminders

Users set daily reminder times with a custom duration picker. Notifications delivered via the shared KMP notification library.

Timeline & Effort

PhaseDurationWhat
v0.1 (MVP)~4 weeksTimer, 15 exercises, stats, settings, onboarding, dark mode
v0.2~2 weeks10 more exercises, position filters, 6 locales, UX polish
v0.3~1 weekDI refactor, architecture cleanup
v0.4~1 weekFirebase Analytics + Crashlytics, CI/CD, release signing
Total~8 weeksProduction-ready on 3 platforms

A team of 3-4 developers building this natively for Android + iOS would need 16-20 weeks and produce two separate codebases to maintain. KMP cut the effort roughly in half while delivering a better architecture than most native apps.

CI/CD Pipeline

  • Build workflow — runs on every push, builds all 3 platforms
  • Release workflow — builds signed APK/AAB, uploads to Play Store (internal track)
  • Secrets management — keystore, Firebase config, and service accounts stored in GitHub Secrets
  • ProGuard — R8 minification for release builds

Lessons Learned

  1. SQLDelight migrations matter — always create .sqm migration files. Check the schema file exists before constructing the database driver, especially on JVM.

  2. DataStore needs named qualifiers — if you have multiple DataStore instances (preferences + timer state), Koin needs named() qualifiers or you get silent conflicts.

  3. Theme transitions need an overlay — don’t animate individual colors with animateColorAsState. Use a fade overlay for smooth light/dark transitions.

  4. KMP notification APIs are fragmented — we ended up building a custom library (kmpnotifier) that wraps platform notification, sound, and haptic APIs behind a shared interface.

  5. Start multi-module early — refactoring from monolith to multi-module at v0.3 was painful. Starting with modules from day one would have saved a week.

The Result

HealthyDesk ships as a single codebase that produces:

  • Android APK/AAB for Play Store
  • iOS app for App Store
  • Desktop JAR for macOS, Windows, Linux

20+ modules. 17 use cases. 25 exercises. 6 languages. 3 platforms. One developer. Eight weeks.

That’s the power of choosing the right architecture and the right tools from the start.


Want Something Like This?

If you’re building a cross-platform app and want production-grade architecture from day one, let’s talk. We specialize in Kotlin Multiplatform MVPs that scale.


Share: Twitter LinkedIn