diff --git a/tasks-app-shared/schemas/net.opatry.tasks.data.TasksAppDatabase/3.json b/tasks-app-shared/schemas/net.opatry.tasks.data.TasksAppDatabase/3.json new file mode 100644 index 00000000..0284e20b --- /dev/null +++ b/tasks-app-shared/schemas/net.opatry.tasks.data.TasksAppDatabase/3.json @@ -0,0 +1,194 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "17de8549f80d34ddcf3b7baff29a9f31", + "entities": [ + { + "tableName": "task_list", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remote_id` TEXT, `etag` TEXT NOT NULL DEFAULT '', `title` TEXT NOT NULL, `update_date` TEXT NOT NULL, `sorting` TEXT NOT NULL DEFAULT 'UserDefined')", + "fields": [ + { + "fieldPath": "id", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdateDate", + "columnName": "update_date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sorting", + "columnName": "sorting", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'UserDefined'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "local_id" + ] + } + }, + { + "tableName": "task", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remote_id` TEXT, `parent_list_local_id` INTEGER NOT NULL, `etag` TEXT NOT NULL DEFAULT '', `title` TEXT NOT NULL, `due_date` TEXT, `update_date` TEXT NOT NULL, `completion_date` TEXT, `notes` TEXT NOT NULL DEFAULT '', `is_completed` INTEGER NOT NULL, `position` TEXT NOT NULL, `parent_local_id` INTEGER, `remote_parent_id` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT" + }, + { + "fieldPath": "parentListLocalId", + "columnName": "parent_list_local_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dueDate", + "columnName": "due_date", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdateDate", + "columnName": "update_date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "completionDate", + "columnName": "completion_date", + "affinity": "TEXT" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "isCompleted", + "columnName": "is_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentTaskLocalId", + "columnName": "parent_local_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentTaskRemoteId", + "columnName": "remote_parent_id", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "local_id" + ] + } + }, + { + "tableName": "user", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remote_id` TEXT, `name` TEXT NOT NULL, `email` TEXT, `avatar_url` TEXT, `is_signed_in` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatar_url", + "affinity": "TEXT" + }, + { + "fieldPath": "isSignedIn", + "columnName": "is_signed_in", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "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, '17de8549f80d34ddcf3b7baff29a9f31')" + ] + } +} \ No newline at end of file diff --git a/tasks-app-shared/src/commonMain/composeResources/values-fr/strings.xml b/tasks-app-shared/src/commonMain/composeResources/values-fr/strings.xml index 6584584c..afa78089 100644 --- a/tasks-app-shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/tasks-app-shared/src/commonMain/composeResources/values-fr/strings.xml @@ -77,6 +77,8 @@ Hier Aujourd'hui Demain + Passée + Sans date Mettre à jour Modifier la tâche diff --git a/tasks-app-shared/src/commonMain/composeResources/values/strings.xml b/tasks-app-shared/src/commonMain/composeResources/values/strings.xml index 5ccc15c1..69f6d830 100644 --- a/tasks-app-shared/src/commonMain/composeResources/values/strings.xml +++ b/tasks-app-shared/src/commonMain/composeResources/values/strings.xml @@ -77,6 +77,8 @@ Yesterday Today Tomorrow + Past + No date Update Edit task diff --git a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/TaskListsViewModel.kt b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/TaskListsViewModel.kt index fc8b4120..0cb54278 100644 --- a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/TaskListsViewModel.kt +++ b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/TaskListsViewModel.kt @@ -37,8 +37,11 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.toLocalDateTime +import net.opatry.tasks.app.ui.model.DateRange import net.opatry.tasks.app.ui.model.TaskListUIModel import net.opatry.tasks.app.ui.model.TaskUIModel +import net.opatry.tasks.app.ui.model.compareTo +import net.opatry.tasks.data.TaskListSorting import net.opatry.tasks.data.TaskRepository import net.opatry.tasks.data.model.TaskDataModel import net.opatry.tasks.data.model.TaskListDataModel @@ -46,13 +49,29 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds private fun TaskListDataModel.asTaskListUIModel(): TaskListUIModel { - // TODO children - // TODO date formatter + val (completedTasks, remainingTasks) = tasks.map(TaskDataModel::asTaskUIModel).partition(TaskUIModel::isCompleted) + + val taskGroups = when (sorting) { + // no grouping + TaskListSorting.Manual -> mapOf(null to remainingTasks) + TaskListSorting.DueDate -> remainingTasks + .sortedWith { o1, o2 -> o1.dateRange.compareTo(o2.dateRange) } + .groupBy { task -> + when (task.dateRange) { + // merge all overdue tasks to the same range + is DateRange.Overdue -> DateRange.Overdue(LocalDate.fromEpochDays(-1), 1) + else -> task.dateRange + } + } + } + return TaskListUIModel( id = id, title = title, lastUpdate = lastUpdate.toString(), - tasks = tasks.map(TaskDataModel::asTaskUIModel) + remainingTasks = taskGroups.toMap(), + completedTasks = completedTasks, + sorting = sorting, ) } @@ -141,6 +160,17 @@ class TaskListsViewModel( } } + fun sortBy(taskList: TaskListUIModel, sorting: TaskListSorting) { + viewModelScope.launch { + try { + taskRepository.sortTasksBy(taskList.id, sorting) + } catch (e: Exception) { + println("Error while sorting task list: $e") + // TODO error handling + } + } + } + fun createTask(taskList: TaskListUIModel, title: String, notes: String = "", dueDate: LocalDate? = null) { viewModelScope.launch { try { diff --git a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/TaskListMenu.kt b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/TaskListMenu.kt index 7ef77256..fed5793d 100644 --- a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/TaskListMenu.kt +++ b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/TaskListMenu.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.unit.dp import net.opatry.tasks.app.ui.model.TaskListUIModel import net.opatry.tasks.app.ui.tooling.TaskfolioPreview import net.opatry.tasks.app.ui.tooling.TaskfolioThemedPreview +import net.opatry.tasks.data.TaskListSorting import net.opatry.tasks.resources.Res import net.opatry.tasks.resources.task_list_menu_clear_all_completed_tasks import net.opatry.tasks.resources.task_list_menu_default_list_cannot_be_deleted @@ -71,7 +72,11 @@ enum class TaskListMenuAction { } @Composable -fun TaskListMenu(taskList: TaskListUIModel, expanded: Boolean, onAction: (TaskListMenuAction) -> Unit) { +fun TaskListMenu( + taskList: TaskListUIModel, + expanded: Boolean, + onAction: (TaskListMenuAction) -> Unit +) { val allowDelete by remember(taskList.canDelete) { mutableStateOf(taskList.canDelete) } DropdownMenu( @@ -90,9 +95,9 @@ fun TaskListMenu(taskList: TaskListUIModel, expanded: Boolean, onAction: (TaskLi text = { RowWithIcon( stringResource(Res.string.task_list_menu_sort_manual), - LucideIcons.Check.takeIf { false/*taskList.sorting == TaskListSorting.Manual*/ }) + LucideIcons.Check.takeIf { taskList.sorting == TaskListSorting.Manual }) }, - enabled = false, // TODO enable when sorting is implemented + enabled = taskList.sorting != TaskListSorting.Manual, onClick = { onAction(TaskListMenuAction.SortManual) } ) @@ -100,9 +105,9 @@ fun TaskListMenu(taskList: TaskListUIModel, expanded: Boolean, onAction: (TaskLi text = { RowWithIcon( stringResource(Res.string.task_list_menu_sort_due_date), - LucideIcons.Check.takeIf { false/*taskList.sorting == TaskListSorting.Date*/ }) + LucideIcons.Check.takeIf { taskList.sorting == TaskListSorting.DueDate }) }, - enabled = false, // TODO enable when sorting is implemented + enabled = taskList.sorting != TaskListSorting.DueDate, onClick = { onAction(TaskListMenuAction.SortDate) } ) @@ -161,7 +166,7 @@ private fun TaskListMenuPreview() { ) { IconButton(onClick = { showMenu = true }) { Icon(LucideIcons.EllipsisVertical, null) - TaskListMenu(TaskListUIModel(0L, "My task list", "TODO DATE", tasks = emptyList()), showMenu) {} + TaskListMenu(TaskListUIModel(0L, "My task list", "TODO DATE"), showMenu) {} } } } diff --git a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/TaskMenu.kt b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/TaskMenu.kt index e3bdffe3..7491160a 100644 --- a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/TaskMenu.kt +++ b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/TaskMenu.kt @@ -36,10 +36,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.text.style.TextOverflow import net.opatry.tasks.app.ui.model.TaskListUIModel import net.opatry.tasks.app.ui.model.TaskUIModel @@ -53,33 +49,38 @@ import net.opatry.tasks.resources.task_menu_new_list import net.opatry.tasks.resources.task_menu_unindent import org.jetbrains.compose.resources.stringResource -sealed class TaskMenuAction { - data object Dismiss : TaskMenuAction() - data object AddSubTask : TaskMenuAction() - data object MoveToTop : TaskMenuAction() - data object Unindent : TaskMenuAction() - data object Indent : TaskMenuAction() - data class MoveToList(val targetParentList: TaskListUIModel) : TaskMenuAction() - data object MoveToNewList : TaskMenuAction() - data object Delete : TaskMenuAction() +sealed class TaskAction { + data object ToggleCompletion : TaskAction() + data object Edit : TaskAction() + data object UpdateDueDate : TaskAction() + data object AddSubTask : TaskAction() + data object MoveToTop : TaskAction() + data object Unindent : TaskAction() + data object Indent : TaskAction() + data class MoveToList(val targetParentList: TaskListUIModel) : TaskAction() + data object MoveToNewList : TaskAction() + data object Delete : TaskAction() } @Composable -fun TaskMenu(taskLists: List, task: TaskUIModel, expanded: Boolean, onAction: (TaskMenuAction) -> Unit) { - val currentTaskList = taskLists.firstOrNull { it.tasks.map(TaskUIModel::id).contains(task.id) } - val taskPosition by remember(currentTaskList) { mutableStateOf(currentTaskList?.tasks?.indexOf(task) ?: -1) } - val canMoveToTop by remember(task) { derivedStateOf { taskPosition > 0 && task.canIndent } } +fun TaskMenu( + taskLists: List, + task: TaskUIModel, + expanded: Boolean, + onAction: (TaskAction?) -> Unit +) { + val currentTaskList = taskLists.firstOrNull { it.containsTask(task) } DropdownMenu( expanded = expanded, - onDismissRequest = { onAction(TaskMenuAction.Dismiss) } + onDismissRequest = { onAction(null) } ) { - if (canMoveToTop) { + if (task.canMoveToTop) { DropdownMenuItem( text = { RowWithIcon(stringResource(Res.string.task_menu_move_to_top)) }, - onClick = { onAction(TaskMenuAction.MoveToTop) }, + onClick = { onAction(TaskAction.MoveToTop) }, enabled = false ) } @@ -89,17 +90,17 @@ fun TaskMenu(taskLists: List, task: TaskUIModel, expanded: Bool text = { RowWithIcon(stringResource(Res.string.task_menu_add_subtask), LucideIcons.SquareStack) }, - onClick = { onAction(TaskMenuAction.AddSubTask) }, + onClick = { onAction(TaskAction.AddSubTask) }, enabled = false ) } - if (task.canIndent && taskPosition > 0) { + if (task.canIndent) { DropdownMenuItem( text = { RowWithIcon(stringResource(Res.string.task_menu_indent)) }, - onClick = { onAction(TaskMenuAction.Indent) }, + onClick = { onAction(TaskAction.Indent) }, enabled = false ) } @@ -109,7 +110,7 @@ fun TaskMenu(taskLists: List, task: TaskUIModel, expanded: Bool text = { RowWithIcon(stringResource(Res.string.task_menu_unindent)) }, - onClick = { onAction(TaskMenuAction.Unindent) }, + onClick = { onAction(TaskAction.Unindent) }, enabled = false ) } @@ -128,7 +129,7 @@ fun TaskMenu(taskLists: List, task: TaskUIModel, expanded: Bool text = { RowWithIcon(stringResource(Res.string.task_menu_new_list), LucideIcons.ListPlus) }, - onClick = { onAction(TaskMenuAction.MoveToNewList) }, + onClick = { onAction(TaskAction.MoveToNewList) }, enabled = false, ) @@ -147,7 +148,7 @@ fun TaskMenu(taskLists: List, task: TaskUIModel, expanded: Bool } }, enabled = taskList.id != currentTaskList?.id, - onClick = { onAction(TaskMenuAction.MoveToList(taskList)) } + onClick = { onAction(TaskAction.MoveToList(taskList)) } ) } } @@ -160,7 +161,7 @@ fun TaskMenu(taskLists: List, task: TaskUIModel, expanded: Bool RowWithIcon(stringResource(Res.string.task_menu_delete), LucideIcons.Trash2) } }, - onClick = { onAction(TaskMenuAction.Delete) } + onClick = { onAction(TaskAction.Delete) } ) } } \ No newline at end of file diff --git a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/model/TaskListUIModel.kt b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/model/TaskListUIModel.kt index 18639360..61d317ec 100644 --- a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/model/TaskListUIModel.kt +++ b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/model/TaskListUIModel.kt @@ -22,18 +22,24 @@ package net.opatry.tasks.app.ui.model -enum class TaskListSorting { - Manual, - Date, -} +import net.opatry.tasks.data.TaskListSorting + data class TaskListUIModel( val id: Long, val title: String, val lastUpdate: String, - val tasks: List, + val remainingTasks: Map> = emptyMap(), + val completedTasks: List = emptyList(), + val sorting: TaskListSorting = TaskListSorting.Manual, ) { - val isEmpty: Boolean = tasks.isEmpty() - val hasCompletedTasks: Boolean = tasks.any { it.isCompleted } + fun containsTask(task: TaskUIModel, includeCompleted: Boolean = false): Boolean { + return remainingTasks.values.flatten().contains(task) + || (includeCompleted && completedTasks.contains(task)) + } + + val isEmpty: Boolean = remainingTasks.isEmpty() && completedTasks.isEmpty() + val hasCompletedTasks: Boolean = completedTasks.isNotEmpty() + val isEmptyRemainingTasksVisible: Boolean = remainingTasks.isEmpty() && hasCompletedTasks val canDelete: Boolean = true // FIXME default list can't be deleted, how to know it? } diff --git a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/model/TaskUIModel.kt b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/model/TaskUIModel.kt index 0b2a8ee8..d6448613 100644 --- a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/model/TaskUIModel.kt +++ b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/model/TaskUIModel.kt @@ -29,18 +29,33 @@ import kotlinx.datetime.daysUntil import kotlinx.datetime.toLocalDateTime sealed class DateRange { + open val date: LocalDate? = null + open val numberOfDays: Int? = null data object None : DateRange() - data class Overdue(val date: LocalDate, val numberOfDays: Int) : DateRange() - data object Yesterday : DateRange() - data object Today : DateRange() - data object Tomorrow : DateRange() - data class Later(val date: LocalDate, val numberOfDays: Int) : DateRange() + data class Overdue(override val date: LocalDate, override val numberOfDays: Int) : DateRange() + data class Today(override val date: LocalDate) : DateRange() { + override val numberOfDays: Int = 0 + } + data class Later(override val date: LocalDate, override val numberOfDays: Int) : DateRange() +} + +operator fun DateRange.compareTo(other: DateRange): Int { + // local variable for non null smart cast convenience + val lhsNumberOfDays = this.numberOfDays + val rhsNumberOfDays = other.numberOfDays + // No date should come last + return when { + lhsNumberOfDays == null -> 1 + rhsNumberOfDays == null -> -1 + else -> lhsNumberOfDays.compareTo(rhsNumberOfDays) + } } data class TaskUIModel( val id: Long, val title: String, val dueDate: LocalDate? = null, + val completionDate: LocalDate? = null, val notes: String = "", val isCompleted: Boolean = false, val position: String = "", // FIXME for debug? @@ -56,16 +71,14 @@ data class TaskUIModel( val daysUntilDueDate = todayLocalDate.daysUntil(dueLocalDate) return when { - daysUntilDueDate < -1 -> DateRange.Overdue(dueLocalDate, -daysUntilDueDate) - daysUntilDueDate == -1 -> DateRange.Yesterday - daysUntilDueDate == 0 -> DateRange.Today - daysUntilDueDate == 1 -> DateRange.Tomorrow - // daysUntilDueDate > 1 - else -> DateRange.Later(dueLocalDate, daysUntilDueDate) + daysUntilDueDate < 0 -> DateRange.Overdue(dueLocalDate, daysUntilDueDate) + daysUntilDueDate > 0 -> DateRange.Later(dueLocalDate, daysUntilDueDate) + else -> DateRange.Today(dueLocalDate) } } + val canMoveToTop: Boolean = false // TODO not in first position in list val canUnindent: Boolean = indent > 0 - val canIndent: Boolean = indent < 1 + val canIndent: Boolean = indent < 1 // TODO & not first position in list val canCreateSubTask: Boolean = indent == 0 } diff --git a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/screen/taskListsPane.kt b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/screen/taskListsPane.kt index 357c5c1c..a125760b 100644 --- a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/screen/taskListsPane.kt +++ b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/screen/taskListsPane.kt @@ -135,7 +135,6 @@ private fun TaskListRowScaffold( id = 0L, title = title, "TODO DATE", - tasks = emptyList(), ), isSelected = isSelected, onClick = {} diff --git a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/screen/tasksPane.kt b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/screen/tasksPane.kt index 37a596e8..70bb570d 100644 --- a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/screen/tasksPane.kt +++ b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/screen/tasksPane.kt @@ -118,18 +118,21 @@ import net.opatry.tasks.app.ui.component.EditTextDialog import net.opatry.tasks.app.ui.component.EmptyState import net.opatry.tasks.app.ui.component.MissingScreen import net.opatry.tasks.app.ui.component.RowWithIcon +import net.opatry.tasks.app.ui.component.TaskAction import net.opatry.tasks.app.ui.component.TaskListMenu import net.opatry.tasks.app.ui.component.TaskListMenuAction import net.opatry.tasks.app.ui.component.TaskMenu -import net.opatry.tasks.app.ui.component.TaskMenuAction import net.opatry.tasks.app.ui.model.DateRange import net.opatry.tasks.app.ui.model.TaskListUIModel import net.opatry.tasks.app.ui.model.TaskUIModel import net.opatry.tasks.app.ui.tooling.TaskfolioPreview import net.opatry.tasks.app.ui.tooling.TaskfolioThemedPreview +import net.opatry.tasks.data.TaskListSorting import net.opatry.tasks.resources.Res import net.opatry.tasks.resources.dialog_cancel import net.opatry.tasks.resources.task_due_date_label_days_ago +import net.opatry.tasks.resources.task_due_date_label_no_date +import net.opatry.tasks.resources.task_due_date_label_past import net.opatry.tasks.resources.task_due_date_label_today import net.opatry.tasks.resources.task_due_date_label_tomorrow import net.opatry.tasks.resources.task_due_date_label_weeks_ago @@ -164,6 +167,8 @@ import net.opatry.tasks.resources.task_list_pane_task_restored_snackbar import net.opatry.tasks.resources.task_lists_screen_empty_list_desc import net.opatry.tasks.resources.task_lists_screen_empty_list_title import org.jetbrains.compose.resources.stringResource +import kotlin.math.abs + @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -231,8 +236,8 @@ fun TaskListDetail( showTaskListActions = false when (action) { TaskListMenuAction.Dismiss -> Unit - TaskListMenuAction.SortManual -> {} - TaskListMenuAction.SortDate -> {} + TaskListMenuAction.SortManual -> viewModel.sortBy(taskList, TaskListSorting.Manual) + TaskListMenuAction.SortDate -> viewModel.sortBy(taskList, TaskListSorting.DueDate) TaskListMenuAction.Rename -> showRenameTaskListDialog = true TaskListMenuAction.ClearCompletedTasks -> showClearTaskListCompletedTasksDialog = true TaskListMenuAction.Delete -> showDeleteTaskListDialog = true @@ -583,25 +588,12 @@ fun TasksColumn( onDeleteTask: (TaskUIModel) -> Unit, ) { var showCompleted by remember(taskList.id) { mutableStateOf(false) } - val tasks = taskList.tasks - - // TODO depending on sorting (manual vs date), sections could be different - // manual: no section title for not completed tasks, expandable "completed" section - // date: sections by date cluster (past, today, tomorrow, future), expandable "completed" section - - // FIXME remember computation & derived states - val groupedTasks = tasks.sortedBy { it.isCompleted }.groupBy { it.isCompleted }.toMutableMap() - val todoCount = groupedTasks[false]?.size ?: 0 - val completedCount = groupedTasks[true]?.size ?: 0 - if (!showCompleted) { - groupedTasks[true] = emptyList() - } LazyColumn( contentPadding = PaddingValues(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - if (completedCount > 0 && todoCount == 0) { + if (taskList.isEmptyRemainingTasksVisible) { item(key = "all_tasks_complete") { EmptyState( icon = LucideIcons.CheckCheck, @@ -611,48 +603,86 @@ fun TasksColumn( ) } } - groupedTasks.forEach { (completed, tasks) -> - if (completed && completedCount > 0) { - stickyHeader(key = "completed") { + + taskList.remainingTasks.forEach { (dateRange, tasks) -> + if (dateRange != null) { + stickyHeader(key = dateRange) { Box( Modifier - .clip(MaterialTheme.shapes.large) .fillMaxWidth() - .clickable { showCompleted = !showCompleted } .background(MaterialTheme.colorScheme.background) .padding(horizontal = 12.dp, vertical = 8.dp) ) { - RowWithIcon( - icon = { - if (showCompleted) { - Icon(LucideIcons.ChevronDown, null) - } else { - Icon(LucideIcons.ChevronRight, null) - } - } - ) { - Text( - stringResource(Res.string.task_list_pane_completed_section_title_with_count, completedCount), - style = MaterialTheme.typography.titleSmall - ) - } + Text( + dateRange.toLabel(sectionLabel = true), + style = MaterialTheme.typography.titleSmall, + color = dateRange.toColor(), + ) } } } items(tasks, key = TaskUIModel::id) { task -> - TaskRow( + RemainingTaskRow( taskLists, task, - onToggleCompletionState = { onToggleCompletionState(task) }, - onEditTask = { onEditTask(task) }, - onUpdateDueDate = { onUpdateDueDate(task) }, - onNewSubTask = { onNewSubTask(task) }, - onUnindent = { onUnindent(task) }, - onIndent = { onIndent(task) }, - onMoveToTop = { onMoveToTop(task) }, - onMoveToList = { onMoveToList(task, it) }, - onMoveToNewList = { onMoveToNewList(task) }, - onDeleteTask = { onDeleteTask(task) }, + showDate = taskList.sorting == TaskListSorting.Manual || dateRange is DateRange.Overdue + ) { action -> + when (action) { + TaskAction.ToggleCompletion -> onToggleCompletionState(task) + TaskAction.Edit -> onEditTask(task) + TaskAction.UpdateDueDate -> onUpdateDueDate(task) + TaskAction.AddSubTask -> onNewSubTask(task) + TaskAction.Unindent -> onUnindent(task) + TaskAction.Indent -> onIndent(task) + TaskAction.MoveToTop -> onMoveToTop(task) + is TaskAction.MoveToList -> onMoveToList(task, action.targetParentList) + TaskAction.MoveToNewList -> onMoveToNewList(task) + TaskAction.Delete -> onDeleteTask(task) + } + } + } + } + + if (taskList.hasCompletedTasks) { + stickyHeader(key = "completed") { + Box( + Modifier + .clip(MaterialTheme.shapes.large) + .fillMaxWidth() + .clickable { showCompleted = !showCompleted } + .background(MaterialTheme.colorScheme.background) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + RowWithIcon( + icon = { + when { + showCompleted -> Icon(LucideIcons.ChevronDown, null) + else -> Icon(LucideIcons.ChevronRight, null) + } + } + ) { + Text( + stringResource(Res.string.task_list_pane_completed_section_title_with_count, taskList.completedTasks.size), + style = MaterialTheme.typography.titleSmall + ) + } + } + } + } + + if (showCompleted) { + items(taskList.completedTasks, key = TaskUIModel::id) { task -> + CompletedTaskRow( + task, + onAction = { action -> + when (action) { + TaskAction.ToggleCompletion -> onToggleCompletionState(task) + TaskAction.Edit -> onEditTask(task) + TaskAction.UpdateDueDate -> onUpdateDueDate(task) + TaskAction.Delete -> onDeleteTask(task) + else -> Unit + } + }, ) } } @@ -661,32 +691,30 @@ fun TasksColumn( @Composable fun DateRange?.toColor(): Color = when (this) { - is DateRange.Overdue, - DateRange.Yesterday -> MaterialTheme.colorScheme.error - - DateRange.Today -> MaterialTheme.colorScheme.primary - DateRange.Tomorrow, + is DateRange.Overdue -> MaterialTheme.colorScheme.error + is DateRange.Today -> MaterialTheme.colorScheme.primary is DateRange.Later, DateRange.None, null -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) } @Composable -fun DateRange.toLabel(): String = when (this) { +fun DateRange.toLabel(sectionLabel: Boolean = false): String = when (this) { is DateRange.Overdue -> { - if (numberOfDays < 7) { - stringResource(Res.string.task_due_date_label_days_ago, numberOfDays) - } else { - stringResource(Res.string.task_due_date_label_weeks_ago, numberOfDays / 7) + val numberOfDays = abs(numberOfDays) + when { + sectionLabel -> stringResource(Res.string.task_due_date_label_past) + numberOfDays == 1 -> stringResource(Res.string.task_due_date_label_yesterday) + numberOfDays < 7 -> stringResource(Res.string.task_due_date_label_days_ago, numberOfDays) + else -> stringResource(Res.string.task_due_date_label_weeks_ago, numberOfDays / 7) } } - DateRange.Yesterday -> stringResource(Res.string.task_due_date_label_yesterday) - DateRange.Today -> stringResource(Res.string.task_due_date_label_today) - DateRange.Tomorrow -> stringResource(Res.string.task_due_date_label_tomorrow) - // TODO localize names & format - is DateRange.Later -> LocalDate.Format { - if (date.year == Clock.System.todayIn(TimeZone.currentSystemDefault()).year) { + is DateRange.Today -> stringResource(Res.string.task_due_date_label_today) + is DateRange.Later -> when { + numberOfDays == 1 -> stringResource(Res.string.task_due_date_label_tomorrow) + // TODO localize names & format + date.year == Clock.System.todayIn(TimeZone.currentSystemDefault()).year -> LocalDate.Format { // FIXME doesn't work with more than 2 dd or MM // byUnicodePattern("ddd', 'MMM' 'yyyy") dayOfWeek(DayOfWeekNames.ENGLISH_ABBREVIATED) // TODO translation @@ -694,7 +722,9 @@ fun DateRange.toLabel(): String = when (this) { monthName(MonthNames.ENGLISH_ABBREVIATED) // TODO translation char(' ') dayOfMonth(Padding.NONE) - } else { + }.format(date) + + else -> LocalDate.Format { // FIXME doesn't work with more than 2 MM // byUnicodePattern("MMMM' 'dd', 'yyyy") monthName(MonthNames.ENGLISH_FULL) // TODO translation @@ -702,41 +732,31 @@ fun DateRange.toLabel(): String = when (this) { dayOfMonth(Padding.NONE) chars(", ") year() - } - }.format(date) + }.format(date) + } - DateRange.None -> "" + DateRange.None -> when { + sectionLabel -> stringResource(Res.string.task_due_date_label_no_date) + else -> "" + } } @Composable -fun TaskRow( +private fun RemainingTaskRow( taskLists: List, task: TaskUIModel, - onToggleCompletionState: () -> Unit, - onEditTask: () -> Unit, - onUpdateDueDate: () -> Unit, - onNewSubTask: () -> Unit, - onUnindent: () -> Unit, - onIndent: () -> Unit, - onMoveToTop: () -> Unit, - onMoveToList: (TaskListUIModel) -> Unit, - onMoveToNewList: () -> Unit, - onDeleteTask: () -> Unit, + modifier: Modifier = Modifier, + showDate: Boolean = true, + onAction: (TaskAction) -> Unit, ) { var showContextualMenu by remember { mutableStateOf(false) } - // TODO remember? - val (taskCheckIcon, taskCheckIconColor) = when { - task.isCompleted -> LucideIcons.CircleCheckBig to MaterialTheme.colorScheme.primary - else -> LucideIcons.Circle to MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - } - - Row(Modifier.clickable(onClick = onEditTask)) { + Row(modifier.clickable(onClick = { onAction(TaskAction.Edit) })) { IconButton( - onClick = onToggleCompletionState, - Modifier.padding(start = 36.dp * task.indent) + onClick = { onAction(TaskAction.ToggleCompletion) }, + modifier = Modifier.padding(start = 36.dp * task.indent) ) { - Icon(taskCheckIcon, null, tint = taskCheckIconColor) + Icon(LucideIcons.Circle, null, tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)) } Column( Modifier @@ -745,7 +765,6 @@ fun TaskRow( ) { Text( task.title, - textDecoration = TextDecoration.LineThrough.takeIf { task.isCompleted }, style = MaterialTheme.typography.titleMedium, overflow = TextOverflow.Ellipsis, maxLines = 1 @@ -759,46 +778,78 @@ fun TaskRow( maxLines = 2 ) } - if (task.dueDate != null) { + if (showDate && task.dueDate != null) { AssistChip( - onClick = onUpdateDueDate, + onClick = { onAction(TaskAction.UpdateDueDate) }, shape = MaterialTheme.shapes.large, label = { Text( task.dateRange.toLabel(), - color = if (task.isCompleted) - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - else - task.dateRange.toColor() + color = task.dateRange.toColor() ) }, ) } } - if (task.isCompleted) { - IconButton(onClick = onDeleteTask) { - Icon(LucideIcons.Trash, stringResource(Res.string.task_list_pane_delete_task_icon_content_desc)) + Box { + IconButton(onClick = { showContextualMenu = true }) { + Icon(LucideIcons.EllipsisVertical, stringResource(Res.string.task_list_pane_task_options_icon_content_desc)) } - } else { - Box { - IconButton(onClick = { showContextualMenu = true }) { - Icon(LucideIcons.EllipsisVertical, stringResource(Res.string.task_list_pane_task_options_icon_content_desc)) - } - TaskMenu(taskLists, task, showContextualMenu) { action -> - showContextualMenu = false - when (action) { - TaskMenuAction.Dismiss -> Unit - TaskMenuAction.AddSubTask -> onNewSubTask() - TaskMenuAction.Indent -> onIndent() - TaskMenuAction.Unindent -> onUnindent() - TaskMenuAction.MoveToTop -> onMoveToTop() - is TaskMenuAction.MoveToList -> onMoveToList(action.targetParentList) - TaskMenuAction.MoveToNewList -> onMoveToNewList() - TaskMenuAction.Delete -> onDeleteTask() - } - } + TaskMenu(taskLists, task, showContextualMenu) { action -> + showContextualMenu = false + action?.let(onAction) + } + } + } +} + +@Composable +private fun CompletedTaskRow( + task: TaskUIModel, + onAction: (TaskAction) -> Unit, +) { + Row(Modifier.clickable(onClick = { onAction(TaskAction.Edit) })) { + IconButton(onClick = { onAction(TaskAction.ToggleCompletion) }) { + Icon(LucideIcons.CircleCheckBig, null, tint = MaterialTheme.colorScheme.primary) + } + Column( + Modifier + .weight(1f) + .padding(vertical = 8.dp) + ) { + Text( + task.title, + textDecoration = TextDecoration.LineThrough, + style = MaterialTheme.typography.titleMedium, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + + if (task.notes.isNotBlank()) { + Text( + task.notes, + style = MaterialTheme.typography.bodySmall, + overflow = TextOverflow.Ellipsis, + maxLines = 2 + ) + } + if (task.dueDate != null) { + AssistChip( + onClick = { onAction(TaskAction.UpdateDueDate) }, + shape = MaterialTheme.shapes.large, + label = { + Text( + task.dateRange.toLabel(), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + }, + ) } } + + IconButton(onClick = { onAction(TaskAction.Delete) }) { + Icon(LucideIcons.Trash, stringResource(Res.string.task_list_pane_delete_task_icon_content_desc)) + } } } @@ -809,7 +860,7 @@ private fun TaskRowScaffold( dueDate: LocalDate? = Clock.System.todayIn(TimeZone.currentSystemDefault()), isCompleted: Boolean = false ) { - TaskRow( + RemainingTaskRow( emptyList(), TaskUIModel( id = 0L, @@ -817,18 +868,8 @@ private fun TaskRowScaffold( notes = notes, dueDate = dueDate, isCompleted = isCompleted - ), - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {} - ) + ) + ) {} } @TaskfolioPreview diff --git a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/data/TasksAppDatabase.kt b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/data/TasksAppDatabase.kt index 553db297..87f72f55 100644 --- a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/data/TasksAppDatabase.kt +++ b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/data/TasksAppDatabase.kt @@ -41,6 +41,12 @@ object Converters { @TypeConverter fun instantToString(instant: Instant?): String? = instant?.toString() + + @TypeConverter + fun sortingFromString(value: String?): TaskListEntity.Sorting? = value?.let(TaskListEntity.Sorting::valueOf) + + @TypeConverter + fun sortingToString(sorting: TaskListEntity.Sorting?): String? = sorting?.name } @Database( @@ -49,9 +55,10 @@ object Converters { TaskEntity::class, UserEntity::class, ], - version = 2, + version = 3, autoMigrations = [ - AutoMigration(from = 1, to = 2) + AutoMigration(from = 1, to = 2), // add user table + AutoMigration(from = 2, to = 3), // add sorting column in task_list table ], ) @ConstructedBy(TasksAppDatabaseConstructor::class) diff --git a/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/TaskListDao.kt b/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/TaskListDao.kt index 78933f83..ad71d1f7 100644 --- a/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/TaskListDao.kt +++ b/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/TaskListDao.kt @@ -61,4 +61,7 @@ LEFT JOIN task ON task_list.local_id = task.parent_list_local_id ORDER BY task_l @Query("DELETE FROM task_list WHERE remote_id IS NOT NULL AND remote_id NOT IN (:validRemoteIds)") suspend fun deleteStaleTaskLists(validRemoteIds: List) + + @Query("UPDATE task_list SET sorting = :sorting WHERE local_id = :taskListId") + suspend fun sortTasksBy(taskListId: Long, sorting: TaskListEntity.Sorting) } \ No newline at end of file diff --git a/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/TaskRepository.kt b/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/TaskRepository.kt index aad70b58..effa7e08 100644 --- a/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/TaskRepository.kt +++ b/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/TaskRepository.kt @@ -42,13 +42,19 @@ import net.opatry.tasks.data.entity.TaskListEntity import net.opatry.tasks.data.model.TaskDataModel import net.opatry.tasks.data.model.TaskListDataModel -private fun TaskList.asTaskListEntity(localId: Long?): TaskListEntity { +enum class TaskListSorting { + Manual, + DueDate, +} + +private fun TaskList.asTaskListEntity(localId: Long?, sorting: TaskListEntity.Sorting): TaskListEntity { return TaskListEntity( id = localId ?: 0, remoteId = id, etag = etag, title = title, lastUpdateDate = updatedDate, + sorting = sorting, ) } @@ -71,14 +77,21 @@ private fun Task.asTaskEntity(parentLocalId: Long, localId: Long?): TaskEntity { } private fun TaskListEntity.asTaskListDataModel(tasks: List): TaskListDataModel { - val sortedTasks = sortTasks(tasks).map { (task, indent) -> - task.asTaskDataModel(indent) + val (sorting, sortedTasks) = when (sorting) { + TaskListEntity.Sorting.UserDefined -> TaskListSorting.Manual to sortTasksManualOrdering(tasks).map { (task, indent) -> + task.asTaskDataModel(indent) + } + + TaskListEntity.Sorting.DueDate -> TaskListSorting.DueDate to sortTasksDateOrdering(tasks).map { task -> + task.asTaskDataModel(0) + } } return TaskListDataModel( id = id, title = title, lastUpdate = lastUpdateDate, tasks = sortedTasks, + sorting = sorting ) } @@ -109,7 +122,7 @@ private fun TaskEntity.asTask(): Task { ) } -fun sortTasks(tasks: List): List> { +fun sortTasksManualOrdering(tasks: List): List> { // Step 1: Create a map of tasks by their IDs for easy lookup val taskMap = tasks.associateBy { it.remoteId ?: it.id.toString() }.toMutableMap() // FIXME local data only? @@ -128,7 +141,7 @@ fun sortTasks(tasks: List): List> { // Step 3: Sort the child tasks by position tree.forEach { (_, children) -> - children.sortBy { it.position } + children.sortBy(TaskEntity::position) } // Step 4: Recursive function to traverse tasks and assign indentation levels @@ -150,6 +163,10 @@ fun sortTasks(tasks: List): List> { return sortedTasks } +fun sortTasksDateOrdering(tasks: List): List { + return tasks.sortedBy(TaskEntity::dueDate) +} + class TaskRepository( private val taskListDao: TaskListDao, private val taskDao: TaskDao, @@ -188,8 +205,8 @@ class TaskRepository( // - check new ones // - etc. val existingEntity = taskListDao.getByRemoteId(remoteTaskList.id) - val finalLocalId = taskListDao.insert(remoteTaskList.asTaskListEntity(existingEntity?.id)) - println("task list ${remoteTaskList.etag} vs ${existingEntity?.etag}) with final local id $finalLocalId") + val updatedEntity = remoteTaskList.asTaskListEntity(existingEntity?.id, existingEntity?.sorting ?: TaskListEntity.Sorting.UserDefined) + val finalLocalId = taskListDao.insert(updatedEntity) taskListIds[finalLocalId] = remoteTaskList.id } taskListDao.deleteStaleTaskLists(remoteTaskLists.map(TaskList::id)) @@ -237,7 +254,7 @@ class TaskRepository( } } if (taskList != null) { - taskListDao.insert(taskList.asTaskListEntity(taskListId)) + taskListDao.insert(taskList.asTaskListEntity(taskListId, TaskListEntity.Sorting.UserDefined)) } } @@ -304,6 +321,15 @@ class TaskRepository( } } + suspend fun sortTasksBy(taskListId: Long, sorting: TaskListSorting) { + val dbSorting = when (sorting) { + TaskListSorting.Manual -> TaskListEntity.Sorting.UserDefined + TaskListSorting.DueDate -> TaskListEntity.Sorting.DueDate + } + // no update date change, it's a local only information unrelated to remote tasks + taskListDao.sortTasksBy(taskListId, dbSorting) + } + suspend fun createTask(taskListId: Long, title: String, notes: String = "", dueDate: Instant? = null) { val now = Clock.System.now() val taskEntity = TaskEntity( diff --git a/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/entity/TaskListEntity.kt b/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/entity/TaskListEntity.kt index 25a60fac..1613eeea 100644 --- a/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/entity/TaskListEntity.kt +++ b/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/entity/TaskListEntity.kt @@ -41,5 +41,12 @@ data class TaskListEntity( val title: String, @ColumnInfo(name = "update_date") val lastUpdateDate: Instant, -) + @ColumnInfo(name = "sorting", defaultValue = "UserDefined") // tightly coupled to converters & enum/string mapping + val sorting: Sorting = Sorting.UserDefined, +) { + enum class Sorting { + UserDefined, + DueDate, + } +} diff --git a/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/model/TaskListDataModel.kt b/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/model/TaskListDataModel.kt index 5886cdd1..6c2212f6 100644 --- a/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/model/TaskListDataModel.kt +++ b/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/model/TaskListDataModel.kt @@ -23,10 +23,12 @@ package net.opatry.tasks.data.model import kotlinx.datetime.Instant +import net.opatry.tasks.data.TaskListSorting data class TaskListDataModel( val id: Long, val title: String, val lastUpdate: Instant, val tasks: List, + val sorting: TaskListSorting, )