A map-first place discovery app built with Kotlin Multiplatform — where the interesting part isn't the map, it's the ranking and sync system underneath it.
Most "nearby places" apps are thin wrappers around a search API. GeoRanker is built around a custom ranking engine that scores places across three weighted signals and generates a human-readable explanation for every recommendation — not just a sorted list.
The ranking formula:
| Signal | Weight | Logic |
|---|---|---|
| Rating | 50% | Higher-rated places rank higher |
| Distance | 30% | Closer places get a proximity boost |
| Price level | 20% | Budget-friendly options are prioritized |
Every result includes an explainability string generated by the domain layer — e.g. "Highly rated (4.8★) and closer than 90% of nearby cafes" — so the user knows why something ranked first, not just that it did.
Two modules with a deliberate boundary between them:
composeApp/ → Android UI shell (Compose, Maps, ViewModel)
sync/ → Shared domain, ranking, persistence, sync
├── domain/
│ ├── ranking/RankingEngine.kt ← pure Kotlin, no Android deps
│ ├── usecase/GetRankedPlacesUseCase.kt
│ └── model/ (Place, RankedPlace, AreaInsight, Filters)
├── data/
│ ├── PlaceRepository.kt ← single source of truth
│ ├── RemoteDataSource.kt ← interface, swappable
│ └── MockDataSource.kt ← used for dev/testing
└── db/
└── Place.sq ← SQLDelight schema
The sync module has a jvmMain source set, which means the ranking engine and repository logic run and test on JVM without any Android runtime dependency.
Data always comes from the local SQLDelight database. Remote fetches happen asynchronously through SyncManager, coordinated by SyncWorker (WorkManager) on Android.
Conflict resolution is timestamp-based — updatedAt wins. The UI never waits on the network; it renders from cache and updates when sync completes.
UI observes Flow from SQLDelight
↑
PlaceRepository (local source of truth)
↑ ↑
SQLDelight SyncManager ← WorkManager (periodic)
↑
RemoteDataSource
Beyond individual rankings, GeoRanker calculates aggregate signals for the visible map area per category:
- Average rating across visible places
- Price distribution (how many budget vs. expensive options)
- Exposed as
AreaInsightfrom the domain layer, not derived in the UI
This means the summary panel reflects the actual ranked dataset, not a separate API call.
| Layer | Choice |
|---|---|
| Language | Kotlin 2.0+ |
| UI | Jetpack Compose Multiplatform |
| State | Coroutines + Flow |
| Persistence | SQLDelight |
| Maps | Google Maps SDK (Android) |
| Background sync | WorkManager |
| DI | Constructor injection (no framework) |
| Build | Gradle Kotlin DSL |
No Retrofit. No Hilt. The network boundary is abstracted behind RemoteDataSource — swapping in a real API requires implementing one interface.
- Add your Maps API key to
local.properties:MAPS_API_KEY=YOUR_KEY_HERE - Requires JDK 17+ and Gradle 8.x.
# Android
./gradlew :composeApp:installDebug
# JVM desktop
./gradlew :composeApp:runIf you're reading this as a code reference:
- Ranking logic:
sync/src/commonMain/domain/ranking/RankingEngine.kt - Offline sync flow:
sync/src/commonMain/data/SyncManager.kt - Explainability: how
RankedPlacecarries human-readable reasoning from domain → UI - KMP boundary:
expect/actualinPlatform.ktandDatabaseDriverFactory
MIT License LICENCE.md