feat: Add comprehensive todo management system with Gemini-powered brain dump analysis
- Add TodoEntity, TodoDao, TodoRepository, and TodoRepositoryImpl for local todo storage with sync support - Implement TodoViewModel with CRUD operations, group management, and subtask handling - Create TodoScreen with Kanban-style board view, group-based organization, and drag-to-reorder support - Add BrainDumpSheet for AI-powered task extraction from natural language input using Gemini API - Implement TodoNot
This commit is contained in:
parent
fc0fe3b30b
commit
bccd5ea2d4
@ -92,6 +92,14 @@ ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
|
||||
hilt {
|
||||
enableAggregatingTask = true
|
||||
}
|
||||
|
||||
tasks.matching { it.name.startsWith("hiltJavaCompile") }.configureEach {
|
||||
(this as JavaCompile).options.compilerArgs.addAll(listOf("-nowarn", "-Xlint:none"))
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
|
||||
9
app/proguard-rules.pro
vendored
9
app/proguard-rules.pro
vendored
@ -5,13 +5,11 @@
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# Keep classes used for serialization
|
||||
-keepattributes *Annotation*,EnclosingMethod,InnerClasses
|
||||
-keepattributes Signature
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
# Keep classes used for serialization + reflection metadata required by Retrofit
|
||||
-keepattributes Signature,Exceptions,*Annotation*,InnerClasses,EnclosingMethod,SourceFile,LineNumberTable,RuntimeVisibleAnnotations,RuntimeVisibleParameterAnnotations
|
||||
|
||||
# Retrofit
|
||||
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
|
||||
-keep interface com.shaarit.data.api.** { *; }
|
||||
-keepclassmembers,allowshrinking,allowobfuscation interface * {
|
||||
@retrofit2.http.* <methods>;
|
||||
}
|
||||
@ -61,7 +59,6 @@
|
||||
|
||||
# Keep Kotlin Metadata
|
||||
-keep class kotlin.Metadata { *; }
|
||||
-keepattributes RuntimeVisibleAnnotations
|
||||
|
||||
# Hilt / Dagger
|
||||
-keepclasseswithmembers class * {
|
||||
|
||||
@ -0,0 +1,700 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 7,
|
||||
"identityHash": "0e82919fe46a8a52b16c457c3b43efd6",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "links",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `tags` TEXT NOT NULL, `is_private` INTEGER NOT NULL, `is_pinned` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `sync_status` TEXT NOT NULL, `local_modified_at` INTEGER NOT NULL, `thumbnail_url` TEXT, `reading_time_minutes` INTEGER, `content_type` TEXT NOT NULL, `site_name` TEXT, `excerpt` TEXT, `link_check_status` TEXT NOT NULL, `fail_count` INTEGER NOT NULL, `last_health_check` INTEGER NOT NULL, `excluded_from_health_check` INTEGER NOT NULL, `reader_content` TEXT, `reader_content_fetched_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isPrivate",
|
||||
"columnName": "is_private",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isPinned",
|
||||
"columnName": "is_pinned",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updated_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "syncStatus",
|
||||
"columnName": "sync_status",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "localModifiedAt",
|
||||
"columnName": "local_modified_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnail_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "readingTimeMinutes",
|
||||
"columnName": "reading_time_minutes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentType",
|
||||
"columnName": "content_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "siteName",
|
||||
"columnName": "site_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "excerpt",
|
||||
"columnName": "excerpt",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "linkCheckStatus",
|
||||
"columnName": "link_check_status",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "failCount",
|
||||
"columnName": "fail_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastHealthCheck",
|
||||
"columnName": "last_health_check",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "excludedFromHealthCheck",
|
||||
"columnName": "excluded_from_health_check",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "readerContent",
|
||||
"columnName": "reader_content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "readerContentFetchedAt",
|
||||
"columnName": "reader_content_fetched_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_links_sync_status",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sync_status"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_sync_status` ON `${TABLE_NAME}` (`sync_status`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_is_private",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"is_private"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_is_private` ON `${TABLE_NAME}` (`is_private`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_created_at",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"created_at"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_created_at` ON `${TABLE_NAME}` (`created_at`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_is_pinned",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"is_pinned"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_is_pinned` ON `${TABLE_NAME}` (`is_pinned`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_links_url` ON `${TABLE_NAME}` (`url`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_content_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"content_type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_content_type` ON `${TABLE_NAME}` (`content_type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_site_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"site_name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_site_name` ON `${TABLE_NAME}` (`site_name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_link_check_status",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"link_check_status"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_link_check_status` ON `${TABLE_NAME}` (`link_check_status`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_last_health_check",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"last_health_check"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_last_health_check` ON `${TABLE_NAME}` (`last_health_check`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"ftsVersion": "FTS4",
|
||||
"ftsOptions": {
|
||||
"tokenizer": "simple",
|
||||
"tokenizerArgs": [],
|
||||
"contentTable": "links",
|
||||
"languageIdColumnName": "",
|
||||
"matchInfo": "FTS4",
|
||||
"notIndexedColumns": [],
|
||||
"prefixSizes": [],
|
||||
"preferredOrder": "ASC"
|
||||
},
|
||||
"contentSyncTriggers": [
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_BEFORE_UPDATE BEFORE UPDATE ON `links` BEGIN DELETE FROM `links_fts` WHERE `docid`=OLD.`rowid`; END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_BEFORE_DELETE BEFORE DELETE ON `links` BEGIN DELETE FROM `links_fts` WHERE `docid`=OLD.`rowid`; END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_AFTER_UPDATE AFTER UPDATE ON `links` BEGIN INSERT INTO `links_fts`(`docid`, `url`, `title`, `description`, `tags`, `excerpt`) VALUES (NEW.`rowid`, NEW.`url`, NEW.`title`, NEW.`description`, NEW.`tags`, NEW.`excerpt`); END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_AFTER_INSERT AFTER INSERT ON `links` BEGIN INSERT INTO `links_fts`(`docid`, `url`, `title`, `description`, `tags`, `excerpt`) VALUES (NEW.`rowid`, NEW.`url`, NEW.`title`, NEW.`description`, NEW.`tags`, NEW.`excerpt`); END"
|
||||
],
|
||||
"tableName": "links_fts",
|
||||
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`url` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `tags` TEXT NOT NULL, `excerpt` TEXT, content=`links`)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "excerpt",
|
||||
"columnName": "excerpt",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": []
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "tags",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `occurrences` INTEGER NOT NULL, `last_used_at` INTEGER NOT NULL, `color` INTEGER, `is_favorite` INTEGER NOT NULL, PRIMARY KEY(`name`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "occurrences",
|
||||
"columnName": "occurrences",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastUsedAt",
|
||||
"columnName": "last_used_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isFavorite",
|
||||
"columnName": "is_favorite",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_tags_name",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tags_name` ON `${TABLE_NAME}` (`name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_tags_occurrences",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"occurrences"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_tags_occurrences` ON `${TABLE_NAME}` (`occurrences`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "link_tag_cross_ref",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`link_id` INTEGER NOT NULL, `tag_name` TEXT NOT NULL, PRIMARY KEY(`link_id`, `tag_name`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "linkId",
|
||||
"columnName": "link_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "tagName",
|
||||
"columnName": "tag_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"link_id",
|
||||
"tag_name"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "collections",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `icon` TEXT NOT NULL, `color` INTEGER, `is_smart` INTEGER NOT NULL, `query` TEXT, `sort_order` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "icon",
|
||||
"columnName": "icon",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isSmart",
|
||||
"columnName": "is_smart",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "query",
|
||||
"columnName": "query",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sort_order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updated_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_collections_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_name` ON `${TABLE_NAME}` (`name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collections_is_smart",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"is_smart"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_is_smart` ON `${TABLE_NAME}` (`is_smart`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collections_sort_order",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sort_order"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "collection_links",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`collection_id` INTEGER NOT NULL, `link_id` INTEGER NOT NULL, `added_at` INTEGER NOT NULL, PRIMARY KEY(`collection_id`, `link_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "collectionId",
|
||||
"columnName": "collection_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "linkId",
|
||||
"columnName": "link_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "addedAt",
|
||||
"columnName": "added_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"collection_id",
|
||||
"link_id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "reading_reminders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `link_id` INTEGER NOT NULL, `remind_at` INTEGER NOT NULL, `repeat_interval` TEXT NOT NULL, `is_dismissed` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, FOREIGN KEY(`link_id`) REFERENCES `links`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "linkId",
|
||||
"columnName": "link_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "remindAt",
|
||||
"columnName": "remind_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "repeatInterval",
|
||||
"columnName": "repeat_interval",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDismissed",
|
||||
"columnName": "is_dismissed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_reading_reminders_link_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"link_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_reading_reminders_link_id` ON `${TABLE_NAME}` (`link_id`)"
|
||||
},
|
||||
{
|
||||
"name": "index_reading_reminders_remind_at",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"remind_at"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_reading_reminders_remind_at` ON `${TABLE_NAME}` (`remind_at`)"
|
||||
},
|
||||
{
|
||||
"name": "index_reading_reminders_is_dismissed",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"is_dismissed"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_reading_reminders_is_dismissed` ON `${TABLE_NAME}` (`is_dismissed`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "links",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"link_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "todos",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `shaarli_link_url` TEXT NOT NULL, `content` TEXT NOT NULL, `is_done` INTEGER NOT NULL, `due_date` INTEGER, `tags` TEXT NOT NULL, `is_synced` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "shaarliLinkUrl",
|
||||
"columnName": "shaarli_link_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "content",
|
||||
"columnName": "content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDone",
|
||||
"columnName": "is_done",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dueDate",
|
||||
"columnName": "due_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isSynced",
|
||||
"columnName": "is_synced",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_todos_shaarli_link_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"shaarli_link_url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_todos_shaarli_link_url` ON `${TABLE_NAME}` (`shaarli_link_url`)"
|
||||
},
|
||||
{
|
||||
"name": "index_todos_due_date",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"due_date"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_todos_due_date` ON `${TABLE_NAME}` (`due_date`)"
|
||||
},
|
||||
{
|
||||
"name": "index_todos_is_done",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"is_done"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_todos_is_done` ON `${TABLE_NAME}` (`is_done`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"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, '0e82919fe46a8a52b16c457c3b43efd6')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,721 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 8,
|
||||
"identityHash": "26e14aba256b6f9d1e3a53e14a132c7e",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "links",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `tags` TEXT NOT NULL, `is_private` INTEGER NOT NULL, `is_pinned` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `sync_status` TEXT NOT NULL, `local_modified_at` INTEGER NOT NULL, `thumbnail_url` TEXT, `reading_time_minutes` INTEGER, `content_type` TEXT NOT NULL, `site_name` TEXT, `excerpt` TEXT, `link_check_status` TEXT NOT NULL, `fail_count` INTEGER NOT NULL, `last_health_check` INTEGER NOT NULL, `excluded_from_health_check` INTEGER NOT NULL, `reader_content` TEXT, `reader_content_fetched_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isPrivate",
|
||||
"columnName": "is_private",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isPinned",
|
||||
"columnName": "is_pinned",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updated_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "syncStatus",
|
||||
"columnName": "sync_status",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "localModifiedAt",
|
||||
"columnName": "local_modified_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnail_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "readingTimeMinutes",
|
||||
"columnName": "reading_time_minutes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentType",
|
||||
"columnName": "content_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "siteName",
|
||||
"columnName": "site_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "excerpt",
|
||||
"columnName": "excerpt",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "linkCheckStatus",
|
||||
"columnName": "link_check_status",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "failCount",
|
||||
"columnName": "fail_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastHealthCheck",
|
||||
"columnName": "last_health_check",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "excludedFromHealthCheck",
|
||||
"columnName": "excluded_from_health_check",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "readerContent",
|
||||
"columnName": "reader_content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "readerContentFetchedAt",
|
||||
"columnName": "reader_content_fetched_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_links_sync_status",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sync_status"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_sync_status` ON `${TABLE_NAME}` (`sync_status`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_is_private",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"is_private"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_is_private` ON `${TABLE_NAME}` (`is_private`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_created_at",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"created_at"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_created_at` ON `${TABLE_NAME}` (`created_at`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_is_pinned",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"is_pinned"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_is_pinned` ON `${TABLE_NAME}` (`is_pinned`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_links_url` ON `${TABLE_NAME}` (`url`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_content_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"content_type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_content_type` ON `${TABLE_NAME}` (`content_type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_site_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"site_name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_site_name` ON `${TABLE_NAME}` (`site_name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_link_check_status",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"link_check_status"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_link_check_status` ON `${TABLE_NAME}` (`link_check_status`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_last_health_check",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"last_health_check"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_last_health_check` ON `${TABLE_NAME}` (`last_health_check`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"ftsVersion": "FTS4",
|
||||
"ftsOptions": {
|
||||
"tokenizer": "simple",
|
||||
"tokenizerArgs": [],
|
||||
"contentTable": "links",
|
||||
"languageIdColumnName": "",
|
||||
"matchInfo": "FTS4",
|
||||
"notIndexedColumns": [],
|
||||
"prefixSizes": [],
|
||||
"preferredOrder": "ASC"
|
||||
},
|
||||
"contentSyncTriggers": [
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_BEFORE_UPDATE BEFORE UPDATE ON `links` BEGIN DELETE FROM `links_fts` WHERE `docid`=OLD.`rowid`; END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_BEFORE_DELETE BEFORE DELETE ON `links` BEGIN DELETE FROM `links_fts` WHERE `docid`=OLD.`rowid`; END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_AFTER_UPDATE AFTER UPDATE ON `links` BEGIN INSERT INTO `links_fts`(`docid`, `url`, `title`, `description`, `tags`, `excerpt`) VALUES (NEW.`rowid`, NEW.`url`, NEW.`title`, NEW.`description`, NEW.`tags`, NEW.`excerpt`); END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_AFTER_INSERT AFTER INSERT ON `links` BEGIN INSERT INTO `links_fts`(`docid`, `url`, `title`, `description`, `tags`, `excerpt`) VALUES (NEW.`rowid`, NEW.`url`, NEW.`title`, NEW.`description`, NEW.`tags`, NEW.`excerpt`); END"
|
||||
],
|
||||
"tableName": "links_fts",
|
||||
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`url` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `tags` TEXT NOT NULL, `excerpt` TEXT, content=`links`)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "excerpt",
|
||||
"columnName": "excerpt",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": []
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "tags",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `occurrences` INTEGER NOT NULL, `last_used_at` INTEGER NOT NULL, `color` INTEGER, `is_favorite` INTEGER NOT NULL, PRIMARY KEY(`name`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "occurrences",
|
||||
"columnName": "occurrences",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastUsedAt",
|
||||
"columnName": "last_used_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isFavorite",
|
||||
"columnName": "is_favorite",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_tags_name",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tags_name` ON `${TABLE_NAME}` (`name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_tags_occurrences",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"occurrences"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_tags_occurrences` ON `${TABLE_NAME}` (`occurrences`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "link_tag_cross_ref",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`link_id` INTEGER NOT NULL, `tag_name` TEXT NOT NULL, PRIMARY KEY(`link_id`, `tag_name`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "linkId",
|
||||
"columnName": "link_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "tagName",
|
||||
"columnName": "tag_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"link_id",
|
||||
"tag_name"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "collections",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `icon` TEXT NOT NULL, `color` INTEGER, `is_smart` INTEGER NOT NULL, `query` TEXT, `sort_order` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "icon",
|
||||
"columnName": "icon",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isSmart",
|
||||
"columnName": "is_smart",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "query",
|
||||
"columnName": "query",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sort_order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updated_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_collections_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_name` ON `${TABLE_NAME}` (`name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collections_is_smart",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"is_smart"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_is_smart` ON `${TABLE_NAME}` (`is_smart`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collections_sort_order",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sort_order"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "collection_links",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`collection_id` INTEGER NOT NULL, `link_id` INTEGER NOT NULL, `added_at` INTEGER NOT NULL, PRIMARY KEY(`collection_id`, `link_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "collectionId",
|
||||
"columnName": "collection_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "linkId",
|
||||
"columnName": "link_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "addedAt",
|
||||
"columnName": "added_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"collection_id",
|
||||
"link_id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "reading_reminders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `link_id` INTEGER NOT NULL, `remind_at` INTEGER NOT NULL, `repeat_interval` TEXT NOT NULL, `is_dismissed` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, FOREIGN KEY(`link_id`) REFERENCES `links`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "linkId",
|
||||
"columnName": "link_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "remindAt",
|
||||
"columnName": "remind_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "repeatInterval",
|
||||
"columnName": "repeat_interval",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDismissed",
|
||||
"columnName": "is_dismissed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_reading_reminders_link_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"link_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_reading_reminders_link_id` ON `${TABLE_NAME}` (`link_id`)"
|
||||
},
|
||||
{
|
||||
"name": "index_reading_reminders_remind_at",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"remind_at"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_reading_reminders_remind_at` ON `${TABLE_NAME}` (`remind_at`)"
|
||||
},
|
||||
{
|
||||
"name": "index_reading_reminders_is_dismissed",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"is_dismissed"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_reading_reminders_is_dismissed` ON `${TABLE_NAME}` (`is_dismissed`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "links",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"link_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "todos",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `shaarli_link_url` TEXT NOT NULL, `content` TEXT NOT NULL, `is_done` INTEGER NOT NULL, `due_date` INTEGER, `tags` TEXT NOT NULL, `is_synced` INTEGER NOT NULL, `group_name` TEXT, `subtasks` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "shaarliLinkUrl",
|
||||
"columnName": "shaarli_link_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "content",
|
||||
"columnName": "content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDone",
|
||||
"columnName": "is_done",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dueDate",
|
||||
"columnName": "due_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isSynced",
|
||||
"columnName": "is_synced",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "groupName",
|
||||
"columnName": "group_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "subtasks",
|
||||
"columnName": "subtasks",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_todos_shaarli_link_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"shaarli_link_url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_todos_shaarli_link_url` ON `${TABLE_NAME}` (`shaarli_link_url`)"
|
||||
},
|
||||
{
|
||||
"name": "index_todos_due_date",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"due_date"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_todos_due_date` ON `${TABLE_NAME}` (`due_date`)"
|
||||
},
|
||||
{
|
||||
"name": "index_todos_is_done",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"is_done"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_todos_is_done` ON `${TABLE_NAME}` (`is_done`)"
|
||||
},
|
||||
{
|
||||
"name": "index_todos_group_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"group_name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_todos_group_name` ON `${TABLE_NAME}` (`group_name`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"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, '26e14aba256b6f9d1e3a53e14a132c7e')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -116,6 +116,10 @@
|
||||
android:exported="false"
|
||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||
|
||||
<receiver
|
||||
android:name=".data.worker.TodoNotificationReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Glance Widget: Recent Links (4×2) -->
|
||||
<receiver
|
||||
android:name=".widget.glance.RecentLinksWidgetReceiver"
|
||||
|
||||
@ -209,6 +209,7 @@ class MainActivity : FragmentActivity() {
|
||||
val mimeType = intent.type ?: ""
|
||||
|
||||
// Check if this is a file share (markdown or text file)
|
||||
@Suppress("DEPRECATION")
|
||||
val fileUri = intent.getParcelableExtra<Uri>(android.content.Intent.EXTRA_STREAM)
|
||||
|
||||
if (fileUri != null && isTextOrMarkdownFile(mimeType, fileUri)) {
|
||||
|
||||
@ -30,6 +30,7 @@ class ShaarItApp : Application(), Configuration.Provider {
|
||||
setupHealthCheckWorker()
|
||||
setupWidgetUpdateWorker()
|
||||
setupReminderNotificationChannel()
|
||||
setupTodoNotificationChannel()
|
||||
setupAudioNotificationChannel()
|
||||
}
|
||||
|
||||
@ -85,8 +86,23 @@ class ShaarItApp : Application(), Configuration.Provider {
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupTodoNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_TODOS,
|
||||
getString(R.string.todo_channel_name),
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = getString(R.string.todo_channel_desc)
|
||||
}
|
||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_REMINDERS = "reading_reminders"
|
||||
const val CHANNEL_TODOS = "todo_reminders"
|
||||
const val CHANNEL_AUDIO = "audio_playback"
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import com.shaarit.data.local.dao.CollectionDao
|
||||
import com.shaarit.data.local.dao.LinkDao
|
||||
import com.shaarit.data.local.dao.ReminderDao
|
||||
import com.shaarit.data.local.dao.TagDao
|
||||
import com.shaarit.data.local.dao.TodoDao
|
||||
import com.shaarit.data.local.database.ShaarliDatabase
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@ -49,4 +50,10 @@ object DatabaseModule {
|
||||
fun provideReminderDao(database: ShaarliDatabase): ReminderDao {
|
||||
return database.reminderDao()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTodoDao(database: ShaarliDatabase): TodoDao {
|
||||
return database.todoDao()
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,9 +3,11 @@ package com.shaarit.core.di
|
||||
import com.shaarit.data.repository.AuthRepositoryImpl
|
||||
import com.shaarit.data.repository.GeminiRepositoryImpl
|
||||
import com.shaarit.data.repository.LinkRepositoryImpl
|
||||
import com.shaarit.data.repository.TodoRepositoryImpl
|
||||
import com.shaarit.domain.repository.AuthRepository
|
||||
import com.shaarit.domain.repository.GeminiRepository
|
||||
import com.shaarit.domain.repository.LinkRepository
|
||||
import com.shaarit.domain.repository.TodoRepository
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
@ -21,4 +23,6 @@ abstract class RepositoryModule {
|
||||
@Binds @Singleton abstract fun bindLinkRepository(impl: LinkRepositoryImpl): LinkRepository
|
||||
|
||||
@Binds @Singleton abstract fun bindGeminiRepository(impl: GeminiRepositoryImpl): GeminiRepository
|
||||
|
||||
@Binds @Singleton abstract fun bindTodoRepository(impl: TodoRepositoryImpl): TodoRepository
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ import com.shaarit.data.dto.CreateLinkDto
|
||||
import com.shaarit.data.dto.InfoDto
|
||||
import com.shaarit.data.dto.LinkDto
|
||||
import com.shaarit.data.dto.TagDto
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
@ -26,12 +25,12 @@ interface ShaarliApi {
|
||||
@Query("searchtags") searchTags: String? = null
|
||||
): List<LinkDto>
|
||||
|
||||
@POST("/api/v1/links") suspend fun addLink(@Body link: CreateLinkDto): Response<LinkDto>
|
||||
@POST("/api/v1/links") suspend fun addLink(@Body link: CreateLinkDto): LinkDto
|
||||
|
||||
@PUT("/api/v1/links/{id}")
|
||||
suspend fun updateLink(@Path("id") id: Int, @Body link: CreateLinkDto): Response<LinkDto>
|
||||
suspend fun updateLink(@Path("id") id: Int, @Body link: CreateLinkDto): LinkDto
|
||||
|
||||
@DELETE("/api/v1/links/{id}") suspend fun deleteLink(@Path("id") id: Int): Response<Unit>
|
||||
@DELETE("/api/v1/links/{id}") suspend fun deleteLink(@Path("id") id: Int)
|
||||
|
||||
@GET("/api/v1/links/{id}")
|
||||
suspend fun getLink(@Path("id") id: Int): LinkDto
|
||||
|
||||
@ -3,6 +3,7 @@ package com.shaarit.data.local.converter
|
||||
import androidx.room.TypeConverter
|
||||
import com.shaarit.data.local.entity.ContentType
|
||||
import com.shaarit.data.local.entity.SyncStatus
|
||||
import com.shaarit.domain.model.SubTask
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@ -64,4 +65,24 @@ class Converters {
|
||||
ContentType.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
// ====== List<SubTask> ======
|
||||
|
||||
@TypeConverter
|
||||
fun fromSubTaskList(value: List<SubTask>): String {
|
||||
return try {
|
||||
json.encodeToString(value)
|
||||
} catch (e: Exception) {
|
||||
"[]"
|
||||
}
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toSubTaskList(value: String): List<SubTask> {
|
||||
return try {
|
||||
json.decodeFromString<List<SubTask>>(value)
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,7 +58,9 @@ interface ReminderDao {
|
||||
|
||||
@Transaction
|
||||
@Query("""
|
||||
SELECT r.*, l.* FROM reading_reminders r
|
||||
SELECT r.id, r.link_id, r.remind_at, r.repeat_interval, r.is_dismissed, r.created_at,
|
||||
l.url, l.title, l.site_name, l.reading_time_minutes, l.thumbnail_url
|
||||
FROM reading_reminders r
|
||||
INNER JOIN links l ON r.link_id = l.id
|
||||
WHERE r.is_dismissed = 0
|
||||
ORDER BY r.remind_at ASC
|
||||
@ -67,7 +69,9 @@ interface ReminderDao {
|
||||
|
||||
@Transaction
|
||||
@Query("""
|
||||
SELECT r.*, l.* FROM reading_reminders r
|
||||
SELECT r.id, r.link_id, r.remind_at, r.repeat_interval, r.is_dismissed, r.created_at,
|
||||
l.url, l.title, l.site_name, l.reading_time_minutes, l.thumbnail_url
|
||||
FROM reading_reminders r
|
||||
INNER JOIN links l ON r.link_id = l.id
|
||||
ORDER BY r.remind_at DESC
|
||||
""")
|
||||
|
||||
81
app/src/main/java/com/shaarit/data/local/dao/TodoDao.kt
Normal file
81
app/src/main/java/com/shaarit/data/local/dao/TodoDao.kt
Normal file
@ -0,0 +1,81 @@
|
||||
package com.shaarit.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import com.shaarit.data.local.entity.TodoEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface TodoDao {
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM todos
|
||||
ORDER BY
|
||||
CASE WHEN is_done = 1 THEN 1 ELSE 0 END ASC,
|
||||
CASE WHEN due_date IS NULL THEN 1 ELSE 0 END ASC,
|
||||
due_date ASC,
|
||||
id DESC
|
||||
"""
|
||||
)
|
||||
fun getTodosStream(): Flow<List<TodoEntity>>
|
||||
|
||||
@Query("SELECT * FROM todos WHERE id = :id LIMIT 1")
|
||||
suspend fun getTodoById(id: Long): TodoEntity?
|
||||
|
||||
@Query("SELECT * FROM todos WHERE shaarli_link_url = :shaarliLinkUrl LIMIT 1")
|
||||
suspend fun getTodoByShaarliLinkUrl(shaarliLinkUrl: String): TodoEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertTodo(todo: TodoEntity): Long
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertTodos(todos: List<TodoEntity>)
|
||||
|
||||
@Update
|
||||
suspend fun updateTodo(todo: TodoEntity)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteTodo(todo: TodoEntity)
|
||||
|
||||
@Query("DELETE FROM todos WHERE id = :id")
|
||||
suspend fun deleteTodoById(id: Long)
|
||||
|
||||
@Query("UPDATE todos SET is_done = :isDone, is_synced = :isSynced WHERE id = :id")
|
||||
suspend fun updateDoneStatus(id: Long, isDone: Boolean, isSynced: Boolean = false)
|
||||
|
||||
@Query("UPDATE todos SET due_date = :dueDate, is_synced = :isSynced WHERE id = :id")
|
||||
suspend fun updateDueDate(id: Long, dueDate: Long?, isSynced: Boolean = false)
|
||||
|
||||
@Query("UPDATE todos SET is_synced = :isSynced WHERE id = :id")
|
||||
suspend fun updateSyncedStatus(id: Long, isSynced: Boolean)
|
||||
|
||||
@Query("UPDATE todos SET is_synced = :isSynced WHERE shaarli_link_url = :url")
|
||||
suspend fun updateSyncedStatusByUrl(url: String, isSynced: Boolean)
|
||||
|
||||
@Query("UPDATE todos SET shaarli_link_url = :newUrl, is_synced = 0 WHERE shaarli_link_url = :oldUrl")
|
||||
suspend fun updateShaarliLinkUrl(oldUrl: String, newUrl: String)
|
||||
|
||||
@Query("SELECT DISTINCT group_name FROM todos WHERE group_name IS NOT NULL ORDER BY group_name ASC")
|
||||
fun getGroupNamesStream(): Flow<List<String>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM todos
|
||||
WHERE group_name = :groupName
|
||||
ORDER BY
|
||||
CASE WHEN is_done = 1 THEN 1 ELSE 0 END ASC,
|
||||
CASE WHEN due_date IS NULL THEN 1 ELSE 0 END ASC,
|
||||
due_date ASC,
|
||||
id DESC
|
||||
"""
|
||||
)
|
||||
fun getTodosByGroupStream(groupName: String): Flow<List<TodoEntity>>
|
||||
|
||||
@Query("DELETE FROM todos")
|
||||
suspend fun clearAll()
|
||||
}
|
||||
@ -12,6 +12,7 @@ import com.shaarit.data.local.dao.CollectionDao
|
||||
import com.shaarit.data.local.dao.LinkDao
|
||||
import com.shaarit.data.local.dao.ReminderDao
|
||||
import com.shaarit.data.local.dao.TagDao
|
||||
import com.shaarit.data.local.dao.TodoDao
|
||||
import com.shaarit.data.local.entity.CollectionEntity
|
||||
import com.shaarit.data.local.entity.CollectionLinkCrossRef
|
||||
import com.shaarit.data.local.entity.LinkEntity
|
||||
@ -19,6 +20,7 @@ import com.shaarit.data.local.entity.LinkFtsEntity
|
||||
import com.shaarit.data.local.entity.LinkTagCrossRef
|
||||
import com.shaarit.data.local.entity.ReadingReminderEntity
|
||||
import com.shaarit.data.local.entity.TagEntity
|
||||
import com.shaarit.data.local.entity.TodoEntity
|
||||
|
||||
/**
|
||||
* Database Room principale pour le cache offline de ShaarIt
|
||||
@ -31,9 +33,10 @@ import com.shaarit.data.local.entity.TagEntity
|
||||
LinkTagCrossRef::class,
|
||||
CollectionEntity::class,
|
||||
CollectionLinkCrossRef::class,
|
||||
ReadingReminderEntity::class
|
||||
ReadingReminderEntity::class,
|
||||
TodoEntity::class
|
||||
],
|
||||
version = 6,
|
||||
version = 8,
|
||||
exportSchema = true
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
@ -43,6 +46,7 @@ abstract class ShaarliDatabase : RoomDatabase() {
|
||||
abstract fun tagDao(): TagDao
|
||||
abstract fun collectionDao(): CollectionDao
|
||||
abstract fun reminderDao(): ReminderDao
|
||||
abstract fun todoDao(): TodoDao
|
||||
|
||||
companion object {
|
||||
private const val DATABASE_NAME = "shaarli.db"
|
||||
@ -92,6 +96,41 @@ abstract class ShaarliDatabase : RoomDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration v6 → v7 : Ajout de la table todos
|
||||
*/
|
||||
val MIGRATION_6_7 = object : Migration(6, 7) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `todos` (
|
||||
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`shaarli_link_url` TEXT NOT NULL,
|
||||
`content` TEXT NOT NULL,
|
||||
`is_done` INTEGER NOT NULL,
|
||||
`due_date` INTEGER,
|
||||
`tags` TEXT NOT NULL,
|
||||
`is_synced` INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_todos_shaarli_link_url` ON `todos` (`shaarli_link_url`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_todos_due_date` ON `todos` (`due_date`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_todos_is_done` ON `todos` (`is_done`)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration v7 → v8 : Ajout des groupes et sous-tâches
|
||||
*/
|
||||
val MIGRATION_7_8 = object : Migration(7, 8) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `todos` ADD COLUMN `group_name` TEXT DEFAULT NULL")
|
||||
db.execSQL("ALTER TABLE `todos` ADD COLUMN `subtasks` TEXT NOT NULL DEFAULT '[]'")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_todos_group_name` ON `todos` (`group_name`)")
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var instance: ShaarliDatabase? = null
|
||||
|
||||
@ -107,7 +146,7 @@ abstract class ShaarliDatabase : RoomDatabase() {
|
||||
ShaarliDatabase::class.java,
|
||||
DATABASE_NAME
|
||||
)
|
||||
.addMigrations(MIGRATION_4_5, MIGRATION_5_6)
|
||||
.addMigrations(MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8)
|
||||
.fallbackToDestructiveMigrationFrom(1, 2, 3)
|
||||
.build()
|
||||
}
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
package com.shaarit.data.local.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import com.shaarit.domain.model.SubTask
|
||||
|
||||
@Entity(
|
||||
tableName = "todos",
|
||||
indices = [
|
||||
Index(value = ["shaarli_link_url"], unique = true),
|
||||
Index(value = ["due_date"]),
|
||||
Index(value = ["is_done"]),
|
||||
Index(value = ["group_name"])
|
||||
]
|
||||
)
|
||||
data class TodoEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
|
||||
@ColumnInfo(name = "shaarli_link_url")
|
||||
val shaarliLinkUrl: String,
|
||||
|
||||
@ColumnInfo(name = "content")
|
||||
val content: String,
|
||||
|
||||
@ColumnInfo(name = "is_done")
|
||||
val isDone: Boolean = false,
|
||||
|
||||
@ColumnInfo(name = "due_date")
|
||||
val dueDate: Long? = null,
|
||||
|
||||
@ColumnInfo(name = "tags")
|
||||
val tags: List<String> = emptyList(),
|
||||
|
||||
@ColumnInfo(name = "is_synced")
|
||||
val isSynced: Boolean = false,
|
||||
|
||||
@ColumnInfo(name = "group_name")
|
||||
val groupName: String? = null,
|
||||
|
||||
@ColumnInfo(name = "subtasks")
|
||||
val subtasks: List<SubTask> = emptyList()
|
||||
)
|
||||
185
app/src/main/java/com/shaarit/data/mapper/TodoMarkdownMapper.kt
Normal file
185
app/src/main/java/com/shaarit/data/mapper/TodoMarkdownMapper.kt
Normal file
@ -0,0 +1,185 @@
|
||||
package com.shaarit.data.mapper
|
||||
|
||||
import com.shaarit.data.local.entity.LinkEntity
|
||||
import com.shaarit.data.local.entity.TodoEntity
|
||||
import com.shaarit.domain.model.SubTask
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class TodoShaarliPayload(
|
||||
val url: String,
|
||||
val title: String,
|
||||
val markdownBody: String,
|
||||
val tags: List<String>
|
||||
)
|
||||
|
||||
data class ParsedTodoFromShaarli(
|
||||
val shaarliLinkUrl: String,
|
||||
val content: String,
|
||||
val isDone: Boolean,
|
||||
val dueDate: Long?,
|
||||
val tags: List<String>,
|
||||
val groupName: String? = null,
|
||||
val subtasks: List<SubTask> = emptyList()
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class TodoMarkdownMapper @Inject constructor() {
|
||||
|
||||
private val hashTagRegex = Regex("#([a-zA-Z0-9_-]+)")
|
||||
private val checkboxRegex = Regex("-\\s*\\[( |x|X)]\\s*(.+)")
|
||||
private val subtaskRegex = Regex("^\\s{2,}-\\s*\\[( |x|X)]\\s*(.+)")
|
||||
private val dueDateRegex = Regex("📅\\s*\\*\\*Échéance\\s*:\\*\\*\\s*([^\\n\\r]+)", RegexOption.IGNORE_CASE)
|
||||
private val groupRegex = Regex("🏷️\\s*\\*\\*Groupe\\s*:\\*\\*\\s*([^\\n\\r]+)", RegexOption.IGNORE_CASE)
|
||||
|
||||
fun toShaarliPayload(todo: TodoEntity): TodoShaarliPayload {
|
||||
val normalizedTags = normalizeTags(todo.tags)
|
||||
val fullTags = (listOf("todo") + normalizedTags).distinct()
|
||||
|
||||
val dueDateLine = todo.dueDate
|
||||
?.let { Instant.ofEpochMilli(it).toString() }
|
||||
?: "Aucune"
|
||||
|
||||
val checkbox = if (todo.isDone) "x" else " "
|
||||
val content = todo.content.trim().ifBlank { "Tâche sans titre" }
|
||||
val hashtagSection = if (normalizedTags.isEmpty()) {
|
||||
""
|
||||
} else {
|
||||
" " + normalizedTags.joinToString(" ") { "#$it" }
|
||||
}
|
||||
|
||||
val body = buildString {
|
||||
append("📅 **Échéance :** ")
|
||||
append(dueDateLine)
|
||||
|
||||
if (!todo.groupName.isNullOrBlank()) {
|
||||
append("\n🏷️ **Groupe :** ")
|
||||
append(todo.groupName.trim())
|
||||
}
|
||||
|
||||
append("\n\n")
|
||||
append("- [")
|
||||
append(checkbox)
|
||||
append("] ")
|
||||
append(content)
|
||||
append(hashtagSection)
|
||||
|
||||
todo.subtasks.forEach { sub ->
|
||||
val subCheck = if (sub.isDone) "x" else " "
|
||||
append("\n - [")
|
||||
append(subCheck)
|
||||
append("] ")
|
||||
append(sub.content.trim())
|
||||
}
|
||||
}
|
||||
|
||||
val finalUrl = todo.shaarliLinkUrl.ifBlank {
|
||||
"note://todo-${UUID.randomUUID()}"
|
||||
}
|
||||
|
||||
return TodoShaarliPayload(
|
||||
url = finalUrl,
|
||||
title = buildTitle(content),
|
||||
markdownBody = body,
|
||||
tags = fullTags
|
||||
)
|
||||
}
|
||||
|
||||
fun fromShaarliLink(link: LinkEntity): ParsedTodoFromShaarli? {
|
||||
if (!isTodoLink(link.url, link.tags)) return null
|
||||
|
||||
val markdown = link.description
|
||||
val lines = markdown.lines()
|
||||
|
||||
// Parse main checkbox (non-indented)
|
||||
val mainCheckboxLine = lines.firstOrNull { checkboxRegex.matches(it.trim()) }
|
||||
val checkboxMatch = mainCheckboxLine?.let { checkboxRegex.find(it.trim()) }
|
||||
|
||||
val lineContent = checkboxMatch?.groupValues?.getOrNull(2)?.trim().orEmpty()
|
||||
val inlineTags = hashTagRegex.findAll(lineContent)
|
||||
.map { it.groupValues[1].lowercase(Locale.ROOT) }
|
||||
.toList()
|
||||
|
||||
val cleanContent = lineContent
|
||||
.replace(hashTagRegex, "")
|
||||
.trim()
|
||||
.ifBlank { link.title.ifBlank { "Tâche" } }
|
||||
|
||||
val isDone = checkboxMatch?.groupValues?.getOrNull(1)?.equals("x", ignoreCase = true) == true
|
||||
|
||||
val dueDateRaw = dueDateRegex.find(markdown)?.groupValues?.getOrNull(1)?.trim()
|
||||
val parsedDueDate = parseDueDate(dueDateRaw)
|
||||
|
||||
val groupRaw = groupRegex.find(markdown)?.groupValues?.getOrNull(1)?.trim()
|
||||
val groupName = groupRaw?.takeIf { it.isNotBlank() && !it.equals("aucun", ignoreCase = true) }
|
||||
|
||||
// Parse subtasks (indented checkboxes)
|
||||
val subtasks = lines.mapNotNull { line ->
|
||||
val match = subtaskRegex.find(line) ?: return@mapNotNull null
|
||||
val subDone = match.groupValues[1].equals("x", ignoreCase = true)
|
||||
val subContent = match.groupValues[2]
|
||||
.replace(hashTagRegex, "")
|
||||
.trim()
|
||||
if (subContent.isNotBlank()) SubTask(subContent, subDone) else null
|
||||
}
|
||||
|
||||
val mergedTags = (
|
||||
link.tags.filterNot { it.equals("todo", true) || it.equals("brain-dump", true) } + inlineTags
|
||||
).map { it.lowercase(Locale.ROOT) }
|
||||
.distinct()
|
||||
|
||||
return ParsedTodoFromShaarli(
|
||||
shaarliLinkUrl = link.url,
|
||||
content = cleanContent,
|
||||
isDone = isDone,
|
||||
dueDate = parsedDueDate,
|
||||
tags = mergedTags,
|
||||
groupName = groupName,
|
||||
subtasks = subtasks
|
||||
)
|
||||
}
|
||||
|
||||
private fun isTodoLink(url: String, tags: List<String>): Boolean {
|
||||
return url.startsWith("note://todo-", ignoreCase = true) ||
|
||||
tags.any { it.equals("todo", ignoreCase = true) }
|
||||
}
|
||||
|
||||
private fun buildTitle(content: String): String {
|
||||
return content
|
||||
.lineSequence()
|
||||
.firstOrNull { it.isNotBlank() }
|
||||
?.take(80)
|
||||
?.trim()
|
||||
?.ifBlank { "Tâche" }
|
||||
?: "Tâche"
|
||||
}
|
||||
|
||||
private fun normalizeTags(tags: List<String>): List<String> {
|
||||
return tags
|
||||
.map { it.trim().trimStart('#').lowercase(Locale.ROOT) }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
private fun parseDueDate(raw: String?): Long? {
|
||||
if (raw.isNullOrBlank()) return null
|
||||
if (raw.equals("aucune", ignoreCase = true) || raw == "-") return null
|
||||
|
||||
return try {
|
||||
Instant.parse(raw).toEpochMilli()
|
||||
} catch (_: Exception) {
|
||||
try {
|
||||
val date = LocalDate.parse(raw, DateTimeFormatter.ISO_DATE)
|
||||
date.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -224,7 +224,7 @@ class LinkMetadataExtractor @Inject constructor() {
|
||||
* Estime le temps de lecture
|
||||
*/
|
||||
private fun estimateReadingTime(doc: Document): Int? {
|
||||
val text = doc.body()?.text() ?: return null
|
||||
val text = doc.body().text()
|
||||
val wordCount = text.split(Regex("\\s+")).size
|
||||
// Moyenne de 200 mots par minute
|
||||
val minutes = (wordCount / 200.0).toInt()
|
||||
|
||||
@ -6,6 +6,8 @@ import com.google.ai.client.generativeai.type.generationConfig
|
||||
import com.shaarit.core.storage.TokenManager
|
||||
import com.shaarit.domain.model.AiContentType
|
||||
import com.shaarit.domain.model.AiEnrichmentResult
|
||||
import com.shaarit.domain.model.BrainDumpResult
|
||||
import com.shaarit.domain.model.BrainDumpTaskSuggestion
|
||||
import com.shaarit.domain.repository.GeminiRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@ -14,6 +16,11 @@ import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.json.JSONObject
|
||||
import android.util.LruCache
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@ -29,6 +36,8 @@ class GeminiRepositoryImpl @Inject constructor(
|
||||
// Cache en mémoire pour éviter de rappeler l'API pour la même URL durant la session (borné à 50 entrées max)
|
||||
private val analysisCache = LruCache<String, AiEnrichmentResult>(50)
|
||||
|
||||
private val brainDumpCache = LruCache<String, BrainDumpResult>(30)
|
||||
|
||||
override fun isApiKeyConfigured(): Boolean {
|
||||
return !tokenManager.getGeminiApiKey().isNullOrBlank()
|
||||
}
|
||||
@ -75,6 +84,51 @@ class GeminiRepositoryImpl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun analyzeBrainDump(input: String): BrainDumpResult = withContext(Dispatchers.IO) {
|
||||
val normalizedInput = input.trim()
|
||||
if (normalizedInput.isBlank()) {
|
||||
throw IllegalArgumentException("Le brain dump ne peut pas être vide")
|
||||
}
|
||||
|
||||
brainDumpCache.get(normalizedInput)?.let {
|
||||
return@withContext it
|
||||
}
|
||||
|
||||
val apiKey = tokenManager.getGeminiApiKey()
|
||||
if (apiKey.isNullOrBlank()) {
|
||||
throw IllegalStateException("Clé API Gemini non configurée.")
|
||||
}
|
||||
|
||||
val modelsToTry = listOf(
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-3-flash",
|
||||
"gemini-2.0-flash-lite",
|
||||
"gemini-1.5-flash"
|
||||
)
|
||||
|
||||
var lastException: Exception? = null
|
||||
for (modelName in modelsToTry) {
|
||||
try {
|
||||
val result = analyzeBrainDumpWithModel(apiKey, modelName, normalizedInput)
|
||||
brainDumpCache.put(normalizedInput, result)
|
||||
return@withContext result
|
||||
} catch (e: Exception) {
|
||||
lastException = e
|
||||
val msg = (e.message ?: "").lowercase()
|
||||
val isRetryable = msg.contains("404") ||
|
||||
msg.contains("not found") ||
|
||||
msg.contains("429") ||
|
||||
msg.contains("quota") ||
|
||||
msg.contains("exhausted")
|
||||
if (!isRetryable) break
|
||||
}
|
||||
}
|
||||
|
||||
lastException?.let { throw it }
|
||||
fallbackBrainDumpResult(normalizedInput)
|
||||
}
|
||||
|
||||
private suspend fun generateTagsWithModel(apiKey: String, modelName: String, title: String, description: String): List<String> {
|
||||
val generativeModel = GenerativeModel(
|
||||
modelName = modelName,
|
||||
@ -103,6 +157,30 @@ class GeminiRepositoryImpl @Inject constructor(
|
||||
return parseTagsResponse(responseText)
|
||||
}
|
||||
|
||||
private suspend fun analyzeBrainDumpWithModel(
|
||||
apiKey: String,
|
||||
modelName: String,
|
||||
input: String
|
||||
): BrainDumpResult {
|
||||
val model = GenerativeModel(
|
||||
modelName = modelName,
|
||||
apiKey = apiKey,
|
||||
generationConfig = generationConfig {
|
||||
temperature = 0.3f
|
||||
maxOutputTokens = 1024
|
||||
}
|
||||
)
|
||||
|
||||
val response = model.generateContent(
|
||||
content {
|
||||
text(buildBrainDumpPrompt(input))
|
||||
}
|
||||
)
|
||||
|
||||
val responseText = response.text ?: throw Exception("Réponse vide pour le brain dump")
|
||||
return parseBrainDumpResponse(responseText, input)
|
||||
}
|
||||
|
||||
private fun parseTagsResponse(responseText: String): List<String> {
|
||||
val cleaned = responseText.replace("```json", "").replace("```", "").trim()
|
||||
val jsonArray = org.json.JSONArray(cleaned)
|
||||
@ -113,6 +191,153 @@ class GeminiRepositoryImpl @Inject constructor(
|
||||
return tags
|
||||
}
|
||||
|
||||
private fun buildBrainDumpPrompt(input: String): String {
|
||||
return """
|
||||
Rôle: Tu es un assistant de productivité qui transforme une décharge mentale en tâches actionnables.
|
||||
|
||||
Entrée utilisateur:
|
||||
$input
|
||||
|
||||
Objectif:
|
||||
1. Extraire des tâches explicites et concrètes.
|
||||
2. Générer un titre global court.
|
||||
3. Déduire une échéance ISO-8601 quand l'utilisateur donne une date claire, sinon null.
|
||||
4. Proposer 1 à 4 tags courts par tâche.
|
||||
|
||||
Règles:
|
||||
- Pas d'invention: si la date n'est pas explicite, dueDate = null.
|
||||
- Titre de tâche impératif, max 80 caractères.
|
||||
- Tags en lowercase, format slug (ex: "admin", "urgent", "sante").
|
||||
|
||||
Réponse STRICTEMENT en JSON valide, sans markdown:
|
||||
{
|
||||
"title": "Titre global",
|
||||
"tasks": [
|
||||
{
|
||||
"title": "Tâche 1",
|
||||
"dueDate": "2026-02-13T16:00:00Z",
|
||||
"tags": ["tag1", "tag2"]
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun parseBrainDumpResponse(responseText: String, fallbackInput: String): BrainDumpResult {
|
||||
return try {
|
||||
val cleaned = responseText
|
||||
.replace("```json", "")
|
||||
.replace("```", "")
|
||||
.trim()
|
||||
|
||||
val json = JSONObject(cleaned)
|
||||
val title = json.optString("title", "").trim().ifBlank { "Brain Dump" }.take(80)
|
||||
|
||||
val tasks = mutableListOf<BrainDumpTaskSuggestion>()
|
||||
val tasksArray = json.optJSONArray("tasks")
|
||||
if (tasksArray != null) {
|
||||
for (i in 0 until tasksArray.length()) {
|
||||
val obj = tasksArray.optJSONObject(i) ?: continue
|
||||
val taskTitle = obj.optString("title", "").trim().take(80)
|
||||
if (taskTitle.isBlank()) continue
|
||||
|
||||
val dueDate = parseBrainDumpDueDate(
|
||||
if (obj.has("dueDate") && !obj.isNull("dueDate")) obj.optString("dueDate") else null
|
||||
)
|
||||
|
||||
val tags = mutableListOf<String>()
|
||||
val tagsArray = obj.optJSONArray("tags")
|
||||
if (tagsArray != null) {
|
||||
for (j in 0 until tagsArray.length()) {
|
||||
val rawTag = tagsArray.optString(j)
|
||||
.trim()
|
||||
.trimStart('#')
|
||||
.lowercase(Locale.ROOT)
|
||||
.replace(" ", "-")
|
||||
.take(30)
|
||||
if (rawTag.isNotBlank()) {
|
||||
tags.add(rawTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.add(
|
||||
BrainDumpTaskSuggestion(
|
||||
title = taskTitle,
|
||||
dueDate = dueDate,
|
||||
tags = tags.distinct()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.isEmpty()) {
|
||||
fallbackBrainDumpResult(fallbackInput)
|
||||
} else {
|
||||
BrainDumpResult(
|
||||
generatedTitle = title,
|
||||
tasks = tasks.take(12)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
fallbackBrainDumpResult(fallbackInput)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseBrainDumpDueDate(raw: String?): Long? {
|
||||
if (raw.isNullOrBlank()) return null
|
||||
val value = raw.trim()
|
||||
if (value.equals("null", ignoreCase = true) ||
|
||||
value.equals("none", ignoreCase = true) ||
|
||||
value.equals("aucune", ignoreCase = true)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
Instant.parse(value).toEpochMilli()
|
||||
} catch (_: Exception) {
|
||||
try {
|
||||
val localDate = LocalDate.parse(value, DateTimeFormatter.ISO_DATE)
|
||||
localDate.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fallbackBrainDumpResult(input: String): BrainDumpResult {
|
||||
val candidateTasks = input
|
||||
.split("\n", ";", "•", "-")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.take(8)
|
||||
.map {
|
||||
BrainDumpTaskSuggestion(
|
||||
title = it.take(80),
|
||||
dueDate = null,
|
||||
tags = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
val tasks = if (candidateTasks.isEmpty()) {
|
||||
listOf(
|
||||
BrainDumpTaskSuggestion(
|
||||
title = input.take(80).ifBlank { "Nouvelle tâche" },
|
||||
dueDate = null,
|
||||
tags = emptyList()
|
||||
)
|
||||
)
|
||||
} else {
|
||||
candidateTasks
|
||||
}
|
||||
|
||||
return BrainDumpResult(
|
||||
generatedTitle = tasks.firstOrNull()?.title ?: "Brain Dump",
|
||||
tasks = tasks
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult> = withContext(Dispatchers.IO) {
|
||||
// Vérifier le cache d'abord
|
||||
analysisCache.get(url)?.let {
|
||||
|
||||
@ -274,26 +274,11 @@ constructor(
|
||||
)
|
||||
} else {
|
||||
// Essayer l'API directement
|
||||
val response = api.addLink(CreateLinkDto(url, title, description, tags, isPrivate))
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { serverLink ->
|
||||
serverLink.toEntity()?.let { entity ->
|
||||
linkDao.insertLink(entity)
|
||||
}
|
||||
}
|
||||
AddLinkResult.Success
|
||||
} else if (response.code() == 409) {
|
||||
val errorBody = response.errorBody()?.string()
|
||||
val existingLink = parseExistingLink(errorBody)
|
||||
AddLinkResult.Conflict(
|
||||
existingLinkId = existingLink?.id ?: 0,
|
||||
existingTitle = existingLink?.title
|
||||
)
|
||||
} else {
|
||||
// Fallback : créer localement
|
||||
addLink(url, title, description, tags, isPrivate)
|
||||
AddLinkResult.Success
|
||||
val serverLink = api.addLink(CreateLinkDto(url, title, description, tags, isPrivate))
|
||||
serverLink.toEntity()?.let { entity ->
|
||||
linkDao.insertLink(entity)
|
||||
}
|
||||
AddLinkResult.Success
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
|
||||
@ -0,0 +1,218 @@
|
||||
package com.shaarit.data.repository
|
||||
|
||||
import android.util.Log
|
||||
import com.shaarit.data.local.dao.LinkDao
|
||||
import com.shaarit.data.local.dao.TodoDao
|
||||
import com.shaarit.data.local.entity.LinkEntity
|
||||
import com.shaarit.data.local.entity.SyncStatus
|
||||
import com.shaarit.data.local.entity.TodoEntity
|
||||
import com.shaarit.data.mapper.TodoMarkdownMapper
|
||||
import com.shaarit.data.sync.SyncManager
|
||||
import com.shaarit.data.worker.TodoNotificationScheduler
|
||||
import com.shaarit.domain.model.TodoItem
|
||||
import com.shaarit.domain.repository.TodoRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.random.Random
|
||||
|
||||
@Singleton
|
||||
class TodoRepositoryImpl @Inject constructor(
|
||||
private val todoDao: TodoDao,
|
||||
private val linkDao: LinkDao,
|
||||
private val syncManager: SyncManager,
|
||||
private val todoMarkdownMapper: TodoMarkdownMapper,
|
||||
private val notificationScheduler: TodoNotificationScheduler
|
||||
) : TodoRepository {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TodoRepository"
|
||||
private const val TODO_URL_PREFIX = "https://shaarit.app/todo/"
|
||||
}
|
||||
|
||||
override fun getTodosStream(): Flow<List<TodoItem>> {
|
||||
return todoDao.getTodosStream().map { todos ->
|
||||
todos.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getGroupNamesStream(): Flow<List<String>> {
|
||||
return todoDao.getGroupNamesStream()
|
||||
}
|
||||
|
||||
override suspend fun getTodoById(id: Long): TodoItem? {
|
||||
return todoDao.getTodoById(id)?.toDomain()
|
||||
}
|
||||
|
||||
override suspend fun upsertTodo(todo: TodoItem): Result<Long> {
|
||||
return runCatching {
|
||||
val normalized = normalizeTodo(todo)
|
||||
val savedId = if (normalized.id == 0L) {
|
||||
todoDao.insertTodo(normalized)
|
||||
} else {
|
||||
todoDao.updateTodo(normalized)
|
||||
normalized.id
|
||||
}
|
||||
|
||||
val persisted = normalized.copy(id = savedId)
|
||||
scheduleIfNeeded(persisted)
|
||||
upsertShaarliLinkFromTodo(persisted)
|
||||
|
||||
savedId
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun toggleDone(todoId: Long, isDone: Boolean): Result<Unit> {
|
||||
return runCatching {
|
||||
val existing = todoDao.getTodoById(todoId)
|
||||
?: throw IllegalArgumentException("Todo not found")
|
||||
|
||||
val updated = existing.copy(
|
||||
isDone = isDone,
|
||||
isSynced = false
|
||||
)
|
||||
todoDao.updateTodo(updated)
|
||||
|
||||
scheduleIfNeeded(updated)
|
||||
upsertShaarliLinkFromTodo(updated)
|
||||
syncManager.syncNow()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun snoozeTodo(todoId: Long, delayMs: Long): Result<Unit> {
|
||||
return runCatching {
|
||||
val existing = todoDao.getTodoById(todoId)
|
||||
?: throw IllegalArgumentException("Todo not found")
|
||||
|
||||
val updated = existing.copy(
|
||||
isDone = false,
|
||||
dueDate = System.currentTimeMillis() + delayMs,
|
||||
isSynced = false
|
||||
)
|
||||
todoDao.updateTodo(updated)
|
||||
|
||||
scheduleIfNeeded(updated)
|
||||
upsertShaarliLinkFromTodo(updated)
|
||||
syncManager.syncNow()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteTodo(todoId: Long): Result<Unit> {
|
||||
return runCatching {
|
||||
val existing = todoDao.getTodoById(todoId)
|
||||
?: throw IllegalArgumentException("Todo not found")
|
||||
|
||||
notificationScheduler.cancel(todoId)
|
||||
todoDao.deleteTodo(existing)
|
||||
|
||||
val linked = linkDao.getLinkByUrl(existing.shaarliLinkUrl)
|
||||
if (linked != null) {
|
||||
if (linked.syncStatus == SyncStatus.PENDING_CREATE) {
|
||||
linkDao.deleteLink(linked.id)
|
||||
} else {
|
||||
linkDao.markForDeletion(linked.id)
|
||||
syncManager.syncNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun upsertShaarliLinkFromTodo(todo: TodoEntity) {
|
||||
val payload = todoMarkdownMapper.toShaarliPayload(todo)
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
val existingLink = linkDao.getLinkByUrl(payload.url)
|
||||
if (existingLink != null) {
|
||||
val updated = existingLink.copy(
|
||||
url = payload.url,
|
||||
title = payload.title,
|
||||
description = payload.markdownBody,
|
||||
tags = payload.tags,
|
||||
isPrivate = true,
|
||||
updatedAt = now,
|
||||
syncStatus = if (existingLink.syncStatus == SyncStatus.PENDING_CREATE) {
|
||||
SyncStatus.PENDING_CREATE
|
||||
} else {
|
||||
SyncStatus.PENDING_UPDATE
|
||||
},
|
||||
localModifiedAt = now
|
||||
)
|
||||
linkDao.updateLink(updated)
|
||||
Log.d(TAG, "Updated mirror link for todo ${todo.id}: url=${payload.url}")
|
||||
} else {
|
||||
val newLink = LinkEntity(
|
||||
id = generateTempId(),
|
||||
url = payload.url,
|
||||
title = payload.title,
|
||||
description = payload.markdownBody,
|
||||
tags = payload.tags,
|
||||
isPrivate = true,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
syncStatus = SyncStatus.PENDING_CREATE,
|
||||
localModifiedAt = now
|
||||
)
|
||||
linkDao.insertLink(newLink)
|
||||
Log.d(TAG, "Created mirror link for todo ${todo.id}: id=${newLink.id}, url=${payload.url}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeTodo(todo: TodoItem): TodoEntity {
|
||||
val cleanedContent = todo.content.trim().ifBlank { "Tâche" }
|
||||
val cleanedTags = todo.tags
|
||||
.map { it.trim().trimStart('#').lowercase(Locale.ROOT) }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
|
||||
val url = todo.shaarliLinkUrl.ifBlank {
|
||||
"$TODO_URL_PREFIX${UUID.randomUUID()}"
|
||||
}
|
||||
|
||||
return TodoEntity(
|
||||
id = todo.id,
|
||||
shaarliLinkUrl = url,
|
||||
content = cleanedContent,
|
||||
isDone = todo.isDone,
|
||||
dueDate = todo.dueDate,
|
||||
tags = cleanedTags,
|
||||
isSynced = false,
|
||||
groupName = todo.groupName?.trim()?.takeIf { it.isNotBlank() },
|
||||
subtasks = todo.subtasks
|
||||
)
|
||||
}
|
||||
|
||||
private fun scheduleIfNeeded(todo: TodoEntity) {
|
||||
if (todo.id == 0L) return
|
||||
|
||||
val dueDate = todo.dueDate
|
||||
if (todo.isDone || dueDate == null || dueDate <= System.currentTimeMillis()) {
|
||||
notificationScheduler.cancel(todo.id)
|
||||
return
|
||||
}
|
||||
|
||||
notificationScheduler.schedule(todo)
|
||||
}
|
||||
|
||||
private fun TodoEntity.toDomain(): TodoItem {
|
||||
return TodoItem(
|
||||
id = id,
|
||||
shaarliLinkUrl = shaarliLinkUrl,
|
||||
content = content,
|
||||
isDone = isDone,
|
||||
dueDate = dueDate,
|
||||
tags = tags,
|
||||
isSynced = isSynced,
|
||||
groupName = groupName,
|
||||
subtasks = subtasks
|
||||
)
|
||||
}
|
||||
|
||||
private fun generateTempId(): Int {
|
||||
val base = (System.currentTimeMillis() % Int.MAX_VALUE).toInt().absoluteValue
|
||||
return -((base + Random.nextInt(1, 10_000)) + 1)
|
||||
}
|
||||
}
|
||||
@ -19,13 +19,17 @@ import com.shaarit.data.dto.CollectionsConfigDto
|
||||
import com.shaarit.data.local.dao.LinkDao
|
||||
import com.shaarit.data.local.dao.CollectionDao
|
||||
import com.shaarit.data.local.dao.TagDao
|
||||
import com.shaarit.data.local.dao.TodoDao
|
||||
import com.shaarit.data.local.entity.CollectionEntity
|
||||
import com.shaarit.data.local.entity.CollectionLinkCrossRef
|
||||
import com.shaarit.data.local.entity.LinkEntity
|
||||
import com.shaarit.data.local.entity.SyncStatus
|
||||
import com.shaarit.data.local.entity.TagEntity
|
||||
import com.shaarit.data.local.entity.TodoEntity
|
||||
import com.shaarit.data.mapper.LinkMapper
|
||||
import com.shaarit.data.mapper.ParsedTodoFromShaarli
|
||||
import com.shaarit.data.mapper.TagMapper
|
||||
import com.shaarit.data.mapper.TodoMarkdownMapper
|
||||
import com.shaarit.core.storage.TokenManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.assisted.Assisted
|
||||
@ -49,6 +53,8 @@ class SyncManager @Inject constructor(
|
||||
private val linkDao: LinkDao,
|
||||
private val tagDao: TagDao,
|
||||
private val collectionDao: CollectionDao,
|
||||
private val todoDao: TodoDao,
|
||||
private val todoMarkdownMapper: TodoMarkdownMapper,
|
||||
private val moshi: Moshi,
|
||||
private val tokenManager: TokenManager,
|
||||
private val api: ShaarliApi
|
||||
@ -56,6 +62,8 @@ class SyncManager @Inject constructor(
|
||||
companion object {
|
||||
private const val TAG = "SyncManager"
|
||||
private const val SYNC_WORK_NAME = "shaarli_sync_work"
|
||||
private const val TODO_LEGACY_URL_PREFIX = "note://todo-"
|
||||
private const val TODO_HTTPS_URL_PREFIX = "https://shaarit.app/todo/"
|
||||
|
||||
private const val COLLECTIONS_CONFIG_TITLE = "collections"
|
||||
private const val COLLECTIONS_CONFIG_TAG = "shaarit_config"
|
||||
@ -176,7 +184,7 @@ class SyncManager @Inject constructor(
|
||||
val linkId = existingId ?: findCollectionsConfigBookmarkIdOnServer()
|
||||
|
||||
if (linkId != null) {
|
||||
val response = api.updateLink(
|
||||
api.updateLink(
|
||||
linkId,
|
||||
CreateLinkDto(
|
||||
url = COLLECTIONS_CONFIG_URL,
|
||||
@ -186,13 +194,10 @@ class SyncManager @Inject constructor(
|
||||
isPrivate = true
|
||||
)
|
||||
)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
tokenManager.saveCollectionsConfigBookmarkId(linkId)
|
||||
tokenManager.setCollectionsConfigDirty(false)
|
||||
}
|
||||
tokenManager.saveCollectionsConfigBookmarkId(linkId)
|
||||
tokenManager.setCollectionsConfigDirty(false)
|
||||
} else {
|
||||
val response = api.addLink(
|
||||
val created = api.addLink(
|
||||
CreateLinkDto(
|
||||
url = COLLECTIONS_CONFIG_URL,
|
||||
title = COLLECTIONS_CONFIG_TITLE,
|
||||
@ -202,13 +207,8 @@ class SyncManager @Inject constructor(
|
||||
)
|
||||
)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val createdId = response.body()?.id
|
||||
if (createdId != null) {
|
||||
tokenManager.saveCollectionsConfigBookmarkId(createdId)
|
||||
}
|
||||
tokenManager.setCollectionsConfigDirty(false)
|
||||
}
|
||||
created.id?.let { tokenManager.saveCollectionsConfigBookmarkId(it) }
|
||||
tokenManager.setCollectionsConfigDirty(false)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Erreur lors de la poussée de la configuration des collections", e)
|
||||
@ -288,32 +288,49 @@ class SyncManager @Inject constructor(
|
||||
|
||||
for (link in pendingCreates) {
|
||||
try {
|
||||
val response = api.addLink(
|
||||
val linkToCreate = migrateLegacyTodoUrlIfNeeded(link)
|
||||
val serverLink = api.addLink(
|
||||
CreateLinkDto(
|
||||
url = link.url,
|
||||
title = link.title.takeIf { it.isNotBlank() },
|
||||
description = link.description.takeIf { it.isNotBlank() },
|
||||
tags = link.tags.ifEmpty { null },
|
||||
isPrivate = link.isPrivate
|
||||
url = linkToCreate.url,
|
||||
title = linkToCreate.title.takeIf { it.isNotBlank() },
|
||||
description = linkToCreate.description.takeIf { it.isNotBlank() },
|
||||
tags = linkToCreate.tags.ifEmpty { null },
|
||||
isPrivate = linkToCreate.isPrivate
|
||||
)
|
||||
)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { serverLink ->
|
||||
// Mettre à jour l'ID local avec l'ID serveur
|
||||
val serverId = serverLink.id
|
||||
if (serverId != null) {
|
||||
val updatedLink = link.copy(
|
||||
id = serverId,
|
||||
syncStatus = SyncStatus.SYNCED
|
||||
)
|
||||
linkDao.insertLink(updatedLink)
|
||||
} else {
|
||||
Log.w(TAG, "Serveur a retourné un lien sans ID pour ${link.url}")
|
||||
}
|
||||
}
|
||||
val syncedUrl = serverLink.url ?: linkToCreate.url
|
||||
val serverId = serverLink.id
|
||||
if (serverId != null && serverId != linkToCreate.id) {
|
||||
// Supprimer l'ancien lien temporaire avant d'insérer avec l'ID serveur
|
||||
linkDao.deleteLink(linkToCreate.id)
|
||||
val updatedLink = linkToCreate.copy(
|
||||
id = serverId,
|
||||
syncStatus = SyncStatus.SYNCED
|
||||
)
|
||||
linkDao.insertLink(updatedLink)
|
||||
Log.d(TAG, "Lien créé: temp=${linkToCreate.id} -> serveur=$serverId url=${linkToCreate.url}")
|
||||
} else if (serverId != null) {
|
||||
linkDao.markAsSynced(linkToCreate.id)
|
||||
Log.d(TAG, "Lien créé avec même ID: ${linkToCreate.id} url=${linkToCreate.url}")
|
||||
} else {
|
||||
Log.e(TAG, "Échec création lien ${link.id}: ${response.code()}")
|
||||
// Serveur n'a pas retourné d'ID - marquer comme synced quand même
|
||||
linkDao.markAsSynced(linkToCreate.id)
|
||||
Log.w(TAG, "Serveur a retourné un lien sans ID pour ${linkToCreate.url}")
|
||||
}
|
||||
markTodoSyncedByUrl(syncedUrl)
|
||||
if (syncedUrl != linkToCreate.url) {
|
||||
markTodoSyncedByUrl(linkToCreate.url)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
val code = e.code()
|
||||
if (code == 409) {
|
||||
// URL déjà existante sur le serveur - supprimer le lien temporaire local
|
||||
linkDao.deleteLink(link.id)
|
||||
markTodoSyncedByUrl(link.url)
|
||||
Log.d(TAG, "Lien déjà existant sur serveur (409), nettoyé temp=${link.id} url=${link.url}")
|
||||
} else {
|
||||
Log.e(TAG, "Échec création lien ${link.id}: $code", e)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception lors de la création du lien ${link.id}", e)
|
||||
@ -326,7 +343,7 @@ class SyncManager @Inject constructor(
|
||||
|
||||
for (link in pendingUpdates) {
|
||||
try {
|
||||
val response = api.updateLink(
|
||||
api.updateLink(
|
||||
link.id,
|
||||
CreateLinkDto(
|
||||
url = link.url,
|
||||
@ -336,12 +353,10 @@ class SyncManager @Inject constructor(
|
||||
isPrivate = link.isPrivate
|
||||
)
|
||||
)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
linkDao.markAsSynced(link.id)
|
||||
} else {
|
||||
Log.e(TAG, "Échec mise à jour lien ${link.id}: ${response.code()}")
|
||||
}
|
||||
linkDao.markAsSynced(link.id)
|
||||
markTodoSyncedByUrl(link.url)
|
||||
} catch (e: HttpException) {
|
||||
Log.e(TAG, "Échec mise à jour lien ${link.id}: ${e.code()}", e)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception lors de la mise à jour du lien ${link.id}", e)
|
||||
}
|
||||
@ -353,13 +368,10 @@ class SyncManager @Inject constructor(
|
||||
|
||||
for (link in pendingDeletes) {
|
||||
try {
|
||||
val response = api.deleteLink(link.id)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
linkDao.deleteLink(link.id)
|
||||
} else {
|
||||
Log.e(TAG, "Échec suppression lien ${link.id}: ${response.code()}")
|
||||
}
|
||||
api.deleteLink(link.id)
|
||||
linkDao.deleteLink(link.id)
|
||||
} catch (e: HttpException) {
|
||||
Log.e(TAG, "Échec suppression lien ${link.id}: ${e.code()}", e)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception lors de la suppression du lien ${link.id}", e)
|
||||
}
|
||||
@ -452,6 +464,7 @@ class SyncManager @Inject constructor(
|
||||
|
||||
if (entities.isNotEmpty()) {
|
||||
linkDao.insertLinks(entities)
|
||||
syncTodosFromPulledLinks(entities)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Page offset=$offset: $newOrUpdatedCount nouveaux/modifiés sur ${validLinks.size} valides")
|
||||
@ -584,6 +597,76 @@ class SyncManager @Inject constructor(
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun markTodoSyncedByUrl(url: String) {
|
||||
todoDao.updateSyncedStatusByUrl(url, isSynced = true)
|
||||
}
|
||||
|
||||
private suspend fun migrateLegacyTodoUrlIfNeeded(link: LinkEntity): LinkEntity {
|
||||
if (!link.url.startsWith(TODO_LEGACY_URL_PREFIX, ignoreCase = true)) return link
|
||||
|
||||
val legacySuffix = link.url.removePrefix(TODO_LEGACY_URL_PREFIX)
|
||||
.ifBlank { System.currentTimeMillis().toString() }
|
||||
val newUrl = "$TODO_HTTPS_URL_PREFIX$legacySuffix"
|
||||
|
||||
if (newUrl == link.url) return link
|
||||
|
||||
val migrated = link.copy(
|
||||
url = newUrl,
|
||||
localModifiedAt = System.currentTimeMillis(),
|
||||
syncStatus = SyncStatus.PENDING_CREATE
|
||||
)
|
||||
linkDao.updateLink(migrated)
|
||||
todoDao.updateShaarliLinkUrl(oldUrl = link.url, newUrl = newUrl)
|
||||
|
||||
Log.d(TAG, "Todo URL migrée pour sync: ${link.url} -> $newUrl")
|
||||
return migrated
|
||||
}
|
||||
|
||||
private suspend fun syncTodosFromPulledLinks(links: List<LinkEntity>) {
|
||||
links.forEach { link ->
|
||||
val parsed = todoMarkdownMapper.fromShaarliLink(link) ?: return@forEach
|
||||
upsertTodoFromServer(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun upsertTodoFromServer(parsed: ParsedTodoFromShaarli) {
|
||||
val existing = todoDao.getTodoByShaarliLinkUrl(parsed.shaarliLinkUrl)
|
||||
val incoming = TodoEntity(
|
||||
id = existing?.id ?: 0,
|
||||
shaarliLinkUrl = parsed.shaarliLinkUrl,
|
||||
content = parsed.content,
|
||||
isDone = parsed.isDone,
|
||||
dueDate = parsed.dueDate,
|
||||
tags = parsed.tags,
|
||||
isSynced = true,
|
||||
groupName = parsed.groupName,
|
||||
subtasks = parsed.subtasks
|
||||
)
|
||||
|
||||
when {
|
||||
existing == null -> {
|
||||
todoDao.insertTodo(incoming)
|
||||
}
|
||||
|
||||
existing.isSynced || hasSameTodoPayload(existing, incoming) -> {
|
||||
todoDao.updateTodo(incoming.copy(id = existing.id, isSynced = true))
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Version locale non synchronisée prioritaire: on évite d'écraser l'édition en cours.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasSameTodoPayload(local: TodoEntity, remote: TodoEntity): Boolean {
|
||||
return local.content == remote.content &&
|
||||
local.isDone == remote.isDone &&
|
||||
local.dueDate == remote.dueDate &&
|
||||
local.tags == remote.tags &&
|
||||
local.groupName == remote.groupName &&
|
||||
local.subtasks == remote.subtasks
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,157 @@
|
||||
package com.shaarit.data.worker
|
||||
|
||||
import android.Manifest
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.shaarit.MainActivity
|
||||
import com.shaarit.R
|
||||
import com.shaarit.ShaarItApp
|
||||
import com.shaarit.domain.repository.TodoRepository
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class TodoNotificationReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val todoId = intent.getLongExtra(EXTRA_TODO_ID, -1L)
|
||||
if (todoId <= 0L) return
|
||||
|
||||
val entryPoint = EntryPointAccessors.fromApplication(
|
||||
context.applicationContext,
|
||||
TodoNotificationReceiverEntryPoint::class.java
|
||||
)
|
||||
val todoRepository = entryPoint.todoRepository()
|
||||
val notificationScheduler = entryPoint.notificationScheduler()
|
||||
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
when (intent.action) {
|
||||
ACTION_TRIGGER -> showTodoNotification(context, todoId, todoRepository)
|
||||
ACTION_MARK_DONE -> {
|
||||
todoRepository.toggleDone(todoId, isDone = true)
|
||||
notificationScheduler.cancel(todoId)
|
||||
NotificationManagerCompat.from(context)
|
||||
.cancel(TodoNotificationScheduler.notificationId(todoId))
|
||||
}
|
||||
ACTION_SNOOZE_ONE_HOUR -> {
|
||||
todoRepository.snoozeTodo(todoId, 3_600_000L)
|
||||
NotificationManagerCompat.from(context)
|
||||
.cancel(TodoNotificationScheduler.notificationId(todoId))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun showTodoNotification(
|
||||
context: Context,
|
||||
todoId: Long,
|
||||
todoRepository: TodoRepository
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val granted = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (!granted) return
|
||||
}
|
||||
|
||||
val todo = todoRepository.getTodoById(todoId) ?: return
|
||||
if (todo.isDone) return
|
||||
|
||||
val openTodoIntent = Intent(context, MainActivity::class.java).apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
data = android.net.Uri.parse("shaarit://todo")
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
|
||||
val contentPendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
TodoNotificationScheduler.notificationId(todoId),
|
||||
openTodoIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val markDoneIntent = Intent(context, TodoNotificationReceiver::class.java).apply {
|
||||
action = ACTION_MARK_DONE
|
||||
putExtra(EXTRA_TODO_ID, todoId)
|
||||
}
|
||||
val markDonePendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
TodoNotificationScheduler.notificationId(todoId) + 1,
|
||||
markDoneIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val snoozeIntent = Intent(context, TodoNotificationReceiver::class.java).apply {
|
||||
action = ACTION_SNOOZE_ONE_HOUR
|
||||
putExtra(EXTRA_TODO_ID, todoId)
|
||||
}
|
||||
val snoozePendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
TodoNotificationScheduler.notificationId(todoId) + 2,
|
||||
snoozeIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val dueText = todo.dueDate?.let {
|
||||
val sdf = SimpleDateFormat("EEE d MMM · HH:mm", Locale.FRANCE)
|
||||
sdf.format(Date(it))
|
||||
} ?: context.getString(R.string.todo_no_due_date)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, ShaarItApp.CHANNEL_TODOS)
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(context.getString(R.string.todo_notification_title, todo.content))
|
||||
.setContentText(context.getString(R.string.todo_notification_content, dueText))
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.addAction(
|
||||
R.drawable.ic_launcher_foreground,
|
||||
context.getString(R.string.todo_action_mark_done),
|
||||
markDonePendingIntent
|
||||
)
|
||||
.addAction(
|
||||
R.drawable.ic_launcher_foreground,
|
||||
context.getString(R.string.todo_action_snooze),
|
||||
snoozePendingIntent
|
||||
)
|
||||
.build()
|
||||
|
||||
NotificationManagerCompat.from(context)
|
||||
.notify(TodoNotificationScheduler.notificationId(todoId), notification)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_TRIGGER = "com.shaarit.todo.ACTION_TRIGGER"
|
||||
const val ACTION_MARK_DONE = "com.shaarit.todo.ACTION_MARK_DONE"
|
||||
const val ACTION_SNOOZE_ONE_HOUR = "com.shaarit.todo.ACTION_SNOOZE_ONE_HOUR"
|
||||
const val EXTRA_TODO_ID = "extra_todo_id"
|
||||
}
|
||||
}
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface TodoNotificationReceiverEntryPoint {
|
||||
fun todoRepository(): TodoRepository
|
||||
fun notificationScheduler(): TodoNotificationScheduler
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
package com.shaarit.data.worker
|
||||
|
||||
import android.Manifest
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.shaarit.data.local.entity.TodoEntity
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class TodoNotificationScheduler @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
|
||||
private val alarmManager: AlarmManager =
|
||||
context.getSystemService(AlarmManager::class.java)
|
||||
|
||||
fun schedule(todo: TodoEntity) {
|
||||
val dueDate = todo.dueDate ?: return
|
||||
if (todo.id == 0L || todo.isDone) return
|
||||
|
||||
val pendingIntent = buildTriggerPendingIntent(todo.id)
|
||||
if (dueDate <= System.currentTimeMillis()) {
|
||||
cancel(todo.id)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) {
|
||||
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, dueDate, pendingIntent)
|
||||
} else {
|
||||
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, dueDate, pendingIntent)
|
||||
}
|
||||
} catch (_: SecurityException) {
|
||||
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, dueDate, pendingIntent)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel(todoId: Long) {
|
||||
alarmManager.cancel(buildTriggerPendingIntent(todoId))
|
||||
}
|
||||
|
||||
fun requiresNotificationPermission(): Boolean {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasNotificationPermission()
|
||||
}
|
||||
|
||||
fun hasNotificationPermission(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun buildTriggerPendingIntent(todoId: Long): PendingIntent {
|
||||
val intent = Intent(context, TodoNotificationReceiver::class.java).apply {
|
||||
action = TodoNotificationReceiver.ACTION_TRIGGER
|
||||
putExtra(TodoNotificationReceiver.EXTRA_TODO_ID, todoId)
|
||||
}
|
||||
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
notificationId(todoId),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun notificationId(todoId: Long): Int {
|
||||
return (todoId % Int.MAX_VALUE).toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package com.shaarit.domain.model
|
||||
|
||||
data class BrainDumpTaskSuggestion(
|
||||
val title: String,
|
||||
val dueDate: Long? = null,
|
||||
val tags: List<String> = emptyList()
|
||||
)
|
||||
|
||||
data class BrainDumpResult(
|
||||
val generatedTitle: String,
|
||||
val tasks: List<BrainDumpTaskSuggestion>
|
||||
)
|
||||
9
app/src/main/java/com/shaarit/domain/model/SubTask.kt
Normal file
9
app/src/main/java/com/shaarit/domain/model/SubTask.kt
Normal file
@ -0,0 +1,9 @@
|
||||
package com.shaarit.domain.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SubTask(
|
||||
val content: String,
|
||||
val isDone: Boolean = false
|
||||
)
|
||||
13
app/src/main/java/com/shaarit/domain/model/TodoItem.kt
Normal file
13
app/src/main/java/com/shaarit/domain/model/TodoItem.kt
Normal file
@ -0,0 +1,13 @@
|
||||
package com.shaarit.domain.model
|
||||
|
||||
data class TodoItem(
|
||||
val id: Long = 0,
|
||||
val shaarliLinkUrl: String,
|
||||
val content: String,
|
||||
val isDone: Boolean = false,
|
||||
val dueDate: Long? = null,
|
||||
val tags: List<String> = emptyList(),
|
||||
val isSynced: Boolean = false,
|
||||
val groupName: String? = null,
|
||||
val subtasks: List<SubTask> = emptyList()
|
||||
)
|
||||
@ -1,9 +1,11 @@
|
||||
package com.shaarit.domain.repository
|
||||
|
||||
import com.shaarit.domain.model.AiEnrichmentResult
|
||||
import com.shaarit.domain.model.BrainDumpResult
|
||||
|
||||
interface GeminiRepository {
|
||||
suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult>
|
||||
suspend fun generateTags(title: String, description: String): Result<List<String>>
|
||||
suspend fun analyzeBrainDump(input: String): BrainDumpResult
|
||||
fun isApiKeyConfigured(): Boolean
|
||||
}
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
package com.shaarit.domain.repository
|
||||
|
||||
import com.shaarit.domain.model.TodoItem
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface TodoRepository {
|
||||
fun getTodosStream(): Flow<List<TodoItem>>
|
||||
|
||||
fun getGroupNamesStream(): Flow<List<String>>
|
||||
|
||||
suspend fun getTodoById(id: Long): TodoItem?
|
||||
|
||||
suspend fun upsertTodo(todo: TodoItem): Result<Long>
|
||||
|
||||
suspend fun toggleDone(todoId: Long, isDone: Boolean): Result<Unit>
|
||||
|
||||
suspend fun snoozeTodo(todoId: Long, delayMs: Long = 3_600_000L): Result<Unit>
|
||||
|
||||
suspend fun deleteTodo(todoId: Long): Result<Unit>
|
||||
}
|
||||
@ -75,6 +75,7 @@ class ClassifyBookmarksUseCase @Inject constructor(
|
||||
}
|
||||
|
||||
// Helper to classify based on URL, Title, Tags
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun classify(url: String, title: String?, tags: List<String>): Pair<ContentType, String?> {
|
||||
val lowerUrl = url.lowercase()
|
||||
val host = try { URI(url).host?.lowercase() } catch (e: Exception) { null } ?: ""
|
||||
|
||||
@ -33,6 +33,7 @@ import java.util.concurrent.TimeUnit
|
||||
* Lecteur audio complet affiché dans une ModalBottomSheet.
|
||||
* Style glassmorphism cohérent avec le reste de l'app.
|
||||
*/
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FullPlayerSheet(
|
||||
|
||||
@ -801,6 +801,7 @@ data class CollectionUiModel(
|
||||
|
||||
// Layout helper
|
||||
@Composable
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
private fun FlowRow(
|
||||
modifier: Modifier = Modifier,
|
||||
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
|
||||
|
||||
@ -278,12 +278,12 @@ fun FeedScreen(
|
||||
onNavigateToTags: () -> Unit = {},
|
||||
onNavigateToCollections: () -> Unit = {},
|
||||
onNavigateToSettings: () -> Unit = {},
|
||||
onNavigateToRandom: () -> Unit = {},
|
||||
onNavigateToHelp: () -> Unit = {},
|
||||
onNavigateToDeadLinks: () -> Unit = {},
|
||||
onNavigateToPinned: () -> Unit = {},
|
||||
onNavigateToReader: (Int) -> Unit = {},
|
||||
onNavigateToReminders: () -> Unit = {},
|
||||
onNavigateToTodo: () -> Unit = {},
|
||||
onPlayAudio: ((com.shaarit.domain.model.ShaarliLink) -> Unit)? = null,
|
||||
initialTagFilter: String? = null,
|
||||
initialCollectionId: Long? = null,
|
||||
@ -418,6 +418,15 @@ fun FeedScreen(
|
||||
}
|
||||
)
|
||||
|
||||
DrawerNavigationItem(
|
||||
icon = Icons.Default.CheckCircle,
|
||||
label = "Mes Tâches",
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
onNavigateToTodo()
|
||||
}
|
||||
)
|
||||
|
||||
DrawerNavigationItem(
|
||||
icon = Icons.Default.PushPin,
|
||||
label = "Épinglés",
|
||||
|
||||
@ -48,6 +48,7 @@ sealed class Screen(val route: String) {
|
||||
fun createRoute(linkId: Int): String = "reader/$linkId"
|
||||
}
|
||||
object Reminders : Screen("reminders")
|
||||
object Todo : Screen("todo")
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ -154,7 +155,6 @@ fun AppNavGraph(
|
||||
onNavigateToTags = { navController.navigate(Screen.Tags.route) },
|
||||
onNavigateToCollections = { navController.navigate(Screen.Collections.route) },
|
||||
onNavigateToSettings = { navController.navigate(Screen.Settings.route) },
|
||||
onNavigateToRandom = { },
|
||||
onNavigateToHelp = { navController.navigate(Screen.Help.route) },
|
||||
onNavigateToDeadLinks = { navController.navigate(Screen.DeadLinks.route) },
|
||||
onNavigateToPinned = { navController.navigate(Screen.Pinned.route) },
|
||||
@ -162,6 +162,7 @@ fun AppNavGraph(
|
||||
navController.navigate(Screen.Reader.createRoute(linkId))
|
||||
},
|
||||
onNavigateToReminders = { navController.navigate(Screen.Reminders.route) },
|
||||
onNavigateToTodo = { navController.navigate(Screen.Todo.route) },
|
||||
onPlayAudio = onPlayAudio,
|
||||
initialTagFilter = tag,
|
||||
initialCollectionId = collectionId
|
||||
@ -343,5 +344,16 @@ fun AppNavGraph(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Screen.Todo.route,
|
||||
deepLinks = listOf(
|
||||
navDeepLink { uriPattern = "shaarit://todo" }
|
||||
)
|
||||
) {
|
||||
com.shaarit.presentation.todo.TodoScreen(
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1061
app/src/main/java/com/shaarit/presentation/todo/TodoScreen.kt
Normal file
1061
app/src/main/java/com/shaarit/presentation/todo/TodoScreen.kt
Normal file
File diff suppressed because it is too large
Load Diff
421
app/src/main/java/com/shaarit/presentation/todo/TodoViewModel.kt
Normal file
421
app/src/main/java/com/shaarit/presentation/todo/TodoViewModel.kt
Normal file
@ -0,0 +1,421 @@
|
||||
package com.shaarit.presentation.todo
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.shaarit.data.sync.SyncManager
|
||||
import com.shaarit.data.worker.TodoNotificationScheduler
|
||||
import com.shaarit.domain.model.SubTask
|
||||
import com.shaarit.domain.model.TodoItem
|
||||
import com.shaarit.domain.repository.GeminiRepository
|
||||
import com.shaarit.domain.repository.TodoRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
data class EditableBrainDumpTask(
|
||||
val localId: String = UUID.randomUUID().toString(),
|
||||
val title: String,
|
||||
val dueDate: Long? = null,
|
||||
val tags: List<String> = emptyList(),
|
||||
val groupName: String? = null
|
||||
)
|
||||
|
||||
data class BrainDumpDialogUiState(
|
||||
val input: String = "",
|
||||
val isAnalyzing: Boolean = false,
|
||||
val isSaving: Boolean = false,
|
||||
val parsedTasks: List<EditableBrainDumpTask> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
val saveCompleted: Boolean = false,
|
||||
val requestNotificationPermission: Boolean = false
|
||||
)
|
||||
|
||||
data class EditTodoDialogUiState(
|
||||
val isVisible: Boolean = false,
|
||||
val todoId: Long = 0,
|
||||
val content: String = "",
|
||||
val dueDate: Long? = null,
|
||||
val tags: List<String> = emptyList(),
|
||||
val groupName: String = "",
|
||||
val subtasks: List<SubTask> = emptyList(),
|
||||
val newSubtaskText: String = "",
|
||||
val isSaving: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val shaarliLinkUrl: String = "",
|
||||
val isDone: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class TodoViewModel @Inject constructor(
|
||||
private val todoRepository: TodoRepository,
|
||||
private val geminiRepository: GeminiRepository,
|
||||
private val syncManager: SyncManager,
|
||||
private val notificationScheduler: TodoNotificationScheduler
|
||||
) : ViewModel() {
|
||||
|
||||
val todos: StateFlow<List<TodoItem>> =
|
||||
todoRepository
|
||||
.getTodosStream()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val groupNames: StateFlow<List<String>> =
|
||||
todoRepository
|
||||
.getGroupNamesStream()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
private val _selectedGroup = MutableStateFlow<String?>(null)
|
||||
val selectedGroup: StateFlow<String?> = _selectedGroup.asStateFlow()
|
||||
|
||||
private val _dialogState = MutableStateFlow(BrainDumpDialogUiState())
|
||||
val dialogState: StateFlow<BrainDumpDialogUiState> = _dialogState.asStateFlow()
|
||||
|
||||
private val _editDialogState = MutableStateFlow(EditTodoDialogUiState())
|
||||
val editDialogState: StateFlow<EditTodoDialogUiState> = _editDialogState.asStateFlow()
|
||||
|
||||
fun selectGroup(group: String?) {
|
||||
_selectedGroup.value = group
|
||||
}
|
||||
|
||||
// ====== Brain Dump Dialog ======
|
||||
|
||||
fun onBrainDumpInputChanged(value: String) {
|
||||
_dialogState.update {
|
||||
it.copy(input = value, errorMessage = null)
|
||||
}
|
||||
}
|
||||
|
||||
fun appendVoiceInput(transcript: String) {
|
||||
val cleaned = transcript.trim()
|
||||
if (cleaned.isBlank()) return
|
||||
|
||||
_dialogState.update {
|
||||
val separator = if (it.input.isBlank()) "" else "\n"
|
||||
it.copy(input = it.input + separator + cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
fun analyzeBrainDump() {
|
||||
val input = _dialogState.value.input.trim()
|
||||
if (input.isBlank()) {
|
||||
_dialogState.update {
|
||||
it.copy(errorMessage = "Le brain dump ne peut pas être vide")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!geminiRepository.isApiKeyConfigured()) {
|
||||
_dialogState.update {
|
||||
it.copy(errorMessage = "Clé API Gemini non configurée dans les paramètres")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_dialogState.update {
|
||||
it.copy(isAnalyzing = true, errorMessage = null, parsedTasks = emptyList())
|
||||
}
|
||||
|
||||
runCatching {
|
||||
geminiRepository.analyzeBrainDump(input)
|
||||
}.onSuccess { result ->
|
||||
val parsedTasks = result.tasks.map { task ->
|
||||
EditableBrainDumpTask(
|
||||
title = task.title,
|
||||
dueDate = task.dueDate,
|
||||
tags = task.tags
|
||||
)
|
||||
}.ifEmpty {
|
||||
listOf(
|
||||
EditableBrainDumpTask(
|
||||
title = input.take(80),
|
||||
dueDate = null,
|
||||
tags = emptyList()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
_dialogState.update {
|
||||
it.copy(
|
||||
isAnalyzing = false,
|
||||
parsedTasks = parsedTasks,
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
_dialogState.update {
|
||||
it.copy(
|
||||
isAnalyzing = false,
|
||||
errorMessage = error.message ?: "Erreur pendant l'analyse IA"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateTaskTitle(taskId: String, title: String) {
|
||||
_dialogState.update { state ->
|
||||
state.copy(
|
||||
parsedTasks = state.parsedTasks.map { task ->
|
||||
if (task.localId == taskId) task.copy(title = title) else task
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateTaskDueDate(taskId: String, dueDate: Long?) {
|
||||
_dialogState.update { state ->
|
||||
state.copy(
|
||||
parsedTasks = state.parsedTasks.map { task ->
|
||||
if (task.localId == taskId) task.copy(dueDate = dueDate) else task
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateTaskGroup(taskId: String, groupName: String?) {
|
||||
_dialogState.update { state ->
|
||||
state.copy(
|
||||
parsedTasks = state.parsedTasks.map { task ->
|
||||
if (task.localId == taskId) task.copy(groupName = groupName) else task
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeTag(taskId: String, tag: String) {
|
||||
_dialogState.update { state ->
|
||||
state.copy(
|
||||
parsedTasks = state.parsedTasks.map { task ->
|
||||
if (task.localId == taskId) {
|
||||
task.copy(tags = task.tags.filterNot { it.equals(tag, ignoreCase = true) })
|
||||
} else {
|
||||
task
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveParsedTasks() {
|
||||
val tasksToSave = _dialogState.value.parsedTasks
|
||||
.map { task ->
|
||||
task.copy(
|
||||
title = task.title.trim(),
|
||||
tags = task.tags
|
||||
.map { it.trim().trimStart('#').lowercase() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
)
|
||||
}
|
||||
.filter { it.title.isNotBlank() }
|
||||
|
||||
if (tasksToSave.isEmpty()) {
|
||||
_dialogState.update {
|
||||
it.copy(errorMessage = "Aucune tâche valide à sauvegarder")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_dialogState.update {
|
||||
it.copy(isSaving = true, errorMessage = null)
|
||||
}
|
||||
|
||||
var hasError = false
|
||||
tasksToSave.forEach { task ->
|
||||
val result = todoRepository.upsertTodo(
|
||||
TodoItem(
|
||||
shaarliLinkUrl = "",
|
||||
content = task.title,
|
||||
isDone = false,
|
||||
dueDate = task.dueDate,
|
||||
tags = task.tags,
|
||||
isSynced = false,
|
||||
groupName = task.groupName?.takeIf { it.isNotBlank() }
|
||||
)
|
||||
)
|
||||
|
||||
if (result.isFailure) {
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
|
||||
syncManager.syncNow()
|
||||
|
||||
val shouldAskPermission = tasksToSave.any { it.dueDate != null } &&
|
||||
notificationScheduler.requiresNotificationPermission()
|
||||
|
||||
_dialogState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
saveCompleted = !hasError,
|
||||
errorMessage = if (hasError) "Certaines tâches n'ont pas pu être sauvegardées" else null,
|
||||
requestNotificationPermission = shouldAskPermission
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeSaveCompleted() {
|
||||
_dialogState.update {
|
||||
it.copy(
|
||||
saveCompleted = false,
|
||||
input = "",
|
||||
parsedTasks = emptyList(),
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearDialogState() {
|
||||
_dialogState.update {
|
||||
BrainDumpDialogUiState()
|
||||
}
|
||||
}
|
||||
|
||||
fun onNotificationPermissionHandled() {
|
||||
_dialogState.update {
|
||||
it.copy(requestNotificationPermission = false)
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Edit Todo Dialog ======
|
||||
|
||||
fun openEditDialog(todoId: Long) {
|
||||
viewModelScope.launch {
|
||||
val todo = todoRepository.getTodoById(todoId) ?: return@launch
|
||||
_editDialogState.value = EditTodoDialogUiState(
|
||||
isVisible = true,
|
||||
todoId = todo.id,
|
||||
content = todo.content,
|
||||
dueDate = todo.dueDate,
|
||||
tags = todo.tags,
|
||||
groupName = todo.groupName ?: "",
|
||||
subtasks = todo.subtasks,
|
||||
shaarliLinkUrl = todo.shaarliLinkUrl,
|
||||
isDone = todo.isDone
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun closeEditDialog() {
|
||||
_editDialogState.value = EditTodoDialogUiState()
|
||||
}
|
||||
|
||||
fun onEditContentChanged(value: String) {
|
||||
_editDialogState.update { it.copy(content = value, errorMessage = null) }
|
||||
}
|
||||
|
||||
fun onEditDueDateChanged(dueDate: Long?) {
|
||||
_editDialogState.update { it.copy(dueDate = dueDate) }
|
||||
}
|
||||
|
||||
fun onEditGroupChanged(groupName: String) {
|
||||
_editDialogState.update { it.copy(groupName = groupName) }
|
||||
}
|
||||
|
||||
fun onEditNewSubtaskTextChanged(text: String) {
|
||||
_editDialogState.update { it.copy(newSubtaskText = text) }
|
||||
}
|
||||
|
||||
fun addSubtask() {
|
||||
val text = _editDialogState.value.newSubtaskText.trim()
|
||||
if (text.isBlank()) return
|
||||
_editDialogState.update {
|
||||
it.copy(
|
||||
subtasks = it.subtasks + SubTask(content = text),
|
||||
newSubtaskText = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeSubtask(index: Int) {
|
||||
_editDialogState.update {
|
||||
it.copy(subtasks = it.subtasks.toMutableList().apply { removeAt(index) })
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleSubtask(index: Int) {
|
||||
_editDialogState.update {
|
||||
val mutable = it.subtasks.toMutableList()
|
||||
val sub = mutable[index]
|
||||
mutable[index] = sub.copy(isDone = !sub.isDone)
|
||||
it.copy(subtasks = mutable)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSubtaskContent(index: Int, content: String) {
|
||||
_editDialogState.update {
|
||||
val mutable = it.subtasks.toMutableList()
|
||||
mutable[index] = mutable[index].copy(content = content)
|
||||
it.copy(subtasks = mutable)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveEditedTodo() {
|
||||
val state = _editDialogState.value
|
||||
val content = state.content.trim()
|
||||
if (content.isBlank()) {
|
||||
_editDialogState.update { it.copy(errorMessage = "Le contenu ne peut pas être vide") }
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_editDialogState.update { it.copy(isSaving = true, errorMessage = null) }
|
||||
|
||||
val result = todoRepository.upsertTodo(
|
||||
TodoItem(
|
||||
id = state.todoId,
|
||||
shaarliLinkUrl = state.shaarliLinkUrl,
|
||||
content = content,
|
||||
isDone = state.isDone,
|
||||
dueDate = state.dueDate,
|
||||
tags = state.tags,
|
||||
isSynced = false,
|
||||
groupName = state.groupName.takeIf { it.isNotBlank() },
|
||||
subtasks = state.subtasks.filter { it.content.isNotBlank() }
|
||||
)
|
||||
)
|
||||
|
||||
if (result.isSuccess) {
|
||||
syncManager.syncNow()
|
||||
_editDialogState.value = EditTodoDialogUiState()
|
||||
} else {
|
||||
_editDialogState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
errorMessage = "Erreur lors de la sauvegarde"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Actions ======
|
||||
|
||||
fun toggleTodo(todoId: Long, isDone: Boolean) {
|
||||
viewModelScope.launch {
|
||||
todoRepository.toggleDone(todoId, isDone)
|
||||
}
|
||||
}
|
||||
|
||||
fun snoozeTodo(todoId: Long) {
|
||||
viewModelScope.launch {
|
||||
todoRepository.snoozeTodo(todoId)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteTodo(todoId: Long) {
|
||||
viewModelScope.launch {
|
||||
todoRepository.deleteTodo(todoId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -25,7 +25,16 @@ class AddLinkTileService : TileService() {
|
||||
`package` = packageName
|
||||
}
|
||||
|
||||
startActivityAndCollapse(intent)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
val pendingIntent = android.app.PendingIntent.getActivity(
|
||||
this, 0, intent,
|
||||
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
startActivityAndCollapse(pendingIntent)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityAndCollapse(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartListening() {
|
||||
|
||||
@ -653,14 +653,17 @@ fun MarkdownPreview(
|
||||
if (markdown.isBlank()) {
|
||||
Text(
|
||||
text = "Aucun contenu à prévisualiser...",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
MarkdownText(
|
||||
markdown = MarkdownUtils.preprocessMarkdown(markdown),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -903,8 +906,10 @@ fun MarkdownReader(
|
||||
) {
|
||||
MarkdownText(
|
||||
markdown = MarkdownUtils.preprocessMarkdown(markdown),
|
||||
color = androidx.compose.ui.graphics.Color.White,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = androidx.compose.ui.graphics.Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -117,4 +117,23 @@
|
||||
<string name="reminder_snooze">Rappeler dans 1h</string>
|
||||
<string name="my_reminders">Mes rappels</string>
|
||||
<string name="no_reminders">Aucun rappel planifié</string>
|
||||
|
||||
<!-- Todo / Brain Dump -->
|
||||
<string name="todo_screen_title">Mes tâches</string>
|
||||
<string name="todo_add_brain_dump">Brain Dump</string>
|
||||
<string name="todo_active_section">À faire</string>
|
||||
<string name="todo_done_section">Terminées</string>
|
||||
<string name="todo_empty_state">Aucune tâche pour le moment</string>
|
||||
<string name="todo_no_due_date">Sans échéance</string>
|
||||
<string name="todo_channel_name">Rappels de tâches</string>
|
||||
<string name="todo_channel_desc">Notifications Brain Dump pour vos tâches à échéance</string>
|
||||
<string name="todo_notification_title">⏰ %1$s</string>
|
||||
<string name="todo_notification_content">Échéance: %1$s</string>
|
||||
<string name="todo_action_mark_done">Marquer comme fait</string>
|
||||
<string name="todo_action_snooze">Reporter 1h</string>
|
||||
<string name="todo_brain_dump_hint">Ex: Demain je dois appeler le médecin, finir le rapport, et penser au cadeau d\'anniversaire</string>
|
||||
<string name="todo_analyze">Analyser</string>
|
||||
<string name="todo_save">Sauvegarder</string>
|
||||
<string name="todo_detected_tasks">%1$d tâche(s) détectée(s)</string>
|
||||
<string name="todo_need_notification_permission">Autoriser les notifications pour recevoir les rappels de tâches.</string>
|
||||
</resources>
|
||||
@ -1,3 +1,3 @@
|
||||
#Thu Feb 12 20:32:53 2026
|
||||
VERSION_NAME=1.4.0
|
||||
VERSION_CODE=13
|
||||
#Fri Feb 13 15:47:32 2026
|
||||
VERSION_NAME=2.1.5
|
||||
VERSION_CODE=20
|
||||
Loading…
x
Reference in New Issue
Block a user