feat: implement Todo module with database support and UI components

This commit is contained in:
Bruno Charest 2026-04-23 20:16:20 -04:00
parent 5652e14352
commit 888841e510
9 changed files with 1185 additions and 9 deletions

View File

@ -0,0 +1,757 @@
{
"formatVersion": 1,
"database": {
"version": 9,
"identityHash": "a77d5257e0e1717bb3899da878237658",
"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, `start_date` INTEGER, `repeat_mode` TEXT, `priority` TEXT, `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": "startDate",
"columnName": "start_date",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "repeatMode",
"columnName": "repeat_mode",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "priority",
"columnName": "priority",
"affinity": "TEXT",
"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_start_date",
"unique": false,
"columnNames": [
"start_date"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_todos_start_date` ON `${TABLE_NAME}` (`start_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`)"
},
{
"name": "index_todos_priority",
"unique": false,
"columnNames": [
"priority"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_todos_priority` ON `${TABLE_NAME}` (`priority`)"
}
],
"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, 'a77d5257e0e1717bb3899da878237658')"
]
}
}

View File

@ -36,7 +36,7 @@ import com.shaarit.data.local.entity.TodoEntity
ReadingReminderEntity::class, ReadingReminderEntity::class,
TodoEntity::class TodoEntity::class
], ],
version = 8, version = 9,
exportSchema = true exportSchema = true
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -131,6 +131,19 @@ abstract class ShaarliDatabase : RoomDatabase() {
} }
} }
/**
* Migration v8 v9 : Ajout de start_date, repeat_mode et priority
*/
val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `todos` ADD COLUMN `start_date` INTEGER DEFAULT NULL")
db.execSQL("ALTER TABLE `todos` ADD COLUMN `repeat_mode` TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE `todos` ADD COLUMN `priority` TEXT DEFAULT NULL")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_todos_start_date` ON `todos` (`start_date`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_todos_priority` ON `todos` (`priority`)")
}
}
@Volatile @Volatile
private var instance: ShaarliDatabase? = null private var instance: ShaarliDatabase? = null
@ -146,7 +159,7 @@ abstract class ShaarliDatabase : RoomDatabase() {
ShaarliDatabase::class.java, ShaarliDatabase::class.java,
DATABASE_NAME DATABASE_NAME
) )
.addMigrations(MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8) .addMigrations(MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9)
.fallbackToDestructiveMigrationFrom(1, 2, 3) .fallbackToDestructiveMigrationFrom(1, 2, 3)
.build() .build()
} }

View File

