diff --git a/App/Sources/Enumerations/TodoGroup.swift b/App/Sources/Enumerations/TodoGroup.swift index cb0b8ea..684624b 100644 --- a/App/Sources/Enumerations/TodoGroup.swift +++ b/App/Sources/Enumerations/TodoGroup.swift @@ -5,16 +5,6 @@ import SwiftUI /// /// This enumeration provides a semantic grouping mechanism for organizing and filtering todo tasks throughout the application. /// Each case represents a distinct category that helps users manage their tasks based on temporal relevance and completion state. -/// -/// ## Features -/// - Provides localized titles and icons for each group -/// - Supports filtering of completed tasks within temporal groups -/// - Enables color-coded visual categorization -/// -/// ## Groups -/// Groups are categorized into two types: -/// - **Temporal groups** (`.today`, `.upcoming`, `.overdue`): Time-based categorization that can optionally include completed tasks -/// - **Status group** (`.completed`): Contains all completed tasks regardless of due date enum TodoGroup { /// Tasks that have been marked as done, regardless of due date. case completed @@ -33,14 +23,19 @@ extension TodoGroup { /// Indicates whether this group supports toggling the visibility of completed tasks. /// /// This property determines if a show/hide completed tasks action should be available for the group. - /// - /// - Returns: `true` for temporal groups (`.today`, `.upcoming`, `.overdue`) that can optionally - /// show or hide completed tasks. Returns `false` for the `.completed` group since it exclusively - /// displays completed tasks. var canShowCompleted: Bool { self != .completed } + /// Indicates whether tasks in this group can be reordered through drag and drop. + /// + /// This property determines if the user should be able to manually reorder tasks within the group. + /// Reordering is useful for temporal groups where users may want to prioritize certain tasks, + /// but is disabled for completed tasks since they are typically sorted by completion date. + var canReorder: Bool { + self != .completed + } + /// The color associated with the task group. var color: Color { switch self { diff --git a/App/Sources/Extensions/TodoFetchDescriptor+Inits.swift b/App/Sources/Extensions/TodoFetchDescriptor+Inits.swift index ad3382d..1acbafb 100644 --- a/App/Sources/Extensions/TodoFetchDescriptor+Inits.swift +++ b/App/Sources/Extensions/TodoFetchDescriptor+Inits.swift @@ -13,9 +13,12 @@ extension FetchDescriptor where T == Todo { /// relevance and completion status. Each group applies appropriate predicates and sort orders: /// /// - **Completed**: All tasks marked as done, sorted by completion date (most recent first) - /// - **Overdue**: Incomplete tasks with due dates before today, sorted by due date (most recent first) - /// - **Today**: Incomplete tasks due today, sorted by due date (earliest first) - /// - **Upcoming**: Incomplete tasks due after today, sorted by due date (earliest first) + /// - **Overdue**: Incomplete tasks with due dates before today, sorted by user-defined order, then by due date (most recent first) + /// - **Today**: Incomplete tasks due today, sorted by user-defined order, then by due date (earliest first) + /// - **Upcoming**: Incomplete tasks due after today, sorted by user-defined order, then by due date (earliest first) + /// + /// The user-defined order allows tasks to be manually reordered within each group through drag and drop, + /// while maintaining a secondary sort by date for tasks with the same order value. /// /// - Parameters: /// - group: The todo group category to filter by. @@ -52,11 +55,11 @@ extension FetchDescriptor where T == Todo { case .completed: [.init(\Todo.completed, order: .reverse)] case .overdue: - [.init(\Todo.due, order: .reverse)] + [.init(\Todo.order, order: .forward), .init(\Todo.due, order: .reverse)] case .today: - [.init(\Todo.due, order: .forward)] + [.init(\Todo.order, order: .forward), .init(\Todo.due, order: .forward)] case .upcoming: - [.init(\Todo.due, order: .forward)] + [.init(\Todo.order, order: .forward), .init(\Todo.due, order: .forward)] } self.init( diff --git a/App/Sources/Models/Todo.swift b/App/Sources/Models/Todo.swift index b127928..6b0fafe 100644 --- a/App/Sources/Models/Todo.swift +++ b/App/Sources/Models/Todo.swift @@ -16,6 +16,9 @@ final class Todo { /// An optional note providing additional details about the todo task. var note: String? + /// The display order of the todo task. + var order: Int + /// The title of the todo task. var title: String @@ -27,15 +30,18 @@ final class Todo { /// - note: An optional note providing additional details. /// - due: The date by which the todo should be completed. /// - completed: The date when the todo was completed, if applicable. + /// - order: The display order of the todo task. init( title: String, note: String? = nil, due: Date, - completed: Date? = nil + completed: Date? = nil, + order: Int = 0 ) { self.completed = completed self.due = due self.note = note + self.order = order self.title = title } diff --git a/App/Sources/View Models/TaskListViewModel.swift b/App/Sources/View Models/TaskListViewModel.swift index 2db3eb9..ac92387 100644 --- a/App/Sources/View Models/TaskListViewModel.swift +++ b/App/Sources/View Models/TaskListViewModel.swift @@ -15,17 +15,12 @@ extension TaskListView { /// - Provides the localized title for the navigation bar /// - Determines whether the completion filter toggle should be displayed /// - Provides label and icon configuration for the completion toggle button + /// - Handles task reordering through drag and drop interactions @Observable final class ViewModel { // MARK: Properties - /// The task group to display tasks for. - /// - /// This property determines which tasks are fetched and displayed in the list. - /// It is used by the content view to initialize the SwiftData query with the appropriate predicates and sort descriptors for the group. - let group: TodoGroup - /// Whether to show completed tasks in the list. /// /// This property controls the inclusion of completed tasks in the query results: @@ -33,6 +28,13 @@ extension TaskListView { /// - `false`: Completed tasks are filtered out at the database level var showCompleted: Bool + /// The task group to display tasks for. + /// + /// This property determines which tasks are fetched and displayed in the list. + /// It is used by the content view to initialize the SwiftData query with the appropriate + /// predicates and sort descriptors for the group. + let group: TodoGroup + // MARK: Initialization /// Initializes a new view model instance for a specific task group. @@ -40,7 +42,7 @@ extension TaskListView { /// - Parameter group: The task group to display tasks for. init(group: TodoGroup) { self.group = group - showCompleted = false + self.showCompleted = false } // MARK: Computed Properties @@ -82,6 +84,36 @@ extension TaskListView { // MARK: Methods + /// Reorders tasks after a drag and drop operation. + /// + /// This method is called when users drag and drop tasks to reorder them within a group. + /// It updates the `order` property of all tasks to reflect their new positions, ensuring + /// that the user's manual ordering preference is persisted. + /// + /// - Parameters: + /// - tasks: The current array of tasks before reordering. + /// - source: The index set of items being moved (typically a single index). + /// - destination: The destination index where items should be inserted. + /// - Note: This method should only be called for groups where `canReorder` is `true`. + /// The view layer is responsible for only enabling drag and drop for applicable groups. + /// - SeeAlso: ``TodoGroup/canReorder`` for determining which groups support reordering. + func reorderTasks( + _ tasks: [Todo], + from source: IndexSet, + to destination: Int + ) { + var updatedTasks = tasks + + updatedTasks.move( + fromOffsets: source, + toOffset: destination + ) + + for (index, task) in updatedTasks.enumerated() { + task.order = index + } + } + /// Toggles the visibility of completed tasks. /// /// This method flips the `showCompleted` state, which triggers SwiftUI to recreate the task list view and its children. diff --git a/App/Sources/Views/TaskList/TaskListContent.swift b/App/Sources/Views/TaskList/TaskListContent.swift index 5857a5d..f918ad2 100644 --- a/App/Sources/Views/TaskList/TaskListContent.swift +++ b/App/Sources/Views/TaskList/TaskListContent.swift @@ -5,42 +5,82 @@ extension TaskListView { /// Internal content view that manages the dynamic SwiftData query for tasks. /// - /// This view is responsible for querying and displaying the list of tasks based on - /// the specified group and completion status filter. It exists as a separate view - /// to enable dynamic query updates when filter parameters change. + /// This view is responsible for querying and displaying the list of tasks based on the specified + /// group and completion status filter. It exists as a separate view to enable dynamic query + /// updates when filter parameters change. + /// + /// Task reordering is enabled based on the ``TodoGroup/canReorder`` property. + /// Groups that support reordering (``TodoGroup/today``, ``TodoGroup/upcoming``, + /// ``TodoGroup/overdue``) allow users to drag and drop tasks to change their order. + /// The ``TodoGroup/completed`` group does not support reordering as tasks are + /// displayed in chronological order by completion date. struct Content: View { + // MARK: Type aliases + + /// Closure type for handling task reorder operations. + /// + /// - Parameters: + /// - tasks: The current array of tasks. + /// - source: The index set of items being moved. + /// - destination: The destination index for the moved items. + typealias ReorderClosure = ([Todo], IndexSet, Int) -> Void + // MARK: Properties /// Query to fetch todos filtered by group and completion status. /// - /// This query is initialized with a `FetchDescriptor` that applies predicates + /// This query is initialized with a todo fetch descriptor that applies predicates /// based on the group type and completion filter. SwiftData automatically /// monitors changes and updates the view when the underlying data changes. + /// + /// Tasks are sorted by their `order` property first, then by date-based criteria + /// specific to each group, enabling both manual and automatic ordering. @Query private var tasks: [Todo] + /// Indicates whether tasks in this view can be reordered through drag and drop. + /// + /// This value is derived from the ``TodoGroup/canReorder`` property and + /// determines whether the `.onMove` modifier is applied to the task list. + private let canReorder: Bool + + /// Closure called when tasks are reordered through drag and drop. + /// + /// This closure is invoked by the `.onMove` modifier and is responsible for + /// updating the `order` property of affected tasks to reflect their new positions. + private let onReorder: ReorderClosure + // MARK: Initializers - /// Creates a content view with a configured task query. + /// Creates a content view with a configured task query and reorder handler. /// - /// This initializer sets up the `@Query` with a fetch descriptor that filters + /// This initializer sets up a query with a fetch descriptor that filters /// tasks based on the specified group and completion status. When these parameters /// change, SwiftUI recreates the view with new values, causing the query to update. /// + /// The reordering capability is automatically determined based on the + /// ``TodoGroup/canReorder`` property. For groups that support reordering, + /// the provided closure will be called when users drag and drop tasks to new positions. + /// /// - Parameters: /// - group: The todo group category to filter by (today, upcoming, overdue, or completed). /// - showCompleted: Whether to include completed tasks in the results. Only applies to groups other than `.completed`. + /// - onReorder: A closure called when tasks are reordered. init( group: TodoGroup, - showCompleted: Bool + showCompleted: Bool, + onReorder: @escaping ReorderClosure ) { - _tasks = .init( + self._tasks = .init( FetchDescriptor( group: group, showCompleted: showCompleted ) ) + + self.canReorder = group.canReorder + self.onReorder = onReorder } // MARK: Body @@ -49,6 +89,12 @@ extension TaskListView { /// /// Displays either a content unavailable state or a list of tasks /// based on whether any tasks match the current filters. + /// + /// When tasks are present, displays them in a grouped list. The list supports + /// drag and drop reordering for groups where `canReorder` is true, allowing + /// users to manually prioritize tasks within the group. For groups where + /// reordering is disabled (such as completed tasks), the list is displayed + /// in read-only mode with chronological sorting. var body: some View { Group { if tasks.isEmpty { @@ -59,8 +105,18 @@ extension TaskListView { } else { List { - ForEach(tasks) { todo in - TaskListView.Item(todo) + if canReorder { + ForEach(tasks) { + TaskListView.Item($0) + } + .onMove { source, destination in + onReorder(tasks, source, destination) + } + } + else { + ForEach(tasks) { + TaskListView.Item($0) + } } } .listStyle(.grouped) @@ -80,7 +136,9 @@ extension TaskListView { TaskListView.Content( group: .today, showCompleted: .random() - ) + ) { _, _, _ in + // On reorder closure + } } #Preview( @@ -90,7 +148,9 @@ extension TaskListView { TaskListView.Content( group: .today, showCompleted: false - ) + ) { _, _, _ in + // On reorder closure + } } #Preview( @@ -100,5 +160,19 @@ extension TaskListView { TaskListView.Content( group: .today, showCompleted: true - ) + ) { _, _, _ in + // On reorder closure + } +} + +#Preview( + "Task list content when only completed tasks shown", + traits: .sampleTasks +) { + TaskListView.Content( + group: .completed, + showCompleted: .random() + ) { _, _, _ in + // On reorder closure + } } diff --git a/App/Sources/Views/TaskListView.swift b/App/Sources/Views/TaskListView.swift index e8eb57c..9794943 100644 --- a/App/Sources/Views/TaskListView.swift +++ b/App/Sources/Views/TaskListView.swift @@ -19,7 +19,13 @@ struct TaskListView: View { /// Creates a new task list view for a specific group. /// + /// Initializes the view with a view model configured for the specified group. + /// The view model manages the state for completion filtering and task reordering, + /// while the view handles the presentation layer including navigation and toolbar. + /// /// - Parameter group: The todo group category to display tasks for. + /// - Note: The group determines which tasks are fetched, whether + /// completion filtering is available, and whether drag and drop reordering is enabled. init(group: TodoGroup) { _viewModel = .init(initialValue: ViewModel(group: group)) } @@ -34,7 +40,9 @@ struct TaskListView: View { Content( group: viewModel.group, showCompleted: viewModel.showCompleted - ) + ) { tasks, source, destination in + viewModel.reorderTasks(tasks, from: source, to: destination) + } .navigationTitle(viewModel.navigationTitle) .toolbar { if viewModel.canShowCompleted { diff --git a/UnitTests/Tests/Enumerations/TodoGroupTests.swift b/UnitTests/Tests/Enumerations/TodoGroupTests.swift index 22116ee..b39933d 100644 --- a/UnitTests/Tests/Enumerations/TodoGroupTests.swift +++ b/UnitTests/Tests/Enumerations/TodoGroupTests.swift @@ -28,6 +28,25 @@ struct TodoGroupTests { #expect(canShowCompleted == expectation) } + @Test( + "canReorder returns correct values", + arguments: zip( + TodoGroup.allCases, + [true, true, true, false] + ) + ) + func canReorder( + for group: TodoGroup, + expects expectation: Bool + ) { + // GIVEN + // WHEN + let canReorder = group.canReorder + + // THEN + #expect(canReorder == expectation) + } + @Test( "group color match expected values", arguments: zip( diff --git a/UnitTests/Tests/Extensions/TodoFetchDescriptorTests.swift b/UnitTests/Tests/Extensions/TodoFetchDescriptor+InitsTests.swift similarity index 57% rename from UnitTests/Tests/Extensions/TodoFetchDescriptorTests.swift rename to UnitTests/Tests/Extensions/TodoFetchDescriptor+InitsTests.swift index 9a521cb..6703150 100644 --- a/UnitTests/Tests/Extensions/TodoFetchDescriptorTests.swift +++ b/UnitTests/Tests/Extensions/TodoFetchDescriptor+InitsTests.swift @@ -4,13 +4,13 @@ import Testing @testable import Todos -@Suite("FetchDescriptor extension tests") -struct TodoFetchDescriptorTests { +@Suite("TodoFetchDescriptor+Inits extension tests") +struct TodoFetchDescriptor_InitsTests { // MARK: Initialization tests - + @Test( - "initializes with group when empty tasks", + "initialization when empty tasks", arguments: TodoGroup.allCases ) @MainActor @@ -18,58 +18,58 @@ struct TodoFetchDescriptorTests { // GIVEN let container = try modelContainer let context = container.mainContext - + // WHEN let descriptor = FetchDescriptor( group: .today, showCompleted: .random() ) let results = try context.fetch(descriptor) - + // THEN #expect(results.isEmpty) } - + @Test( - "initializes with group and show completed disabled when filled tasks", + "initialization with show completed disabled", arguments: TodoGroup.allCases ) @MainActor - func initializationWhenFilledTasks(for group: TodoGroup) throws { + func initializationWithNoShowCompleted(for group: TodoGroup) throws { // GIVEN let container = try modelContainer let context = container.mainContext - + insert(into: context) - + // WHEN let descriptor = FetchDescriptor( group: group, showCompleted: false ) let results = try context.fetch(descriptor) - + // THEN #expect(results.count == 2) - + switch group { - case .completed: - #expect(results[0].title == "Completed task #2") - #expect(results[1].title == "Completed task #1") - case .overdue: - #expect(results[0].title == "Overdue task #2") - #expect(results[1].title == "Overdue task #1") - case .today: - #expect(results[0].title == "Today task #1") - #expect(results[1].title == "Today task #2") - case .upcoming: - #expect(results[0].title == "Upcoming task #1") - #expect(results[1].title == "Upcoming task #2") + case .completed: + #expect(results[0].title == "Completed task #2") + #expect(results[1].title == "Completed task #1") + case .overdue: + #expect(results[0].title == "Overdue task #2") + #expect(results[1].title == "Overdue task #1") + case .today: + #expect(results[0].title == "Today task #1") + #expect(results[1].title == "Today task #2") + case .upcoming: + #expect(results[0].title == "Upcoming task #1") + #expect(results[1].title == "Upcoming task #2") } } - + @Test( - "initializes with group and show completed enabled when filled tasks", + "initialization with show completed enabled", arguments: TodoGroup.allCases ) @MainActor @@ -77,38 +77,39 @@ struct TodoFetchDescriptorTests { // GIVEN let container = try modelContainer let context = container.mainContext - + insert(into: context) - + // WHEN let descriptor = FetchDescriptor( group: group, showCompleted: true ) let results = try context.fetch(descriptor) - + // THEN if group == .overdue || group == .upcoming { #expect(results.count == 3) - } else { + } + else { #expect(results.count == 2) } - + switch group { - case .completed: - #expect(results[0].title == "Completed task #2") - #expect(results[1].title == "Completed task #1") - case .overdue: - #expect(results[0].title == "Completed task #1") - #expect(results[1].title == "Overdue task #2") - #expect(results[2].title == "Overdue task #1") - case .today: - #expect(results[0].title == "Today task #1") - #expect(results[1].title == "Today task #2") - case .upcoming: - #expect(results[0].title == "Upcoming task #1") - #expect(results[1].title == "Upcoming task #2") - #expect(results[2].title == "Completed task #2") + case .completed: + #expect(results[0].title == "Completed task #2") + #expect(results[1].title == "Completed task #1") + case .overdue: + #expect(results[0].title == "Overdue task #2") + #expect(results[1].title == "Completed task #1") + #expect(results[2].title == "Overdue task #1") + case .today: + #expect(results[0].title == "Today task #1") + #expect(results[1].title == "Today task #2") + case .upcoming: + #expect(results[0].title == "Completed task #2") + #expect(results[1].title == "Upcoming task #1") + #expect(results[2].title == "Upcoming task #2") } } @@ -116,7 +117,7 @@ struct TodoFetchDescriptorTests { // MARK: - Helpers -private extension TodoFetchDescriptorTests { +private extension TodoFetchDescriptor_InitsTests { // MARK: Computed @@ -138,40 +139,48 @@ private extension TodoFetchDescriptorTests { let tasks: [Todo] = [ .init( title: "Today task #1", - due: .now + due: .now, + order: 0 ), .init( title: "Today task #2", - due: .now + due: .now, + order: 1 ), .init( title: "Upcoming task #1", - due: calendar.date(byAdding: .day, value: 1, to: .now) ?? .now + due: calendar.date(byAdding: .day, value: 2, to: .now) ?? .now, + order: 0 ), .init( title: "Upcoming task #2", - due: calendar.date(byAdding: .day, value: 1, to: .now) ?? .now + due: calendar.date(byAdding: .day, value: 3, to: .now) ?? .now, + order: 1 ), .init( title: "Overdue task #1", - due: calendar.date(byAdding: .day, value: -1, to: .now) ?? .now + due: calendar.date(byAdding: .day, value: -1, to: .now) ?? .now, + order: 1 ), .init( title: "Overdue task #2", - due: calendar.date(byAdding: .day, value: -1, to: .now) ?? .now + due: calendar.date(byAdding: .day, value: -1, to: .now) ?? .now, + order: 0 ), .init( title: "Completed task #1", due: calendar.date(byAdding: .day, value: -1, to: .now) ?? .now, - completed: .now + completed: .now, + order: 1 ), .init( title: "Completed task #2", due: calendar.date(byAdding: .day, value: 1, to: .now) ?? .now, - completed: .now - ) + completed: .now, + order: 0 + ), ] - + for task in tasks { context.insert(task) } diff --git a/UnitTests/Tests/View Models/TaskListViewModelTests.swift b/UnitTests/Tests/View Models/TaskListViewModelTests.swift index c87cf5c..a1e98cf 100644 --- a/UnitTests/Tests/View Models/TaskListViewModelTests.swift +++ b/UnitTests/Tests/View Models/TaskListViewModelTests.swift @@ -36,7 +36,7 @@ struct TaskListViewModelTests { ) { // GIVEN let viewModel = TaskListView.ViewModel(group: group) - + viewModel.showCompleted = showCompleted // WHEN @@ -46,7 +46,8 @@ struct TaskListViewModelTests { if showCompleted { #expect(title == "view.task-list.label.hide-completed") #expect(icon == "eye.slash") - } else { + } + else { #expect(title == "view.task-list.label.show-completed") #expect(icon == "eye") } @@ -90,6 +91,56 @@ struct TaskListViewModelTests { // MARK: Method tests + @Test("reorderTasks updates order property when moving task forward") + func reorderTasksMovingForward() { + // GIVEN + let viewModel = TaskListView.ViewModel(group: .today) + let tasks: [Todo] = [ + .init(title: "Task 1", due: .now, order: 0), + .init(title: "Task 2", due: .now, order: 1), + .init(title: "Task 3", due: .now, order: 2), + .init(title: "Task 4", due: .now, order: 3), + ] + + // WHEN + viewModel.reorderTasks( + tasks, + from: IndexSet(integer: 0), + to: 3 + ) + + // THEN + #expect(tasks[0].order == 2) + #expect(tasks[1].order == 0) + #expect(tasks[2].order == 1) + #expect(tasks[3].order == 3) + } + + @Test("reorderTasks updates order property when moving task backward") + func reorderTasksMovingBackward() { + // GIVEN + let viewModel = TaskListView.ViewModel(group: .today) + let tasks: [Todo] = [ + .init(title: "Task 1", due: .now, order: 0), + .init(title: "Task 2", due: .now, order: 1), + .init(title: "Task 3", due: .now, order: 2), + .init(title: "Task 4", due: .now, order: 3), + ] + + // WHEN + viewModel.reorderTasks( + tasks, + from: IndexSet(integer: 3), + to: 1 + ) + + // THEN + #expect(tasks[0].order == 0) + #expect(tasks[1].order == 2) + #expect(tasks[2].order == 3) + #expect(tasks[3].order == 1) + } + @Test("toggleCompletedVisbility toggles the showCompleted property") func toggleCompletedVisibility() { // GIVEN