From 22999bb578ca75527024a1281b6fee72a64ead6c Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 10 Feb 2026 02:15:26 +0100 Subject: [PATCH] Implemented the TaskGroupListView view and its respective view model in the app target. --- App/Resources/Localizable.xcstrings | 11 +++ .../View Models/ContentViewModel.swift | 2 +- .../View Models/NewTaskViewModel.swift | 2 +- .../View Models/TaskGroupListViewModel.swift | 86 ++++++++++++++++++ App/Sources/Views/TaskGroupListView.swift | 79 ++++++++++++++++ .../TaskGroupListViewModelTests.swift | 91 +++++++++++++++++++ 6 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 App/Sources/View Models/TaskGroupListViewModel.swift create mode 100644 App/Sources/Views/TaskGroupListView.swift create mode 100644 UnitTests/Tests/View Models/TaskGroupListViewModelTests.swift diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings index 482e0bf..46bd4cd 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -110,6 +110,17 @@ } } } + }, + "view.task-group-list.navigation.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todos" + } + } + } } }, "version" : "1.1" diff --git a/App/Sources/View Models/ContentViewModel.swift b/App/Sources/View Models/ContentViewModel.swift index ecddcec..d565b92 100644 --- a/App/Sources/View Models/ContentViewModel.swift +++ b/App/Sources/View Models/ContentViewModel.swift @@ -11,7 +11,7 @@ extension ContentView { /// The view model manages: /// - Presentation state for the new task view within a sheet. @Observable - class ViewModel { + final class ViewModel { // MARK: Properties diff --git a/App/Sources/View Models/NewTaskViewModel.swift b/App/Sources/View Models/NewTaskViewModel.swift index cc300f6..732c753 100644 --- a/App/Sources/View Models/NewTaskViewModel.swift +++ b/App/Sources/View Models/NewTaskViewModel.swift @@ -13,7 +13,7 @@ extension NewTaskView { /// - Form validation (ensuring title is not empty) /// - Saving the todo to SwiftData model context @Observable - class ViewModel { + final class ViewModel { // MARK: Properties diff --git a/App/Sources/View Models/TaskGroupListViewModel.swift b/App/Sources/View Models/TaskGroupListViewModel.swift new file mode 100644 index 0000000..8537307 --- /dev/null +++ b/App/Sources/View Models/TaskGroupListViewModel.swift @@ -0,0 +1,86 @@ +import SwiftData +import SwiftUI + +extension TaskGroupListView { + + /// View model for managing the logic for the task group list view. + /// + /// This observable class encapsulates the state and business logic for the task group list view. + /// It follows the MVVM pattern to separate presentation logic from the view layer. + /// + /// ## Responsibilities + /// - Providing access to all task groups for iteration + /// - Efficiently counting tasks per group using SwiftData fetch descriptors + /// - Managing date calculations for group categorization + @Observable + final class ViewModel { + + // MARK: Properties + + /// The calendar used for date calculations. + /// + /// This calendar is used to determine day boundaries for categorizing tasks into date groups: today, upcoming, overdue. + /// Defaults to the current calendar. + private let calendar: Calendar + + // MARK: Initialization + + /// Initializes a new view model instance. + /// + /// - Parameter calendar: The calendar to use for date calculations. Defaults to `.current`. + init(calendar: Calendar = .current) { + self.calendar = calendar + } + + // MARK: Computed + + /// Returns all available task groups in an predetermined order. + /// + /// This computed property provides access to all task group cases for iteration in the view. + /// + /// - Returns: An array of all `TodoGroup` cases in a predetermined order. + var groups: [TodoGroup] { + TodoGroup.allCases + } + + // MARK: Functions + + /// Calculates the number of tasks for a given task group using SwiftData. + /// + /// This method performs an efficient database-level count using SwiftData's `fetchCount` method with predicate-based filtering. + /// Tasks are categorized based on their due date and completion status. + /// + /// - Parameters: + /// - group: The task group to count tasks for. + /// - modelContext: The SwiftData model context to query. + /// - Returns: The number of tasks in the specified group, or 0 if an error occurs. + func count( + for group: TodoGroup, + in modelContext: ModelContext + ) -> Int { + let today = calendar.startOfDay(for: .now) + let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) ?? .now + + let fetchDescriptor: FetchDescriptor = + switch group { + case .completed: + .init(predicate: #Predicate { $0.completed != nil }) + case .overdue: + .init(predicate: #Predicate { $0.completed == nil && $0.due < today }) + case .today: + .init(predicate: #Predicate { $0.completed == nil && $0.due >= today && $0.due < tomorrow }) + case .upcoming: + .init(predicate: #Predicate { $0.completed == nil && $0.due >= tomorrow }) + } + + do { + return try modelContext.fetchCount(fetchDescriptor) + } + catch { + print("Error fetching count for group \(group): \(error)") + return 0 + } + } + + } +} diff --git a/App/Sources/Views/TaskGroupListView.swift b/App/Sources/Views/TaskGroupListView.swift new file mode 100644 index 0000000..a334709 --- /dev/null +++ b/App/Sources/Views/TaskGroupListView.swift @@ -0,0 +1,79 @@ +import SwiftData +import SwiftUI + +/// A view that displays todo tasks grouped by date and completion categories. +/// +/// This view serves as the main interface for browsing tasks organized into four groups: +/// - **Today**: Tasks due today that are not yet completed +/// - **Upcoming**: Tasks due in the future that are not yet completed +/// - **Overdue**: Tasks due in the past that are not yet completed +/// - **Completed**: Tasks that have been marked as done +struct TaskGroupListView: View { + + // MARK: Properties + + /// The SwiftData model context for database operations. + /// + /// This context is used by the view model to perform efficient database-level queries for counting tasks in each group. + @Environment(\.modelContext) + private var modelContext + + /// The view model managing the state and logic for this view. + /// + /// Handles task counting and provides access to all task groups. + /// Initialized with the default calendar for date calculations. + @State + private var viewModel = ViewModel() + + // MARK: Body + + /// The content and behavior of the view. + /// + /// Displays a scrollable vertical list of task group cards. Each card shows: + /// - The group's icon in its associated color + /// - The group's title + /// - The current count of tasks in that group + /// + /// Cards are tappable and navigate to a detailed task list for the selected group. + /// Task counts are fetched on-demand from the model context for each render. + var body: some View { + ScrollView { + VStack(spacing: 16) { + ForEach(viewModel.groups) { group in + NavigationLink(destination: TaskListView(group: group)) { + Card( + group: group, + count: viewModel.count( + for: group, + in: modelContext + ) + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + } + .navigationTitle("view.task-group-list.navigation.title") + } +} + +// MARK: - Previews + +#Preview( + "Task group list with empty tasks", + traits: .emptyContainer +) { + NavigationStack { + TaskGroupListView() + } +} + +#Preview( + "Task group list with sample tasks", + traits: .sampleTasks +) { + NavigationStack { + TaskGroupListView() + } +} diff --git a/UnitTests/Tests/View Models/TaskGroupListViewModelTests.swift b/UnitTests/Tests/View Models/TaskGroupListViewModelTests.swift new file mode 100644 index 0000000..f3251c9 --- /dev/null +++ b/UnitTests/Tests/View Models/TaskGroupListViewModelTests.swift @@ -0,0 +1,91 @@ +import Foundation +import SwiftData +import Testing + +@testable import Todos + +@Suite("TaskGroupListView view model tests") +struct TaskGroupListViewModelTests { + + // MARK: Properties + + private let calendar: Calendar = .current + + // MARK: Computed tests + + @Test("groups returns cases in predetermined order") + func groups() { + // GIVEN + let viewModel = TaskGroupListView.ViewModel() + + // WHEN + let groups = viewModel.groups + + // THEN + #expect(groups.count == 4) + #expect(groups[0] == .today) + #expect(groups[1] == .upcoming) + #expect(groups[2] == .overdue) + #expect(groups[3] == .completed) + } + + // MARK: Functions tests + + @MainActor + @Test( + "count returns zero when empty data", + arguments: TodoGroup.allCases + ) + func countWhenEmptyData(for group: TodoGroup) throws { + // GIVEN + let container = try ModelContainer( + for: Todo.self, + configurations: .init(isStoredInMemoryOnly: true) + ) + let viewModel = TaskGroupListView.ViewModel() + + // WHEN + let count = viewModel.count(for: group, in: container.mainContext) + + // THEN + #expect(count == 0) + } + + @MainActor + @Test( + "count returns correct count when filled data", + arguments: TodoGroup.allCases + ) + func countWhenFilledData(for group: TodoGroup) throws { + // GIVEN + let container = try ModelContainer( + for: Todo.self, + configurations: .init(isStoredInMemoryOnly: true) + ) + let context = container.mainContext + + 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 + + context.insert(Todo(title: "Today task #1", due: today)) + context.insert(Todo(title: "Today task #2", due: today)) + context.insert(Todo(title: "Tomorrow task #1", due: tomorrow)) + context.insert(Todo(title: "Next week task #1", due: nextWeek)) + context.insert(Todo(title: "Yesterday task #1", due: yesterday)) + context.insert(Todo(title: "Last week task #1", due: lastWeek)) + context.insert(Todo(title: "Completed task #1", due: tomorrow, completed: today)) + context.insert(Todo(title: "Completed task #2", due: yesterday, completed: today)) + + let viewModel = TaskGroupListView.ViewModel() + + // WHEN + let count = viewModel.count(for: group, in: context) + + // THEN + #expect(count == 2) + } + +}