Task deletion feature (#7)
This PR contains the work done to add support for todo tasks deletion on the *Task list* view. Reviewed-on: rock-n-code/todos-app#7 Co-authored-by: Javier Cicchelli <javier@rock-n-code.com> Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit was merged in pull request #7.
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import Foundation
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -84,6 +85,26 @@ extension TaskListView {
|
|||||||
|
|
||||||
// MARK: Methods
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Deletes tasks at the specified indices.
|
||||||
|
///
|
||||||
|
/// This method removes tasks from the model context after a swipe-to-delete action.
|
||||||
|
/// The deletion is performed through SwiftData's model context, which handles
|
||||||
|
/// persistence and triggers automatic UI updates.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - tasks: The current array of tasks.
|
||||||
|
/// - offsets: The index set of tasks to delete.
|
||||||
|
/// - modelContext: The SwiftData model context to perform the deletion.
|
||||||
|
func deleteTasks(
|
||||||
|
_ tasks: [Todo],
|
||||||
|
at offsets: IndexSet,
|
||||||
|
from modelContext: ModelContext
|
||||||
|
) {
|
||||||
|
for index in offsets {
|
||||||
|
modelContext.delete(tasks[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Reorders tasks after a drag and drop operation.
|
/// Reorders tasks after a drag and drop operation.
|
||||||
///
|
///
|
||||||
/// This method is called when users drag and drop tasks to reorder them within a group.
|
/// This method is called when users drag and drop tasks to reorder them within a group.
|
||||||
|
|||||||
@@ -18,17 +18,24 @@ extension TaskListView {
|
|||||||
|
|
||||||
// MARK: Type aliases
|
// MARK: Type aliases
|
||||||
|
|
||||||
|
/// Closure type for handling task deletion operations.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - todos: The current array of todo tasks.
|
||||||
|
/// - offsets: The index set of tasks to delete.
|
||||||
|
typealias DeleteClosure = ([Todo], IndexSet) -> Void
|
||||||
|
|
||||||
/// Closure type for handling task reorder operations.
|
/// Closure type for handling task reorder operations.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - tasks: The current array of tasks.
|
/// - todos: The current array of todo tasks.
|
||||||
/// - source: The index set of items being moved.
|
/// - source: The index set of items being moved.
|
||||||
/// - destination: The destination index for the moved items.
|
/// - destination: The destination index for the moved items.
|
||||||
typealias ReorderClosure = ([Todo], IndexSet, Int) -> Void
|
typealias ReorderClosure = ([Todo], IndexSet, Int) -> Void
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
/// Query to fetch todos filtered by group and completion status.
|
/// Query to fetch todo tasks filtered by group and completion status.
|
||||||
///
|
///
|
||||||
/// This query is initialized with a todo fetch descriptor 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
|
/// based on the group type and completion filter. SwiftData automatically
|
||||||
@@ -37,7 +44,7 @@ extension TaskListView {
|
|||||||
/// Tasks are sorted by their `order` property first, then by date-based criteria
|
/// Tasks are sorted by their `order` property first, then by date-based criteria
|
||||||
/// specific to each group, enabling both manual and automatic ordering.
|
/// specific to each group, enabling both manual and automatic ordering.
|
||||||
@Query
|
@Query
|
||||||
private var tasks: [Todo]
|
private var todos: [Todo]
|
||||||
|
|
||||||
/// Indicates whether tasks in this view can be reordered through drag and drop.
|
/// Indicates whether tasks in this view can be reordered through drag and drop.
|
||||||
///
|
///
|
||||||
@@ -45,6 +52,12 @@ extension TaskListView {
|
|||||||
/// determines whether the `.onMove` modifier is applied to the task list.
|
/// determines whether the `.onMove` modifier is applied to the task list.
|
||||||
private let canReorder: Bool
|
private let canReorder: Bool
|
||||||
|
|
||||||
|
/// Closure called when tasks are deleted through swipe-to-delete.
|
||||||
|
///
|
||||||
|
/// This closure is invoked by the `.onDelete` modifier and is responsible for
|
||||||
|
/// removing the selected tasks from the model context.
|
||||||
|
private let onDelete: DeleteClosure
|
||||||
|
|
||||||
/// Closure called when tasks are reordered through drag and drop.
|
/// Closure called when tasks are reordered through drag and drop.
|
||||||
///
|
///
|
||||||
/// This closure is invoked by the `.onMove` modifier and is responsible for
|
/// This closure is invoked by the `.onMove` modifier and is responsible for
|
||||||
@@ -67,12 +80,14 @@ extension TaskListView {
|
|||||||
/// - group: The todo group category to filter by (today, upcoming, overdue, or completed).
|
/// - 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`.
|
/// - showCompleted: Whether to include completed tasks in the results. Only applies to groups other than `.completed`.
|
||||||
/// - onReorder: A closure called when tasks are reordered.
|
/// - onReorder: A closure called when tasks are reordered.
|
||||||
|
/// - onDelete: A closure called when tasks are deleted.
|
||||||
init(
|
init(
|
||||||
group: TodoGroup,
|
group: TodoGroup,
|
||||||
showCompleted: Bool,
|
showCompleted: Bool,
|
||||||
|
onDelete: @escaping DeleteClosure,
|
||||||
onReorder: @escaping ReorderClosure
|
onReorder: @escaping ReorderClosure
|
||||||
) {
|
) {
|
||||||
self._tasks = .init(
|
self._todos = .init(
|
||||||
FetchDescriptor<Todo>(
|
FetchDescriptor<Todo>(
|
||||||
group: group,
|
group: group,
|
||||||
showCompleted: showCompleted
|
showCompleted: showCompleted
|
||||||
@@ -80,6 +95,7 @@ extension TaskListView {
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.canReorder = group.canReorder
|
self.canReorder = group.canReorder
|
||||||
|
self.onDelete = onDelete
|
||||||
self.onReorder = onReorder
|
self.onReorder = onReorder
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +113,7 @@ extension TaskListView {
|
|||||||
/// in read-only mode with chronological sorting.
|
/// in read-only mode with chronological sorting.
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if tasks.isEmpty {
|
if todos.isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"view.task-list-content.content-unavailable.title",
|
"view.task-list-content.content-unavailable.title",
|
||||||
systemImage: "checkmark.circle"
|
systemImage: "checkmark.circle"
|
||||||
@@ -105,19 +121,7 @@ extension TaskListView {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
List {
|
List {
|
||||||
if canReorder {
|
items
|
||||||
ForEach(tasks) {
|
|
||||||
TaskListView.Item($0)
|
|
||||||
}
|
|
||||||
.onMove { source, destination in
|
|
||||||
onReorder(tasks, source, destination)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
ForEach(tasks) {
|
|
||||||
TaskListView.Item($0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.listStyle(.grouped)
|
.listStyle(.grouped)
|
||||||
}
|
}
|
||||||
@@ -127,6 +131,34 @@ extension TaskListView {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension TaskListView.Content {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// A view that renders the task items with appropriate modifiers.
|
||||||
|
@ViewBuilder
|
||||||
|
var items: some View {
|
||||||
|
let items = ForEach(todos) {
|
||||||
|
TaskListView.Item($0)
|
||||||
|
}
|
||||||
|
.onDelete { offsets in
|
||||||
|
onDelete(todos, offsets)
|
||||||
|
}
|
||||||
|
|
||||||
|
if canReorder {
|
||||||
|
items.onMove { source, destination in
|
||||||
|
onReorder(todos, source, destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Previews
|
// MARK: - Previews
|
||||||
|
|
||||||
#Preview(
|
#Preview(
|
||||||
@@ -136,7 +168,9 @@ extension TaskListView {
|
|||||||
TaskListView.Content(
|
TaskListView.Content(
|
||||||
group: .today,
|
group: .today,
|
||||||
showCompleted: .random()
|
showCompleted: .random()
|
||||||
) { _, _, _ in
|
) { _, _ in
|
||||||
|
// On delete closure
|
||||||
|
} onReorder: { _, _, _ in
|
||||||
// On reorder closure
|
// On reorder closure
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,7 +182,9 @@ extension TaskListView {
|
|||||||
TaskListView.Content(
|
TaskListView.Content(
|
||||||
group: .today,
|
group: .today,
|
||||||
showCompleted: false
|
showCompleted: false
|
||||||
) { _, _, _ in
|
) { _, _ in
|
||||||
|
// On delete closure
|
||||||
|
} onReorder: { _, _, _ in
|
||||||
// On reorder closure
|
// On reorder closure
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,7 +196,9 @@ extension TaskListView {
|
|||||||
TaskListView.Content(
|
TaskListView.Content(
|
||||||
group: .today,
|
group: .today,
|
||||||
showCompleted: true
|
showCompleted: true
|
||||||
) { _, _, _ in
|
) { _, _ in
|
||||||
|
// On delete closure
|
||||||
|
} onReorder: { _, _, _ in
|
||||||
// On reorder closure
|
// On reorder closure
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,7 +210,9 @@ extension TaskListView {
|
|||||||
TaskListView.Content(
|
TaskListView.Content(
|
||||||
group: .completed,
|
group: .completed,
|
||||||
showCompleted: .random()
|
showCompleted: .random()
|
||||||
) { _, _, _ in
|
) { _, _ in
|
||||||
|
// On delete closure
|
||||||
|
} onReorder: { _, _, _ in
|
||||||
// On reorder closure
|
// On reorder closure
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ struct TaskListView: View {
|
|||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The SwiftData model context for performing data operations.
|
||||||
|
@Environment(\.modelContext)
|
||||||
|
private var modelContext
|
||||||
|
|
||||||
/// The view model managing the state and business logic for this view.
|
/// The view model managing the state and business logic for this view.
|
||||||
///
|
///
|
||||||
/// This view model holds the group configuration and completion filter state.
|
/// This view model holds the group configuration and completion filter state.
|
||||||
@@ -40,8 +44,18 @@ struct TaskListView: View {
|
|||||||
Content(
|
Content(
|
||||||
group: viewModel.group,
|
group: viewModel.group,
|
||||||
showCompleted: viewModel.showCompleted
|
showCompleted: viewModel.showCompleted
|
||||||
) { tasks, source, destination in
|
) { tasks, offsets in
|
||||||
viewModel.reorderTasks(tasks, from: source, to: destination)
|
viewModel.deleteTasks(
|
||||||
|
tasks,
|
||||||
|
at: offsets,
|
||||||
|
from: modelContext
|
||||||
|
)
|
||||||
|
} onReorder: { tasks, source, destination in
|
||||||
|
viewModel.reorderTasks(
|
||||||
|
tasks,
|
||||||
|
from: source,
|
||||||
|
to: destination
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.navigationTitle(viewModel.navigationTitle)
|
.navigationTitle(viewModel.navigationTitle)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
|||||||
@@ -91,6 +91,45 @@ struct TaskListViewModelTests {
|
|||||||
|
|
||||||
// MARK: Method tests
|
// MARK: Method tests
|
||||||
|
|
||||||
|
@Test("deleteTasks removes a single task from model context")
|
||||||
|
func deleteTasksSingle() throws {
|
||||||
|
// GIVEN
|
||||||
|
let container = try ModelContainer(
|
||||||
|
for: Todo.self,
|
||||||
|
configurations: .init(isStoredInMemoryOnly: true)
|
||||||
|
)
|
||||||
|
let context = ModelContext(container)
|
||||||
|
|
||||||
|
let viewModel = TaskListView.ViewModel(group: .today)
|
||||||
|
let todosToInsert: [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),
|
||||||
|
]
|
||||||
|
let countInitial = todosToInsert.count
|
||||||
|
|
||||||
|
for todo in todosToInsert {
|
||||||
|
context.insert(todo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
viewModel.deleteTasks(
|
||||||
|
todosToInsert,
|
||||||
|
at: IndexSet(integer: 1),
|
||||||
|
from: context
|
||||||
|
)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
let descriptor = FetchDescriptor<Todo>()
|
||||||
|
let todos = try context.fetch(descriptor)
|
||||||
|
|
||||||
|
#expect(countInitial == 3)
|
||||||
|
#expect(todos.count == 2)
|
||||||
|
#expect(todos.contains(where: { $0.title == "Task 1" }))
|
||||||
|
#expect(todos.contains(where: { $0.title == "Task 3" }))
|
||||||
|
#expect(!todos.contains(where: { $0.title == "Task 2" }))
|
||||||
|
}
|
||||||
|
|
||||||
@Test("reorderTasks updates order property when moving task forward")
|
@Test("reorderTasks updates order property when moving task forward")
|
||||||
func reorderTasksMovingForward() {
|
func reorderTasksMovingForward() {
|
||||||
// GIVEN
|
// GIVEN
|
||||||
|
|||||||
Reference in New Issue
Block a user