From e8d60e35a99beb14c2d09e803e18b040eb7d5ce9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 14:06:57 +0000 Subject: [PATCH] alpha.24: calendar overhaul, timer notifications, unattended screen-time capture Calendar - Multiple calendars, each with a colour (12 swatches or any hex), one default, hidden individually from a colour key across the top. Events are drawn in their calendar's colour and pick their calendar when saved. - Pinch-to-zoom finally works in week and day view: the gesture is claimed on the initial pointer pass, before the vertical scroll and the day columns can consume it, and reads the live hour height instead of a stale capture. - Swiping (or the arrows) slides the period in from the side it came from, in month, week, day and the new Agenda list. The loaded window is padded a period either side so the incoming page already has its events. - Proton-style stacked alerts replace the single 30-minute toggle: at start, 5/10/15/30/60/120 minutes, 1/2 days, a week, or a typed value. Editing an event cancels its previous alarms (new CancelRemindersFor action). - Locations autocomplete through OpenStreetMap Nominatim, debounced. - Clashing events share the column width instead of hiding each other, the timeline opens on the current hour, the title jumps to any date, and there is event search plus duplicate and day-shift. - Subscriptions: any ICS or webcal link becomes a read-only coloured calendar, keyed by ICS UID, wiped and rewritten each pull so upstream cancellations disappear, refreshed on open and every six hours by a worker. The ICS codec now reads and writes UID, all-day dates and VALARM offsets. - Jarvis gains event_full, calendar_new, calendar_subscribe and calendar_sync plus a provider describing calendars, colours, defaults and what is coming. Clock and Focus - Running timers and the stopwatch post ongoing notifications with Pause/Resume and Reset. The number is drawn by the system chronometer from an absolute instant, so it needs no per-second updates and stays correct while the app is away. - Clock's timer and stopwatch moved into singletons, so leaving the tab no longer resets them. - Fixed the "Show seconds" button sitting on top of the countdown: the fade container stacks its children, so each mode now owns its own column. Screen time and backups - A worker re-derives the last 45 days every four hours whether or not the module is opened, because Android purges raw usage after about a month. - A daily worker takes an encrypted database backup on its own once a passphrase is set. Schema 18 adds the calendars table plus calendarId, reminderMinutes and externalUid on events. New unit tests cover reminder encoding, the palette and the ICS reader. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011kSsXNrSk6NDjM2FjFZ7Y9 --- README.md | 10 +- app/build.gradle.kts | 4 +- .../com/lifeos/app/LifeOsApplication.kt | 8 + .../18.json | 2537 +++++++++++++++++ .../com/lifeos/core/database/LifeDatabase.kt | 4 +- .../database/calendar/CalendarEntities.kt | 70 + .../core/database/reminders/ReminderDao.kt | 4 + core/service/src/main/AndroidManifest.xml | 10 + .../com/lifeos/core/service/ActionEcho.kt | 9 + .../com/lifeos/core/service/LifeAction.kt | 34 + .../com/lifeos/core/service/TimerNotifier.kt | 195 ++ .../feature/adhd/data/FocusTimerController.kt | 28 +- feature/calendar/build.gradle.kts | 4 + .../lifeos/feature/calendar/CalendarScreen.kt | 1030 +++++-- .../feature/calendar/CalendarViewModel.kt | 471 ++- .../calendar/data/CalendarActionHandler.kt | 29 - .../calendar/data/CalendarJarvisBridge.kt | 199 ++ .../feature/calendar/data/CalendarPalette.kt | 49 + .../calendar/data/CalendarRepository.kt | 407 ++- .../calendar/data/CalendarSubscriptionSync.kt | 91 + .../lifeos/feature/calendar/data/IcsCodec.kt | 70 +- .../feature/calendar/data/PlaceLookup.kt | 72 + .../feature/calendar/di/CalendarModule.kt | 6 + .../work/CalendarSubscriptionWorker.kt | 54 + .../calendar/data/CalendarCodecTest.kt | 97 + .../lifeos/feature/chat/data/JarvisToolbox.kt | 60 + feature/clock/build.gradle.kts | 1 + .../com/lifeos/feature/clock/ClockScreen.kt | 217 +- .../lifeos/feature/clock/ClockViewModel.kt | 39 + .../clock/data/ClockTimerController.kt | 152 + .../feature/clock/data/StopwatchController.kt | 107 + .../reminders/data/RemindersActionHandler.kt | 34 +- feature/screentime/build.gradle.kts | 3 + .../screentime/work/ScreenTimeSyncWorker.kt | 58 + feature/sync/build.gradle.kts | 3 + .../feature/sync/work/AutoBackupWorker.kt | 60 + 36 files changed, 5733 insertions(+), 493 deletions(-) create mode 100644 core/database/schemas/com.lifeos.core.database.LifeDatabase/18.json create mode 100644 core/service/src/main/kotlin/com/lifeos/core/service/TimerNotifier.kt delete mode 100644 feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/CalendarActionHandler.kt create mode 100644 feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/CalendarJarvisBridge.kt create mode 100644 feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/CalendarPalette.kt create mode 100644 feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/CalendarSubscriptionSync.kt create mode 100644 feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/PlaceLookup.kt create mode 100644 feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/work/CalendarSubscriptionWorker.kt create mode 100644 feature/calendar/src/test/kotlin/com/lifeos/feature/calendar/data/CalendarCodecTest.kt create mode 100644 feature/clock/src/main/kotlin/com/lifeos/feature/clock/data/ClockTimerController.kt create mode 100644 feature/clock/src/main/kotlin/com/lifeos/feature/clock/data/StopwatchController.kt create mode 100644 feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/work/ScreenTimeSyncWorker.kt create mode 100644 feature/sync/src/main/kotlin/com/lifeos/feature/sync/work/AutoBackupWorker.kt diff --git a/README.md b/README.md index 25212f2..d5c1d84 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ or on your own NAS (Ollama). No third-party cloud, ever. **Spec:** [`docs/PRODUCTION_PLAN.md`](docs/PRODUCTION_PLAN.md). Every module and rule traces back to a section (and often a community demand source) there. -## Status — v0.1.0-alpha.18 +## Status — v0.1.0-alpha.24 | Area | State | |---|---| @@ -73,6 +73,12 @@ and rule traces back to a section (and often a community demand source) there. | Plan: auto-schedules due tasks into real calendar gaps inside working hours as ordinary, movable events; Jarvis plans today or tomorrow on request | Done | | Surfaces: a "Next up" Glance home-screen widget plus quick-settings tiles for capture and a 25-minute focus block | Done | | Voice: replies can be spoken through the device's on-device speech engine, and any module (or Jarvis) can say something with a Speak action | Done | +| Calendar v3: many calendars, each with its own colour (12 swatches or any hex), one marked default, individually hidden from a colour key across the top; events are drawn in their calendar's colour and choose their calendar when saved. Pinch-to-zoom works in week and day view (the gesture is claimed on the initial pass before the scroll and the columns see it, and reads the live hour height). Swiping or the arrows slide the period in from the side it came from, in month, week, day and the new Agenda list. Proton-style stacked alerts (at start, 5/10/15/30/60/120 min, 1/2 days, a week, or a typed value) replace the single 30-minute toggle, and editing an event drops its old alarms. Locations autocomplete through OpenStreetMap Nominatim. Clashing events sit side by side instead of hiding each other, the timeline opens on the current hour, the title bar jumps to any date, and there is event search and duplicate | Done | +| Calendar subscriptions: any ICS or webcal link (holidays, a shared work calendar) becomes a read-only coloured calendar, keyed by ICS UID so refreshes update instead of duplicating, wiped-and-rewritten each pull so upstream cancellations disappear, refreshed on open and every six hours by a worker. The ICS codec now reads UID, all-day dates and VALARM offsets, and writes them back | Done | +| Jarvis gains calendar tools: `[[event_full: when \| title \| calendar \| alert minutes \| location]]`, `[[calendar_new:]]`, `[[calendar_subscribe:]]`, `[[calendar_sync:]]`, and a calendar provider that reads out your calendars, their colours, defaults, subscription state and what is coming up | Done | +| Running timers show up in the notification shade: Clock's timer and stopwatch and the Focus timer all post an ongoing notification with Pause/Resume and Reset buttons. The number is drawn by the system's own chronometer from an absolute instant, so it stays correct with no per-second updates and keeps counting while the app is away. Clock's timer and stopwatch moved into singletons, so they no longer reset when you leave the tab | Done | +| Clock timer fix: the "Show seconds" button no longer sits on top of the countdown - the fade container stacks its children, so each mode now owns a column of its own | Done | +| Screen time never goes stale: a worker re-derives the last 45 days every four hours whether or not the module is opened (Android purges raw usage after about a month), and a daily worker takes an encrypted database backup on its own once a passphrase is set | Done | | Deferred post-alpha: Glance home-screen widgets, HA WebSocket live state/zones, Vault unlock UI, first-run onboarding checklist (grants live in Settings → System access), FinTS bank sync | Planned | **Google-free by design:** no Google service is ever called at runtime (no Play Services, no Google recognizer, no Google Maps). Remaining Google-*authored* open-source, fully on-device libraries: AndroidX/Jetpack (unavoidable on Android), MediaPipe (Gemma inference), ML Kit on-device OCR/barcode (no network) — swap candidates documented in the plan. @@ -82,7 +88,7 @@ and rule traces back to a section (and often a community demand source) there. Grab `lifeos-v*.apk` from [Releases](../../releases), then: ``` -adb install -r -g lifeos-v0.1.0-alpha.18.apk +adb install -r -g lifeos-v0.1.0-alpha.24.apk ``` or copy to the phone and allow *Install unknown apps*. Android 13+ (minSdk 33). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 93b80bb..ca31d2b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,8 +10,8 @@ android { defaultConfig { applicationId = "com.lifeos" - versionCode = 23 - versionName = "0.1.0-alpha.23" + versionCode = 24 + versionName = "0.1.0-alpha.24" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/kotlin/com/lifeos/app/LifeOsApplication.kt b/app/src/main/kotlin/com/lifeos/app/LifeOsApplication.kt index 0bb2e17..e160735 100644 --- a/app/src/main/kotlin/com/lifeos/app/LifeOsApplication.kt +++ b/app/src/main/kotlin/com/lifeos/app/LifeOsApplication.kt @@ -6,8 +6,11 @@ import androidx.work.Configuration import com.lifeos.core.database.LifeDatabase import com.lifeos.core.places.PlaceEngine import com.lifeos.core.recall.RecallIndex +import com.lifeos.feature.calendar.work.CalendarSubscriptionWorker import com.lifeos.feature.dhl.work.PackagePollWorker +import com.lifeos.feature.screentime.work.ScreenTimeSyncWorker import com.lifeos.feature.sync.data.BackupService +import com.lifeos.feature.sync.work.AutoBackupWorker import com.lifeos.feature.triggers.data.TriggerEngine import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineScope @@ -43,6 +46,11 @@ class LifeOsApplication : Application(), Configuration.Provider { BackupService.applyStagedRestore(this, LifeDatabase.NAME) super.onCreate() PackagePollWorker.schedule(this) + // Usage stats age out of Android after ~a month, and backups are worth + // nothing if they only happen when someone remembers to open a screen. + ScreenTimeSyncWorker.schedule(this) + AutoBackupWorker.schedule(this) + CalendarSubscriptionWorker.schedule(this) // Rules and place matching are the two things that must run whether or // not their screens were ever opened. placeEngine.start() diff --git a/core/database/schemas/com.lifeos.core.database.LifeDatabase/18.json b/core/database/schemas/com.lifeos.core.database.LifeDatabase/18.json new file mode 100644 index 0000000..cea92c3 --- /dev/null +++ b/core/database/schemas/com.lifeos.core.database.LifeDatabase/18.json @@ -0,0 +1,2537 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "944a282976673e0c258c6abf7681d962", + "entities": [ + { + "tableName": "vault_blobs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ref` TEXT NOT NULL, `algo` TEXT NOT NULL, `keyAlias` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `title` TEXT, `createdAt` INTEGER NOT NULL, `nasSynced` INTEGER NOT NULL, PRIMARY KEY(`ref`))", + "fields": [ + { + "fieldPath": "ref", + "columnName": "ref", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "algo", + "columnName": "algo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyAlias", + "columnName": "keyAlias", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nasSynced", + "columnName": "nasSynced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "ref" + ] + } + }, + { + "tableName": "ai_conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ai_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `conversationId` INTEGER NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `engine` TEXT, `createdAt` INTEGER NOT NULL, `imagePaths` TEXT NOT NULL DEFAULT '', FOREIGN KEY(`conversationId`) REFERENCES `ai_conversations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "engine", + "columnName": "engine", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "imagePaths", + "columnName": "imagePaths", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ai_messages_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_messages_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "ai_conversations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "conversationId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "captures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `text` TEXT, `blobVaultRef` TEXT, `routedTo` TEXT, `routedEntityId` INTEGER, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT" + }, + { + "fieldPath": "blobVaultRef", + "columnName": "blobVaultRef", + "affinity": "TEXT" + }, + { + "fieldPath": "routedTo", + "columnName": "routedTo", + "affinity": "TEXT" + }, + { + "fieldPath": "routedEntityId", + "columnName": "routedEntityId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "log_forms", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `fieldsJson` TEXT NOT NULL, `color` INTEGER, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldsJson", + "columnName": "fieldsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "log_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `formId` INTEGER NOT NULL, `valuesJson` TEXT NOT NULL, `source` TEXT NOT NULL, `at` INTEGER NOT NULL, FOREIGN KEY(`formId`) REFERENCES `log_forms`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "formId", + "columnName": "formId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "valuesJson", + "columnName": "valuesJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "at", + "columnName": "at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_log_entries_formId", + "unique": false, + "columnNames": [ + "formId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_entries_formId` ON `${TABLE_NAME}` (`formId`)" + } + ], + "foreignKeys": [ + { + "table": "log_forms", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "formId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `done` INTEGER NOT NULL, `listId` INTEGER, `parentId` INTEGER, `dueAt` INTEGER, `sourceModule` TEXT, `sourceEntityId` INTEGER, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "done", + "columnName": "done", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "listId", + "columnName": "listId", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER" + }, + { + "fieldPath": "dueAt", + "columnName": "dueAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "sourceModule", + "columnName": "sourceModule", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceEntityId", + "columnName": "sourceEntityId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `path` TEXT NOT NULL, `title` TEXT NOT NULL, `bodyVaultRef` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bodyVaultRef", + "columnName": "bodyVaultRef", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_notes_path", + "unique": true, + "columnNames": [ + "path" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_notes_path` ON `${TABLE_NAME}` (`path`)" + } + ] + }, + { + "tableName": "note_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `fromNoteId` INTEGER NOT NULL, `toTitle` TEXT NOT NULL, FOREIGN KEY(`fromNoteId`) REFERENCES `notes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fromNoteId", + "columnName": "fromNoteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toTitle", + "columnName": "toTitle", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_note_links_fromNoteId", + "unique": false, + "columnNames": [ + "fromNoteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_links_fromNoteId` ON `${TABLE_NAME}` (`fromNoteId`)" + }, + { + "name": "index_note_links_toTitle", + "unique": false, + "columnNames": [ + "toTitle" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_links_toTitle` ON `${TABLE_NAME}` (`toTitle`)" + } + ], + "foreignKeys": [ + { + "table": "notes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "fromNoteId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "note_embeddings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `noteId` INTEGER NOT NULL, `chunkIndex` INTEGER NOT NULL, `chunkText` TEXT NOT NULL, `vector` BLOB NOT NULL, FOREIGN KEY(`noteId`) REFERENCES `notes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteId", + "columnName": "noteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chunkIndex", + "columnName": "chunkIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chunkText", + "columnName": "chunkText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vector", + "columnName": "vector", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_note_embeddings_noteId", + "unique": false, + "columnNames": [ + "noteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_embeddings_noteId` ON `${TABLE_NAME}` (`noteId`)" + } + ], + "foreignKeys": [ + { + "table": "notes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "noteId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "reminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `notes` TEXT, `at` INTEGER NOT NULL, `recurrence` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `firedAt` INTEGER, `sourceModule` TEXT, `sourceEntityId` INTEGER, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT" + }, + { + "fieldPath": "at", + "columnName": "at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recurrence", + "columnName": "recurrence", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firedAt", + "columnName": "firedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "sourceModule", + "columnName": "sourceModule", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceEntityId", + "columnName": "sourceEntityId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "task_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "calendar_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `location` TEXT, `notes` TEXT, `startsAt` INTEGER NOT NULL, `endsAt` INTEGER NOT NULL, `allDay` INTEGER NOT NULL, `reminderId` INTEGER, `systemEventId` INTEGER, `calendarId` INTEGER DEFAULT NULL, `reminderMinutes` TEXT NOT NULL DEFAULT '', `externalUid` TEXT DEFAULT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT" + }, + { + "fieldPath": "startsAt", + "columnName": "startsAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endsAt", + "columnName": "endsAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allDay", + "columnName": "allDay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminderId", + "columnName": "reminderId", + "affinity": "INTEGER" + }, + { + "fieldPath": "systemEventId", + "columnName": "systemEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "calendarId", + "columnName": "calendarId", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "reminderMinutes", + "columnName": "reminderMinutes", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "externalUid", + "columnName": "externalUid", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "unified_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appPackage` TEXT NOT NULL, `appLabel` TEXT NOT NULL, `title` TEXT, `text` TEXT, `notificationKey` TEXT NOT NULL, `postedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appPackage", + "columnName": "appPackage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appLabel", + "columnName": "appLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT" + }, + { + "fieldPath": "notificationKey", + "columnName": "notificationKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postedAt", + "columnName": "postedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_unified_messages_appPackage", + "unique": false, + "columnNames": [ + "appPackage" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_unified_messages_appPackage` ON `${TABLE_NAME}` (`appPackage`)" + }, + { + "name": "index_unified_messages_notificationKey_postedAt", + "unique": true, + "columnNames": [ + "notificationKey", + "postedAt" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_unified_messages_notificationKey_postedAt` ON `${TABLE_NAME}` (`notificationKey`, `postedAt`)" + } + ] + }, + { + "tableName": "packages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `trackingNumber` TEXT NOT NULL, `label` TEXT, `status` TEXT NOT NULL, `statusDescription` TEXT, `estimatedDeliveryAt` INTEGER, `reminderId` INTEGER, `lastRefreshedAt` INTEGER, `sourceModule` TEXT, `sourceEntityId` INTEGER, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackingNumber", + "columnName": "trackingNumber", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusDescription", + "columnName": "statusDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "estimatedDeliveryAt", + "columnName": "estimatedDeliveryAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "reminderId", + "columnName": "reminderId", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastRefreshedAt", + "columnName": "lastRefreshedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "sourceModule", + "columnName": "sourceModule", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceEntityId", + "columnName": "sourceEntityId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_packages_trackingNumber", + "unique": true, + "columnNames": [ + "trackingNumber" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_packages_trackingNumber` ON `${TABLE_NAME}` (`trackingNumber`)" + } + ] + }, + { + "tableName": "tracking_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `packageId` INTEGER NOT NULL, `status` TEXT NOT NULL, `description` TEXT, `location` TEXT, `at` INTEGER NOT NULL, FOREIGN KEY(`packageId`) REFERENCES `packages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageId", + "columnName": "packageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT" + }, + { + "fieldPath": "at", + "columnName": "at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tracking_events_packageId", + "unique": false, + "columnNames": [ + "packageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracking_events_packageId` ON `${TABLE_NAME}` (`packageId`)" + } + ], + "foreignKeys": [ + { + "table": "packages", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "packageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "scanned_documents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `imagePath` TEXT, `ocrText` TEXT NOT NULL, `extractedJson` TEXT, `linkedModule` TEXT, `linkedEntityId` INTEGER, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imagePath", + "columnName": "imagePath", + "affinity": "TEXT" + }, + { + "fieldPath": "ocrText", + "columnName": "ocrText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extractedJson", + "columnName": "extractedJson", + "affinity": "TEXT" + }, + { + "fieldPath": "linkedModule", + "columnName": "linkedModule", + "affinity": "TEXT" + }, + { + "fieldPath": "linkedEntityId", + "columnName": "linkedEntityId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `merchant` TEXT NOT NULL, `amountCents` INTEGER NOT NULL, `categoryId` INTEGER, `at` INTEGER NOT NULL, `source` TEXT NOT NULL, `sourceDocId` INTEGER, `notes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "merchant", + "columnName": "merchant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountCents", + "columnName": "amountCents", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "INTEGER" + }, + { + "fieldPath": "at", + "columnName": "at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceDocId", + "columnName": "sourceDocId", + "affinity": "INTEGER" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_transactions_categoryId", + "unique": false, + "columnNames": [ + "categoryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_categoryId` ON `${TABLE_NAME}` (`categoryId`)" + }, + { + "name": "index_transactions_at", + "unique": false, + "columnNames": [ + "at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_at` ON `${TABLE_NAME}` (`at`)" + } + ] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_categories_name", + "unique": true, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_categories_name` ON `${TABLE_NAME}` (`name`)" + } + ] + }, + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `merchant` TEXT NOT NULL, `amountCents` INTEGER NOT NULL, `cadence` TEXT NOT NULL, `lastChargedAt` INTEGER NOT NULL, `status` TEXT NOT NULL, `cancelUrl` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "merchant", + "columnName": "merchant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountCents", + "columnName": "amountCents", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cadence", + "columnName": "cadence", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastChargedAt", + "columnName": "lastChargedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cancelUrl", + "columnName": "cancelUrl", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_subscriptions_merchant", + "unique": true, + "columnNames": [ + "merchant" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_merchant` ON `${TABLE_NAME}` (`merchant`)" + } + ] + }, + { + "tableName": "warranties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productName` TEXT NOT NULL, `purchaseTxId` INTEGER, `purchasedAt` INTEGER NOT NULL, `warrantyMonths` INTEGER NOT NULL, `reminderId` INTEGER, `docId` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productName", + "columnName": "productName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purchaseTxId", + "columnName": "purchaseTxId", + "affinity": "INTEGER" + }, + { + "fieldPath": "purchasedAt", + "columnName": "purchasedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "warrantyMonths", + "columnName": "warrantyMonths", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminderId", + "columnName": "reminderId", + "affinity": "INTEGER" + }, + { + "fieldPath": "docId", + "columnName": "docId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "email_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `messageUid` TEXT NOT NULL, `from` TEXT NOT NULL, `subject` TEXT NOT NULL, `preview` TEXT NOT NULL, `receivedAt` INTEGER NOT NULL, `hasInvoiceSignal` INTEGER NOT NULL, `hasInviteSignal` INTEGER NOT NULL, `hasSubscriptionSignal` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageUid", + "columnName": "messageUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "from", + "columnName": "from", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "preview", + "columnName": "preview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receivedAt", + "columnName": "receivedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasInvoiceSignal", + "columnName": "hasInvoiceSignal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasInviteSignal", + "columnName": "hasInviteSignal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSubscriptionSignal", + "columnName": "hasSubscriptionSignal", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_email_messages_messageUid", + "unique": true, + "columnNames": [ + "messageUid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_email_messages_messageUid` ON `${TABLE_NAME}` (`messageUid`)" + } + ] + }, + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `author` TEXT NOT NULL, `isbn` TEXT, `status` TEXT NOT NULL, `ratingHalfStars` INTEGER, `notes` TEXT, `addedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isbn", + "columnName": "isbn", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ratingHalfStars", + "columnName": "ratingHalfStars", + "affinity": "INTEGER" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT" + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "saved_places", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `query` TEXT NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "archive_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `source` TEXT NOT NULL, `kind` TEXT NOT NULL, `title` TEXT NOT NULL, `body` TEXT NOT NULL, `capturedAt` INTEGER NOT NULL, `annotated` INTEGER NOT NULL, `annotation` TEXT NOT NULL, `expiresAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "capturedAt", + "columnName": "capturedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "annotated", + "columnName": "annotated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "annotation", + "columnName": "annotation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "macros", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `nlPrompt` TEXT NOT NULL, `stepsJson` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastRunAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nlPrompt", + "columnName": "nlPrompt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stepsJson", + "columnName": "stepsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastRunAt", + "columnName": "lastRunAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "focus_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `minutes` INTEGER NOT NULL, `startedAt` INTEGER NOT NULL, `completed` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minutes", + "columnName": "minutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "startedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completed", + "columnName": "completed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "interaction_logs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `engine` TEXT NOT NULL, `kind` TEXT NOT NULL, `accepted` INTEGER, `at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "engine", + "columnName": "engine", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accepted", + "columnName": "accepted", + "affinity": "INTEGER" + }, + { + "fieldPath": "at", + "columnName": "at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sourceUrl` TEXT NOT NULL, `mediaUrl` TEXT NOT NULL, `title` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `status` TEXT NOT NULL, `progressPercent` INTEGER NOT NULL, `sizeBytes` INTEGER NOT NULL, `savedUri` TEXT, `error` TEXT, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaUrl", + "columnName": "mediaUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "progressPercent", + "columnName": "progressPercent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedUri", + "columnName": "savedUri", + "affinity": "TEXT" + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "my_plants", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `speciesId` TEXT NOT NULL, `waterEveryDays` INTEGER NOT NULL, `lastWateredAt` INTEGER, `reminderId` INTEGER, `createdAt` INTEGER NOT NULL, `photoPath` TEXT, `notes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "speciesId", + "columnName": "speciesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "waterEveryDays", + "columnName": "waterEveryDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastWateredAt", + "columnName": "lastWateredAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "reminderId", + "columnName": "reminderId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "photoPath", + "columnName": "photoPath", + "affinity": "TEXT" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "screen_time_days", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`date` TEXT NOT NULL, `totalForegroundMs` INTEGER NOT NULL, `unlocks` INTEGER NOT NULL, `notifications` INTEGER NOT NULL, `capturedAt` INTEGER NOT NULL, PRIMARY KEY(`date`))", + "fields": [ + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalForegroundMs", + "columnName": "totalForegroundMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unlocks", + "columnName": "unlocks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notifications", + "columnName": "notifications", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "capturedAt", + "columnName": "capturedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "date" + ] + } + }, + { + "tableName": "screen_time_apps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`date` TEXT NOT NULL, `packageName` TEXT NOT NULL, `label` TEXT NOT NULL, `foregroundMs` INTEGER NOT NULL, PRIMARY KEY(`date`, `packageName`))", + "fields": [ + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "foregroundMs", + "columnName": "foregroundMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "date", + "packageName" + ] + } + }, + { + "tableName": "brick_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `blockedPackages` TEXT NOT NULL, `activator` TEXT NOT NULL, `deactivator` TEXT NOT NULL, `nfcTagId` TEXT, `startMinuteOfDay` INTEGER, `endMinuteOfDay` INTEGER, `strict` INTEGER NOT NULL, `inverse` INTEGER NOT NULL DEFAULT 0, `unlockMinutes` INTEGER NOT NULL DEFAULT 60, `unlockAllowance` INTEGER NOT NULL DEFAULT 1, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedPackages", + "columnName": "blockedPackages", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activator", + "columnName": "activator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deactivator", + "columnName": "deactivator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nfcTagId", + "columnName": "nfcTagId", + "affinity": "TEXT" + }, + { + "fieldPath": "startMinuteOfDay", + "columnName": "startMinuteOfDay", + "affinity": "INTEGER" + }, + { + "fieldPath": "endMinuteOfDay", + "columnName": "endMinuteOfDay", + "affinity": "INTEGER" + }, + { + "fieldPath": "strict", + "columnName": "strict", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inverse", + "columnName": "inverse", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "unlockMinutes", + "columnName": "unlockMinutes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "60" + }, + { + "fieldPath": "unlockAllowance", + "columnName": "unlockAllowance", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "brick_app_limits", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `dailyMinutes` INTEGER NOT NULL, PRIMARY KEY(`profileId`, `packageName`))", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dailyMinutes", + "columnName": "dailyMinutes", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profileId", + "packageName" + ] + } + }, + { + "tableName": "brick_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `profileId` INTEGER NOT NULL, `startedAt` INTEGER NOT NULL, `endedAt` INTEGER, `startedBy` TEXT NOT NULL, `blockedAttempts` INTEGER NOT NULL, `unlockUntil` INTEGER, `unlocksUsed` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "startedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "startedBy", + "columnName": "startedBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAttempts", + "columnName": "blockedAttempts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unlockUntil", + "columnName": "unlockUntil", + "affinity": "INTEGER" + }, + { + "fieldPath": "unlocksUsed", + "columnName": "unlocksUsed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "brick_usage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`date` TEXT NOT NULL, `packageName` TEXT NOT NULL, `secondsUsed` INTEGER NOT NULL, PRIMARY KEY(`date`, `packageName`))", + "fields": [ + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondsUsed", + "columnName": "secondsUsed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "date", + "packageName" + ] + } + }, + { + "tableName": "places", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `latitude` REAL, `longitude` REAL, `radiusMeters` INTEGER NOT NULL, `wifiSsid` TEXT, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL" + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL" + }, + { + "fieldPath": "radiusMeters", + "columnName": "radiusMeters", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wifiSsid", + "columnName": "wifiSsid", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "trigger_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `triggerType` TEXT NOT NULL, `triggerArg` TEXT NOT NULL, `days` TEXT NOT NULL, `window` TEXT NOT NULL, `actionType` TEXT NOT NULL, `actionArg` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `lastFiredAt` INTEGER, `fireCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "triggerType", + "columnName": "triggerType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "triggerArg", + "columnName": "triggerArg", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "days", + "columnName": "days", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "window", + "columnName": "window", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actionType", + "columnName": "actionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actionArg", + "columnName": "actionArg", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastFiredAt", + "columnName": "lastFiredAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "fireCount", + "columnName": "fireCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "trigger_fires", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ruleId` INTEGER NOT NULL, `ruleName` TEXT NOT NULL, `at` INTEGER NOT NULL, `outcome` TEXT NOT NULL, `detail` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleId", + "columnName": "ruleId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleName", + "columnName": "ruleName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "at", + "columnName": "at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outcome", + "columnName": "outcome", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "detail", + "columnName": "detail", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "signals", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appPackage` TEXT NOT NULL, `appLabel` TEXT NOT NULL, `title` TEXT NOT NULL, `text` TEXT NOT NULL, `postedAt` INTEGER NOT NULL, `readAt` INTEGER, `extracted` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appPackage", + "columnName": "appPackage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appLabel", + "columnName": "appLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postedAt", + "columnName": "postedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readAt", + "columnName": "readAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "extracted", + "columnName": "extracted", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_signals_postedAt", + "unique": false, + "columnNames": [ + "postedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_signals_postedAt` ON `${TABLE_NAME}` (`postedAt`)" + }, + { + "name": "index_signals_appPackage", + "unique": false, + "columnNames": [ + "appPackage" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_signals_appPackage` ON `${TABLE_NAME}` (`appPackage`)" + } + ] + }, + { + "tableName": "recall_chunks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sourceKey` TEXT NOT NULL, `module` TEXT NOT NULL, `title` TEXT NOT NULL, `body` TEXT NOT NULL, `vector` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceKey", + "columnName": "sourceKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "module", + "columnName": "module", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vector", + "columnName": "vector", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_recall_chunks_sourceKey", + "unique": false, + "columnNames": [ + "sourceKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_recall_chunks_sourceKey` ON `${TABLE_NAME}` (`sourceKey`)" + }, + { + "name": "index_recall_chunks_module", + "unique": false, + "columnNames": [ + "module" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_recall_chunks_module` ON `${TABLE_NAME}` (`module`)" + } + ] + }, + { + "tableName": "backup_runs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `at` INTEGER NOT NULL, `destination` TEXT NOT NULL, `fileName` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `status` TEXT NOT NULL, `detail` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "at", + "columnName": "at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "destination", + "columnName": "destination", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "detail", + "columnName": "detail", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "calendars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `colorArgb` INTEGER NOT NULL, `isDefault` INTEGER NOT NULL, `visible` INTEGER NOT NULL, `subscriptionUrl` TEXT, `lastSyncedAt` INTEGER, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "colorArgb", + "columnName": "colorArgb", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDefault", + "columnName": "isDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visible", + "columnName": "visible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionUrl", + "columnName": "subscriptionUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '944a282976673e0c258c6abf7681d962')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/main/kotlin/com/lifeos/core/database/LifeDatabase.kt b/core/database/src/main/kotlin/com/lifeos/core/database/LifeDatabase.kt index 052c3a9..9979cdd 100644 --- a/core/database/src/main/kotlin/com/lifeos/core/database/LifeDatabase.kt +++ b/core/database/src/main/kotlin/com/lifeos/core/database/LifeDatabase.kt @@ -110,8 +110,9 @@ import com.lifeos.core.database.screentime.ScreenTimeDayEntity com.lifeos.core.database.signals.SignalEntity::class, com.lifeos.core.database.recall.RecallChunkEntity::class, com.lifeos.core.database.backup.BackupRunEntity::class, + com.lifeos.core.database.calendar.CalendarListEntity::class, ], - version = 17, + version = 18, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -130,6 +131,7 @@ import com.lifeos.core.database.screentime.ScreenTimeDayEntity AutoMigration(from = 14, to = 15), AutoMigration(from = 15, to = 16), AutoMigration(from = 16, to = 17), + AutoMigration(from = 17, to = 18), ], ) abstract class LifeDatabase : RoomDatabase() { diff --git a/core/database/src/main/kotlin/com/lifeos/core/database/calendar/CalendarEntities.kt b/core/database/src/main/kotlin/com/lifeos/core/database/calendar/CalendarEntities.kt index 768528d..b8b3e49 100644 --- a/core/database/src/main/kotlin/com/lifeos/core/database/calendar/CalendarEntities.kt +++ b/core/database/src/main/kotlin/com/lifeos/core/database/calendar/CalendarEntities.kt @@ -1,5 +1,6 @@ package com.lifeos.core.database.calendar +import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.Entity import androidx.room.Insert @@ -8,6 +9,28 @@ import androidx.room.Query import androidx.room.Update import kotlinx.coroutines.flow.Flow +/** + * A calendar the user keeps events in (§Module 19). + * + * Colour lives here rather than on the event, so recolouring a calendar + * recolours everything in it. [subscriptionUrl] makes it a live mirror of a + * remote ICS feed (holidays, a shared work calendar); those rows are replaced on + * every sync, which is why locally created events never go into one. + */ +@Entity(tableName = "calendars") +data class CalendarListEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, + /** ARGB, as stored by Compose's Color.toArgb(). */ + val colorArgb: Int, + val isDefault: Boolean = false, + val visible: Boolean = true, + /** Non-null = read-only subscription refreshed from this URL. */ + val subscriptionUrl: String? = null, + val lastSyncedAt: Long? = null, + val createdAt: Long, +) + /** A local-first calendar event (§Module 19). Provider/ICS sync layers on top. */ @Entity(tableName = "calendar_events") data class CalendarEventEntity( @@ -21,6 +44,12 @@ data class CalendarEventEntity( val reminderId: Long? = null, /** Non-null when mirrored to the system Calendar Provider. */ val systemEventId: Long? = null, + /** Which calendar this belongs to; null = the default one. */ + @ColumnInfo(defaultValue = "NULL") val calendarId: Long? = null, + /** Minutes-before offsets, comma separated ("0,30,1440"); empty = no alert. */ + @ColumnInfo(defaultValue = "") val reminderMinutes: String = "", + /** ICS UID for subscription rows, so a refresh updates instead of duplicating. */ + @ColumnInfo(defaultValue = "NULL") val externalUid: String? = null, val createdAt: Long, val updatedAt: Long, ) @@ -28,6 +57,47 @@ data class CalendarEventEntity( @Dao interface CalendarDao { + // ---- calendars --------------------------------------------------------- + + @Query("SELECT * FROM calendars ORDER BY isDefault DESC, name") + fun observeCalendars(): Flow> + + @Query("SELECT * FROM calendars ORDER BY isDefault DESC, name") + suspend fun allCalendars(): List + + @Query("SELECT * FROM calendars WHERE id = :id") + suspend fun calendar(id: Long): CalendarListEntity? + + @Query("SELECT * FROM calendars WHERE isDefault = 1 LIMIT 1") + suspend fun defaultCalendar(): CalendarListEntity? + + @Query("SELECT * FROM calendars WHERE subscriptionUrl IS NOT NULL") + suspend fun subscriptions(): List + + @Insert + suspend fun insertCalendar(calendar: CalendarListEntity): Long + + @Update + suspend fun updateCalendar(calendar: CalendarListEntity) + + @Query("UPDATE calendars SET isDefault = 0") + suspend fun clearDefaultCalendar() + + @Query("DELETE FROM calendars WHERE id = :id") + suspend fun deleteCalendar(id: Long) + + @Query("UPDATE calendar_events SET calendarId = NULL WHERE calendarId = :calendarId") + suspend fun detachEventsFrom(calendarId: Long) + + @Query("DELETE FROM calendar_events WHERE calendarId = :calendarId") + suspend fun deleteEventsOf(calendarId: Long) + + @Query("SELECT * FROM calendar_events WHERE calendarId = :calendarId AND externalUid = :uid LIMIT 1") + suspend fun bySubscriptionUid(calendarId: Long, uid: String): CalendarEventEntity? + + @Query("UPDATE calendars SET lastSyncedAt = :at WHERE id = :id") + suspend fun markSynced(id: Long, at: Long) + @Insert suspend fun insert(event: CalendarEventEntity): Long diff --git a/core/database/src/main/kotlin/com/lifeos/core/database/reminders/ReminderDao.kt b/core/database/src/main/kotlin/com/lifeos/core/database/reminders/ReminderDao.kt index 89ad96e..2c6d836 100644 --- a/core/database/src/main/kotlin/com/lifeos/core/database/reminders/ReminderDao.kt +++ b/core/database/src/main/kotlin/com/lifeos/core/database/reminders/ReminderDao.kt @@ -32,4 +32,8 @@ interface ReminderDao { @Query("DELETE FROM reminders WHERE id = :id") suspend fun delete(id: Long) + + /** Every reminder another module created for one of its rows. */ + @Query("SELECT * FROM reminders WHERE sourceModule = :module AND sourceEntityId = :entityId") + suspend fun bySource(module: String, entityId: Long): List } diff --git a/core/service/src/main/AndroidManifest.xml b/core/service/src/main/AndroidManifest.xml index 91b9335..227c2e3 100644 --- a/core/service/src/main/AndroidManifest.xml +++ b/core/service/src/main/AndroidManifest.xml @@ -20,6 +20,16 @@ android:value="Cross-module coordination hub: event bus, rules engine, and scheduler for on-device automation (§1.4)" /> + + + + + + + = emptyList(), + ) : LifeAction + + /** Adds a calendar (§Module 19); [colorName] is a palette name or #RRGGBB. */ + data class CreateCalendar( + val name: String, + val colorName: String, + val makeDefault: Boolean, + override val source: SourceRef, + ) : LifeAction + + /** Subscribes to a remote ICS feed and pulls it in straight away. */ + data class SubscribeCalendar( + val name: String, + val url: String, + val colorName: String, + override val source: SourceRef, + ) : LifeAction + + /** Refreshes every subscribed calendar now. */ + data class SyncCalendars( + override val source: SourceRef, + ) : LifeAction + + /** Drops the reminders another module created for one of its rows. */ + data class CancelRemindersFor( + val module: String, + val entityId: Long, + override val source: SourceRef, ) : LifeAction // ---- Jarvis-facing actions (§Module 9) --------------------------------- diff --git a/core/service/src/main/kotlin/com/lifeos/core/service/TimerNotifier.kt b/core/service/src/main/kotlin/com/lifeos/core/service/TimerNotifier.kt new file mode 100644 index 0000000..71205d7 --- /dev/null +++ b/core/service/src/main/kotlin/com/lifeos/core/service/TimerNotifier.kt @@ -0,0 +1,195 @@ +package com.lifeos.core.service + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** Which running clock a notification belongs to. */ +enum class TimerKind(val notificationId: Int, val label: String) { + CLOCK_TIMER(5001, "Timer"), + STOPWATCH(5002, "Stopwatch"), + FOCUS_TIMER(5003, "Focus timer"), +} + +/** What the buttons on those notifications ask for. */ +enum class TimerCommand { TOGGLE, RESET } + +/** + * Notification buttons talk to whichever module owns the clock without either + * side depending on the other: the receiver posts here, the controller collects. + */ +object TimerCommandBus { + private val _commands = MutableSharedFlow>(extraBufferCapacity = 8) + val commands = _commands.asSharedFlow() + + fun send(kind: TimerKind, command: TimerCommand) { + _commands.tryEmit(kind to command) + } +} + +/** Turns the notification's Pause/Reset taps into [TimerCommandBus] traffic. */ +class TimerCommandReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val kind = intent.getStringExtra(EXTRA_KIND)?.let { name -> + TimerKind.entries.firstOrNull { it.name == name } + } ?: return + val command = intent.getStringExtra(EXTRA_COMMAND)?.let { name -> + TimerCommand.entries.firstOrNull { it.name == name } + } ?: return + TimerCommandBus.send(kind, command) + } + + companion object { + const val ACTION = "com.lifeos.action.TIMER_COMMAND" + const val EXTRA_KIND = "kind" + const val EXTRA_COMMAND = "command" + } +} + +/** + * Ongoing notifications for anything that counts (§Module 4/§Module 5). + * + * The countdown and the stopwatch are drawn by the system's own chronometer + * (`setUsesChronometer`), anchored to an absolute wall-clock instant. That means + * the notification keeps ticking correctly with no per-second updates from the + * app at all — the number stays right even if the process is swapped out, which + * is exactly what a background timer has to survive. + */ +@Singleton +class TimerNotifier @Inject constructor( + @ApplicationContext private val context: Context, +) { + + private val manager: NotificationManager = + context.getSystemService(NotificationManager::class.java) + + /** A counting-down clock; [remainingMs] is measured from now. */ + fun countdown(kind: TimerKind, title: String, remainingMs: Long, running: Boolean) { + ensureChannel() + val builder = base(kind, title) + if (running) { + builder + .setWhen(System.currentTimeMillis() + remainingMs) + .setUsesChronometer(true) + .setChronometerCountDown(true) + .setContentText("Counting down") + .addAction(0, "Pause", commandIntent(kind, TimerCommand.TOGGLE)) + } else { + builder + .setShowWhen(false) + .setContentText("Paused at ${clock(remainingMs)}") + .addAction(0, "Resume", commandIntent(kind, TimerCommand.TOGGLE)) + } + builder.addAction(0, "Reset", commandIntent(kind, TimerCommand.RESET)) + manager.notify(kind.notificationId, builder.build()) + } + + /** A counting-up clock; [elapsedMs] is how much has already been counted. */ + fun stopwatch(kind: TimerKind, title: String, elapsedMs: Long, running: Boolean) { + ensureChannel() + val builder = base(kind, title) + if (running) { + builder + .setWhen(System.currentTimeMillis() - elapsedMs) + .setUsesChronometer(true) + .setContentText("Running") + .addAction(0, "Pause", commandIntent(kind, TimerCommand.TOGGLE)) + } else { + builder + .setShowWhen(false) + .setContentText("Paused at ${clock(elapsedMs)}") + .addAction(0, "Resume", commandIntent(kind, TimerCommand.TOGGLE)) + } + builder.addAction(0, "Reset", commandIntent(kind, TimerCommand.RESET)) + manager.notify(kind.notificationId, builder.build()) + } + + fun cancel(kind: TimerKind) = manager.cancel(kind.notificationId) + + /** A one-shot "your timer is up" note, replacing the ongoing one. */ + fun finished(kind: TimerKind, title: String) { + ensureChannel() + manager.notify( + kind.notificationId, + NotificationCompat.Builder(context, CHANNEL_ID) + .setContentTitle(title) + .setContentText("Time is up") + .setSmallIcon(android.R.drawable.ic_lock_idle_alarm) + .setAutoCancel(true) + .setOnlyAlertOnce(false) + .setContentIntent(openApp()) + .build(), + ) + } + + private fun base(kind: TimerKind, title: String) = + NotificationCompat.Builder(context, CHANNEL_ID) + .setContentTitle(title) + .setSmallIcon(android.R.drawable.ic_lock_idle_alarm) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setSilent(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_STOPWATCH) + .setContentIntent(openApp()) + .also { it.setSubText(kind.label) } + + private fun commandIntent(kind: TimerKind, command: TimerCommand): PendingIntent { + val intent = Intent(context, TimerCommandReceiver::class.java) + .setAction(TimerCommandReceiver.ACTION) + .putExtra(TimerCommandReceiver.EXTRA_KIND, kind.name) + .putExtra(TimerCommandReceiver.EXTRA_COMMAND, command.name) + return PendingIntent.getBroadcast( + context, + kind.notificationId * 10 + command.ordinal, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun openApp(): PendingIntent? { + val launch = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null + return PendingIntent.getActivity( + context, + 0, + launch, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun ensureChannel() { + manager.createNotificationChannel( + NotificationChannel(CHANNEL_ID, "Running timers", NotificationManager.IMPORTANCE_LOW).apply { + description = "Live countdowns and stopwatches from Clock and Focus" + setShowBadge(false) + enableVibration(false) + }, + ) + } + + private fun clock(ms: Long): String { + val totalSeconds = (ms / 1000).coerceAtLeast(0) + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + return if (hours > 0) { + "%d:%02d:%02d".format(hours, minutes, seconds) + } else { + "%02d:%02d".format(minutes, seconds) + } + } + + private companion object { + const val CHANNEL_ID = "lifeos_timers" + } +} diff --git a/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/data/FocusTimerController.kt b/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/data/FocusTimerController.kt index aeb19d4..4efdbbb 100644 --- a/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/data/FocusTimerController.kt +++ b/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/data/FocusTimerController.kt @@ -6,6 +6,10 @@ import android.os.VibrationEffect import android.os.VibratorManager import com.lifeos.core.database.adhd.FocusDao import com.lifeos.core.database.adhd.FocusSessionEntity +import com.lifeos.core.service.TimerCommand +import com.lifeos.core.service.TimerCommandBus +import com.lifeos.core.service.TimerKind +import com.lifeos.core.service.TimerNotifier import com.lifeos.feature.adhd.overlay.TimerOverlayState import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -39,6 +43,7 @@ data class FocusTimerState( class FocusTimerController @Inject constructor( @ApplicationContext private val context: Context, private val focusDao: FocusDao, + private val notifier: TimerNotifier, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -58,6 +63,16 @@ class FocusTimerController @Inject constructor( _state.value = _state.value.copy(overlayVisible = visible) } } + // The ongoing notification's own buttons. + scope.launch { + TimerCommandBus.commands.collect { (kind, command) -> + if (kind != TimerKind.FOCUS_TIMER) return@collect + when (command) { + TimerCommand.TOGGLE -> toggle() + TimerCommand.RESET -> reset() + } + } + } } fun setTotal(seconds: Int) { @@ -65,6 +80,7 @@ class FocusTimerController @Inject constructor( stopTicker() deadlineElapsed = 0L _state.value = _state.value.copy(totalSeconds = total, remainingSeconds = total, running = false) + notifier.cancel(TimerKind.FOCUS_TIMER) pushOverlay() } @@ -77,6 +93,12 @@ class FocusTimerController @Inject constructor( if (current.remainingSeconds <= 0 || current.running) return deadlineElapsed = SystemClock.elapsedRealtime() + current.remainingSeconds * 1000L _state.value = current.copy(running = true) + notifier.countdown( + TimerKind.FOCUS_TIMER, + "Focus", + current.remainingSeconds * 1000L, + running = true, + ) pushOverlay() startTicker() } @@ -84,8 +106,10 @@ class FocusTimerController @Inject constructor( fun pause() { if (!_state.value.running) return stopTicker() - _state.value = _state.value.copy(running = false, remainingSeconds = remainingFromDeadline()) + val left = remainingFromDeadline() + _state.value = _state.value.copy(running = false, remainingSeconds = left) deadlineElapsed = 0L + notifier.countdown(TimerKind.FOCUS_TIMER, "Focus", left * 1000L, running = false) pushOverlay() } @@ -97,6 +121,7 @@ class FocusTimerController @Inject constructor( deadlineElapsed = 0L _state.value = current.copy(running = false, remainingSeconds = current.totalSeconds) if (ran) recordSession(current.totalSeconds / 60, completed = false) + notifier.cancel(TimerKind.FOCUS_TIMER) pushOverlay() } @@ -130,6 +155,7 @@ class FocusTimerController @Inject constructor( deadlineElapsed = 0L _state.value = _state.value.copy(running = false, remainingSeconds = 0) recordSession(total / 60, completed = true) + notifier.finished(TimerKind.FOCUS_TIMER, "Focus block done") TimerOverlayState.hide(context) _state.value = _state.value.copy(overlayVisible = false, remainingSeconds = total) runCatching { diff --git a/feature/calendar/build.gradle.kts b/feature/calendar/build.gradle.kts index 913bacd..572ec3f 100644 --- a/feature/calendar/build.gradle.kts +++ b/feature/calendar/build.gradle.kts @@ -15,7 +15,11 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.hilt.work) + ksp(libs.androidx.hilt.compiler) implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.activity.compose) testImplementation(libs.turbine) + testImplementation(libs.junit) } diff --git a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/CalendarScreen.kt b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/CalendarScreen.kt index 2650ea3..91da2ed 100644 --- a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/CalendarScreen.kt +++ b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/CalendarScreen.kt @@ -3,12 +3,21 @@ package com.lifeos.feature.calendar import android.content.Intent import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,6 +30,7 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -32,10 +42,17 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.AssistChip import androidx.compose.material3.Button -import androidx.compose.material3.Card +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -60,28 +77,38 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.lifeos.core.database.calendar.CalendarEventEntity +import com.lifeos.core.designsystem.component.LifeMotion +import com.lifeos.feature.calendar.data.CalendarPalette +import com.lifeos.feature.calendar.data.DefaultCalendarRepository import java.text.DateFormat import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.Locale +import kotlin.math.abs private const val DAY_MS = CalendarViewModel.DAY_MS @@ -108,10 +135,10 @@ fun CalendarRoute(viewModel: CalendarViewModel = hiltViewModel()) { } /** - * Calendar (§Module 19, standalone-app cut): Month grid with event chips + - * agenda, Week and Day hour timelines with positioned event blocks, tap a slot - * to create at that hour, swipe or arrows to move between periods, Today jump, - * overflow for Proton sync / system mirror / ICS export. + * Calendar (§Module 19): coloured calendars, a month grid, week and day hour + * timelines with real pinch zoom and overlap-aware blocks, an agenda list, + * animated swipes between periods, Proton-style multi-alert reminders, + * OpenStreetMap location suggestions and live ICS subscriptions. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -133,8 +160,17 @@ internal fun CalendarScreen(uiState: CalendarUiState, onEvent: (CalendarUiEvent) Scaffold( topBar = { TopAppBar( - title = { Text(periodTitle(uiState.viewMode, uiState.anchor)) }, + title = { + // Tapping the period name opens a date picker to jump anywhere. + Text( + periodTitle(uiState.viewMode, uiState.anchor), + modifier = Modifier.clickable { onEvent(CalendarUiEvent.ToggleDatePicker) }, + ) + }, actions = { + IconButton(onClick = { onEvent(CalendarUiEvent.ToggleSearch) }) { + Icon(Icons.Filled.Search, contentDescription = "Search events") + } TextButton(onClick = { onEvent(CalendarUiEvent.Today) }) { Text("Today") } IconButton(onClick = { onEvent(CalendarUiEvent.Previous) }) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Previous") @@ -148,6 +184,14 @@ internal fun CalendarScreen(uiState: CalendarUiState, onEvent: (CalendarUiEvent) Icon(Icons.Filled.MoreVert, contentDescription = "More") } DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + DropdownMenuItem( + text = { Text("Manage calendars") }, + onClick = { onEvent(CalendarUiEvent.ToggleCalendarManager); menu = false }, + ) + DropdownMenuItem( + text = { Text("Refresh subscriptions") }, + onClick = { onEvent(CalendarUiEvent.SyncSubscriptions); menu = false }, + ) DropdownMenuItem( text = { Text("Proton sync & export") }, onClick = { onEvent(CalendarUiEvent.ToggleConnections); menu = false }, @@ -179,12 +223,7 @@ internal fun CalendarScreen(uiState: CalendarUiState, onEvent: (CalendarUiEvent) Column( modifier = Modifier .fillMaxSize() - .padding(innerPadding) - // Horizontal swipe anywhere moves the period, like a real calendar app. - .pointerInputSwipe( - onSwipeLeft = { onEvent(CalendarUiEvent.Next) }, - onSwipeRight = { onEvent(CalendarUiEvent.Previous) }, - ), + .padding(innerPadding), ) { SingleChoiceSegmentedButtonRow( modifier = Modifier @@ -197,44 +236,181 @@ internal fun CalendarScreen(uiState: CalendarUiState, onEvent: (CalendarUiEvent) onClick = { onEvent(CalendarUiEvent.SetViewMode(mode)) }, shape = SegmentedButtonDefaults.itemShape(index, CalendarViewMode.entries.size), ) { - Text(mode.name.lowercase().replaceFirstChar { it.uppercase() }) + Text(mode.name.lowercase().replaceFirstChar { it.uppercase() }, maxLines = 1) } } } - when (uiState.viewMode) { - CalendarViewMode.MONTH -> MonthView(uiState, onEvent) - CalendarViewMode.WEEK -> WeekView(uiState, onEvent) - CalendarViewMode.DAY -> DayView(uiState, onEvent) + if (uiState.calendars.size > 1) CalendarLegend(uiState, onEvent) + + // Swiping and the arrows both land here, and the period slides in from + // the side it came from. + AnimatedContent( + targetState = uiState.anchor, + transitionSpec = { + val forward = uiState.direction >= 0 + val enter = slideInHorizontally( + animationSpec = tween(LifeMotion.ENTER_MS), + initialOffsetX = { width -> if (forward) width else -width }, + ) + fadeIn(tween(LifeMotion.ENTER_MS)) + val exit = slideOutHorizontally( + animationSpec = tween(LifeMotion.EXIT_MS), + targetOffsetX = { width -> if (forward) -width else width }, + ) + fadeOut(tween(LifeMotion.EXIT_MS)) + enter togetherWith exit + }, + label = "calendar-period", + modifier = Modifier + .fillMaxSize() + .periodSwipe( + onSwipeLeft = { onEvent(CalendarUiEvent.Next) }, + onSwipeRight = { onEvent(CalendarUiEvent.Previous) }, + ), + ) { anchor -> + val page = uiState.copy(anchor = anchor) + when (uiState.viewMode) { + CalendarViewMode.MONTH -> MonthView(page, onEvent) + CalendarViewMode.WEEK -> WeekView(page, onEvent) + CalendarViewMode.DAY -> DayView(page, onEvent) + CalendarViewMode.AGENDA -> AgendaView(page, onEvent) + } } } } if (uiState.showEditor) EventEditorSheet(uiState, onEvent) if (uiState.showConnections) ConnectionsSheet(uiState, onEvent) + if (uiState.showCalendarManager) CalendarManagerSheet(uiState, onEvent) + if (uiState.showSearch) SearchSheet(uiState, onEvent) + if (uiState.showDatePicker) { + val pickerState = rememberDatePickerState(initialSelectedDateMillis = uiState.selectedDay) + DatePickerDialog( + onDismissRequest = { onEvent(CalendarUiEvent.ToggleDatePicker) }, + confirmButton = { + TextButton( + onClick = { + pickerState.selectedDateMillis?.let { + onEvent(CalendarUiEvent.JumpToDate(it)) + } ?: onEvent(CalendarUiEvent.ToggleDatePicker) + }, + ) { Text("Jump") } + }, + dismissButton = { + TextButton(onClick = { onEvent(CalendarUiEvent.ToggleDatePicker) }) { Text("Cancel") } + }, + ) { + DatePicker(state = pickerState) + } + } +} + +/** Colour key across the top; tapping one hides or shows that calendar. */ +@Composable +private fun CalendarLegend(uiState: CalendarUiState, onEvent: (CalendarUiEvent) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + uiState.calendars.forEach { calendar -> + val colour = Color(calendar.colorArgb) + Surface( + onClick = { onEvent(CalendarUiEvent.ToggleCalendarVisible(calendar.id)) }, + shape = RoundedCornerShape(50), + color = if (calendar.visible) colour.copy(alpha = 0.20f) else Color.Transparent, + border = BorderStroke(1.dp, colour.copy(alpha = 0.6f)), + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Box(modifier = Modifier.size(8.dp).background(colour, CircleShape)) + Text( + calendar.name, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + color = if (calendar.visible) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + } } -/** Simple horizontal fling detection without a pager. */ -private fun Modifier.pointerInputSwipe(onSwipeLeft: () -> Unit, onSwipeRight: () -> Unit): Modifier = +/** + * Horizontal fling that moves the period. Only fires on a gesture that is + * clearly sideways, so the timeline's vertical scroll and the pinch zoom keep + * working underneath it. + */ +private fun Modifier.periodSwipe(onSwipeLeft: () -> Unit, onSwipeRight: () -> Unit): Modifier = this.then( Modifier.pointerInput(Unit) { var total = 0f + val tracker = VelocityTracker() detectHorizontalDragGestures( - onDragStart = { total = 0f }, + onDragStart = { total = 0f; tracker.resetTracking() }, onHorizontalDrag = { change, amount -> change.consume() total += amount + tracker.addPosition(change.uptimeMillis, change.position) }, onDragEnd = { - when { - total < -120f -> onSwipeLeft() - total > 120f -> onSwipeRight() + val velocity = tracker.calculateVelocity().x + val moved = abs(total) > 110f || abs(velocity) > 700f + if (moved) { + if (total < 0) onSwipeLeft() else onSwipeRight() } }, ) }, ) +/** + * Pinch-to-zoom that actually fires inside a scrollable timeline. + * + * The earlier `detectTransformGestures` never worked here for two reasons: the + * vertical scroll and the day columns' own tap/long-press handlers consumed the + * pointers first, and the gesture closure captured the hour height from its first + * composition so every pinch scaled the same stale value. This watches the + * Initial pass — before children see anything — only claims the gesture once a + * second finger is down, and reads the live height through a state holder. + */ +@Composable +private fun Modifier.pinchZoom(currentHeightDp: Float, onHeight: (Float) -> Unit): Modifier { + val height = rememberUpdatedState(currentHeightDp) + val sink = rememberUpdatedState(onHeight) + return this.then( + Modifier.pointerInput(Unit) { + awaitPointerEventScope { + var lastDistance = 0f + while (true) { + val pointerEvent = awaitPointerEvent(PointerEventPass.Initial) + val down = pointerEvent.changes.filter { it.pressed } + if (down.size >= 2) { + val distance = (down[0].position - down[1].position).getDistance() + if (lastDistance > 1f && distance > 1f) { + val factor = distance / lastDistance + sink.value((height.value * factor).coerceIn(24f, 260f)) + } + lastDistance = distance + // Claim it, so neither the scroll nor the columns react. + pointerEvent.changes.forEach { it.consume() } + } else { + lastDistance = 0f + } + } + } + }, + ) +} + // -------------------------------------------------------------------- month -- @Composable @@ -305,19 +481,17 @@ private fun MonthView(uiState: CalendarUiState, onEvent: (CalendarUiEvent) -> Un ) } dayEvents.take(2).forEach { event -> + val colour = Color(uiState.colorOf(event)) Text( event.title, maxLines = 1, overflow = TextOverflow.Clip, style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, + color = onColour(colour), modifier = Modifier .fillMaxWidth() .padding(top = 1.dp) - .background( - MaterialTheme.colorScheme.secondaryContainer, - RoundedCornerShape(4.dp), - ) + .background(colour.copy(alpha = 0.85f), RoundedCornerShape(4.dp)) .padding(horizontal = 3.dp), ) } @@ -347,7 +521,47 @@ private fun MonthView(uiState: CalendarUiState, onEvent: (CalendarUiEvent) -> Un ) } } - items(selectedEvents, key = { it.id }) { event -> EventRow(event, onEvent) } + items(selectedEvents, key = { it.id }) { event -> EventRow(event, uiState, onEvent) } + } + } +} + +// ------------------------------------------------------------------- agenda -- + +/** A flat, scrollable "what is coming" list — the fastest way to read a month. */ +@Composable +private fun AgendaView(uiState: CalendarUiState, onEvent: (CalendarUiEvent) -> Unit) { + val from = uiState.anchor + val to = from + 62L * DAY_MS + val grouped = remember(uiState.events, from) { + uiState.events + .filter { it.startsAt in from..to } + .sortedBy { it.startsAt } + .groupBy { CalendarViewModel.startOfDay(it.startsAt) } + .toSortedMap() + } + if (grouped.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + "Nothing scheduled from ${DAY_TITLE.format(Date(from))}.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return + } + LazyColumn(modifier = Modifier.fillMaxSize()) { + grouped.forEach { (day, events) -> + item(key = "head-$day") { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) { + Text( + DAY_FULL.format(Date(day)), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) + } + } + items(events, key = { it.id }) { event -> EventRow(event, uiState, onEvent) } } } } @@ -357,6 +571,7 @@ private fun MonthView(uiState: CalendarUiState, onEvent: (CalendarUiEvent) -> Un @Composable private fun WeekView(uiState: CalendarUiState, onEvent: (CalendarUiEvent) -> Unit) { val days = (0 until 7).map { uiState.anchor + it * DAY_MS } + val today = CalendarViewModel.startOfDay(System.currentTimeMillis()) Column(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxWidth().padding(start = 44.dp, end = 8.dp)) { days.forEach { day -> @@ -375,83 +590,98 @@ private fun WeekView(uiState: CalendarUiState, onEvent: (CalendarUiEvent) -> Uni Text( DAY_NUM.format(Date(day)), style = MaterialTheme.typography.titleSmall, - color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + color = when { + day == today -> MaterialTheme.colorScheme.primary + isSelected -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.onSurface + }, ) } } } - Timeline( - days = days, - events = uiState.events, - hourHeight = uiState.hourHeightDp.dp, - minutesPerStep = uiState.minutesPerStep, - onEvent = onEvent, - ) + AllDayStrip(days, uiState, onEvent) + Timeline(days = days, uiState = uiState, onEvent = onEvent) } } @Composable private fun DayView(uiState: CalendarUiState, onEvent: (CalendarUiEvent) -> Unit) { - val allDay = uiState.events.filter { it.allDay && CalendarViewModel.startOfDay(it.startsAt) == uiState.anchor } Column(modifier = Modifier.fillMaxSize()) { + AllDayStrip(listOf(uiState.anchor), uiState, onEvent) + Timeline(days = listOf(uiState.anchor), uiState = uiState, onEvent = onEvent) + } +} + +/** All-day events sit above the hour grid, where they cannot distort it. */ +@Composable +private fun AllDayStrip( + days: List, + uiState: CalendarUiState, + onEvent: (CalendarUiEvent) -> Unit, +) { + val allDay = uiState.events.filter { event -> + event.allDay && days.any { CalendarViewModel.startOfDay(event.startsAt) == it } + } + if (allDay.isEmpty()) return + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp)) { allDay.forEach { event -> + val colour = Color(uiState.colorOf(event)) Surface( onClick = { onEvent(CalendarUiEvent.EditEvent(event)) }, - color = MaterialTheme.colorScheme.secondaryContainer, + color = colour.copy(alpha = 0.85f), shape = RoundedCornerShape(8.dp), - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 2.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp), ) { Text( "All day · ${event.title}", - modifier = Modifier.padding(8.dp), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 5.dp), style = MaterialTheme.typography.bodySmall, + color = onColour(colour), ) } } - Timeline( - days = listOf(uiState.anchor), - events = uiState.events, - hourHeight = uiState.hourHeightDp.dp, - minutesPerStep = uiState.minutesPerStep, - onEvent = onEvent, - ) } } /** - * Shared hour grid. Pinch anywhere to zoom: the hour height scales and the - * ladder snaps through 60/30/15/10/5-minute steps, down to a compressed - * overview where a whole day fits on screen. Long-press then drag inside a day - * column sweeps out a time range (Google-Calendar style) and opens the editor - * pre-filled with those exact times. + * Shared hour grid for Week and Day. + * + * Pinch anywhere to zoom: the hour height scales and the ladder snaps through + * 60/30/15/10/5-minute steps, down to a compressed overview where a whole day + * fits. Long-press then drag inside a column sweeps out a range and opens the + * editor pre-filled. Overlapping events share the column width instead of hiding + * each other, and the current time is a live red line. */ @Composable private fun Timeline( days: List, - events: List, - hourHeight: androidx.compose.ui.unit.Dp, - minutesPerStep: Int, + uiState: CalendarUiState, onEvent: (CalendarUiEvent) -> Unit, ) { val density = LocalDensity.current - val scroll = rememberScrollState(initial = with(density) { (hourHeight * 7).roundToPx() }) + val hourHeight = uiState.hourHeightDp.dp + val minutesPerStep = uiState.minutesPerStep val now = System.currentTimeMillis() val hourHeightPx = with(density) { hourHeight.toPx() } - // Pinch zoom: vertical scale drives hour height, which in turn picks the - // ladder granularity — coarse when compressed, fine when stretched. - val zoomModifier = Modifier.pointerInput(Unit) { - detectTransformGestures { _, _, zoom, _ -> - if (zoom == 1f) return@detectTransformGestures - val next = (hourHeight.value * zoom).coerceIn(24f, 220f) - onEvent(CalendarUiEvent.SetZoom(stepForHeight(next), next)) - } + // Open on the current hour when today is in view, on the working day otherwise. + val focusMinute = remember(days.first()) { + if (days.any { CalendarViewModel.startOfDay(now) == it }) { + ((now - CalendarViewModel.startOfDay(now)) / 60_000L).toInt() - 90 + } else { + 7 * 60 + }.coerceAtLeast(0) } + val scroll = rememberScrollState( + initial = with(density) { (hourHeight * (focusMinute / 60f)).roundToPx() }, + ) Row( modifier = Modifier .fillMaxSize() - .then(zoomModifier) + .pinchZoom(uiState.hourHeightDp) { next -> + onEvent(CalendarUiEvent.SetZoom(stepForHeight(next), next)) + } .verticalScroll(scroll), ) { Column(modifier = Modifier.width(44.dp)) { @@ -466,9 +696,10 @@ private fun Timeline( } } days.forEach { day -> - val dayEvents = events.filter { + val dayEvents = uiState.events.filter { !it.allDay && it.startsAt < day + DAY_MS && it.endsAt > day } + val laid = remember(dayEvents, day) { layOut(dayEvents, day) } // Live drag selection for this column, in minutes from midnight. var dragFrom by remember(day) { mutableStateOf(null) } var dragTo by remember(day) { mutableStateOf(null) } @@ -480,8 +711,14 @@ private fun Timeline( .padding(horizontal = 1.dp) .pointerInput(day, minutesPerStep, hourHeightPx) { detectTapGestures { offset -> - val hour = (offset.y / hourHeightPx).toInt().coerceIn(0, 23) - onEvent(CalendarUiEvent.NewEventAt(day, hour)) + val minute = snapMinutes(offset.y / hourHeightPx * 60f, minutesPerStep) + onEvent( + CalendarUiEvent.NewEventForRange( + dayStart = day, + startMinuteOfDay = minute.coerceIn(0, 24 * 60 - minutesPerStep), + durationMinutes = maxOf(minutesPerStep, 30), + ), + ) } } .pointerInput(day, minutesPerStep, hourHeightPx) { @@ -546,18 +783,65 @@ private fun Timeline( ) } } - dayEvents.forEach { event -> + laid.forEach { placed -> + val event = placed.event val startMin = ((maxOf(event.startsAt, day) - day) / 60_000L).toInt() val endMin = ((minOf(event.endsAt, day + DAY_MS) - day) / 60_000L).toInt() - val height = ((endMin - startMin).coerceAtLeast(minutesPerStep) / 60f) + val heightHours = ((endMin - startMin).coerceAtLeast(minutesPerStep) / 60f) + val colour = if (event.id < 0) { + MaterialTheme.colorScheme.tertiary + } else { + Color(uiState.colorOf(event)) + } + EventBlock( + event = event, + colour = colour, + lane = placed.lane, + lanes = placed.lanes, + offsetY = hourHeight * (startMin / 60f), + blockHeight = hourHeight * heightHours, + onEvent = onEvent, + ) + } + if (CalendarViewModel.startOfDay(now) == day) { + val nowMin = ((now - day) / 60_000L).toInt() + HorizontalDivider( + modifier = Modifier.offset(y = hourHeight * (nowMin / 60f)), + thickness = 2.dp, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +/** + * One block in the hour grid. [lane] of [lanes] splits the column so clashing + * events sit side by side, the way every real calendar draws them. + */ +@Composable +private fun EventBlock( + event: CalendarEventEntity, + colour: Color, + lane: Int, + lanes: Int, + offsetY: Dp, + blockHeight: Dp, + onEvent: (CalendarUiEvent) -> Unit, +) { + Box(modifier = Modifier.fillMaxWidth().offset(y = offsetY).height(blockHeight)) { + Row(modifier = Modifier.fillMaxSize()) { + repeat(lanes) { index -> + if (index == lane) { Surface( onClick = { onEvent(CalendarUiEvent.EditEvent(event)) }, - color = MaterialTheme.colorScheme.primaryContainer, + color = colour.copy(alpha = 0.88f), shape = RoundedCornerShape(6.dp), modifier = Modifier - .fillMaxWidth() - .offset(y = hourHeight * (startMin / 60f)) - .height(hourHeight * height), + .weight(1f) + .fillMaxHeight() + .padding(end = 1.dp), ) { Column(modifier = Modifier.padding(4.dp)) { Text( @@ -565,32 +849,78 @@ private fun Timeline( style = MaterialTheme.typography.labelSmall, maxLines = 2, overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onPrimaryContainer, + color = onColour(colour), ) // A squeezed block has no room for a second line. - if (hourHeight * height > 34.dp) { + if (blockHeight > 34.dp) { Text( TIME.format(Date(event.startsAt)), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f), + color = onColour(colour).copy(alpha = 0.75f), ) } } } - } - if (CalendarViewModel.startOfDay(now) == day) { - val nowMin = ((now - day) / 60_000L).toInt() - HorizontalDivider( - modifier = Modifier.offset(y = hourHeight * (nowMin / 60f)), - thickness = 2.dp, - color = MaterialTheme.colorScheme.error, - ) + } else { + Box(modifier = Modifier.weight(1f).fillMaxHeight()) } } } } } +/** An event plus the lane it was given among the ones it overlaps. */ +private data class PlacedEvent(val event: CalendarEventEntity, val lane: Int, val lanes: Int) + +/** + * Greedy interval colouring: events are walked in start order and dropped into + * the first lane whose last event has already finished. Every member of a + * clashing cluster is then told how many lanes the cluster needed, so they all + * shrink by the same amount. + */ +private fun layOut(events: List, day: Long): List { + if (events.isEmpty()) return emptyList() + val sorted = events.sortedWith(compareBy({ it.startsAt }, { -(it.endsAt - it.startsAt) })) + val laneEnds = mutableListOf() + val assigned = mutableListOf>() + // Clusters are maximal runs of events that transitively overlap. + val clusterOf = mutableListOf() + var clusterIndex = 0 + var clusterEnd = Long.MIN_VALUE + + sorted.forEach { event -> + val start = maxOf(event.startsAt, day) + val end = maxOf(minOf(event.endsAt, day + DAY_MS), start + 60_000L) + if (start >= clusterEnd) { + clusterIndex++ + laneEnds.clear() + clusterEnd = end + } else { + clusterEnd = maxOf(clusterEnd, end) + } + val lane = laneEnds.indexOfFirst { it <= start }.let { found -> + if (found >= 0) { + laneEnds[found] = end + found + } else { + laneEnds += end + laneEnds.lastIndex + } + } + assigned += event to lane + clusterOf += clusterIndex + } + + val lanesPerCluster = mutableMapOf() + assigned.forEachIndexed { index, (_, lane) -> + val cluster = clusterOf[index] + lanesPerCluster[cluster] = maxOf(lanesPerCluster[cluster] ?: 1, lane + 1) + } + return assigned.mapIndexed { index, (event, lane) -> + PlacedEvent(event, lane, lanesPerCluster[clusterOf[index]] ?: 1) + } +} + /** Rounds a minute-of-day to the visible ladder step. */ private fun snapMinutes(minutes: Float, step: Int): Int = ((minutes / step).toInt() * step).coerceIn(0, 24 * 60) @@ -607,14 +937,29 @@ private fun stepForHeight(hourHeightDp: Float): Int = when { private fun formatMinuteOfDay(minute: Int): String = "%02d:%02d".format((minute / 60).coerceAtMost(23), minute % 60) +/** Readable text on an arbitrary calendar colour. */ +private fun onColour(colour: Color): Color { + val luminance = 0.299f * colour.red + 0.587f * colour.green + 0.114f * colour.blue + return if (luminance > 0.6f) Color(0xFF10151C) else Color.White +} + // ------------------------------------------------------------------- shared -- @Composable -private fun EventRow(event: CalendarEventEntity, onEvent: (CalendarUiEvent) -> Unit) { +private fun EventRow( + event: CalendarEventEntity, + uiState: CalendarUiState, + onEvent: (CalendarUiEvent) -> Unit, +) { val timeFormat = DateFormat.getTimeInstance(DateFormat.SHORT) + val isTask = event.id <= 0 + val colour = if (isTask) MaterialTheme.colorScheme.tertiary else Color(uiState.colorOf(event)) ListItem( - modifier = Modifier.clickable(enabled = event.id > 0) { onEvent(CalendarUiEvent.EditEvent(event)) }, - headlineContent = { Text(event.title) }, + modifier = Modifier.clickable(enabled = !isTask) { onEvent(CalendarUiEvent.EditEvent(event)) }, + leadingContent = { + Box(modifier = Modifier.size(10.dp).background(colour, CircleShape)) + }, + headlineContent = { Text(if (isTask) "To-do · ${event.title}" else event.title) }, supportingContent = { Text( buildString { @@ -625,16 +970,29 @@ private fun EventRow(event: CalendarEventEntity, onEvent: (CalendarUiEvent) -> U append(" – ") append(timeFormat.format(Date(event.endsAt))) } - event.location?.let { append(" · $it") } - if (event.reminderId != null) append(" · reminder set") + event.location?.takeIf { it.isNotBlank() }?.let { append(" · $it") } + uiState.calendarName(event.calendarId)?.let { append(" · $it") } + val alerts = DefaultCalendarRepository.decodeReminders(event.reminderMinutes) + if (alerts.isNotEmpty()) { + append( + " · " + alerts.joinToString("/") { + DefaultCalendarRepository.humanOffset(it) + } + " before", + ) + } }, ) }, trailingContent = { // Negative id = a to-do surfaced from Tasks; edit it there, not here. - if (event.id > 0) { - IconButton(onClick = { onEvent(CalendarUiEvent.Delete(event.id)) }) { - Icon(Icons.Filled.Delete, contentDescription = "Delete") + if (!isTask) { + Row { + IconButton(onClick = { onEvent(CalendarUiEvent.Duplicate(event.id)) }) { + Icon(Icons.Filled.ContentCopy, contentDescription = "Duplicate") + } + IconButton(onClick = { onEvent(CalendarUiEvent.Delete(event.id)) }) { + Icon(Icons.Filled.Delete, contentDescription = "Delete") + } } } }, @@ -652,90 +1010,440 @@ private fun EventEditorSheet(uiState: CalendarUiState, onEvent: (CalendarUiEvent onDismissRequest = { onEvent(CalendarUiEvent.ToggleEditor) }, sheetState = sheetState, ) { - Column( + LazyColumn( modifier = Modifier .padding(horizontal = 24.dp) .navigationBarsPadding() - .imePadding() - .padding(bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + .imePadding(), ) { - Text( - if (uiState.editingEventId != null) "Edit event" else - "New event · ${DAY_TITLE.format(Date(uiState.selectedDay))}", - style = MaterialTheme.typography.titleLarge, - ) - OutlinedTextField( - value = uiState.editorTitle, - onValueChange = { onEvent(CalendarUiEvent.EditorTitleChanged(it)) }, - label = { Text("Title") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = uiState.editorLocation, - onValueChange = { onEvent(CalendarUiEvent.EditorLocationChanged(it)) }, - label = { Text("Location (feeds leave-by alerts)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = uiState.editorNotes, - onValueChange = { onEvent(CalendarUiEvent.EditorNotesChanged(it)) }, - label = { Text("Notes") }, - modifier = Modifier.fillMaxWidth(), - ) - if (!uiState.editorAllDay) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + item { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + if (uiState.editingEventId != null) { + "Edit event" + } else { + "New event · ${DAY_TITLE.format(Date(uiState.selectedDay))}" + }, + style = MaterialTheme.typography.titleLarge, + ) OutlinedTextField( - value = uiState.editorHour, - onValueChange = { onEvent(CalendarUiEvent.EditorHourChanged(it)) }, - label = { Text("Hour") }, + value = uiState.editorTitle, + onValueChange = { onEvent(CalendarUiEvent.EditorTitleChanged(it)) }, + label = { Text("Title") }, singleLine = true, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxWidth(), ) + + // Which calendar it lands in, colour and all. + if (uiState.calendars.any { it.subscriptionUrl == null }) { + Text("Calendar", style = MaterialTheme.typography.labelLarge) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + uiState.calendars.filter { it.subscriptionUrl == null }.forEach { calendar -> + val colour = Color(calendar.colorArgb) + FilterChip( + selected = uiState.editorCalendarId == calendar.id, + onClick = { onEvent(CalendarUiEvent.EditorCalendarPicked(calendar.id)) }, + leadingIcon = { + Box(modifier = Modifier.size(10.dp).background(colour, CircleShape)) + }, + label = { Text(calendar.name, maxLines = 1) }, + ) + } + } + } + OutlinedTextField( - value = uiState.editorMinute, - onValueChange = { onEvent(CalendarUiEvent.EditorMinuteChanged(it)) }, - label = { Text("Min") }, + value = uiState.editorLocation, + onValueChange = { onEvent(CalendarUiEvent.EditorLocationChanged(it)) }, + label = { Text("Location") }, + supportingText = { + Text( + if (uiState.searchingPlaces) { + "Searching OpenStreetMap…" + } else { + "Type three letters for suggestions" + }, + ) + }, singleLine = true, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxWidth(), ) + uiState.locationSuggestions.forEach { suggestion -> + Surface( + onClick = { onEvent(CalendarUiEvent.EditorLocationPicked(suggestion)) }, + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + suggestion.label, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(8.dp), + ) + } + } + OutlinedTextField( - value = uiState.editorDurationMinutes, - onValueChange = { onEvent(CalendarUiEvent.EditorDurationChanged(it)) }, - label = { Text("Duration (min)") }, - singleLine = true, - modifier = Modifier.weight(1.2f), + value = uiState.editorNotes, + onValueChange = { onEvent(CalendarUiEvent.EditorNotesChanged(it)) }, + label = { Text("Notes") }, + modifier = Modifier.fillMaxWidth(), ) + if (!uiState.editorAllDay) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = uiState.editorHour, + onValueChange = { onEvent(CalendarUiEvent.EditorHourChanged(it)) }, + label = { Text("Hour") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = uiState.editorMinute, + onValueChange = { onEvent(CalendarUiEvent.EditorMinuteChanged(it)) }, + label = { Text("Min") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = uiState.editorDurationMinutes, + onValueChange = { onEvent(CalendarUiEvent.EditorDurationChanged(it)) }, + label = { Text("Length (min)") }, + singleLine = true, + modifier = Modifier.weight(1.2f), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + CalendarViewModel.DURATION_PRESETS.forEach { minutes -> + AssistChip( + onClick = { onEvent(CalendarUiEvent.EditorDurationPreset(minutes)) }, + label = { Text(DefaultCalendarRepository.humanOffset(minutes)) }, + ) + } + } + } + + FilterChip( + selected = uiState.editorAllDay, + onClick = { onEvent(CalendarUiEvent.EditorAllDayToggled) }, + label = { Text("All day") }, + ) + + // Proton-style stack of alerts rather than one fixed 30 minutes. + Text("Alerts before it starts", style = MaterialTheme.typography.labelLarge) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + CalendarViewModel.REMINDER_PRESETS.take(5).forEach { minutes -> + ReminderChip(minutes, uiState, onEvent) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + CalendarViewModel.REMINDER_PRESETS.drop(5).forEach { minutes -> + ReminderChip(minutes, uiState, onEvent) + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = uiState.editorCustomReminder, + onValueChange = { onEvent(CalendarUiEvent.EditorCustomReminderChanged(it)) }, + label = { Text("Custom (min)") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + OutlinedButton( + onClick = { onEvent(CalendarUiEvent.EditorCustomReminderAdded) }, + enabled = uiState.editorCustomReminder.isNotBlank(), + ) { Text("Add") } + } + if (uiState.editorReminders.isNotEmpty()) { + Text( + "Alerting " + uiState.editorReminders.joinToString(", ") { + if (it == 0) "at start" else "${DefaultCalendarRepository.humanOffset(it)} before" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { onEvent(CalendarUiEvent.Save) }, + enabled = uiState.editorTitle.isNotBlank(), + modifier = Modifier.weight(1f), + ) { + Text(if (uiState.editingEventId != null) "Save changes" else "Create") + } + uiState.editingEventId?.let { id -> + OutlinedButton(onClick = { onEvent(CalendarUiEvent.Duplicate(id)) }) { + Text("Copy") + } + OutlinedButton(onClick = { onEvent(CalendarUiEvent.Delete(id)) }) { + Text("Delete") + } + } + } + Box(modifier = Modifier.height(24.dp)) } } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - FilterChip( - selected = uiState.editorAllDay, - onClick = { onEvent(CalendarUiEvent.EditorAllDayToggled) }, - label = { Text("All day") }, + } + } +} + +@Composable +private fun ReminderChip(minutes: Int, uiState: CalendarUiState, onEvent: (CalendarUiEvent) -> Unit) { + FilterChip( + selected = minutes in uiState.editorReminders, + onClick = { onEvent(CalendarUiEvent.EditorReminderToggled(minutes)) }, + label = { + Text(if (minutes == 0) "At start" else DefaultCalendarRepository.humanOffset(minutes)) + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CalendarManagerSheet(uiState: CalendarUiState, onEvent: (CalendarUiEvent) -> Unit) { + ModalBottomSheet(onDismissRequest = { onEvent(CalendarUiEvent.ToggleCalendarManager) }) { + LazyColumn( + modifier = Modifier + .padding(horizontal = 24.dp) + .navigationBarsPadding() + .imePadding(), + ) { + item { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Calendars", style = MaterialTheme.typography.titleLarge) + if (uiState.calendars.isEmpty()) { + Text( + "No calendars yet — the first one is created as soon as you save an event.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + items(uiState.calendars, key = { it.id }) { calendar -> + val colour = Color(calendar.colorArgb) + ListItem( + modifier = Modifier.clickable { onEvent(CalendarUiEvent.EditCalendar(calendar.id)) }, + leadingContent = { + Box(modifier = Modifier.size(18.dp).background(colour, CircleShape)) + }, + headlineContent = { Text(calendar.name) }, + supportingContent = { + Text( + buildString { + append(CalendarPalette.nameOf(calendar.colorArgb)) + if (calendar.isDefault) append(" · default") + if (calendar.subscriptionUrl != null) append(" · subscription") + calendar.lastSyncedAt?.let { + append(" · synced ${STAMP.format(Date(it))}") + } + }, + ) + }, + trailingContent = { + Row { + IconButton( + onClick = { onEvent(CalendarUiEvent.ToggleCalendarVisible(calendar.id)) }, + ) { + Icon( + if (calendar.visible) { + Icons.Filled.Visibility + } else { + Icons.Filled.VisibilityOff + }, + contentDescription = "Show or hide", + ) + } + if (!calendar.isDefault && calendar.subscriptionUrl == null) { + IconButton( + onClick = { onEvent(CalendarUiEvent.SetDefaultCalendar(calendar.id)) }, + ) { + Icon(Icons.Filled.Check, contentDescription = "Make default") + } + } + IconButton( + onClick = { + onEvent( + CalendarUiEvent.DeleteCalendar( + calendar.id, + // A subscription's events are only mirrors, so they go too. + deleteEvents = calendar.subscriptionUrl != null, + ), + ) + }, + ) { + Icon(Icons.Filled.Delete, contentDescription = "Delete") + } + } + }, ) - if (uiState.editingEventId == null) { - FilterChip( - selected = uiState.editorRemind, - onClick = { onEvent(CalendarUiEvent.EditorRemindToggled) }, - label = { Text("Remind 30 min before") }, + } + item { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + HorizontalDivider() + Text( + if (uiState.editingCalendarId != null) "Edit calendar" else "New calendar", + style = MaterialTheme.typography.titleMedium, ) + OutlinedTextField( + value = uiState.calendarNameDraft, + onValueChange = { onEvent(CalendarUiEvent.CalendarNameDraftChanged(it)) }, + label = { Text("Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Text("Colour", style = MaterialTheme.typography.labelLarge) + // Swatches first, then a hex box for anything else. + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + CalendarPalette.named.chunked(6).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + row.forEach { (_, argb) -> + val selected = uiState.calendarColorDraft == argb + Box( + modifier = Modifier + .size(34.dp) + .background(Color(argb), CircleShape) + .border( + width = if (selected) 3.dp else 0.dp, + color = MaterialTheme.colorScheme.onSurface, + shape = CircleShape, + ) + .clickable { + onEvent(CalendarUiEvent.CalendarColorDraftChanged(argb)) + }, + ) + } + } + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = uiState.calendarHexDraft, + onValueChange = { onEvent(CalendarUiEvent.CalendarHexDraftChanged(it)) }, + label = { Text("Custom hex") }, + placeholder = { Text("#8B5CF6") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + Box( + modifier = Modifier + .size(34.dp) + .background(Color(uiState.calendarColorDraft), CircleShape), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { onEvent(CalendarUiEvent.SaveCalendarDraft) }, + enabled = uiState.calendarNameDraft.isNotBlank(), + modifier = Modifier.weight(1f), + ) { + Text(if (uiState.editingCalendarId != null) "Save" else "Create") + } + if (uiState.editingCalendarId != null) { + OutlinedButton(onClick = { onEvent(CalendarUiEvent.ClearCalendarDraft) }) { + Text("Cancel") + } + } + } + + HorizontalDivider() + Text("Subscribe to a calendar", style = MaterialTheme.typography.titleMedium) + Text( + "Any ICS or webcal link — public holidays, a shared work calendar, a fixture " + + "list. It becomes a read-only coloured calendar and refreshes itself every " + + "six hours and on open.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = uiState.subscriptionNameDraft, + onValueChange = { onEvent(CalendarUiEvent.SubscriptionNameChanged(it)) }, + label = { Text("Name") }, + placeholder = { Text("German holidays") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = uiState.subscriptionUrlDraft, + onValueChange = { onEvent(CalendarUiEvent.SubscriptionUrlChanged(it)) }, + label = { Text("ICS link") }, + placeholder = { Text("https://…/holidays.ics") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + if (uiState.syncing) LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { onEvent(CalendarUiEvent.Subscribe) }, + enabled = uiState.subscriptionUrlDraft.isNotBlank() && !uiState.syncing, + modifier = Modifier.weight(1f), + ) { Text("Subscribe") } + OutlinedButton( + onClick = { onEvent(CalendarUiEvent.SyncSubscriptions) }, + enabled = !uiState.syncing, + ) { Text("Refresh all") } + } + Box(modifier = Modifier.height(24.dp)) } } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = { onEvent(CalendarUiEvent.Save) }, - enabled = uiState.editorTitle.isNotBlank(), - modifier = Modifier.weight(1f), - ) { - Text(if (uiState.editingEventId != null) "Save changes" else "Create") - } - uiState.editingEventId?.let { id -> - OutlinedButton(onClick = { onEvent(CalendarUiEvent.Delete(id)) }) { Text("Delete") } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchSheet(uiState: CalendarUiState, onEvent: (CalendarUiEvent) -> Unit) { + ModalBottomSheet(onDismissRequest = { onEvent(CalendarUiEvent.ToggleSearch) }) { + Column( + modifier = Modifier + .padding(horizontal = 24.dp) + .navigationBarsPadding() + .imePadding(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Find an event", style = MaterialTheme.typography.titleLarge) + OutlinedTextField( + value = uiState.searchQuery, + onValueChange = { onEvent(CalendarUiEvent.SearchQueryChanged(it)) }, + label = { Text("Title, place or notes") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + if (uiState.searchQuery.isNotBlank() && uiState.searchResults.isEmpty()) { + Text( + "Nothing matches.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + LazyColumn(modifier = Modifier.fillMaxWidth().height(360.dp)) { + items(uiState.searchResults, key = { it.id }) { event -> + ListItem( + modifier = Modifier.clickable { onEvent(CalendarUiEvent.EditEvent(event)) }, + leadingContent = { + Box( + modifier = Modifier + .size(10.dp) + .background(Color(uiState.colorOf(event)), CircleShape), + ) + }, + headlineContent = { Text(event.title) }, + supportingContent = { + Text("${DAY_FULL.format(Date(event.startsAt))} · ${TIME.format(Date(event.startsAt))}") + }, + ) } } + Box(modifier = Modifier.height(16.dp)) } } } @@ -785,7 +1493,7 @@ private fun ConnectionsSheet(uiState: CalendarUiState, onEvent: (CalendarUiEvent } private fun periodTitle(mode: CalendarViewMode, anchor: Long): String = when (mode) { - CalendarViewMode.MONTH -> MONTH_TITLE.format(Date(anchor)) + CalendarViewMode.MONTH, CalendarViewMode.AGENDA -> MONTH_TITLE.format(Date(anchor)) CalendarViewMode.WEEK -> { val end = anchor + 6 * DAY_MS "${DAY_SHORT.format(Date(anchor))} – ${DAY_SHORT.format(Date(end))}" @@ -795,7 +1503,9 @@ private fun periodTitle(mode: CalendarViewMode, anchor: Long): String = when (mo private val MONTH_TITLE = SimpleDateFormat("MMMM yyyy", Locale.getDefault()) private val DAY_TITLE = SimpleDateFormat("EEE d MMM", Locale.getDefault()) +private val DAY_FULL = SimpleDateFormat("EEEE d MMMM", Locale.getDefault()) private val DAY_SHORT = SimpleDateFormat("d MMM", Locale.getDefault()) private val WEEKDAY = SimpleDateFormat("EEE", Locale.getDefault()) private val DAY_NUM = SimpleDateFormat("d", Locale.getDefault()) private val TIME = SimpleDateFormat("HH:mm", Locale.getDefault()) +private val STAMP = SimpleDateFormat("d MMM HH:mm", Locale.getDefault()) diff --git a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/CalendarViewModel.kt b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/CalendarViewModel.kt index 6d5f59a..5997e54 100644 --- a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/CalendarViewModel.kt +++ b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/CalendarViewModel.kt @@ -4,27 +4,40 @@ import androidx.lifecycle.viewModelScope import com.lifeos.core.common.result.LifeResult import com.lifeos.core.common.viewmodel.LifeViewModel import com.lifeos.core.database.calendar.CalendarEventEntity +import com.lifeos.core.database.calendar.CalendarListEntity import com.lifeos.core.datastore.SettingsRepository +import com.lifeos.feature.calendar.data.CalendarPalette import com.lifeos.feature.calendar.data.CalendarRepository +import com.lifeos.feature.calendar.data.CalendarSubscriptionSync +import com.lifeos.feature.calendar.data.DefaultCalendarRepository +import com.lifeos.feature.calendar.data.EventDraft +import com.lifeos.feature.calendar.data.PlaceLookup +import com.lifeos.feature.calendar.data.PlaceSuggestion import com.lifeos.feature.calendar.data.ProtonIcsSync import com.lifeos.feature.calendar.data.SystemCalendarMirror import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import java.util.Calendar import javax.inject.Inject -enum class CalendarViewMode { MONTH, WEEK, DAY } +enum class CalendarViewMode { MONTH, WEEK, DAY, AGENDA } data class CalendarUiState( val viewMode: CalendarViewMode = CalendarViewMode.MONTH, /** Anchor of the visible period: first ms of the month / week / day. */ val anchor: Long = 0L, - /** All events inside the loaded window (padded month grid or week). */ + /** Events inside the loaded window, already filtered to visible calendars. */ val events: List = emptyList(), + val calendars: List = emptyList(), + /** +1 when the last move went forward, -1 backward — drives the slide direction. */ + val direction: Int = 1, /** Selected day (start-of-day millis) for the agenda + new events. */ val selectedDay: Long = 0L, val showEditor: Boolean = false, @@ -36,7 +49,12 @@ data class CalendarUiState( val editorMinute: String = "0", val editorDurationMinutes: String = "60", val editorAllDay: Boolean = false, - val editorRemind: Boolean = true, + val editorCalendarId: Long? = null, + /** Minutes-before alerts, Proton-style multiples. */ + val editorReminders: List = listOf(30), + val editorCustomReminder: String = "", + val locationSuggestions: List = emptyList(), + val searchingPlaces: Boolean = false, /** Ladder granularity in minutes (5, 10, 15, 30, 60) — pinch to change. */ val minutesPerStep: Int = 60, /** Height of one hour in dp; shrinks for the compressed overview. */ @@ -44,8 +62,29 @@ data class CalendarUiState( val showConnections: Boolean = false, val protonUrlDraft: String = "", val syncing: Boolean = false, + // ---- calendar manager ------------------------------------------------- + val showCalendarManager: Boolean = false, + val editingCalendarId: Long? = null, + val calendarNameDraft: String = "", + val calendarColorDraft: Int = CalendarPalette.default, + val calendarHexDraft: String = "", + val subscriptionNameDraft: String = "", + val subscriptionUrlDraft: String = "", + // ---- search ----------------------------------------------------------- + val showSearch: Boolean = false, + val searchQuery: String = "", + val searchResults: List = emptyList(), + val showDatePicker: Boolean = false, val error: String? = null, -) +) { + val defaultCalendarId: Long? get() = calendars.firstOrNull { it.isDefault }?.id + + /** Colour an event should be drawn in, from the calendar it belongs to. */ + fun colorOf(event: CalendarEventEntity): Int = + calendars.firstOrNull { it.id == event.calendarId }?.colorArgb ?: CalendarPalette.default + + fun calendarName(id: Long?): String? = calendars.firstOrNull { it.id == id }?.name +} sealed interface CalendarUiEvent { data class SetViewMode(val mode: CalendarViewMode) : CalendarUiEvent @@ -53,6 +92,9 @@ sealed interface CalendarUiEvent { data object Next : CalendarUiEvent data object Today : CalendarUiEvent data class SelectDay(val dayStart: Long) : CalendarUiEvent + data class JumpToDate(val millis: Long) : CalendarUiEvent + data object ToggleDatePicker : CalendarUiEvent + /** Opens the editor pre-filled for [dayStart] at [hour] (timeline tap / FAB). */ data class NewEventAt(val dayStart: Long, val hour: Int) : CalendarUiEvent @@ -72,15 +114,43 @@ sealed interface CalendarUiEvent { data class EditEvent(val event: CalendarEventEntity) : CalendarUiEvent data class EditorTitleChanged(val value: String) : CalendarUiEvent data class EditorLocationChanged(val value: String) : CalendarUiEvent + data class EditorLocationPicked(val suggestion: PlaceSuggestion) : CalendarUiEvent data class EditorNotesChanged(val value: String) : CalendarUiEvent data class EditorHourChanged(val value: String) : CalendarUiEvent data class EditorMinuteChanged(val value: String) : CalendarUiEvent data class EditorDurationChanged(val value: String) : CalendarUiEvent + data class EditorDurationPreset(val minutes: Int) : CalendarUiEvent data object EditorAllDayToggled : CalendarUiEvent - data object EditorRemindToggled : CalendarUiEvent + data class EditorCalendarPicked(val calendarId: Long) : CalendarUiEvent + data class EditorReminderToggled(val minutes: Int) : CalendarUiEvent + data class EditorCustomReminderChanged(val value: String) : CalendarUiEvent + data object EditorCustomReminderAdded : CalendarUiEvent data object Save : CalendarUiEvent data class Delete(val eventId: Long) : CalendarUiEvent + data class Duplicate(val eventId: Long) : CalendarUiEvent + data class ShiftEvent(val eventId: Long, val days: Int) : CalendarUiEvent data object MirrorToSystem : CalendarUiEvent + + // ---- calendars -------------------------------------------------------- + data object ToggleCalendarManager : CalendarUiEvent + data class CalendarNameDraftChanged(val value: String) : CalendarUiEvent + data class CalendarColorDraftChanged(val argb: Int) : CalendarUiEvent + data class CalendarHexDraftChanged(val value: String) : CalendarUiEvent + data object SaveCalendarDraft : CalendarUiEvent + data class EditCalendar(val calendarId: Long) : CalendarUiEvent + data object ClearCalendarDraft : CalendarUiEvent + data class SetDefaultCalendar(val calendarId: Long) : CalendarUiEvent + data class ToggleCalendarVisible(val calendarId: Long) : CalendarUiEvent + data class DeleteCalendar(val calendarId: Long, val deleteEvents: Boolean) : CalendarUiEvent + data class SubscriptionNameChanged(val value: String) : CalendarUiEvent + data class SubscriptionUrlChanged(val value: String) : CalendarUiEvent + data object Subscribe : CalendarUiEvent + data object SyncSubscriptions : CalendarUiEvent + + // ---- search ---------------------------------------------------------- + data object ToggleSearch : CalendarUiEvent + data class SearchQueryChanged(val value: String) : CalendarUiEvent + data object ToggleConnections : CalendarUiEvent data class ProtonUrlChanged(val value: String) : CalendarUiEvent data object SyncProton : CalendarUiEvent @@ -98,6 +168,8 @@ class CalendarViewModel @Inject constructor( private val calendarRepository: CalendarRepository, private val systemCalendarMirror: SystemCalendarMirror, private val protonIcsSync: ProtonIcsSync, + private val subscriptionSync: CalendarSubscriptionSync, + private val placeLookup: PlaceLookup, private val settingsRepository: SettingsRepository, ) : LifeViewModel( CalendarUiState( @@ -106,19 +178,35 @@ class CalendarViewModel @Inject constructor( ), ) { - /** Loaded window follows mode + anchor; padded so month grids fill fully. */ + /** Loaded window follows mode + anchor, padded a period either side. */ private val window = MutableStateFlow(windowFor(CalendarViewMode.MONTH, startOfMonth(System.currentTimeMillis()))) + private var placeJob: Job? = null + private var searchJob: Job? = null + init { viewModelScope.launch { + // Hidden calendars are filtered here so every view agrees. window .flatMapLatest { (start, end) -> calendarRepository.observeWindow(start, end) } - .collect { events -> updateState { it.copy(events = events) } } + .combine(calendarRepository.observeCalendars()) { events, calendars -> + val hidden = calendars.filter { !it.visible }.map { it.id }.toSet() + events.filter { it.calendarId == null || it.calendarId !in hidden } to calendars + } + .collect { (events, calendars) -> + updateState { it.copy(events = events, calendars = calendars) } + } } viewModelScope.launch { val url = settingsRepository.protonIcsUrl.first() updateState { it.copy(protonUrlDraft = url) } } + viewModelScope.launch { + // A calendar to save into always exists, even on a first run. + calendarRepository.ensureDefaultCalendar() + // Subscriptions are refreshed on open as well as on the six-hour worker. + runCatching { subscriptionSync.syncAll() } + } } override fun onEvent(event: CalendarUiEvent) { @@ -130,61 +218,37 @@ class CalendarViewModel @Inject constructor( } CalendarUiEvent.Previous -> shift(-1) CalendarUiEvent.Next -> shift(1) - CalendarUiEvent.Today -> { - val today = startOfDay(System.currentTimeMillis()) - val anchor = anchorFor(uiState.value.viewMode, today) - updateState { it.copy(anchor = anchor, selectedDay = today) } - window.value = windowFor(uiState.value.viewMode, anchor) + CalendarUiEvent.Today -> jumpTo(System.currentTimeMillis()) + is CalendarUiEvent.JumpToDate -> { + updateState { it.copy(showDatePicker = false) } + jumpTo(event.millis) } + CalendarUiEvent.ToggleDatePicker -> + updateState { it.copy(showDatePicker = !it.showDatePicker) } is CalendarUiEvent.SelectDay -> updateState { it.copy(selectedDay = event.dayStart) } is CalendarUiEvent.SetZoom -> updateState { it.copy( minutesPerStep = event.minutesPerStep.coerceIn(5, 60), - hourHeightDp = event.hourHeightDp.coerceIn(24f, 220f), + hourHeightDp = event.hourHeightDp.coerceIn(24f, 260f), ) } is CalendarUiEvent.NewEventForRange -> updateState { - it.copy( - selectedDay = event.dayStart, - showEditor = true, - editingEventId = null, - editorTitle = "", - editorLocation = "", - editorNotes = "", - editorHour = (event.startMinuteOfDay / 60).toString(), - editorMinute = (event.startMinuteOfDay % 60).toString(), - editorDurationMinutes = event.durationMinutes.coerceAtLeast(5).toString(), - editorAllDay = false, - editorRemind = true, + it.blankEditor( + day = event.dayStart, + hour = event.startMinuteOfDay / 60, + minute = event.startMinuteOfDay % 60, + duration = event.durationMinutes.coerceAtLeast(5), ) } is CalendarUiEvent.NewEventAt -> updateState { - it.copy( - selectedDay = event.dayStart, - showEditor = true, - editingEventId = null, - editorTitle = "", - editorLocation = "", - editorNotes = "", - editorHour = event.hour.toString(), - editorMinute = "0", - editorDurationMinutes = "60", - editorAllDay = false, - editorRemind = true, - ) + it.blankEditor(day = event.dayStart, hour = event.hour, minute = 0, duration = 60) } CalendarUiEvent.ToggleEditor -> updateState { - it.copy( - showEditor = !it.showEditor, - editingEventId = null, - editorTitle = "", - editorLocation = "", - editorNotes = "", - editorHour = "9", - editorMinute = "0", - editorDurationMinutes = "60", - editorAllDay = false, - ) + if (it.showEditor) { + it.copy(showEditor = false, editingEventId = null, locationSuggestions = emptyList()) + } else { + it.blankEditor(day = it.selectedDay, hour = 9, minute = 0, duration = 60) + } } is CalendarUiEvent.EditEvent -> { // Task mirrors (negative ids) are edited in Tasks, not here. @@ -193,6 +257,7 @@ class CalendarViewModel @Inject constructor( updateState { it.copy( showEditor = true, + showSearch = false, editingEventId = event.event.id, editorTitle = event.event.title, editorLocation = event.event.location.orEmpty(), @@ -202,27 +267,79 @@ class CalendarViewModel @Inject constructor( editorDurationMinutes = ((event.event.endsAt - event.event.startsAt) / 60_000L).toString(), editorAllDay = event.event.allDay, + editorCalendarId = event.event.calendarId ?: it.defaultCalendarId, + editorReminders = DefaultCalendarRepository.decodeReminders(event.event.reminderMinutes), + editorCustomReminder = "", + locationSuggestions = emptyList(), selectedDay = startOfDay(event.event.startsAt), ) } } is CalendarUiEvent.EditorTitleChanged -> updateState { it.copy(editorTitle = event.value) } - is CalendarUiEvent.EditorLocationChanged -> + is CalendarUiEvent.EditorLocationChanged -> { updateState { it.copy(editorLocation = event.value) } + suggestPlaces(event.value) + } + is CalendarUiEvent.EditorLocationPicked -> { + placeJob?.cancel() + updateState { + it.copy( + editorLocation = event.suggestion.label, + locationSuggestions = emptyList(), + searchingPlaces = false, + ) + } + } is CalendarUiEvent.EditorNotesChanged -> updateState { it.copy(editorNotes = event.value) } is CalendarUiEvent.EditorHourChanged -> updateState { it.copy(editorHour = event.value) } is CalendarUiEvent.EditorMinuteChanged -> updateState { it.copy(editorMinute = event.value) } is CalendarUiEvent.EditorDurationChanged -> updateState { it.copy(editorDurationMinutes = event.value) } + is CalendarUiEvent.EditorDurationPreset -> + updateState { it.copy(editorDurationMinutes = event.minutes.toString()) } CalendarUiEvent.EditorAllDayToggled -> updateState { it.copy(editorAllDay = !it.editorAllDay) } - CalendarUiEvent.EditorRemindToggled -> - updateState { it.copy(editorRemind = !it.editorRemind) } + is CalendarUiEvent.EditorCalendarPicked -> + updateState { it.copy(editorCalendarId = event.calendarId) } + is CalendarUiEvent.EditorReminderToggled -> updateState { + val current = it.editorReminders + it.copy( + editorReminders = if (event.minutes in current) { + current - event.minutes + } else { + (current + event.minutes).sorted() + }, + ) + } + is CalendarUiEvent.EditorCustomReminderChanged -> + updateState { it.copy(editorCustomReminder = event.value) } + CalendarUiEvent.EditorCustomReminderAdded -> updateState { + val minutes = it.editorCustomReminder.filter { c -> c.isDigit() }.toIntOrNull() + if (minutes == null || minutes > 60 * 24 * 30) { + it.copy(editorCustomReminder = "", error = "Enter minutes between 0 and 43200") + } else { + it.copy( + editorReminders = (it.editorReminders + minutes).distinct().sorted(), + editorCustomReminder = "", + ) + } + } CalendarUiEvent.Save -> save() is CalendarUiEvent.Delete -> viewModelScope.launch { calendarRepository.delete(event.eventId) updateState { it.copy(showEditor = false, editingEventId = null) } } + is CalendarUiEvent.Duplicate -> viewModelScope.launch { + when (val result = calendarRepository.duplicate(event.eventId)) { + is LifeResult.Success -> updateState { + it.copy(showEditor = false, editingEventId = null, error = "Copied") + } + is LifeResult.Failure -> updateState { it.copy(error = result.error.message) } + } + } + is CalendarUiEvent.ShiftEvent -> viewModelScope.launch { + calendarRepository.shiftBy(event.eventId, event.days * DAY_MS) + } CalendarUiEvent.MirrorToSystem -> viewModelScope.launch { when (val result = systemCalendarMirror.mirrorAll()) { is LifeResult.Success -> updateState { @@ -231,6 +348,70 @@ class CalendarViewModel @Inject constructor( is LifeResult.Failure -> updateState { it.copy(error = result.error.message) } } } + + CalendarUiEvent.ToggleCalendarManager -> updateState { + it.copy(showCalendarManager = !it.showCalendarManager).clearCalendarDraft() + } + is CalendarUiEvent.CalendarNameDraftChanged -> + updateState { it.copy(calendarNameDraft = event.value) } + is CalendarUiEvent.CalendarColorDraftChanged -> updateState { + it.copy(calendarColorDraft = event.argb, calendarHexDraft = CalendarPalette.hex(event.argb)) + } + is CalendarUiEvent.CalendarHexDraftChanged -> updateState { + val parsed = CalendarPalette.parse(event.value) + it.copy(calendarHexDraft = event.value, calendarColorDraft = parsed) + } + CalendarUiEvent.SaveCalendarDraft -> saveCalendarDraft() + is CalendarUiEvent.EditCalendar -> updateState { state -> + val calendar = state.calendars.firstOrNull { it.id == event.calendarId } + ?: return@updateState state + state.copy( + editingCalendarId = calendar.id, + calendarNameDraft = calendar.name, + calendarColorDraft = calendar.colorArgb, + calendarHexDraft = CalendarPalette.hex(calendar.colorArgb), + ) + } + CalendarUiEvent.ClearCalendarDraft -> updateState { it.clearCalendarDraft() } + is CalendarUiEvent.SetDefaultCalendar -> viewModelScope.launch { + calendarRepository.setDefaultCalendar(event.calendarId) + } + is CalendarUiEvent.ToggleCalendarVisible -> viewModelScope.launch { + val calendar = uiState.value.calendars.firstOrNull { it.id == event.calendarId } + ?: return@launch + calendarRepository.setCalendarVisible(calendar.id, !calendar.visible) + } + is CalendarUiEvent.DeleteCalendar -> viewModelScope.launch { + calendarRepository.deleteCalendar(event.calendarId, event.deleteEvents) + updateState { it.clearCalendarDraft() } + } + is CalendarUiEvent.SubscriptionNameChanged -> + updateState { it.copy(subscriptionNameDraft = event.value) } + is CalendarUiEvent.SubscriptionUrlChanged -> + updateState { it.copy(subscriptionUrlDraft = event.value) } + CalendarUiEvent.Subscribe -> subscribe() + CalendarUiEvent.SyncSubscriptions -> viewModelScope.launch { + updateState { it.copy(syncing = true) } + val result = subscriptionSync.syncAll() + updateState { + it.copy( + syncing = false, + error = when (result) { + is LifeResult.Success -> "Subscriptions refreshed: ${result.value} event(s)" + is LifeResult.Failure -> result.error.message + }, + ) + } + } + + CalendarUiEvent.ToggleSearch -> updateState { + it.copy(showSearch = !it.showSearch, searchQuery = "", searchResults = emptyList()) + } + is CalendarUiEvent.SearchQueryChanged -> { + updateState { it.copy(searchQuery = event.value) } + searchEvents(event.value) + } + CalendarUiEvent.ToggleConnections -> updateState { it.copy(showConnections = !it.showConnections) } is CalendarUiEvent.ProtonUrlChanged -> updateState { it.copy(protonUrlDraft = event.value) } @@ -253,6 +434,14 @@ class CalendarViewModel @Inject constructor( } } + private fun jumpTo(at: Long) { + val day = startOfDay(at) + val anchor = anchorFor(uiState.value.viewMode, day) + val forward = anchor >= uiState.value.anchor + updateState { it.copy(anchor = anchor, selectedDay = day, direction = if (forward) 1 else -1) } + window.value = windowFor(uiState.value.viewMode, anchor) + } + private fun shift(by: Int) { val state = uiState.value val calendar = Calendar.getInstance().apply { timeInMillis = state.anchor } @@ -260,14 +449,16 @@ class CalendarViewModel @Inject constructor( CalendarViewMode.MONTH -> calendar.add(Calendar.MONTH, by) CalendarViewMode.WEEK -> calendar.add(Calendar.WEEK_OF_YEAR, by) CalendarViewMode.DAY -> calendar.add(Calendar.DAY_OF_YEAR, by) + CalendarViewMode.AGENDA -> calendar.add(Calendar.MONTH, by) } val anchor = calendar.timeInMillis updateState { it.copy( anchor = anchor, + direction = by, // Keep the selection inside the visible period. selectedDay = when (state.viewMode) { - CalendarViewMode.DAY -> anchor + CalendarViewMode.MONTH, CalendarViewMode.AGENDA -> anchor else -> anchor }, ) @@ -275,6 +466,98 @@ class CalendarViewModel @Inject constructor( window.value = windowFor(state.viewMode, anchor) } + /** Debounced Nominatim lookup; a short or empty box clears the list. */ + private fun suggestPlaces(query: String) { + placeJob?.cancel() + if (query.trim().length < 3) { + updateState { it.copy(locationSuggestions = emptyList(), searchingPlaces = false) } + return + } + placeJob = viewModelScope.launch { + delay(450) + updateState { it.copy(searchingPlaces = true) } + val results = placeLookup.suggest(query) + updateState { it.copy(locationSuggestions = results, searchingPlaces = false) } + } + } + + private fun searchEvents(query: String) { + searchJob?.cancel() + if (query.isBlank()) { + updateState { it.copy(searchResults = emptyList()) } + return + } + searchJob = viewModelScope.launch { + delay(200) + val results = calendarRepository.search(query) + updateState { it.copy(searchResults = results) } + } + } + + private fun saveCalendarDraft() { + val state = uiState.value + val name = state.calendarNameDraft.trim() + if (name.isEmpty()) { + updateState { it.copy(error = "Name the calendar first") } + return + } + viewModelScope.launch { + val editing = state.editingCalendarId + if (editing != null) { + calendarRepository.renameCalendar(editing, name) + calendarRepository.recolourCalendar(editing, state.calendarColorDraft) + } else { + val result = calendarRepository.createCalendar( + name = name, + colorArgb = state.calendarColorDraft, + ) + if (result is LifeResult.Failure) { + updateState { it.copy(error = result.error.message) } + return@launch + } + } + updateState { it.clearCalendarDraft() } + } + } + + private fun subscribe() { + val state = uiState.value + val url = state.subscriptionUrlDraft.trim() + val name = state.subscriptionNameDraft.trim().ifEmpty { "Subscribed calendar" } + if (!url.contains("://")) { + updateState { it.copy(error = "Paste a webcal:// or https:// ICS link") } + return + } + viewModelScope.launch { + updateState { it.copy(syncing = true) } + val used = state.calendars.map { it.colorArgb } + val created = calendarRepository.createCalendar( + name = name, + colorArgb = CalendarPalette.nextUnused(used), + subscriptionUrl = url, + ) + when (created) { + is LifeResult.Success -> { + val synced = subscriptionSync.syncOne(created.value) + updateState { + it.copy( + syncing = false, + subscriptionNameDraft = "", + subscriptionUrlDraft = "", + error = when (synced) { + is LifeResult.Success -> "Subscribed: ${synced.value} event(s) imported" + is LifeResult.Failure -> synced.error.message + }, + ) + } + } + is LifeResult.Failure -> updateState { + it.copy(syncing = false, error = created.error.message) + } + } + } + } + private fun save() { val state = uiState.value val title = state.editorTitle.trim() @@ -287,37 +570,63 @@ class CalendarViewModel @Inject constructor( } else { state.selectedDay + hour * 3_600_000L + minute * 60_000L } - val endsAt = if (state.editorAllDay) state.selectedDay + 86_400_000L else startsAt + duration * 60_000L + val endsAt = if (state.editorAllDay) state.selectedDay + DAY_MS else startsAt + duration * 60_000L + val draft = EventDraft( + title = title, + startsAt = startsAt, + endsAt = endsAt, + location = state.editorLocation.trim().ifEmpty { null }, + notes = state.editorNotes.trim().ifEmpty { null }, + allDay = state.editorAllDay, + calendarId = state.editorCalendarId ?: state.defaultCalendarId, + reminderMinutes = state.editorReminders, + ) viewModelScope.launch { val result = if (state.editingEventId != null) { - calendarRepository.update( - eventId = state.editingEventId, - title = title, - startsAt = startsAt, - endsAt = endsAt, - location = state.editorLocation.trim().ifEmpty { null }, - notes = state.editorNotes.trim().ifEmpty { null }, - allDay = state.editorAllDay, - ) + calendarRepository.update(state.editingEventId, draft) } else { - calendarRepository.create( - title = title, - startsAt = startsAt, - endsAt = endsAt, - location = state.editorLocation.trim().ifEmpty { null }, - notes = state.editorNotes.trim().ifEmpty { null }, - allDay = state.editorAllDay, - remindMinutesBefore = if (state.editorRemind) 30 else null, - ) + calendarRepository.create(draft) } when (result) { - is LifeResult.Success -> updateState { it.copy(showEditor = false, editingEventId = null) } + is LifeResult.Success -> updateState { + it.copy(showEditor = false, editingEventId = null, locationSuggestions = emptyList()) + } is LifeResult.Failure -> updateState { it.copy(error = result.error.message) } } } } + private fun CalendarUiState.blankEditor(day: Long, hour: Int, minute: Int, duration: Int) = copy( + selectedDay = day, + showEditor = true, + editingEventId = null, + editorTitle = "", + editorLocation = "", + editorNotes = "", + editorHour = hour.toString(), + editorMinute = minute.toString(), + editorDurationMinutes = duration.toString(), + editorAllDay = false, + editorCalendarId = defaultCalendarId, + editorReminders = listOf(30), + editorCustomReminder = "", + locationSuggestions = emptyList(), + ) + + private fun CalendarUiState.clearCalendarDraft() = copy( + editingCalendarId = null, + calendarNameDraft = "", + calendarColorDraft = CalendarPalette.nextUnused(calendars.map { it.colorArgb }), + calendarHexDraft = "", + ) + companion object { + /** Alert offsets the editor offers, mirroring Proton Calendar's set. */ + val REMINDER_PRESETS = listOf(0, 5, 10, 15, 30, 60, 120, 1440, 2880, 10080) + + /** Quick lengths in the editor. */ + val DURATION_PRESETS = listOf(15, 30, 45, 60, 90, 120, 240) + fun startOfDay(at: Long): Long = Calendar.getInstance().apply { timeInMillis = at set(Calendar.HOUR_OF_DAY, 0) @@ -339,19 +648,23 @@ class CalendarViewModel @Inject constructor( }.timeInMillis fun anchorFor(mode: CalendarViewMode, day: Long): Long = when (mode) { - CalendarViewMode.MONTH -> startOfMonth(day) + CalendarViewMode.MONTH, CalendarViewMode.AGENDA -> startOfMonth(day) CalendarViewMode.WEEK -> startOfWeek(day) CalendarViewMode.DAY -> startOfDay(day) } - /** Loaded range, padded so the 6x7 month grid's leading/trailing days have events too. */ + /** + * Loaded range. Padded a whole period either side so a swipe lands on a + * page that already has its events, which is what makes the slide look + * instant instead of flashing empty. + */ fun windowFor(mode: CalendarViewMode, anchor: Long): Pair = when (mode) { - CalendarViewMode.MONTH -> { + CalendarViewMode.MONTH, CalendarViewMode.AGENDA -> { val gridStart = startOfWeek(anchor) - gridStart to gridStart + 42L * DAY_MS + (gridStart - 42L * DAY_MS) to (gridStart + 84L * DAY_MS) } - CalendarViewMode.WEEK -> anchor to anchor + 7L * DAY_MS - CalendarViewMode.DAY -> anchor to anchor + DAY_MS + CalendarViewMode.WEEK -> (anchor - 7L * DAY_MS) to (anchor + 14L * DAY_MS) + CalendarViewMode.DAY -> (anchor - DAY_MS) to (anchor + 2L * DAY_MS) } const val DAY_MS = 86_400_000L diff --git a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/CalendarActionHandler.kt b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/CalendarActionHandler.kt deleted file mode 100644 index 5faabf0..0000000 --- a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/CalendarActionHandler.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.lifeos.feature.calendar.data - -import com.lifeos.core.common.result.LifeResult -import com.lifeos.core.service.LifeAction -import com.lifeos.core.service.LifeActionHandler -import javax.inject.Inject - -/** Executes [LifeAction.CreateCalendarEvent] (R7's target). */ -internal class CalendarActionHandler @Inject constructor( - private val calendarRepository: CalendarRepository, -) : LifeActionHandler { - - override fun canHandle(action: LifeAction) = action is LifeAction.CreateCalendarEvent - - override suspend fun execute(action: LifeAction): LifeResult { - val create = action as LifeAction.CreateCalendarEvent - return when ( - val result = calendarRepository.create( - title = create.title, - startsAt = create.startsAt, - endsAt = create.endsAt, - remindMinutesBefore = 30, - ) - ) { - is LifeResult.Success -> LifeResult.Success(result.value) - is LifeResult.Failure -> result - } - } -} diff --git a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/CalendarJarvisBridge.kt b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/CalendarJarvisBridge.kt new file mode 100644 index 0000000..a6bf407 --- /dev/null +++ b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/CalendarJarvisBridge.kt @@ -0,0 +1,199 @@ +package com.lifeos.feature.calendar.data + +import com.lifeos.core.common.result.LifeError +import com.lifeos.core.common.result.LifeResult +import com.lifeos.core.database.calendar.CalendarDao +import com.lifeos.core.service.ActionEcho +import com.lifeos.core.service.LifeAction +import com.lifeos.core.service.LifeActionHandler +import com.lifeos.core.service.LifeDataProvider +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject + +/** + * Calendar as Jarvis reads it (§Module 9): the calendars themselves, what is + * coming up, and where the subscriptions stand. + */ +internal class CalendarProvider @Inject constructor( + private val calendarDao: CalendarDao, + private val calendarRepository: CalendarRepository, +) : LifeDataProvider { + + override val topic: String = "calendar" + override val description: String = "your calendars, their colours, and upcoming events" + + override suspend fun read(query: String?): String { + val days = query?.filter { it.isDigit() }?.toIntOrNull()?.coerceIn(1, 90) ?: 14 + val now = System.currentTimeMillis() + val calendars = calendarRepository.calendars() + val events = calendarDao.allEvents() + .filter { it.endsAt > now && it.startsAt < now + days * 86_400_000L } + .sortedBy { it.startsAt } + .take(25) + val byId = calendars.associateBy { it.id } + return buildString { + if (calendars.isEmpty()) { + appendLine("Calendars: none yet (one is created the first time you save an event).") + } else { + appendLine("Calendars (${calendars.size}):") + calendars.forEach { calendar -> + val bits = buildList { + add(CalendarPalette.nameOf(calendar.colorArgb)) + if (calendar.isDefault) add("default") + if (!calendar.visible) add("hidden") + calendar.subscriptionUrl?.let { add("subscribed") } + calendar.lastSyncedAt?.let { add("synced ${STAMP.format(Date(it))}") } + } + appendLine("- ${calendar.name} (${bits.joinToString(", ")})") + } + } + if (events.isEmpty()) { + appendLine("Nothing scheduled in the next $days day(s).") + } else { + appendLine("Next $days day(s):") + events.forEach { event -> + val where = event.calendarId?.let { byId[it]?.name } ?: "unfiled" + val when0 = if (event.allDay) { + "${DAY.format(Date(event.startsAt))} all day" + } else { + "${DAY.format(Date(event.startsAt))} ${TIME.format(Date(event.startsAt))}" + } + val alerts = DefaultCalendarRepository.decodeReminders(event.reminderMinutes) + appendLine( + "- $when0 · ${event.title} [$where]" + + (event.location?.takeIf { it.isNotBlank() }?.let { " @ $it" } ?: "") + + (if (alerts.isEmpty()) { + "" + } else { + " (alerts " + + alerts.joinToString("/") { DefaultCalendarRepository.humanOffset(it) } + + " before)" + }), + ) + } + } + }.trim() + } + + private companion object { + val DAY = SimpleDateFormat("EEE d MMM", Locale.getDefault()) + val TIME = SimpleDateFormat("HH:mm", Locale.getDefault()) + val STAMP = SimpleDateFormat("d MMM HH:mm", Locale.getDefault()) + } +} + +/** + * Everything Jarvis can do to the calendar: file an event (into a named + * calendar, with its own alerts), create a calendar, subscribe to an ICS feed, + * and refresh those feeds. + */ +internal class CalendarActionHandler @Inject constructor( + private val calendarRepository: CalendarRepository, + private val subscriptionSync: CalendarSubscriptionSync, + private val echo: ActionEcho, +) : LifeActionHandler { + + override fun canHandle(action: LifeAction) = action is LifeAction.CreateCalendarEvent || + action is LifeAction.CreateCalendar || + action is LifeAction.SubscribeCalendar || + action is LifeAction.SyncCalendars + + override suspend fun execute(action: LifeAction): LifeResult = when (action) { + is LifeAction.CreateCalendarEvent -> { + val calendarId = matchCalendar(action.calendarName) + val result = calendarRepository.create( + EventDraft( + title = action.title, + startsAt = action.startsAt, + endsAt = action.endsAt, + location = action.location.trim().ifBlank { null }, + notes = action.notes.trim().ifBlank { null }, + calendarId = calendarId, + reminderMinutes = action.reminderMinutes.ifEmpty { listOf(30) }, + ), + ) + when (result) { + is LifeResult.Success -> LifeResult.Success(result.value) + is LifeResult.Failure -> result + } + } + + is LifeAction.CreateCalendar -> { + val used = calendarRepository.calendars().map { it.colorArgb } + val colour = if (action.colorName.isBlank()) { + CalendarPalette.nextUnused(used) + } else { + CalendarPalette.parse(action.colorName) + } + when ( + val result = calendarRepository.createCalendar( + name = action.name, + colorArgb = colour, + makeDefault = action.makeDefault, + ) + ) { + is LifeResult.Success -> { + echo.text("Calendar \"${action.name}\" (${CalendarPalette.nameOf(colour)}) created") + LifeResult.Success(result.value) + } + is LifeResult.Failure -> result + } + } + + is LifeAction.SubscribeCalendar -> { + if (!action.url.contains("://")) { + LifeResult.Failure(LifeError.Validation("That does not look like an ICS link")) + } else { + val used = calendarRepository.calendars().map { it.colorArgb } + val colour = if (action.colorName.isBlank()) { + CalendarPalette.nextUnused(used) + } else { + CalendarPalette.parse(action.colorName) + } + when ( + val created = calendarRepository.createCalendar( + name = action.name, + colorArgb = colour, + subscriptionUrl = action.url, + ) + ) { + is LifeResult.Success -> { + // Pull it immediately so the answer can say how many landed. + when (val synced = subscriptionSync.syncOne(created.value)) { + is LifeResult.Success -> + echo.text("Subscribed to \"${action.name}\": ${synced.value} event(s)") + is LifeResult.Failure -> + echo.text("Subscribed to \"${action.name}\", but the first pull failed: ${synced.error.message}") + } + LifeResult.Success(created.value) + } + is LifeResult.Failure -> created + } + } + } + + is LifeAction.SyncCalendars -> when (val result = subscriptionSync.syncAll()) { + is LifeResult.Success -> { + echo.text("Subscriptions refreshed: ${result.value} event(s)") + LifeResult.Success(result.value.toLong()) + } + is LifeResult.Failure -> result + } + + else -> LifeResult.Failure(LifeError.Validation("Unsupported action")) + } + + /** Loose name matching, so "work" finds "Work travel". */ + private suspend fun matchCalendar(name: String): Long? { + if (name.isBlank()) return null + val needle = name.trim().lowercase() + val calendars = calendarRepository.calendars().filter { it.subscriptionUrl == null } + return ( + calendars.firstOrNull { it.name.lowercase() == needle } + ?: calendars.firstOrNull { needle in it.name.lowercase() } + ?: calendars.firstOrNull { it.name.lowercase() in needle } + )?.id + } +} diff --git a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/CalendarPalette.kt b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/CalendarPalette.kt new file mode 100644 index 0000000..f52056c --- /dev/null +++ b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/CalendarPalette.kt @@ -0,0 +1,49 @@ +package com.lifeos.feature.calendar.data + +/** + * The colours a calendar can wear (§Module 19). Kept as plain ARGB ints so the + * repository, the Jarvis bridge and the UI all speak the same language, and any + * `#RRGGBB` the user types is just as valid as a named one. + */ +object CalendarPalette { + + /** Name to ARGB, in the order the picker shows them. */ + val named: List> = listOf( + "Blue" to 0xFF3B82F6.toInt(), + "Indigo" to 0xFF6366F1.toInt(), + "Violet" to 0xFF8B5CF6.toInt(), + "Pink" to 0xFFEC4899.toInt(), + "Red" to 0xFFEF4444.toInt(), + "Orange" to 0xFFF97316.toInt(), + "Amber" to 0xFFF59E0B.toInt(), + "Lime" to 0xFF84CC16.toInt(), + "Green" to 0xFF22C55E.toInt(), + "Teal" to 0xFF14B8A6.toInt(), + "Cyan" to 0xFF06B6D4.toInt(), + "Slate" to 0xFF64748B.toInt(), + ) + + val default: Int = named.first().second + + /** Accepts a palette name or a hex literal; falls back to [default]. */ + fun parse(value: String): Int { + val trimmed = value.trim() + named.firstOrNull { it.first.equals(trimmed, ignoreCase = true) }?.let { return it.second } + val hex = trimmed.removePrefix("#") + return when (hex.length) { + 6 -> hex.toLongOrNull(16)?.let { (0xFF000000L or it).toInt() } ?: default + 8 -> hex.toLongOrNull(16)?.toInt() ?: default + else -> default + } + } + + fun hex(argb: Int): String = "#%06X".format(argb and 0xFFFFFF) + + /** Closest palette name, for describing a colour back to the user. */ + fun nameOf(argb: Int): String = + named.firstOrNull { it.second == argb }?.first ?: hex(argb) + + /** Pick a colour for a new calendar that is not already in use. */ + fun nextUnused(used: Collection): Int = + named.firstOrNull { it.second !in used }?.second ?: default +} diff --git a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/CalendarRepository.kt b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/CalendarRepository.kt index fd7aef7..03eaae4 100644 --- a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/CalendarRepository.kt +++ b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/CalendarRepository.kt @@ -6,6 +6,7 @@ import com.lifeos.core.common.result.getOrNull import com.lifeos.core.common.result.runCatchingLife import com.lifeos.core.database.calendar.CalendarDao import com.lifeos.core.database.calendar.CalendarEventEntity +import com.lifeos.core.database.calendar.CalendarListEntity import com.lifeos.core.database.capture.CaptureDao import com.lifeos.core.model.LifeModule import com.lifeos.core.model.SourceRef @@ -20,42 +21,67 @@ import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton +/** Everything an event carries when it is written. */ +data class EventDraft( + val title: String, + val startsAt: Long, + val endsAt: Long, + val location: String? = null, + val notes: String? = null, + val allDay: Boolean = false, + val calendarId: Long? = null, + /** Minutes-before offsets, Proton-style: 0 = at start, 1440 = a day before. */ + val reminderMinutes: List = emptyList(), +) + /** - * Local-first calendar (§Module 19). Events live in Room and publish - * [LifeEvent.CalendarEventChanged]; the system Calendar Provider mirror and - * Proton ICS bridges (§8.6) layer on in a follow-up — the [CalendarEventEntity.systemEventId] - * column is already in place for it. + * Local-first calendar (§Module 19). Events live in Room, belong to a coloured + * calendar, publish [LifeEvent.CalendarEventChanged], and can be mirrored into + * the system Calendar Provider or exchanged as ICS. */ interface CalendarRepository { fun observeWindow(windowStart: Long, windowEnd: Long): Flow> fun observeUpcoming(): Flow> + fun observeCalendars(): Flow> + + /** Creates the starter calendar the first time one is needed. */ + suspend fun ensureDefaultCalendar(): Long + suspend fun calendars(): List - /** Creates an event; when [remindMinutesBefore] is set, links a reminder via LifeAction. */ - suspend fun create( - title: String, - startsAt: Long, - endsAt: Long, - location: String? = null, - notes: String? = null, - allDay: Boolean = false, - remindMinutesBefore: Int? = null, + suspend fun createCalendar( + name: String, + colorArgb: Int, + subscriptionUrl: String? = null, + makeDefault: Boolean = false, ): LifeResult - /** Edits an existing event in place. */ - suspend fun update( - eventId: Long, - title: String, - startsAt: Long, - endsAt: Long, - location: String?, - notes: String?, - allDay: Boolean, - ): LifeResult + suspend fun renameCalendar(calendarId: Long, name: String) + suspend fun recolourCalendar(calendarId: Long, colorArgb: Int) + suspend fun setCalendarVisible(calendarId: Long, visible: Boolean) + suspend fun setDefaultCalendar(calendarId: Long) + + /** Removes a calendar; its events are deleted or set loose. */ + suspend fun deleteCalendar(calendarId: Long, deleteEvents: Boolean) + + suspend fun create(draft: EventDraft): LifeResult + suspend fun update(eventId: Long, draft: EventDraft): LifeResult + + /** Copies an event to the same slot, so only the date needs changing. */ + suspend fun duplicate(eventId: Long): LifeResult + + /** Moves an event by whole days, for drag-free rescheduling. */ + suspend fun shiftBy(eventId: Long, deltaMs: Long): LifeResult suspend fun delete(eventId: Long) - /** Imports parsed ICS events, deduping on (title, startsAt); returns how many were new. */ - suspend fun importParsed(events: List): LifeResult + suspend fun search(query: String, limit: Int = 40): List + + /** Imports parsed ICS events; deduped by UID inside a calendar, else by title+start. */ + suspend fun importParsed( + events: List, + calendarId: Long? = null, + replaceCalendar: Boolean = false, + ): LifeResult /** The whole local calendar as an RFC 5545 document (Proton ICS bridge, §8.6). */ suspend fun exportIcs(): String @@ -79,9 +105,9 @@ internal class DefaultCalendarRepository @Inject constructor( val taskEvents = tasks.map { task -> CalendarEventEntity( id = -task.id, - title = "☑ ${task.title}", + title = task.title, location = null, - notes = null, + notes = TASK_MARKER, startsAt = task.dueAt ?: 0L, endsAt = (task.dueAt ?: 0L) + 30 * 60_000L, createdAt = task.createdAt, @@ -94,94 +120,241 @@ internal class DefaultCalendarRepository @Inject constructor( override fun observeUpcoming(): Flow> = calendarDao.observeUpcoming(System.currentTimeMillis()) - override suspend fun create( - title: String, - startsAt: Long, - endsAt: Long, - location: String?, - notes: String?, - allDay: Boolean, - remindMinutesBefore: Int?, + override fun observeCalendars(): Flow> = calendarDao.observeCalendars() + + override suspend fun ensureDefaultCalendar(): Long = withContext(dispatchers.io) { + calendarDao.defaultCalendar()?.id + ?: calendarDao.allCalendars().firstOrNull()?.id + ?: calendarDao.insertCalendar( + CalendarListEntity( + name = "Personal", + colorArgb = CalendarPalette.default, + isDefault = true, + createdAt = System.currentTimeMillis(), + ), + ) + } + + override suspend fun calendars(): List = withContext(dispatchers.io) { + calendarDao.allCalendars() + } + + override suspend fun createCalendar( + name: String, + colorArgb: Int, + subscriptionUrl: String?, + makeDefault: Boolean, ): LifeResult = withContext(dispatchers.io) { + runCatchingLife { + val clean = name.trim().take(60) + require(clean.isNotEmpty()) { "A calendar needs a name" } + // A subscription is a read-only mirror, so it can never be the default. + val asDefault = makeDefault && subscriptionUrl == null + if (asDefault) calendarDao.clearDefaultCalendar() + val existing = calendarDao.allCalendars() + calendarDao.insertCalendar( + CalendarListEntity( + name = clean, + colorArgb = colorArgb, + isDefault = asDefault || existing.none { it.subscriptionUrl == null }, + subscriptionUrl = subscriptionUrl?.trim()?.ifBlank { null }, + createdAt = System.currentTimeMillis(), + ), + ) + } + } + + override suspend fun renameCalendar(calendarId: Long, name: String) = withContext(dispatchers.io) { + val calendar = calendarDao.calendar(calendarId) ?: return@withContext + calendarDao.updateCalendar(calendar.copy(name = name.trim().take(60).ifEmpty { calendar.name })) + } + + override suspend fun recolourCalendar(calendarId: Long, colorArgb: Int) = withContext(dispatchers.io) { + val calendar = calendarDao.calendar(calendarId) ?: return@withContext + calendarDao.updateCalendar(calendar.copy(colorArgb = colorArgb)) + } + + override suspend fun setCalendarVisible(calendarId: Long, visible: Boolean) = + withContext(dispatchers.io) { + val calendar = calendarDao.calendar(calendarId) ?: return@withContext + calendarDao.updateCalendar(calendar.copy(visible = visible)) + } + + override suspend fun setDefaultCalendar(calendarId: Long) = withContext(dispatchers.io) { + val calendar = calendarDao.calendar(calendarId) ?: return@withContext + if (calendar.subscriptionUrl != null) return@withContext + calendarDao.clearDefaultCalendar() + calendarDao.updateCalendar(calendar.copy(isDefault = true)) + } + + override suspend fun deleteCalendar(calendarId: Long, deleteEvents: Boolean) = + withContext(dispatchers.io) { + if (deleteEvents) calendarDao.deleteEventsOf(calendarId) else calendarDao.detachEventsFrom(calendarId) + val wasDefault = calendarDao.calendar(calendarId)?.isDefault == true + calendarDao.deleteCalendar(calendarId) + // Never leave the user without a default to save into. + if (wasDefault) { + calendarDao.allCalendars().firstOrNull { it.subscriptionUrl == null }?.let { + calendarDao.updateCalendar(it.copy(isDefault = true)) + } + } + } + + override suspend fun create(draft: EventDraft): LifeResult = withContext(dispatchers.io) { runCatchingLife { val now = System.currentTimeMillis() + val calendarId = draft.calendarId ?: ensureDefaultCalendar() val eventId = calendarDao.insert( CalendarEventEntity( - title = title, - location = location, - notes = notes, - startsAt = startsAt, - endsAt = endsAt, - allDay = allDay, + title = draft.title, + location = draft.location, + notes = draft.notes, + startsAt = draft.startsAt, + endsAt = draft.endsAt, + allDay = draft.allDay, + calendarId = calendarId, + reminderMinutes = encodeReminders(draft.reminderMinutes), createdAt = now, updatedAt = now, ), ) - - if (remindMinutesBefore != null) { - val remindAt = startsAt - TimeUnit.MINUTES.toMillis(remindMinutesBefore.toLong()) - if (remindAt > now) { - val reminderId = actionDispatcher.get().dispatch( - LifeAction.CreateReminder( - title = title, - at = remindAt, - source = SourceRef(LifeModule.CALENDAR, eventId.toString()), - ), - ).getOrNull() - if (reminderId != null) { - calendarDao.getById(eventId)?.let { - calendarDao.update(it.copy(reminderId = reminderId, updatedAt = now)) - } - } - } - } - - eventBus.tryPublish(LifeEvent.CalendarEventChanged(eventId, title, startsAt, hasLocation = location != null)) + armReminders(eventId, draft) + eventBus.tryPublish( + LifeEvent.CalendarEventChanged( + eventId, + draft.title, + draft.startsAt, + hasLocation = draft.location != null, + ), + ) eventId } } - override suspend fun update( - eventId: Long, - title: String, - startsAt: Long, - endsAt: Long, - location: String?, - notes: String?, - allDay: Boolean, - ): LifeResult = withContext(dispatchers.io) { + override suspend fun update(eventId: Long, draft: EventDraft): LifeResult = + withContext(dispatchers.io) { + runCatchingLife { + val existing = calendarDao.getById(eventId) ?: error("Event not found") + calendarDao.update( + existing.copy( + title = draft.title, + startsAt = draft.startsAt, + endsAt = draft.endsAt, + location = draft.location, + notes = draft.notes, + allDay = draft.allDay, + calendarId = draft.calendarId ?: existing.calendarId, + reminderMinutes = encodeReminders(draft.reminderMinutes), + updatedAt = System.currentTimeMillis(), + ), + ) + // Old alerts belong to old times; drop them before arming new ones. + dispatch( + LifeAction.CancelRemindersFor( + module = LifeModule.CALENDAR.name, + entityId = eventId, + source = sourceOf(eventId), + ), + ) + armReminders(eventId, draft) + eventBus.tryPublish( + LifeEvent.CalendarEventChanged( + eventId, + draft.title, + draft.startsAt, + hasLocation = draft.location != null, + ), + ) + Unit + } + } + + override suspend fun duplicate(eventId: Long): LifeResult = withContext(dispatchers.io) { runCatchingLife { val existing = calendarDao.getById(eventId) ?: error("Event not found") - calendarDao.update( + val now = System.currentTimeMillis() + calendarDao.insert( existing.copy( - title = title, - startsAt = startsAt, - endsAt = endsAt, - location = location, - notes = notes, - allDay = allDay, - updatedAt = System.currentTimeMillis(), + id = 0, + title = "${existing.title} (copy)", + reminderId = null, + systemEventId = null, + externalUid = null, + createdAt = now, + updatedAt = now, ), ) - eventBus.tryPublish( - LifeEvent.CalendarEventChanged(eventId, title, startsAt, hasLocation = location != null), - ) - Unit } } + override suspend fun shiftBy(eventId: Long, deltaMs: Long): LifeResult = + withContext(dispatchers.io) { + runCatchingLife { + val existing = calendarDao.getById(eventId) ?: error("Event not found") + calendarDao.update( + existing.copy( + startsAt = existing.startsAt + deltaMs, + endsAt = existing.endsAt + deltaMs, + updatedAt = System.currentTimeMillis(), + ), + ) + Unit + } + } + override suspend fun delete(eventId: Long) = withContext(dispatchers.io) { + dispatch( + LifeAction.CancelRemindersFor( + module = LifeModule.CALENDAR.name, + entityId = eventId, + source = sourceOf(eventId), + ), + ) calendarDao.delete(eventId) } - override suspend fun importParsed(events: List): LifeResult = + override suspend fun search(query: String, limit: Int): List = withContext(dispatchers.io) { - runCatchingLife { - val existing = calendarDao.allEvents().map { it.title to it.startsAt }.toSet() - val now = System.currentTimeMillis() - var imported = 0 - events.forEach { event -> - if ((event.title to event.startsAt) !in existing) { + val needle = query.trim().lowercase() + if (needle.isEmpty()) return@withContext emptyList() + calendarDao.allEvents() + .filter { + it.title.lowercase().contains(needle) || + it.location.orEmpty().lowercase().contains(needle) || + it.notes.orEmpty().lowercase().contains(needle) + } + .sortedBy { it.startsAt } + .take(limit) + } + + override suspend fun importParsed( + events: List, + calendarId: Long?, + replaceCalendar: Boolean, + ): LifeResult = withContext(dispatchers.io) { + runCatchingLife { + val target = calendarId ?: ensureDefaultCalendar() + // A subscription is a mirror: wipe and rewrite so cancellations vanish too. + if (replaceCalendar) calendarDao.deleteEventsOf(target) + val existing = calendarDao.allEvents().map { it.title to it.startsAt }.toSet() + val now = System.currentTimeMillis() + var written = 0 + events.forEach { event -> + val byUid = event.uid?.let { calendarDao.bySubscriptionUid(target, it) } + when { + byUid != null -> calendarDao.update( + byUid.copy( + title = event.title, + startsAt = event.startsAt, + endsAt = event.endsAt, + location = event.location, + notes = event.notes, + allDay = event.allDay, + updatedAt = now, + ), + ) + + (event.title to event.startsAt) !in existing -> { calendarDao.insert( CalendarEventEntity( title = event.title, @@ -189,18 +362,70 @@ internal class DefaultCalendarRepository @Inject constructor( notes = event.notes, startsAt = event.startsAt, endsAt = event.endsAt, + allDay = event.allDay, + calendarId = target, + externalUid = event.uid, createdAt = now, updatedAt = now, ), ) - imported++ + written++ } } - imported } + written } + } override suspend fun exportIcs(): String = withContext(dispatchers.io) { IcsCodec.export(calendarDao.allEvents()) } + + /** Schedules one alert per offset, and remembers the first on the event. */ + private suspend fun armReminders(eventId: Long, draft: EventDraft) { + if (draft.reminderMinutes.isEmpty()) return + val now = System.currentTimeMillis() + var firstId: Long? = null + draft.reminderMinutes.distinct().sorted().forEach { minutes -> + val remindAt = draft.startsAt - TimeUnit.MINUTES.toMillis(minutes.toLong()) + if (remindAt <= now) return@forEach + val reminderId = dispatch( + LifeAction.CreateReminder( + title = if (minutes == 0) draft.title else "${draft.title} in ${humanOffset(minutes)}", + at = remindAt, + source = sourceOf(eventId), + ), + ) + if (firstId == null) firstId = reminderId + } + val linked = firstId + if (linked != null) { + calendarDao.getById(eventId)?.let { + calendarDao.update(it.copy(reminderId = linked, updatedAt = now)) + } + } + } + + private suspend fun dispatch(action: LifeAction): Long? = + actionDispatcher.get().dispatch(action).getOrNull() + + private fun sourceOf(eventId: Long) = SourceRef(LifeModule.CALENDAR, eventId.toString()) + + companion object { + /** Marks the read-only rows mirrored in from Tasks. */ + const val TASK_MARKER = "lifeos:task" + + fun encodeReminders(minutes: List): String = + minutes.distinct().sorted().joinToString(",") + + fun decodeReminders(value: String): List = + value.split(',').mapNotNull { it.trim().toIntOrNull() }.distinct().sorted() + + fun humanOffset(minutes: Int): String = when { + minutes == 0 -> "now" + minutes % 1440 == 0 -> "${minutes / 1440}d" + minutes % 60 == 0 -> "${minutes / 60}h" + else -> "${minutes}m" + } + } } diff --git a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/CalendarSubscriptionSync.kt b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/CalendarSubscriptionSync.kt new file mode 100644 index 0000000..2b8dc3f --- /dev/null +++ b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/CalendarSubscriptionSync.kt @@ -0,0 +1,91 @@ +package com.lifeos.feature.calendar.data + +import com.lifeos.core.common.log.LifeLogger +import com.lifeos.core.common.result.LifeError +import com.lifeos.core.common.result.LifeResult +import com.lifeos.core.database.calendar.CalendarDao +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Live-syncing calendar subscriptions (§Module 19). + * + * Any `webcal://`/`https://` ICS feed — public holidays, a shared work + * calendar, a sports schedule — becomes a read-only coloured calendar that is + * refreshed on open and on a schedule. Each refresh replaces the feed's events + * wholesale, so cancellations upstream disappear here too, and events are keyed + * by their ICS UID so nothing duplicates. + */ +@Singleton +class CalendarSubscriptionSync @Inject constructor( + private val okHttpClient: OkHttpClient, + private val calendarDao: CalendarDao, + private val calendarRepository: CalendarRepository, +) { + + /** Refreshes every subscription; returns how many events were written. */ + suspend fun syncAll(): LifeResult = withContext(Dispatchers.IO) { + val subscriptions = calendarDao.subscriptions() + if (subscriptions.isEmpty()) { + return@withContext LifeResult.Failure( + LifeError.Validation("No subscribed calendars yet — add an ICS link first"), + ) + } + var total = 0 + val failures = mutableListOf() + subscriptions.forEach { calendar -> + when (val result = syncOne(calendar.id)) { + is LifeResult.Success -> total += result.value + is LifeResult.Failure -> failures += "${calendar.name}: ${result.error.message}" + } + } + if (failures.isNotEmpty() && total == 0) { + LifeResult.Failure(LifeError.Network(failures.joinToString("; "))) + } else { + LifeResult.Success(total) + } + } + + suspend fun syncOne(calendarId: Long): LifeResult = withContext(Dispatchers.IO) { + val calendar = calendarDao.calendar(calendarId) + ?: return@withContext LifeResult.Failure(LifeError.NotFound("Calendar is gone")) + val url = calendar.subscriptionUrl + ?: return@withContext LifeResult.Failure( + LifeError.Validation("\"${calendar.name}\" is a local calendar, not a subscription"), + ) + val ics = try { + okHttpClient.newCall(Request.Builder().url(normalise(url)).build()).execute().use { response -> + if (!response.isSuccessful) { + return@withContext LifeResult.Failure( + LifeError.Network("${calendar.name}: HTTP ${response.code}"), + ) + } + response.body.string() + } + } catch (t: Throwable) { + LifeLogger.w(TAG, "Subscription fetch failed for ${calendar.name}", t) + return@withContext LifeResult.Failure(LifeError.Network("Fetch failed: ${t.message}", t)) + } + val parsed = IcsCodec.parse(ics) + if (parsed.isEmpty()) { + return@withContext LifeResult.Failure( + LifeError.Validation("${calendar.name}: the feed held no events"), + ) + } + val result = calendarRepository.importParsed(parsed, calendarId, replaceCalendar = true) + if (result is LifeResult.Success) calendarDao.markSynced(calendarId, System.currentTimeMillis()) + result + } + + /** Calendar links are handed out as webcal:// but fetched over HTTPS. */ + private fun normalise(url: String): String = url.trim() + .replace(Regex("^webcal://", RegexOption.IGNORE_CASE), "https://") + + private companion object { + const val TAG = "CalendarSubscriptionSync" + } +} diff --git a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/IcsCodec.kt b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/IcsCodec.kt index e2aac73..1bd42ff 100644 --- a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/IcsCodec.kt +++ b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/IcsCodec.kt @@ -10,8 +10,9 @@ import java.time.format.DateTimeFormatter /** * Minimal iCalendar (RFC 5545) reader/writer (§8.6): VEVENT with - * SUMMARY/DTSTART/DTEND/LOCATION/DESCRIPTION — the subset Proton's ICS - * bridges and every calendar app exchange. Deliberately dependency-free. + * SUMMARY/DTSTART/DTEND/LOCATION/DESCRIPTION/UID plus VALARM offsets — the + * subset Proton's ICS bridges, holiday feeds and every calendar app exchange. + * Deliberately dependency-free. */ object IcsCodec { @@ -23,15 +24,28 @@ object IcsCodec { appendLine("BEGIN:VCALENDAR") appendLine("VERSION:2.0") appendLine("PRODID:-//LifeOS//Calendar//EN") + appendLine("CALSCALE:GREGORIAN") events.forEach { event -> appendLine("BEGIN:VEVENT") - appendLine("UID:lifeos-${event.id}@lifeos.local") + appendLine("UID:${event.externalUid ?: "lifeos-${event.id}@lifeos.local"}") appendLine("DTSTAMP:${format(event.createdAt)}") - appendLine("DTSTART:${format(event.startsAt)}") - appendLine("DTEND:${format(event.endsAt)}") + if (event.allDay) { + appendLine("DTSTART;VALUE=DATE:${formatDate(event.startsAt)}") + appendLine("DTEND;VALUE=DATE:${formatDate(event.endsAt)}") + } else { + appendLine("DTSTART:${format(event.startsAt)}") + appendLine("DTEND:${format(event.endsAt)}") + } appendLine("SUMMARY:${escape(event.title)}") event.location?.takeIf { it.isNotBlank() }?.let { appendLine("LOCATION:${escape(it)}") } event.notes?.takeIf { it.isNotBlank() }?.let { appendLine("DESCRIPTION:${escape(it)}") } + DefaultCalendarRepository.decodeReminders(event.reminderMinutes).forEach { minutes -> + appendLine("BEGIN:VALARM") + appendLine("ACTION:DISPLAY") + appendLine("DESCRIPTION:${escape(event.title)}") + appendLine("TRIGGER:-PT${minutes}M") + appendLine("END:VALARM") + } appendLine("END:VEVENT") } appendLine("END:VCALENDAR") @@ -43,12 +57,17 @@ object IcsCodec { val endsAt: Long, val location: String?, val notes: String?, + val uid: String? = null, + val allDay: Boolean = false, + /** Minutes-before offsets carried by the feed's VALARM blocks. */ + val reminderMinutes: List = emptyList(), ) fun parse(ics: String): List { val events = mutableListOf() var inEvent = false var fields = mutableMapOf() + var alarms = mutableListOf() // RFC 5545 folds long lines with a leading space — unfold first. val unfolded = ics.replace("\r\n", "\n").replace("\n ", "").replace("\n\t", "") unfolded.lineSequence().forEach { line -> @@ -56,26 +75,41 @@ object IcsCodec { line.startsWith("BEGIN:VEVENT") -> { inEvent = true fields = mutableMapOf() + alarms = mutableListOf() } line.startsWith("END:VEVENT") -> { inEvent = false - val start = fields["DTSTART"]?.let(::parseInstant) + val rawStart = fields["DTSTART"] + val start = rawStart?.let(::parseInstant) val title = fields["SUMMARY"] - if (start != null && !title.isNullOrBlank()) { + if (rawStart != null && start != null && !title.isNullOrBlank()) { + // A date-only DTSTART is the ICS way of saying "all day". + val allDay = rawStart.length == 8 || + fields["DTSTART_PARAMS"]?.contains("DATE") == true events += ParsedEvent( title = unescape(title), startsAt = start, - endsAt = fields["DTEND"]?.let(::parseInstant) ?: (start + 3_600_000), + endsAt = fields["DTEND"]?.let(::parseInstant) + ?: (start + if (allDay) 86_400_000L else 3_600_000L), location = fields["LOCATION"]?.let(::unescape), notes = fields["DESCRIPTION"]?.let(::unescape), + uid = fields["UID"]?.trim()?.ifBlank { null }, + allDay = allDay, + reminderMinutes = alarms.toList(), ) } } + inEvent && line.startsWith("TRIGGER") -> { + parseTriggerMinutes(line.substringAfter(':'))?.let { alarms += it } + } inEvent -> { val separator = line.indexOf(':') if (separator > 0) { - // Strip parameters: "DTSTART;TZID=Europe/Berlin" → "DTSTART". - val key = line.substring(0, separator).substringBefore(';') + // Strip parameters: "DTSTART;TZID=Europe/Berlin" → "DTSTART", + // keeping them aside so VALUE=DATE still marks all-day rows. + val head = line.substring(0, separator) + val key = head.substringBefore(';') + if (head.contains(';')) fields["${key}_PARAMS"] = head.substringAfter(';') fields[key] = line.substring(separator + 1) } } @@ -84,9 +118,25 @@ object IcsCodec { return events } + /** "-PT30M", "-PT2H", "-P1D" become minutes before the start. */ + internal fun parseTriggerMinutes(value: String): Int? { + val trimmed = value.trim() + if (!trimmed.startsWith("-P")) return null + val body = trimmed.removePrefix("-P") + val days = Regex("(\\d+)D").find(body)?.groupValues?.get(1)?.toIntOrNull() ?: 0 + val time = body.substringAfter('T', "") + val hours = Regex("(\\d+)H").find(time)?.groupValues?.get(1)?.toIntOrNull() ?: 0 + val minutes = Regex("(\\d+)M").find(time)?.groupValues?.get(1)?.toIntOrNull() ?: 0 + val total = days * 1440 + hours * 60 + minutes + return if (total in 0..(60 * 24 * 30)) total else null + } + private fun format(epochMs: Long): String = UTC_FORMAT.format(Instant.ofEpochMilli(epochMs).atOffset(ZoneOffset.UTC)) + private fun formatDate(epochMs: Long): String = + DATE_FORMAT.format(Instant.ofEpochMilli(epochMs).atZone(ZoneId.systemDefault())) + private fun parseInstant(value: String): Long? = try { when { value.endsWith("Z") -> LocalDateTime.parse(value, UTC_FORMAT) diff --git a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/PlaceLookup.kt b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/PlaceLookup.kt new file mode 100644 index 0000000..f103fcb --- /dev/null +++ b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/data/PlaceLookup.kt @@ -0,0 +1,72 @@ +package com.lifeos.feature.calendar.data + +import com.lifeos.core.common.log.LifeLogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONArray +import javax.inject.Inject +import javax.inject.Singleton + +/** One suggestion from the geocoder. */ +data class PlaceSuggestion( + val label: String, + val latitude: Double, + val longitude: Double, +) + +/** + * Location autocomplete for the event editor, via OpenStreetMap's Nominatim + * (§Module 19). No key, no account, no tracking beyond the single query — and + * because Nominatim's policy requires an identifying User-Agent, one is sent. + * + * Queries are only issued from the editor, on a debounce, so the public + * instance's one-request-per-second rule is respected by construction. + */ +@Singleton +class PlaceLookup @Inject constructor( + private val okHttpClient: OkHttpClient, +) { + + suspend fun suggest(query: String, limit: Int = 6): List = + withContext(Dispatchers.IO) { + val needle = query.trim() + if (needle.length < 3) return@withContext emptyList() + val url = "https://nominatim.openstreetmap.org/search" + + "?format=jsonv2&addressdetails=0&limit=$limit&q=${encode(needle)}" + try { + val body = okHttpClient.newCall( + Request.Builder() + .url(url) + .header("User-Agent", USER_AGENT) + .header("Accept-Language", java.util.Locale.getDefault().toLanguageTag()) + .build(), + ).execute().use { response -> + if (!response.isSuccessful) return@withContext emptyList() + response.body.string() + } + val array = JSONArray(body) + (0 until array.length()).mapNotNull { index -> + val row = array.optJSONObject(index) ?: return@mapNotNull null + val label = row.optString("display_name").ifBlank { return@mapNotNull null } + PlaceSuggestion( + label = label, + latitude = row.optString("lat").toDoubleOrNull() ?: return@mapNotNull null, + longitude = row.optString("lon").toDoubleOrNull() ?: return@mapNotNull null, + ) + } + } catch (t: Throwable) { + LifeLogger.w(TAG, "Nominatim lookup failed", t) + emptyList() + } + } + + private fun encode(value: String): String = + java.net.URLEncoder.encode(value, "UTF-8") + + private companion object { + const val TAG = "PlaceLookup" + const val USER_AGENT = "LifeOS/1.0 (private personal calendar; offline-first)" + } +} diff --git a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/di/CalendarModule.kt b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/di/CalendarModule.kt index 85a7d72..474a8eb 100644 --- a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/di/CalendarModule.kt +++ b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/di/CalendarModule.kt @@ -1,7 +1,9 @@ package com.lifeos.feature.calendar.di import com.lifeos.core.service.LifeActionHandler +import com.lifeos.core.service.LifeDataProvider import com.lifeos.feature.calendar.data.CalendarActionHandler +import com.lifeos.feature.calendar.data.CalendarProvider import com.lifeos.feature.calendar.data.CalendarRepository import com.lifeos.feature.calendar.data.DefaultCalendarRepository import dagger.Binds @@ -22,4 +24,8 @@ internal abstract class CalendarModule { @Binds @IntoSet abstract fun bindCalendarActionHandler(impl: CalendarActionHandler): LifeActionHandler + + @Binds + @IntoSet + abstract fun bindCalendarProvider(impl: CalendarProvider): LifeDataProvider } diff --git a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/work/CalendarSubscriptionWorker.kt b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/work/CalendarSubscriptionWorker.kt new file mode 100644 index 0000000..83bd5f2 --- /dev/null +++ b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/work/CalendarSubscriptionWorker.kt @@ -0,0 +1,54 @@ +package com.lifeos.feature.calendar.work + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.lifeos.core.common.log.LifeLogger +import com.lifeos.feature.calendar.data.CalendarSubscriptionSync +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import java.util.concurrent.TimeUnit + +/** + * Keeps subscribed calendars live (§Module 19). Holiday and shared feeds change + * without warning, so they are pulled every six hours in the background rather + * than only when the Calendar screen happens to be open. + */ +@HiltWorker +class CalendarSubscriptionWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val subscriptionSync: CalendarSubscriptionSync, +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result = try { + subscriptionSync.syncAll() + Result.success() + } catch (t: Throwable) { + LifeLogger.w(TAG, "Subscription sync failed", t) + Result.retry() + } + + companion object { + private const val TAG = "CalendarSubscriptionWorker" + private const val WORK_NAME = "lifeos-calendar-subscriptions" + + fun schedule(context: Context) { + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + PeriodicWorkRequestBuilder(6, TimeUnit.HOURS) + .setConstraints( + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(), + ) + .build(), + ) + } + } +} diff --git a/feature/calendar/src/test/kotlin/com/lifeos/feature/calendar/data/CalendarCodecTest.kt b/feature/calendar/src/test/kotlin/com/lifeos/feature/calendar/data/CalendarCodecTest.kt new file mode 100644 index 0000000..83e83b4 --- /dev/null +++ b/feature/calendar/src/test/kotlin/com/lifeos/feature/calendar/data/CalendarCodecTest.kt @@ -0,0 +1,97 @@ +package com.lifeos.feature.calendar.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Covers the pieces the calendar's colours, alerts and ICS feeds hang on. */ +class CalendarCodecTest { + + @Test + fun `reminder offsets round trip sorted and deduped`() { + val encoded = DefaultCalendarRepository.encodeReminders(listOf(60, 0, 30, 30)) + assertEquals("0,30,60", encoded) + assertEquals(listOf(0, 30, 60), DefaultCalendarRepository.decodeReminders(encoded)) + } + + @Test + fun `garbage in a reminder column is ignored rather than crashing`() { + assertEquals(listOf(15), DefaultCalendarRepository.decodeReminders("15,,abc, ")) + assertEquals(emptyList(), DefaultCalendarRepository.decodeReminders("")) + } + + @Test + fun `offsets read the way a person would say them`() { + assertEquals("now", DefaultCalendarRepository.humanOffset(0)) + assertEquals("45m", DefaultCalendarRepository.humanOffset(45)) + assertEquals("2h", DefaultCalendarRepository.humanOffset(120)) + assertEquals("1d", DefaultCalendarRepository.humanOffset(1440)) + assertEquals("7d", DefaultCalendarRepository.humanOffset(10080)) + } + + @Test + fun `palette accepts names and hex and refuses nonsense`() { + assertEquals(0xFF22C55E.toInt(), CalendarPalette.parse("green")) + assertEquals(0xFF8B5CF6.toInt(), CalendarPalette.parse("#8B5CF6")) + assertEquals(0xFF8B5CF6.toInt(), CalendarPalette.parse("8b5cf6")) + assertEquals(CalendarPalette.default, CalendarPalette.parse("purple-ish")) + assertEquals("#22C55E", CalendarPalette.hex(0xFF22C55E.toInt())) + } + + @Test + fun `a new calendar avoids colours already in use`() { + val used = CalendarPalette.named.take(3).map { it.second } + val picked = CalendarPalette.nextUnused(used) + assertTrue(picked !in used) + assertEquals(CalendarPalette.named[3].second, picked) + } + + @Test + fun `VALARM triggers become minutes before the start`() { + assertEquals(30, IcsCodec.parseTriggerMinutes("-PT30M")) + assertEquals(120, IcsCodec.parseTriggerMinutes("-PT2H")) + assertEquals(1440, IcsCodec.parseTriggerMinutes("-P1D")) + assertEquals(90, IcsCodec.parseTriggerMinutes("-PT1H30M")) + // Alarms after the start are not something this calendar models. + assertNull(IcsCodec.parseTriggerMinutes("PT15M")) + } + + @Test + fun `a holiday feed parses as all-day events with their uid`() { + val ics = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + UID:2026-01-01-newyear@example.com + SUMMARY:New Year's Day + DTSTART;VALUE=DATE:20260101 + DTEND;VALUE=DATE:20260102 + END:VEVENT + BEGIN:VEVENT + UID:standup-42@example.com + SUMMARY:Stand-up + DTSTART:20260105T083000Z + DTEND:20260105T090000Z + LOCATION:Kitchen + BEGIN:VALARM + ACTION:DISPLAY + TRIGGER:-PT10M + END:VALARM + END:VEVENT + END:VCALENDAR + """.trimIndent() + + val events = IcsCodec.parse(ics) + assertEquals(2, events.size) + val holiday = events.first() + assertEquals("New Year's Day", holiday.title) + assertTrue(holiday.allDay) + assertEquals("2026-01-01-newyear@example.com", holiday.uid) + + val standup = events[1] + assertTrue(!standup.allDay) + assertEquals("Kitchen", standup.location) + assertEquals(listOf(10), standup.reminderMinutes) + } +} diff --git a/feature/chat/src/main/kotlin/com/lifeos/feature/chat/data/JarvisToolbox.kt b/feature/chat/src/main/kotlin/com/lifeos/feature/chat/data/JarvisToolbox.kt index 0a2ebb4..3104c3c 100644 --- a/feature/chat/src/main/kotlin/com/lifeos/feature/chat/data/JarvisToolbox.kt +++ b/feature/chat/src/main/kotlin/com/lifeos/feature/chat/data/JarvisToolbox.kt @@ -67,6 +67,9 @@ class JarvisToolbox @Inject constructor( [[add_task: title]] [[done_task: id]] [[delete_task: id]] [[timer: 5m]] [[remind: 18:00 | title]] [[remind: +25m | title]] [[cancel_reminder: id]] [[event: tomorrow 15:00 | title]] [[note: title | body]] + [[event_full: tomorrow 15:00 | title | calendar | 10,60 (alert minutes) | location]] + [[calendar_new: name | colour or #hex]] [[calendar_subscribe: name | ics url]] + [[calendar_sync:]] [[edit_note: title | new full body]] [[append_note: title | text to add]] [[paste: title | text]] [[burner_paste: title | text]] (burner = one-time, encrypted) [[download: url]] [[water_plant: name]] [[add_plant: name | species | every N days]] @@ -294,6 +297,63 @@ class JarvisToolbox @Inject constructor( dispatcher.dispatch(LifeAction.CreateCalendarEvent(title.ifBlank { "Event" }, at, at + 3_600_000L, SOURCE)) "Event ${AT.format(Date(at))}: $title" } + "event_full" -> { + // when | title | calendar | alert minutes | location + val parts = args.split('|').map { it.trim() } + val at = parseWhen(parts.getOrElse(0) { "" }) ?: error("bad time") + val title = parts.getOrElse(1) { "" }.ifBlank { "Event" } + val calendarName = parts.getOrElse(2) { "" } + val alerts = parts.getOrElse(3) { "" } + .split(',', ' ') + .mapNotNull { it.trim().toIntOrNull() } + val location = parts.getOrElse(4) { "" } + dispatch( + LifeAction.CreateCalendarEvent( + title = title, + startsAt = at, + endsAt = at + 3_600_000L, + source = SOURCE, + calendarName = calendarName, + location = location, + reminderMinutes = alerts, + ), + ) + buildString { + append("Event ${AT.format(Date(at))}: $title") + if (calendarName.isNotBlank()) append(" in $calendarName") + if (alerts.isNotEmpty()) append(", alerts ${alerts.joinToString("/")}m before") + } + } + "calendar_new" -> { + val (name, colour) = splitArgs(args) + if (name.isBlank()) error("name the calendar") + dispatch( + LifeAction.CreateCalendar( + name = name, + colorName = colour, + makeDefault = false, + source = SOURCE, + ), + ) + echo.lastNote ?: "Calendar \"$name\" created" + } + "calendar_subscribe" -> { + val (name, url) = splitArgs(args) + if (!url.contains("://")) error("need an ics or webcal link") + dispatch( + LifeAction.SubscribeCalendar( + name = name.ifBlank { "Subscribed calendar" }, + url = url, + colorName = "", + source = SOURCE, + ), + ) + echo.lastNote ?: "Subscribed to $name" + } + "calendar_sync" -> { + dispatch(LifeAction.SyncCalendars(SOURCE)) + echo.lastNote ?: "Subscriptions refreshed" + } "note" -> { val (title, body) = splitArgs(args) dispatcher.dispatch(LifeAction.CreateNote(title.take(48), body.ifBlank { title }, SOURCE)) diff --git a/feature/clock/build.gradle.kts b/feature/clock/build.gradle.kts index 34a1b8f..2ccbbba 100644 --- a/feature/clock/build.gradle.kts +++ b/feature/clock/build.gradle.kts @@ -8,6 +8,7 @@ dependencies { implementation(projects.core.common) implementation(projects.core.designsystem) implementation(projects.core.datastore) + implementation(projects.core.service) implementation(projects.core.ui) implementation(libs.androidx.lifecycle.viewmodel.compose) diff --git a/feature/clock/src/main/kotlin/com/lifeos/feature/clock/ClockScreen.kt b/feature/clock/src/main/kotlin/com/lifeos/feature/clock/ClockScreen.kt index 1697028..d82ccae 100644 --- a/feature/clock/src/main/kotlin/com/lifeos/feature/clock/ClockScreen.kt +++ b/feature/clock/src/main/kotlin/com/lifeos/feature/clock/ClockScreen.kt @@ -45,8 +45,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -72,6 +70,8 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.lifeos.core.designsystem.component.FadeThrough import com.lifeos.core.designsystem.component.EmptyState +import com.lifeos.feature.clock.data.ClockTimerState +import com.lifeos.feature.clock.data.StopwatchState import java.time.LocalTime import java.time.ZoneId import java.time.ZonedDateTime @@ -114,8 +114,8 @@ fun ClockRoute(viewModel: ClockViewModel = hiltViewModel()) { 1 -> WorldTab(uiState, viewModel::onEvent) 2 -> TimeZoneMapTab(viewModel::onEvent) 3 -> ConvertTab(uiState) - 4 -> StopwatchTab() - else -> TimerTab() + 4 -> StopwatchTab(uiState.stopwatch, viewModel::onEvent) + else -> TimerTab(uiState.timer, viewModel::onEvent) } } } @@ -412,17 +412,7 @@ internal fun friendlyZone(zoneId: String): String = when { } @Composable -private fun StopwatchTab() { - var running by remember { mutableStateOf(false) } - var elapsedMs by remember { mutableLongStateOf(0L) } - val laps = remember { mutableStateOf(listOf()) } - LaunchedEffect(running) { - val startedAt = System.currentTimeMillis() - elapsedMs - while (running) { - elapsedMs = System.currentTimeMillis() - startedAt - delay(37) - } - } +private fun StopwatchTab(state: StopwatchState, onEvent: (ClockUiEvent) -> Unit) { Column( modifier = Modifier .fillMaxSize() @@ -430,27 +420,33 @@ private fun StopwatchTab() { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(20.dp), ) { - Text(formatStopwatch(elapsedMs), style = MaterialTheme.typography.displayMedium, fontFamily = FontFamily.Monospace) + Text( + formatStopwatch(state.elapsedMs), + style = MaterialTheme.typography.displayMedium, + fontFamily = FontFamily.Monospace, + ) Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Button(onClick = { running = !running }) { Text(if (running) "Pause" else "Start") } + Button(onClick = { onEvent(ClockUiEvent.StopwatchToggle) }) { + Text(if (state.running) "Pause" else "Start") + } OutlinedButton( - onClick = { - // Running → record a lap; paused → reset everything. - if (running) { - laps.value = laps.value + elapsedMs - } else { - elapsedMs = 0 - laps.value = emptyList() - } - }, - enabled = running || elapsedMs > 0, - ) { Text(if (running) "Lap" else "Reset") } + onClick = { onEvent(ClockUiEvent.StopwatchLapOrReset) }, + enabled = state.running || state.elapsedMs > 0, + ) { Text(if (state.running) "Lap" else "Reset") } + } + if (state.running || state.elapsedMs > 0) { + Text( + "Counting in the notification shade too — it keeps going with the app closed.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) } - if (laps.value.isNotEmpty()) { + if (state.laps.isNotEmpty()) { LazyColumn(modifier = Modifier.fillMaxWidth()) { - val entries = laps.value.mapIndexed { i, total -> - val split = if (i == 0) total else total - laps.value[i - 1] - Triple(laps.value.size - i, split, total) + val entries = state.laps.mapIndexed { i, total -> + val split = if (i == 0) total else total - state.laps[i - 1] + Triple(state.laps.size - i, split, total) }.reversed() items(entries) { (number, split, total) -> ListItem( @@ -474,25 +470,11 @@ private fun formatStopwatch(ms: Long): String { } @Composable -private fun TimerTab() { - var hours by remember { mutableIntStateOf(0) } - var minutes by remember { mutableIntStateOf(5) } - var seconds by remember { mutableIntStateOf(0) } - var remainingSeconds by remember { mutableLongStateOf(0L) } - var running by remember { mutableStateOf(false) } +private fun TimerTab(state: ClockTimerState, onEvent: (ClockUiEvent) -> Unit) { var showAsSeconds by remember { mutableStateOf(false) } // null = wheels; 0/1/2 = typing into hours/minutes/seconds. var typedField by remember { mutableStateOf(null) } - LaunchedEffect(running) { - while (running && remainingSeconds > 0) { - delay(1_000) - remainingSeconds -= 1 - } - if (remainingSeconds == 0L) running = false - } - - val configured = hours * 3600L + minutes * 60L + seconds Column( modifier = Modifier .fillMaxSize() @@ -503,85 +485,104 @@ private fun TimerTab() { // Countdown, typed entry and wheels all fade into one another. FadeThrough( targetState = when { - running || remainingSeconds > 0 -> 0 + state.armed -> 0 typedField != null -> 1 else -> 2 }, label = "timer-mode", ) { mode -> - if (mode == 0) { - // Centered time; the display toggle sits below so nothing skews. - Text( - if (showAsSeconds) "${remainingSeconds}s" else formatCountdown(remainingSeconds), - style = MaterialTheme.typography.displayLarge, - fontFamily = FontFamily.Monospace, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedButton(onClick = { showAsSeconds = !showAsSeconds }) { - Icon(Icons.Filled.SwapHoriz, contentDescription = null) - Text(if (showAsSeconds) " Show mm:ss" else " Show seconds") - } - } else if (mode == 1) { - // Typed entry: number keyboard, auto-advancing to the next field. - TypedDuration( - hours = hours, - minutes = minutes, - seconds = seconds, - startField = typedField ?: 0, - onHours = { hours = it }, - onMinutes = { minutes = it }, - onSeconds = { seconds = it }, - onDone = { typedField = null }, - ) - } else { - // Samsung-style three infinite wheels; tap one to type instead. - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - WheelPicker(range = 0..99, value = hours, onValue = { hours = it }, onTap = { typedField = 0 }) - WheelLabel("h") - WheelPicker(range = 0..59, value = minutes, onValue = { minutes = it }, onTap = { typedField = 1 }) - WheelLabel("m") - WheelPicker(range = 0..59, value = seconds, onValue = { seconds = it }, onTap = { typedField = 2 }) - WheelLabel("s") + // Each mode owns a Column of its own: the fade container stacks its + // children, so a bare Text + Button pair would sit on top of one another. + when (mode) { + 0 -> Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (showAsSeconds) { + "${state.remainingSeconds}s" + } else { + formatCountdown(state.remainingSeconds) + }, + style = MaterialTheme.typography.displayLarge, + fontFamily = FontFamily.Monospace, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedButton(onClick = { showAsSeconds = !showAsSeconds }) { + Icon(Icons.Filled.SwapHoriz, contentDescription = null) + Text(if (showAsSeconds) " Show mm:ss" else " Show seconds") + } + } + + 1 -> TypedDuration( + hours = state.hours, + minutes = state.minutes, + seconds = state.seconds, + startField = typedField ?: 0, + onHours = { onEvent(ClockUiEvent.TimerHoursChanged(it)) }, + onMinutes = { onEvent(ClockUiEvent.TimerMinutesChanged(it)) }, + onSeconds = { onEvent(ClockUiEvent.TimerSecondsChanged(it)) }, + onDone = { typedField = null }, + ) + + else -> Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + // Samsung-style three infinite wheels; tap one to type instead. + WheelPicker( + range = 0..99, + value = state.hours, + onValue = { onEvent(ClockUiEvent.TimerHoursChanged(it)) }, + onTap = { typedField = 0 }, + ) + WheelLabel("h") + WheelPicker( + range = 0..59, + value = state.minutes, + onValue = { onEvent(ClockUiEvent.TimerMinutesChanged(it)) }, + onTap = { typedField = 1 }, + ) + WheelLabel("m") + WheelPicker( + range = 0..59, + value = state.seconds, + onValue = { onEvent(ClockUiEvent.TimerSecondsChanged(it)) }, + onTap = { typedField = 2 }, + ) + WheelLabel("s") + } } } - } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf(1L, 5L, 10L, 25L).forEach { m -> + listOf(1, 5, 10, 25).forEach { m -> FilterChip( - selected = !running && remainingSeconds == 0L && configured == m * 60, - onClick = { - hours = 0; minutes = m.toInt(); seconds = 0 - remainingSeconds = 0 - running = false - }, + selected = !state.armed && state.configuredSeconds == m * 60L, + onClick = { onEvent(ClockUiEvent.TimerPreset(m)) }, label = { Text("${m}m") }, ) } } Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Button( - onClick = { - if (running) { - running = false - } else { - if (remainingSeconds == 0L) remainingSeconds = configured - if (remainingSeconds > 0) running = true - } - }, - enabled = running || remainingSeconds > 0 || configured > 0, - ) { Text(if (running) "Pause" else "Start") } + onClick = { onEvent(ClockUiEvent.TimerToggle) }, + enabled = state.armed || state.configuredSeconds > 0, + ) { Text(if (state.running) "Pause" else "Start") } OutlinedButton( - onClick = { - running = false - remainingSeconds = 0 - }, - enabled = running || remainingSeconds > 0, + onClick = { onEvent(ClockUiEvent.TimerReset) }, + enabled = state.armed, ) { Text("Reset") } } + if (state.armed) { + Text( + "Running in the background with a notification you can pause from.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } } } diff --git a/feature/clock/src/main/kotlin/com/lifeos/feature/clock/ClockViewModel.kt b/feature/clock/src/main/kotlin/com/lifeos/feature/clock/ClockViewModel.kt index 81f0b06..389e156 100644 --- a/feature/clock/src/main/kotlin/com/lifeos/feature/clock/ClockViewModel.kt +++ b/feature/clock/src/main/kotlin/com/lifeos/feature/clock/ClockViewModel.kt @@ -3,6 +3,10 @@ package com.lifeos.feature.clock import androidx.lifecycle.viewModelScope import com.lifeos.core.common.viewmodel.LifeViewModel import com.lifeos.core.datastore.SettingsRepository +import com.lifeos.feature.clock.data.ClockTimerController +import com.lifeos.feature.clock.data.ClockTimerState +import com.lifeos.feature.clock.data.StopwatchController +import com.lifeos.feature.clock.data.StopwatchState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -14,6 +18,9 @@ data class ClockUiState( val face: Int = 0, val worldClocks: List = emptyList(), val zoneDraft: String = "", + /** Countdown and stopwatch live in singletons, so they survive tab switches. */ + val timer: ClockTimerState = ClockTimerState(), + val stopwatch: StopwatchState = StopwatchState(), val message: String? = null, ) @@ -25,6 +32,18 @@ sealed interface ClockUiEvent { /** Map tap: nearest city's real zone (DST-correct), ocean falls back to UTC offset. */ data class AddZoneFromMap(val latitude: Double, val longitude: Double) : ClockUiEvent data class RemoveZone(val zoneId: String) : ClockUiEvent + + data class TimerHoursChanged(val value: Int) : ClockUiEvent + data class TimerMinutesChanged(val value: Int) : ClockUiEvent + data class TimerSecondsChanged(val value: Int) : ClockUiEvent + data class TimerPreset(val minutes: Int) : ClockUiEvent + data object TimerToggle : ClockUiEvent + data object TimerReset : ClockUiEvent + + data object StopwatchToggle : ClockUiEvent + /** Running: record a lap. Paused: wipe the run. */ + data object StopwatchLapOrReset : ClockUiEvent + data object DismissMessage : ClockUiEvent } @@ -33,6 +52,8 @@ sealed interface ClockUiEffect @HiltViewModel class ClockViewModel @Inject constructor( private val settingsRepository: SettingsRepository, + private val timerController: ClockTimerController, + private val stopwatchController: StopwatchController, ) : LifeViewModel(ClockUiState()) { init { @@ -41,6 +62,12 @@ class ClockViewModel @Inject constructor( updateState { it.copy(worldClocks = zones) } } } + viewModelScope.launch { + timerController.state.collect { timer -> updateState { it.copy(timer = timer) } } + } + viewModelScope.launch { + stopwatchController.state.collect { watch -> updateState { it.copy(stopwatch = watch) } } + } } override fun onEvent(event: ClockUiEvent) { @@ -89,6 +116,18 @@ class ClockViewModel @Inject constructor( val current = settingsRepository.worldClocks.first() settingsRepository.setWorldClocks(current - event.zoneId) } + + is ClockUiEvent.TimerHoursChanged -> timerController.setHours(event.value) + is ClockUiEvent.TimerMinutesChanged -> timerController.setMinutes(event.value) + is ClockUiEvent.TimerSecondsChanged -> timerController.setSeconds(event.value) + is ClockUiEvent.TimerPreset -> timerController.setPresetMinutes(event.minutes) + ClockUiEvent.TimerToggle -> timerController.toggle() + ClockUiEvent.TimerReset -> timerController.reset() + + ClockUiEvent.StopwatchToggle -> stopwatchController.toggle() + ClockUiEvent.StopwatchLapOrReset -> + if (uiState.value.stopwatch.running) stopwatchController.lap() else stopwatchController.reset() + ClockUiEvent.DismissMessage -> updateState { it.copy(message = null) } } } diff --git a/feature/clock/src/main/kotlin/com/lifeos/feature/clock/data/ClockTimerController.kt b/feature/clock/src/main/kotlin/com/lifeos/feature/clock/data/ClockTimerController.kt new file mode 100644 index 0000000..3963fb5 --- /dev/null +++ b/feature/clock/src/main/kotlin/com/lifeos/feature/clock/data/ClockTimerController.kt @@ -0,0 +1,152 @@ +package com.lifeos.feature.clock.data + +import android.content.Context +import android.os.SystemClock +import android.os.VibrationEffect +import android.os.VibratorManager +import com.lifeos.core.service.TimerCommand +import com.lifeos.core.service.TimerCommandBus +import com.lifeos.core.service.TimerKind +import com.lifeos.core.service.TimerNotifier +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** The Clock module's countdown, as the whole app sees it. */ +data class ClockTimerState( + val hours: Int = 0, + val minutes: Int = 5, + val seconds: Int = 0, + /** 0 when the timer has never been armed; otherwise what is left. */ + val remainingSeconds: Long = 0, + val running: Boolean = false, +) { + val configuredSeconds: Long get() = hours * 3600L + minutes * 60L + seconds + val armed: Boolean get() = running || remainingSeconds > 0 +} + +/** + * Clock's timer, lifted out of the composable (§Module 4). + * + * Like Focus's timer it counts from an absolute deadline, so leaving the tab, + * the app or the screen changes nothing, and it posts an ongoing notification + * whose countdown the system itself renders. + */ +@Singleton +class ClockTimerController @Inject constructor( + @ApplicationContext private val context: Context, + private val notifier: TimerNotifier, +) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var ticker: Job? = null + private var deadlineElapsed = 0L + + private val _state = MutableStateFlow(ClockTimerState()) + val state = _state.asStateFlow() + + init { + scope.launch { + TimerCommandBus.commands.collect { (kind, command) -> + if (kind != TimerKind.CLOCK_TIMER) return@collect + when (command) { + TimerCommand.TOGGLE -> toggle() + TimerCommand.RESET -> reset() + } + } + } + } + + fun setHours(value: Int) = editConfig { it.copy(hours = value.coerceIn(0, 99)) } + fun setMinutes(value: Int) = editConfig { it.copy(minutes = value.coerceIn(0, 59)) } + fun setSeconds(value: Int) = editConfig { it.copy(seconds = value.coerceIn(0, 59)) } + + /** Preset chips: set the length and clear anything already counting. */ + fun setPresetMinutes(value: Int) { + stopTicker() + deadlineElapsed = 0L + notifier.cancel(TimerKind.CLOCK_TIMER) + _state.value = _state.value.copy(hours = 0, minutes = value, seconds = 0, remainingSeconds = 0, running = false) + } + + fun toggle() { + if (_state.value.running) pause() else start() + } + + fun start() { + val current = _state.value + if (current.running) return + val remaining = if (current.remainingSeconds > 0) current.remainingSeconds else current.configuredSeconds + if (remaining <= 0) return + deadlineElapsed = SystemClock.elapsedRealtime() + remaining * 1000L + _state.value = current.copy(remainingSeconds = remaining, running = true) + notifier.countdown(TimerKind.CLOCK_TIMER, "Timer", remaining * 1000L, running = true) + startTicker() + } + + fun pause() { + if (!_state.value.running) return + stopTicker() + val left = remainingFromDeadline() + deadlineElapsed = 0L + _state.value = _state.value.copy(running = false, remainingSeconds = left) + notifier.countdown(TimerKind.CLOCK_TIMER, "Timer", left * 1000L, running = false) + } + + fun reset() { + stopTicker() + deadlineElapsed = 0L + _state.value = _state.value.copy(running = false, remainingSeconds = 0) + notifier.cancel(TimerKind.CLOCK_TIMER) + } + + /** Config edits are only meaningful while nothing is counting. */ + private fun editConfig(block: (ClockTimerState) -> ClockTimerState) { + if (_state.value.armed) return + _state.value = block(_state.value) + } + + private fun startTicker() { + stopTicker() + ticker = scope.launch { + while (true) { + val left = remainingFromDeadline() + _state.value = _state.value.copy(remainingSeconds = left) + if (left <= 0L) { + finish() + return@launch + } + delay(250) + } + } + } + + private fun stopTicker() { + ticker?.cancel() + ticker = null + } + + private fun finish() { + deadlineElapsed = 0L + _state.value = _state.value.copy(running = false, remainingSeconds = 0) + notifier.finished(TimerKind.CLOCK_TIMER, "Timer done") + runCatching { + context.getSystemService(VibratorManager::class.java).defaultVibrator + .vibrate(VibrationEffect.createWaveform(longArrayOf(0, 300, 150, 300, 150, 500), -1)) + } + } + + private fun remainingFromDeadline(): Long { + if (deadlineElapsed <= 0L) return _state.value.remainingSeconds + val ms = deadlineElapsed - SystemClock.elapsedRealtime() + return ((ms + 999) / 1000).coerceAtLeast(0L) + } +} diff --git a/feature/clock/src/main/kotlin/com/lifeos/feature/clock/data/StopwatchController.kt b/feature/clock/src/main/kotlin/com/lifeos/feature/clock/data/StopwatchController.kt new file mode 100644 index 0000000..1911895 --- /dev/null +++ b/feature/clock/src/main/kotlin/com/lifeos/feature/clock/data/StopwatchController.kt @@ -0,0 +1,107 @@ +package com.lifeos.feature.clock.data + +import android.os.SystemClock +import com.lifeos.core.service.TimerCommand +import com.lifeos.core.service.TimerCommandBus +import com.lifeos.core.service.TimerKind +import com.lifeos.core.service.TimerNotifier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +data class StopwatchState( + val elapsedMs: Long = 0L, + val running: Boolean = false, + /** Cumulative totals at each lap press. */ + val laps: List = emptyList(), +) + +/** + * Clock's stopwatch (§Module 4). Counts from an absolute start instant so tab + * switches and backgrounding cannot lose time, and mirrors itself into an + * ongoing notification that keeps counting on its own. + */ +@Singleton +class StopwatchController @Inject constructor( + private val notifier: TimerNotifier, +) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var ticker: Job? = null + + /** Elapsed-realtime instant the current run started from, minus prior time. */ + private var originElapsed = 0L + + private val _state = MutableStateFlow(StopwatchState()) + val state = _state.asStateFlow() + + init { + scope.launch { + TimerCommandBus.commands.collect { (kind, command) -> + if (kind != TimerKind.STOPWATCH) return@collect + when (command) { + TimerCommand.TOGGLE -> toggle() + TimerCommand.RESET -> reset() + } + } + } + } + + fun toggle() { + if (_state.value.running) pause() else start() + } + + fun start() { + if (_state.value.running) return + originElapsed = SystemClock.elapsedRealtime() - _state.value.elapsedMs + _state.value = _state.value.copy(running = true) + notifier.stopwatch(TimerKind.STOPWATCH, "Stopwatch", _state.value.elapsedMs, running = true) + startTicker() + } + + fun pause() { + if (!_state.value.running) return + stopTicker() + val elapsed = elapsedFromOrigin() + originElapsed = 0L + _state.value = _state.value.copy(running = false, elapsedMs = elapsed) + notifier.stopwatch(TimerKind.STOPWATCH, "Stopwatch", elapsed, running = false) + } + + fun lap() { + if (!_state.value.running) return + _state.value = _state.value.copy(laps = _state.value.laps + elapsedFromOrigin()) + } + + fun reset() { + stopTicker() + originElapsed = 0L + _state.value = StopwatchState() + notifier.cancel(TimerKind.STOPWATCH) + } + + private fun startTicker() { + stopTicker() + ticker = scope.launch { + while (true) { + _state.value = _state.value.copy(elapsedMs = elapsedFromOrigin()) + delay(37) + } + } + } + + private fun stopTicker() { + ticker?.cancel() + ticker = null + } + + private fun elapsedFromOrigin(): Long = + if (originElapsed <= 0L) _state.value.elapsedMs else SystemClock.elapsedRealtime() - originElapsed +} diff --git a/feature/reminders/src/main/kotlin/com/lifeos/feature/reminders/data/RemindersActionHandler.kt b/feature/reminders/src/main/kotlin/com/lifeos/feature/reminders/data/RemindersActionHandler.kt index ac088ab..7a5e6e3 100644 --- a/feature/reminders/src/main/kotlin/com/lifeos/feature/reminders/data/RemindersActionHandler.kt +++ b/feature/reminders/src/main/kotlin/com/lifeos/feature/reminders/data/RemindersActionHandler.kt @@ -1,27 +1,41 @@ package com.lifeos.feature.reminders.data import com.lifeos.core.common.result.LifeResult +import com.lifeos.core.database.reminders.ReminderDao import com.lifeos.core.service.LifeAction import com.lifeos.core.service.LifeActionHandler import javax.inject.Inject -/** Executes [LifeAction.CreateReminder] for other modules (calendar, rules). */ +/** Executes reminder actions for other modules (calendar, rules, Jarvis). */ internal class RemindersActionHandler @Inject constructor( private val remindersRepository: RemindersRepository, + private val reminderDao: ReminderDao, ) : LifeActionHandler { - override fun canHandle(action: LifeAction): Boolean = action is LifeAction.CreateReminder + override fun canHandle(action: LifeAction): Boolean = + action is LifeAction.CreateReminder || action is LifeAction.CancelRemindersFor - override suspend fun execute(action: LifeAction): LifeResult { - val create = action as LifeAction.CreateReminder - return when (val result = remindersRepository.create( - title = create.title, - at = create.at, - recurrence = create.recurrence, - source = create.source, - )) { + override suspend fun execute(action: LifeAction): LifeResult = when (action) { + is LifeAction.CreateReminder -> when ( + val result = remindersRepository.create( + title = action.title, + at = action.at, + recurrence = action.recurrence, + source = action.source, + ) + ) { is LifeResult.Success -> LifeResult.Success(result.value) is LifeResult.Failure -> result } + + is LifeAction.CancelRemindersFor -> { + // Editing an event has to take its old alerts with it, or the phone + // rings for a time that no longer exists. + val stale = reminderDao.bySource(action.module, action.entityId) + stale.forEach { remindersRepository.delete(it.id) } + LifeResult.Success(stale.size.toLong()) + } + + else -> LifeResult.Success(null) } } diff --git a/feature/screentime/build.gradle.kts b/feature/screentime/build.gradle.kts index cb90473..bd25b9f 100644 --- a/feature/screentime/build.gradle.kts +++ b/feature/screentime/build.gradle.kts @@ -15,6 +15,9 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.hilt.work) + ksp(libs.androidx.hilt.compiler) implementation(libs.androidx.compose.material.icons.extended) implementation(libs.kotlinx.serialization.json) } diff --git a/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/work/ScreenTimeSyncWorker.kt b/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/work/ScreenTimeSyncWorker.kt new file mode 100644 index 0000000..a8689f3 --- /dev/null +++ b/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/work/ScreenTimeSyncWorker.kt @@ -0,0 +1,58 @@ +package com.lifeos.feature.screentime.work + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.lifeos.core.common.log.LifeLogger +import com.lifeos.feature.screentime.data.ScreenTimeCollector +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import java.util.concurrent.TimeUnit + +/** + * Keeps the screen-time archive complete without the module ever being opened + * (§Module Screen Time). + * + * Android purges raw usage events after roughly a month, so a phone that goes + * weeks between visits to this screen would silently lose the gap. This worker + * runs every few hours and re-derives the recent window, which means the local + * copy is always ahead of the system's retention. + */ +@HiltWorker +class ScreenTimeSyncWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val collector: ScreenTimeCollector, +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result = try { + if (!collector.hasPermission()) { + // Nothing to harvest yet; retrying would just burn wake-ups. + Result.success() + } else { + collector.sync(days = 45) + Result.success() + } + } catch (t: Throwable) { + LifeLogger.w(TAG, "Screen-time sync failed", t) + Result.retry() + } + + companion object { + private const val TAG = "ScreenTimeSyncWorker" + private const val WORK_NAME = "lifeos-screentime-sync" + + fun schedule(context: Context) { + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + // UPDATE, not KEEP: an interval change has to take effect on upgrade. + ExistingPeriodicWorkPolicy.UPDATE, + PeriodicWorkRequestBuilder(4, TimeUnit.HOURS).build(), + ) + } + } +} diff --git a/feature/sync/build.gradle.kts b/feature/sync/build.gradle.kts index af7e500..9c24cce 100644 --- a/feature/sync/build.gradle.kts +++ b/feature/sync/build.gradle.kts @@ -14,6 +14,9 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.hilt.work) + ksp(libs.androidx.hilt.compiler) implementation(libs.androidx.compose.material.icons.extended) implementation(libs.okhttp) diff --git a/feature/sync/src/main/kotlin/com/lifeos/feature/sync/work/AutoBackupWorker.kt b/feature/sync/src/main/kotlin/com/lifeos/feature/sync/work/AutoBackupWorker.kt new file mode 100644 index 0000000..4ee8cec --- /dev/null +++ b/feature/sync/src/main/kotlin/com/lifeos/feature/sync/work/AutoBackupWorker.kt @@ -0,0 +1,60 @@ +package com.lifeos.feature.sync.work + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.lifeos.core.common.log.LifeLogger +import com.lifeos.core.datastore.SettingsRepository +import com.lifeos.feature.sync.data.BackupService +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.first +import java.util.concurrent.TimeUnit + +/** + * Daily unattended backup (§Module Sync). Runs only once a passphrase exists — + * without one there is nothing to encrypt with, and a silent plaintext dump is + * not a trade this app makes. + */ +@HiltWorker +class AutoBackupWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val backupService: BackupService, + private val settingsRepository: SettingsRepository, +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result = try { + if (settingsRepository.backupPassphrase.first().isBlank()) { + Result.success() + } else { + backupService.backupNow().fold( + onSuccess = { Result.success() }, + onFailure = { + LifeLogger.w(TAG, "Auto backup failed", it) + Result.retry() + }, + ) + } + } catch (t: Throwable) { + LifeLogger.w(TAG, "Auto backup crashed", t) + Result.retry() + } + + companion object { + private const val TAG = "AutoBackupWorker" + private const val WORK_NAME = "lifeos-auto-backup" + + fun schedule(context: Context) { + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + PeriodicWorkRequestBuilder(1, TimeUnit.DAYS).build(), + ) + } + } +}