From fae7a476a1c476ef2404319375f9ce399f2ca3b5 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Mon, 9 Feb 2026 20:37:52 +0100 Subject: [PATCH 1/7] Implemented the TodoGroup enumeration in the app target. --- App/Resources/Localizable.xcstrings | 44 +++++++++ App/Sources/Enumerations/TodoGroup.swift | 70 +++++++++++++ .../Tests/Enumerations/TodoGroupTests.swift | 99 +++++++++++++++++++ .../View Models/ContentViewModelTests.swift | 2 +- .../View Models/NewTaskViewModelTests.swift | 2 +- 5 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 App/Sources/Enumerations/TodoGroup.swift create mode 100644 UnitTests/Tests/Enumerations/TodoGroupTests.swift diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings index c025f50..482e0bf 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -1,6 +1,50 @@ { "sourceLanguage" : "en", "strings" : { + "enumeration.todo-group.title.completed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completed" + } + } + } + }, + "enumeration.todo-group.title.overdue" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overdue" + } + } + } + }, + "enumeration.todo-group.title.today" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Today" + } + } + } + }, + "enumeration.todo-group.title.upcoming" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upcoming" + } + } + } + }, "view.new-task.navigation.title" : { "extractionState" : "manual", "localizations" : { diff --git a/App/Sources/Enumerations/TodoGroup.swift b/App/Sources/Enumerations/TodoGroup.swift new file mode 100644 index 0000000..ccf3ac2 --- /dev/null +++ b/App/Sources/Enumerations/TodoGroup.swift @@ -0,0 +1,70 @@ +import Foundation +import SwiftUI + +/// Categorizes todo tasks into groups based on their due dates and completion status. +/// +/// 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. +enum TodoGroup { + /// Tasks that have been marked as done, regardless of due date. + case completed + /// Tasks with due dates in the past that remain incomplete. + case overdue + /// Tasks due on the current day that are not yet completed. + case today + /// Tasks due in the future that are not yet completed. + case upcoming +} + +// MARK: - Computed + +extension TodoGroup { + + /// The color associated with the task group. + var color: Color { + switch self { + case .completed: .gray + case .overdue: .red + case .today: .blue + case .upcoming: .green + } + } + + /// The system icon name for the task group. + var iconName: String { + switch self { + case .completed: "checkmark.circle.fill" + case .overdue: "calendar.badge.exclamationmark" + case .today: "calendar.badge.clock" + case .upcoming: "calendar" + } + } + + /// The display title for the task group. + var title: LocalizedStringKey { + switch self { + case .completed: "enumeration.todo-group.title.completed" + case .overdue: "enumeration.todo-group.title.overdue" + case .today: "enumeration.todo-group.title.today" + case .upcoming: "enumeration.todo-group.title.upcoming" + + } + } + +} + +// MARK: - CaseIterable + +extension TodoGroup: CaseIterable { + + static var allCases: [TodoGroup] { [.today, .upcoming, .overdue, .completed] } + +} + +// MARK: - Identifiable + +extension TodoGroup: Identifiable { + + var id: Self { self } + +} diff --git a/UnitTests/Tests/Enumerations/TodoGroupTests.swift b/UnitTests/Tests/Enumerations/TodoGroupTests.swift new file mode 100644 index 0000000..e58058e --- /dev/null +++ b/UnitTests/Tests/Enumerations/TodoGroupTests.swift @@ -0,0 +1,99 @@ +import Foundation +import SwiftUI +import Testing + +@testable import Todos + +@Suite("TodoGroup enumeration tests") +struct TodoGroupTests { + + // MARK: Computed tests + + @Test( + "group color match expected values", + arguments: zip( + TodoGroup.allCases, + [Color.blue, .green, .red, .gray] + ) + ) + func color( + for group: TodoGroup, + expects expectation: Color + ) { + // GIVEN + // WHEN + let color = group.color + + // THEN + #expect(color == expectation) + } + + @Test( + "group icon names match expected values", + arguments: zip( + TodoGroup.allCases, + ["calendar.badge.clock", "calendar", "calendar.badge.exclamationmark", "checkmark.circle.fill"] + ) + ) + func iconName( + for group: TodoGroup, + expects expectation: String + ) { + // GIVEN + // WHEN + let iconName = group.iconName + + // THEN + #expect(iconName == expectation) + } + + @Test( + "group titles match expected values", + arguments: zip( + TodoGroup.allCases, + ["today", "upcoming", "overdue", "completed"] + ) + ) + func title( + for group: TodoGroup, + expects expectation: String + ) { + // GIVEN + let key = "enumeration.todo-group.title." + expectation + + // WHEN + let title = group.title + + // THEN + #expect(title == .init(key)) + } + + // MARK: Case Iteration Tests + + @Test("all cases contains all cases, in correct order") + func allCases() { + // GIVEN + // WHEN + let allCases = TodoGroup.allCases + + // THEN + #expect(allCases.count == 4) + #expect(allCases[0] == .today) + #expect(allCases[1] == .upcoming) + #expect(allCases[2] == .overdue) + #expect(allCases[3] == .completed) + } + + // MARK: Identifiable Tests + + @Test("id returns an identity of a case", arguments: TodoGroup.allCases) + func id(for group: TodoGroup) { + // GIVEN + // WHEN + let id = group.id + + // THEN + #expect(id == group) + } + +} diff --git a/UnitTests/Tests/View Models/ContentViewModelTests.swift b/UnitTests/Tests/View Models/ContentViewModelTests.swift index 668bd6e..adb8e25 100644 --- a/UnitTests/Tests/View Models/ContentViewModelTests.swift +++ b/UnitTests/Tests/View Models/ContentViewModelTests.swift @@ -4,7 +4,7 @@ import Testing @testable import Todos -@Suite("ContentView.ViewModel Tests") +@Suite("ContentView view model tests") struct ContentViewModelTests { // MARK: Initialization Tests diff --git a/UnitTests/Tests/View Models/NewTaskViewModelTests.swift b/UnitTests/Tests/View Models/NewTaskViewModelTests.swift index e33bc0c..12961b1 100644 --- a/UnitTests/Tests/View Models/NewTaskViewModelTests.swift +++ b/UnitTests/Tests/View Models/NewTaskViewModelTests.swift @@ -4,7 +4,7 @@ import Testing @testable import Todos -@Suite("NewTaskView ViewModel Tests") +@Suite("NewTaskView view model tests") struct NewTaskViewModelTests { // MARK: Initialization tests -- 2.54.0 From 5f6ae0d55b72061da5a747fadb86edec64508e4e Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Mon, 9 Feb 2026 22:27:07 +0100 Subject: [PATCH 2/7] Implemented the TaskGroupListCard view in the app target. --- App/Sources/Views/NewTaskView.swift | 4 +- .../TaskGroupList/TaskGroupListCard.swift | 113 ++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 App/Sources/Views/TaskGroupList/TaskGroupListCard.swift diff --git a/App/Sources/Views/NewTaskView.swift b/App/Sources/Views/NewTaskView.swift index 9a7ec71..c25b455 100644 --- a/App/Sources/Views/NewTaskView.swift +++ b/App/Sources/Views/NewTaskView.swift @@ -52,7 +52,7 @@ struct NewTaskView: View { "view.new-task.placeholder.title", text: $viewModel.title ) - .autocapitalization(.none) + .autocapitalization(.sentences) .autocorrectionDisabled(false) .keyboardType(.asciiCapable) @@ -61,7 +61,7 @@ struct NewTaskView: View { text: $viewModel.note, axis: .vertical ) - .autocapitalization(.none) + .autocapitalization(.sentences) .autocorrectionDisabled(false) .keyboardType(.asciiCapable) .lineLimit(3 ... 6) diff --git a/App/Sources/Views/TaskGroupList/TaskGroupListCard.swift b/App/Sources/Views/TaskGroupList/TaskGroupListCard.swift new file mode 100644 index 0000000..47e06dc --- /dev/null +++ b/App/Sources/Views/TaskGroupList/TaskGroupListCard.swift @@ -0,0 +1,113 @@ +import SwiftUI + +extension TaskGroupListView { + + /// A card-style view representing a single todo task group with its count. + /// + /// This view displays a task group as a compact card showing: + /// - The group's icon and title on the left + /// - The task count on the right + /// - A tinted background using the group's color + /// + /// The view is designed to be used within the `TaskGroupListView` and is typically + /// wrapped in a `NavigationLink` to allow navigation to detailed task lists. + struct Card: View { + + // MARK: Properties + + /// The number of tasks. + private let count: Int + + /// The task group. + private let group: TodoGroup + + // MARK: Initializers + + /// Creates a task group list card. + /// - Parameters: + /// - group: The task group to display. + /// - count: The number of tasks in the group. + init( + group: TodoGroup, + count: Int + ) { + self.count = count + self.group = group + } + + // MARK: Body + + /// The content and behavior of the view. + /// + /// Displays a horizontal card layout with the group icon and title on the left, + /// and the task count on the right, all within a colored background. + var body: some View { + HStack { + VStack( + alignment: .leading, + spacing: 8 + ) { + Image(systemName: group.iconName) + .font(.system(size: 40)) + .foregroundStyle(group.color) + + Text(group.title) + .font(.headline) + .foregroundStyle(.primary) + .frame( + maxWidth: .infinity, + alignment: .leading + ) + } + .padding(.vertical, 24) + + Spacer() + + Text("\(count)") + .font(.title) + .fontWeight(.bold) + .foregroundStyle(group.color) + } + .padding(.horizontal, 24) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(group.color.opacity(0.1)) + ) + } + } + +} + +// MARK: - Previews + +#Preview("Today item") { + TaskGroupListView.Card( + group: .today, + count: .random(in: 0...20) + ) + .padding(.horizontal, 16) +} + +#Preview("Upcoming item") { + TaskGroupListView.Card( + group: .upcoming, + count: .random(in: 0...20) + ) + .padding(.horizontal, 16) +} + +#Preview("Overdue item") { + TaskGroupListView.Card( + group: .overdue, + count: .random(in: 0...20) + ) + .padding(.horizontal, 16) +} + +#Preview("Completed item") { + TaskGroupListView.Card( + group: .completed, + count: .random(in: 0...20) + ) + .padding(.horizontal, 16) +} -- 2.54.0 From 7b982799dbb39b02191be3896edc517c9f37fafb Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 10 Feb 2026 01:22:11 +0100 Subject: [PATCH 3/7] Implemented the SampleTaskDataModifier preview modifier in the app target. --- .../Modifiers/SampleTaskDataModifier.swift | 131 ++++++++++++++++++ Todos.xcodeproj/project.pbxproj | 2 + 2 files changed, 133 insertions(+) create mode 100644 App/Previews/Modifiers/SampleTaskDataModifier.swift diff --git a/App/Previews/Modifiers/SampleTaskDataModifier.swift b/App/Previews/Modifiers/SampleTaskDataModifier.swift new file mode 100644 index 0000000..5b463ef --- /dev/null +++ b/App/Previews/Modifiers/SampleTaskDataModifier.swift @@ -0,0 +1,131 @@ +import SwiftData +import SwiftUI + +/// A preview modifier that provides sample task data for SwiftUI previews. +/// +/// This modifier creates an in-memory SwiftData model container populated with representative sample todos across all task groups +/// (today, upcoming, overdue, completed). +/// It's designed to be used with SwiftUI previews to display realistic data without +/// requiring a persistent database. +/// +/// ## Usage +/// Use the `.sampleTasks` trait on your preview: +/// +/// ```swift +/// #Preview(traits: .sampleTasks) { +/// TaskGroupListView() +/// } +/// ``` +/// +/// This automatically provides a model container with sample data to the preview. +struct SampleTaskDataModifier: PreviewModifier { + + // MARK: Functions + + /// Creates and configures a shared in-memory model container with sample task data. + /// + /// This method is called once when the preview is loaded and creates an in-memory + /// SwiftData container populated with 8 representative todo tasks spanning all + /// task groups and various configurations. + /// + /// - Returns: A `ModelContainer` configured for in-memory storage with sample data. + /// - Throws: An error if the model container cannot be created. + static func makeSharedContext() async throws -> ModelContainer { + let modelContainer = try ModelContainer( + for: Todo.self, + configurations: .init(isStoredInMemoryOnly: true) + ) + + 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 + ), + .init( + title: "Today task 2", + note: "This is some random note attached to the task.", + due: today + ), + .init( + title: "Tomorrow task", + due: tomorrow + ), + .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 + ), + .init( + title: "Yesterday task", + due: yesterday + ), + .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 + ), + .init( + title: "Completed task 1", + due: today, + completed: today + ), + .init( + title: "Completed task 2", + note: "This is some random note attached to the task.", + due: yesterday, + completed: today + ), + ] + + for task in tasks { + modelContainer.mainContext.insert(task) + } + + return modelContainer + } + + /// Applies the model container to the preview content. + /// + /// This method is called by SwiftUI to apply the modifier to the preview. + /// It injects the model container into the SwiftUI environment, making it + /// available to any views that use `@Query` or `@Environment(\.modelContext)`. + /// + /// - Parameters: + /// - content: The preview content to modify. + /// - context: The model container with sample data. + /// - Returns: The content with the model container applied. + func body( + content: Content, + context: ModelContainer + ) -> some View { + content.modelContainer(context) + } + +} + +// MARK: - PreviewTrait + +extension PreviewTrait where T == Preview.ViewTraits { + + /// A preview trait that provides sample task data. + /// + /// Use this trait to automatically populate previews with realistic sample todos + /// without manually creating a model container and inserting data. + /// + /// ## Example + /// ```swift + /// #Preview(traits: .sampleTasks) { + /// TaskGroupListView() + /// } + /// ``` + /// + /// This is equivalent to manually creating a container with sample data but + /// much more concise and reusable across multiple previews. + static var sampleTasks: Self = .modifier(SampleTaskDataModifier()) + +} diff --git a/Todos.xcodeproj/project.pbxproj b/Todos.xcodeproj/project.pbxproj index da9cfef..3688d17 100644 --- a/Todos.xcodeproj/project.pbxproj +++ b/Todos.xcodeproj/project.pbxproj @@ -378,6 +378,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = App/Previews/Modifiers/SampleTaskDataModifier.swift; DEVELOPMENT_TEAM = 7FMNM89WKG; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -423,6 +424,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = App/Previews/Modifiers/SampleTaskDataModifier.swift; DEVELOPMENT_TEAM = 7FMNM89WKG; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; -- 2.54.0 From 24639081c09a46258a33c0a1a4f50e1afd880e28 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 10 Feb 2026 01:32:31 +0100 Subject: [PATCH 4/7] Implemented the EmptyContainerModifier preview modifier in the app target. --- .../Modifiers/EmptyContainerModifier.swift | 79 +++++++++++++++++++ App/Sources/Views/NewTaskView.swift | 6 +- Todos.xcodeproj/project.pbxproj | 4 +- 3 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 App/Previews/Modifiers/EmptyContainerModifier.swift diff --git a/App/Previews/Modifiers/EmptyContainerModifier.swift b/App/Previews/Modifiers/EmptyContainerModifier.swift new file mode 100644 index 0000000..9f9cd28 --- /dev/null +++ b/App/Previews/Modifiers/EmptyContainerModifier.swift @@ -0,0 +1,79 @@ +import SwiftData +import SwiftUI + +/// A preview modifier that provides an empty SwiftData model container for SwiftUI previews. +/// +/// This modifier creates an in-memory SwiftData model container with no data, useful for testing and previewing views in their empty or initial state. +/// It's particularly helpful for testing empty state UI, loading states, or views that should gracefully handle no data. +/// +/// ## Usage +/// Use the `.emptyContainer` trait on your preview: +/// +/// ```swift +/// #Preview(traits: .emptyContainer) { +/// TaskGroupListView() +/// } +/// ``` +/// +/// This automatically provides an empty model container to the preview, allowing SwiftData queries to return empty results. +struct EmptyContainerModifier: PreviewModifier { + + // MARK: Functions + + /// Creates and configures a shared in-memory model container with no data. + /// + /// This method is called once when the preview is loaded and creates an in-memory + /// SwiftData container with no persisted entities. The container is configured + /// to store data only in memory, ensuring previews don't affect any persistent storage. + /// + /// - Returns: An empty `ModelContainer` configured for in-memory storage. + /// - Throws: An error if the model container cannot be created. + static func makeSharedContext() async throws -> ModelContainer { + try ModelContainer( + for: Todo.self, + configurations: .init(isStoredInMemoryOnly: true) + ) + } + + /// Applies the empty model container to the preview content. + /// + /// This method is called by SwiftUI to apply the modifier to the preview. + /// It injects the empty model container into the SwiftUI environment, making it + /// available to any views that use `@Query` or `@Environment(\.modelContext)`. + /// Queries will return empty results, allowing you to test empty state behavior. + /// + /// - Parameters: + /// - content: The preview content to modify. + /// - context: The empty model container. + /// - Returns: The content with the empty model container applied. + func body( + content: Content, + context: ModelContainer + ) -> some View { + content.modelContainer(context) + } + +} + +// MARK: - PreviewTrait + +extension PreviewTrait where T == Preview.ViewTraits { + + /// A preview trait that provides an empty model container. + /// + /// Use this trait to test views in their empty state without any data. + /// This is particularly useful for testing empty state UI, placeholder content, + /// and ensuring views handle the absence of data gracefully. + /// + /// ## Example + /// ```swift + /// #Preview("Empty State", traits: .emptyContainer) { + /// TaskGroupListView() + /// } + /// ``` + /// + /// The view will receive an empty SwiftData container, causing any `@Query` + /// properties to return empty collections. + static var emptyContainer: Self = .modifier(EmptyContainerModifier()) + +} diff --git a/App/Sources/Views/NewTaskView.swift b/App/Sources/Views/NewTaskView.swift index c25b455..7b95d0b 100644 --- a/App/Sources/Views/NewTaskView.swift +++ b/App/Sources/Views/NewTaskView.swift @@ -99,7 +99,9 @@ struct NewTaskView: View { // MARK: - Previews -#Preview("New todo task") { +#Preview( + "New todo task", + traits: .emptyContainer +) { NewTaskView() - .modelContainer(for: Todo.self, inMemory: true) } diff --git a/Todos.xcodeproj/project.pbxproj b/Todos.xcodeproj/project.pbxproj index 3688d17..7747608 100644 --- a/Todos.xcodeproj/project.pbxproj +++ b/Todos.xcodeproj/project.pbxproj @@ -378,7 +378,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = App/Previews/Modifiers/SampleTaskDataModifier.swift; + DEVELOPMENT_ASSET_PATHS = "App/Previews/Modifiers/SampleTaskDataModifier.swift App/Previews/Modifiers/EmptyContainerModifier.swift"; DEVELOPMENT_TEAM = 7FMNM89WKG; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -424,7 +424,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = App/Previews/Modifiers/SampleTaskDataModifier.swift; + DEVELOPMENT_ASSET_PATHS = "App/Previews/Modifiers/SampleTaskDataModifier.swift App/Previews/Modifiers/EmptyContainerModifier.swift"; DEVELOPMENT_TEAM = 7FMNM89WKG; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; -- 2.54.0 From 22999bb578ca75527024a1281b6fee72a64ead6c Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 10 Feb 2026 02:15:26 +0100 Subject: [PATCH 5/7] 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) + } + +} -- 2.54.0 From 52a54184afb9145c781a18f87832645df5013991 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 10 Feb 2026 02:16:30 +0100 Subject: [PATCH 6/7] Integrated the TaskGroupListView view into the ContentView view in the app target. --- App/Sources/Views/ContentView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/App/Sources/Views/ContentView.swift b/App/Sources/Views/ContentView.swift index 34bb847..0e861e8 100644 --- a/App/Sources/Views/ContentView.swift +++ b/App/Sources/Views/ContentView.swift @@ -27,7 +27,7 @@ struct ContentView: View { var body: some View { NavigationView { ZStack(alignment: .bottomTrailing) { - List {} + TaskGroupListView() Button(action: viewModel.showNewTask) { Image(systemName: "plus") -- 2.54.0 From 937941f50b5f7ab2bdcf9f2313953f37fe44ed94 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 10 Feb 2026 03:18:56 +0100 Subject: [PATCH 7/7] Implemented the view refresh after data updates for the TaskGroupListView view in the app target. --- .../View Models/TaskGroupListViewModel.swift | 51 ++++++++++--------- App/Sources/Views/TaskGroupListView.swift | 14 +++-- .../TaskGroupListViewModelTests.swift | 35 +++++-------- 3 files changed, 45 insertions(+), 55 deletions(-) diff --git a/App/Sources/View Models/TaskGroupListViewModel.swift b/App/Sources/View Models/TaskGroupListViewModel.swift index 8537307..e420f98 100644 --- a/App/Sources/View Models/TaskGroupListViewModel.swift +++ b/App/Sources/View Models/TaskGroupListViewModel.swift @@ -45,41 +45,42 @@ extension TaskGroupListView { // MARK: Functions - /// Calculates the number of tasks for a given task group using SwiftData. + /// Calculates the number of tasks for a given task group from an in-memory collection. /// - /// 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. + /// This method filters the provided tasks array to count only those tasks that belong + /// to the specified group based on their due date and completion status. Tasks are + /// categorized as follows: + /// + /// - **Completed**: Tasks with a non-nil `completed` date + /// - **Overdue**: Incomplete tasks due before today + /// - **Today**: Incomplete tasks due today (from start of day to end of day) + /// - **Upcoming**: Incomplete tasks due tomorrow or later /// /// - Parameters: + /// - tasks: The array of tasks to filter and count. /// - 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. + /// - Returns: The number of tasks in the specified group. func count( - for group: TodoGroup, - in modelContext: ModelContext + _ tasks: [Todo], + for group: TodoGroup ) -> 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 }) + return tasks + .filter { task in + switch group { + case .completed: + task.completed != nil + case .overdue: + task.completed == nil && task.due < today + case .today: + task.completed == nil && task.due >= today && task.due < tomorrow + case .upcoming: + task.completed == nil && task.due >= tomorrow + } } - - do { - return try modelContext.fetchCount(fetchDescriptor) - } - catch { - print("Error fetching count for group \(group): \(error)") - return 0 - } + .count } } diff --git a/App/Sources/Views/TaskGroupListView.swift b/App/Sources/Views/TaskGroupListView.swift index a334709..da3a86d 100644 --- a/App/Sources/Views/TaskGroupListView.swift +++ b/App/Sources/Views/TaskGroupListView.swift @@ -12,11 +12,12 @@ struct TaskGroupListView: View { // MARK: Properties - /// The SwiftData model context for database operations. + /// Query to monitor all todo tasks for changes. /// - /// 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 + /// This query triggers view updates whenever todo tasks are inserted, updated, or deleted, + /// ensuring the task counts displayed in the view remain accurate. + @Query + private var tasks: [Todo] /// The view model managing the state and logic for this view. /// @@ -43,10 +44,7 @@ struct TaskGroupListView: View { NavigationLink(destination: TaskListView(group: group)) { Card( group: group, - count: viewModel.count( - for: group, - in: modelContext - ) + count: viewModel.count(tasks, for: group) ) } .buttonStyle(.plain) diff --git a/UnitTests/Tests/View Models/TaskGroupListViewModelTests.swift b/UnitTests/Tests/View Models/TaskGroupListViewModelTests.swift index f3251c9..7ee98fd 100644 --- a/UnitTests/Tests/View Models/TaskGroupListViewModelTests.swift +++ b/UnitTests/Tests/View Models/TaskGroupListViewModelTests.swift @@ -31,58 +31,49 @@ struct TaskGroupListViewModelTests { // 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 tasks: [Todo] = [] let viewModel = TaskGroupListView.ViewModel() // WHEN - let count = viewModel.count(for: group, in: container.mainContext) + let count = viewModel.count(tasks, for: group) // 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 tasks = [ + Todo(title: "Today task #1", due: today), + Todo(title: "Today task #2", due: today), + Todo(title: "Tomorrow task #1", due: tomorrow), + Todo(title: "Next week task #1", due: nextWeek), + 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) + ] let viewModel = TaskGroupListView.ViewModel() // WHEN - let count = viewModel.count(for: group, in: context) + let count = viewModel.count(tasks, for: group) // THEN #expect(count == 2) -- 2.54.0