feat: Add comprehensive todo management system with Gemini-powered brain dump analysis

- Add TodoEntity, TodoDao, TodoRepository, and TodoRepositoryImpl for local todo storage with sync support
- Implement TodoViewModel with CRUD operations, group management, and subtask handling
- Create TodoScreen with Kanban-style board view, group-based organization, and drag-to-reorder support
- Add BrainDumpSheet for AI-powered task extraction from natural language input using Gemini API
- Implement TodoNot
This commit is contained in:
Bruno Charest 2026-02-13 15:51:39 -05:00
parent fc0fe3b30b
commit bccd5ea2d4
39 changed files with 4272 additions and 97 deletions

View File

@ -92,6 +92,14 @@ ksp {
arg("room.schemaLocation", "$projectDir/schemas") 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 { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)

View File

@ -5,13 +5,11 @@
# For more details, see # For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html # http://developer.android.com/guide/developing/tools/proguard.html
# Keep classes used for serialization # Keep classes used for serialization + reflection metadata required by Retrofit
-keepattributes *Annotation*,EnclosingMethod,InnerClasses -keepattributes Signature,Exceptions,*Annotation*,InnerClasses,EnclosingMethod,SourceFile,LineNumberTable,RuntimeVisibleAnnotations,RuntimeVisibleParameterAnnotations
-keepattributes Signature
-keepattributes SourceFile,LineNumberTable
# Retrofit # Retrofit
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations -keep interface com.shaarit.data.api.** { *; }
-keepclassmembers,allowshrinking,allowobfuscation interface * { -keepclassmembers,allowshrinking,allowobfuscation interface * {
@retrofit2.http.* <methods>; @retrofit2.http.* <methods>;
} }
@ -61,7 +59,6 @@
# Keep Kotlin Metadata # Keep Kotlin Metadata
-keep class kotlin.Metadata { *; } -keep class kotlin.Metadata { *; }
-keepattributes RuntimeVisibleAnnotations
# Hilt / Dagger # Hilt / Dagger
-keepclasseswithmembers class * { -keepclasseswithmembers class * {

View File

@ -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')"
]
}
}

View File

@ -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')"
]
}
}

View File

@ -116,6 +116,10 @@
android:exported="false" android:exported="false"
android:permission="android.permission.BIND_REMOTEVIEWS" /> android:permission="android.permission.BIND_REMOTEVIEWS" />
<receiver
android:name=".data.worker.TodoNotificationReceiver"
android:exported="false" />
<!-- Glance Widget: Recent Links (4×2) --> <!-- Glance Widget: Recent Links (4×2) -->
<receiver <receiver
android:name=".widget.glance.RecentLinksWidgetReceiver" android:name=".widget.glance.RecentLinksWidgetReceiver"

View File

@ -209,6 +209,7 @@ class MainActivity : FragmentActivity() {
val mimeType = intent.type ?: "" val mimeType = intent.type ?: ""
// Check if this is a file share (markdown or text file) // Check if this is a file share (markdown or text file)
@Suppress("DEPRECATION")
val fileUri = intent.getParcelableExtra<Uri>(android.content.Intent.EXTRA_STREAM) val fileUri = intent.getParcelableExtra<Uri>(android.content.Intent.EXTRA_STREAM)
if (fileUri != null && isTextOrMarkdownFile(mimeType, fileUri)) { if (fileUri != null && isTextOrMarkdownFile(mimeType, fileUri)) {

View File

@ -30,6 +30,7 @@ class ShaarItApp : Application(), Configuration.Provider {
setupHealthCheckWorker() setupHealthCheckWorker()
setupWidgetUpdateWorker() setupWidgetUpdateWorker()
setupReminderNotificationChannel() setupReminderNotificationChannel()
setupTodoNotificationChannel()
setupAudioNotificationChannel() 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 { companion object {
const val CHANNEL_REMINDERS = "reading_reminders" const val CHANNEL_REMINDERS = "reading_reminders"
const val CHANNEL_TODOS = "todo_reminders"
const val CHANNEL_AUDIO = "audio_playback" const val CHANNEL_AUDIO = "audio_playback"
} }
} }

View File

@ -5,6 +5,7 @@ import com.shaarit.data.local.dao.CollectionDao
import com.shaarit.data.local.dao.LinkDao import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.local.dao.ReminderDao import com.shaarit.data.local.dao.ReminderDao
import com.shaarit.data.local.dao.TagDao import com.shaarit.data.local.dao.TagDao
import com.shaarit.data.local.dao.TodoDao
import com.shaarit.data.local.database.ShaarliDatabase import com.shaarit.data.local.database.ShaarliDatabase
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@ -49,4 +50,10 @@ object DatabaseModule {
fun provideReminderDao(database: ShaarliDatabase): ReminderDao { fun provideReminderDao(database: ShaarliDatabase): ReminderDao {
return database.reminderDao() return database.reminderDao()
} }
@Provides
@Singleton
fun provideTodoDao(database: ShaarliDatabase): TodoDao {
return database.todoDao()
}
} }

View File

@ -3,9 +3,11 @@ package com.shaarit.core.di
import com.shaarit.data.repository.AuthRepositoryImpl import com.shaarit.data.repository.AuthRepositoryImpl
import com.shaarit.data.repository.GeminiRepositoryImpl import com.shaarit.data.repository.GeminiRepositoryImpl
import com.shaarit.data.repository.LinkRepositoryImpl import com.shaarit.data.repository.LinkRepositoryImpl
import com.shaarit.data.repository.TodoRepositoryImpl
import com.shaarit.domain.repository.AuthRepository import com.shaarit.domain.repository.AuthRepository
import com.shaarit.domain.repository.GeminiRepository import com.shaarit.domain.repository.GeminiRepository
import com.shaarit.domain.repository.LinkRepository import com.shaarit.domain.repository.LinkRepository
import com.shaarit.domain.repository.TodoRepository
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -21,4 +23,6 @@ abstract class RepositoryModule {
@Binds @Singleton abstract fun bindLinkRepository(impl: LinkRepositoryImpl): LinkRepository @Binds @Singleton abstract fun bindLinkRepository(impl: LinkRepositoryImpl): LinkRepository
@Binds @Singleton abstract fun bindGeminiRepository(impl: GeminiRepositoryImpl): GeminiRepository @Binds @Singleton abstract fun bindGeminiRepository(impl: GeminiRepositoryImpl): GeminiRepository
@Binds @Singleton abstract fun bindTodoRepository(impl: TodoRepositoryImpl): TodoRepository
} }

View File

@ -4,7 +4,6 @@ import com.shaarit.data.dto.CreateLinkDto
import com.shaarit.data.dto.InfoDto import com.shaarit.data.dto.InfoDto
import com.shaarit.data.dto.LinkDto import com.shaarit.data.dto.LinkDto
import com.shaarit.data.dto.TagDto import com.shaarit.data.dto.TagDto
import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
@ -26,12 +25,12 @@ interface ShaarliApi {
@Query("searchtags") searchTags: String? = null @Query("searchtags") searchTags: String? = null
): List<LinkDto> ): List<LinkDto>
@POST("/api/v1/links") suspend fun addLink(@Body link: CreateLinkDto): Response<LinkDto> @POST("/api/v1/links") suspend fun addLink(@Body link: CreateLinkDto): LinkDto
@PUT("/api/v1/links/{id}") @PUT("/api/v1/links/{id}")
suspend fun updateLink(@Path("id") id: Int, @Body link: CreateLinkDto): Response<LinkDto> suspend fun updateLink(@Path("id") id: Int, @Body link: CreateLinkDto): LinkDto
@DELETE("/api/v1/links/{id}") suspend fun deleteLink(@Path("id") id: Int): Response<Unit> @DELETE("/api/v1/links/{id}") suspend fun deleteLink(@Path("id") id: Int)
@GET("/api/v1/links/{id}") @GET("/api/v1/links/{id}")
suspend fun getLink(@Path("id") id: Int): LinkDto suspend fun getLink(@Path("id") id: Int): LinkDto

View File

@ -3,6 +3,7 @@ package com.shaarit.data.local.converter
import androidx.room.TypeConverter import androidx.room.TypeConverter
import com.shaarit.data.local.entity.ContentType import com.shaarit.data.local.entity.ContentType
import com.shaarit.data.local.entity.SyncStatus import com.shaarit.data.local.entity.SyncStatus
import com.shaarit.domain.model.SubTask
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -64,4 +65,24 @@ class Converters {
ContentType.UNKNOWN ContentType.UNKNOWN
} }
} }
// ====== List<SubTask> ======
@TypeConverter
fun fromSubTaskList(value: List<SubTask>): String {
return try {
json.encodeToString(value)
} catch (e: Exception) {
"[]"
}
}
@TypeConverter
fun toSubTaskList(value: String): List<SubTask> {
return try {
json.decodeFromString<List<SubTask>>(value)
} catch (e: Exception) {
emptyList()
}
}
} }

