feat: Add comprehensive todo management system with Gemini-powered brain dump analysis
- Add TodoEntity, TodoDao, TodoRepository, and TodoRepositoryImpl for local todo storage with sync support - Implement TodoViewModel with CRUD operations, group management, and subtask handling - Create TodoScreen with Kanban-style board view, group-based organization, and drag-to-reorder support - Add BrainDumpSheet for AI-powered task extraction from natural language input using Gemini API - Implement TodoNot
This commit is contained in:
parent
fc0fe3b30b
commit
bccd5ea2d4
@ -92,6 +92,14 @@ ksp {
|
|||||||
arg("room.schemaLocation", "$projectDir/schemas")
|
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)
|
||||||
|
|||||||
9
app/proguard-rules.pro
vendored
9
app/proguard-rules.pro
vendored
@ -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 * {
|
||||||
|
|||||||
@ -0,0 +1,700 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 7,
|
||||||
|
"identityHash": "0e82919fe46a8a52b16c457c3b43efd6",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "links",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `tags` TEXT NOT NULL, `is_private` INTEGER NOT NULL, `is_pinned` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `sync_status` TEXT NOT NULL, `local_modified_at` INTEGER NOT NULL, `thumbnail_url` TEXT, `reading_time_minutes` INTEGER, `content_type` TEXT NOT NULL, `site_name` TEXT, `excerpt` TEXT, `link_check_status` TEXT NOT NULL, `fail_count` INTEGER NOT NULL, `last_health_check` INTEGER NOT NULL, `excluded_from_health_check` INTEGER NOT NULL, `reader_content` TEXT, `reader_content_fetched_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "tags",
|
||||||
|
"columnName": "tags",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isPrivate",
|
||||||
|
"columnName": "is_private",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isPinned",
|
||||||
|
"columnName": "is_pinned",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "created_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updated_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "syncStatus",
|
||||||
|
"columnName": "sync_status",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "localModifiedAt",
|
||||||
|
"columnName": "local_modified_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "thumbnailUrl",
|
||||||
|
"columnName": "thumbnail_url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "readingTimeMinutes",
|
||||||
|
"columnName": "reading_time_minutes",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "contentType",
|
||||||
|
"columnName": "content_type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "siteName",
|
||||||
|
"columnName": "site_name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "excerpt",
|
||||||
|
"columnName": "excerpt",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "linkCheckStatus",
|
||||||
|
"columnName": "link_check_status",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "failCount",
|
||||||
|
"columnName": "fail_count",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastHealthCheck",
|
||||||
|
"columnName": "last_health_check",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "excludedFromHealthCheck",
|
||||||
|
"columnName": "excluded_from_health_check",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "readerContent",
|
||||||
|
"columnName": "reader_content",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "readerContentFetchedAt",
|
||||||
|
"columnName": "reader_content_fetched_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_links_sync_status",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sync_status"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_sync_status` ON `${TABLE_NAME}` (`sync_status`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_links_is_private",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"is_private"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_is_private` ON `${TABLE_NAME}` (`is_private`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_links_created_at",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"created_at"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_created_at` ON `${TABLE_NAME}` (`created_at`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_links_is_pinned",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"is_pinned"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_is_pinned` ON `${TABLE_NAME}` (`is_pinned`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_links_url",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_links_url` ON `${TABLE_NAME}` (`url`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_links_content_type",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"content_type"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_content_type` ON `${TABLE_NAME}` (`content_type`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_links_site_name",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"site_name"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_site_name` ON `${TABLE_NAME}` (`site_name`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_links_link_check_status",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"link_check_status"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_link_check_status` ON `${TABLE_NAME}` (`link_check_status`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_links_last_health_check",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"last_health_check"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_last_health_check` ON `${TABLE_NAME}` (`last_health_check`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ftsVersion": "FTS4",
|
||||||
|
"ftsOptions": {
|
||||||
|
"tokenizer": "simple",
|
||||||
|
"tokenizerArgs": [],
|
||||||
|
"contentTable": "links",
|
||||||
|
"languageIdColumnName": "",
|
||||||
|
"matchInfo": "FTS4",
|
||||||
|
"notIndexedColumns": [],
|
||||||
|
"prefixSizes": [],
|
||||||
|
"preferredOrder": "ASC"
|
||||||
|
},
|
||||||
|
"contentSyncTriggers": [
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_BEFORE_UPDATE BEFORE UPDATE ON `links` BEGIN DELETE FROM `links_fts` WHERE `docid`=OLD.`rowid`; END",
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_BEFORE_DELETE BEFORE DELETE ON `links` BEGIN DELETE FROM `links_fts` WHERE `docid`=OLD.`rowid`; END",
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_AFTER_UPDATE AFTER UPDATE ON `links` BEGIN INSERT INTO `links_fts`(`docid`, `url`, `title`, `description`, `tags`, `excerpt`) VALUES (NEW.`rowid`, NEW.`url`, NEW.`title`, NEW.`description`, NEW.`tags`, NEW.`excerpt`); END",
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_AFTER_INSERT AFTER INSERT ON `links` BEGIN INSERT INTO `links_fts`(`docid`, `url`, `title`, `description`, `tags`, `excerpt`) VALUES (NEW.`rowid`, NEW.`url`, NEW.`title`, NEW.`description`, NEW.`tags`, NEW.`excerpt`); END"
|
||||||
|
],
|
||||||
|
"tableName": "links_fts",
|
||||||
|
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`url` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `tags` TEXT NOT NULL, `excerpt` TEXT, content=`links`)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "tags",
|
||||||
|
"columnName": "tags",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "excerpt",
|
||||||
|
"columnName": "excerpt",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": []
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "tags",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `occurrences` INTEGER NOT NULL, `last_used_at` INTEGER NOT NULL, `color` INTEGER, `is_favorite` INTEGER NOT NULL, PRIMARY KEY(`name`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "occurrences",
|
||||||
|
"columnName": "occurrences",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastUsedAt",
|
||||||
|
"columnName": "last_used_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "color",
|
||||||
|
"columnName": "color",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isFavorite",
|
||||||
|
"columnName": "is_favorite",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"name"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_tags_name",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tags_name` ON `${TABLE_NAME}` (`name`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tags_occurrences",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"occurrences"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tags_occurrences` ON `${TABLE_NAME}` (`occurrences`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "link_tag_cross_ref",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`link_id` INTEGER NOT NULL, `tag_name` TEXT NOT NULL, PRIMARY KEY(`link_id`, `tag_name`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "linkId",
|
||||||
|
"columnName": "link_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "tagName",
|
||||||
|
"columnName": "tag_name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"link_id",
|
||||||
|
"tag_name"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "collections",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `icon` TEXT NOT NULL, `color` INTEGER, `is_smart` INTEGER NOT NULL, `query` TEXT, `sort_order` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "icon",
|
||||||
|
"columnName": "icon",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "color",
|
||||||
|
"columnName": "color",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isSmart",
|
||||||
|
"columnName": "is_smart",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "query",
|
||||||
|
"columnName": "query",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sortOrder",
|
||||||
|
"columnName": "sort_order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "created_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updated_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_collections_name",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_name` ON `${TABLE_NAME}` (`name`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_collections_is_smart",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"is_smart"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_is_smart` ON `${TABLE_NAME}` (`is_smart`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_collections_sort_order",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sort_order"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "collection_links",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`collection_id` INTEGER NOT NULL, `link_id` INTEGER NOT NULL, `added_at` INTEGER NOT NULL, PRIMARY KEY(`collection_id`, `link_id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "collectionId",
|
||||||
|
"columnName": "collection_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "linkId",
|
||||||
|
"columnName": "link_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "addedAt",
|
||||||
|
"columnName": "added_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"collection_id",
|
||||||
|
"link_id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "reading_reminders",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `link_id` INTEGER NOT NULL, `remind_at` INTEGER NOT NULL, `repeat_interval` TEXT NOT NULL, `is_dismissed` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, FOREIGN KEY(`link_id`) REFERENCES `links`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "linkId",
|
||||||
|
"columnName": "link_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "remindAt",
|
||||||
|
"columnName": "remind_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "repeatInterval",
|
||||||
|
"columnName": "repeat_interval",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDismissed",
|
||||||
|
"columnName": "is_dismissed",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "created_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_reading_reminders_link_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"link_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_reading_reminders_link_id` ON `${TABLE_NAME}` (`link_id`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_reading_reminders_remind_at",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"remind_at"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_reading_reminders_remind_at` ON `${TABLE_NAME}` (`remind_at`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_reading_reminders_is_dismissed",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"is_dismissed"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_reading_reminders_is_dismissed` ON `${TABLE_NAME}` (`is_dismissed`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "links",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"link_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "todos",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `shaarli_link_url` TEXT NOT NULL, `content` TEXT NOT NULL, `is_done` INTEGER NOT NULL, `due_date` INTEGER, `tags` TEXT NOT NULL, `is_synced` INTEGER NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "shaarliLinkUrl",
|
||||||
|
"columnName": "shaarli_link_url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "content",
|
||||||
|
"columnName": "content",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDone",
|
||||||
|
"columnName": "is_done",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "dueDate",
|
||||||
|
"columnName": "due_date",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "tags",
|
||||||
|
"columnName": "tags",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isSynced",
|
||||||
|
"columnName": "is_synced",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_todos_shaarli_link_url",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"shaarli_link_url"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_todos_shaarli_link_url` ON `${TABLE_NAME}` (`shaarli_link_url`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_todos_due_date",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"due_date"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_todos_due_date` ON `${TABLE_NAME}` (`due_date`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_todos_is_done",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"is_done"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_todos_is_done` ON `${TABLE_NAME}` (`is_done`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"views": [],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0e82919fe46a8a52b16c457c3b43efd6')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,721 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 8,
|
||||||
|
"identityHash": "26e14aba256b6f9d1e3a53e14a132c7e",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "links",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `tags` TEXT NOT NULL, `is_private` INTEGER NOT NULL, `is_pinned` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `sync_status` TEXT NOT NULL, `local_modified_at` INTEGER NOT NULL, `thumbnail_url` TEXT, `reading_time_minutes` INTEGER, `content_type` TEXT NOT NULL, `site_name` TEXT, `excerpt` TEXT, `link_check_status` TEXT NOT NULL, `fail_count` INTEGER NOT NULL, `last_health_check` INTEGER NOT NULL, `excluded_from_health_check` INTEGER NOT NULL, `reader_content` TEXT, `reader_content_fetched_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "tags",
|
||||||
|
"columnName": "tags",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isPrivate",
|
||||||
|
"columnName": "is_private",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isPinned",
|
||||||
|
"columnName": "is_pinned",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "created_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updated_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "syncStatus",
|
||||||
|
"columnName": "sync_status",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "localModifiedAt",
|
||||||
|
"columnName": "local_modified_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "thumbnailUrl",
|
||||||
|
"columnName": "thumbnail_url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "readingTimeMinutes",
|
||||||
|
"columnName": "reading_time_minutes",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "contentType",
|
||||||
|
"columnName": "content_type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "siteName",
|
||||||
|
"columnName": "site_name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "excerpt",
|
||||||
|
"columnName": "excerpt",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "linkCheckStatus",
|
||||||
|
"columnName": "link_check_status",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "failCount",
|
||||||
|
"columnName": "fail_count",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastHealthCheck",
|
||||||
|
"columnName": "last_health_check",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "excludedFromHealthCheck",
|
||||||
|
"columnName": "excluded_from_health_check",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "readerContent",
|
||||||
|
"columnName": "reader_content",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "readerContentFetchedAt",
|
||||||
|
"columnName": "reader_content_fetched_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_links_sync_status",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sync_status"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_sync_status` ON `${TABLE_NAME}` (`sync_status`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_links_is_private",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"is_private"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_is_private` ON `${TABLE_NAME}` (`is_private`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_links_created_at",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"created_at"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_created_at` ON `${TABLE_NAME}` (`created_at`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_links_is_pinned",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"is_pinned"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_is_pinned` ON `${TABLE_NAME}` (`is_pinned`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_links_url",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_links_url` ON `${TABLE_NAME}` (`url`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_links_content_type",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"content_type"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_content_type` ON `${TABLE_NAME}` (`content_type`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_links_site_name",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"site_name"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_site_name` ON `${TABLE_NAME}` (`site_name`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_links_link_check_status",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"link_check_status"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_link_check_status` ON `${TABLE_NAME}` (`link_check_status`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_links_last_health_check",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"last_health_check"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_last_health_check` ON `${TABLE_NAME}` (`last_health_check`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ftsVersion": "FTS4",
|
||||||
|
"ftsOptions": {
|
||||||
|
"tokenizer": "simple",
|
||||||
|
"tokenizerArgs": [],
|
||||||
|
"contentTable": "links",
|
||||||
|
"languageIdColumnName": "",
|
||||||
|
"matchInfo": "FTS4",
|
||||||
|
"notIndexedColumns": [],
|
||||||
|
"prefixSizes": [],
|
||||||
|
"preferredOrder": "ASC"
|
||||||
|
},
|
||||||
|
"contentSyncTriggers": [
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_BEFORE_UPDATE BEFORE UPDATE ON `links` BEGIN DELETE FROM `links_fts` WHERE `docid`=OLD.`rowid`; END",
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_BEFORE_DELETE BEFORE DELETE ON `links` BEGIN DELETE FROM `links_fts` WHERE `docid`=OLD.`rowid`; END",
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_AFTER_UPDATE AFTER UPDATE ON `links` BEGIN INSERT INTO `links_fts`(`docid`, `url`, `title`, `description`, `tags`, `excerpt`) VALUES (NEW.`rowid`, NEW.`url`, NEW.`title`, NEW.`description`, NEW.`tags`, NEW.`excerpt`); END",
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_AFTER_INSERT AFTER INSERT ON `links` BEGIN INSERT INTO `links_fts`(`docid`, `url`, `title`, `description`, `tags`, `excerpt`) VALUES (NEW.`rowid`, NEW.`url`, NEW.`title`, NEW.`description`, NEW.`tags`, NEW.`excerpt`); END"
|
||||||
|
],
|
||||||
|
"tableName": "links_fts",
|
||||||
|
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`url` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `tags` TEXT NOT NULL, `excerpt` TEXT, content=`links`)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "tags",
|
||||||
|
"columnName": "tags",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "excerpt",
|
||||||
|
"columnName": "excerpt",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": []
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "tags",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `occurrences` INTEGER NOT NULL, `last_used_at` INTEGER NOT NULL, `color` INTEGER, `is_favorite` INTEGER NOT NULL, PRIMARY KEY(`name`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "occurrences",
|
||||||
|
"columnName": "occurrences",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastUsedAt",
|
||||||
|
"columnName": "last_used_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "color",
|
||||||
|
"columnName": "color",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isFavorite",
|
||||||
|
"columnName": "is_favorite",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"name"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_tags_name",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tags_name` ON `${TABLE_NAME}` (`name`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_tags_occurrences",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"occurrences"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_tags_occurrences` ON `${TABLE_NAME}` (`occurrences`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "link_tag_cross_ref",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`link_id` INTEGER NOT NULL, `tag_name` TEXT NOT NULL, PRIMARY KEY(`link_id`, `tag_name`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "linkId",
|
||||||
|
"columnName": "link_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "tagName",
|
||||||
|
"columnName": "tag_name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"link_id",
|
||||||
|
"tag_name"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "collections",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `icon` TEXT NOT NULL, `color` INTEGER, `is_smart` INTEGER NOT NULL, `query` TEXT, `sort_order` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "icon",
|
||||||
|
"columnName": "icon",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "color",
|
||||||
|
"columnName": "color",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isSmart",
|
||||||
|
"columnName": "is_smart",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "query",
|
||||||
|
"columnName": "query",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sortOrder",
|
||||||
|
"columnName": "sort_order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "created_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updated_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_collections_name",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_name` ON `${TABLE_NAME}` (`name`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_collections_is_smart",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"is_smart"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_is_smart` ON `${TABLE_NAME}` (`is_smart`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_collections_sort_order",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sort_order"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "collection_links",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`collection_id` INTEGER NOT NULL, `link_id` INTEGER NOT NULL, `added_at` INTEGER NOT NULL, PRIMARY KEY(`collection_id`, `link_id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "collectionId",
|
||||||
|
"columnName": "collection_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "linkId",
|
||||||
|
"columnName": "link_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "addedAt",
|
||||||
|
"columnName": "added_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"collection_id",
|
||||||
|
"link_id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "reading_reminders",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `link_id` INTEGER NOT NULL, `remind_at` INTEGER NOT NULL, `repeat_interval` TEXT NOT NULL, `is_dismissed` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, FOREIGN KEY(`link_id`) REFERENCES `links`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "linkId",
|
||||||
|
"columnName": "link_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "remindAt",
|
||||||
|
"columnName": "remind_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "repeatInterval",
|
||||||
|
"columnName": "repeat_interval",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDismissed",
|
||||||
|
"columnName": "is_dismissed",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "created_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_reading_reminders_link_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"link_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_reading_reminders_link_id` ON `${TABLE_NAME}` (`link_id`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_reading_reminders_remind_at",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"remind_at"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_reading_reminders_remind_at` ON `${TABLE_NAME}` (`remind_at`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_reading_reminders_is_dismissed",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"is_dismissed"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_reading_reminders_is_dismissed` ON `${TABLE_NAME}` (`is_dismissed`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "links",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"link_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "todos",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `shaarli_link_url` TEXT NOT NULL, `content` TEXT NOT NULL, `is_done` INTEGER NOT NULL, `due_date` INTEGER, `tags` TEXT NOT NULL, `is_synced` INTEGER NOT NULL, `group_name` TEXT, `subtasks` TEXT NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "shaarliLinkUrl",
|
||||||
|
"columnName": "shaarli_link_url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "content",
|
||||||
|
"columnName": "content",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDone",
|
||||||
|
"columnName": "is_done",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "dueDate",
|
||||||
|
"columnName": "due_date",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "tags",
|
||||||
|
"columnName": "tags",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isSynced",
|
||||||
|
"columnName": "is_synced",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "groupName",
|
||||||
|
"columnName": "group_name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subtasks",
|
||||||
|
"columnName": "subtasks",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_todos_shaarli_link_url",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"shaarli_link_url"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_todos_shaarli_link_url` ON `${TABLE_NAME}` (`shaarli_link_url`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_todos_due_date",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"due_date"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_todos_due_date` ON `${TABLE_NAME}` (`due_date`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_todos_is_done",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"is_done"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_todos_is_done` ON `${TABLE_NAME}` (`is_done`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_todos_group_name",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"group_name"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_todos_group_name` ON `${TABLE_NAME}` (`group_name`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"views": [],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '26e14aba256b6f9d1e3a53e14a132c7e')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -116,6 +116,10 @@
|
|||||||
android:exported="false"
|
android: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"
|
||||||
|
|||||||
@ -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)) {
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
""")
|
""")
|
||||||
|
|||||||
81
app/src/main/java/com/shaarit/data/local/dao/TodoDao.kt
Normal file
81
app/src/main/java/com/shaarit/data/local/dao/TodoDao.kt
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package com.shaarit.data.local.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Delete
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Update
|
||||||
|
import com.shaarit.data.local.entity.TodoEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface TodoDao {
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM todos
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN is_done = 1 THEN 1 ELSE 0 END ASC,
|
||||||
|
CASE WHEN due_date IS NULL THEN 1 ELSE 0 END ASC,
|
||||||
|
due_date ASC,
|
||||||
|
id DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun getTodosStream(): Flow<List<TodoEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM todos WHERE id = :id LIMIT 1")
|
||||||
|
suspend fun getTodoById(id: Long): TodoEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM todos WHERE shaarli_link_url = :shaarliLinkUrl LIMIT 1")
|
||||||
|
suspend fun getTodoByShaarliLinkUrl(shaarliLinkUrl: String): TodoEntity?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertTodo(todo: TodoEntity): Long
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertTodos(todos: List<TodoEntity>)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateTodo(todo: TodoEntity)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteTodo(todo: TodoEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM todos WHERE id = :id")
|
||||||
|
suspend fun deleteTodoById(id: Long)
|
||||||
|
|
||||||
|
@Query("UPDATE todos SET is_done = :isDone, is_synced = :isSynced WHERE id = :id")
|
||||||
|
suspend fun updateDoneStatus(id: Long, isDone: Boolean, isSynced: Boolean = false)
|
||||||
|
|
||||||
|
@Query("UPDATE todos SET due_date = :dueDate, is_synced = :isSynced WHERE id = :id")
|
||||||
|
suspend fun updateDueDate(id: Long, dueDate: Long?, isSynced: Boolean = false)
|
||||||
|
|
||||||
|
@Query("UPDATE todos SET is_synced = :isSynced WHERE id = :id")
|
||||||
|
suspend fun updateSyncedStatus(id: Long, isSynced: Boolean)
|
||||||
|
|
||||||
|
@Query("UPDATE todos SET is_synced = :isSynced WHERE shaarli_link_url = :url")
|
||||||
|
suspend fun updateSyncedStatusByUrl(url: String, isSynced: Boolean)
|
||||||
|
|
||||||
|
@Query("UPDATE todos SET shaarli_link_url = :newUrl, is_synced = 0 WHERE shaarli_link_url = :oldUrl")
|
||||||
|
suspend fun updateShaarliLinkUrl(oldUrl: String, newUrl: String)
|
||||||
|
|
||||||
|
@Query("SELECT DISTINCT group_name FROM todos WHERE group_name IS NOT NULL ORDER BY group_name ASC")
|
||||||
|
fun getGroupNamesStream(): Flow<List<String>>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM todos
|
||||||
|
WHERE group_name = :groupName
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN is_done = 1 THEN 1 ELSE 0 END ASC,
|
||||||
|
CASE WHEN due_date IS NULL THEN 1 ELSE 0 END ASC,
|
||||||
|
due_date ASC,
|
||||||
|
id DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun getTodosByGroupStream(groupName: String): Flow<List<TodoEntity>>
|
||||||
|
|
||||||
|
@Query("DELETE FROM todos")
|
||||||
|
suspend fun clearAll()
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ import com.shaarit.data.local.dao.CollectionDao
|
|||||||
import com.shaarit.data.local.dao.LinkDao
|
import com.shaarit.data.local.dao.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"
|
||||||
@ -92,6 +96,41 @@ abstract class ShaarliDatabase : RoomDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration v6 → v7 : Ajout de la table todos
|
||||||
|
*/
|
||||||
|
val MIGRATION_6_7 = object : Migration(6, 7) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `todos` (
|
||||||
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`shaarli_link_url` TEXT NOT NULL,
|
||||||
|
`content` TEXT NOT NULL,
|
||||||
|
`is_done` INTEGER NOT NULL,
|
||||||
|
`due_date` INTEGER,
|
||||||
|
`tags` TEXT NOT NULL,
|
||||||
|
`is_synced` INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_todos_shaarli_link_url` ON `todos` (`shaarli_link_url`)")
|
||||||
|
db.execSQL("CREATE INDEX IF NOT EXISTS `index_todos_due_date` ON `todos` (`due_date`)")
|
||||||
|
db.execSQL("CREATE INDEX IF NOT EXISTS `index_todos_is_done` ON `todos` (`is_done`)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration v7 → v8 : Ajout des groupes et sous-tâches
|
||||||
|
*/
|
||||||
|
val MIGRATION_7_8 = object : Migration(7, 8) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE `todos` ADD COLUMN `group_name` TEXT DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE `todos` ADD COLUMN `subtasks` TEXT NOT NULL DEFAULT '[]'")
|
||||||
|
db.execSQL("CREATE INDEX IF NOT EXISTS `index_todos_group_name` ON `todos` (`group_name`)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Volatile
|
@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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,45 @@
|
|||||||
|
package com.shaarit.data.local.entity
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import com.shaarit.domain.model.SubTask
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "todos",
|
||||||
|
indices = [
|
||||||
|
Index(value = ["shaarli_link_url"], unique = true),
|
||||||
|
Index(value = ["due_date"]),
|
||||||
|
Index(value = ["is_done"]),
|
||||||
|
Index(value = ["group_name"])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class TodoEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
val id: Long = 0,
|
||||||
|
|
||||||
|
@ColumnInfo(name = "shaarli_link_url")
|
||||||
|
val shaarliLinkUrl: String,
|
||||||
|
|
||||||
|
@ColumnInfo(name = "content")
|
||||||
|
val content: String,
|
||||||
|
|
||||||
|
@ColumnInfo(name = "is_done")
|
||||||
|
val isDone: Boolean = false,
|
||||||
|
|
||||||
|
@ColumnInfo(name = "due_date")
|
||||||
|
val dueDate: Long? = null,
|
||||||
|
|
||||||
|
@ColumnInfo(name = "tags")
|
||||||
|
val tags: List<String> = emptyList(),
|
||||||
|
|
||||||
|
@ColumnInfo(name = "is_synced")
|
||||||
|
val isSynced: Boolean = false,
|
||||||
|
|
||||||
|
@ColumnInfo(name = "group_name")
|
||||||
|
val groupName: String? = null,
|
||||||
|
|
||||||
|
@ColumnInfo(name = "subtasks")
|
||||||
|
val subtasks: List<SubTask> = emptyList()
|
||||||
|
)
|
||||||
185
app/src/main/java/com/shaarit/data/mapper/TodoMarkdownMapper.kt
Normal file
185
app/src/main/java/com/shaarit/data/mapper/TodoMarkdownMapper.kt
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
package com.shaarit.data.mapper
|
||||||
|
|
||||||
|
import com.shaarit.data.local.entity.LinkEntity
|
||||||
|
import com.shaarit.data.local.entity.TodoEntity
|
||||||
|
import com.shaarit.domain.model.SubTask
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
data class TodoShaarliPayload(
|
||||||
|
val url: String,
|
||||||
|
val title: String,
|
||||||
|
val markdownBody: String,
|
||||||
|
val tags: List<String>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ParsedTodoFromShaarli(
|
||||||
|
val shaarliLinkUrl: String,
|
||||||
|
val content: String,
|
||||||
|
val isDone: Boolean,
|
||||||
|
val dueDate: Long?,
|
||||||
|
val tags: List<String>,
|
||||||
|
val groupName: String? = null,
|
||||||
|
val subtasks: List<SubTask> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class TodoMarkdownMapper @Inject constructor() {
|
||||||
|
|
||||||
|
private val hashTagRegex = Regex("#([a-zA-Z0-9_-]+)")
|
||||||
|
private val checkboxRegex = Regex("-\\s*\\[( |x|X)]\\s*(.+)")
|
||||||
|
private val subtaskRegex = Regex("^\\s{2,}-\\s*\\[( |x|X)]\\s*(.+)")
|
||||||
|
private val dueDateRegex = Regex("📅\\s*\\*\\*Échéance\\s*:\\*\\*\\s*([^\\n\\r]+)", RegexOption.IGNORE_CASE)
|
||||||
|
private val groupRegex = Regex("🏷️\\s*\\*\\*Groupe\\s*:\\*\\*\\s*([^\\n\\r]+)", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
fun toShaarliPayload(todo: TodoEntity): TodoShaarliPayload {
|
||||||
|
val normalizedTags = normalizeTags(todo.tags)
|
||||||
|
val fullTags = (listOf("todo") + normalizedTags).distinct()
|
||||||
|
|
||||||
|
val dueDateLine = todo.dueDate
|
||||||
|
?.let { Instant.ofEpochMilli(it).toString() }
|
||||||
|
?: "Aucune"
|
||||||
|
|
||||||
|
val checkbox = if (todo.isDone) "x" else " "
|
||||||
|
val content = todo.content.trim().ifBlank { "Tâche sans titre" }
|
||||||
|
val hashtagSection = if (normalizedTags.isEmpty()) {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
" " + normalizedTags.joinToString(" ") { "#$it" }
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = buildString {
|
||||||
|
append("📅 **Échéance :** ")
|
||||||
|
append(dueDateLine)
|
||||||
|
|
||||||
|
if (!todo.groupName.isNullOrBlank()) {
|
||||||
|
append("\n🏷️ **Groupe :** ")
|
||||||
|
append(todo.groupName.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
append("\n\n")
|
||||||
|
append("- [")
|
||||||
|
append(checkbox)
|
||||||
|
append("] ")
|
||||||
|
append(content)
|
||||||
|
append(hashtagSection)
|
||||||
|
|
||||||
|
todo.subtasks.forEach { sub ->
|
||||||
|
val subCheck = if (sub.isDone) "x" else " "
|
||||||
|
append("\n - [")
|
||||||
|
append(subCheck)
|
||||||
|
append("] ")
|
||||||
|
append(sub.content.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val finalUrl = todo.shaarliLinkUrl.ifBlank {
|
||||||
|
"note://todo-${UUID.randomUUID()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
return TodoShaarliPayload(
|
||||||
|
url = finalUrl,
|
||||||
|
title = buildTitle(content),
|
||||||
|
markdownBody = body,
|
||||||
|
tags = fullTags
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fromShaarliLink(link: LinkEntity): ParsedTodoFromShaarli? {
|
||||||
|
if (!isTodoLink(link.url, link.tags)) return null
|
||||||
|
|
||||||
|
val markdown = link.description
|
||||||
|
val lines = markdown.lines()
|
||||||
|
|
||||||
|
// Parse main checkbox (non-indented)
|
||||||
|
val mainCheckboxLine = lines.firstOrNull { checkboxRegex.matches(it.trim()) }
|
||||||
|
val checkboxMatch = mainCheckboxLine?.let { checkboxRegex.find(it.trim()) }
|
||||||
|
|
||||||
|
val lineContent = checkboxMatch?.groupValues?.getOrNull(2)?.trim().orEmpty()
|
||||||
|
val inlineTags = hashTagRegex.findAll(lineContent)
|
||||||
|
.map { it.groupValues[1].lowercase(Locale.ROOT) }
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
val cleanContent = lineContent
|
||||||
|
.replace(hashTagRegex, "")
|
||||||
|
.trim()
|
||||||
|
.ifBlank { link.title.ifBlank { "Tâche" } }
|
||||||
|
|
||||||
|
val isDone = checkboxMatch?.groupValues?.getOrNull(1)?.equals("x", ignoreCase = true) == true
|
||||||
|
|
||||||
|
val dueDateRaw = dueDateRegex.find(markdown)?.groupValues?.getOrNull(1)?.trim()
|
||||||
|
val parsedDueDate = parseDueDate(dueDateRaw)
|
||||||
|
|
||||||
|
val groupRaw = groupRegex.find(markdown)?.groupValues?.getOrNull(1)?.trim()
|
||||||
|
val groupName = groupRaw?.takeIf { it.isNotBlank() && !it.equals("aucun", ignoreCase = true) }
|
||||||
|
|
||||||
|
// Parse subtasks (indented checkboxes)
|
||||||
|
val subtasks = lines.mapNotNull { line ->
|
||||||
|
val match = subtaskRegex.find(line) ?: return@mapNotNull null
|
||||||
|
val subDone = match.groupValues[1].equals("x", ignoreCase = true)
|
||||||
|
val subContent = match.groupValues[2]
|
||||||
|
.replace(hashTagRegex, "")
|
||||||
|
.trim()
|
||||||
|
if (subContent.isNotBlank()) SubTask(subContent, subDone) else null
|
||||||
|
}
|
||||||
|
|
||||||
|
val mergedTags = (
|
||||||
|
link.tags.filterNot { it.equals("todo", true) || it.equals("brain-dump", true) } + inlineTags
|
||||||
|
).map { it.lowercase(Locale.ROOT) }
|
||||||
|
.distinct()
|
||||||
|
|
||||||
|
return ParsedTodoFromShaarli(
|
||||||
|
shaarliLinkUrl = link.url,
|
||||||
|
content = cleanContent,
|
||||||
|
isDone = isDone,
|
||||||
|
dueDate = parsedDueDate,
|
||||||
|
tags = mergedTags,
|
||||||
|
groupName = groupName,
|
||||||
|
subtasks = subtasks
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isTodoLink(url: String, tags: List<String>): Boolean {
|
||||||
|
return url.startsWith("note://todo-", ignoreCase = true) ||
|
||||||
|
tags.any { it.equals("todo", ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildTitle(content: String): String {
|
||||||
|
return content
|
||||||
|
.lineSequence()
|
||||||
|
.firstOrNull { it.isNotBlank() }
|
||||||
|
?.take(80)
|
||||||
|
?.trim()
|
||||||
|
?.ifBlank { "Tâche" }
|
||||||
|
?: "Tâche"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeTags(tags: List<String>): List<String> {
|
||||||
|
return tags
|
||||||
|
.map { it.trim().trimStart('#').lowercase(Locale.ROOT) }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDueDate(raw: String?): Long? {
|
||||||
|
if (raw.isNullOrBlank()) return null
|
||||||
|
if (raw.equals("aucune", ignoreCase = true) || raw == "-") return null
|
||||||
|
|
||||||
|
return try {
|
||||||
|
Instant.parse(raw).toEpochMilli()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
try {
|
||||||
|
val date = LocalDate.parse(raw, DateTimeFormatter.ISO_DATE)
|
||||||
|
date.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -224,7 +224,7 @@ class LinkMetadataExtractor @Inject constructor() {
|
|||||||
* Estime le temps de lecture
|
* 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()
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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) {
|
|
||||||
response.body()?.let { serverLink ->
|
|
||||||
serverLink.toEntity()?.let { entity ->
|
serverLink.toEntity()?.let { entity ->
|
||||||
linkDao.insertLink(entity)
|
linkDao.insertLink(entity)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
AddLinkResult.Success
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: HttpException) {
|
} catch (e: HttpException) {
|
||||||
|
|||||||
@ -0,0 +1,218 @@
|
|||||||
|
package com.shaarit.data.repository
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.shaarit.data.local.dao.LinkDao
|
||||||
|
import com.shaarit.data.local.dao.TodoDao
|
||||||
|
import com.shaarit.data.local.entity.LinkEntity
|
||||||
|
import com.shaarit.data.local.entity.SyncStatus
|
||||||
|
import com.shaarit.data.local.entity.TodoEntity
|
||||||
|
import com.shaarit.data.mapper.TodoMarkdownMapper
|
||||||
|
import com.shaarit.data.sync.SyncManager
|
||||||
|
import com.shaarit.data.worker.TodoNotificationScheduler
|
||||||
|
import com.shaarit.domain.model.TodoItem
|
||||||
|
import com.shaarit.domain.repository.TodoRepository
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class TodoRepositoryImpl @Inject constructor(
|
||||||
|
private val todoDao: TodoDao,
|
||||||
|
private val linkDao: LinkDao,
|
||||||
|
private val syncManager: SyncManager,
|
||||||
|
private val todoMarkdownMapper: TodoMarkdownMapper,
|
||||||
|
private val notificationScheduler: TodoNotificationScheduler
|
||||||
|
) : TodoRepository {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "TodoRepository"
|
||||||
|
private const val TODO_URL_PREFIX = "https://shaarit.app/todo/"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getTodosStream(): Flow<List<TodoItem>> {
|
||||||
|
return todoDao.getTodosStream().map { todos ->
|
||||||
|
todos.map { it.toDomain() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getGroupNamesStream(): Flow<List<String>> {
|
||||||
|
return todoDao.getGroupNamesStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTodoById(id: Long): TodoItem? {
|
||||||
|
return todoDao.getTodoById(id)?.toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun upsertTodo(todo: TodoItem): Result<Long> {
|
||||||
|
return runCatching {
|
||||||
|
val normalized = normalizeTodo(todo)
|
||||||
|
val savedId = if (normalized.id == 0L) {
|
||||||
|
todoDao.insertTodo(normalized)
|
||||||
|
} else {
|
||||||
|
todoDao.updateTodo(normalized)
|
||||||
|
normalized.id
|
||||||
|
}
|
||||||
|
|
||||||
|
val persisted = normalized.copy(id = savedId)
|
||||||
|
scheduleIfNeeded(persisted)
|
||||||
|
upsertShaarliLinkFromTodo(persisted)
|
||||||
|
|
||||||
|
savedId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun toggleDone(todoId: Long, isDone: Boolean): Result<Unit> {
|
||||||
|
return runCatching {
|
||||||
|
val existing = todoDao.getTodoById(todoId)
|
||||||
|
?: throw IllegalArgumentException("Todo not found")
|
||||||
|
|
||||||
|
val updated = existing.copy(
|
||||||
|
isDone = isDone,
|
||||||
|
isSynced = false
|
||||||
|
)
|
||||||
|
todoDao.updateTodo(updated)
|
||||||
|
|
||||||
|
scheduleIfNeeded(updated)
|
||||||
|
upsertShaarliLinkFromTodo(updated)
|
||||||
|
syncManager.syncNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun snoozeTodo(todoId: Long, delayMs: Long): Result<Unit> {
|
||||||
|
return runCatching {
|
||||||
|
val existing = todoDao.getTodoById(todoId)
|
||||||
|
?: throw IllegalArgumentException("Todo not found")
|
||||||
|
|
||||||
|
val updated = existing.copy(
|
||||||
|
isDone = false,
|
||||||
|
dueDate = System.currentTimeMillis() + delayMs,
|
||||||
|
isSynced = false
|
||||||
|
)
|
||||||
|
todoDao.updateTodo(updated)
|
||||||
|
|
||||||
|
scheduleIfNeeded(updated)
|
||||||
|
upsertShaarliLinkFromTodo(updated)
|
||||||
|
syncManager.syncNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteTodo(todoId: Long): Result<Unit> {
|
||||||
|
return runCatching {
|
||||||
|
val existing = todoDao.getTodoById(todoId)
|
||||||
|
?: throw IllegalArgumentException("Todo not found")
|
||||||
|
|
||||||
|
notificationScheduler.cancel(todoId)
|
||||||
|
todoDao.deleteTodo(existing)
|
||||||
|
|
||||||
|
val linked = linkDao.getLinkByUrl(existing.shaarliLinkUrl)
|
||||||
|
if (linked != null) {
|
||||||
|
if (linked.syncStatus == SyncStatus.PENDING_CREATE) {
|
||||||
|
linkDao.deleteLink(linked.id)
|
||||||
|
} else {
|
||||||
|
linkDao.markForDeletion(linked.id)
|
||||||
|
syncManager.syncNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun upsertShaarliLinkFromTodo(todo: TodoEntity) {
|
||||||
|
val payload = todoMarkdownMapper.toShaarliPayload(todo)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
|
val existingLink = linkDao.getLinkByUrl(payload.url)
|
||||||
|
if (existingLink != null) {
|
||||||
|
val updated = existingLink.copy(
|
||||||
|
url = payload.url,
|
||||||
|
title = payload.title,
|
||||||
|
description = payload.markdownBody,
|
||||||
|
tags = payload.tags,
|
||||||
|
isPrivate = true,
|
||||||
|
updatedAt = now,
|
||||||
|
syncStatus = if (existingLink.syncStatus == SyncStatus.PENDING_CREATE) {
|
||||||
|
SyncStatus.PENDING_CREATE
|
||||||
|
} else {
|
||||||
|
SyncStatus.PENDING_UPDATE
|
||||||
|
},
|
||||||
|
localModifiedAt = now
|
||||||
|
)
|
||||||
|
linkDao.updateLink(updated)
|
||||||
|
Log.d(TAG, "Updated mirror link for todo ${todo.id}: url=${payload.url}")
|
||||||
|
} else {
|
||||||
|
val newLink = LinkEntity(
|
||||||
|
id = generateTempId(),
|
||||||
|
url = payload.url,
|
||||||
|
title = payload.title,
|
||||||
|
description = payload.markdownBody,
|
||||||
|
tags = payload.tags,
|
||||||
|
isPrivate = true,
|
||||||
|
createdAt = now,
|
||||||
|
updatedAt = now,
|
||||||
|
syncStatus = SyncStatus.PENDING_CREATE,
|
||||||
|
localModifiedAt = now
|
||||||
|
)
|
||||||
|
linkDao.insertLink(newLink)
|
||||||
|
Log.d(TAG, "Created mirror link for todo ${todo.id}: id=${newLink.id}, url=${payload.url}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeTodo(todo: TodoItem): TodoEntity {
|
||||||
|
val cleanedContent = todo.content.trim().ifBlank { "Tâche" }
|
||||||
|
val cleanedTags = todo.tags
|
||||||
|
.map { it.trim().trimStart('#').lowercase(Locale.ROOT) }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
|
||||||
|
val url = todo.shaarliLinkUrl.ifBlank {
|
||||||
|
"$TODO_URL_PREFIX${UUID.randomUUID()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
return TodoEntity(
|
||||||
|
id = todo.id,
|
||||||
|
shaarliLinkUrl = url,
|
||||||
|
content = cleanedContent,
|
||||||
|
isDone = todo.isDone,
|
||||||
|
dueDate = todo.dueDate,
|
||||||
|
tags = cleanedTags,
|
||||||
|
isSynced = false,
|
||||||
|
groupName = todo.groupName?.trim()?.takeIf { it.isNotBlank() },
|
||||||
|
subtasks = todo.subtasks
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleIfNeeded(todo: TodoEntity) {
|
||||||
|
if (todo.id == 0L) return
|
||||||
|
|
||||||
|
val dueDate = todo.dueDate
|
||||||
|
if (todo.isDone || dueDate == null || dueDate <= System.currentTimeMillis()) {
|
||||||
|
notificationScheduler.cancel(todo.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationScheduler.schedule(todo)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TodoEntity.toDomain(): TodoItem {
|
||||||
|
return TodoItem(
|
||||||
|
id = id,
|
||||||
|
shaarliLinkUrl = shaarliLinkUrl,
|
||||||
|
content = content,
|
||||||
|
isDone = isDone,
|
||||||
|
dueDate = dueDate,
|
||||||
|
tags = tags,
|
||||||
|
isSynced = isSynced,
|
||||||
|
groupName = groupName,
|
||||||
|
subtasks = subtasks
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateTempId(): Int {
|
||||||
|
val base = (System.currentTimeMillis() % Int.MAX_VALUE).toInt().absoluteValue
|
||||||
|
return -((base + Random.nextInt(1, 10_000)) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,13 +19,17 @@ import com.shaarit.data.dto.CollectionsConfigDto
|
|||||||
import com.shaarit.data.local.dao.LinkDao
|
import com.shaarit.data.local.dao.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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (response.isSuccessful) {
|
|
||||||
tokenManager.saveCollectionsConfigBookmarkId(linkId)
|
tokenManager.saveCollectionsConfigBookmarkId(linkId)
|
||||||
tokenManager.setCollectionsConfigDirty(false)
|
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,14 +207,9 @@ class SyncManager @Inject constructor(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (response.isSuccessful) {
|
created.id?.let { tokenManager.saveCollectionsConfigBookmarkId(it) }
|
||||||
val createdId = response.body()?.id
|
|
||||||
if (createdId != null) {
|
|
||||||
tokenManager.saveCollectionsConfigBookmarkId(createdId)
|
|
||||||
}
|
|
||||||
tokenManager.setCollectionsConfigDirty(false)
|
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 ->
|
|
||||||
// Mettre à jour l'ID local avec l'ID serveur
|
|
||||||
val serverId = serverLink.id
|
val serverId = serverLink.id
|
||||||
if (serverId != null) {
|
if (serverId != null && serverId != linkToCreate.id) {
|
||||||
val updatedLink = link.copy(
|
// Supprimer l'ancien lien temporaire avant d'insérer avec l'ID serveur
|
||||||
|
linkDao.deleteLink(linkToCreate.id)
|
||||||
|
val updatedLink = linkToCreate.copy(
|
||||||
id = serverId,
|
id = serverId,
|
||||||
syncStatus = SyncStatus.SYNCED
|
syncStatus = SyncStatus.SYNCED
|
||||||
)
|
)
|
||||||
linkDao.insertLink(updatedLink)
|
linkDao.insertLink(updatedLink)
|
||||||
|
Log.d(TAG, "Lien créé: temp=${linkToCreate.id} -> serveur=$serverId url=${linkToCreate.url}")
|
||||||
|
} else if (serverId != null) {
|
||||||
|
linkDao.markAsSynced(linkToCreate.id)
|
||||||
|
Log.d(TAG, "Lien créé avec même ID: ${linkToCreate.id} url=${linkToCreate.url}")
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "Serveur a retourné un lien sans ID pour ${link.url}")
|
// 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 {
|
} else {
|
||||||
Log.e(TAG, "Échec création lien ${link.id}: ${response.code()}")
|
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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (response.isSuccessful) {
|
|
||||||
linkDao.markAsSynced(link.id)
|
linkDao.markAsSynced(link.id)
|
||||||
} else {
|
markTodoSyncedByUrl(link.url)
|
||||||
Log.e(TAG, "Échec mise à jour lien ${link.id}: ${response.code()}")
|
} catch (e: HttpException) {
|
||||||
}
|
Log.e(TAG, "Échec mise à jour lien ${link.id}: ${e.code()}", e)
|
||||||
} 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)
|
||||||
|
|
||||||
if (response.isSuccessful) {
|
|
||||||
linkDao.deleteLink(link.id)
|
linkDao.deleteLink(link.id)
|
||||||
} else {
|
} catch (e: HttpException) {
|
||||||
Log.e(TAG, "Échec suppression lien ${link.id}: ${response.code()}")
|
Log.e(TAG, "Échec suppression lien ${link.id}: ${e.code()}", e)
|
||||||
}
|
|
||||||
} 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -0,0 +1,157 @@
|
|||||||
|
package com.shaarit.data.worker
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.shaarit.MainActivity
|
||||||
|
import com.shaarit.R
|
||||||
|
import com.shaarit.ShaarItApp
|
||||||
|
import com.shaarit.domain.repository.TodoRepository
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class TodoNotificationReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val todoId = intent.getLongExtra(EXTRA_TODO_ID, -1L)
|
||||||
|
if (todoId <= 0L) return
|
||||||
|
|
||||||
|
val entryPoint = EntryPointAccessors.fromApplication(
|
||||||
|
context.applicationContext,
|
||||||
|
TodoNotificationReceiverEntryPoint::class.java
|
||||||
|
)
|
||||||
|
val todoRepository = entryPoint.todoRepository()
|
||||||
|
val notificationScheduler = entryPoint.notificationScheduler()
|
||||||
|
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
when (intent.action) {
|
||||||
|
ACTION_TRIGGER -> showTodoNotification(context, todoId, todoRepository)
|
||||||
|
ACTION_MARK_DONE -> {
|
||||||
|
todoRepository.toggleDone(todoId, isDone = true)
|
||||||
|
notificationScheduler.cancel(todoId)
|
||||||
|
NotificationManagerCompat.from(context)
|
||||||
|
.cancel(TodoNotificationScheduler.notificationId(todoId))
|
||||||
|
}
|
||||||
|
ACTION_SNOOZE_ONE_HOUR -> {
|
||||||
|
todoRepository.snoozeTodo(todoId, 3_600_000L)
|
||||||
|
NotificationManagerCompat.from(context)
|
||||||
|
.cancel(TodoNotificationScheduler.notificationId(todoId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun showTodoNotification(
|
||||||
|
context: Context,
|
||||||
|
todoId: Long,
|
||||||
|
todoRepository: TodoRepository
|
||||||
|
) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
val granted = ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!granted) return
|
||||||
|
}
|
||||||
|
|
||||||
|
val todo = todoRepository.getTodoById(todoId) ?: return
|
||||||
|
if (todo.isDone) return
|
||||||
|
|
||||||
|
val openTodoIntent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
action = Intent.ACTION_VIEW
|
||||||
|
data = android.net.Uri.parse("shaarit://todo")
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentPendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
TodoNotificationScheduler.notificationId(todoId),
|
||||||
|
openTodoIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val markDoneIntent = Intent(context, TodoNotificationReceiver::class.java).apply {
|
||||||
|
action = ACTION_MARK_DONE
|
||||||
|
putExtra(EXTRA_TODO_ID, todoId)
|
||||||
|
}
|
||||||
|
val markDonePendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
TodoNotificationScheduler.notificationId(todoId) + 1,
|
||||||
|
markDoneIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val snoozeIntent = Intent(context, TodoNotificationReceiver::class.java).apply {
|
||||||
|
action = ACTION_SNOOZE_ONE_HOUR
|
||||||
|
putExtra(EXTRA_TODO_ID, todoId)
|
||||||
|
}
|
||||||
|
val snoozePendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
TodoNotificationScheduler.notificationId(todoId) + 2,
|
||||||
|
snoozeIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val dueText = todo.dueDate?.let {
|
||||||
|
val sdf = SimpleDateFormat("EEE d MMM · HH:mm", Locale.FRANCE)
|
||||||
|
sdf.format(Date(it))
|
||||||
|
} ?: context.getString(R.string.todo_no_due_date)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, ShaarItApp.CHANNEL_TODOS)
|
||||||
|
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||||
|
.setContentTitle(context.getString(R.string.todo_notification_title, todo.content))
|
||||||
|
.setContentText(context.getString(R.string.todo_notification_content, dueText))
|
||||||
|
.setContentIntent(contentPendingIntent)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||||
|
.addAction(
|
||||||
|
R.drawable.ic_launcher_foreground,
|
||||||
|
context.getString(R.string.todo_action_mark_done),
|
||||||
|
markDonePendingIntent
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
R.drawable.ic_launcher_foreground,
|
||||||
|
context.getString(R.string.todo_action_snooze),
|
||||||
|
snoozePendingIntent
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context)
|
||||||
|
.notify(TodoNotificationScheduler.notificationId(todoId), notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_TRIGGER = "com.shaarit.todo.ACTION_TRIGGER"
|
||||||
|
const val ACTION_MARK_DONE = "com.shaarit.todo.ACTION_MARK_DONE"
|
||||||
|
const val ACTION_SNOOZE_ONE_HOUR = "com.shaarit.todo.ACTION_SNOOZE_ONE_HOUR"
|
||||||
|
const val EXTRA_TODO_ID = "extra_todo_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface TodoNotificationReceiverEntryPoint {
|
||||||
|
fun todoRepository(): TodoRepository
|
||||||
|
fun notificationScheduler(): TodoNotificationScheduler
|
||||||
|
}
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
package com.shaarit.data.worker
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.shaarit.data.local.entity.TodoEntity
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class TodoNotificationScheduler @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val alarmManager: AlarmManager =
|
||||||
|
context.getSystemService(AlarmManager::class.java)
|
||||||
|
|
||||||
|
fun schedule(todo: TodoEntity) {
|
||||||
|
val dueDate = todo.dueDate ?: return
|
||||||
|
if (todo.id == 0L || todo.isDone) return
|
||||||
|
|
||||||
|
val pendingIntent = buildTriggerPendingIntent(todo.id)
|
||||||
|
if (dueDate <= System.currentTimeMillis()) {
|
||||||
|
cancel(todo.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) {
|
||||||
|
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, dueDate, pendingIntent)
|
||||||
|
} else {
|
||||||
|
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, dueDate, pendingIntent)
|
||||||
|
}
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, dueDate, pendingIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(todoId: Long) {
|
||||||
|
alarmManager.cancel(buildTriggerPendingIntent(todoId))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requiresNotificationPermission(): Boolean {
|
||||||
|
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasNotificationPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasNotificationPermission(): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
|
||||||
|
return ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildTriggerPendingIntent(todoId: Long): PendingIntent {
|
||||||
|
val intent = Intent(context, TodoNotificationReceiver::class.java).apply {
|
||||||
|
action = TodoNotificationReceiver.ACTION_TRIGGER
|
||||||
|
putExtra(TodoNotificationReceiver.EXTRA_TODO_ID, todoId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
notificationId(todoId),
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun notificationId(todoId: Long): Int {
|
||||||
|
return (todoId % Int.MAX_VALUE).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package com.shaarit.domain.model
|
||||||
|
|
||||||
|
data class BrainDumpTaskSuggestion(
|
||||||
|
val title: String,
|
||||||
|
val dueDate: Long? = null,
|
||||||
|
val tags: List<String> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BrainDumpResult(
|
||||||
|
val generatedTitle: String,
|
||||||
|
val tasks: List<BrainDumpTaskSuggestion>
|
||||||
|
)
|
||||||
9
app/src/main/java/com/shaarit/domain/model/SubTask.kt
Normal file
9
app/src/main/java/com/shaarit/domain/model/SubTask.kt
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
package com.shaarit.domain.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SubTask(
|
||||||
|
val content: String,
|
||||||
|
val isDone: Boolean = false
|
||||||
|
)
|
||||||
13
app/src/main/java/com/shaarit/domain/model/TodoItem.kt
Normal file
13
app/src/main/java/com/shaarit/domain/model/TodoItem.kt
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package com.shaarit.domain.model
|
||||||
|
|
||||||
|
data class TodoItem(
|
||||||
|
val id: Long = 0,
|
||||||
|
val shaarliLinkUrl: String,
|
||||||
|
val content: String,
|
||||||
|
val isDone: Boolean = false,
|
||||||
|
val dueDate: Long? = null,
|
||||||
|
val tags: List<String> = emptyList(),
|
||||||
|
val isSynced: Boolean = false,
|
||||||
|
val groupName: String? = null,
|
||||||
|
val subtasks: List<SubTask> = emptyList()
|
||||||
|
)
|
||||||
@ -1,9 +1,11 @@
|
|||||||
package com.shaarit.domain.repository
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,20 @@
|
|||||||
|
package com.shaarit.domain.repository
|
||||||
|
|
||||||
|
import com.shaarit.domain.model.TodoItem
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface TodoRepository {
|
||||||
|
fun getTodosStream(): Flow<List<TodoItem>>
|
||||||
|
|
||||||
|
fun getGroupNamesStream(): Flow<List<String>>
|
||||||
|
|
||||||
|
suspend fun getTodoById(id: Long): TodoItem?
|
||||||
|
|
||||||
|
suspend fun upsertTodo(todo: TodoItem): Result<Long>
|
||||||
|
|
||||||
|
suspend fun toggleDone(todoId: Long, isDone: Boolean): Result<Unit>
|
||||||
|
|
||||||
|
suspend fun snoozeTodo(todoId: Long, delayMs: Long = 3_600_000L): Result<Unit>
|
||||||
|
|
||||||
|
suspend fun deleteTodo(todoId: Long): Result<Unit>
|
||||||
|
}
|
||||||
@ -75,6 +75,7 @@ class ClassifyBookmarksUseCase @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper to classify based on URL, Title, Tags
|
// 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 } ?: ""
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
@ -418,6 +418,15 @@ fun FeedScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
label = "Épinglés",
|
label = "Épinglés",
|
||||||
|
|||||||
@ -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() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1061
app/src/main/java/com/shaarit/presentation/todo/TodoScreen.kt
Normal file
1061
app/src/main/java/com/shaarit/presentation/todo/TodoScreen.kt
Normal file
File diff suppressed because it is too large
Load Diff
421
app/src/main/java/com/shaarit/presentation/todo/TodoViewModel.kt
Normal file
421
app/src/main/java/com/shaarit/presentation/todo/TodoViewModel.kt
Normal file
@ -0,0 +1,421 @@
|
|||||||
|
package com.shaarit.presentation.todo
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.shaarit.data.sync.SyncManager
|
||||||
|
import com.shaarit.data.worker.TodoNotificationScheduler
|
||||||
|
import com.shaarit.domain.model.SubTask
|
||||||
|
import com.shaarit.domain.model.TodoItem
|
||||||
|
import com.shaarit.domain.repository.GeminiRepository
|
||||||
|
import com.shaarit.domain.repository.TodoRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class EditableBrainDumpTask(
|
||||||
|
val localId: String = UUID.randomUUID().toString(),
|
||||||
|
val title: String,
|
||||||
|
val dueDate: Long? = null,
|
||||||
|
val tags: List<String> = emptyList(),
|
||||||
|
val groupName: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BrainDumpDialogUiState(
|
||||||
|
val input: String = "",
|
||||||
|
val isAnalyzing: Boolean = false,
|
||||||
|
val isSaving: Boolean = false,
|
||||||
|
val parsedTasks: List<EditableBrainDumpTask> = emptyList(),
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val saveCompleted: Boolean = false,
|
||||||
|
val requestNotificationPermission: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EditTodoDialogUiState(
|
||||||
|
val isVisible: Boolean = false,
|
||||||
|
val todoId: Long = 0,
|
||||||
|
val content: String = "",
|
||||||
|
val dueDate: Long? = null,
|
||||||
|
val tags: List<String> = emptyList(),
|
||||||
|
val groupName: String = "",
|
||||||
|
val subtasks: List<SubTask> = emptyList(),
|
||||||
|
val newSubtaskText: String = "",
|
||||||
|
val isSaving: Boolean = false,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val shaarliLinkUrl: String = "",
|
||||||
|
val isDone: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class TodoViewModel @Inject constructor(
|
||||||
|
private val todoRepository: TodoRepository,
|
||||||
|
private val geminiRepository: GeminiRepository,
|
||||||
|
private val syncManager: SyncManager,
|
||||||
|
private val notificationScheduler: TodoNotificationScheduler
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val todos: StateFlow<List<TodoItem>> =
|
||||||
|
todoRepository
|
||||||
|
.getTodosStream()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
|
val groupNames: StateFlow<List<String>> =
|
||||||
|
todoRepository
|
||||||
|
.getGroupNamesStream()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
|
private val _selectedGroup = MutableStateFlow<String?>(null)
|
||||||
|
val selectedGroup: StateFlow<String?> = _selectedGroup.asStateFlow()
|
||||||
|
|
||||||
|
private val _dialogState = MutableStateFlow(BrainDumpDialogUiState())
|
||||||
|
val dialogState: StateFlow<BrainDumpDialogUiState> = _dialogState.asStateFlow()
|
||||||
|
|
||||||
|
private val _editDialogState = MutableStateFlow(EditTodoDialogUiState())
|
||||||
|
val editDialogState: StateFlow<EditTodoDialogUiState> = _editDialogState.asStateFlow()
|
||||||
|
|
||||||
|
fun selectGroup(group: String?) {
|
||||||
|
_selectedGroup.value = group
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Brain Dump Dialog ======
|
||||||
|
|
||||||
|
fun onBrainDumpInputChanged(value: String) {
|
||||||
|
_dialogState.update {
|
||||||
|
it.copy(input = value, errorMessage = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun appendVoiceInput(transcript: String) {
|
||||||
|
val cleaned = transcript.trim()
|
||||||
|
if (cleaned.isBlank()) return
|
||||||
|
|
||||||
|
_dialogState.update {
|
||||||
|
val separator = if (it.input.isBlank()) "" else "\n"
|
||||||
|
it.copy(input = it.input + separator + cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun analyzeBrainDump() {
|
||||||
|
val input = _dialogState.value.input.trim()
|
||||||
|
if (input.isBlank()) {
|
||||||
|
_dialogState.update {
|
||||||
|
it.copy(errorMessage = "Le brain dump ne peut pas être vide")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!geminiRepository.isApiKeyConfigured()) {
|
||||||
|
_dialogState.update {
|
||||||
|
it.copy(errorMessage = "Clé API Gemini non configurée dans les paramètres")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_dialogState.update {
|
||||||
|
it.copy(isAnalyzing = true, errorMessage = null, parsedTasks = emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
geminiRepository.analyzeBrainDump(input)
|
||||||
|
}.onSuccess { result ->
|
||||||
|
val parsedTasks = result.tasks.map { task ->
|
||||||
|
EditableBrainDumpTask(
|
||||||
|
title = task.title,
|
||||||
|
dueDate = task.dueDate,
|
||||||
|
tags = task.tags
|
||||||
|
)
|
||||||
|
}.ifEmpty {
|
||||||
|
listOf(
|
||||||
|
EditableBrainDumpTask(
|
||||||
|
title = input.take(80),
|
||||||
|
dueDate = null,
|
||||||
|
tags = emptyList()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_dialogState.update {
|
||||||
|
it.copy(
|
||||||
|
isAnalyzing = false,
|
||||||
|
parsedTasks = parsedTasks,
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
_dialogState.update {
|
||||||
|
it.copy(
|
||||||
|
isAnalyzing = false,
|
||||||
|
errorMessage = error.message ?: "Erreur pendant l'analyse IA"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateTaskTitle(taskId: String, title: String) {
|
||||||
|
_dialogState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
parsedTasks = state.parsedTasks.map { task ->
|
||||||
|
if (task.localId == taskId) task.copy(title = title) else task
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateTaskDueDate(taskId: String, dueDate: Long?) {
|
||||||
|
_dialogState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
parsedTasks = state.parsedTasks.map { task ->
|
||||||
|
if (task.localId == taskId) task.copy(dueDate = dueDate) else task
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateTaskGroup(taskId: String, groupName: String?) {
|
||||||
|
_dialogState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
parsedTasks = state.parsedTasks.map { task ->
|
||||||
|
if (task.localId == taskId) task.copy(groupName = groupName) else task
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeTag(taskId: String, tag: String) {
|
||||||
|
_dialogState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
parsedTasks = state.parsedTasks.map { task ->
|
||||||
|
if (task.localId == taskId) {
|
||||||
|
task.copy(tags = task.tags.filterNot { it.equals(tag, ignoreCase = true) })
|
||||||
|
} else {
|
||||||
|
task
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveParsedTasks() {
|
||||||
|
val tasksToSave = _dialogState.value.parsedTasks
|
||||||
|
.map { task ->
|
||||||
|
task.copy(
|
||||||
|
title = task.title.trim(),
|
||||||
|
tags = task.tags
|
||||||
|
.map { it.trim().trimStart('#').lowercase() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.filter { it.title.isNotBlank() }
|
||||||
|
|
||||||
|
if (tasksToSave.isEmpty()) {
|
||||||
|
_dialogState.update {
|
||||||
|
it.copy(errorMessage = "Aucune tâche valide à sauvegarder")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_dialogState.update {
|
||||||
|
it.copy(isSaving = true, errorMessage = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasError = false
|
||||||
|
tasksToSave.forEach { task ->
|
||||||
|
val result = todoRepository.upsertTodo(
|
||||||
|
TodoItem(
|
||||||
|
shaarliLinkUrl = "",
|
||||||
|
content = task.title,
|
||||||
|
isDone = false,
|
||||||
|
dueDate = task.dueDate,
|
||||||
|
tags = task.tags,
|
||||||
|
isSynced = false,
|
||||||
|
groupName = task.groupName?.takeIf { it.isNotBlank() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.isFailure) {
|
||||||
|
hasError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncManager.syncNow()
|
||||||
|
|
||||||
|
val shouldAskPermission = tasksToSave.any { it.dueDate != null } &&
|
||||||
|
notificationScheduler.requiresNotificationPermission()
|
||||||
|
|
||||||
|
_dialogState.update {
|
||||||
|
it.copy(
|
||||||
|
isSaving = false,
|
||||||
|
saveCompleted = !hasError,
|
||||||
|
errorMessage = if (hasError) "Certaines tâches n'ont pas pu être sauvegardées" else null,
|
||||||
|
requestNotificationPermission = shouldAskPermission
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeSaveCompleted() {
|
||||||
|
_dialogState.update {
|
||||||
|
it.copy(
|
||||||
|
saveCompleted = false,
|
||||||
|
input = "",
|
||||||
|
parsedTasks = emptyList(),
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearDialogState() {
|
||||||
|
_dialogState.update {
|
||||||
|
BrainDumpDialogUiState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onNotificationPermissionHandled() {
|
||||||
|
_dialogState.update {
|
||||||
|
it.copy(requestNotificationPermission = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Edit Todo Dialog ======
|
||||||
|
|
||||||
|
fun openEditDialog(todoId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val todo = todoRepository.getTodoById(todoId) ?: return@launch
|
||||||
|
_editDialogState.value = EditTodoDialogUiState(
|
||||||
|
isVisible = true,
|
||||||
|
todoId = todo.id,
|
||||||
|
content = todo.content,
|
||||||
|
dueDate = todo.dueDate,
|
||||||
|
tags = todo.tags,
|
||||||
|
groupName = todo.groupName ?: "",
|
||||||
|
subtasks = todo.subtasks,
|
||||||
|
shaarliLinkUrl = todo.shaarliLinkUrl,
|
||||||
|
isDone = todo.isDone
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeEditDialog() {
|
||||||
|
_editDialogState.value = EditTodoDialogUiState()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEditContentChanged(value: String) {
|
||||||
|
_editDialogState.update { it.copy(content = value, errorMessage = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEditDueDateChanged(dueDate: Long?) {
|
||||||
|
_editDialogState.update { it.copy(dueDate = dueDate) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEditGroupChanged(groupName: String) {
|
||||||
|
_editDialogState.update { it.copy(groupName = groupName) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEditNewSubtaskTextChanged(text: String) {
|
||||||
|
_editDialogState.update { it.copy(newSubtaskText = text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addSubtask() {
|
||||||
|
val text = _editDialogState.value.newSubtaskText.trim()
|
||||||
|
if (text.isBlank()) return
|
||||||
|
_editDialogState.update {
|
||||||
|
it.copy(
|
||||||
|
subtasks = it.subtasks + SubTask(content = text),
|
||||||
|
newSubtaskText = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeSubtask(index: Int) {
|
||||||
|
_editDialogState.update {
|
||||||
|
it.copy(subtasks = it.subtasks.toMutableList().apply { removeAt(index) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleSubtask(index: Int) {
|
||||||
|
_editDialogState.update {
|
||||||
|
val mutable = it.subtasks.toMutableList()
|
||||||
|
val sub = mutable[index]
|
||||||
|
mutable[index] = sub.copy(isDone = !sub.isDone)
|
||||||
|
it.copy(subtasks = mutable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSubtaskContent(index: Int, content: String) {
|
||||||
|
_editDialogState.update {
|
||||||
|
val mutable = it.subtasks.toMutableList()
|
||||||
|
mutable[index] = mutable[index].copy(content = content)
|
||||||
|
it.copy(subtasks = mutable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveEditedTodo() {
|
||||||
|
val state = _editDialogState.value
|
||||||
|
val content = state.content.trim()
|
||||||
|
if (content.isBlank()) {
|
||||||
|
_editDialogState.update { it.copy(errorMessage = "Le contenu ne peut pas être vide") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_editDialogState.update { it.copy(isSaving = true, errorMessage = null) }
|
||||||
|
|
||||||
|
val result = todoRepository.upsertTodo(
|
||||||
|
TodoItem(
|
||||||
|
id = state.todoId,
|
||||||
|
shaarliLinkUrl = state.shaarliLinkUrl,
|
||||||
|
content = content,
|
||||||
|
isDone = state.isDone,
|
||||||
|
dueDate = state.dueDate,
|
||||||
|
tags = state.tags,
|
||||||
|
isSynced = false,
|
||||||
|
groupName = state.groupName.takeIf { it.isNotBlank() },
|
||||||
|
subtasks = state.subtasks.filter { it.content.isNotBlank() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.isSuccess) {
|
||||||
|
syncManager.syncNow()
|
||||||
|
_editDialogState.value = EditTodoDialogUiState()
|
||||||
|
} else {
|
||||||
|
_editDialogState.update {
|
||||||
|
it.copy(
|
||||||
|
isSaving = false,
|
||||||
|
errorMessage = "Erreur lors de la sauvegarde"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Actions ======
|
||||||
|
|
||||||
|
fun toggleTodo(todoId: Long, isDone: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
todoRepository.toggleDone(todoId, isDone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun snoozeTodo(todoId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
todoRepository.snoozeTodo(todoId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteTodo(todoId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
todoRepository.deleteTodo(todoId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,8 +25,17 @@ class AddLinkTileService : TileService() {
|
|||||||
`package` = packageName
|
`package` = packageName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
startActivityAndCollapse(intent)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onStartListening() {
|
override fun onStartListening() {
|
||||||
super.onStartListening()
|
super.onStartListening()
|
||||||
|
|||||||
@ -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
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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
|
||||||
Loading…
x
Reference in New Issue
Block a user