Compose vs Flutter
Build the new Urvi in Kotlin with Jetpack Compose, with domain, data and sync in plain Kotlin modules that can move to Kotlin Multiplatform later. Switch to Flutter only if an iOS field app is a firm requirement within about a year and the reviewers are comfortable reviewing Dart.
What this app actually needs
| Need | Evidence in today's code | Why it matters for the choice |
|---|---|---|
| Offline-first store with heavy writes | About 50 entities, a sync ledger with dependencies, about 40 response handlers | The sync engine is the hardest, most valuable part |
| Reliable background sync | 23 WorkManager workers, foreground dataSync workers, OEM battery killers in rural Android | Native WorkManager is first-class; cross-platform wraps it |
| Large forms with complex validation | Farmer profile (21 fields, cascading rules), crop block (PMDS, A Grade, edit reasons), KAP (ceilings) | Both frameworks handle this; rules must live in a testable domain layer |
| Maps and walking GPS polygon capture | osmdroid, 5 m displacement, 2 s interval, KML files | Needs a foreground location service during capture and offline tiles |
| Camera and signatures | System camera intent, compression, signature pad | Easy in both |
| Low-end Android, 2G/3G | Field phones, no R8 today | Cold start, memory and APK size count |
| Telugu and Tamil, in-app language switch | values-te, values-ta, LocaleManager | Existing strings.xml can be reused natively |
| In-place upgrade with unsynced data | Pending rows in agri_db, Java-serialized ops in SYNC_QUEUE_DB | The new app must read the old store once to avoid data loss |
| Team skills | Agents write the code; 3 reviewers who read Kotlin fluently; 99.7% Kotlin today, no Dart in the repo | Reviewers must catch agent mistakes in sync and migration code, which is faster in a language they know |
| iOS | No iOS code or stated requirement | The main argument for Flutter is not proven yet |
Scored comparison
Scores are 1 (poor) to 5 (strong) for this app, not in general. Weight is 1-5.
| Criterion | Weight | Compose | Flutter | Note |
|---|---|---|---|---|
| Offline DB and sync engine | 5 | 5 | 3 | Room + WorkManager are native; Flutter needs drift plus a headless-isolate WorkManager plugin |
| Background work on OEM low-end Android | 5 | 5 | 3 | Expedited and foreground work, Doze handling, debugging are simpler natively |
| Reading the old on-device data at upgrade | 5 | 4 | 2 | Kotlin can read agri_db and, if needed, deserialize old queue ops; Dart can read SQLite but not Java-serialized params |
| Review confidence | 4 | 5 | 3 | Agents write both well; the 3 reviewers catch sync and migration mistakes faster in Kotlin than in Dart |
| Large forms and validation | 4 | 4 | 4 | Both fine; Flutter's form packages are mature, Compose needs a small in-house form-state helper |
| Maps and walking GPS capture | 4 | 4 | 3 | Fused location + foreground service is direct in Kotlin; Flutter needs geolocator plus a foreground-service plugin |
| Low-end performance and APK size | 4 | 4 | 3 | Flutter adds an engine (several MB) and slower cold start on old ARMv7 phones |
| Localization (te, ta) | 3 | 5 | 4 | Reuse strings.xml and the per-app language API vs convert to ARB |
| Reuse of existing assets | 3 | 4 | 1 | Assessment library AAR, validators and rules can be lifted into Kotlin; Flutter rewrites everything |
| Incremental delivery | 3 | 4 | 2 | Compose screens and the new outbox can be trialled inside the current app; Flutter is a big-bang or awkward add-to-app |
| iOS or web reach | 2 | 3 | 5 | Kotlin Multiplatform shares logic; UI on iOS needs Compose Multiplatform or SwiftUI |
| Camera and signature | 2 | 5 | 4 | CameraX or system intent vs camera / image_picker plugins |
| UI iteration speed | 2 | 4 | 5 | Hot reload is better in Flutter; Compose previews and Live Edit close some of the gap |
| Agent code quality | 2 | 5 | 5 | Claude writes idiomatic Kotlin/Compose and Dart/Flutter equally well |
| Weighted total (of 240) | 211 (88%) | 151 (63%) |
If iOS becomes a must-have (weight 5 instead of 2), the totals move to 220 vs 166. Compose still leads on score, but the real cost difference then is building a second UI, which is why iOS is the main flip condition below.
Risks of each option
| Option | Risk | Mitigation |
|---|---|---|
| Compose | Compose performance on 2-3 GB phones if R8 and Baseline Profiles are skipped | Make R8, Baseline Profiles and a Macrobenchmark gate part of the day 9 hardening |
| Compose | Agents copy old patterns (fragments, Executors, !!) from today's code | Architecture rules in CLAUDE.md, lint rules that fail the build, a review agent with a checklist |
| Compose | iOS arrives later and needs a second UI | Keep domain, data and sync free of Android imports so they can move to commonMain |
| Compose | Map choice: osmdroid is ageing | Use MapLibre Native (or keep osmdroid inside AndroidView for the day 5 land work only) |
| Flutter | Agents write Dart well, but the 3 reviewers are slower to spot mistakes in Dart sync and migration code | Opus review agent on every sync PR; extra torture tests on day 2 |
| Flutter | Background sync via a Dart isolate is less predictable on low-RAM OEM phones | Keep sync logic small; native Kotlin WorkManager shim calling Dart only for mapping |
| Flutter | Cannot read Java-serialized SYNC_QUEUE_DB ops | Mandatory drain release of the old app before any upgrade; server-side recovery of DB dumps |
| Flutter | Assessment library must be rewritten | Decide early whether surveys stay; if yes, give it a full day of agent work |
| Flutter | Plugin quality for location foreground service, tile caching | Spike both on day 1 on the slowest field phone |
The Kotlin Multiplatform option
KMP is not a third UI framework. It lets the domain rules, data layer, App Service client and sync engine live in commonMain, compiled for Android now and iOS later.
| Layer | Shared in KMP | Platform-specific |
|---|---|---|
| Domain rules (category gate, land usage, validators, season logic) | Yes | |
| App Service client (Ktor, kotlinx.serialization) | Yes | Engine: OkHttp on Android, Darwin on iOS |
| Local DB (Room KMP or SQLDelight) and outbox | Yes | Driver and SQLCipher setup |
| Sync engine (outbox drain, delta apply) | Yes | Scheduler: WorkManager on Android, BGTaskScheduler on iOS |
| UI | Optional (Compose Multiplatform) | Compose on Android; SwiftUI or Compose Multiplatform on iOS |
| Maps, camera, location, secure storage | Platform code behind small interfaces |
Recommendation: structure the Compose app as if it were KMP (pure Kotlin domain, data and sync modules with no Android imports, Ktor and kotlinx.serialization rather than Gson) but ship Android-only until iOS is confirmed. The extra cost is small now and avoids a rewrite later.
Recommendation
Choose Jetpack Compose, KMP-ready. The parts that decide Urvi's success are offline storage, the outbox, background sync on cheap Android phones and a safe upgrade for devices holding unsynced farmer data. Native Kotlin is strongest exactly there, the reviewers already read Kotlin, and the existing rules can be extracted and unit-tested rather than translated.
Flip to Flutter if all of these hold:
| Condition | Why it flips the call |
|---|---|
| An iOS (or web) field app is a firm requirement within about 12 months | One UI codebase beats two |
| The reviewers are comfortable reviewing Dart, or one Flutter-experienced reviewer joins | Removes the review risk on the sync engine |
| The programme accepts a mandatory drain release and a short field freeze at cutover | Removes the old-data migration risk |
| Platform Commons standardises on Flutter for its other apps | Shared components and staff outweigh per-app fit |
Flip to KMP with shared iOS UI (Compose Multiplatform) if iOS is confirmed but the reviewers stay with Kotlin.
Target architecture: Compose
| Layer | Choice | Notes |
|---|---|---|
| UI | Jetpack Compose, Material 3, Navigation Compose (type-safe routes) | One activity; design-system module with field-friendly components (large touch targets, offline banners) |
| State | ViewModel + StateFlow, unidirectional data flow | Screen state as immutable data classes; events as sealed types |
| Domain | Pure Kotlin module: use cases, CategoryGate, LandUsage, validators, season rules | Rule parameters come from the config bundle; 100% unit-tested |
| Data | Repositories per feature; mappers between DTO and entity | No god repository |
| Local DB | Room 2.8 (KMP-capable) with SQLCipher (sqlcipher-android), or SQLDelight | Local UUID primary keys, unique server ids, foreign keys, indexes |
| Sync | Outbox table + one unique CoroutineWorker (expedited for "Sync now", foreground dataSync for long runs) | Exponential backoff; delta pull by cursor |
| Network | Ktor client (OkHttp engine) + kotlinx.serialization; Auth plugin for bearer refresh | One client, one JSON library |
| Auth | AppAuth-Android or App Service login endpoint; tokens in DataStore encrypted with Tink + Android Keystore | No password stored |
| DI | Hilt (Android-only) or Koin (if KMP soon) | |
| Maps | MapLibre Native Android with offline tile packs (fallback: osmdroid in AndroidView) | Polygon area, accuracy filter, self-intersection check in the domain module |
| Location | Fused Location Provider; foreground service type location during walking capture | Reject fixes worse than the configured accuracy |
| Camera | CameraX or ActivityResultContracts.TakePicture; one compression pipeline | EXIF stripped; signature pad as a Compose canvas |
| Files | Upload queue in the outbox; resumable uploads to the App Service | |
| i18n | strings.xml en/te/ta; AppCompatDelegate.setApplicationLocales | Missing-string check in CI |
| Observability | Crashlytics; redacted telemetry to the App Service | Timber or Kermit with a redacting tree |
| Testing | JUnit 5 or kotest, Turbine, in-memory Room, Ktor MockEngine, Compose UI tests, Roborazzi screenshots, Macrobenchmark + Baseline Profiles | Migration and outbox tests from day one |
| Build | Version catalog, flavors dev/stage/prod, R8 on, CI |
Target architecture: Flutter
| Layer | Choice | Notes |
|---|---|---|
| UI | Flutter Material 3, go_router | Design-system package; test on the slowest field phone |
| State | Riverpod (or Bloc) | Immutable state with freezed |
| Domain | Pure Dart package: use cases, category gate, land usage, validators | Rule parameters from the config bundle |
| Data | Repositories per feature | |
| Local DB | drift with sqlcipher_flutter_libs | Same schema rules as the Compose option |
| Sync | drift outbox table + workmanager plugin; flutter_foreground_task for long runs | Consider a thin native WorkManager shim if the isolate proves flaky |
| Network | dio + retrofit.dart, freezed / json_serializable; QueuedInterceptor for token refresh | |
| Auth | flutter_appauth or App Service login; flutter_secure_storage | |
| DI | Riverpod providers | |
| Maps | flutter_map + flutter_map_tile_caching | Polygon logic in the domain package |
| Location | geolocator with a foreground service | Accuracy filter as above |
| Camera | camera or image_picker, flutter_image_compress, signature | |
| Forms | reactive_forms or flutter_form_builder | Validators from the domain package |
| i18n | flutter_localizations + ARB (intl) | Convert strings.xml once with a script |
| Observability | firebase_crashlytics; redacted telemetry to the App Service | |
| Testing | flutter_test, mocktail, integration_test, golden tests, drift in-memory tests | |
| Legacy reader | sqflite read of agri_db pending rows at first launch | Cannot read SYNC_QUEUE_DB params; relies on the drain release |