View File

@ -58,7 +58,9 @@ interface ReminderDao {
@Transaction @Transaction
@Query(""" @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 INNER JOIN links l ON r.link_id = l.id
WHERE r.is_dismissed = 0 WHERE r.is_dismissed = 0
ORDER BY r.remind_at ASC ORDER BY r.remind_at ASC
@ -67,7 +69,9 @@ interface ReminderDao {
@Transaction @Transaction
@Query(""" @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 INNER JOIN links l ON r.link_id = l.id
ORDER BY r.remind_at DESC ORDER BY r.remind_at DESC
""") """)

View File

@ -0,0 +1,81 @@
package com.shaarit.data.local.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.shaarit.data.local.entity.TodoEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface TodoDao {
@Query(
"""
SELECT * FROM todos
ORDER BY
CASE WHEN is_done = 1 THEN 1 ELSE 0 END ASC,
CASE WHEN due_date IS NULL THEN 1 ELSE 0 END ASC,
due_date ASC,
id DESC
"""
)
fun getTodosStream(): Flow<List<TodoEntity>>
@Query("SELECT * FROM todos WHERE id = :id LIMIT 1")
suspend fun getTodoById(id: Long): TodoEntity?
@Query("SELECT * FROM todos WHERE shaarli_link_url = :shaarliLinkUrl LIMIT 1")
suspend fun getTodoByShaarliLinkUrl(shaarliLinkUrl: String): TodoEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTodo(todo: TodoEntity): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTodos(todos: List<TodoEntity>)
@Update
suspend fun updateTodo(todo: TodoEntity)
@Delete
suspend fun deleteTodo(todo: TodoEntity)
@Query("DELETE FROM todos WHERE id = :id")
suspend fun deleteTodoById(id: Long)
@Query("UPDATE todos SET is_done = :isDone, is_synced = :isSynced WHERE id = :id")
suspend fun updateDoneStatus(id: Long, isDone: Boolean, isSynced: Boolean = false)
@Query("UPDATE todos SET due_date = :dueDate, is_synced = :isSynced WHERE id = :id")
suspend fun updateDueDate(id: Long, dueDate: Long?, isSynced: Boolean = false)
@Query("UPDATE todos SET is_synced = :isSynced WHERE id = :id")
suspend fun updateSyncedStatus(id: Long, isSynced: Boolean)
@Query("UPDATE todos SET is_synced = :isSynced WHERE shaarli_link_url = :url")
suspend fun updateSyncedStatusByUrl(url: String, isSynced: Boolean)
@Query("UPDATE todos SET shaarli_link_url = :newUrl, is_synced = 0 WHERE shaarli_link_url = :oldUrl")
suspend fun updateShaarliLinkUrl(oldUrl: String, newUrl: String)
@Query("SELECT DISTINCT group_name FROM todos WHERE group_name IS NOT NULL ORDER BY group_name ASC")
fun getGroupNamesStream(): Flow<List<String>>
@Query(
"""
SELECT * FROM todos
WHERE group_name = :groupName
ORDER BY
CASE WHEN is_done = 1 THEN 1 ELSE 0 END ASC,
CASE WHEN due_date IS NULL THEN 1 ELSE 0 END ASC,
due_date ASC,
id DESC
"""
)
fun getTodosByGroupStream(groupName: String): Flow<List<TodoEntity>>
@Query("DELETE FROM todos")
suspend fun clearAll()
}

View File

@ -12,6 +12,7 @@ import com.shaarit.data.local.dao.CollectionDao
import com.shaarit.data.local.dao.LinkDao import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.local.dao.ReminderDao import com.shaarit.data.local.dao.ReminderDao
import com.shaarit.data.local.dao.TagDao 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.CollectionEntity
import com.shaarit.data.local.entity.CollectionLinkCrossRef import com.shaarit.data.local.entity.CollectionLinkCrossRef
import com.shaarit.data.local.entity.LinkEntity 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.LinkTagCrossRef
import com.shaarit.data.local.entity.ReadingReminderEntity import com.shaarit.data.local.entity.ReadingReminderEntity
import com.shaarit.data.local.entity.TagEntity import com.shaarit.data.local.entity.TagEntity
import com.shaarit.data.local.entity.TodoEntity
/** /**
* Database Room principale pour le cache offline de ShaarIt * Database Room principale pour le cache offline de ShaarIt
@ -31,9 +33,10 @@ import com.shaarit.data.local.entity.TagEntity
LinkTagCrossRef::class, LinkTagCrossRef::class,
CollectionEntity::class, CollectionEntity::class,
CollectionLinkCrossRef::class, CollectionLinkCrossRef::class,
ReadingReminderEntity::class ReadingReminderEntity::class,
TodoEntity::class
], ],
version = 6, version = 8,
exportSchema = true exportSchema = true
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -43,6 +46,7 @@ abstract class ShaarliDatabase : RoomDatabase() {
abstract fun tagDao(): TagDao abstract fun tagDao(): TagDao
abstract fun collectionDao(): CollectionDao abstract fun collectionDao(): CollectionDao
abstract fun reminderDao(): ReminderDao abstract fun reminderDao(): ReminderDao
abstract fun todoDao(): TodoDao
companion object { companion object {
private const val DATABASE_NAME = "shaarli.db" 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`)") 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 @Volatile
private var instance: ShaarliDatabase? = null private var instance: ShaarliDatabase? = null
@ -107,7 +146,7 @@ abstract class ShaarliDatabase : RoomDatabase() {
ShaarliDatabase::class.java, ShaarliDatabase::class.java,
DATABASE_NAME 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) .fallbackToDestructiveMigrationFrom(1, 2, 3)
.build() .build()
} }

View File

@ -0,0 +1,45 @@
package com.shaarit.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import com.shaarit.domain.model.SubTask
@Entity(
tableName = "todos",
indices = [
Index(value = ["shaarli_link_url"], unique = true),
Index(value = ["due_date"]),
Index(value = ["is_done"]),
Index(value = ["group_name"])
]
)
data class TodoEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(name = "shaarli_link_url")
val shaarliLinkUrl: String,
@ColumnInfo(name = "content")
val content: String,
@ColumnInfo(name = "is_done")
val isDone: Boolean = false,
@ColumnInfo(name = "due_date")
val dueDate: Long? = null,
@ColumnInfo(name = "tags")
val tags: List<String> = emptyList(),
@ColumnInfo(name = "is_synced")
val isSynced: Boolean = false,
@ColumnInfo(name = "group_name")
val groupName: String? = null,
@ColumnInfo(name = "subtasks")
val subtasks: List<SubTask> = emptyList()
)

View File

@ -0,0 +1,185 @@
package com.shaarit.data.mapper
import com.shaarit.data.local.entity.LinkEntity
import com.shaarit.data.local.entity.TodoEntity
import com.shaarit.domain.model.SubTask
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
data class TodoShaarliPayload(
val url: String,
val title: String,
val markdownBody: String,
val tags: List<String>
)
data class ParsedTodoFromShaarli(
val shaarliLinkUrl: String,
val content: String,
val isDone: Boolean,
val dueDate: Long?,
val tags: List<String>,
val groupName: String? = null,
val subtasks: List<SubTask> = emptyList()
)
@Singleton
class TodoMarkdownMapper @Inject constructor() {
private val hashTagRegex = Regex("#([a-zA-Z0-9_-]+)")
private val checkboxRegex = Regex("-\\s*\\[( |x|X)]\\s*(.+)")
private val subtaskRegex = Regex("^\\s{2,}-\\s*\\[( |x|X)]\\s*(.+)")
private val dueDateRegex = Regex("📅\\s*\\*\\*Échéance\\s*:\\*\\*\\s*([^\\n\\r]+)", RegexOption.IGNORE_CASE)
private val groupRegex = Regex("🏷️\\s*\\*\\*Groupe\\s*:\\*\\*\\s*([^\\n\\r]+)", RegexOption.IGNORE_CASE)
fun toShaarliPayload(todo: TodoEntity): TodoShaarliPayload {
val normalizedTags = normalizeTags(todo.tags)
val fullTags = (listOf("todo") + normalizedTags).distinct()
val dueDateLine = todo.dueDate
?.let { Instant.ofEpochMilli(it).toString() }
?: "Aucune"
val checkbox = if (todo.isDone) "x" else " "
val content = todo.content.trim().ifBlank { "Tâche sans titre" }
val hashtagSection = if (normalizedTags.isEmpty()) {
""
} else {
" " + normalizedTags.joinToString(" ") { "#$it" }
}
val body = buildString {
append("📅 **Échéance :** ")
append(dueDateLine)
if (!todo.groupName.isNullOrBlank()) {
append("\n🏷️ **Groupe :** ")
append(todo.groupName.trim())
}
append("\n\n")
append("- [")
append(checkbox)
append("] ")
append(content)
append(hashtagSection)
todo.subtasks.forEach { sub ->
val subCheck = if (sub.isDone) "x" else " "
append("\n - [")
append(subCheck)
append("] ")
append(sub.content.trim())
}
}
val finalUrl = todo.shaarliLinkUrl.ifBlank {
"note://todo-${UUID.randomUUID()}"
}
return TodoShaarliPayload(
url = finalUrl,
title = buildTitle(content),
markdownBody = body,
tags = fullTags
)
}
fun fromShaarliLink(link: LinkEntity): ParsedTodoFromShaarli? {
if (!isTodoLink(link.url, link.tags)) return null
val markdown = link.description
val lines = markdown.lines()
// Parse main checkbox (non-indented)
val mainCheckboxLine = lines.firstOrNull { checkboxRegex.matches(it.trim()) }
val checkboxMatch = mainCheckboxLine?.let { checkboxRegex.find(it.trim()) }
val lineContent = checkboxMatch?.groupValues?.getOrNull(2)?.trim().orEmpty()
val inlineTags = hashTagRegex.findAll(lineContent)
.map { it.groupValues[1].lowercase(Locale.ROOT) }
.toList()
val cleanContent = lineContent
.replace(hashTagRegex, "")
.trim()
.ifBlank { link.title.ifBlank { "Tâche" } }
val isDone = checkboxMatch?.groupValues?.getOrNull(1)?.equals("x", ignoreCase = true) == true
val dueDateRaw = dueDateRegex.find(markdown)?.groupValues?.getOrNull(1)?.trim()
val parsedDueDate = parseDueDate(dueDateRaw)
val groupRaw = groupRegex.find(markdown)?.groupValues?.getOrNull(1)?.trim()
val groupName = groupRaw?.takeIf { it.isNotBlank() && !it.equals("aucun", ignoreCase = true) }
// Parse subtasks (indented checkboxes)
val subtasks = lines.mapNotNull { line ->
val match = subtaskRegex.find(line) ?: return@mapNotNull null
val subDone = match.groupValues[1].equals("x", ignoreCase = true)
val subContent = match.groupValues[2]
.replace(hashTagRegex, "")
.trim()
if (subContent.isNotBlank()) SubTask(subContent, subDone) else null
}
val mergedTags = (
link.tags.filterNot { it.equals("todo", true) || it.equals("brain-dump", true) } + inlineTags
).map { it.lowercase(Locale.ROOT) }
.distinct()
return ParsedTodoFromShaarli(
shaarliLinkUrl = link.url,
content = cleanContent,
isDone = isDone,
dueDate = parsedDueDate,
tags = mergedTags,
groupName = groupName,
subtasks = subtasks
)
}
private fun isTodoLink(url: String, tags: List<String>): Boolean {
return url.startsWith("note://todo-", ignoreCase = true) ||
tags.any { it.equals("todo", ignoreCase = true) }
}
private fun buildTitle(content: String): String {
return content
.lineSequence()
.firstOrNull { it.isNotBlank() }
?.take(80)
?.trim()
?.ifBlank { "Tâche" }
?: "Tâche"
}
private fun normalizeTags(tags: List<String>): List<String> {
return tags
.map { it.trim().trimStart('#').lowercase(Locale.ROOT) }
.filter { it.isNotBlank() }
.distinct()
}
private fun parseDueDate(raw: String?): Long? {
if (raw.isNullOrBlank()) return null
if (raw.equals("aucune", ignoreCase = true) || raw == "-") return null
return try {
Instant.parse(raw).toEpochMilli()
} catch (_: Exception) {
try {
val date = LocalDate.parse(raw, DateTimeFormatter.ISO_DATE)
date.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
} catch (_: Exception) {
null
}
}
}
}

View File

@ -224,7 +224,7 @@ class LinkMetadataExtractor @Inject constructor() {
* Estime le temps de lecture * Estime le temps de lecture
*/ */
private fun estimateReadingTime(doc: Document): Int? { 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 val wordCount = text.split(Regex("\\s+")).size
// Moyenne de 200 mots par minute // Moyenne de 200 mots par minute
val minutes = (wordCount / 200.0).toInt() val minutes = (wordCount / 200.0).toInt()

View File

@ -6,6 +6,8 @@ import com.google.ai.client.generativeai.type.generationConfig
import com.shaarit.core.storage.TokenManager import com.shaarit.core.storage.TokenManager
import com.shaarit.domain.model.AiContentType import com.shaarit.domain.model.AiContentType
import com.shaarit.domain.model.AiEnrichmentResult 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 com.shaarit.domain.repository.GeminiRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -14,6 +16,11 @@ import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.json.JSONObject import org.json.JSONObject
import android.util.LruCache 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.Inject
import javax.inject.Singleton 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) // Cache en mémoire pour éviter de rappeler l'API pour la même URL durant la session (borné à 50 entrées max)
private val analysisCache = LruCache<String, AiEnrichmentResult>(50) private val analysisCache = LruCache<String, AiEnrichmentResult>(50)
private val brainDumpCache = LruCache<String, BrainDumpResult>(30)
override fun isApiKeyConfigured(): Boolean { override fun isApiKeyConfigured(): Boolean {
return !tokenManager.getGeminiApiKey().isNullOrBlank() return !tokenManager.getGeminiApiKey().isNullOrBlank()
} }
@ -75,6 +84,51 @@ class GeminiRepositoryImpl @Inject constructor(
} }
} }
override suspend fun analyzeBrainDump(input: String): BrainDumpResult = withContext(Dispatchers.IO) {
val normalizedInput = input.trim()
if (normalizedInput.isBlank()) {
throw IllegalArgumentException("Le brain dump ne peut pas être vide")
}
brainDumpCache.get(normalizedInput)?.let {
return@withContext it
}
val apiKey = tokenManager.getGeminiApiKey()
if (apiKey.isNullOrBlank()) {
throw IllegalStateException("Clé API Gemini non configurée.")
}
val modelsToTry = listOf(
"gemini-2.5-flash-lite",
"gemini-2.5-flash",
"gemini-3-flash",
"gemini-2.0-flash-lite",
"gemini-1.5-flash"
)
var lastException: Exception? = null
for (modelName in modelsToTry) {
try {
val result = analyzeBrainDumpWithModel(apiKey, modelName, normalizedInput)
brainDumpCache.put(normalizedInput, result)
return@withContext result
} catch (e: Exception) {
lastException = e
val msg = (e.message ?: "").lowercase()
val isRetryable = msg.contains("404") ||
msg.contains("not found") ||
msg.contains("429") ||
msg.contains("quota") ||
msg.contains("exhausted")
if (!isRetryable) break
}
}
lastException?.let { throw it }
fallbackBrainDumpResult(normalizedInput)
}
private suspend fun generateTagsWithModel(apiKey: String, modelName: String, title: String, description: String): List<String> { private suspend fun generateTagsWithModel(apiKey: String, modelName: String, title: String, description: String): List<String> {
val generativeModel = GenerativeModel( val generativeModel = GenerativeModel(
modelName = modelName, modelName = modelName,
@ -103,6 +157,30 @@ class GeminiRepositoryImpl @Inject constructor(
return parseTagsResponse(responseText) return parseTagsResponse(responseText)
} }
private suspend fun analyzeBrainDumpWithModel(
apiKey: String,
modelName: String,
input: String
): BrainDumpResult {
val model = GenerativeModel(
modelName = modelName,
apiKey = apiKey,
generationConfig = generationConfig {
temperature = 0.3f
maxOutputTokens = 1024
}
)
val response = model.generateContent(
content {
text(buildBrainDumpPrompt(input))
}
)
val responseText = response.text ?: throw Exception("Réponse vide pour le brain dump")
return parseBrainDumpResponse(responseText, input)
}
private fun parseTagsResponse(responseText: String): List<String> { private fun parseTagsResponse(responseText: String): List<String> {
val cleaned = responseText.replace("```json", "").replace("```", "").trim() val cleaned = responseText.replace("```json", "").replace("```", "").trim()
val jsonArray = org.json.JSONArray(cleaned) val jsonArray = org.json.JSONArray(cleaned)
@ -113,6 +191,153 @@ class GeminiRepositoryImpl @Inject constructor(
return tags return tags
} }
private fun buildBrainDumpPrompt(input: String): String {
return """
Rôle: Tu es un assistant de productivité qui transforme une décharge mentale en tâches actionnables.
Entrée utilisateur:
$input
Objectif:
1. Extraire des tâches explicites et concrètes.
2. Générer un titre global court.
3. Déduire une échéance ISO-8601 quand l'utilisateur donne une date claire, sinon null.
4. Proposer 1 à 4 tags courts par tâche.
Règles:
- Pas d'invention: si la date n'est pas explicite, dueDate = null.
- Titre de tâche impératif, max 80 caractères.
- Tags en lowercase, format slug (ex: "admin", "urgent", "sante").
Réponse STRICTEMENT en JSON valide, sans markdown:
{
"title": "Titre global",
"tasks": [
{
"title": "Tâche 1",
"dueDate": "2026-02-13T16:00:00Z",
"tags": ["tag1", "tag2"]
}
]
}
""".trimIndent()
}
private fun parseBrainDumpResponse(responseText: String, fallbackInput: String): BrainDumpResult {
return try {
val cleaned = responseText
.replace("```json", "")
.replace("```", "")
.trim()
val json = JSONObject(cleaned)
val title = json.optString("title", "").trim().ifBlank { "Brain Dump" }.take(80)
val tasks = mutableListOf<BrainDumpTaskSuggestion>()
val tasksArray = json.optJSONArray("tasks")
if (tasksArray != null) {
for (i in 0 until tasksArray.length()) {
val obj = tasksArray.optJSONObject(i) ?: continue
val taskTitle = obj.optString("title", "").trim().take(80)
if (taskTitle.isBlank()) continue
val dueDate = parseBrainDumpDueDate(
if (obj.has("dueDate") && !obj.isNull("dueDate")) obj.optString("dueDate") else null
)
val tags = mutableListOf<String>()
val tagsArray = obj.optJSONArray("tags")
if (tagsArray != null) {
for (j in 0 until tagsArray.length()) {
val rawTag = tagsArray.optString(j)
.trim()
.trimStart('#')
.lowercase(Locale.ROOT)
.replace(" ", "-")
.take(30)
if (rawTag.isNotBlank()) {
tags.add(rawTag)
}
}
}
tasks.add(
BrainDumpTaskSuggestion(
title = taskTitle,
dueDate = dueDate,
tags = tags.distinct()
)
)
}
}
if (tasks.isEmpty()) {
fallbackBrainDumpResult(fallbackInput)
} else {
BrainDumpResult(
generatedTitle = title,
tasks = tasks.take(12)
)
}
} catch (_: Exception) {
fallbackBrainDumpResult(fallbackInput)
}
}
private fun parseBrainDumpDueDate(raw: String?): Long? {
if (raw.isNullOrBlank()) return null
val value = raw.trim()
if (value.equals("null", ignoreCase = true) ||
value.equals("none", ignoreCase = true) ||
value.equals("aucune", ignoreCase = true)
) {
return null
}
return try {
Instant.parse(value).toEpochMilli()
} catch (_: Exception) {
try {
val localDate = LocalDate.parse(value, DateTimeFormatter.ISO_DATE)
localDate.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
} catch (_: Exception) {
null
}
}
}
private fun fallbackBrainDumpResult(input: String): BrainDumpResult {
val candidateTasks = input
.split("\n", ";", "", "-")
.map { it.trim() }
.filter { it.isNotBlank() }
.take(8)
.map {
BrainDumpTaskSuggestion(
title = it.take(80),
dueDate = null,
tags = emptyList()
)
}
val tasks = if (candidateTasks.isEmpty()) {
listOf(
BrainDumpTaskSuggestion(
title = input.take(80).ifBlank { "Nouvelle tâche" },
dueDate = null,
tags = emptyList()
)
)
} else {
candidateTasks
}
return BrainDumpResult(
generatedTitle = tasks.firstOrNull()?.title ?: "Brain Dump",
tasks = tasks
)
}
override suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult> = withContext(Dispatchers.IO) { override suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult> = withContext(Dispatchers.IO) {
// Vérifier le cache d'abord // Vérifier le cache d'abord
analysisCache.get(url)?.let { analysisCache.get(url)?.let {

View File

@ -274,26 +274,11 @@ constructor(
) )
} else { } else {
// Essayer l'API directement // Essayer l'API directement
val response = api.addLink(CreateLinkDto(url, title, description, tags, isPrivate)) val serverLink = api.addLink(CreateLinkDto(url, title, description, tags, isPrivate))
if (response.isSuccessful) { serverLink.toEntity()?.let { entity ->
response.body()?.let { serverLink -> linkDao.insertLink(entity)
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
} }
AddLinkResult.Success
} }
} }
} catch (e: HttpException) { } catch (e: HttpException) {

View File

@ -0,0 +1,218 @@
package com.shaarit.data.repository
import android.util.Log
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.local.dao.TodoDao
import com.shaarit.data.local.entity.LinkEntity
import com.shaarit.data.local.entity.SyncStatus
import com.shaarit.data.local.entity.TodoEntity
import com.shaarit.data.mapper.TodoMarkdownMapper
import com.shaarit.data.sync.SyncManager
import com.shaarit.data.worker.TodoNotificationScheduler
import com.shaarit.domain.model.TodoItem
import com.shaarit.domain.repository.TodoRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.util.Locale
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.absoluteValue
import kotlin.random.Random
@Singleton
class TodoRepositoryImpl @Inject constructor(
private val todoDao: TodoDao,
private val linkDao: LinkDao,
private val syncManager: SyncManager,
private val todoMarkdownMapper: TodoMarkdownMapper,
private val notificationScheduler: TodoNotificationScheduler
) : TodoRepository {
companion object {
private const val TAG = "TodoRepository"
private const val TODO_URL_PREFIX = "https://shaarit.app/todo/"
}
override fun getTodosStream(): Flow<List<TodoItem>> {
return todoDao.getTodosStream().map { todos ->
todos.map { it.toDomain() }
}
}
override fun getGroupNamesStream(): Flow<List<String>> {
return todoDao.getGroupNamesStream()
}
override suspend fun getTodoById(id: Long): TodoItem? {
return todoDao.getTodoById(id)?.toDomain()
}
override suspend fun upsertTodo(todo: TodoItem): Result<Long> {
return runCatching {
val normalized = normalizeTodo(todo)
val savedId = if (normalized.id == 0L) {
todoDao.insertTodo(normalized)
} else {
todoDao.updateTodo(normalized)
normalized.id
}
val persisted = normalized.copy(id = savedId)
scheduleIfNeeded(persisted)
upsertShaarliLinkFromTodo(persisted)
savedId
}
}
override suspend fun toggleDone(todoId: Long, isDone: Boolean): Result<Unit> {
return runCatching {
val existing = todoDao.getTodoById(todoId)
?: throw IllegalArgumentException("Todo not found")
val updated = existing.copy(
isDone = isDone,
isSynced = false
)
todoDao.updateTodo(updated)
scheduleIfNeeded(updated)
upsertShaarliLinkFromTodo(updated)
syncManager.syncNow()
}
}
override suspend fun snoozeTodo(todoId: Long, delayMs: Long): Result<Unit> {
return runCatching {
val existing = todoDao.getTodoById(todoId)
?: throw IllegalArgumentException("Todo not found")
val updated = existing.copy(
isDone = false,
dueDate = System.currentTimeMillis() + delayMs,
isSynced = false
)
todoDao.updateTodo(updated)
scheduleIfNeeded(updated)
upsertShaarliLinkFromTodo(updated)
syncManager.syncNow()
}
}
override suspend fun deleteTodo(todoId: Long): Result<Unit> {
return runCatching {
val existing = todoDao.getTodoById(todoId)
?: throw IllegalArgumentException("Todo not found")
notificationScheduler.cancel(todoId)
todoDao.deleteTodo(existing)
val linked = linkDao.getLinkByUrl(existing.shaarliLinkUrl)
if (linked != null) {
if (linked.syncStatus == SyncStatus.PENDING_CREATE) {
linkDao.deleteLink(linked.id)
} else {
linkDao.markForDeletion(linked.id)
syncManager.syncNow()
}
}
}
}
private suspend fun upsertShaarliLinkFromTodo(todo: TodoEntity) {
val payload = todoMarkdownMapper.toShaarliPayload(todo)
val now = System.currentTimeMillis()
val existingLink = linkDao.getLinkByUrl(payload.url)
if (existingLink != null) {
val updated = existingLink.copy(
url = payload.url,
title = payload.title,
description = payload.markdownBody,
tags = payload.tags,
isPrivate = true,
updatedAt = now,
syncStatus = if (existingLink.syncStatus == SyncStatus.PENDING_CREATE) {
SyncStatus.PENDING_CREATE
} else {
SyncStatus.PENDING_UPDATE
},
localModifiedAt = now
)
linkDao.updateLink(updated)
Log.d(TAG, "Updated mirror link for todo ${todo.id}: url=${payload.url}")
} else {
val newLink = LinkEntity(
id = generateTempId(),
url = payload.url,
title = payload.title,
description = payload.markdownBody,
tags = payload.tags,
isPrivate = true,
createdAt = now,
updatedAt = now,
syncStatus = SyncStatus.PENDING_CREATE,
localModifiedAt = now
)
linkDao.insertLink(newLink)
Log.d(TAG, "Created mirror link for todo ${todo.id}: id=${newLink.id}, url=${payload.url}")
}
}
private fun normalizeTodo(todo: TodoItem): TodoEntity {
val cleanedContent = todo.content.trim().ifBlank { "Tâche" }
val cleanedTags = todo.tags
.map { it.trim().trimStart('#').lowercase(Locale.ROOT) }
.filter { it.isNotBlank() }
.distinct()
val url = todo.shaarliLinkUrl.ifBlank {
"$TODO_URL_PREFIX${UUID.randomUUID()}"
}
return TodoEntity(
id = todo.id,
shaarliLinkUrl = url,
content = cleanedContent,
isDone = todo.isDone,
dueDate = todo.dueDate,
tags = cleanedTags,
isSynced = false,
groupName = todo.groupName?.trim()?.takeIf { it.isNotBlank() },
subtasks = todo.subtasks
)
}
private fun scheduleIfNeeded(todo: TodoEntity) {
if (todo.id == 0L) return
val dueDate = todo.dueDate
if (todo.isDone || dueDate == null || dueDate <= System.currentTimeMillis()) {
notificationScheduler.cancel(todo.id)
return
}
notificationScheduler.schedule(todo)
}
private fun TodoEntity.toDomain(): TodoItem {
return TodoItem(
id = id,
shaarliLinkUrl = shaarliLinkUrl,
content = content,
isDone = isDone,
dueDate = dueDate,
tags = tags,
isSynced = isSynced,
groupName = groupName,
subtasks = subtasks
)
}
private fun generateTempId(): Int {
val base = (System.currentTimeMillis() % Int.MAX_VALUE).toInt().absoluteValue
return -((base + Random.nextInt(1, 10_000)) + 1)
}
}

View File

@ -19,13 +19,17 @@ import com.shaarit.data.dto.CollectionsConfigDto
import com.shaarit.data.local.dao.LinkDao import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.local.dao.CollectionDao import com.shaarit.data.local.dao.CollectionDao
import com.shaarit.data.local.dao.TagDao 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.CollectionEntity
import com.shaarit.data.local.entity.CollectionLinkCrossRef import com.shaarit.data.local.entity.CollectionLinkCrossRef
import com.shaarit.data.local.entity.LinkEntity import com.shaarit.data.local.entity.LinkEntity
import com.shaarit.data.local.entity.SyncStatus import com.shaarit.data.local.entity.SyncStatus
import com.shaarit.data.local.entity.TagEntity 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.LinkMapper
import com.shaarit.data.mapper.ParsedTodoFromShaarli
import com.shaarit.data.mapper.TagMapper import com.shaarit.data.mapper.TagMapper
import com.shaarit.data.mapper.TodoMarkdownMapper
import com.shaarit.core.storage.TokenManager import com.shaarit.core.storage.TokenManager
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.assisted.Assisted import dagger.assisted.Assisted
@ -49,6 +53,8 @@ class SyncManager @Inject constructor(
private val linkDao: LinkDao, private val linkDao: LinkDao,
private val tagDao: TagDao, private val tagDao: TagDao,
private val collectionDao: CollectionDao, private val collectionDao: CollectionDao,
private val todoDao: TodoDao,
private val todoMarkdownMapper: TodoMarkdownMapper,
private val moshi: Moshi, private val moshi: Moshi,
private val tokenManager: TokenManager, private val tokenManager: TokenManager,
private val api: ShaarliApi private val api: ShaarliApi
@ -56,6 +62,8 @@ class SyncManager @Inject constructor(
companion object { companion object {
private const val TAG = "SyncManager" private const val TAG = "SyncManager"
private const val SYNC_WORK_NAME = "shaarli_sync_work" 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_TITLE = "collections"
private const val COLLECTIONS_CONFIG_TAG = "shaarit_config" private const val COLLECTIONS_CONFIG_TAG = "shaarit_config"
@ -176,7 +184,7 @@ class SyncManager @Inject constructor(
val linkId = existingId ?: findCollectionsConfigBookmarkIdOnServer() val linkId = existingId ?: findCollectionsConfigBookmarkIdOnServer()
if (linkId != null) { if (linkId != null) {
val response = api.updateLink( api.updateLink(
linkId, linkId,
CreateLinkDto( CreateLinkDto(
url = COLLECTIONS_CONFIG_URL, url = COLLECTIONS_CONFIG_URL,
@ -186,13 +194,10 @@ class SyncManager @Inject constructor(
isPrivate = true isPrivate = true
) )
) )
tokenManager.saveCollectionsConfigBookmarkId(linkId)
if (response.isSuccessful) { tokenManager.setCollectionsConfigDirty(false)
tokenManager.saveCollectionsConfigBookmarkId(linkId)
tokenManager.setCollectionsConfigDirty(false)
}
} else { } else {
val response = api.addLink( val created = api.addLink(
CreateLinkDto( CreateLinkDto(
url = COLLECTIONS_CONFIG_URL, url = COLLECTIONS_CONFIG_URL,
title = COLLECTIONS_CONFIG_TITLE, title = COLLECTIONS_CONFIG_TITLE,
@ -202,13 +207,8 @@ class SyncManager @Inject constructor(
) )
) )
if (response.isSuccessful) { created.id?.let { tokenManager.saveCollectionsConfigBookmarkId(it) }
val createdId = response.body()?.id tokenManager.setCollectionsConfigDirty(false)
if (createdId != null) {
tokenManager.saveCollectionsConfigBookmarkId(createdId)
}
tokenManager.setCollectionsConfigDirty(false)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Erreur lors de la poussée de la configuration des collections", e) 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) { for (link in pendingCreates) {
try { try {
val response = api.addLink( val linkToCreate = migrateLegacyTodoUrlIfNeeded(link)
val serverLink = api.addLink(
CreateLinkDto( CreateLinkDto(
url = link.url, url = linkToCreate.url,
title = link.title.takeIf { it.isNotBlank() }, title = linkToCreate.title.takeIf { it.isNotBlank() },
description = link.description.takeIf { it.isNotBlank() }, description = linkToCreate.description.takeIf { it.isNotBlank() },
tags = link.tags.ifEmpty { null }, tags = linkToCreate.tags.ifEmpty { null },
isPrivate = link.isPrivate isPrivate = linkToCreate.isPrivate
) )
) )
if (response.isSuccessful) { val syncedUrl = serverLink.url ?: linkToCreate.url
response.body()?.let { serverLink -> val serverId = serverLink.id
// Mettre à jour l'ID local avec l'ID serveur if (serverId != null && serverId != linkToCreate.id) {
val serverId = serverLink.id // Supprimer l'ancien lien temporaire avant d'insérer avec l'ID serveur
if (serverId != null) { linkDao.deleteLink(linkToCreate.id)
val updatedLink = link.copy( val updatedLink = linkToCreate.copy(
id = serverId, id = serverId,
syncStatus = SyncStatus.SYNCED syncStatus = SyncStatus.SYNCED
) )
linkDao.insertLink(updatedLink) linkDao.insertLink(updatedLink)
} else { Log.d(TAG, "Lien créé: temp=${linkToCreate.id} -> serveur=$serverId url=${linkToCreate.url}")
Log.w(TAG, "Serveur a retourné un lien sans ID pour ${link.url}") } else if (serverId != null) {
} linkDao.markAsSynced(linkToCreate.id)
} Log.d(TAG, "Lien créé avec même ID: ${linkToCreate.id} url=${linkToCreate.url}")
} else { } 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) { } catch (e: Exception) {
Log.e(TAG, "Exception lors de la création du lien ${link.id}", e) 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) { for (link in pendingUpdates) {
try { try {
val response = api.updateLink( api.updateLink(
link.id, link.id,
CreateLinkDto( CreateLinkDto(
url = link.url, url = link.url,
@ -336,12 +353,10 @@ class SyncManager @Inject constructor(
isPrivate = link.isPrivate isPrivate = link.isPrivate
) )
) )
linkDao.markAsSynced(link.id)
if (response.isSuccessful) { markTodoSyncedByUrl(link.url)
linkDao.markAsSynced(link.id) } catch (e: HttpException) {
} else { Log.e(TAG, "Échec mise à jour lien ${link.id}: ${e.code()}", e)
Log.e(TAG, "Échec mise à jour lien ${link.id}: ${response.code()}")
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Exception lors de la mise à jour du lien ${link.id}", e) 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) { for (link in pendingDeletes) {
try { try {
val response = api.deleteLink(link.id) api.deleteLink(link.id)
linkDao.deleteLink(link.id)
if (response.isSuccessful) { } catch (e: HttpException) {
linkDao.deleteLink(link.id) Log.e(TAG, "Échec suppression lien ${link.id}: ${e.code()}", e)
} else {
Log.e(TAG, "Échec suppression lien ${link.id}: ${response.code()}")
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Exception lors de la suppression du lien ${link.id}", e) Log.e(TAG, "Exception lors de la suppression du lien ${link.id}", e)
} }
@ -452,6 +464,7 @@ class SyncManager @Inject constructor(
if (entities.isNotEmpty()) { if (entities.isNotEmpty()) {
linkDao.insertLinks(entities) linkDao.insertLinks(entities)
syncTodosFromPulledLinks(entities)
} }
Log.d(TAG, "Page offset=$offset: $newOrUpdatedCount nouveaux/modifiés sur ${validLinks.size} valides") Log.d(TAG, "Page offset=$offset: $newOrUpdatedCount nouveaux/modifiés sur ${validLinks.size} valides")
@ -584,6 +597,76 @@ class SyncManager @Inject constructor(
System.currentTimeMillis() System.currentTimeMillis()
} }
} }
private suspend fun markTodoSyncedByUrl(url: String) {
todoDao.updateSyncedStatusByUrl(url, isSynced = true)
}
private suspend fun migrateLegacyTodoUrlIfNeeded(link: LinkEntity): LinkEntity {
if (!link.url.startsWith(TODO_LEGACY_URL_PREFIX, ignoreCase = true)) return link
val legacySuffix = link.url.removePrefix(TODO_LEGACY_URL_PREFIX)
.ifBlank { System.currentTimeMillis().toString() }
val newUrl = "$TODO_HTTPS_URL_PREFIX$legacySuffix"
if (newUrl == link.url) return link
val migrated = link.copy(
url = newUrl,
localModifiedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING_CREATE
)
linkDao.updateLink(migrated)
todoDao.updateShaarliLinkUrl(oldUrl = link.url, newUrl = newUrl)
Log.d(TAG, "Todo URL migrée pour sync: ${link.url} -> $newUrl")
return migrated
}
private suspend fun syncTodosFromPulledLinks(links: List<LinkEntity>) {
links.forEach { link ->
val parsed = todoMarkdownMapper.fromShaarliLink(link) ?: return@forEach
upsertTodoFromServer(parsed)
}
}
private suspend fun upsertTodoFromServer(parsed: ParsedTodoFromShaarli) {
val existing = todoDao.getTodoByShaarliLinkUrl(parsed.shaarliLinkUrl)
val incoming = TodoEntity(
id = existing?.id ?: 0,
shaarliLinkUrl = parsed.shaarliLinkUrl,
content = parsed.content,
isDone = parsed.isDone,
dueDate = parsed.dueDate,
tags = parsed.tags,
isSynced = true,
groupName = parsed.groupName,
subtasks = parsed.subtasks
)
when {
existing == null -> {
todoDao.insertTodo(incoming)
}
existing.isSynced || hasSameTodoPayload(existing, incoming) -> {
todoDao.updateTodo(incoming.copy(id = existing.id, isSynced = true))
}
else -> {
// Version locale non synchronisée prioritaire: on évite d'écraser l'édition en cours.
}
}
}
private fun hasSameTodoPayload(local: TodoEntity, remote: TodoEntity): Boolean {
return local.content == remote.content &&
local.isDone == remote.isDone &&
local.dueDate == remote.dueDate &&
local.tags == remote.tags &&
local.groupName == remote.groupName &&
local.subtasks == remote.subtasks
}
} }
/** /**

View File

@ -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
}

View File

@ -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()
}
}
}

View File

@ -0,0 +1,12 @@
package com.shaarit.domain.model
data class BrainDumpTaskSuggestion(
val title: String,
val dueDate: Long? = null,
val tags: List<String> = emptyList()
)
data class BrainDumpResult(
val generatedTitle: String,
val tasks: List<BrainDumpTaskSuggestion>
)

View File

@ -0,0 +1,9 @@
package com.shaarit.domain.model
import kotlinx.serialization.Serializable
@Serializable
data class SubTask(
val content: String,
val isDone: Boolean = false
)

View File

@ -0,0 +1,13 @@
package com.shaarit.domain.model
data class TodoItem(
val id: Long = 0,
val shaarliLinkUrl: String,
val content: String,
val isDone: Boolean = false,
val dueDate: Long? = null,
val tags: List<String> = emptyList(),
val isSynced: Boolean = false,
val groupName: String? = null,
val subtasks: List<SubTask> = emptyList()
)

View File

@ -1,9 +1,11 @@
package com.shaarit.domain.repository package com.shaarit.domain.repository
import com.shaarit.domain.model.AiEnrichmentResult import com.shaarit.domain.model.AiEnrichmentResult
import com.shaarit.domain.model.BrainDumpResult
interface GeminiRepository { interface GeminiRepository {
suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult> suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult>
suspend fun generateTags(title: String, description: String): Result<List<String>> suspend fun generateTags(title: String, description: String): Result<List<String>>
suspend fun analyzeBrainDump(input: String): BrainDumpResult
fun isApiKeyConfigured(): Boolean fun isApiKeyConfigured(): Boolean
} }

View File

@ -0,0 +1,20 @@
package com.shaarit.domain.repository
import com.shaarit.domain.model.TodoItem
import kotlinx.coroutines.flow.Flow
interface TodoRepository {
fun getTodosStream(): Flow<List<TodoItem>>
fun getGroupNamesStream(): Flow<List<String>>
suspend fun getTodoById(id: Long): TodoItem?
suspend fun upsertTodo(todo: TodoItem): Result<Long>
suspend fun toggleDone(todoId: Long, isDone: Boolean): Result<Unit>
suspend fun snoozeTodo(todoId: Long, delayMs: Long = 3_600_000L): Result<Unit>
suspend fun deleteTodo(todoId: Long): Result<Unit>
}

View File

@ -75,6 +75,7 @@ class ClassifyBookmarksUseCase @Inject constructor(
} }
// Helper to classify based on URL, Title, Tags // Helper to classify based on URL, Title, Tags
@Suppress("UNUSED_PARAMETER")
fun classify(url: String, title: String?, tags: List<String>): Pair<ContentType, String?> { fun classify(url: String, title: String?, tags: List<String>): Pair<ContentType, String?> {
val lowerUrl = url.lowercase() val lowerUrl = url.lowercase()
val host = try { URI(url).host?.lowercase() } catch (e: Exception) { null } ?: "" val host = try { URI(url).host?.lowercase() } catch (e: Exception) { null } ?: ""

View File

@ -33,6 +33,7 @@ import java.util.concurrent.TimeUnit
* Lecteur audio complet affiché dans une ModalBottomSheet. * Lecteur audio complet affiché dans une ModalBottomSheet.
* Style glassmorphism cohérent avec le reste de l'app. * Style glassmorphism cohérent avec le reste de l'app.
*/ */
@Suppress("UNUSED_PARAMETER")
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun FullPlayerSheet( fun FullPlayerSheet(

View File

@ -801,6 +801,7 @@ data class CollectionUiModel(
// Layout helper // Layout helper
@Composable @Composable
@Suppress("UNUSED_PARAMETER")
private fun FlowRow( private fun FlowRow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,

View File

@ -278,12 +278,12 @@ fun FeedScreen(
onNavigateToTags: () -> Unit = {}, onNavigateToTags: () -> Unit = {},
onNavigateToCollections: () -> Unit = {}, onNavigateToCollections: () -> Unit = {},
onNavigateToSettings: () -> Unit = {}, onNavigateToSettings: () -> Unit = {},
onNavigateToRandom: () -> Unit = {},
onNavigateToHelp: () -> Unit = {}, onNavigateToHelp: () -> Unit = {},
onNavigateToDeadLinks: () -> Unit = {}, onNavigateToDeadLinks: () -> Unit = {},
onNavigateToPinned: () -> Unit = {}, onNavigateToPinned: () -> Unit = {},
onNavigateToReader: (Int) -> Unit = {}, onNavigateToReader: (Int) -> Unit = {},
onNavigateToReminders: () -> Unit = {}, onNavigateToReminders: () -> Unit = {},
onNavigateToTodo: () -> Unit = {},
onPlayAudio: ((com.shaarit.domain.model.ShaarliLink) -> Unit)? = null, onPlayAudio: ((com.shaarit.domain.model.ShaarliLink) -> Unit)? = null,
initialTagFilter: String? = null, initialTagFilter: String? = null,
initialCollectionId: Long? = null, initialCollectionId: Long? = null,
@ -417,6 +417,15 @@ fun FeedScreen(
onNavigateToCollections() onNavigateToCollections()
} }
) )
DrawerNavigationItem(
icon = Icons.Default.CheckCircle,
label = "Mes Tâches",
onClick = {
scope.launch { drawerState.close() }
onNavigateToTodo()
}
)
DrawerNavigationItem( DrawerNavigationItem(
icon = Icons.Default.PushPin, icon = Icons.Default.PushPin,

View File

@ -48,6 +48,7 @@ sealed class Screen(val route: String) {
fun createRoute(linkId: Int): String = "reader/$linkId" fun createRoute(linkId: Int): String = "reader/$linkId"
} }
object Reminders : Screen("reminders") object Reminders : Screen("reminders")
object Todo : Screen("todo")
} }
@Composable @Composable
@ -154,7 +155,6 @@ fun AppNavGraph(
onNavigateToTags = { navController.navigate(Screen.Tags.route) }, onNavigateToTags = { navController.navigate(Screen.Tags.route) },
onNavigateToCollections = { navController.navigate(Screen.Collections.route) }, onNavigateToCollections = { navController.navigate(Screen.Collections.route) },
onNavigateToSettings = { navController.navigate(Screen.Settings.route) }, onNavigateToSettings = { navController.navigate(Screen.Settings.route) },
onNavigateToRandom = { },
onNavigateToHelp = { navController.navigate(Screen.Help.route) }, onNavigateToHelp = { navController.navigate(Screen.Help.route) },
onNavigateToDeadLinks = { navController.navigate(Screen.DeadLinks.route) }, onNavigateToDeadLinks = { navController.navigate(Screen.DeadLinks.route) },
onNavigateToPinned = { navController.navigate(Screen.Pinned.route) }, onNavigateToPinned = { navController.navigate(Screen.Pinned.route) },
@ -162,6 +162,7 @@ fun AppNavGraph(
navController.navigate(Screen.Reader.createRoute(linkId)) navController.navigate(Screen.Reader.createRoute(linkId))
}, },
onNavigateToReminders = { navController.navigate(Screen.Reminders.route) }, onNavigateToReminders = { navController.navigate(Screen.Reminders.route) },
onNavigateToTodo = { navController.navigate(Screen.Todo.route) },
onPlayAudio = onPlayAudio, onPlayAudio = onPlayAudio,
initialTagFilter = tag, initialTagFilter = tag,
initialCollectionId = collectionId 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() }
)
}
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,421 @@
package com.shaarit.presentation.todo
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shaarit.data.sync.SyncManager
import com.shaarit.data.worker.TodoNotificationScheduler
import com.shaarit.domain.model.SubTask
import com.shaarit.domain.model.TodoItem
import com.shaarit.domain.repository.GeminiRepository
import com.shaarit.domain.repository.TodoRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.util.UUID
import javax.inject.Inject
data class EditableBrainDumpTask(
val localId: String = UUID.randomUUID().toString(),
val title: String,
val dueDate: Long? = null,
val tags: List<String> = emptyList(),
val groupName: String? = null
)
data class BrainDumpDialogUiState(
val input: String = "",
val isAnalyzing: Boolean = false,
val isSaving: Boolean = false,
val parsedTasks: List<EditableBrainDumpTask> = emptyList(),
val errorMessage: String? = null,
val saveCompleted: Boolean = false,
val requestNotificationPermission: Boolean = false
)
data class EditTodoDialogUiState(
val isVisible: Boolean = false,
val todoId: Long = 0,
val content: String = "",
val dueDate: Long? = null,
val tags: List<String> = emptyList(),
val groupName: String = "",
val subtasks: List<SubTask> = emptyList(),
val newSubtaskText: String = "",
val isSaving: Boolean = false,
val errorMessage: String? = null,
val shaarliLinkUrl: String = "",
val isDone: Boolean = false
)
@HiltViewModel
class TodoViewModel @Inject constructor(
private val todoRepository: TodoRepository,
private val geminiRepository: GeminiRepository,
private val syncManager: SyncManager,
private val notificationScheduler: TodoNotificationScheduler
) : ViewModel() {
val todos: StateFlow<List<TodoItem>> =
todoRepository
.getTodosStream()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val groupNames: StateFlow<List<String>> =
todoRepository
.getGroupNamesStream()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _selectedGroup = MutableStateFlow<String?>(null)
val selectedGroup: StateFlow<String?> = _selectedGroup.asStateFlow()
private val _dialogState = MutableStateFlow(BrainDumpDialogUiState())
val dialogState: StateFlow<BrainDumpDialogUiState> = _dialogState.asStateFlow()
private val _editDialogState = MutableStateFlow(EditTodoDialogUiState())
val editDialogState: StateFlow<EditTodoDialogUiState> = _editDialogState.asStateFlow()
fun selectGroup(group: String?) {
_selectedGroup.value = group
}
// ====== Brain Dump Dialog ======
fun onBrainDumpInputChanged(value: String) {
_dialogState.update {
it.copy(input = value, errorMessage = null)
}
}
fun appendVoiceInput(transcript: String) {
val cleaned = transcript.trim()
if (cleaned.isBlank()) return
_dialogState.update {
val separator = if (it.input.isBlank()) "" else "\n"
it.copy(input = it.input + separator + cleaned)
}
}
fun analyzeBrainDump() {
val input = _dialogState.value.input.trim()
if (input.isBlank()) {
_dialogState.update {
it.copy(errorMessage = "Le brain dump ne peut pas être vide")
}
return
}
if (!geminiRepository.isApiKeyConfigured()) {
_dialogState.update {
it.copy(errorMessage = "Clé API Gemini non configurée dans les paramètres")
}
return
}
viewModelScope.launch {
_dialogState.update {
it.copy(isAnalyzing = true, errorMessage = null, parsedTasks = emptyList())
}
runCatching {
geminiRepository.analyzeBrainDump(input)
}.onSuccess { result ->
val parsedTasks = result.tasks.map { task ->
EditableBrainDumpTask(
title = task.title,
dueDate = task.dueDate,
tags = task.tags
)
}.ifEmpty {
listOf(
EditableBrainDumpTask(
title = input.take(80),
dueDate = null,
tags = emptyList()
)
)
}
_dialogState.update {
it.copy(
isAnalyzing = false,
parsedTasks = parsedTasks,
errorMessage = null
)
}
}.onFailure { error ->
_dialogState.update {
it.copy(
isAnalyzing = false,
errorMessage = error.message ?: "Erreur pendant l'analyse IA"
)
}
}
}
}
fun updateTaskTitle(taskId: String, title: String) {
_dialogState.update { state ->
state.copy(
parsedTasks = state.parsedTasks.map { task ->
if (task.localId == taskId) task.copy(title = title) else task
}
)
}
}
fun updateTaskDueDate(taskId: String, dueDate: Long?) {
_dialogState.update { state ->
state.copy(
parsedTasks = state.parsedTasks.map { task ->
if (task.localId == taskId) task.copy(dueDate = dueDate) else task
}
)
}
}
fun updateTaskGroup(taskId: String, groupName: String?) {
_dialogState.update { state ->
state.copy(
parsedTasks = state.parsedTasks.map { task ->
if (task.localId == taskId) task.copy(groupName = groupName) else task
}
)
}
}
fun removeTag(taskId: String, tag: String) {
_dialogState.update { state ->
state.copy(
parsedTasks = state.parsedTasks.map { task ->
if (task.localId == taskId) {
task.copy(tags = task.tags.filterNot { it.equals(tag, ignoreCase = true) })
} else {
task
}
}
)
}
}
fun saveParsedTasks() {
val tasksToSave = _dialogState.value.parsedTasks
.map { task ->
task.copy(
title = task.title.trim(),
tags = task.tags
.map { it.trim().trimStart('#').lowercase() }
.filter { it.isNotBlank() }
.distinct()
)
}
.filter { it.title.isNotBlank() }
if (tasksToSave.isEmpty()) {
_dialogState.update {
it.copy(errorMessage = "Aucune tâche valide à sauvegarder")
}
return
}
viewModelScope.launch {
_dialogState.update {
it.copy(isSaving = true, errorMessage = null)
}
var hasError = false
tasksToSave.forEach { task ->
val result = todoRepository.upsertTodo(
TodoItem(
shaarliLinkUrl = "",
content = task.title,
isDone = false,
dueDate = task.dueDate,
tags = task.tags,
isSynced = false,
groupName = task.groupName?.takeIf { it.isNotBlank() }
)
)
if (result.isFailure) {
hasError = true
}
}
syncManager.syncNow()
val shouldAskPermission = tasksToSave.any { it.dueDate != null } &&
notificationScheduler.requiresNotificationPermission()
_dialogState.update {
it.copy(
isSaving = false,
saveCompleted = !hasError,
errorMessage = if (hasError) "Certaines tâches n'ont pas pu être sauvegardées" else null,
requestNotificationPermission = shouldAskPermission
)
}
}
}
fun consumeSaveCompleted() {
_dialogState.update {
it.copy(
saveCompleted = false,
input = "",
parsedTasks = emptyList(),
errorMessage = null
)
}
}
fun clearDialogState() {
_dialogState.update {
BrainDumpDialogUiState()
}
}
fun onNotificationPermissionHandled() {
_dialogState.update {
it.copy(requestNotificationPermission = false)
}
}
// ====== Edit Todo Dialog ======
fun openEditDialog(todoId: Long) {
viewModelScope.launch {
val todo = todoRepository.getTodoById(todoId) ?: return@launch
_editDialogState.value = EditTodoDialogUiState(
isVisible = true,
todoId = todo.id,
content = todo.content,
dueDate = todo.dueDate,
tags = todo.tags,
groupName = todo.groupName ?: "",
subtasks = todo.subtasks,
shaarliLinkUrl = todo.shaarliLinkUrl,
isDone = todo.isDone
)
}
}
fun closeEditDialog() {
_editDialogState.value = EditTodoDialogUiState()
}
fun onEditContentChanged(value: String) {
_editDialogState.update { it.copy(content = value, errorMessage = null) }
}
fun onEditDueDateChanged(dueDate: Long?) {
_editDialogState.update { it.copy(dueDate = dueDate) }
}
fun onEditGroupChanged(groupName: String) {
_editDialogState.update { it.copy(groupName = groupName) }
}
fun onEditNewSubtaskTextChanged(text: String) {
_editDialogState.update { it.copy(newSubtaskText = text) }
}
fun addSubtask() {
val text = _editDialogState.value.newSubtaskText.trim()
if (text.isBlank()) return
_editDialogState.update {
it.copy(
subtasks = it.subtasks + SubTask(content = text),
newSubtaskText = ""
)
}
}
fun removeSubtask(index: Int) {
_editDialogState.update {
it.copy(subtasks = it.subtasks.toMutableList().apply { removeAt(index) })
}
}
fun toggleSubtask(index: Int) {
_editDialogState.update {
val mutable = it.subtasks.toMutableList()
val sub = mutable[index]
mutable[index] = sub.copy(isDone = !sub.isDone)
it.copy(subtasks = mutable)
}
}
fun updateSubtaskContent(index: Int, content: String) {
_editDialogState.update {
val mutable = it.subtasks.toMutableList()
mutable[index] = mutable[index].copy(content = content)
it.copy(subtasks = mutable)
}
}
fun saveEditedTodo() {
val state = _editDialogState.value
val content = state.content.trim()
if (content.isBlank()) {
_editDialogState.update { it.copy(errorMessage = "Le contenu ne peut pas être vide") }
return
}
viewModelScope.launch {
_editDialogState.update { it.copy(isSaving = true, errorMessage = null) }
val result = todoRepository.upsertTodo(
TodoItem(
id = state.todoId,
shaarliLinkUrl = state.shaarliLinkUrl,
content = content,
isDone = state.isDone,
dueDate = state.dueDate,
tags = state.tags,
isSynced = false,
groupName = state.groupName.takeIf { it.isNotBlank() },
subtasks = state.subtasks.filter { it.content.isNotBlank() }
)
)
if (result.isSuccess) {
syncManager.syncNow()
_editDialogState.value = EditTodoDialogUiState()
} else {
_editDialogState.update {
it.copy(
isSaving = false,
errorMessage = "Erreur lors de la sauvegarde"
)
}
}
}
}
// ====== Actions ======
fun toggleTodo(todoId: Long, isDone: Boolean) {
viewModelScope.launch {
todoRepository.toggleDone(todoId, isDone)
}
}
fun snoozeTodo(todoId: Long) {
viewModelScope.launch {
todoRepository.snoozeTodo(todoId)
}
}
fun deleteTodo(todoId: Long) {
viewModelScope.launch {
todoRepository.deleteTodo(todoId)
}
}
}

View File

@ -25,7 +25,16 @@ class AddLinkTileService : TileService() {
`package` = packageName `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() { override fun onStartListening() {

View File

@ -653,14 +653,17 @@ fun MarkdownPreview(
if (markdown.isBlank()) { if (markdown.isBlank()) {
Text( Text(
text = "Aucun contenu à prévisualiser...", text = "Aucun contenu à prévisualiser...",
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), style = MaterialTheme.typography.bodyMedium.copy(
style = MaterialTheme.typography.bodyMedium color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
) )
} else { } else {
MarkdownText( MarkdownText(
markdown = MarkdownUtils.preprocessMarkdown(markdown), 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( MarkdownText(
markdown = MarkdownUtils.preprocessMarkdown(markdown), 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
)
) )
} }
} }

View File

@ -117,4 +117,23 @@
<string name="reminder_snooze">Rappeler dans 1h</string> <string name="reminder_snooze">Rappeler dans 1h</string>
<string name="my_reminders">Mes rappels</string> <string name="my_reminders">Mes rappels</string>
<string name="no_reminders">Aucun rappel planifié</string> <string name="no_reminders">Aucun rappel planifié</string>
<!-- Todo / Brain Dump -->
<string name="todo_screen_title">Mes tâches</string>
<string name="todo_add_brain_dump">Brain Dump</string>
<string name="todo_active_section">À faire</string>
<string name="todo_done_section">Terminées</string>
<string name="todo_empty_state">Aucune tâche pour le moment</string>
<string name="todo_no_due_date">Sans échéance</string>
<string name="todo_channel_name">Rappels de tâches</string>
<string name="todo_channel_desc">Notifications Brain Dump pour vos tâches à échéance</string>
<string name="todo_notification_title">⏰ %1$s</string>
<string name="todo_notification_content">Échéance: %1$s</string>
<string name="todo_action_mark_done">Marquer comme fait</string>
<string name="todo_action_snooze">Reporter 1h</string>
<string name="todo_brain_dump_hint">Ex: Demain je dois appeler le médecin, finir le rapport, et penser au cadeau d\'anniversaire</string>
<string name="todo_analyze">Analyser</string>
<string name="todo_save">Sauvegarder</string>
<string name="todo_detected_tasks">%1$d tâche(s) détectée(s)</string>
<string name="todo_need_notification_permission">Autoriser les notifications pour recevoir les rappels de tâches.</string>
</resources> </resources>

View File

@ -1,3 +1,3 @@
#Thu Feb 12 20:32:53 2026 #Fri Feb 13 15:47:32 2026
VERSION_NAME=1.4.0 VERSION_NAME=2.1.5
VERSION_CODE=13 VERSION_CODE=20