A Kotlin Multiplatform for practising offline-online sync2
(save locally → auto-sync when online → retry in background), rebuilt with clean architecture
and MVVM. The shared module targets Android + iOS; the runnable app here is Android
(Jetpack Compose). iOS actuals are stubbed pending an iOS app target.
Save users locally → auto-sync to server when online → retry failed syncs in background.
| Layer | Technology |
|---|---|
| UI | Jetpack Compose + Material 3 (Android) |
| Architecture | Clean Architecture (data / domain / presentation) + MVVM |
| DI | Koin |
| Local DB | SQLDelight |
| Networking | Ktor Client |
| Background Sync | WorkManager (Android) |
| Connectivity | ConnectivityManager via a shared NetworkInfo abstraction |
| Error Handling | A small Either<Failure, T> type (dartz-style) |
User taps "Add" → ViewModel validates input
│
▼
UseCase → Repository.addUser()
┌────┴────┐
▼ ▼
SQLDelight Online?
INSERT │
(isSynced=0)┌────┴────┐
│ YES NO
│ │ │
│ POST → SyncScheduler
│ mark retries when
│ synced network returns
▼
Flow<List> emits → UI recomposes automatically
Key behaviour:
- Data is always saved locally first (SQLDelight/SQLite), making the app fully usable offline
- If online, an immediate POST is attempted; on success the row is marked as synced
- If offline or the POST fails, the row stays
isSynced = false - Real-time connectivity monitoring via
ConnectivityManager.NetworkCallback— the UI reacts instantly - When connectivity returns, an immediate sync pushes all pending rows
- WorkManager runs a periodic sync (every 15 min) as a safety net
- The UI shows "Synced" / "Pending" badges and an offline banner
kmp_user_list/
├── settings.gradle.kts
├── build.gradle.kts
│
├── shared/ ← Kotlin Multiplatform module (androidTarget + iOS)
│ └── src/
│ ├── commonMain/kotlin/com/potential/kmpuserlist/
│ │ ├── core/
│ │ │ ├── platform/PlatformContext.kt ← expect Context abstraction
│ │ │ ├── database/DatabaseDriverFactory.kt ← expect SQLDelight driver factory
│ │ │ ├── network/NetworkInfo.kt ← expect connectivity monitor
│ │ │ ├── network/HttpClientFactory.kt ← Ktor client (expect engine)
│ │ │ ├── worker/SyncScheduler.kt ← expect background sync scheduler
│ │ │ ├── usecase/UseCase.kt ← base UseCase<T, Params> contract
│ │ │ ├── error/Failure.kt
│ │ │ ├── util/Either.kt
│ │ │ └── di/AppModule.kt ← shared Koin modules
│ │ │
│ │ └── features/user_list/
│ │ ├── data/
│ │ │ ├── model/UserDto.kt ← Ktor/JSON DTO
│ │ │ ├── source/UserLocalSource.kt ← SQLDelight CRUD
│ │ │ ├── source/UserRemoteSource.kt ← Ktor API calls
│ │ │ └── repository/UserRepositoryImpl.kt ← offline-first implementation
│ │ │
│ │ ├── domain/
│ │ │ ├── entity/UserEntity.kt
│ │ │ ├── repository/UserRepository.kt
│ │ │ └── usecase/
│ │ │ ├── AddUserUseCase.kt
│ │ │ ├── DeleteUserUseCase.kt
│ │ │ ├── GetUsersUseCase.kt
│ │ │ └── SyncUsersUseCase.kt
│ │ │
│ │ └── presentation/
│ │ ├── UserListViewModel.kt ← plain MVVM view-model + StateFlow
│ │ └── UserListState.kt
│ │
│ ├── commonMain/sqldelight/.../database/User.sq ← table + queries
│ ├── androidMain/kotlin/... ← Android actuals (WorkManager, ConnectivityManager, AndroidSqliteDriver, OkHttp)
│ └── iosMain/kotlin/... ← iOS actuals (stubbed; see note below)
│
└── androidApp/ ← Android application (Jetpack Compose)
└── src/main/kotlin/com/potential/kmpuserlist/android/
├── UserListApplication.kt ← Koin startup + periodic sync scheduling
├── MainActivity.kt
├── AndroidUserListViewModel.kt ← androidx.lifecycle.ViewModel adapter
└── ui/
├── UserListScreen.kt ← home screen (offline banner, list, FAB)
├── AddUserDialog.kt ← input dialog with validation
└── UserItem.kt ← card with sync badge + delete
- Add User — name (letters only) and age (digits only) with input validation
- Delete User — remove entries with a single tap
- Offline Banner — appears/disappears instantly based on real-time connectivity
- Sync Status Badges — each row shows "Synced" or "Pending"
- Auto-Sync on Reconnect — pending rows are pushed immediately when internet returns
- Background Sync — WorkManager retries every 15 min as a safety net
- Material 3 — supports light/dark theme with system preference
cd kmp_user_list
./gradlew :androidApp:installDebugNote: The project uses
jsonplaceholder.typicode.comas a mock API (seeUserRemoteSource). Swap the base URL for your real endpoint when wiring up production data.
- Add a few users with internet ON → they show "Synced"
- Turn on Airplane Mode → offline banner appears instantly
- Add more users → they show "Pending"
- Turn Airplane Mode OFF → banner disappears, pending rows sync automatically
This exact flow was verified end-to-end on an emulator during development (add → sync, delete, offline add → "Pending", reconnect → auto-sync to "Synced").
shared declares iosX64, iosArm64, and iosSimulatorArm64 targets and compiles cleanly for
all three, but NetworkInfo and SyncScheduler iOS actuals are intentionally minimal stubs
(always-online, no-op scheduling) since only the Android app was requested/verified here. Swap
them for NWPathMonitor / BGTaskScheduler implementations when an iOS app target is added —
DatabaseDriverFactory and the Ktor Darwin engine are already wired up for that target.
- Offline-first with SQLDelight + isSynced flag
- Clean Architecture (data / domain / presentation)
- Immediate sync attempt on add
- Real-time connectivity monitoring
- Auto-sync when connectivity returns
- Periodic background sync via WorkManager
- MVVM presentation layer (StateFlow-based)
- Koin dependency injection
- Ktor HTTP client with logging + timeouts
- Input validation (letters-only name, digits-only age)
- Delete user support
- Either-based error handling
- Material 3 + light/dark theme
- Reactive UI via Flow
- Unit tests (Repository, ViewModel)
- Pull-to-refresh
- Edit user functionality
- iOS app target (shared module is ready; actuals are stubbed)