diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b017c82..6d21dfa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 0939e15..1bfec51 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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.* ; } @@ -61,7 +59,6 @@ # Keep Kotlin Metadata -keep class kotlin.Metadata { *; } --keepattributes RuntimeVisibleAnnotations # Hilt / Dagger -keepclasseswithmembers class * { diff --git a/app/schemas/com.shaarit.data.local.database.ShaarliDatabase/7.json b/app/schemas/com.shaarit.data.local.database.ShaarliDatabase/7.json new file mode 100644 index 0000000..e8f2f1c --- /dev/null +++ b/app/schemas/com.shaarit.data.local.database.ShaarliDatabase/7.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.shaarit.data.local.database.ShaarliDatabase/8.json b/app/schemas/com.shaarit.data.local.database.ShaarliDatabase/8.json new file mode 100644 index 0000000..da72396 --- /dev/null +++ b/app/schemas/com.shaarit.data.local.database.ShaarliDatabase/8.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index df6fc24..4770bbe 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -116,6 +116,10 @@ android:exported="false" android:permission="android.permission.BIND_REMOTEVIEWS" /> + + (android.content.Intent.EXTRA_STREAM) if (fileUri != null && isTextOrMarkdownFile(mimeType, fileUri)) { diff --git a/app/src/main/java/com/shaarit/ShaarItApp.kt b/app/src/main/java/com/shaarit/ShaarItApp.kt index ee2b319..f06b3a6 100644 --- a/app/src/main/java/com/shaarit/ShaarItApp.kt +++ b/app/src/main/java/com/shaarit/ShaarItApp.kt @@ -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" } } diff --git a/app/src/main/java/com/shaarit/core/di/DatabaseModule.kt b/app/src/main/java/com/shaarit/core/di/DatabaseModule.kt index 372e9f7..7d035ef 100644 --- a/app/src/main/java/com/shaarit/core/di/DatabaseModule.kt +++ b/app/src/main/java/com/shaarit/core/di/DatabaseModule.kt @@ -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() + } } diff --git a/app/src/main/java/com/shaarit/core/di/RepositoryModule.kt b/app/src/main/java/com/shaarit/core/di/RepositoryModule.kt index 2c0d833..a72d37f 100644 --- a/app/src/main/java/com/shaarit/core/di/RepositoryModule.kt +++ b/app/src/main/java/com/shaarit/core/di/RepositoryModule.kt @@ -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 } diff --git a/app/src/main/java/com/shaarit/data/api/ShaarliApi.kt b/app/src/main/java/com/shaarit/data/api/ShaarliApi.kt index e385d4c..dd1f205 100644 --- a/app/src/main/java/com/shaarit/data/api/ShaarliApi.kt +++ b/app/src/main/java/com/shaarit/data/api/ShaarliApi.kt @@ -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 - @POST("/api/v1/links") suspend fun addLink(@Body link: CreateLinkDto): Response + @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 + suspend fun updateLink(@Path("id") id: Int, @Body link: CreateLinkDto): LinkDto - @DELETE("/api/v1/links/{id}") suspend fun deleteLink(@Path("id") id: Int): Response + @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 diff --git a/app/src/main/java/com/shaarit/data/local/converter/Converters.kt b/app/src/main/java/com/shaarit/data/local/converter/Converters.kt index f4a368f..b376ed5 100644 --- a/app/src/main/java/com/shaarit/data/local/converter/Converters.kt +++ b/app/src/main/java/com/shaarit/data/local/converter/Converters.kt @@ -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 ====== + + @TypeConverter + fun fromSubTaskList(value: List): String { + return try { + json.encodeToString(value) + } catch (e: Exception) { + "[]" + } + } + + @TypeConverter + fun toSubTaskList(value: String): List { + return try { + json.decodeFromString>(value) + } catch (e: Exception) { + emptyList() + } + } } diff --git a/app/src/main/java/com/shaarit/data/local/dao/ReminderDao.kt b/app/src/main/java/com/shaarit/data/local/dao/ReminderDao.kt index b6826ad..b13a09e 100644 --- a/app/src/main/java/com/shaarit/data/local/dao/ReminderDao.kt +++ b/app/src/main/java/com/shaarit/data/local/dao/ReminderDao.kt @@ -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 """) diff --git a/app/src/main/java/com/shaarit/data/local/dao/TodoDao.kt b/app/src/main/java/com/shaarit/data/local/dao/TodoDao.kt new file mode 100644 index 0000000..9a4ef62 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/local/dao/TodoDao.kt @@ -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> + + @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) + + @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> + + @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> + + @Query("DELETE FROM todos") + suspend fun clearAll() +} diff --git a/app/src/main/java/com/shaarit/data/local/database/ShaarliDatabase.kt b/app/src/main/java/com/shaarit/data/local/database/ShaarliDatabase.kt index 9a3ee8f..088116c 100644 --- a/app/src/main/java/com/shaarit/data/local/database/ShaarliDatabase.kt +++ b/app/src/main/java/com/shaarit/data/local/database/ShaarliDatabase.kt @@ -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" @@ -91,6 +95,41 @@ abstract class ShaarliDatabase : RoomDatabase() { db.execSQL("CREATE INDEX IF NOT EXISTS `index_reading_reminders_is_dismissed` ON `reading_reminders` (`is_dismissed`)") } } + + /** + * 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() } diff --git a/app/src/main/java/com/shaarit/data/local/entity/TodoEntity.kt b/app/src/main/java/com/shaarit/data/local/entity/TodoEntity.kt new file mode 100644 index 0000000..7f03bf6 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/local/entity/TodoEntity.kt @@ -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 = emptyList(), + + @ColumnInfo(name = "is_synced") + val isSynced: Boolean = false, + + @ColumnInfo(name = "group_name") + val groupName: String? = null, + + @ColumnInfo(name = "subtasks") + val subtasks: List = emptyList() +) diff --git a/app/src/main/java/com/shaarit/data/mapper/TodoMarkdownMapper.kt b/app/src/main/java/com/shaarit/data/mapper/TodoMarkdownMapper.kt new file mode 100644 index 0000000..d5b3b5d --- /dev/null +++ b/app/src/main/java/com/shaarit/data/mapper/TodoMarkdownMapper.kt @@ -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 +) + +data class ParsedTodoFromShaarli( + val shaarliLinkUrl: String, + val content: String, + val isDone: Boolean, + val dueDate: Long?, + val tags: List, + val groupName: String? = null, + val subtasks: List = 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): 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): List { + 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 + } + } + } +} diff --git a/app/src/main/java/com/shaarit/data/metadata/LinkMetadataExtractor.kt b/app/src/main/java/com/shaarit/data/metadata/LinkMetadataExtractor.kt index bf595e4..3028760 100644 --- a/app/src/main/java/com/shaarit/data/metadata/LinkMetadataExtractor.kt +++ b/app/src/main/java/com/shaarit/data/metadata/LinkMetadataExtractor.kt @@ -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() diff --git a/app/src/main/java/com/shaarit/data/repository/GeminiRepositoryImpl.kt b/app/src/main/java/com/shaarit/data/repository/GeminiRepositoryImpl.kt index ecd596b..8f5a02f 100644 --- a/app/src/main/java/com/shaarit/data/repository/GeminiRepositoryImpl.kt +++ b/app/src/main/java/com/shaarit/data/repository/GeminiRepositoryImpl.kt @@ -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(50) + private val brainDumpCache = LruCache(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 { 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 { 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() + 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() + 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 = withContext(Dispatchers.IO) { // Vérifier le cache d'abord analysisCache.get(url)?.let { diff --git a/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt b/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt index 552e857..bd90af6 100644 --- a/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt +++ b/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt @@ -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) { diff --git a/app/src/main/java/com/shaarit/data/repository/TodoRepositoryImpl.kt b/app/src/main/java/com/shaarit/data/repository/TodoRepositoryImpl.kt new file mode 100644 index 0000000..356b2b0 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/repository/TodoRepositoryImpl.kt @@ -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> { + return todoDao.getTodosStream().map { todos -> + todos.map { it.toDomain() } + } + } + + override fun getGroupNamesStream(): Flow> { + return todoDao.getGroupNamesStream() + } + + override suspend fun getTodoById(id: Long): TodoItem? { + return todoDao.getTodoById(id)?.toDomain() + } + + override suspend fun upsertTodo(todo: TodoItem): Result { + 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 { + 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 { + 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 { + 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) + } +} diff --git a/app/src/main/java/com/shaarit/data/sync/SyncManager.kt b/app/src/main/java/com/shaarit/data/sync/SyncManager.kt index f8109e4..a1fabc3 100644 --- a/app/src/main/java/com/shaarit/data/sync/SyncManager.kt +++ b/app/src/main/java/com/shaarit/data/sync/SyncManager.kt @@ -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) { + 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 + } } /** diff --git a/app/src/main/java/com/shaarit/data/worker/TodoNotificationReceiver.kt b/app/src/main/java/com/shaarit/data/worker/TodoNotificationReceiver.kt new file mode 100644 index 0000000..28b3e80 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/worker/TodoNotificationReceiver.kt @@ -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 +} diff --git a/app/src/main/java/com/shaarit/data/worker/TodoNotificationScheduler.kt b/app/src/main/java/com/shaarit/data/worker/TodoNotificationScheduler.kt new file mode 100644 index 0000000..eabac90 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/worker/TodoNotificationScheduler.kt @@ -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() + } + } +} diff --git a/app/src/main/java/com/shaarit/domain/model/BrainDumpResult.kt b/app/src/main/java/com/shaarit/domain/model/BrainDumpResult.kt new file mode 100644 index 0000000..bebdb8d --- /dev/null +++ b/app/src/main/java/com/shaarit/domain/model/BrainDumpResult.kt @@ -0,0 +1,12 @@ +package com.shaarit.domain.model + +data class BrainDumpTaskSuggestion( + val title: String, + val dueDate: Long? = null, + val tags: List = emptyList() +) + +data class BrainDumpResult( + val generatedTitle: String, + val tasks: List +) diff --git a/app/src/main/java/com/shaarit/domain/model/SubTask.kt b/app/src/main/java/com/shaarit/domain/model/SubTask.kt new file mode 100644 index 0000000..b9e692b --- /dev/null +++ b/app/src/main/java/com/shaarit/domain/model/SubTask.kt @@ -0,0 +1,9 @@ +package com.shaarit.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class SubTask( + val content: String, + val isDone: Boolean = false +) diff --git a/app/src/main/java/com/shaarit/domain/model/TodoItem.kt b/app/src/main/java/com/shaarit/domain/model/TodoItem.kt new file mode 100644 index 0000000..53d03fc --- /dev/null +++ b/app/src/main/java/com/shaarit/domain/model/TodoItem.kt @@ -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 = emptyList(), + val isSynced: Boolean = false, + val groupName: String? = null, + val subtasks: List = emptyList() +) diff --git a/app/src/main/java/com/shaarit/domain/repository/GeminiRepository.kt b/app/src/main/java/com/shaarit/domain/repository/GeminiRepository.kt index 11a9b57..6070871 100644 --- a/app/src/main/java/com/shaarit/domain/repository/GeminiRepository.kt +++ b/app/src/main/java/com/shaarit/domain/repository/GeminiRepository.kt @@ -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 suspend fun generateTags(title: String, description: String): Result> + suspend fun analyzeBrainDump(input: String): BrainDumpResult fun isApiKeyConfigured(): Boolean } diff --git a/app/src/main/java/com/shaarit/domain/repository/TodoRepository.kt b/app/src/main/java/com/shaarit/domain/repository/TodoRepository.kt new file mode 100644 index 0000000..2023d1f --- /dev/null +++ b/app/src/main/java/com/shaarit/domain/repository/TodoRepository.kt @@ -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> + + fun getGroupNamesStream(): Flow> + + suspend fun getTodoById(id: Long): TodoItem? + + suspend fun upsertTodo(todo: TodoItem): Result + + suspend fun toggleDone(todoId: Long, isDone: Boolean): Result + + suspend fun snoozeTodo(todoId: Long, delayMs: Long = 3_600_000L): Result + + suspend fun deleteTodo(todoId: Long): Result +} diff --git a/app/src/main/java/com/shaarit/domain/usecase/ClassifyBookmarksUseCase.kt b/app/src/main/java/com/shaarit/domain/usecase/ClassifyBookmarksUseCase.kt index aa5fb12..04b3061 100644 --- a/app/src/main/java/com/shaarit/domain/usecase/ClassifyBookmarksUseCase.kt +++ b/app/src/main/java/com/shaarit/domain/usecase/ClassifyBookmarksUseCase.kt @@ -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): Pair { val lowerUrl = url.lowercase() val host = try { URI(url).host?.lowercase() } catch (e: Exception) { null } ?: "" diff --git a/app/src/main/java/com/shaarit/presentation/audio/FullPlayerSheet.kt b/app/src/main/java/com/shaarit/presentation/audio/FullPlayerSheet.kt index 221eb25..b3f4cb2 100644 --- a/app/src/main/java/com/shaarit/presentation/audio/FullPlayerSheet.kt +++ b/app/src/main/java/com/shaarit/presentation/audio/FullPlayerSheet.kt @@ -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( diff --git a/app/src/main/java/com/shaarit/presentation/collections/CollectionsScreen.kt b/app/src/main/java/com/shaarit/presentation/collections/CollectionsScreen.kt index 45eb7b7..eca0c4c 100644 --- a/app/src/main/java/com/shaarit/presentation/collections/CollectionsScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/collections/CollectionsScreen.kt @@ -801,6 +801,7 @@ data class CollectionUiModel( // Layout helper @Composable +@Suppress("UNUSED_PARAMETER") private fun FlowRow( modifier: Modifier = Modifier, horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, diff --git a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt index 5b426b4..ae76a25 100644 --- a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt @@ -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, @@ -417,6 +417,15 @@ fun FeedScreen( onNavigateToCollections() } ) + + DrawerNavigationItem( + icon = Icons.Default.CheckCircle, + label = "Mes Tâches", + onClick = { + scope.launch { drawerState.close() } + onNavigateToTodo() + } + ) DrawerNavigationItem( icon = Icons.Default.PushPin, diff --git a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt index 58d0772..912fd9e 100644 --- a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt +++ b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt @@ -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() } + ) + } } } diff --git a/app/src/main/java/com/shaarit/presentation/todo/TodoScreen.kt b/app/src/main/java/com/shaarit/presentation/todo/TodoScreen.kt new file mode 100644 index 0000000..f33867f --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/todo/TodoScreen.kt @@ -0,0 +1,1061 @@ +package com.shaarit.presentation.todo + +import android.Manifest +import android.app.DatePickerDialog +import android.app.TimePickerDialog +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccessTime +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Snooze +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.shaarit.R +import com.shaarit.domain.model.SubTask +import com.shaarit.domain.model.TodoItem +import com.shaarit.ui.components.GlassCard +import com.shaarit.ui.components.VoiceInputButton +import com.shaarit.ui.components.shimmerEffect +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun TodoScreen( + onNavigateBack: () -> Unit, + viewModel: TodoViewModel = hiltViewModel() +) { + val todos by viewModel.todos.collectAsState() + val groupNames by viewModel.groupNames.collectAsState() + val selectedGroup by viewModel.selectedGroup.collectAsState() + val dialogState by viewModel.dialogState.collectAsState() + val editDialogState by viewModel.editDialogState.collectAsState() + + var showBrainDumpDialog by remember { mutableStateOf(false) } + + val notificationPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { + viewModel.onNotificationPermissionHandled() + } + + LaunchedEffect(dialogState.requestNotificationPermission) { + if (dialogState.requestNotificationPermission) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + viewModel.onNotificationPermissionHandled() + } + } + } + + LaunchedEffect(dialogState.saveCompleted) { + if (dialogState.saveCompleted) { + showBrainDumpDialog = false + viewModel.consumeSaveCompleted() + } + } + + val filteredTodos = remember(todos, selectedGroup) { + if (selectedGroup == null) todos + else todos.filter { it.groupName == selectedGroup } + } + + val activeTodos = remember(filteredTodos) { + filteredTodos + .filter { !it.isDone } + .sortedWith(compareBy { it.dueDate ?: Long.MAX_VALUE }.thenBy { it.id }) + } + + val doneTodos = remember(filteredTodos) { + filteredTodos + .filter { it.isDone } + .sortedByDescending { it.dueDate ?: 0L } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "✅ ${stringResourceCompat(R.string.todo_screen_title)}", + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Retour") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = { + showBrainDumpDialog = true + viewModel.clearDialogState() + }, + modifier = Modifier.size(74.dp), + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) { + Text(text = "✨", fontSize = 30.sp) + } + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + // Group filter chips + if (groupNames.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = selectedGroup == null, + onClick = { viewModel.selectGroup(null) }, + label = { Text("Toutes") }, + leadingIcon = if (selectedGroup == null) { + { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) } + } else null + ) + groupNames.forEach { group -> + FilterChip( + selected = selectedGroup == group, + onClick = { viewModel.selectGroup(group) }, + label = { Text(group) }, + leadingIcon = { + Icon( + Icons.Default.Folder, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + ) + } + } + } + + if (filteredTodos.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResourceCompat(R.string.todo_empty_state), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (activeTodos.isNotEmpty()) { + item { + Text( + text = "${stringResourceCompat(R.string.todo_active_section)} (${activeTodos.size})", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + } + + items(activeTodos, key = { it.id }) { todo -> + TodoItemCard( + todo = todo, + onToggleDone = { checked -> viewModel.toggleTodo(todo.id, checked) }, + onDelete = { viewModel.deleteTodo(todo.id) }, + onEdit = { viewModel.openEditDialog(todo.id) }, + onSnooze = if (todo.dueDate != null) { + { viewModel.snoozeTodo(todo.id) } + } else { + null + } + ) + } + } + + if (doneTodos.isNotEmpty()) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "${stringResourceCompat(R.string.todo_done_section)} (${doneTodos.size})", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold + ) + } + + items(doneTodos, key = { it.id }) { todo -> + TodoItemCard( + todo = todo, + onToggleDone = { checked -> viewModel.toggleTodo(todo.id, checked) }, + onDelete = { viewModel.deleteTodo(todo.id) }, + onEdit = { viewModel.openEditDialog(todo.id) }, + onSnooze = null, + isDoneSection = true + ) + } + } + } + } + } + } + + if (showBrainDumpDialog) { + BrainDumpDialog( + state = dialogState, + groupNames = groupNames, + onDismiss = { + showBrainDumpDialog = false + viewModel.clearDialogState() + }, + onInputChanged = viewModel::onBrainDumpInputChanged, + onVoiceInput = viewModel::appendVoiceInput, + onAnalyze = viewModel::analyzeBrainDump, + onSave = viewModel::saveParsedTasks, + onTaskTitleChanged = viewModel::updateTaskTitle, + onTaskDueDateChanged = viewModel::updateTaskDueDate, + onTaskGroupChanged = viewModel::updateTaskGroup, + onRemoveTag = viewModel::removeTag + ) + } + + if (editDialogState.isVisible) { + EditTodoDialog( + state = editDialogState, + groupNames = groupNames, + onDismiss = viewModel::closeEditDialog, + onContentChanged = viewModel::onEditContentChanged, + onDueDateChanged = viewModel::onEditDueDateChanged, + onGroupChanged = viewModel::onEditGroupChanged, + onNewSubtaskTextChanged = viewModel::onEditNewSubtaskTextChanged, + onAddSubtask = viewModel::addSubtask, + onRemoveSubtask = viewModel::removeSubtask, + onToggleSubtask = viewModel::toggleSubtask, + onUpdateSubtaskContent = viewModel::updateSubtaskContent, + onSave = viewModel::saveEditedTodo + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun TodoItemCard( + todo: TodoItem, + onToggleDone: (Boolean) -> Unit, + onDelete: () -> Unit, + onEdit: () -> Unit, + onSnooze: (() -> Unit)?, + isDoneSection: Boolean = false +) { + val isOverdue = !todo.isDone && todo.dueDate != null && todo.dueDate < System.currentTimeMillis() + + GlassCard( + modifier = Modifier + .fillMaxWidth() + .alpha(if (isDoneSection) 0.72f else 1f) + .clickable(onClick = onEdit), + glowColor = if (isOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { + Checkbox( + checked = todo.isDone, + onCheckedChange = onToggleDone + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = todo.content, + style = MaterialTheme.typography.titleSmall, + textDecoration = if (todo.isDone) TextDecoration.LineThrough else TextDecoration.None, + color = if (todo.isDone) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + + // Subtask progress + if (todo.subtasks.isNotEmpty()) { + val doneCount = todo.subtasks.count { it.isDone } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "$doneCount/${todo.subtasks.size} sous-tâches", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontStyle = FontStyle.Italic + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + DueDateChip( + dueDate = todo.dueDate, + isOverdue = isOverdue, + isDone = todo.isDone + ) + + // Group chip + if (!todo.groupName.isNullOrBlank()) { + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + shape = MaterialTheme.shapes.small + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Icon( + imageVector = Icons.Default.Folder, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onTertiaryContainer + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = todo.groupName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + } + } + + if (todo.tags.isNotEmpty()) { + Spacer(modifier = Modifier.height(6.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + todo.tags.take(4).forEach { tag -> + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) { + Text( + text = "#$tag", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + IconButton(onClick = onEdit) { + Icon(Icons.Default.Edit, contentDescription = "Modifier") + } + if (onSnooze != null && !todo.isDone) { + IconButton(onClick = onSnooze) { + Icon(Icons.Default.Snooze, contentDescription = "Reporter 1h") + } + } + IconButton(onClick = onDelete) { + Icon( + Icons.Default.Delete, + contentDescription = "Supprimer", + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } +} + +@Composable +private fun DueDateChip( + dueDate: Long?, + isOverdue: Boolean, + isDone: Boolean +) { + val label = dueDate?.let(::formatDateTime) + ?: stringResourceCompat(R.string.todo_no_due_date) + + val chipColor = when { + isDone -> MaterialTheme.colorScheme.surfaceVariant + isOverdue -> MaterialTheme.colorScheme.error.copy(alpha = 0.2f) + dueDate != null -> Color(0xFF2E7D32).copy(alpha = 0.2f) + else -> MaterialTheme.colorScheme.surfaceVariant + } + + val textColor = when { + isDone -> MaterialTheme.colorScheme.onSurfaceVariant + isOverdue -> MaterialTheme.colorScheme.error + dueDate != null -> Color(0xFF1B5E20) + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + + Surface( + color = chipColor, + shape = MaterialTheme.shapes.small + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Icon( + imageVector = Icons.Default.AccessTime, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = textColor + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = textColor + ) + } + } +} + +// ====== Edit Todo Dialog ====== + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun EditTodoDialog( + state: EditTodoDialogUiState, + groupNames: List, + onDismiss: () -> Unit, + onContentChanged: (String) -> Unit, + onDueDateChanged: (Long?) -> Unit, + onGroupChanged: (String) -> Unit, + onNewSubtaskTextChanged: (String) -> Unit, + onAddSubtask: () -> Unit, + onRemoveSubtask: (Int) -> Unit, + onToggleSubtask: (Int) -> Unit, + onUpdateSubtaskContent: (Int, String) -> Unit, + onSave: () -> Unit +) { + val context = androidx.compose.ui.platform.LocalContext.current + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("✏️ Modifier la tâche") + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Fermer") + } + } + }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Content + OutlinedTextField( + value = state.content, + onValueChange = onContentChanged, + modifier = Modifier.fillMaxWidth(), + label = { Text("Contenu") }, + singleLine = false, + maxLines = 4, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences) + ) + + // Group + OutlinedTextField( + value = state.groupName, + onValueChange = onGroupChanged, + modifier = Modifier.fillMaxWidth(), + label = { Text("Groupe") }, + placeholder = { Text("Ex: Famille, Personnel, Projet X...") }, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null) } + ) + + if (groupNames.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + groupNames.forEach { group -> + Surface( + color = if (state.groupName == group) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + shape = MaterialTheme.shapes.small, + onClick = { onGroupChanged(group) } + ) { + Text( + text = group, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + color = if (state.groupName == group) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + } + } + } + + // Due date + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = state.dueDate?.let(::formatDateTime) ?: "Sans échéance", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row { + TextButton(onClick = { + pickDateTime(context, state.dueDate) { millis -> onDueDateChanged(millis) } + }) { + Text("Date") + } + if (state.dueDate != null) { + TextButton(onClick = { onDueDateChanged(null) }) { + Text("Effacer") + } + } + } + } + + // Subtasks + Text( + text = "Sous-tâches", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + state.subtasks.forEachIndexed { index, sub -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = sub.isDone, + onCheckedChange = { onToggleSubtask(index) }, + modifier = Modifier.size(32.dp) + ) + OutlinedTextField( + value = sub.content, + onValueChange = { onUpdateSubtaskContent(index, it) }, + modifier = Modifier.weight(1f), + singleLine = true, + textStyle = MaterialTheme.typography.bodySmall.copy( + textDecoration = if (sub.isDone) TextDecoration.LineThrough else TextDecoration.None + ) + ) + IconButton( + onClick = { onRemoveSubtask(index) }, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Default.Close, + contentDescription = "Supprimer", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.error + ) + } + } + } + + // Add subtask + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = state.newSubtaskText, + onValueChange = onNewSubtaskTextChanged, + modifier = Modifier.weight(1f), + placeholder = { Text("Nouvelle sous-tâche...") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + capitalization = KeyboardCapitalization.Sentences + ), + keyboardActions = KeyboardActions(onDone = { onAddSubtask() }) + ) + IconButton(onClick = onAddSubtask) { + Icon(Icons.Default.Add, contentDescription = "Ajouter") + } + } + + // Tags (read-only display) + if (state.tags.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + state.tags.forEach { tag -> + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) { + Text( + text = "#$tag", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + state.errorMessage?.let { message -> + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + } + }, + confirmButton = { + TextButton( + onClick = onSave, + enabled = !state.isSaving && state.content.isNotBlank() + ) { + if (state.isSaving) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(8.dp)) + } + Icon(Icons.Default.Check, contentDescription = null) + Spacer(modifier = Modifier.width(6.dp)) + Text("Enregistrer") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Annuler") + } + } + ) +} + +// ====== Brain Dump Dialog ====== + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun BrainDumpDialog( + state: BrainDumpDialogUiState, + groupNames: List, + onDismiss: () -> Unit, + onInputChanged: (String) -> Unit, + onVoiceInput: (String) -> Unit, + onAnalyze: () -> Unit, + onSave: () -> Unit, + onTaskTitleChanged: (String, String) -> Unit, + onTaskDueDateChanged: (String, Long?) -> Unit, + onTaskGroupChanged: (String, String?) -> Unit, + onRemoveTag: (String, String) -> Unit +) { + val context = androidx.compose.ui.platform.LocalContext.current + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("✨ ${stringResourceCompat(R.string.todo_add_brain_dump)}") + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Fermer") + } + } + }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = state.input, + onValueChange = onInputChanged, + modifier = Modifier.fillMaxWidth(), + minLines = 4, + maxLines = 8, + placeholder = { + Text(stringResourceCompat(R.string.todo_brain_dump_hint)) + }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences + ), + trailingIcon = { + VoiceInputButton( + onResult = onVoiceInput, + contentDescription = "Saisie vocale" + ) + } + ) + + TextButton( + onClick = onAnalyze, + enabled = !state.isAnalyzing && state.input.isNotBlank(), + modifier = Modifier.fillMaxWidth() + ) { + if (state.isAnalyzing) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(stringResourceCompat(R.string.todo_analyze)) + } + + if (state.isAnalyzing) { + BrainDumpShimmer() + } + + if (state.parsedTasks.isNotEmpty()) { + Text( + text = stringResourceCompat( + R.string.todo_detected_tasks, + state.parsedTasks.size + ), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + + state.parsedTasks.forEach { task -> + GlassCard(modifier = Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = task.title, + onValueChange = { onTaskTitleChanged(task.localId, it) }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Titre") }, + singleLine = false, + maxLines = 3 + ) + + // Group selector for brain dump task + var groupInput by remember(task.localId) { + mutableStateOf(task.groupName ?: "") + } + OutlinedTextField( + value = groupInput, + onValueChange = { + groupInput = it + onTaskGroupChanged(task.localId, it.takeIf { g -> g.isNotBlank() }) + }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Groupe") }, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null) } + ) + + if (groupNames.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + groupNames.forEach { group -> + Surface( + color = if (groupInput == group) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + shape = MaterialTheme.shapes.small, + onClick = { + groupInput = group + onTaskGroupChanged(task.localId, group) + } + ) { + Text( + text = group, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + color = if (groupInput == group) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = task.dueDate?.let(::formatDateTime) + ?: stringResourceCompat(R.string.todo_no_due_date), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row { + TextButton( + onClick = { + pickDateTime( + context = context, + initial = task.dueDate, + onDateSelected = { millis -> + onTaskDueDateChanged(task.localId, millis) + } + ) + } + ) { + Text("Date") + } + if (task.dueDate != null) { + TextButton(onClick = { onTaskDueDateChanged(task.localId, null) }) { + Text("Effacer") + } + } + } + } + + if (task.tags.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + task.tags.forEach { tag -> + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = MaterialTheme.shapes.small, + onClick = { onRemoveTag(task.localId, tag) } + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "#$tag", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + modifier = Modifier.size(12.dp) + ) + } + } + } + } + } + } + } + } + } + + state.errorMessage?.let { message -> + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + + if (state.requestNotificationPermission) { + Text( + text = stringResourceCompat(R.string.todo_need_notification_permission), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + confirmButton = { + TextButton( + onClick = onSave, + enabled = !state.isSaving && state.parsedTasks.isNotEmpty() + ) { + if (state.isSaving) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(8.dp)) + } + Icon(Icons.Default.Check, contentDescription = null) + Spacer(modifier = Modifier.width(6.dp)) + Text(stringResourceCompat(R.string.todo_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Annuler") + } + } + ) +} + +@Composable +private fun BrainDumpShimmer() { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + repeat(3) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(72.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium + ) + .shimmerEffect() + ) + } + } +} + +private fun pickDateTime( + context: android.content.Context, + initial: Long?, + onDateSelected: (Long) -> Unit +) { + val calendar = Calendar.getInstance().apply { + if (initial != null) { + timeInMillis = initial + } + } + + DatePickerDialog( + context, + { _, year, month, dayOfMonth -> + TimePickerDialog( + context, + { _, hourOfDay, minute -> + val selected = Calendar.getInstance().apply { + set(year, month, dayOfMonth, hourOfDay, minute, 0) + set(Calendar.MILLISECOND, 0) + } + onDateSelected(selected.timeInMillis) + }, + calendar.get(Calendar.HOUR_OF_DAY), + calendar.get(Calendar.MINUTE), + true + ).show() + }, + calendar.get(Calendar.YEAR), + calendar.get(Calendar.MONTH), + calendar.get(Calendar.DAY_OF_MONTH) + ).show() +} + +private fun formatDateTime(timestamp: Long): String { + val sdf = SimpleDateFormat("EEE d MMM · HH:mm", Locale.FRANCE) + return sdf.format(Date(timestamp)) +} + +@Composable +private fun stringResourceCompat(id: Int, vararg formatArgs: Any): String { + return androidx.compose.ui.res.stringResource(id = id, *formatArgs) +} diff --git a/app/src/main/java/com/shaarit/presentation/todo/TodoViewModel.kt b/app/src/main/java/com/shaarit/presentation/todo/TodoViewModel.kt new file mode 100644 index 0000000..43d1edd --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/todo/TodoViewModel.kt @@ -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 = emptyList(), + val groupName: String? = null +) + +data class BrainDumpDialogUiState( + val input: String = "", + val isAnalyzing: Boolean = false, + val isSaving: Boolean = false, + val parsedTasks: List = 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 = emptyList(), + val groupName: String = "", + val subtasks: List = 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> = + todoRepository + .getTodosStream() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val groupNames: StateFlow> = + todoRepository + .getGroupNamesStream() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + private val _selectedGroup = MutableStateFlow(null) + val selectedGroup: StateFlow = _selectedGroup.asStateFlow() + + private val _dialogState = MutableStateFlow(BrainDumpDialogUiState()) + val dialogState: StateFlow = _dialogState.asStateFlow() + + private val _editDialogState = MutableStateFlow(EditTodoDialogUiState()) + val editDialogState: StateFlow = _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) + } + } +} diff --git a/app/src/main/java/com/shaarit/service/AddLinkTileService.kt b/app/src/main/java/com/shaarit/service/AddLinkTileService.kt index f992f59..8e77336 100644 --- a/app/src/main/java/com/shaarit/service/AddLinkTileService.kt +++ b/app/src/main/java/com/shaarit/service/AddLinkTileService.kt @@ -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() { diff --git a/app/src/main/java/com/shaarit/ui/components/MarkdownEditor.kt b/app/src/main/java/com/shaarit/ui/components/MarkdownEditor.kt index 076fd04..c4ed40e 100644 --- a/app/src/main/java/com/shaarit/ui/components/MarkdownEditor.kt +++ b/app/src/main/java/com/shaarit/ui/components/MarkdownEditor.kt @@ -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 + ) ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7ee54fc..7a44d62 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -117,4 +117,23 @@ Rappeler dans 1h Mes rappels Aucun rappel planifié + + + Mes tâches + Brain Dump + À faire + Terminées + Aucune tâche pour le moment + Sans échéance + Rappels de tâches + Notifications Brain Dump pour vos tâches à échéance + ⏰ %1$s + Échéance: %1$s + Marquer comme fait + Reporter 1h + Ex: Demain je dois appeler le médecin, finir le rapport, et penser au cadeau d\'anniversaire + Analyser + Sauvegarder + %1$d tâche(s) détectée(s) + Autoriser les notifications pour recevoir les rappels de tâches. \ No newline at end of file diff --git a/version.properties b/version.properties index deb1e4a..af6330b 100644 --- a/version.properties +++ b/version.properties @@ -1,3 +1,3 @@ -#Thu Feb 12 20:32:53 2026 -VERSION_NAME=1.4.0 -VERSION_CODE=13 \ No newline at end of file +#Fri Feb 13 15:47:32 2026 +VERSION_NAME=2.1.5 +VERSION_CODE=20 \ No newline at end of file