From cbf33302dd18982c49e938d3383b3af1e37009b8 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Wed, 11 Feb 2026 13:00:46 +0000 Subject: [PATCH] Show completed task feature (#5) This PR contains the work done to add support for showing or hiding completed todo tasks to the *Task list* view. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/todos-app/pulls/5 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- App/Resources/Localizable.xcstrings | 38 ++++++- App/Sources/Enumerations/TodoGroup.swift | 33 +++++- .../TodoFetchDescriptor+Inits.swift | 46 +++++--- .../View Models/TaskListViewModel.swift | 73 ++++++++++-- .../Views/TaskList/TaskListContent.swift | 104 ++++++++++++++++++ App/Sources/Views/TaskListView.swift | 62 ++++++----- .../Tests/Enumerations/TodoGroupTests.swift | 19 ++++ .../Extensions/TodoFetchDescriptorTests.swift | 56 +++++++++- .../View Models/TaskListViewModelTests.swift | 89 ++++++++++++++- 9 files changed, 457 insertions(+), 63 deletions(-) create mode 100644 App/Sources/Views/TaskList/TaskListContent.swift diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings index 068d18e..cfe439f 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -45,6 +45,7 @@ } } }, + }, "view.new-task.navigation.title" : { "extractionState" : "manual", "localizations" : { @@ -121,6 +122,17 @@ } } } + "view.task-list-content.content-unavailable.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All Tasks Completed" + } + } + } + }, "view.task-list-item.placeholder.completed" : { "extractionState" : "manual", "localizations" : { @@ -154,13 +166,35 @@ } } }, - "view.task-list.content-unavailable.title" : { + "view.task-list.label.actions" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "All Tasks Completed" + "value" : "Menu Actions" + } + } + } + }, + "view.task-list.label.hide-completed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hide Completed" + } + } + } + }, + "view.task-list.label.show-completed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Completed" } } } diff --git a/App/Sources/Enumerations/TodoGroup.swift b/App/Sources/Enumerations/TodoGroup.swift index ccf3ac2..cb0b8ea 100644 --- a/App/Sources/Enumerations/TodoGroup.swift +++ b/App/Sources/Enumerations/TodoGroup.swift @@ -5,6 +5,16 @@ 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 @@ -20,6 +30,17 @@ enum TodoGroup { 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 + } + /// The color associated with the task group. var color: Color { switch self { @@ -40,7 +61,7 @@ extension TodoGroup { } } - /// The display title for the task group. + /// The localized display title for the task group. var title: LocalizedStringKey { switch self { case .completed: "enumeration.todo-group.title.completed" @@ -57,6 +78,13 @@ extension TodoGroup { extension TodoGroup: CaseIterable { + /// All todo group cases in their display order. + /// + /// The cases are ordered by priority and temporal relevance: + /// 1. `.today` - Most immediate tasks + /// 2. `.upcoming` - Future tasks + /// 3. `.overdue` - Past due tasks requiring attention + /// 4. `.completed` - Finished tasks static var allCases: [TodoGroup] { [.today, .upcoming, .overdue, .completed] } } @@ -65,6 +93,9 @@ extension TodoGroup: CaseIterable { extension TodoGroup: Identifiable { + /// The unique identifier for the group. + /// + /// Uses the case itself as the identifier, enabling use in SwiftUI's `ForEach` and other collection views. var id: Self { self } } diff --git a/App/Sources/Extensions/TodoFetchDescriptor+Inits.swift b/App/Sources/Extensions/TodoFetchDescriptor+Inits.swift index 0136396..ad3382d 100644 --- a/App/Sources/Extensions/TodoFetchDescriptor+Inits.swift +++ b/App/Sources/Extensions/TodoFetchDescriptor+Inits.swift @@ -19,36 +19,50 @@ extension FetchDescriptor where T == Todo { /// /// - Parameters: /// - group: The todo group category to filter by. + /// - showCompleted: Whether to include completed tasks. Only applies to groups other than `.completed`. /// - calendar: The calendar to use for date calculations. Defaults to `.current`. init( group: TodoGroup, + showCompleted: Bool, calendar: Calendar = .current ) { + let predicate: Predicate 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)] - ) + predicate = #Predicate { $0.completed != nil } case .overdue: - self.init( - predicate: #Predicate { $0.completed == nil && $0.due < today }, - sortBy: [.init(\.due, order: .reverse)] - ) + predicate = showCompleted + ? #Predicate { $0.due < today } + : #Predicate { $0.completed == nil && $0.due < today } case .today: - self.init( - predicate: #Predicate { $0.completed == nil && $0.due >= today && $0.due < tomorrow }, - sortBy: [.init(\.due, order: .forward)] - ) + predicate = showCompleted + ? #Predicate { $0.due >= today && $0.due < tomorrow } + : #Predicate { $0.completed == nil && $0.due >= today && $0.due < tomorrow } case .upcoming: - self.init( - predicate: #Predicate { $0.completed == nil && $0.due >= tomorrow }, - sortBy: [.init(\.due, order: .forward)] - ) + predicate = showCompleted + ? #Predicate { $0.due >= tomorrow } + : #Predicate { $0.completed == nil && $0.due >= tomorrow } } + + let sortDescriptors: [SortDescriptor] = + switch group { + case .completed: + [.init(\Todo.completed, order: .reverse)] + case .overdue: + [.init(\Todo.due, order: .reverse)] + case .today: + [.init(\Todo.due, order: .forward)] + case .upcoming: + [.init(\Todo.due, order: .forward)] + } + + self.init( + predicate: predicate, + sortBy: sortDescriptors + ) } } diff --git a/App/Sources/View Models/TaskListViewModel.swift b/App/Sources/View Models/TaskListViewModel.swift index f2ea825..2db3eb9 100644 --- a/App/Sources/View Models/TaskListViewModel.swift +++ b/App/Sources/View Models/TaskListViewModel.swift @@ -3,13 +3,18 @@ import SwiftUI extension TaskListView { - /// View model for managing the logic for the task list view. + /// View model for managing the state and business logic of 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. + /// This observable class encapsulates the presentation logic for the task list view, following the MVVM pattern to + /// separate concerns between the view and business logic layers. /// /// ## Responsibilities - /// - Providing the navigation title for the current group + /// + /// - Maintains the task group category (today, upcoming, overdue, completed) + /// - Manages the visibility preference for completed tasks + /// - 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 @Observable final class ViewModel { @@ -18,7 +23,15 @@ extension TaskListView { /// The task group to display tasks for. /// /// This property determines which tasks are fetched and displayed in the list. - private let group: TodoGroup + /// 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: + /// - `true`: Completed tasks are included in the fetch results + /// - `false`: Completed tasks are filtered out at the database level + var showCompleted: Bool // MARK: Initialization @@ -27,17 +40,55 @@ extension TaskListView { /// - Parameter group: The task group to display tasks for. init(group: TodoGroup) { self.group = group + showCompleted = false } - // MARK: Computed + // MARK: Computed Properties - /// The navigation title for the current task group. + /// The localized button title and icon for the show/hide completed todo tasks. /// - /// This computed property provides the localized title for the current group, - /// suitable for display in a navigation bar. + /// This computed property provides the appropriate label and icon based on + /// the current `showCompleted` state. /// - /// - Returns: The localized title key for the current group. - var navigationTitle: LocalizedStringKey { group.title } + /// - Returns: A tuple containing the localized title key and SF Symbol icon name. + var buttonCompleted: (title: LocalizedStringKey, icon: String) { + let title: LocalizedStringKey = showCompleted + ? "view.task-list.label.hide-completed" + : "view.task-list.label.show-completed" + let icon = showCompleted + ? "eye.slash" + : "eye" + + return (title, icon) + } + + /// Whether the completed todo tasks should be displayed. + /// + /// This property delegates to the group's `canShowCompleted` property to determine if completed todo tasks should be shown. + /// + /// - Returns: `true` if the completed todo tasks should be displayed, `false` otherwise. + var canShowCompleted: Bool { + group.canShowCompleted + } + + /// The navigation title for the task list view. + /// + /// This computed property provides the localized title for the task list view, suitable for display in a navigation bar. + /// + /// - Returns: The localized title key for the task list view. + var navigationTitle: LocalizedStringKey { + group.title + } + + // MARK: Methods + + /// Toggles the visibility of completed tasks. + /// + /// This method flips the `showCompleted` state, which triggers SwiftUI to recreate the task list view and its children. + /// The toggle has no effect when `canShowCompleted` is `false`. + func toggleCompletedVisibility() { + showCompleted.toggle() + } } } diff --git a/App/Sources/Views/TaskList/TaskListContent.swift b/App/Sources/Views/TaskList/TaskListContent.swift new file mode 100644 index 0000000..5857a5d --- /dev/null +++ b/App/Sources/Views/TaskList/TaskListContent.swift @@ -0,0 +1,104 @@ +import SwiftData +import SwiftUI + +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. + struct Content: View { + + // MARK: Properties + + /// Query to fetch todos filtered by group and completion status. + /// + /// This query is initialized with a `FetchDescriptor` that applies predicates + /// based on the group type and completion filter. SwiftData automatically + /// monitors changes and updates the view when the underlying data changes. + @Query + private var tasks: [Todo] + + // MARK: Initializers + + /// Creates a content view with a configured task query. + /// + /// This initializer sets up the `@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. + /// + /// - 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`. + init( + group: TodoGroup, + showCompleted: Bool + ) { + _tasks = .init( + FetchDescriptor( + group: group, + showCompleted: showCompleted + ) + ) + } + + // MARK: Body + + /// The content and behavior of the view. + /// + /// Displays either a content unavailable state or a list of tasks + /// based on whether any tasks match the current filters. + var body: some View { + Group { + if tasks.isEmpty { + ContentUnavailableView( + "view.task-list-content.content-unavailable.title", + systemImage: "checkmark.circle" + ) + } + else { + List { + ForEach(tasks) { todo in + TaskListView.Item(todo) + } + } + .listStyle(.grouped) + } + } + } + } + +} + +// MARK: - Previews + +#Preview( + "Task list content when there are no tasks", + traits: .emptyContainer +) { + TaskListView.Content( + group: .today, + showCompleted: .random() + ) +} + +#Preview( + "Task list content when completed tasks not shown", + traits: .sampleTasks +) { + TaskListView.Content( + group: .today, + showCompleted: false + ) +} + +#Preview( + "Task list content when completed tasks shown", + traits: .sampleTasks +) { + TaskListView.Content( + group: .today, + showCompleted: true + ) +} diff --git a/App/Sources/Views/TaskListView.swift b/App/Sources/Views/TaskListView.swift index 78cd378..e8eb57c 100644 --- a/App/Sources/Views/TaskListView.swift +++ b/App/Sources/Views/TaskListView.swift @@ -1,54 +1,60 @@ import SwiftData import SwiftUI -/// A view that displays all todo tasks for a specific task group. +/// A view that displays filtered 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. +/// This view provides the presentation layer for displaying and managing tasks within a specific group category. +/// It follows the MVVM pattern, delegating business logic to a view model and data fetching to a separate content view. 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. + + /// The view model managing the state and business logic for this view. + /// + /// This view model holds the group configuration and completion filter state. @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. + /// + /// - Parameter group: The todo group category 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. + /// Displays the task list content with navigation title and toolbar controls. + /// The toolbar includes a completion filter toggle for groups that support it. 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) + Content( + group: viewModel.group, + showCompleted: viewModel.showCompleted + ) + .navigationTitle(viewModel.navigationTitle) + .toolbar { + if viewModel.canShowCompleted { + Menu { + Button.init( + viewModel.buttonCompleted.title, + systemImage: viewModel.buttonCompleted.icon + ) { + viewModel.toggleCompletedVisibility() } + } label: { + Label( + "view.task-list.label.actions", + systemImage: "ellipsis" + ) } - .listStyle(.grouped) } } - .navigationTitle(viewModel.navigationTitle) } + } // MARK: - Previews diff --git a/UnitTests/Tests/Enumerations/TodoGroupTests.swift b/UnitTests/Tests/Enumerations/TodoGroupTests.swift index e58058e..22116ee 100644 --- a/UnitTests/Tests/Enumerations/TodoGroupTests.swift +++ b/UnitTests/Tests/Enumerations/TodoGroupTests.swift @@ -9,6 +9,25 @@ struct TodoGroupTests { // MARK: Computed tests + @Test( + "canShowCompleted returns correct values", + arguments: zip( + TodoGroup.allCases, + [true, true, true, false] + ) + ) + func canShowCompleted( + for group: TodoGroup, + expects expectation: Bool + ) { + // GIVEN + // WHEN + let canShowCompleted = group.canShowCompleted + + // THEN + #expect(canShowCompleted == expectation) + } + @Test( "group color match expected values", arguments: zip( diff --git a/UnitTests/Tests/Extensions/TodoFetchDescriptorTests.swift b/UnitTests/Tests/Extensions/TodoFetchDescriptorTests.swift index 68d6ddf..9a521cb 100644 --- a/UnitTests/Tests/Extensions/TodoFetchDescriptorTests.swift +++ b/UnitTests/Tests/Extensions/TodoFetchDescriptorTests.swift @@ -20,7 +20,10 @@ struct TodoFetchDescriptorTests { let context = container.mainContext // WHEN - let descriptor = FetchDescriptor(group: .today) + let descriptor = FetchDescriptor( + group: .today, + showCompleted: .random() + ) let results = try context.fetch(descriptor) // THEN @@ -28,7 +31,7 @@ struct TodoFetchDescriptorTests { } @Test( - "initializes with group when filled tasks", + "initializes with group and show completed disabled when filled tasks", arguments: TodoGroup.allCases ) @MainActor @@ -40,7 +43,10 @@ struct TodoFetchDescriptorTests { insert(into: context) // WHEN - let descriptor = FetchDescriptor(group: group) + let descriptor = FetchDescriptor( + group: group, + showCompleted: false + ) let results = try context.fetch(descriptor) // THEN @@ -61,6 +67,50 @@ struct TodoFetchDescriptorTests { #expect(results[1].title == "Upcoming task #2") } } + + @Test( + "initializes with group and show completed enabled when filled tasks", + arguments: TodoGroup.allCases + ) + @MainActor + func initializationWithShowCompleted(for group: TodoGroup) throws { + // 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 { + #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") + } + } } diff --git a/UnitTests/Tests/View Models/TaskListViewModelTests.swift b/UnitTests/Tests/View Models/TaskListViewModelTests.swift index 7693472..c87cf5c 100644 --- a/UnitTests/Tests/View Models/TaskListViewModelTests.swift +++ b/UnitTests/Tests/View Models/TaskListViewModelTests.swift @@ -1,5 +1,6 @@ import Foundation import SwiftData +import SwiftUI import Testing @testable import Todos @@ -7,10 +8,73 @@ import Testing @Suite("TaskList view model tests") struct TaskListViewModelTests { - // MARK: Computed tests + // MARK: Initializers tests @Test( - "navigationTitle returns correct title for each and every group", + "properties are set correctly during initialization", + arguments: TodoGroup.allCases + ) + func initialization(for group: TodoGroup) { + // GIVEN + // WHEN + let viewModel = TaskListView.ViewModel(group: group) + + // THEN + #expect(viewModel.group == group) + #expect(viewModel.showCompleted == false) + } + + // MARK: Computed property tests + + @Test( + "buttonCompleted returns correct values when showCompleted is true", + arguments: TodoGroup.allCases, [true, false] + ) + func buttonCompleted( + for group: TodoGroup, + showCompleted: Bool + ) { + // GIVEN + let viewModel = TaskListView.ViewModel(group: group) + + viewModel.showCompleted = showCompleted + + // WHEN + let (title, icon) = viewModel.buttonCompleted + + // THEN + if showCompleted { + #expect(title == "view.task-list.label.hide-completed") + #expect(icon == "eye.slash") + } else { + #expect(title == "view.task-list.label.show-completed") + #expect(icon == "eye") + } + } + + @Test( + "canShowCompleted returns correct value for each group", + arguments: zip( + TodoGroup.allCases, + [true, true, true, false] + ) + ) + func canShowCompleted( + for group: TodoGroup, + expects expectation: Bool + ) { + // GIVEN + let viewModel = TaskListView.ViewModel(group: group) + + // WHEN + let canShowCompleted = viewModel.canShowCompleted + + // THEN + #expect(canShowCompleted == expectation) + } + + @Test( + "navigationTitle returns correct view title for each group", arguments: TodoGroup.allCases ) func navigationTitle(for group: TodoGroup) { @@ -24,4 +88,25 @@ struct TaskListViewModelTests { #expect(title == group.title) } + // MARK: Method tests + + @Test("toggleCompletedVisbility toggles the showCompleted property") + func toggleCompletedVisibility() { + // GIVEN + let viewModel = TaskListView.ViewModel(group: .today) + let initialValue = viewModel.showCompleted + + // WHEN + viewModel.toggleCompletedVisibility() + let afterFirstToggle = viewModel.showCompleted + + viewModel.toggleCompletedVisibility() + let afterSecondToggle = viewModel.showCompleted + + // THEN + #expect(initialValue == false) + #expect(afterFirstToggle == true) + #expect(afterSecondToggle == false) + } + }