@ -11,8 +11,10 @@ import com.shaarit.domain.model.SubTask
indices = [ indices = [
Index(value = ["shaarli_link_url"], unique = true), Index(value = ["shaarli_link_url"], unique = true),
Index(value = ["due_date"]), Index(value = ["due_date"]),
Index(value = ["start_date"]),
Index(value = ["is_done"]), Index(value = ["is_done"]),
Index(value = ["group_name"]) Index(value = ["group_name"]),
Index(value = ["priority"])
] ]
) )
data class TodoEntity( data class TodoEntity(
@ -31,6 +33,15 @@ data class TodoEntity(
@ColumnInfo(name = "due_date") @ColumnInfo(name = "due_date")
val dueDate: Long? = null, val dueDate: Long? = null,
@ColumnInfo(name = "start_date")
val startDate: Long? = null,
@ColumnInfo(name = "repeat_mode")
val repeatMode: String? = null,
@ColumnInfo(name = "priority")
val priority: String? = null,
@ColumnInfo(name = "tags") @ColumnInfo(name = "tags")
val tags: List<String> = emptyList(), val tags: List<String> = emptyList(),

View File

@ -181,7 +181,10 @@ class TodoRepositoryImpl @Inject constructor(
shaarliLinkUrl = url, shaarliLinkUrl = url,
content = cleanedContent, content = cleanedContent,
isDone = todo.isDone, isDone = todo.isDone,
startDate = todo.startDate,
dueDate = todo.dueDate, dueDate = todo.dueDate,
repeatMode = todo.repeatMode,
priority = todo.priority,
tags = cleanedTags, tags = cleanedTags,
isSynced = false, isSynced = false,
groupName = todo.groupName?.trim()?.takeIf { it.isNotBlank() }, groupName = todo.groupName?.trim()?.takeIf { it.isNotBlank() },
@ -207,7 +210,10 @@ class TodoRepositoryImpl @Inject constructor(
shaarliLinkUrl = shaarliLinkUrl, shaarliLinkUrl = shaarliLinkUrl,
content = content, content = content,
isDone = isDone, isDone = isDone,
startDate = startDate,
dueDate = dueDate, dueDate = dueDate,
repeatMode = repeatMode,
priority = priority,
tags = tags, tags = tags,
isSynced = isSynced, isSynced = isSynced,
groupName = groupName, groupName = groupName,

View File

@ -5,7 +5,10 @@ data class TodoItem(
val shaarliLinkUrl: String, val shaarliLinkUrl: String,
val content: String, val content: String,
val isDone: Boolean = false, val isDone: Boolean = false,
val startDate: Long? = null,
val dueDate: Long? = null, val dueDate: Long? = null,
val repeatMode: String? = null,
val priority: String? = null,
val tags: List<String> = emptyList(), val tags: List<String> = emptyList(),
val isSynced: Boolean = false, val isSynced: Boolean = false,
val groupName: String? = null, val groupName: String? = null,

View File

@ -0,0 +1,218 @@
package com.shaarit.presentation.todo
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import org.json.JSONArray
import org.json.JSONObject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomRepeatDialog(
initialRepeatMode: String?,
onDismiss: () -> Unit,
onSave: (String) -> Unit
) {
var interval by remember { mutableStateOf("1") }
var unit by remember { mutableStateOf("semaine") }
val units = listOf("jour", "semaine", "mois", "an")
var expandedUnit by remember { mutableStateOf(false) }
val daysOfWeek = listOf("D", "L", "M", "M", "J", "V", "S")
var selectedDays by remember { mutableStateOf(setOf<Int>()) }
var endMode by remember { mutableStateOf("jamais") } // "jamais", "date", "occurrences"
var endDate by remember { mutableStateOf("20 mai") }
var endOccurrences by remember { mutableStateOf("1") }
// Parse initialRepeatMode if it's CUSTOM
LaunchedEffect(initialRepeatMode) {
if (initialRepeatMode?.startsWith("CUSTOM:") == true) {
try {
val json = JSONObject(initialRepeatMode.substring(7))
interval = json.optString("interval", "1")
unit = json.optString("unit", "semaine")
val daysArr = json.optJSONArray("daysOfWeek")
if (daysArr != null) {
val days = mutableSetOf<Int>()
for (i in 0 until daysArr.length()) {
days.add(daysArr.getInt(i))
}
selectedDays = days
}
endMode = json.optString("endMode", "jamais")
endDate = json.optString("endDate", "")
endOccurrences = json.optString("endOccurrences", "1")
} catch (e: Exception) {
// Default fallback
}
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = true
)
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Répétition personnalisée") },
navigationIcon = {
IconButton(onClick = onDismiss) {
Icon(Icons.Default.ArrowBack, contentDescription = "Retour")
}
},
actions = {
TextButton(onClick = {
val json = JSONObject()
json.put("interval", interval)
json.put("unit", unit)
if (unit == "semaine") {
json.put("daysOfWeek", JSONArray(selectedDays.toList()))
}
json.put("endMode", endMode)
if (endMode == "date") json.put("endDate", endDate)
if (endMode == "occurrences") json.put("endOccurrences", endOccurrences)
onSave("CUSTOM:${json.toString()}")
}) {
Text("ENREGISTRER", color = MaterialTheme.colorScheme.primary)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
) {
Text("Répéter par intervalle de", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = interval,
onValueChange = { interval = it },
modifier = Modifier.width(80.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
Spacer(modifier = Modifier.width(16.dp))
Box {
OutlinedButton(onClick = { expandedUnit = true }) {
Text(unit)
}
DropdownMenu(
expanded = expandedUnit,
onDismissRequest = { expandedUnit = false }
) {
units.forEach { u ->
DropdownMenuItem(
text = { Text(u) },
onClick = { unit = u; expandedUnit = false }
)
}
}
}
}
if (unit == "semaine") {
Spacer(modifier = Modifier.height(24.dp))
Text("Répéter le", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
daysOfWeek.forEachIndexed { index, day ->
val isSelected = selectedDays.contains(index)
Box(
modifier = Modifier
.size(40.dp)
.background(
color = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent,
shape = CircleShape
)
.clickable {
if (isSelected) selectedDays = selectedDays - index
else selectedDays = selectedDays + index
},
contentAlignment = Alignment.Center
) {
Text(
text = day,
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Text("Se termine", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = endMode == "jamais",
onClick = { endMode = "jamais" }
)
Text("Jamais", modifier = Modifier.clickable { endMode = "jamais" })
}
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = endMode == "date",
onClick = { endMode = "date" }
)
Text("Le ", modifier = Modifier.clickable { endMode = "date" })
OutlinedTextField(
value = endDate,
onValueChange = { endDate = it },
modifier = Modifier.width(120.dp).padding(start = 8.dp),
enabled = endMode == "date",
singleLine = true
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = endMode == "occurrences",
onClick = { endMode = "occurrences" }
)
Text("Après ", modifier = Modifier.clickable { endMode = "occurrences" })
OutlinedTextField(
value = endOccurrences,
onValueChange = { endOccurrences = it },
modifier = Modifier.width(80.dp).padding(horizontal = 8.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
enabled = endMode == "occurrences",
singleLine = true
)
Text("occurrence", modifier = Modifier.clickable { endMode = "occurrences" })
}
}
}
}
}

View File

@ -49,8 +49,10 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Event import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.Flag
import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Snooze import androidx.compose.material.icons.filled.Snooze
import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank
@ -443,6 +445,9 @@ fun TodoScreen(
onDismiss = viewModel::closeEditDialog, onDismiss = viewModel::closeEditDialog,
onContentChanged = viewModel::onEditContentChanged, onContentChanged = viewModel::onEditContentChanged,
onDueDateChanged = viewModel::onEditDueDateChanged, onDueDateChanged = viewModel::onEditDueDateChanged,
onStartDateChanged = viewModel::onEditStartDateChanged,
onRepeatModeChanged = viewModel::onEditRepeatModeChanged,
onPriorityChanged = viewModel::onEditPriorityChanged,
onGroupChanged = viewModel::onEditGroupChanged, onGroupChanged = viewModel::onEditGroupChanged,
onNewSubtaskTextChanged = viewModel::onEditNewSubtaskTextChanged, onNewSubtaskTextChanged = viewModel::onEditNewSubtaskTextChanged,
onAddSubtask = viewModel::addSubtask, onAddSubtask = viewModel::addSubtask,
@ -657,6 +662,9 @@ private fun EditTodoDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onContentChanged: (String) -> Unit, onContentChanged: (String) -> Unit,
onDueDateChanged: (Long?) -> Unit, onDueDateChanged: (Long?) -> Unit,
onStartDateChanged: (Long?) -> Unit,
onRepeatModeChanged: (String?) -> Unit,
onPriorityChanged: (String?) -> Unit,
onGroupChanged: (String) -> Unit, onGroupChanged: (String) -> Unit,
onNewSubtaskTextChanged: (String) -> Unit, onNewSubtaskTextChanged: (String) -> Unit,
onAddSubtask: () -> Unit, onAddSubtask: () -> Unit,
@ -669,6 +677,10 @@ private fun EditTodoDialog(
onSave: () -> Unit onSave: () -> Unit
) { ) {
var showDatePicker by remember { mutableStateOf(false) } var showDatePicker by remember { mutableStateOf(false) }
var showStartDatePicker by remember { mutableStateOf(false) }
var showRepeatMenu by remember { mutableStateOf(false) }
var showCustomRepeatDialog by remember { mutableStateOf(false) }
var showPriorityMenu by remember { mutableStateOf(false) }
androidx.compose.ui.window.Dialog( androidx.compose.ui.window.Dialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@ -737,6 +749,17 @@ private fun EditTodoDialog(
androidx.compose.material3.Divider(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) androidx.compose.material3.Divider(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
// Start Date
val startDateText = state.startDate?.let { formatDateTime(it, timezoneId) } ?: "Pas de date de début"
TaskActionRow(
icon = Icons.Default.Event,
text = startDateText,
onClick = { showStartDatePicker = true },
isSet = state.startDate != null
)
androidx.compose.material3.Divider(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
// Due Date // Due Date
val dateText = state.dueDate?.let { formatDateTime(it, timezoneId) } ?: "Aucune date d'échéance" val dateText = state.dueDate?.let { formatDateTime(it, timezoneId) } ?: "Aucune date d'échéance"
TaskActionRow( TaskActionRow(
@ -748,6 +771,105 @@ private fun EditTodoDialog(
androidx.compose.material3.Divider(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) androidx.compose.material3.Divider(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
// Repeat Mode
val repeatText = when {
state.repeatMode == null || state.repeatMode == "NONE" -> "Ne pas répéter"
state.repeatMode == "DAILY" -> "Tous les jours"
state.repeatMode == "WEEKLY" -> "Toutes les semaines"
state.repeatMode == "MONTHLY" -> "Tous les mois"
state.repeatMode == "YEARLY" -> "Tous les ans"
state.repeatMode?.startsWith("CUSTOM") == true -> "Personnalisé..."
else -> "Ne pas répéter"
}
androidx.compose.foundation.layout.Box {
TaskActionRow(
icon = Icons.Default.Repeat,
text = repeatText,
onClick = { showRepeatMenu = true },
isSet = state.repeatMode != null && state.repeatMode != "NONE"
)
androidx.compose.material3.DropdownMenu(
expanded = showRepeatMenu,
onDismissRequest = { showRepeatMenu = false }
) {
androidx.compose.material3.DropdownMenuItem(
text = { Text("Ne pas répéter") },
onClick = { onRepeatModeChanged("NONE"); showRepeatMenu = false }
)
androidx.compose.material3.DropdownMenuItem(
text = { Text("Tous les jours") },
onClick = { onRepeatModeChanged("DAILY"); showRepeatMenu = false }
)
androidx.compose.material3.DropdownMenuItem(
text = { Text("Toutes les semaines") },
onClick = { onRepeatModeChanged("WEEKLY"); showRepeatMenu = false }
)
androidx.compose.material3.DropdownMenuItem(
text = { Text("Tous les mois") },
onClick = { onRepeatModeChanged("MONTHLY"); showRepeatMenu = false }
)
androidx.compose.material3.DropdownMenuItem(
text = { Text("Tous les ans") },
onClick = { onRepeatModeChanged("YEARLY"); showRepeatMenu = false }
)
androidx.compose.material3.DropdownMenuItem(
text = { Text("Personnalisé...") },
onClick = { showRepeatMenu = false; showCustomRepeatDialog = true }
)
}
}
androidx.compose.material3.Divider(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
// Priority
val priorityText = when(state.priority) {
"RED" -> "Urgent (Rouge)"
"YELLOW" -> "Élevée (Jaune)"
"BLUE" -> "Normale (Bleu)"
"GREEN" -> "Basse (Vert)"
else -> "Priorité normale"
}
val priorityColor = when(state.priority) {
"RED" -> androidx.compose.ui.graphics.Color.Red
"YELLOW" -> androidx.compose.ui.graphics.Color.Yellow
"BLUE" -> androidx.compose.ui.graphics.Color.Blue
"GREEN" -> androidx.compose.ui.graphics.Color.Green
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
androidx.compose.foundation.layout.Box {
TaskActionRow(
icon = Icons.Default.Flag,
iconTint = priorityColor,
textTint = priorityColor,
text = priorityText,
onClick = { showPriorityMenu = true },
isSet = state.priority != null
)
androidx.compose.material3.DropdownMenu(
expanded = showPriorityMenu,
onDismissRequest = { showPriorityMenu = false }
) {
androidx.compose.material3.DropdownMenuItem(
text = { Text("Urgent (Rouge)", color = androidx.compose.ui.graphics.Color.Red) },
onClick = { onPriorityChanged("RED"); showPriorityMenu = false }
)
androidx.compose.material3.DropdownMenuItem(
text = { Text("Élevée (Jaune)", color = androidx.compose.ui.graphics.Color.Yellow) },
onClick = { onPriorityChanged("YELLOW"); showPriorityMenu = false }
)
androidx.compose.material3.DropdownMenuItem(
text = { Text("Normale (Bleu)", color = androidx.compose.ui.graphics.Color.Blue) },
onClick = { onPriorityChanged("BLUE"); showPriorityMenu = false }
)
androidx.compose.material3.DropdownMenuItem(
text = { Text("Basse (Vert)", color = androidx.compose.ui.graphics.Color.Green) },
onClick = { onPriorityChanged("GREEN"); showPriorityMenu = false }
)
}
}
androidx.compose.material3.Divider(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
// Group (Famille) // Group (Famille)
TaskGroupRow( TaskGroupRow(
groupName = state.groupName, groupName = state.groupName,
@ -791,6 +913,26 @@ private fun EditTodoDialog(
onDateSelected = onDueDateChanged onDateSelected = onDueDateChanged
) )
} }
if (showStartDatePicker) {
DateTimePickerBottomSheet(
initialDate = state.startDate,
timezoneId = timezoneId,
onDismiss = { showStartDatePicker = false },
onDateSelected = onStartDateChanged
)
}
if (showCustomRepeatDialog) {
CustomRepeatDialog(
initialRepeatMode = state.repeatMode,
onDismiss = { showCustomRepeatDialog = false },
onSave = {
onRepeatModeChanged(it)
showCustomRepeatDialog = false
}
)
}
} }
} }
@ -799,7 +941,9 @@ private fun TaskActionRow(
icon: androidx.compose.ui.graphics.vector.ImageVector, icon: androidx.compose.ui.graphics.vector.ImageVector,
text: String, text: String,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
isSet: Boolean = false isSet: Boolean = false,
iconTint: androidx.compose.ui.graphics.Color? = null,
textTint: androidx.compose.ui.graphics.Color? = null
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -811,12 +955,12 @@ private fun TaskActionRow(
Icon( Icon(
icon, icon,
contentDescription = null, contentDescription = null,
tint = if (isSet) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant tint = iconTint ?: if (isSet) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Text( Text(
text, text,
color = if (isSet) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, color = textTint ?: if (isSet) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge
) )
} }

View File

@ -44,7 +44,10 @@ data class EditTodoDialogUiState(
val isCreateMode: Boolean = false, val isCreateMode: Boolean = false,
val todoId: Long = 0, val todoId: Long = 0,
val content: String = "", val content: String = "",
val startDate: Long? = null,
val dueDate: Long? = null, val dueDate: Long? = null,
val repeatMode: String? = null,
val priority: String? = null,
val tags: List<String> = emptyList(), val tags: List<String> = emptyList(),
val groupName: String = "", val groupName: String = "",
val subtasks: List<SubTask> = emptyList(), val subtasks: List<SubTask> = emptyList(),
@ -310,7 +313,10 @@ class TodoViewModel @Inject constructor(
isVisible = true, isVisible = true,
todoId = todo.id, todoId = todo.id,
content = todo.content, content = todo.content,
startDate = todo.startDate,
dueDate = todo.dueDate, dueDate = todo.dueDate,
repeatMode = todo.repeatMode,
priority = todo.priority,
tags = todo.tags, tags = todo.tags,
groupName = todo.groupName ?: "", groupName = todo.groupName ?: "",
subtasks = todo.subtasks, subtasks = todo.subtasks,
@ -332,6 +338,18 @@ class TodoViewModel @Inject constructor(
_editDialogState.update { it.copy(dueDate = dueDate) } _editDialogState.update { it.copy(dueDate = dueDate) }
} }
fun onEditStartDateChanged(startDate: Long?) {
_editDialogState.update { it.copy(startDate = startDate) }
}
fun onEditRepeatModeChanged(repeatMode: String?) {
_editDialogState.update { it.copy(repeatMode = repeatMode) }
}
fun onEditPriorityChanged(priority: String?) {
_editDialogState.update { it.copy(priority = priority) }
}
fun onEditGroupChanged(groupName: String) { fun onEditGroupChanged(groupName: String) {
_editDialogState.update { it.copy(groupName = groupName) } _editDialogState.update { it.copy(groupName = groupName) }
} }
@ -411,7 +429,10 @@ class TodoViewModel @Inject constructor(
shaarliLinkUrl = "", shaarliLinkUrl = "",
content = content, content = content,
isDone = false, isDone = false,
startDate = state.startDate,
dueDate = state.dueDate, dueDate = state.dueDate,
repeatMode = state.repeatMode,
priority = state.priority,
tags = state.tags, tags = state.tags,
isSynced = false, isSynced = false,
groupName = state.groupName.takeIf { it.isNotBlank() }, groupName = state.groupName.takeIf { it.isNotBlank() },
@ -423,7 +444,10 @@ class TodoViewModel @Inject constructor(
shaarliLinkUrl = state.shaarliLinkUrl, shaarliLinkUrl = state.shaarliLinkUrl,
content = content, content = content,
isDone = state.isDone, isDone = state.isDone,
startDate = state.startDate,
dueDate = state.dueDate, dueDate = state.dueDate,
repeatMode = state.repeatMode,
priority = state.priority,
tags = state.tags, tags = state.tags,
isSynced = false, isSynced = false,
groupName = state.groupName.takeIf { it.isNotBlank() }, groupName = state.groupName.takeIf { it.isNotBlank() },

View File

@ -1,3 +1,3 @@
#Thu Apr 23 19:02:26 2026 #Thu Apr 23 19:46:44 2026
VERSION_NAME=2.13.0 VERSION_NAME=2.14.0
VERSION_CODE=40 VERSION_CODE=41