Skip to content

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.

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.

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)

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).

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:

  1. Each store exposes a Flow over a SQLDelight query. CadenceRepository re-exports them as tasks, projects, sections, tags and attachmentIndex.
  2. The ViewModel combines those with the settings, the sync status and the undo slot into one CadenceUiState. Because combine has 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.
  3. Derived views — the Inbox, overdue tasks, a project’s tasks, a tag’s tasks — are methods on CadenceUiState, backed by by lazy indexes built once per emission. A new derivation goes there, not into a screen.
  4. A callback calls a ViewModel method, which launches a repository call. Every mutating repository method routes its write through a private write { } wrapper, which ticks localWrites when the write succeeds.
  5. The ViewModel debounces localWrites by two seconds and runs a sync round. A row merged in by a pull never passes through the repository, so it can never tick localWrites — 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.

The dependency graph is written by hand, in three places.

  • CadenceCore builds what both shells share: the database, the blob store, CadenceRepository with its stores and StoreTransaction, CadenceSyncEngine, and the application scope they run on. A store a new feature adds is one line here.
  • Each shell has an AppContainer that opens the database driver (the one genuinely platform-specific step — DatabaseDriverFactory takes a Context on Android and a File on the desktop), constructs CadenceCore, and builds its own adapters into a ViewModelAdapters. Android’s lives in AppContainer.kt and is created in CadenceApplication.onCreate; the desktop’s, AppContainer.kt, is constructed once at the top of main().
  • 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.

: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.

  • 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.