Group tasks feature (#3)
This PR contains the work done to implement the **Group Task** feature, and to integrate it into the main content view of the app. Furthermore, an issue related to triggering a view update after inserting the todo tasks into the model container has been fixed. Reviewed-on: rock-n-code/todos-app#3 Co-authored-by: Javier Cicchelli <javier@rock-n-code.com> Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit was merged in pull request #3.
This commit is contained in:
@@ -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())
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user