From 2a3323b8e632c48c941e0a7d2010e2ce4eba8fa5 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 10 Feb 2026 15:31:52 +0000 Subject: [PATCH] List tasks feature (#4) This PR contains the work done to implement the **List Tasks** feature, and to integrate it into the main content view of the app. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/todos-app/pulls/4 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .../Modifiers/SampleTaskDataModifier.swift | 31 ++-- App/Resources/Localizable.xcstrings | 43 +++++ .../TodoFetchDescriptor+Inits.swift | 54 +++++++ .../Styles/CompletionToggleStyle.swift | 77 +++++++++ .../View Models/TaskListViewModel.swift | 43 +++++ App/Sources/Views/TaskList/TaskListItem.swift | 148 ++++++++++++++++++ App/Sources/Views/TaskListView.swift | 99 ++++++++++++ .../Extensions/TodoFetchDescriptorTests.swift | 130 +++++++++++++++ .../View Models/NewTaskViewModelTests.swift | 37 +++-- .../TaskGroupListViewModelTests.swift | 35 +++-- .../View Models/TaskListViewModelTests.swift | 27 ++++ 11 files changed, 679 insertions(+), 45 deletions(-) create mode 100644 App/Sources/Extensions/TodoFetchDescriptor+Inits.swift create mode 100644 App/Sources/Styles/CompletionToggleStyle.swift create mode 100644 App/Sources/View Models/TaskListViewModel.swift create mode 100644 App/Sources/Views/TaskList/TaskListItem.swift create mode 100644 App/Sources/Views/TaskListView.swift create mode 100644 UnitTests/Tests/Extensions/TodoFetchDescriptorTests.swift create mode 100644 UnitTests/Tests/View Models/TaskListViewModelTests.swift diff --git a/App/Previews/Modifiers/SampleTaskDataModifier.swift b/App/Previews/Modifiers/SampleTaskDataModifier.swift index 5b463ef..e8422e3 100644 --- a/App/Previews/Modifiers/SampleTaskDataModifier.swift +++ b/App/Previews/Modifiers/SampleTaskDataModifier.swift @@ -37,48 +37,45 @@ struct SampleTaskDataModifier: PreviewModifier { ) let calendar: Calendar = .current - let today: Date = .now - let tomorrow: Date = calendar.date(byAdding: .day, value: 1, to: today) ?? today - let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today let tasks: [Todo] = [ .init( - title: "Today task 1", - due: today + title: "Today task #1", + due: .now ), .init( - title: "Today task 2", + title: "Today task #2", note: "This is some random note attached to the task.", - due: today + due: .now ), .init( title: "Tomorrow task", - due: tomorrow + due: calendar.date(byAdding: .day, value: 1, to: .now) ?? .now ), .init( title: "Next week task", note: "This is some random note attached to the task.", - due: calendar.date(byAdding: .day, value: 7, to: today) ?? today + due: calendar.date(byAdding: .day, value: 7, to: .now) ?? .now ), .init( title: "Yesterday task", - due: yesterday + due: calendar.date(byAdding: .day, value: -1, to: .now) ?? .now ), .init( title: "Last week task", note: "This is some random note attached to the task.", - due: calendar.date(byAdding: .day, value: -7, to: today) ?? today + due: calendar.date(byAdding: .day, value: -7, to: .now) ?? .now ), .init( - title: "Completed task 1", - due: today, - completed: today + title: "Completed task #1", + due: calendar.date(byAdding: .day, value: 1, to: .now) ?? .now, + completed: .now ), .init( - title: "Completed task 2", + title: "Completed task #2", note: "This is some random note attached to the task.", - due: yesterday, - completed: today + due: calendar.date(byAdding: .day, value: -1, to: .now) ?? .now, + completed: .now ), ] diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings index 46bd4cd..068d18e 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -121,6 +121,49 @@ } } } + "view.task-list-item.placeholder.completed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap to complete or not your task" + } + } + } + }, + "view.task-list-item.placeholder.note" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add extra notes to your task (if needed)" + } + } + } + }, + "view.task-list-item.placeholder.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Give a title to your task" + } + } + } + }, + "view.task-list.content-unavailable.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All Tasks Completed" + } + } + } } }, "version" : "1.1" diff --git a/App/Sources/Extensions/TodoFetchDescriptor+Inits.swift b/App/Sources/Extensions/TodoFetchDescriptor+Inits.swift new file mode 100644 index 0000000..0136396 --- /dev/null +++ b/App/Sources/Extensions/TodoFetchDescriptor+Inits.swift @@ -0,0 +1,54 @@ +import Foundation +import SwiftData + +/// Extension providing convenient initializers for fetching todos by group category. +/// +/// This extension allows creating fetch descriptors that automatically filter and sort todos +/// based on their completion status and due dates according to the specified `TodoGroup`. +extension FetchDescriptor where T == Todo { + + /// Creates a fetch descriptor for todos filtered and sorted by a specific group. + /// + /// This initializer provides a convenient way to fetch todos categorized by their temporal + /// 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) + /// + /// - Parameters: + /// - group: The todo group category to filter by. + /// - calendar: The calendar to use for date calculations. Defaults to `.current`. + init( + group: TodoGroup, + calendar: Calendar = .current + ) { + let today = calendar.startOfDay(for: .now) + let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) ?? today + + switch group { + case .completed: + self.init( + predicate: #Predicate { $0.completed != nil }, + sortBy: [.init(\.completed, order: .reverse)] + ) + case .overdue: + self.init( + predicate: #Predicate { $0.completed == nil && $0.due < today }, + sortBy: [.init(\.due, order: .reverse)] + ) + case .today: + self.init( + predicate: #Predicate { $0.completed == nil && $0.due >= today && $0.due < tomorrow }, + sortBy: [.init(\.due, order: .forward)] + ) + case .upcoming: + self.init( + predicate: #Predicate { $0.completed == nil && $0.due >= tomorrow }, + sortBy: [.init(\.due, order: .forward)] + ) + } + } + +} diff --git a/App/Sources/Styles/CompletionToggleStyle.swift b/App/Sources/Styles/CompletionToggleStyle.swift new file mode 100644 index 0000000..998be3a --- /dev/null +++ b/App/Sources/Styles/CompletionToggleStyle.swift @@ -0,0 +1,77 @@ +import SwiftUI + +/// A custom toggle style designed for task completion indication. +/// +/// This style renders a circular checkbox that visually represents completion state: +/// - **Off state**: An empty circle with a secondary-colored stroke border +/// - **On state**: A circle with a primary-colored stroke border containing a filled inner circle +/// +/// ## Usage +/// +/// Apply this style to any `Toggle` view using the `.completion` static accessor: +/// +/// ```swift +/// Toggle("Complete task", isOn: $isCompleted) +/// .toggleStyle(.completion) +/// ``` +struct CompletionToggleStyle: ToggleStyle { + + // MARK: Functions + + func makeBody(configuration: Configuration) -> some View { + Button { + configuration.isOn.toggle() + } label: { + HStack(alignment: .top, spacing: 12) { + ZStack { + Circle() + .strokeBorder( + configuration.isOn ? .primary : .secondary, + lineWidth: 2 + ) + .frame(width: 24, height: 24) + + if configuration.isOn { + Circle() + .fill(.primary) + .frame(width: 16, height: 16) + } + } + } + } + .animation( + .easeInOut, + value: configuration.isOn + ) + .buttonStyle(.plain) + } + +} + +// MARK: - Toggle Style Extension + +extension ToggleStyle where Self == CompletionToggleStyle { + + /// A toggle style for task completion with a circular checkbox design. + /// + /// This convenience accessor provides easy access to the ``CompletionToggleStyle`` + /// for use with SwiftUI's toggle style modifier. + static var completion: CompletionToggleStyle { + CompletionToggleStyle() + } + +} + +// MARK: - Previews + +#Preview("Completion toggle style") { + @Previewable + @State + var isOn = false + + Toggle( + "Unchecked task", + isOn: $isOn + ) + .toggleStyle(.completion) +} diff --git a/App/Sources/View Models/TaskListViewModel.swift b/App/Sources/View Models/TaskListViewModel.swift new file mode 100644 index 0000000..f2ea825 --- /dev/null +++ b/App/Sources/View Models/TaskListViewModel.swift @@ -0,0 +1,43 @@ +import SwiftData +import SwiftUI + +extension TaskListView { + + /// View model for managing the logic for the task list view. + /// + /// This observable class encapsulates the state and business logic for the task list view. + /// It follows the MVVM pattern to separate presentation logic from the view layer. + /// + /// ## Responsibilities + /// - Providing the navigation title for the current group + @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. + private let group: TodoGroup + + // MARK: Initialization + + /// Initializes a new view model instance for a specific task group. + /// + /// - Parameter group: The task group to display tasks for. + init(group: TodoGroup) { + self.group = group + } + + // MARK: Computed + + /// The navigation title for the current task group. + /// + /// This computed property provides the localized title for the current group, + /// suitable for display in a navigation bar. + /// + /// - Returns: The localized title key for the current group. + var navigationTitle: LocalizedStringKey { group.title } + + } +} diff --git a/App/Sources/Views/TaskList/TaskListItem.swift b/App/Sources/Views/TaskList/TaskListItem.swift new file mode 100644 index 0000000..7a825ba --- /dev/null +++ b/App/Sources/Views/TaskList/TaskListItem.swift @@ -0,0 +1,148 @@ +import SwiftUI + +extension TaskListView { + + /// A view that displays a single editable todo task in a list. + /// + /// This view provides a comprehensive interface for viewing and editing task information, + /// including: + /// - A completion toggle using a circular checkbox design + /// - An editable title field with multi-line support + /// - An optional note field that appears when the task has notes + /// - A date picker for selecting the task's due date + /// + /// The view automatically adjusts its appearance based on completion state, dimming + /// completed tasks to provide visual feedback. All text fields support automatic + /// capitalization and keyboard correction for improved user experience. + struct Item: View { + + // MARK: Properties + + /// The todo task to display. + @Bindable + private var item: Todo + + // MARK: Initializers + + /// Creates a new task list item view for the specified todo. + /// + /// The item is wrapped in a `@Bindable` property wrapper to enable two-way + /// data binding for editing task properties directly within the view. + /// + /// - Parameter item: The todo task to display and edit. + init(_ item: Todo) { + _item = Bindable(item) + } + + // MARK: Body + + /// The content and behavior of the view. + /// + /// Renders an editable task list item with a completion toggle, title field, + /// optional note field, and date picker. The view's foreground color automatically + /// adjusts based on completion state, and changes animate smoothly. + var body: some View { + HStack( + alignment: .top, + spacing: 16 + ) { + Toggle( + "view.task-list-item.placeholder.completed", + isOn: isCompleted + ) + .toggleStyle(.completion) + + VStack( + alignment: .leading, + spacing: 8 + ) { + TextField( + "view.task-list-item.placeholder.title", + text: $item.title, + axis: .vertical + ) + .font(.headline) + + if showNote { + TextField( + "view.task-list-item.placeholder.note", + text: note, + axis: .vertical + ) + .font(.subheadline) + } + + DatePicker(selection: $item.due, displayedComponents: .date) { + Image(systemName: "calendar") + } + .frame(width: 160) + .disabled(isCompleted.wrappedValue) + } + .autocapitalization(.sentences) + .autocorrectionDisabled(false) + .foregroundStyle(foregroundColor) + .keyboardType(.asciiCapable) + } + .animation( + .easeInOut, + value: item.completed + ) + } + } + +} + +// MARK: - Helpers + +private extension TaskListView.Item { + + // MARK: Bindings + + var isCompleted: Binding { + .init { + item.completed != nil + } set: { + item.completed = $0 ? .now : nil + } + } + + var note: Binding { + .init { + item.note ?? "" + } set: { + item.note = $0.isEmpty ? nil : $0 + } + } + + // MARK: Computed + + var foregroundColor: Color { + item.completed == nil ? .primary : .secondary + } + + var showNote: Bool { + guard let note = item.note else { + return false + } + + return note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } + +} + +// MARK: - Previews + +#Preview("Task list item") { + @Previewable + var todo: Todo = .init( + title: "Complete project documentation", + note: "Add detailed comments and README", + due: .now, + completed: nil + ) + + List { + TaskListView.Item(todo) + } + .listStyle(.grouped) +} diff --git a/App/Sources/Views/TaskListView.swift b/App/Sources/Views/TaskListView.swift new file mode 100644 index 0000000..78cd378 --- /dev/null +++ b/App/Sources/Views/TaskListView.swift @@ -0,0 +1,99 @@ +import SwiftData +import SwiftUI + +/// A view that displays all todo tasks for a specific task group. +/// +/// This view shows a filtered list of tasks based on the selected group (today, upcoming, past, or completed). +/// The view uses a view model to separate business logic from presentation. +struct TaskListView: View { + + // MARK: Properties + + /// Query to fetch todos filtered by the current group. + @Query + private var tasks: [Todo] + + /// The view model managing the business logic for this view. + @State + private var viewModel: ViewModel + + // MARK: Initialization + + /// Creates a new task list view for a specific group. + /// - Parameter group: The todo group to display tasks for. + init(group: TodoGroup) { + _tasks = .init(FetchDescriptor(group: group)) + _viewModel = .init(initialValue: ViewModel(group: group)) + } + + // MARK: Body + + /// The content and behavior of the view. + /// + /// Displays a list of tasks filtered by the current group. + var body: some View { + Group { + if tasks.isEmpty { + ContentUnavailableView( + "view.task-list.content-unavailable.title", + systemImage: "checkmark.circle" + ) + } else { + List { + ForEach(tasks) { todo in + Item(todo) + } + } + .listStyle(.grouped) + } + } + .navigationTitle(viewModel.navigationTitle) + } +} + +// MARK: - Previews + +#Preview( + "Task list with empty tasks", + traits: .emptyContainer +) { + NavigationStack { + TaskListView(group: .today) + } +} + +#Preview( + "Task list - today", + traits: .sampleTasks +) { + NavigationStack { + TaskListView(group: .today) + } +} + +#Preview( + "Task list - upcoming", + traits: .sampleTasks +) { + NavigationStack { + TaskListView(group: .upcoming) + } +} + +#Preview( + "Task list - overdue", + traits: .sampleTasks +) { + NavigationStack { + TaskListView(group: .overdue) + } +} + +#Preview( + "Task list - completed", + traits: .sampleTasks +) { + NavigationStack { + TaskListView(group: .completed) + } +} diff --git a/UnitTests/Tests/Extensions/TodoFetchDescriptorTests.swift b/UnitTests/Tests/Extensions/TodoFetchDescriptorTests.swift new file mode 100644 index 0000000..68d6ddf --- /dev/null +++ b/UnitTests/Tests/Extensions/TodoFetchDescriptorTests.swift @@ -0,0 +1,130 @@ +import Foundation +import SwiftData +import Testing + +@testable import Todos + +@Suite("FetchDescriptor extension tests") +struct TodoFetchDescriptorTests { + + // MARK: Initialization tests + + @Test( + "initializes with group when empty tasks", + arguments: TodoGroup.allCases + ) + @MainActor + func initializationWhenEmptyTasks(for group: TodoGroup) throws { + // GIVEN + let container = try modelContainer + let context = container.mainContext + + // WHEN + let descriptor = FetchDescriptor(group: .today) + let results = try context.fetch(descriptor) + + // THEN + #expect(results.isEmpty) + } + + @Test( + "initializes with group when filled tasks", + arguments: TodoGroup.allCases + ) + @MainActor + func initializationWhenFilledTasks(for group: TodoGroup) throws { + // GIVEN + let container = try modelContainer + let context = container.mainContext + + insert(into: context) + + // WHEN + let descriptor = FetchDescriptor(group: group) + 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") + } + } + +} + +// MARK: - Helpers + +private extension TodoFetchDescriptorTests { + + // MARK: Computed + + var modelContainer: ModelContainer { + get throws { + try ModelContainer( + for: Todo.self, + configurations: .init(isStoredInMemoryOnly: true) + ) + } + } + + // MARK: Functions + + func insert( + into context: ModelContext, + calendar: Calendar = .current + ) { + let tasks: [Todo] = [ + .init( + title: "Today task #1", + due: .now + ), + .init( + title: "Today task #2", + due: .now + ), + .init( + title: "Upcoming task #1", + due: calendar.date(byAdding: .day, value: 1, to: .now) ?? .now + ), + .init( + title: "Upcoming task #2", + due: calendar.date(byAdding: .day, value: 1, to: .now) ?? .now + ), + .init( + title: "Overdue task #1", + due: calendar.date(byAdding: .day, value: -1, to: .now) ?? .now + ), + .init( + title: "Overdue task #2", + due: calendar.date(byAdding: .day, value: -1, to: .now) ?? .now + ), + .init( + title: "Completed task #1", + due: calendar.date(byAdding: .day, value: -1, to: .now) ?? .now, + completed: .now + ), + .init( + title: "Completed task #2", + due: calendar.date(byAdding: .day, value: 1, to: .now) ?? .now, + completed: .now + ) + ] + + for task in tasks { + context.insert(task) + } + } + +} diff --git a/UnitTests/Tests/View Models/NewTaskViewModelTests.swift b/UnitTests/Tests/View Models/NewTaskViewModelTests.swift index 12961b1..0f32407 100644 --- a/UnitTests/Tests/View Models/NewTaskViewModelTests.swift +++ b/UnitTests/Tests/View Models/NewTaskViewModelTests.swift @@ -124,10 +124,7 @@ struct NewTaskViewModelTests { @MainActor func saveWithAllProperties() throws { // GIVEN - let container = try ModelContainer( - for: Todo.self, - configurations: .init(isStoredInMemoryOnly: true) - ) + let container = try modelContainer let context = container.mainContext let viewModel = NewTaskView.ViewModel() @@ -153,10 +150,7 @@ struct NewTaskViewModelTests { @MainActor func saveWithEmptyNote() throws { // GIVEN - let container = try ModelContainer( - for: Todo.self, - configurations: .init(isStoredInMemoryOnly: true) - ) + let container = try modelContainer let context = container.mainContext let viewModel = NewTaskView.ViewModel() @@ -182,10 +176,7 @@ struct NewTaskViewModelTests { @MainActor func saveWithWhitespaceNote() throws { // GIVEN - let container = try ModelContainer( - for: Todo.self, - configurations: .init(isStoredInMemoryOnly: true) - ) + let container = try modelContainer let context = container.mainContext let viewModel = NewTaskView.ViewModel() @@ -211,10 +202,7 @@ struct NewTaskViewModelTests { @MainActor func saveMultipleTodos() throws { // GIVEN - let container = try ModelContainer( - for: Todo.self, - configurations: .init(isStoredInMemoryOnly: true) - ) + let container = try modelContainer let context = container.mainContext let viewModelFirst = NewTaskView.ViewModel() let viewModelSecond = NewTaskView.ViewModel() @@ -237,3 +225,20 @@ struct NewTaskViewModelTests { } } + +// MARK: - Helpers + +private extension NewTaskViewModelTests { + + // MARK: Computed + + var modelContainer: ModelContainer { + get throws { + try ModelContainer( + for: Todo.self, + configurations: .init(isStoredInMemoryOnly: true) + ) + } + } + +} diff --git a/UnitTests/Tests/View Models/TaskGroupListViewModelTests.swift b/UnitTests/Tests/View Models/TaskGroupListViewModelTests.swift index 7ee98fd..4e5068c 100644 --- a/UnitTests/Tests/View Models/TaskGroupListViewModelTests.swift +++ b/UnitTests/Tests/View Models/TaskGroupListViewModelTests.swift @@ -37,11 +37,10 @@ struct TaskGroupListViewModelTests { ) func countWhenEmptyData(for group: TodoGroup) throws { // GIVEN - let tasks: [Todo] = [] let viewModel = TaskGroupListView.ViewModel() // WHEN - let count = viewModel.count(tasks, for: group) + let count = viewModel.count(emptyTasks, for: group) // THEN #expect(count == 0) @@ -53,13 +52,33 @@ struct TaskGroupListViewModelTests { ) func countWhenFilledData(for group: TodoGroup) throws { // GIVEN + let viewModel = TaskGroupListView.ViewModel() + + // WHEN + let count = viewModel.count(sampleTasks, for: group) + + // THEN + #expect(count == 2) + } + +} + +// MARK: - Helpers + +private extension TaskGroupListViewModelTests { + + // MARK: Computed + + var emptyTasks: [Todo] { [] } + + var sampleTasks: [Todo] { let today: Date = .now let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) ?? today let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today let nextWeek = calendar.date(byAdding: .day, value: 7, to: today) ?? today let lastWeek = calendar.date(byAdding: .day, value: -7, to: today) ?? today - let tasks = [ + return [ Todo(title: "Today task #1", due: today), Todo(title: "Today task #2", due: today), Todo(title: "Tomorrow task #1", due: tomorrow), @@ -67,16 +86,8 @@ struct TaskGroupListViewModelTests { Todo(title: "Yesterday task #1", due: yesterday), Todo(title: "Last week task #1", due: lastWeek), Todo(title: "Completed task #1", due: tomorrow, completed: today), - Todo(title: "Completed task #2", due: yesterday, completed: today) + Todo(title: "Completed task #2", due: yesterday, completed: today), ] - - let viewModel = TaskGroupListView.ViewModel() - - // WHEN - let count = viewModel.count(tasks, for: group) - - // THEN - #expect(count == 2) } } diff --git a/UnitTests/Tests/View Models/TaskListViewModelTests.swift b/UnitTests/Tests/View Models/TaskListViewModelTests.swift new file mode 100644 index 0000000..7693472 --- /dev/null +++ b/UnitTests/Tests/View Models/TaskListViewModelTests.swift @@ -0,0 +1,27 @@ +import Foundation +import SwiftData +import Testing + +@testable import Todos + +@Suite("TaskList view model tests") +struct TaskListViewModelTests { + + // MARK: Computed tests + + @Test( + "navigationTitle returns correct title for each and every group", + arguments: TodoGroup.allCases + ) + func navigationTitle(for group: TodoGroup) { + // GIVEN + let viewModel = TaskListView.ViewModel(group: group) + + // WHEN + let title = viewModel.navigationTitle + + // THEN + #expect(title == group.title) + } + +}