diff --git a/README.md b/README.md index 0e69ec7..d346567 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ and rule traces back to a section (and often a community demand source) there. | Navigation bar settings list every module (was still showing only the original four), with a searchable picker; the default bar stays Home + Calendar/Tasks/Inbox/Jarvis | Done | | Downloader resolves session-bound player links (ThisVid and the rest of the kt_player family) by letting the page's own player run in an offscreen WebView and recording the media request, then downloads it with that session's Referer, Cookie and User-Agent; teaser/sprite/ad URLs are filtered out | Done | | Brick tags are programmed on pairing (LifeOS MIME record + Android Application Record), so a tap flips a mode with the app closed and without a chooser; broader NDEF/TECH/TAG filters, tag id read from the record, and an NFC-off banner that opens NFC settings | Done | +| Brick inverse mode: pick the window you want socials shut (e.g. 08:00-22:00) and the tag becomes the way in, not out - each tap buys a set stretch of access (15/30/60/120 min or a typed value), capped at one, two, three or unlimited unlocks per window, with the count resetting when the window next starts. Outside the window nothing is blocked. Covered by unit tests | Done | +| One motion vocabulary app-wide (core:designsystem/Motion.kt): screens fade through each other with a barely-there scale, swapped content cross-fades (clock and Pastebin tabs, Brick list/editor, screen-time week/day stats, timer wheels/typed entry), banners fade in, and resizing layouts animate instead of snapping | 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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6605881..0c1fa47 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,8 +10,8 @@ android { defaultConfig { applicationId = "com.lifeos" - versionCode = 20 - versionName = "0.1.0-alpha.20" + versionCode = 21 + versionName = "0.1.0-alpha.21" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/kotlin/com/lifeos/app/ui/LifeOsApp.kt b/app/src/main/kotlin/com/lifeos/app/ui/LifeOsApp.kt index 2eb9885..2a55c6b 100644 --- a/app/src/main/kotlin/com/lifeos/app/ui/LifeOsApp.kt +++ b/app/src/main/kotlin/com/lifeos/app/ui/LifeOsApp.kt @@ -26,6 +26,10 @@ import com.lifeos.app.ui.screen.InboxTabScreen import com.lifeos.app.ui.screen.TasksTabScreen import com.lifeos.app.ui.settings.SettingsRoute import com.lifeos.core.ui.navigation.LifeDestination +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import com.lifeos.core.designsystem.component.LifeMotion import com.lifeos.core.ui.navigation.TopLevelDestination import com.lifeos.feature.adhd.FocusRoute import com.lifeos.feature.agentic.MacrosRoute @@ -135,23 +139,16 @@ fun LifeOsApp(captureRequests: Int = 0, navBarIds: List = emptyList()) { navController = navController, startDestination = LifeDestination.Home, modifier = Modifier.padding(innerPadding), - // Gentle shared-axis feel (§7 motion): quick fade + small vertical lift. + // One motion vocabulary (§7): screens fade through each other with a + // barely-there scale, nothing slides or bounces. enterTransition = { - androidx.compose.animation.fadeIn(androidx.compose.animation.core.tween(220)) + - androidx.compose.animation.slideInVertically( - animationSpec = androidx.compose.animation.core.tween(220), - initialOffsetY = { it / 24 }, - ) + fadeIn(LifeMotion.enterSpec()) + scaleIn(LifeMotion.enterSpec(), initialScale = 0.98f) }, - exitTransition = { androidx.compose.animation.fadeOut(androidx.compose.animation.core.tween(140)) }, - popEnterTransition = { androidx.compose.animation.fadeIn(androidx.compose.animation.core.tween(220)) }, - popExitTransition = { - androidx.compose.animation.fadeOut(androidx.compose.animation.core.tween(140)) + - androidx.compose.animation.slideOutVertically( - animationSpec = androidx.compose.animation.core.tween(140), - targetOffsetY = { it / 24 }, - ) + exitTransition = { fadeOut(LifeMotion.exitSpec()) }, + popEnterTransition = { + fadeIn(LifeMotion.enterSpec()) + scaleIn(LifeMotion.enterSpec(), initialScale = 1.01f) }, + popExitTransition = { fadeOut(LifeMotion.exitSpec()) }, ) { composable { HomeScreen(onNavigate = { navController.navigate(it) }) diff --git a/app/src/main/kotlin/com/lifeos/app/ui/screen/HomeScreen.kt b/app/src/main/kotlin/com/lifeos/app/ui/screen/HomeScreen.kt index 21adf9e..b32965a 100644 --- a/app/src/main/kotlin/com/lifeos/app/ui/screen/HomeScreen.kt +++ b/app/src/main/kotlin/com/lifeos/app/ui/screen/HomeScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import com.lifeos.core.designsystem.component.smoothSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateListOf @@ -354,7 +355,7 @@ private fun ReorderableTileGrid( contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(if (listLayout) 8.dp else 12.dp), - modifier = Modifier.pointerInput(listLayout) { + modifier = Modifier.smoothSize().pointerInput(listLayout) { detectDragGesturesAfterLongPress( onDragStart = { position -> itemAt(position)?.let { info -> diff --git a/core/database/schemas/com.lifeos.core.database.LifeDatabase/15.json b/core/database/schemas/com.lifeos.core.database.LifeDatabase/15.json new file mode 100644 index 0000000..28c82f0 --- /dev/null +++ b/core/database/schemas/com.lifeos.core.database.LifeDatabase/15.json @@ -0,0 +1,2064 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "f285f7d045d97fe345355a298fd4a4c1", + "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, 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 + } + ], + "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, `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": "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" + ] + } + } + ], + "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, 'f285f7d045d97fe345355a298fd4a4c1')" + ] + } +} \ 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 73c3962..8104c12 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 @@ -105,7 +105,7 @@ import com.lifeos.core.database.screentime.ScreenTimeDayEntity BrickSessionEntity::class, BrickUsageEntity::class, ], - version = 14, + version = 15, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -121,6 +121,7 @@ import com.lifeos.core.database.screentime.ScreenTimeDayEntity AutoMigration(from = 11, to = 12), AutoMigration(from = 12, to = 13), AutoMigration(from = 13, to = 14), + AutoMigration(from = 14, to = 15), ], ) abstract class LifeDatabase : RoomDatabase() { diff --git a/core/database/src/main/kotlin/com/lifeos/core/database/brick/BrickEntities.kt b/core/database/src/main/kotlin/com/lifeos/core/database/brick/BrickEntities.kt index c407827..a2fe1d1 100644 --- a/core/database/src/main/kotlin/com/lifeos/core/database/brick/BrickEntities.kt +++ b/core/database/src/main/kotlin/com/lifeos/core/database/brick/BrickEntities.kt @@ -1,5 +1,6 @@ package com.lifeos.core.database.brick +import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.Entity import androidx.room.Insert @@ -17,6 +18,10 @@ import kotlinx.coroutines.flow.Flow * [nfcTagId] is the paired tag's hardware id (hex) — the tap that flips the mode. * [startMinuteOfDay]/[endMinuteOfDay] drive TIME activation (minutes since midnight). * [strict] blocks the in-app "stop" button, so only the real condition ends it. + * + * [inverse] flips the whole idea around: the schedule is the blocked window and + * a tag tap buys [unlockMinutes] of access inside it, at most [unlockAllowance] + * times per window (0 = unlimited). */ @Entity(tableName = "brick_profiles") data class BrickProfileEntity( @@ -30,6 +35,9 @@ data class BrickProfileEntity( val startMinuteOfDay: Int? = null, val endMinuteOfDay: Int? = null, val strict: Boolean = false, + @ColumnInfo(defaultValue = "0") val inverse: Boolean = false, + @ColumnInfo(defaultValue = "60") val unlockMinutes: Int = 60, + @ColumnInfo(defaultValue = "1") val unlockAllowance: Int = 1, val createdAt: Long, ) @@ -52,6 +60,10 @@ data class BrickSessionEntity( val startedBy: String, /** Attempts to open a blocked app while the mode was live. */ val blockedAttempts: Int = 0, + /** Inverse mode: access is open until this timestamp (null = blocked now). */ + val unlockUntil: Long? = null, + /** Inverse mode: unlocks spent in this run of the schedule. */ + @ColumnInfo(defaultValue = "0") val unlocksUsed: Int = 0, ) /** Foreground minutes per app per day — powers the daily-allowance limits. */ @@ -123,6 +135,9 @@ interface BrickDao { @Query("UPDATE brick_sessions SET endedAt = :endedAt WHERE endedAt IS NULL") suspend fun endAllSessions(endedAt: Long) + @Query("UPDATE brick_sessions SET unlockUntil = :until, unlocksUsed = :used WHERE id = :sessionId") + suspend fun setUnlock(sessionId: Long, until: Long?, used: Int) + // ---- usage ----------------------------------------------------------- @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertUsage(usage: BrickUsageEntity) diff --git a/core/designsystem/src/main/kotlin/com/lifeos/core/designsystem/component/Motion.kt b/core/designsystem/src/main/kotlin/com/lifeos/core/designsystem/component/Motion.kt new file mode 100644 index 0000000..15a37f1 --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/lifeos/core/designsystem/component/Motion.kt @@ -0,0 +1,79 @@ +package com.lifeos.core.designsystem.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * One motion vocabulary for the whole app (§7 motion). Everything is a fade at + * heart: content that changes cross-fades, content that appears fades in with a + * barely-there scale, and boxes that resize animate instead of jumping. + * + * Durations are deliberately short - the app should feel calm, not slow. + */ +object LifeMotion { + /** Leaving the screen: fast, so nothing feels sticky. */ + const val EXIT_MS = 130 + + /** Arriving: still quick, but soft enough to read as a fade. */ + const val ENTER_MS = 220 + + /** Cross-fading one piece of content for another. */ + const val SWAP_MS = 240 + + /** Layout growing or shrinking. */ + const val RESIZE_MS = 260 + + fun enterSpec() = tween(durationMillis = ENTER_MS, easing = LinearOutSlowInEasing) + fun exitSpec() = tween(durationMillis = EXIT_MS, easing = FastOutSlowInEasing) + fun swapSpec() = tween(durationMillis = SWAP_MS, easing = FastOutSlowInEasing) +} + +/** Cross-fades whenever [targetState] changes. The default for swapped content. */ +@Composable +fun FadeThrough( + targetState: T, + modifier: Modifier = Modifier, + label: String = "fade-through", + content: @Composable (T) -> Unit, +) { + Crossfade( + targetState = targetState, + modifier = modifier, + animationSpec = LifeMotion.swapSpec(), + label = label, + content = content, + ) +} + +/** Fades a block in and out, with a hint of scale so it does not pop. */ +@Composable +fun FadeVisible( + visible: Boolean, + modifier: Modifier = Modifier, + label: String = "fade-visible", + content: @Composable () -> Unit, +) { + AnimatedVisibility( + visible = visible, + modifier = modifier, + enter = fadeIn(LifeMotion.enterSpec()) + scaleIn(LifeMotion.enterSpec(), initialScale = 0.97f), + exit = fadeOut(LifeMotion.exitSpec()) + scaleOut(LifeMotion.exitSpec(), targetScale = 0.97f), + label = label, + content = { content() }, + ) +} + +/** Animates height/width changes instead of snapping to the new size. */ +fun Modifier.smoothSize(): Modifier = this.animateContentSize( + animationSpec = tween(durationMillis = LifeMotion.RESIZE_MS, easing = FastOutSlowInEasing), +) diff --git a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickScreen.kt b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickScreen.kt index 1f5ce1a..1fec13a 100644 --- a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickScreen.kt +++ b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add @@ -52,13 +53,18 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import com.lifeos.core.database.brick.BrickProfileEntity +import com.lifeos.core.designsystem.component.FadeThrough +import com.lifeos.core.designsystem.component.FadeVisible +import com.lifeos.core.designsystem.component.smoothSize import com.lifeos.core.designsystem.component.EmptyState +import com.lifeos.feature.brick.data.BrickPolicy import com.lifeos.feature.brick.nfc.BrickReader import com.lifeos.feature.brick.nfc.BrickTagWriter import com.lifeos.feature.brick.nfc.uid @@ -74,9 +80,6 @@ import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable fun BrickRoute(viewModel: BrickViewModel = hiltViewModel()) { - val profiles by viewModel.profiles.collectAsState() - val active by viewModel.active.collectAsState() - val serviceEnabled by viewModel.serviceEnabled.collectAsState() val message by viewModel.message.collectAsState() val draft by viewModel.draft.collectAsState() val pairing by viewModel.pairingTag.collectAsState() @@ -102,6 +105,14 @@ fun BrickRoute(viewModel: BrickViewModel = hiltViewModel()) { // captures the id, otherwise a tap flips the matching mode right here. // (Outside the app, the manifest's NFC filters route taps to BrickNfcActivity.) val appContext = LocalContext.current + // Minute tick so the inverse-mode countdown stays honest without polling. + var nowTick by remember { mutableStateOf(System.currentTimeMillis()) } + LaunchedEffect(Unit) { + while (true) { + nowTick = System.currentTimeMillis() + kotlinx.coroutines.delay(15_000) + } + } val nfcReady = remember(appContext) { android.nfc.NfcAdapter.getDefaultAdapter(appContext)?.isEnabled != false } @@ -118,10 +129,34 @@ fun BrickRoute(viewModel: BrickViewModel = hiltViewModel()) { }, ) - if (draft != null) { - ProfileEditor(viewModel = viewModel, snackbarHostState = snackbarHostState) - return + // Editor and mode list cross-fade instead of snapping. + FadeThrough(targetState = draft != null, label = "brick-editor") { editing -> + if (editing) { + ProfileEditor(viewModel = viewModel, snackbarHostState = snackbarHostState) + } else { + BrickOverview( + viewModel = viewModel, + snackbarHostState = snackbarHostState, + nowTick = nowTick, + nfcReady = nfcReady, + appContext = appContext, + ) + } } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BrickOverview( + viewModel: BrickViewModel, + snackbarHostState: SnackbarHostState, + nowTick: Long, + nfcReady: Boolean, + appContext: android.content.Context, +) { + val profiles by viewModel.profiles.collectAsState() + val active by viewModel.active.collectAsState() + val serviceEnabled by viewModel.serviceEnabled.collectAsState() Scaffold( topBar = { TopAppBar(title = { Text("Brick") }) }, @@ -194,14 +229,46 @@ fun BrickRoute(viewModel: BrickViewModel = hiltViewModel()) { style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Text( - when (mode.profile.deactivator) { - "NFC" -> "Ends when you scan the paired tag" - "TIME" -> "Ends at ${formatMinute(mode.profile.endMinuteOfDay)}" - else -> if (mode.profile.strict) "Strict — cannot be ended early" else "Can be ended here" - }, - style = MaterialTheme.typography.bodyMedium, - ) + if (mode.profile.inverse) { + val unlockedUntil = mode.session.unlockUntil?.takeIf { it > nowTick } + val left = BrickPolicy.unlocksLeft(mode.rules) + Text( + if (unlockedUntil != null) { + "Open until ${TIME.format(Date(unlockedUntil))} " + + "(${((unlockedUntil - nowTick) / 60_000L).coerceAtLeast(0)} min left)" + } else { + BrickPolicy.unlockHint(mode.rules) + }, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + "Window ends at ${formatMinute(mode.profile.endMinuteOfDay)} - " + + ( + if (left == null) { + "unlimited unlocks" + } else { + "$left of ${mode.profile.unlockAllowance} unlocks left, " + + "${mode.profile.unlockMinutes} min each" + } + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + when (mode.profile.deactivator) { + "NFC" -> "Ends when you scan the paired tag" + "TIME" -> "Ends at ${formatMinute(mode.profile.endMinuteOfDay)}" + else -> + if (mode.profile.strict) { + "Strict — cannot be ended early" + } else { + "Can be ended here" + } + }, + style = MaterialTheme.typography.bodyMedium, + ) + } OutlinedButton(onClick = { viewModel.stopNow() }) { Text("End mode") } } } @@ -247,8 +314,21 @@ private fun ProfileCard( Text( buildString { append("${profile.blockedPackages.lines().count { it.isNotBlank() }} app(s)") - append(" · on: ${activatorLabel(profile.activator, profile.startMinuteOfDay)}") - append(" · off: ${activatorLabel(profile.deactivator, profile.endMinuteOfDay)}") + if (profile.inverse) { + append(" · inverse ${formatMinute(profile.startMinuteOfDay)}") + append("-${formatMinute(profile.endMinuteOfDay)}") + append(" · ${profile.unlockMinutes} min per tap") + append( + if (profile.unlockAllowance <= 0) { + " · unlimited unlocks" + } else { + " · ${profile.unlockAllowance} unlock(s)" + }, + ) + } else { + append(" · on: ${activatorLabel(profile.activator, profile.startMinuteOfDay)}") + append(" · off: ${activatorLabel(profile.deactivator, profile.endMinuteOfDay)}") + } if (profile.strict) append(" · strict") }, style = MaterialTheme.typography.bodySmall, @@ -306,32 +386,130 @@ private fun ProfileEditor(viewModel: BrickViewModel, snackbarHostState: Snackbar ) } - item { Text("Turns on with", style = MaterialTheme.typography.titleSmall) } item { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf("MANUAL" to "By hand", "NFC" to "NFC tag", "TIME" to "Time").forEach { (value, label) -> - FilterChip( - selected = current.activator == value, - onClick = { viewModel.updateDraft { it.copy(activator = value) } }, - label = { Text(label) }, - ) + Surface(modifier = Modifier.fillMaxWidth()) { + ListItem( + headlineContent = { Text("Inverse mode") }, + supportingContent = { + Text( + "Blocked for the whole window you set; a tag tap buys a stretch of access. " + + "Put the tag somewhere inconvenient and every unlock costs a walk.", + ) + }, + trailingContent = { + Switch( + checked = current.inverse, + onCheckedChange = { value -> + viewModel.updateDraft { + it.copy( + inverse = value, + // The window drives both edges in inverse mode. + activator = if (value) "TIME" else it.activator, + deactivator = if (value) "TIME" else it.deactivator, + ) + } + }, + ) + }, + ) + } + } + + if (current.inverse) { + item { + MinuteField( + label = "Blocked from", + minuteOfDay = current.startMinuteOfDay, + onChange = { value -> viewModel.updateDraft { it.copy(startMinuteOfDay = value) } }, + ) + } + item { + MinuteField( + label = "Blocked until", + minuteOfDay = current.endMinuteOfDay, + onChange = { value -> viewModel.updateDraft { it.copy(endMinuteOfDay = value) } }, + ) + } + item { Text("One unlock lasts", style = MaterialTheme.typography.titleSmall) } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(15, 30, 60, 120).forEach { minutes -> + FilterChip( + selected = current.unlockMinutes == minutes, + onClick = { viewModel.updateDraft { it.copy(unlockMinutes = minutes) } }, + label = { Text(if (minutes < 60) "${minutes}m" else "${minutes / 60}h") }, + ) + } } } + item { + OutlinedTextField( + value = current.unlockMinutes.toString(), + onValueChange = { value -> + val minutes = value.filter { it.isDigit() }.take(4).toIntOrNull() ?: 0 + viewModel.updateDraft { it.copy(unlockMinutes = minutes) } + }, + label = { Text("Unlock length in minutes") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + } + item { Text("Unlocks per window", style = MaterialTheme.typography.titleSmall) } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf( + 1 to "Once only", + 2 to "Twice", + 3 to "3 times", + 0 to "Unlimited", + ).forEach { (allowance, label) -> + FilterChip( + selected = current.unlockAllowance == allowance, + onClick = { viewModel.updateDraft { it.copy(unlockAllowance = allowance) } }, + label = { Text(label) }, + ) + } + } + } + item { + Text( + "The count resets when the window next starts, so \"once only\" means one unlock " + + "per day. Outside the window nothing is blocked and a tap does nothing.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - item { Text("Turns off with", style = MaterialTheme.typography.titleSmall) } - item { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf("MANUAL" to "By hand", "NFC" to "NFC tag", "TIME" to "Time").forEach { (value, label) -> - FilterChip( - selected = current.deactivator == value, - onClick = { viewModel.updateDraft { it.copy(deactivator = value) } }, - label = { Text(label) }, - ) + + if (!current.inverse) { + item { Text("Turns on with", style = MaterialTheme.typography.titleSmall) } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("MANUAL" to "By hand", "NFC" to "NFC tag", "TIME" to "Time").forEach { (value, label) -> + FilterChip( + selected = current.activator == value, + onClick = { viewModel.updateDraft { it.copy(activator = value) } }, + label = { Text(label) }, + ) + } + } + } + item { Text("Turns off with", style = MaterialTheme.typography.titleSmall) } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("MANUAL" to "By hand", "NFC" to "NFC tag", "TIME" to "Time").forEach { (value, label) -> + FilterChip( + selected = current.deactivator == value, + onClick = { viewModel.updateDraft { it.copy(deactivator = value) } }, + label = { Text(label) }, + ) + } } } } - if (current.activator == "NFC" || current.deactivator == "NFC") { + if (current.inverse || current.activator == "NFC" || current.deactivator == "NFC") { item { Card { Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { @@ -373,7 +551,7 @@ private fun ProfileEditor(viewModel: BrickViewModel, snackbarHostState: Snackbar } } - if (current.activator == "TIME") { + if (!current.inverse && current.activator == "TIME") { item { MinuteField( label = "Starts at", @@ -382,7 +560,7 @@ private fun ProfileEditor(viewModel: BrickViewModel, snackbarHostState: Snackbar ) } } - if (current.deactivator == "TIME") { + if (!current.inverse && current.deactivator == "TIME") { item { MinuteField( label = "Ends at", diff --git a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickViewModel.kt b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickViewModel.kt index 8a08ce1..9ab8b97 100644 --- a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickViewModel.kt +++ b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickViewModel.kt @@ -35,6 +35,11 @@ data class ProfileDraft( val startMinuteOfDay: Int? = null, val endMinuteOfDay: Int? = null, val strict: Boolean = false, + /** Inverse mode: the schedule is the blocked window, taps buy access. */ + val inverse: Boolean = false, + val unlockMinutes: Int = 60, + /** Unlocks per window run; 0 = unlimited. */ + val unlockAllowance: Int = 1, ) @HiltViewModel @@ -118,6 +123,9 @@ class BrickViewModel @Inject constructor( startMinuteOfDay = profile.startMinuteOfDay, endMinuteOfDay = profile.endMinuteOfDay, strict = profile.strict, + inverse = profile.inverse, + unlockMinutes = profile.unlockMinutes, + unlockAllowance = profile.unlockAllowance, ) } } @@ -169,15 +177,29 @@ class BrickViewModel @Inject constructor( _message.value = "Give the mode a name and pick at least one app" return } - if (draft.activator == "NFC" && draft.nfcTagId == null) { + if (draft.inverse) { + if (draft.nfcTagId == null) { + _message.value = "Inverse mode needs a paired tag — that tap is the only way in" + return + } + if (draft.startMinuteOfDay == null || draft.endMinuteOfDay == null) { + _message.value = "Set the window this mode blocks (start and end)" + return + } + if (draft.unlockMinutes < 1) { + _message.value = "An unlock has to last at least a minute" + return + } + } + if (!draft.inverse && draft.activator == "NFC" && draft.nfcTagId == null) { _message.value = "Pair an NFC tag first, or pick another activator" return } - if (draft.activator == "TIME" && draft.startMinuteOfDay == null) { + if (!draft.inverse && draft.activator == "TIME" && draft.startMinuteOfDay == null) { _message.value = "Set a start time" return } - if (draft.deactivator == "TIME" && draft.endMinuteOfDay == null) { + if (!draft.inverse && draft.deactivator == "TIME" && draft.endMinuteOfDay == null) { _message.value = "Set an end time" return } @@ -187,12 +209,17 @@ class BrickViewModel @Inject constructor( id = draft.id, name = draft.name.trim(), blockedPackages = draft.blocked.joinToString("\n"), - activator = draft.activator, - deactivator = draft.deactivator, + // Inverse modes are schedule-driven on both edges; the tag + // unlocks inside the window rather than ending the mode. + activator = if (draft.inverse) "TIME" else draft.activator, + deactivator = if (draft.inverse) "TIME" else draft.deactivator, nfcTagId = draft.nfcTagId, startMinuteOfDay = draft.startMinuteOfDay, endMinuteOfDay = draft.endMinuteOfDay, strict = draft.strict, + inverse = draft.inverse, + unlockMinutes = draft.unlockMinutes, + unlockAllowance = draft.unlockAllowance, createdAt = System.currentTimeMillis(), ), limits = draft.limits, diff --git a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickPolicy.kt b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickPolicy.kt index e739b5e..25153f7 100644 --- a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickPolicy.kt +++ b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickPolicy.kt @@ -9,6 +9,16 @@ sealed interface BlockDecision { data class LimitReached(val profileName: String, val dailyMinutes: Int) : BlockDecision } +/** Why a tag tap did or did not buy access in inverse mode. */ +sealed interface UnlockDecision { + /** Granted: access is open until [until]; [used] of [allowance] spent (0 = unlimited). */ + data class Granted(val until: Long, val used: Int, val allowance: Int) : UnlockDecision + /** Already unlocked; nothing spent. */ + data class AlreadyOpen(val until: Long) : UnlockDecision + /** Every unlock for this window is gone. */ + data class NoneLeft(val allowance: Int) : UnlockDecision +} + /** What a mode looks like to the policy — no Android types, so it is unit-testable. */ data class ModeRules( val profileName: String, @@ -18,6 +28,16 @@ data class ModeRules( val deactivator: String, val strict: Boolean, val endMinuteOfDay: Int?, + /** Inverse mode: blocked through the window, tag taps buy access. */ + val inverse: Boolean = false, + /** Minutes one tag tap opens up. */ + val unlockMinutes: Int = 60, + /** Unlocks allowed per window run; 0 = unlimited. */ + val unlockAllowance: Int = 1, + /** Epoch millis the current unlock runs until; null = nothing open. */ + val unlockUntil: Long? = null, + /** Unlocks already spent in this window run. */ + val unlocksUsed: Int = 0, ) /** @@ -33,18 +53,23 @@ object BrickPolicy { /** * Should [packageName] be allowed in the foreground? * [usedSecondsToday] only matters for packages that carry an allowance. + * [nowMillis] decides whether an inverse-mode unlock is still running. */ fun decide( rules: ModeRules?, packageName: String, ownPackage: String, usedSecondsToday: Long, + nowMillis: Long = System.currentTimeMillis(), ): BlockDecision { if (rules == null) return BlockDecision.Allow // LifeOS itself is never blocked — otherwise you could not unlock. if (packageName == ownPackage) return BlockDecision.Allow if (packageName !in rules.blockedPackages) return BlockDecision.Allow + // Inverse mode: a live unlock opens everything for its duration. + if (rules.inverse && unlockActive(rules, nowMillis)) return BlockDecision.Allow + val limit = rules.limits[packageName] if (limit != null && limit > 0) { return if (usedSecondsToday < limit * 60L) { @@ -56,18 +81,54 @@ object BrickPolicy { return BlockDecision.Blocked(rules.profileName, unlockHint(rules)) } + /** Whether an unlock is running at [nowMillis]. */ + fun unlockActive(rules: ModeRules, nowMillis: Long = System.currentTimeMillis()): Boolean = + rules.inverse && (rules.unlockUntil ?: 0L) > nowMillis + + /** Unlocks still available in this window run; null when unlimited. */ + fun unlocksLeft(rules: ModeRules): Int? = + if (rules.unlockAllowance <= 0) null else (rules.unlockAllowance - rules.unlocksUsed).coerceAtLeast(0) + + /** What a tag tap does in inverse mode. */ + fun unlock(rules: ModeRules, nowMillis: Long = System.currentTimeMillis()): UnlockDecision { + if (unlockActive(rules, nowMillis)) { + return UnlockDecision.AlreadyOpen(rules.unlockUntil ?: nowMillis) + } + val left = unlocksLeft(rules) + if (left != null && left <= 0) return UnlockDecision.NoneLeft(rules.unlockAllowance) + val minutes = rules.unlockMinutes.coerceAtLeast(1) + return UnlockDecision.Granted( + until = nowMillis + minutes * 60_000L, + used = rules.unlocksUsed + 1, + allowance = rules.unlockAllowance, + ) + } + /** Whether an end request coming from [by] satisfies the mode's exit rule. */ fun canStop(rules: ModeRules, by: String): Boolean = when { by == FORCE -> true + // Inverse modes are ended by their schedule, never by the tag: the tag + // buys access inside the window instead of ending it. + rules.inverse -> by == "TIME" // Strict modes accept nothing but their configured condition. rules.strict -> by == rules.deactivator rules.deactivator == "MANUAL" -> true else -> by == rules.deactivator } - fun unlockHint(rules: ModeRules): String = when (rules.deactivator) { - "NFC" -> "Scan your Brick tag to unlock" - "TIME" -> "Unlocks at ${formatMinute(rules.endMinuteOfDay)}" + fun unlockHint(rules: ModeRules): String = when { + rules.inverse -> { + val left = unlocksLeft(rules) + when { + left == null -> "Scan your Brick tag for ${rules.unlockMinutes} min of access" + left > 0 -> "Scan your Brick tag for ${rules.unlockMinutes} min " + + "($left of ${rules.unlockAllowance} unlocks left)" + else -> "No unlocks left — opens again at ${formatMinute(rules.endMinuteOfDay)}" + } + } + + rules.deactivator == "NFC" -> "Scan your Brick tag to unlock" + rules.deactivator == "TIME" -> "Unlocks at ${formatMinute(rules.endMinuteOfDay)}" else -> if (rules.strict) "Strict mode — wait it out" else "Stop the mode in LifeOS to unlock" } diff --git a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickRepository.kt b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickRepository.kt index 7cac7c1..8cee3c0 100644 --- a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickRepository.kt +++ b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickRepository.kt @@ -48,6 +48,11 @@ class BrickRepository @Inject constructor( deactivator = profile.deactivator, strict = profile.strict, endMinuteOfDay = profile.endMinuteOfDay, + inverse = profile.inverse, + unlockMinutes = profile.unlockMinutes, + unlockAllowance = profile.unlockAllowance, + unlockUntil = session.unlockUntil, + unlocksUsed = session.unlocksUsed, ) } @@ -139,6 +144,9 @@ class BrickRepository @Inject constructor( val running = _active.value if (running != null) { val runningTag = running.profile.nfcTagId?.trim()?.uppercase() + // Inverse mode: the tap buys access inside the window instead of + // ending the mode. + if (running.profile.inverse && runningTag == tagId) return grantUnlock(running) return when { runningTag == tagId -> if (stop("NFC")) "\"${running.profile.name}\" unlocked" else "Wrong tag for this mode" @@ -149,9 +157,66 @@ class BrickRepository @Inject constructor( } val profile = brickDao.profileByTag(tagId) ?: return "Tag $tagId is not paired with any mode yet — pair it in Brick" + if (profile.inverse) { + // Inverse modes only exist inside their window; outside it nothing + // is blocked, so there is nothing to unlock. + val inWindow = withinWindow(profile) + if (!inWindow) { + return "\"${profile.name}\" only blocks between " + + "${BrickPolicy.formatMinute(profile.startMinuteOfDay)} and " + + "${BrickPolicy.formatMinute(profile.endMinuteOfDay)} — nothing to unlock right now" + } + // The window is live but no session exists yet (phone was off at the + // start minute): open it, then spend an unlock. + if (!start(profile.id, "TIME")) return "Couldn't start the mode" + val running = _active.value ?: return "Couldn't start the mode" + return grantUnlock(running) + } return if (start(profile.id, "NFC")) "\"${profile.name}\" is now blocking" else "Couldn't start the mode" } + /** Spends one unlock of an inverse mode and reports what happened. */ + private suspend fun grantUnlock(running: ActiveMode): String { + val name = running.profile.name + return when (val decision = BrickPolicy.unlock(running.rules)) { + is UnlockDecision.AlreadyOpen -> + "\"$name\" is already open until ${clock(decision.until)}" + + is UnlockDecision.NoneLeft -> + "No unlocks left for \"$name\" — it opens again at " + + BrickPolicy.formatMinute(running.profile.endMinuteOfDay) + + is UnlockDecision.Granted -> { + brickDao.setUnlock(running.session.id, decision.until, decision.used) + val session = running.session.copy(unlockUntil = decision.until, unlocksUsed = decision.used) + _active.value = snapshot(session, running.profile) + val quota = if (decision.allowance <= 0) { + "unlimited unlocks" + } else { + "${decision.used} of ${decision.allowance} used" + } + "\"$name\" open for ${running.profile.unlockMinutes} min, " + + "until ${clock(decision.until)} ($quota)" + } + } + } + + /** True when [profile]'s schedule window covers the current minute. */ + private fun withinWindow(profile: BrickProfileEntity): Boolean { + val start = profile.startMinuteOfDay ?: return true + val end = profile.endMinuteOfDay ?: return true + val now = minuteOfDayNow() + // Windows that wrap past midnight (22:00 - 06:00) count both sides. + return if (start <= end) now in start until end else now >= start || now < end + } + + /** Remaining unlock time in millis, or null when nothing is open. */ + fun unlockRemaining(): Long? { + val rules = _active.value?.rules ?: return null + if (!BrickPolicy.unlockActive(rules)) return null + return (rules.unlockUntil ?: 0L) - System.currentTimeMillis() + } + // ---- the hot path ------------------------------------------------------ /** Should [packageName] be allowed in the foreground right now? */ @@ -218,7 +283,9 @@ class BrickRepository @Inject constructor( suspend fun onScheduleTick(profileId: Long, starting: Boolean) { val profile = brickDao.profile(profileId) ?: return if (starting) { - if (_active.value == null && profile.activator == "TIME") start(profileId, "TIME") + if (_active.value == null && (profile.activator == "TIME" || profile.inverse)) { + start(profileId, "TIME") + } } else { val running = _active.value if (running?.profile?.id == profileId) stop("TIME") @@ -228,11 +295,14 @@ class BrickRepository @Inject constructor( private fun today(): String = DATE.format(Date()) + private fun clock(millis: Long): String = CLOCK.format(Date(millis)) + companion object { /** Internal override used when a profile disappears; bypasses strict rules. */ const val FORCE = BrickPolicy.FORCE private const val TAG = "BrickRepository" private val DATE = SimpleDateFormat("yyyy-MM-dd", Locale.US) + private val CLOCK = SimpleDateFormat("HH:mm", Locale.US) fun minuteOfDayNow(): Int = Calendar.getInstance().let { it.get(Calendar.HOUR_OF_DAY) * 60 + it.get(Calendar.MINUTE) diff --git a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickScheduler.kt b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickScheduler.kt index f39a915..f017915 100644 --- a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickScheduler.kt +++ b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickScheduler.kt @@ -32,11 +32,15 @@ class BrickScheduler @Inject constructor( profiles.forEach { profile -> cancel(profile.id, starting = true) cancel(profile.id, starting = false) - if (profile.activator != "TIME" && profile.deactivator != "TIME") return@forEach - profile.startMinuteOfDay?.takeIf { profile.activator == "TIME" }?.let { + // Inverse modes are always schedule-driven on both edges: the window + // is the blocked stretch, and tag taps only work inside it. + val timeStart = profile.inverse || profile.activator == "TIME" + val timeEnd = profile.inverse || profile.deactivator == "TIME" + if (!timeStart && !timeEnd) return@forEach + profile.startMinuteOfDay?.takeIf { timeStart }?.let { schedule(profile.id, it, starting = true) } - profile.endMinuteOfDay?.takeIf { profile.deactivator == "TIME" }?.let { + profile.endMinuteOfDay?.takeIf { timeEnd }?.let { schedule(profile.id, it, starting = false) } } diff --git a/feature/brick/src/test/kotlin/com/lifeos/feature/brick/data/BrickPolicyTest.kt b/feature/brick/src/test/kotlin/com/lifeos/feature/brick/data/BrickPolicyTest.kt index 020292b..a8dc6d0 100644 --- a/feature/brick/src/test/kotlin/com/lifeos/feature/brick/data/BrickPolicyTest.kt +++ b/feature/brick/src/test/kotlin/com/lifeos/feature/brick/data/BrickPolicyTest.kt @@ -112,4 +112,90 @@ class BrickPolicyTest { private companion object { const val OWN = "com.lifeos" } + + // ---- inverse mode ------------------------------------------------------ + + private val inverse = ModeRules( + profileName = "Socials curfew", + blockedPackages = setOf("com.instagram.android"), + limits = emptyMap(), + deactivator = "TIME", + strict = false, + endMinuteOfDay = 22 * 60, + inverse = true, + unlockMinutes = 60, + unlockAllowance = 1, + ) + + @Test + fun `inverse mode blocks while nothing is unlocked`() { + val decision = BrickPolicy.decide(inverse, "com.instagram.android", OWN, 0, nowMillis = 1_000L) + assertTrue(decision is BlockDecision.Blocked) + } + + @Test + fun `a live unlock allows the blocked app`() { + val rules = inverse.copy(unlockUntil = 10_000L, unlocksUsed = 1) + assertEquals( + BlockDecision.Allow, + BrickPolicy.decide(rules, "com.instagram.android", OWN, 0, nowMillis = 5_000L), + ) + } + + @Test + fun `an expired unlock blocks again`() { + val rules = inverse.copy(unlockUntil = 10_000L, unlocksUsed = 1) + val decision = BrickPolicy.decide(rules, "com.instagram.android", OWN, 0, nowMillis = 10_001L) + assertTrue(decision is BlockDecision.Blocked) + } + + @Test + fun `first tap grants an unlock of the configured length`() { + val decision = BrickPolicy.unlock(inverse, nowMillis = 1_000L) + assertTrue(decision is UnlockDecision.Granted) + decision as UnlockDecision.Granted + assertEquals(1_000L + 60 * 60_000L, decision.until) + assertEquals(1, decision.used) + } + + @Test + fun `a single-unlock window refuses the second tap`() { + val spent = inverse.copy(unlocksUsed = 1, unlockUntil = 5_000L) + assertTrue(BrickPolicy.unlock(spent, nowMillis = 6_000L) is UnlockDecision.NoneLeft) + } + + @Test + fun `tapping during an open unlock does not spend another`() { + val open = inverse.copy(unlocksUsed = 1, unlockUntil = 9_000L) + assertTrue(BrickPolicy.unlock(open, nowMillis = 5_000L) is UnlockDecision.AlreadyOpen) + } + + @Test + fun `multiple unlocks are allowed up to the allowance`() { + val rules = inverse.copy(unlockAllowance = 3, unlocksUsed = 2, unlockUntil = 1_000L) + val decision = BrickPolicy.unlock(rules, nowMillis = 2_000L) + assertTrue(decision is UnlockDecision.Granted) + assertEquals(3, (decision as UnlockDecision.Granted).used) + assertTrue(BrickPolicy.unlock(rules.copy(unlocksUsed = 3), nowMillis = 2_000L) is UnlockDecision.NoneLeft) + } + + @Test + fun `unlimited allowance never runs out`() { + val rules = inverse.copy(unlockAllowance = 0, unlocksUsed = 12, unlockUntil = 1_000L) + assertEquals(null, BrickPolicy.unlocksLeft(rules)) + assertTrue(BrickPolicy.unlock(rules, nowMillis = 2_000L) is UnlockDecision.Granted) + } + + @Test + fun `inverse modes only end on their schedule`() { + assertTrue(BrickPolicy.canStop(inverse, "TIME")) + assertEquals(false, BrickPolicy.canStop(inverse, "NFC")) + assertEquals(false, BrickPolicy.canStop(inverse, "MANUAL")) + assertTrue(BrickPolicy.canStop(inverse, BrickPolicy.FORCE)) + } + + @Test + fun `LifeOS itself stays reachable during an inverse window`() { + assertEquals(BlockDecision.Allow, BrickPolicy.decide(inverse, OWN, OWN, 0, nowMillis = 1_000L)) + } } diff --git a/feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/ClearSkyScreen.kt b/feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/ClearSkyScreen.kt index a748b61..74a6f21 100644 --- a/feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/ClearSkyScreen.kt +++ b/feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/ClearSkyScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.InputChip +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -145,6 +146,12 @@ fun ClearSkyRoute(viewModel: ClearSkyViewModel = hiltViewModel()) { ) } + if (state.loading && state.forecast != null) { + item { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } + if (state.results.isNotEmpty()) { items(state.results) { result -> Card(modifier = Modifier.fillMaxWidth()) { 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 9b8f3a6..761710a 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 @@ -71,6 +71,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp 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 java.time.LocalTime import java.time.ZoneId @@ -108,13 +109,15 @@ fun ClockRoute(viewModel: ClockViewModel = hiltViewModel()) { ) } } - when (uiState.tab) { - 0 -> FacesTab(uiState.face) { viewModel.onEvent(ClockUiEvent.SelectFace(it)) } - 1 -> WorldTab(uiState, viewModel::onEvent) - 2 -> TimeZoneMapTab(viewModel::onEvent) - 3 -> ConvertTab(uiState) - 4 -> StopwatchTab() - else -> TimerTab() + FadeThrough(targetState = uiState.tab, label = "clock-tab") { tab -> + when (tab) { + 0 -> FacesTab(uiState.face) { viewModel.onEvent(ClockUiEvent.SelectFace(it)) } + 1 -> WorldTab(uiState, viewModel::onEvent) + 2 -> TimeZoneMapTab(viewModel::onEvent) + 3 -> ConvertTab(uiState) + 4 -> StopwatchTab() + else -> TimerTab() + } } } } @@ -498,7 +501,16 @@ private fun TimerTab() { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(20.dp), ) { - if (running || remainingSeconds > 0) { + // Countdown, typed entry and wheels all fade into one another. + FadeThrough( + targetState = when { + running || remainingSeconds > 0 -> 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), @@ -511,7 +523,7 @@ private fun TimerTab() { Icon(Icons.Filled.SwapHoriz, contentDescription = null) Text(if (showAsSeconds) " Show mm:ss" else " Show seconds") } - } else if (typedField != null) { + } else if (mode == 1) { // Typed entry: number keyboard, auto-advancing to the next field. TypedDuration( hours = hours, @@ -538,6 +550,7 @@ private fun TimerTab() { } TextButton(onClick = { typedField = 0 }) { Text("Type a duration") } } + } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { listOf(1L, 5L, 10L, 25L).forEach { m -> FilterChip( diff --git a/feature/downloader/src/main/kotlin/com/lifeos/feature/downloader/DownloaderScreen.kt b/feature/downloader/src/main/kotlin/com/lifeos/feature/downloader/DownloaderScreen.kt index 08dae36..1eb76de 100644 --- a/feature/downloader/src/main/kotlin/com/lifeos/feature/downloader/DownloaderScreen.kt +++ b/feature/downloader/src/main/kotlin/com/lifeos/feature/downloader/DownloaderScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import com.lifeos.core.designsystem.component.FadeVisible import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -163,9 +164,9 @@ fun DownloaderRoute(viewModel: DownloaderViewModel = hiltViewModel()) { else Text("Scan") } } - message?.let { + FadeVisible(visible = message != null) { Text( - it, + message.orEmpty(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), diff --git a/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/PastebinScreen.kt b/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/PastebinScreen.kt index 5a42c53..6c3e5ef 100644 --- a/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/PastebinScreen.kt +++ b/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/PastebinScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import com.lifeos.core.designsystem.component.FadeThrough import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -90,10 +91,12 @@ fun PastebinRoute(viewModel: PastebinViewModel = hiltViewModel()) { ) } } - when (state.tab) { - 0 -> ComposerTab(state, viewModel) - 1 -> ListTab(state, viewModel) - else -> SettingsTab(state, viewModel) + FadeThrough(targetState = state.tab, label = "pastebin-tab") { tab -> + when (tab) { + 0 -> ComposerTab(state, viewModel) + 1 -> ListTab(state, viewModel) + else -> SettingsTab(state, viewModel) + } } } } diff --git a/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/ScreenTimeScreen.kt b/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/ScreenTimeScreen.kt index 9dcf60d..949453e 100644 --- a/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/ScreenTimeScreen.kt +++ b/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/ScreenTimeScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.lifeos.core.designsystem.component.EmptyState +import com.lifeos.core.designsystem.component.FadeThrough import kotlinx.coroutines.launch /** @@ -154,13 +155,17 @@ fun ScreenTimeRoute(viewModel: ScreenTimeViewModel = hiltViewModel()) { } val selected = state.selectedDate?.let { date -> state.days.firstOrNull { it.date == date } } item { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - if (selected != null) { - StatCard("Screen time", formatDuration(selected.totalMs), Modifier.weight(1f)) - StatCard("Unlocks", selected.unlocks.toString(), Modifier.weight(1f)) - } else { - StatCard("Daily average", formatDuration(state.dailyAverageMs), Modifier.weight(1f)) - StatCard("Week total", formatDuration(state.weekTotalMs), Modifier.weight(1f)) + // Week totals and the tapped day's numbers cross-fade into each other. + FadeThrough(targetState = state.selectedDate, label = "screentime-stats") { date -> + val day = date?.let { value -> state.days.firstOrNull { it.date == value } } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (day != null) { + StatCard("Screen time", formatDuration(day.totalMs), Modifier.weight(1f)) + StatCard("Unlocks", day.unlocks.toString(), Modifier.weight(1f)) + } else { + StatCard("Daily average", formatDuration(state.dailyAverageMs), Modifier.weight(1f)) + StatCard("Week total", formatDuration(state.weekTotalMs), Modifier.weight(1f)) + } } } } @@ -172,7 +177,8 @@ fun ScreenTimeRoute(viewModel: ScreenTimeViewModel = hiltViewModel()) { ) } item { - if (selected != null) { + FadeThrough(targetState = selected != null, label = "screentime-day-header") { hasSelection -> + if (hasSelection) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -189,6 +195,7 @@ fun ScreenTimeRoute(viewModel: ScreenTimeViewModel = hiltViewModel()) { StatCard("Notifications", state.days.sumOf { it.notifications }.toString(), Modifier.weight(1f)) } } + } } if (state.topApps.isEmpty()) { item {