Group tasks feature #3

Merged
javier merged 7 commits from app/group-tasks into main 2026-02-10 02:22:41 +00:00
16 changed files with 806 additions and 9 deletions
@@ -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())
}
@@ -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())
}
+55
View File
@@ -1,6 +1,50 @@
{ {
"sourceLanguage" : "en", "sourceLanguage" : "en",
"strings" : { "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" : { "view.new-task.navigation.title" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
@@ -66,6 +110,17 @@
} }
} }
} }
},
"view.task-group-list.navigation.title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Todos"
}
}
}
} }
}, },
"version" : "1.1" "version" : "1.1"
+70
View File
@@ -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 }
}
@@ -11,7 +11,7 @@ extension ContentView {
/// The view model manages: /// The view model manages:
/// - Presentation state for the new task view within a sheet. /// - Presentation state for the new task view within a sheet.
@Observable @Observable
class ViewModel { final class ViewModel {
// MARK: Properties // MARK: Properties
@@ -13,7 +13,7 @@ extension NewTaskView {
/// - Form validation (ensuring title is not empty) /// - Form validation (ensuring title is not empty)
/// - Saving the todo to SwiftData model context /// - Saving the todo to SwiftData model context
@Observable @Observable
class ViewModel { final class ViewModel {
// MARK: Properties // MARK: Properties
@@ -0,0 +1,87 @@
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 from an in-memory collection.
///
/// 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.
/// - Returns: The number of tasks in the specified group.
func count(
_ tasks: [Todo],
for group: TodoGroup
) -> Int {
let today = calendar.startOfDay(for: .now)
let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) ?? .now
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
}
}
.count
}
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ struct ContentView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
List {} TaskGroupListView()
Button(action: viewModel.showNewTask) { Button(action: viewModel.showNewTask) {
Image(systemName: "plus") Image(systemName: "plus")
+6 -4
View File
@@ -52,7 +52,7 @@ struct NewTaskView: View {
"view.new-task.placeholder.title", "view.new-task.placeholder.title",
text: $viewModel.title text: $viewModel.title
) )
.autocapitalization(.none) .autocapitalization(.sentences)
.autocorrectionDisabled(false) .autocorrectionDisabled(false)
.keyboardType(.asciiCapable) .keyboardType(.asciiCapable)
@@ -61,7 +61,7 @@ struct NewTaskView: View {
text: $viewModel.note, text: $viewModel.note,
axis: .vertical axis: .vertical
) )
.autocapitalization(.none) .autocapitalization(.sentences)
.autocorrectionDisabled(false) .autocorrectionDisabled(false)
.keyboardType(.asciiCapable) .keyboardType(.asciiCapable)
.lineLimit(3 ... 6) .lineLimit(3 ... 6)
@@ -99,7 +99,9 @@ struct NewTaskView: View {
// MARK: - Previews // MARK: - Previews
#Preview("New todo task") { #Preview(
"New todo task",
traits: .emptyContainer
) {
NewTaskView() NewTaskView()
.modelContainer(for: Todo.self, inMemory: true)
} }
@@ -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)
}
+77
View File
@@ -0,0 +1,77 @@
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
/// Query to monitor all todo tasks for changes.
///
/// 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.
///
/// 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(tasks, for: group)
)
}
.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()
}
}
+2
View File
@@ -378,6 +378,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "App/Previews/Modifiers/SampleTaskDataModifier.swift App/Previews/Modifiers/EmptyContainerModifier.swift";
DEVELOPMENT_TEAM = 7FMNM89WKG; DEVELOPMENT_TEAM = 7FMNM89WKG;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -423,6 +424,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "App/Previews/Modifiers/SampleTaskDataModifier.swift App/Previews/Modifiers/EmptyContainerModifier.swift";
DEVELOPMENT_TEAM = 7FMNM89WKG; DEVELOPMENT_TEAM = 7FMNM89WKG;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -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)
}
}
@@ -4,7 +4,7 @@ import Testing
@testable import Todos @testable import Todos
@Suite("ContentView.ViewModel Tests") @Suite("ContentView view model tests")
struct ContentViewModelTests { struct ContentViewModelTests {
// MARK: Initialization Tests // MARK: Initialization Tests
@@ -4,7 +4,7 @@ import Testing
@testable import Todos @testable import Todos
@Suite("NewTaskView ViewModel Tests") @Suite("NewTaskView view model tests")
struct NewTaskViewModelTests { struct NewTaskViewModelTests {
// MARK: Initialization tests // MARK: Initialization tests
@@ -0,0 +1,82 @@
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
@Test(
"count returns zero when empty data",
arguments: TodoGroup.allCases
)
func countWhenEmptyData(for group: TodoGroup) throws {
// GIVEN
let tasks: [Todo] = []
let viewModel = TaskGroupListView.ViewModel()
// WHEN
let count = viewModel.count(tasks, for: group)
// THEN
#expect(count == 0)
}
@Test(
"count returns correct count when filled data",
arguments: TodoGroup.allCases
)
func countWhenFilledData(for group: TodoGroup) throws {
// GIVEN
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 = [
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(tasks, for: group)
// THEN
#expect(count == 2)
}
}