Architecture at a glance
Primico is one Kotlin codebase with two thin shells: a domain module and a UI module that both platforms share, and an Android app and a desktop app that each add only what their machine does differently. Data flows one way — from SQLite, through a single repository and a single ViewModel, into stateless screens — and every write goes back through the repository.
This page is the map. The other concept pages each take one part of it and explain why it looks the way it does.
Four modules, one direction
Section titled “Four modules, one direction”flowchart TD
android[":app-android<br/>Android shell"] --> ui
desktop[":app-desktop<br/>JVM desktop shell"] --> ui
android --> core
desktop --> core
ui[":ui<br/>screens, theme, ViewModel,<br/>platform ports"] --> core
core[":core<br/>domain, storage, sync"]
| Module | What lives there |
|---|---|
:core |
The domain model, the recurrence and reminder rules, the quick-add parser, the backup codec, storage (SQLDelight) and the sync engine. No Compose. |
:ui |
Every screen, the theme, the formatters, the string resources, CadenceViewModel and the platform ports. Compose Multiplatform. |
:app-android |
Navigation, notifications, AlarmManager, the Storage Access Framework, home-screen widgets. |
:app-desktop |
The window, the sidebar and two-pane layout, keyboard shortcuts, the tray icon, a JSON settings file. |
Nothing points upward: :core does not know Compose exists, and :ui does not know which shell
it is running in. The full inventory is in Modules; the history of how
the single Android app was split up is ADR 0001.
Ports and adapters
Section titled “Ports and adapters”Wherever Android and the desktop genuinely differ, the shared code states what it needs as an interface — a port — and each shell supplies an implementation — an adapter. There are two families of them.
Store ports, in :core. Stores.kt
declares TaskStore, ProjectStore, SectionStore, TagStore, AttachmentStore,
BackupStore and StoreTransaction. They speak Task and Project, not rows, and carry no
database annotation. There is exactly one adapter for each —
SqlDelightStores.kt
— because SQLDelight is itself multiplatform. The ports are kept for their vocabulary, not so
that a test can swap in a fake (see Testing philosophy).
Platform ports, in :ui. ui/platform/Ports.kt
declares ReminderScheduler, BackupGateway, BackupFilePicker, AttachmentFilePicker and
AttachmentOpener; SettingsStore sits next to the settings types in
ui/settings/SettingsStore.kt.
| Port | Android adapter | Desktop adapter |
|---|---|---|
ReminderScheduler |
AlarmReminderScheduler (exact alarms) |
DesktopReminderScheduler (30 s poll, tray balloon) |
BackupGateway |
BackupIo |
DesktopBackupIo |
BackupFilePicker |
rememberSafBackupFilePicker (SAF) |
DesktopBackupFilePicker |
AttachmentOpener |
AndroidAttachmentOpener (FileProvider) |
DesktopAttachmentOpener (java.awt.Desktop) |
SettingsStore |
SharedPrefsSettingsStore |
DesktopSettingsStore (settings.json) |
Why sync is not a port
Section titled “Why sync is not a port”HTTPS and JSON are the same on both platforms, so
CadenceSyncEngine
is a concrete class in :core, built once and shared by both shells. A port is for what the
machine does differently — alarms, a file chooser, a content URI — and a network request is not
that (ADR 0002, decision 7).
One ViewModel, one state
Section titled “One ViewModel, one state”There is exactly one ViewModel for the whole app,
CadenceViewModel,
and it exposes exactly one StateFlow<CadenceUiState>. Every screen is a stateless composable
that receives that whole state plus callbacks. No screen loads its own data.
flowchart LR
db[("SQLite")] --> stores["SqlDelight*Store<br/>observeAll()"]
stores --> repo["CadenceRepository<br/>tasks · projects · sections<br/>tags · attachmentIndex"]
repo --> vm["CadenceViewModel<br/>combine(...)"]
settings["SettingsStore.state"] --> vm
syncStatus["syncEngine.status"] --> vm
undo["UndoSlot<br/>hiddenIds · snackbar"] --> vm
vm --> state["state: StateFlow of CadenceUiState"]
state --> screens["Stateless screens"]
screens -- "callbacks" --> vm
vm -- "suspend calls" --> repo
repo -- "write { }" --> db
repo -- "localWrites tick" --> debounce["2 s debounce"]
debounce --> engine["CadenceSyncEngine.syncOnce()"]
Reading the diagram from the top:
- Each store exposes a
Flowover a SQLDelight query.CadenceRepositoryre-exports them astasks,projects,sections,tagsandattachmentIndex. - The ViewModel
combines those with the settings, the sync status and the undo slot into oneCadenceUiState. Becausecombinehas typed overloads for at most five flows, the inputs are folded into small private groups first (Records,Transient) — a detail you will meet the moment you add a sixth. - Derived views — the Inbox, overdue tasks, a project’s tasks, a tag’s tasks — are methods on
CadenceUiState, backed byby lazyindexes built once per emission. A new derivation goes there, not into a screen. - A callback calls a ViewModel method, which launches a repository call. Every mutating
repository method routes its write through a private
write { }wrapper, which tickslocalWriteswhen the write succeeds. - The ViewModel debounces
localWritesby two seconds and runs a sync round. A row merged in by a pull never passes through the repository, so it can never ticklocalWrites— which is what keeps two devices from waking each other forever. Sync has the rest.
Why a plain class and not an Android ViewModel
Section titled “Why a plain class and not an Android ViewModel”The desktop has no androidx.lifecycle. The only two things Android needs from it are a scope
that survives a rotation and a moment to stop, so CadenceViewModel takes a scope in its
constructor and has a close() method. On Android,
CadenceViewModelHost
is an androidx.lifecycle.ViewModel whose whole body is one field, built from
viewModelScope. On the desktop, main() creates a plain CoroutineScope and calls close()
from the window’s onCloseRequest.
Wiring without a DI framework
Section titled “Wiring without a DI framework”The dependency graph is written by hand, in three places.
CadenceCorebuilds what both shells share: the database, the blob store,CadenceRepositorywith its stores andStoreTransaction,CadenceSyncEngine, and the application scope they run on. A store a new feature adds is one line here.- Each shell has an
AppContainerthat opens the database driver (the one genuinely platform-specific step —DatabaseDriverFactorytakes aContexton Android and aFileon the desktop), constructsCadenceCore, and builds its own adapters into aViewModelAdapters. Android’s lives inAppContainer.ktand is created inCadenceApplication.onCreate; the desktop’s,AppContainer.kt, is constructed once at the top ofmain(). cadenceViewModel(core, adapters, scope)assembles the seven-argument ViewModel constructor once, so neither shell repeats it.
The rule for where a new singleton goes: shared by both shells → CadenceCore; genuinely
platform-specific (SharedPreferences, AlarmManager, a tray icon) → the shell’s AppContainer.
jvmShared, not commonMain
Section titled “jvmShared, not commonMain”:core and :ui are Kotlin Multiplatform modules with an android and a jvm target, but
their hand-written code lives in a hand-declared jvmShared source set that both targets depend
on — not in commonMain. Both targets are the JVM, so jvmShared may use the JDK, and that is
why the recurrence engine still speaks java.time rather than kotlinx-datetime.
commonMain holds only what is generated or genuinely portable: SQLDelight’s query code, and
:ui’s string resources. It starts to earn its keep the day a browser target exists; moving code
there before then would buy portability nothing needs, at the price of rewriting every date in
the app (ADR 0001, decision 3).
The one expect/actual pair is DatabaseDriverFactory: its androidMain actual takes a
Context and its jvmMain actual takes a data directory, and the expect declares no
constructor so neither has to match the other.
Where to go next
Section titled “Where to go next”- Local-first — why the device’s database is the truth and the server is optional.
- Sync — how a round pulls, merges and pushes, and when rounds run.
- Recurrence — a recurring task as a chain of rows.
- Tasks, projects and tags — the organising model and the sort rule.
- Reminders — one planner, one reconciler, two platform schedulers.
- Undo and deletes — deferred writes and tombstones.
- Attachments — the row is the truth, the blob is a cache.
- The desktop shell — sidebar, two panes, drag and drop, the keyboard.
- Testing philosophy — real stores, fake platform ports, no mocks.
- Naming — why the product is Primico and the code says Cadence.