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/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/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/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) + } + }