Kotlin Multiplatform (KMP) app for Android and iOS with a shared Compose UI. It provides a user feed backed by the GoRest GraphQL API, with offline-first storage and sync.
This app is developed by a human and accelerated by AI. The workflow: developer architected (specs, structure, and design decisions); AI implemented (code from those specs); developer verified and iterated (review, tests, and fixes); AI helped on improvements (polish, refactors, and features like the fancy feed section). Human in the loop, AI in the flow.
- Animations - Richer, smoother transitions (list insertions, detail expand/collapse, morphing FAB) and more polished micro-interactions.
- Pagination - Load more users using GraphQL
pageInfo.endCursorand infinite scroll or “Load more”. - Pull-to-refresh - Explicit sync trigger from the feed instead of (or in addition to) the toolbar button.
- Tests & coverage - More unit tests (e.g. add-user and validation).
- Accessibility & polish - Screen-reader labels, focus order, and clearer error states.
- Localizations - Add new locales to all apps.
- iOS Glass Design Support - Add new iOS Glass Design support by adding SwiftUI in iosMain.
The app uses the GoRest API. Create and sync work without a token; add user and delete user require a Bearer token.
- Copy the example properties file (do not commit your real token):
cp local.properties.example local.properties
- Edit
local.propertiesin the project root and set your token:Get a free token at https://gorest.co.in/ (sign up and create an API token).GOREST_TOKEN=your_bearer_token_here - Keep
local.propertiesout of version control. It is listed in.gitignore; never add it to the repo.
The BuildKonfig plugin reads GOREST_TOKEN from local.properties at build time and exposes it as BuildKonfig.GOREST_TOKEN in code. The Ktor client adds it as a Bearer header only for mutations (create/delete user).
- Android: Open the project in Android Studio and run the
composeAppAndroid configuration, or from the root:./gradlew :composeApp:installDebug
- iOS: Build and run the iOS app from Xcode (open the
iosAppor generated Xcode project), or use the Kotlin task that builds the framework and then run from Xcode.
Requirements: JDK 11+, Android Studio or IntelliJ, and for iOS: Xcode and a Mac.
The app follows Clean Architecture with MVI (Model–View–Intent) on the UI side.
| Layer | Role |
|---|---|
| UI | Compose Multiplatform screens and components. A single ViewModel exposes StateFlow<ViewState> and handles Intents (user actions). No business logic in composables. |
| Domain | Use cases only: GetUserFeedUseCase, AddUserUseCase, DeleteUserUseCase. They depend on the repository interface and orchestrate sync/add/delete. |
| Data | Repository implementation (UserRepositoryImpl) that talks to SQLDelight (local DB) and the GraphQL API (Ktor). Single source of truth is the database; network updates the DB, and the UI observes DB flows. |
- Offline-first: All reads come from SQLDelight. The GraphQL API is used to sync data into the DB and to perform mutations; the UI always reflects local state.
- Single source of truth: The database. Sync and mutations write to the DB; the repository exposes
Flows so the UI updates automatically when data changes. - MVI: One-way data flow: Intents → ViewModel → Use cases/Repository → updated State → UI. Makes state predictable and testable.
- Dependency injection: Koin in shared code. Platform-specific modules provide the SQLDelight driver and any platform APIs; the rest is shared (repository, use cases, ViewModel, GraphQL client).
- Kotlin Multiplatform — shared logic and UI; targets Android and iOS.
- Compose Multiplatform — single UI codebase (Material 3, adaptive layouts).
- Ktor — HTTP client for GraphQL (POST with JSON
queryandvariables). Endpoint:https://gorest.co.in/public/v2/graphql. - SQLDelight — local SQL DB and type-safe queries; drivers: Android (Android Driver), iOS (Native).
- Koin — DI for ViewModel, repository, use cases, HTTP client, and DB.
- BuildKonfig — reads
GOREST_TOKENfromlocal.propertiesand generatesBuildKonfig.GOREST_TOKEN(no token in source). - kotlinx.serialization — JSON for GraphQL request/response bodies.
composeApp— the KMP module (shared +androidMain+iosMain).presentation.feed— User feed screen, list/detail, add-user form, delete confirmation;UserFeedState/UserFeedIntentandSharedViewModel.domain.usecase— GetUserFeed, AddUser, DeleteUser.data.repository—UserRepositorycontract andUserRepositoryImpl(DB + API).data.remote— GraphQL API (Ktor), request/response models,BuildKonfigfor auth.db— SQLDelight schema and generated code (UserEntity, queries).di— Koin modules (shared + platform).
- User feed: List and detail view; pull/sync from GraphQL; shimmer while loading; relative timestamps (“5 mins ago”) from
syncedAt. - Add user: Form with validation (RFC 5322–style email, name min 2 chars); Save disabled until valid; mutation uses Bearer token.
- Delete user: Long-press → confirm dialog; undo with Snackbar and timeout; then commit deletion to API and DB.
All tests are unit tests in the shared commonTest source set. They run when you execute the Android unit-test task (shared code is exercised on the JVM as part of that run).
| Test class | What it covers |
|---|---|
| SharedViewModelTest | MVI state and intents: initial state, state from repository, ClearError intent, DeleteUser failure path, relative-time tick flow. Uses FakeUserRepository and Turbine for StateFlow. |
| GetUserFeedUseCaseTest | GetUserFeedUseCase: usersFlow() emits from repository; sync() returns repository result. |
| RelativeTimeTest | formatRelativeTime(): "Just now" (< 1 min), "1 min ago", "N mins ago", "1 hour ago", and future timestamps (clamped to "Just now"). |
| ComposeAppCommonTest | Smoke test (e.g. 1 + 2 == 3) to confirm test runtime works. |
Total: 13 tests across 4 classes.
- kotlin.test — assertions and
@Test. - kotlinx.coroutines.test —
runTestfor coroutine-based code. - Turbine — testing
Flow/StateFlow(e.g.vm.state.test { ... }). - FakeUserRepository — in-memory implementation of
UserRepositorywith configurable sync/add/delete results for ViewModel and use-case tests.
JaCoCo is enabled for unit tests. Coverage is collected when you run testDebugUnitTest and reported by the jacocoTestReport task.
| Metric | Coverage |
|---|---|
| Instructions | 5% |
| Branches | 3% |
| Lines | ~7% (75 of 1,091 lines covered) |
Coverage is focused on domain and presentation logic (ViewModel, use cases, RelativeTime); the repository and API are faked in tests. UI composables, DI, theme, and generated code are largely uncovered. Packages with higher coverage in the report: domain.util (e.g. RelativeTime, validation) and domain.usecase (use cases exercised by ViewModel tests).
Generate the report: Run unit tests, then the report task (the report uses the latest coverage data from the last test run):
./gradlew :composeApp:testDebugUnitTest
./gradlew :composeApp:jacocoTestReportOpen the HTML report at composeApp/build/reports/jacoco/jacocoTestReport/html/index.html to see per-package and per-class coverage.
From the project root:
./gradlew :composeApp:testDebugUnitTestThis builds the composeApp module and runs all unit tests (including commonTest). For other targets, use the matching test task (e.g. testReleaseUnitTest).
Run the command above locally to get current results. Example output when all tests pass:
> Task :composeApp:testDebugUnitTest
ComposeAppCommonTest > example() PASSED
GetUserFeedUseCaseTest > usersFlow_emitsFromRepository() PASSED
GetUserFeedUseCaseTest > sync_returnsRepositoryResult() PASSED
RelativeTimeTest > formatRelativeTime_justNow_underOneMinute() PASSED
RelativeTimeTest > formatRelativeTime_oneMinuteAgo() PASSED
RelativeTimeTest > formatRelativeTime_minsAgo() PASSED
RelativeTimeTest > formatRelativeTime_oneHourAgo() PASSED
RelativeTimeTest > formatRelativeTime_future_returnsJustNow() PASSED
SharedViewModelTest > initialState_hasEmptyUsers() PASSED
SharedViewModelTest > state_emitsUsersFromRepository() PASSED
SharedViewModelTest > intentClearError_clearsErrorMessage() PASSED
SharedViewModelTest > intentDeleteUser_onFailure_setsErrorMessage() PASSED
SharedViewModelTest > relativeTimeTickFlow_emits() PASSED
BUILD SUCCESSFUL
HTML reports are written to composeApp/build/reports/tests/testDebugUnitTest/ after a run.
- Copy
local.properties.exampletolocal.propertiesand setGOREST_TOKEN(see Getting started). - Architecture: Clean Architecture + MVI; UI → ViewModel → Use cases → Repository; DB is the single source of truth; Koin for DI.
- Stack: KMP, Compose, Ktor (GraphQL), SQLDelight, BuildKonfig, Koin.
- Where to look: Feed UI and intents in
presentation.feed; business flow indomain.usecaseanddata.repository; API and models indata.remote; schema indbandcomposeApp/.../sqldelight.