Add Today View dashboard replacing Care tab (Phase 6)

Replace the Care tab with a new Today View dashboard that shows
overdue and today's care tasks grouped by room. Features include:

- TodayView: Main dashboard with greeting, progress stats, and task sections
- TodayViewModel: Room-based task grouping with completion tracking
- QuickStatsBar: Progress indicator showing completed vs total tasks
- TaskSection: Collapsible sections for overdue/today tasks
- RoomTaskGroup: Tasks grouped by room with "Water all" bulk action
- InMemoryRoomRepository: In-memory room storage for testing/mocks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-23 15:05:55 -06:00
parent 08ced7dbbb
commit efd935568a
9 changed files with 998 additions and 10 deletions

View File

@@ -6,6 +6,15 @@
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
010B95AE71B789A1929646CE /* TodayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A34C0DA41757D0CD1B7AB99 /* TodayViewModel.swift */; };
5DB7D935A323777A1B908A37 /* RoomTaskGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 678533C17B8C3244E2001F4F /* RoomTaskGroup.swift */; };
E1AE868BC224D8A4AFE1CD52 /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9D5ED974C43A2EC68CD03B /* TodayView.swift */; };
E201A13692202CDE6B2DE12B /* TaskSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADA0723BB8443C632252796 /* TaskSection.swift */; };
EB273C140861D3673719DC63 /* QuickStatsBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = C50ADB17181CA3A5FB44BBE2 /* QuickStatsBar.swift */; };
FA125D54D0111F44B104862B /* InMemoryRoomRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52FD1E71E8C36B0075A932F2 /* InMemoryRoomRepository.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
1C4B79FA2F21C37C00ED69CF /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
@@ -27,6 +36,12 @@
1C4B79E82F21C37A00ED69CF /* PlantGuide.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PlantGuide.app; sourceTree = BUILT_PRODUCTS_DIR; };
1C4B79F92F21C37C00ED69CF /* PlantGuideTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PlantGuideTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
1C4B7A032F21C37C00ED69CF /* PlantGuideUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PlantGuideUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
52FD1E71E8C36B0075A932F2 /* InMemoryRoomRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InMemoryRoomRepository.swift; sourceTree = "<group>"; };
678533C17B8C3244E2001F4F /* RoomTaskGroup.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoomTaskGroup.swift; sourceTree = "<group>"; };
7A9D5ED974C43A2EC68CD03B /* TodayView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; };
8A34C0DA41757D0CD1B7AB99 /* TodayViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodayViewModel.swift; sourceTree = "<group>"; };
C50ADB17181CA3A5FB44BBE2 /* QuickStatsBar.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = QuickStatsBar.swift; sourceTree = "<group>"; };
DADA0723BB8443C632252796 /* TaskSection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TaskSection.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -50,11 +65,15 @@
};
1C4B79FC2F21C37C00ED69CF /* PlantGuideTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = PlantGuideTests;
sourceTree = "<group>";
};
1C4B7A062F21C37C00ED69CF /* PlantGuideUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = PlantGuideUITests;
sourceTree = "<group>";
};
@@ -85,6 +104,17 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1A0266DEC4BEC766E4813767 /* Components */ = {
isa = PBXGroup;
children = (
C50ADB17181CA3A5FB44BBE2 /* QuickStatsBar.swift */,
DADA0723BB8443C632252796 /* TaskSection.swift */,
678533C17B8C3244E2001F4F /* RoomTaskGroup.swift */,
);
name = Components;
path = Components;
sourceTree = "<group>";
};
1C4B79DF2F21C37A00ED69CF = {
isa = PBXGroup;
children = (
@@ -92,6 +122,7 @@
1C4B79FC2F21C37C00ED69CF /* PlantGuideTests */,
1C4B7A062F21C37C00ED69CF /* PlantGuideUITests */,
1C4B79E92F21C37A00ED69CF /* Products */,
7B600A469FEF4379984ED673 /* PlantGuide */,
);
sourceTree = "<group>";
};
@@ -105,6 +136,63 @@
name = Products;
sourceTree = "<group>";
};
43A10090BDB504EEA8160579 /* Data */ = {
isa = PBXGroup;
children = (
775B6E967C5DFFD7F1871824 /* Repositories */,
);
name = Data;
path = Data;
sourceTree = "<group>";
};
775B6E967C5DFFD7F1871824 /* Repositories */ = {
isa = PBXGroup;
children = (
52FD1E71E8C36B0075A932F2 /* InMemoryRoomRepository.swift */,
);
name = Repositories;
path = Repositories;
sourceTree = "<group>";
};
7B600A469FEF4379984ED673 /* PlantGuide */ = {
isa = PBXGroup;
children = (
DEFE3CA84863FD85C7F7BB48 /* Presentation */,
43A10090BDB504EEA8160579 /* Data */,
);
name = PlantGuide;
path = PlantGuide;
sourceTree = "<group>";
};
96D83367DDD373621B7CA753 /* Scenes */ = {
isa = PBXGroup;
children = (
EB55B50C41964C736A4FF8A3 /* TodayView */,
);
name = Scenes;
path = Scenes;
sourceTree = "<group>";
};
DEFE3CA84863FD85C7F7BB48 /* Presentation */ = {
isa = PBXGroup;
children = (
96D83367DDD373621B7CA753 /* Scenes */,
);
name = Presentation;
path = Presentation;
sourceTree = "<group>";
};
EB55B50C41964C736A4FF8A3 /* TodayView */ = {
isa = PBXGroup;
children = (
8A34C0DA41757D0CD1B7AB99 /* TodayViewModel.swift */,
7A9D5ED974C43A2EC68CD03B /* TodayView.swift */,
1A0266DEC4BEC766E4813767 /* Components */,
);
name = TodayView;
path = TodayView;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -124,8 +212,6 @@
1C4B79EA2F21C37A00ED69CF /* PlantGuide */,
);
name = PlantGuide;
packageProductDependencies = (
);
productName = PlantGuide;
productReference = 1C4B79E82F21C37A00ED69CF /* PlantGuide.app */;
productType = "com.apple.product-type.application";
@@ -147,8 +233,6 @@
1C4B79FC2F21C37C00ED69CF /* PlantGuideTests */,
);
name = PlantGuideTests;
packageProductDependencies = (
);
productName = PlantGuideTests;
productReference = 1C4B79F92F21C37C00ED69CF /* PlantGuideTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
@@ -170,8 +254,6 @@
1C4B7A062F21C37C00ED69CF /* PlantGuideUITests */,
);
name = PlantGuideUITests;
packageProductDependencies = (
);
productName = PlantGuideUITests;
productReference = 1C4B7A032F21C37C00ED69CF /* PlantGuideUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
@@ -249,6 +331,12 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
010B95AE71B789A1929646CE /* TodayViewModel.swift in Sources */,
E1AE868BC224D8A4AFE1CD52 /* TodayView.swift in Sources */,
EB273C140861D3673719DC63 /* QuickStatsBar.swift in Sources */,
E201A13692202CDE6B2DE12B /* TaskSection.swift in Sources */,
5DB7D935A323777A1B908A37 /* RoomTaskGroup.swift in Sources */,
FA125D54D0111F44B104862B /* InMemoryRoomRepository.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -22,6 +22,7 @@ protocol DIContainerProtocol: AnyObject, Sendable {
func makeCollectionViewModel() -> CollectionViewModel
func makeSettingsViewModel() -> SettingsViewModel
func makeBrowsePlantsViewModel() -> BrowsePlantsViewModel
func makeTodayViewModel() -> TodayViewModel
// MARK: - Registration
func register<T>(type: T.Type, factory: @escaping @MainActor () -> T)
@@ -549,6 +550,15 @@ final class DIContainer: DIContainerProtocol, ObservableObject {
RoomsViewModel(manageRoomsUseCase: manageRoomsUseCase)
}
/// Factory method for TodayViewModel
func makeTodayViewModel() -> TodayViewModel {
TodayViewModel(
careScheduleRepository: careScheduleRepository,
plantRepository: plantRepository,
roomRepository: roomRepository
)
}
// MARK: - Custom Registration
/// Register a custom factory for a type
@@ -699,6 +709,14 @@ final class MockDIContainer: DIContainerProtocol {
BrowsePlantsViewModel(databaseService: PlantDatabaseService())
}
func makeTodayViewModel() -> TodayViewModel {
TodayViewModel(
careScheduleRepository: InMemoryCareScheduleRepository.shared,
plantRepository: InMemoryPlantRepository.shared,
roomRepository: InMemoryRoomRepository.shared
)
}
func register<T>(type: T.Type, factory: @escaping @MainActor () -> T) {
factories[String(describing: type)] = factory
}

View File

@@ -0,0 +1,118 @@
//
// InMemoryRoomRepository.swift
// PlantGuide
//
// Created for PlantGuide plant identification app.
//
import Foundation
/// In-memory implementation of RoomRepositoryProtocol.
/// Stores rooms in memory for development and testing purposes.
actor InMemoryRoomRepository: RoomRepositoryProtocol {
// MARK: - Singleton
static let shared = InMemoryRoomRepository()
// MARK: - Storage
private var rooms: [UUID: Room] = [:]
// MARK: - Initialization
private init() {}
// MARK: - RoomRepositoryProtocol - Fetch Operations
func fetchAll() async throws -> [Room] {
Array(rooms.values).sorted { $0.sortOrder < $1.sortOrder }
}
func fetch(id: UUID) async throws -> Room? {
rooms[id]
}
func fetchOtherRoom() async throws -> Room {
try await createDefaultRoomsIfNeeded()
// Find the "Other" room
if let otherRoom = rooms.values.first(where: { $0.name == "Other" && $0.isDefault }) {
return otherRoom
}
// Create "Other" room as fallback
let otherRoom = Room(name: "Other", icon: "square.grid.2x2", sortOrder: 999, isDefault: true)
rooms[otherRoom.id] = otherRoom
return otherRoom
}
// MARK: - RoomRepositoryProtocol - Save Operations
func save(_ room: Room) async throws {
rooms[room.id] = room
}
func update(_ room: Room) async throws {
guard rooms[room.id] != nil else {
throw RoomStorageError.roomNotFound(room.id)
}
rooms[room.id] = room
}
// MARK: - RoomRepositoryProtocol - Delete Operations
func delete(id: UUID) async throws {
guard let room = rooms[id] else {
throw RoomStorageError.roomNotFound(id)
}
if room.name == "Other" && room.isDefault {
throw RoomStorageError.cannotDeleteOtherRoom
}
if room.isDefault {
throw RoomStorageError.cannotDeleteDefaultRoom(room.name)
}
rooms.removeValue(forKey: id)
}
// MARK: - RoomRepositoryProtocol - Query Operations
func exists(id: UUID) async throws -> Bool {
rooms[id] != nil
}
func plantCount(for roomID: UUID) async throws -> Int {
// In-memory implementation doesn't track plants, return 0
0
}
// MARK: - RoomRepositoryProtocol - Initialization
func createDefaultRoomsIfNeeded() async throws {
guard rooms.isEmpty else { return }
// Create default rooms
let defaultRooms = [
Room(name: "Kitchen", icon: "refrigerator", sortOrder: 0, isDefault: true),
Room(name: "Living Room", icon: "sofa", sortOrder: 1, isDefault: true),
Room(name: "Bedroom", icon: "bed.double", sortOrder: 2, isDefault: true),
Room(name: "Bathroom", icon: "shower", sortOrder: 3, isDefault: true),
Room(name: "Office", icon: "desktopcomputer", sortOrder: 4, isDefault: true),
Room(name: "Patio/Balcony", icon: "sun.max", sortOrder: 5, isDefault: true),
Room(name: "Other", icon: "square.grid.2x2", sortOrder: 6, isDefault: true),
]
for room in defaultRooms {
rooms[room.id] = room
}
}
// MARK: - Testing Support
func reset() {
rooms.removeAll()
}
}

View File

@@ -4,7 +4,7 @@ enum Tab: String, CaseIterable {
case camera
case browse
case collection
case care
case today
case settings
}
@@ -32,11 +32,11 @@ struct MainTabView: View {
}
.tag(Tab.collection)
CareScheduleView()
TodayView()
.tabItem {
Label("Care", systemImage: "calendar")
Label("Today", systemImage: "sun.horizon")
}
.tag(Tab.care)
.tag(Tab.today)
SettingsView()
.tabItem {

View File

@@ -0,0 +1,89 @@
import SwiftUI
// MARK: - QuickStatsBar
/// A horizontal bar showing today's task progress
struct QuickStatsBar: View {
// MARK: - Properties
/// Number of tasks completed today
let completedCount: Int
/// Total number of tasks for today
let totalCount: Int
// MARK: - Computed Properties
/// Progress value between 0 and 1
private var progress: Double {
guard totalCount > 0 else { return 0 }
return Double(completedCount) / Double(totalCount)
}
/// Whether all tasks are completed
private var isAllComplete: Bool {
totalCount > 0 && completedCount >= totalCount
}
// MARK: - Body
var body: some View {
VStack(alignment: .leading, spacing: DesignSystem.Spacing.sm) {
HStack {
Text("\(completedCount) of \(totalCount) tasks completed")
.font(DesignSystem.Typography.subheadline)
.foregroundStyle(DesignSystem.Colors.textSecondary)
Spacer()
if isAllComplete {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(DesignSystem.Colors.success)
.accessibilityHidden(true)
}
}
ProgressView(value: progress)
.tint(isAllComplete ? DesignSystem.Colors.success : DesignSystem.Colors.accent)
.accessibilityHidden(true)
}
.padding(DesignSystem.Spacing.md)
.background(DesignSystem.Colors.surface)
.clipShape(RoundedRectangle(cornerRadius: DesignSystem.CornerRadius.md))
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabel)
.accessibilityValue("\(Int(progress * 100)) percent complete")
}
// MARK: - Accessibility
private var accessibilityLabel: String {
if totalCount == 0 {
return "No tasks scheduled for today"
} else if isAllComplete {
return "All \(totalCount) tasks completed"
} else {
return "\(completedCount) of \(totalCount) tasks completed"
}
}
}
// MARK: - Preview
#Preview("In Progress") {
QuickStatsBar(completedCount: 3, totalCount: 7)
.padding()
.background(DesignSystem.Colors.background)
}
#Preview("All Complete") {
QuickStatsBar(completedCount: 5, totalCount: 5)
.padding()
.background(DesignSystem.Colors.background)
}
#Preview("No Tasks") {
QuickStatsBar(completedCount: 0, totalCount: 0)
.padding()
.background(DesignSystem.Colors.background)
}

View File

@@ -0,0 +1,174 @@
import SwiftUI
// MARK: - RoomTaskGroup
/// Tasks grouped under a room header with optional bulk actions
struct RoomTaskGroup: View {
// MARK: - Properties
/// The room containing these tasks
let room: Room
/// Tasks for plants in this room
let tasks: [CareTask]
/// Closure to get the plant name for a given task
let plantName: (CareTask) -> String
/// Closure called when a task is marked as complete
let onComplete: (CareTask) -> Void
// MARK: - Computed Properties
/// Whether all tasks in this group are watering tasks
private var allTasksAreWatering: Bool {
!tasks.isEmpty && tasks.allSatisfy { $0.type == .watering }
}
/// Count of incomplete tasks
private var incompleteTasksCount: Int {
tasks.filter { !$0.isCompleted }.count
}
// MARK: - Body
var body: some View {
VStack(alignment: .leading, spacing: 0) {
roomHeader
ForEach(tasks) { task in
CareTaskRow(
task: task,
plantName: plantName(task),
onComplete: { onComplete(task) }
)
}
}
}
// MARK: - Room Header
@ViewBuilder
private var roomHeader: some View {
HStack(spacing: DesignSystem.Spacing.xs) {
Image(systemName: room.icon)
.font(.subheadline)
.foregroundStyle(DesignSystem.Colors.accent)
.accessibilityHidden(true)
Text(room.name)
.font(DesignSystem.Typography.headline)
.foregroundStyle(DesignSystem.Colors.textPrimary)
Text("(\(tasks.count))")
.font(DesignSystem.Typography.subheadline)
.foregroundStyle(DesignSystem.Colors.textSecondary)
Spacer()
if allTasksAreWatering && incompleteTasksCount > 1 {
waterAllButton
}
}
.padding(.vertical, DesignSystem.Spacing.xs)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(room.name), \(tasks.count) tasks")
}
// MARK: - Water All Button
@ViewBuilder
private var waterAllButton: some View {
Button {
waterAllTasks()
} label: {
HStack(spacing: DesignSystem.Spacing.xxs) {
Image(systemName: "drop.fill")
.font(.caption)
Text("Water all")
.font(DesignSystem.Typography.caption1)
.fontWeight(.medium)
}
.foregroundStyle(DesignSystem.Colors.taskWatering)
.padding(.horizontal, DesignSystem.Spacing.sm)
.padding(.vertical, DesignSystem.Spacing.xxs)
.background(DesignSystem.Colors.taskWatering.opacity(0.15))
.clipShape(Capsule())
}
.buttonStyle(.plain)
.accessibilityLabel("Water all plants in \(room.name)")
.accessibilityHint("Marks all \(incompleteTasksCount) watering tasks as complete")
}
// MARK: - Actions
private func waterAllTasks() {
for task in tasks where !task.isCompleted {
onComplete(task)
}
}
}
// MARK: - Preview
#Preview("Kitchen - Watering Tasks") {
let kitchen = Room(name: "Kitchen", icon: "refrigerator", sortOrder: 0, isDefault: true)
let plantID1 = UUID()
let plantID2 = UUID()
let tasks = [
CareTask(plantID: plantID1, type: .watering, scheduledDate: Date()),
CareTask(plantID: plantID2, type: .watering, scheduledDate: Date())
]
return List {
RoomTaskGroup(
room: kitchen,
tasks: tasks,
plantName: { task in
task.plantID == plantID1 ? "Pothos" : "Herbs"
},
onComplete: { _ in }
)
}
}
#Preview("Living Room - Mixed Tasks") {
let livingRoom = Room(name: "Living Room", icon: "sofa", sortOrder: 1, isDefault: true)
let plantID1 = UUID()
let plantID2 = UUID()
let tasks = [
CareTask(plantID: plantID1, type: .watering, scheduledDate: Date()),
CareTask(plantID: plantID2, type: .fertilizing, scheduledDate: Date())
]
return List {
RoomTaskGroup(
room: livingRoom,
tasks: tasks,
plantName: { task in
task.plantID == plantID1 ? "Monstera" : "Fiddle Leaf Fig"
},
onComplete: { _ in }
)
}
}
#Preview("Office - Single Task") {
let office = Room(name: "Office", icon: "desktopcomputer", sortOrder: 4, isDefault: true)
let plantID = UUID()
let tasks = [
CareTask(plantID: plantID, type: .watering, scheduledDate: Date())
]
return List {
RoomTaskGroup(
room: office,
tasks: tasks,
plantName: { _ in "Snake Plant" },
onComplete: { _ in }
)
}
}

View File

@@ -0,0 +1,135 @@
import SwiftUI
// MARK: - TaskSection
/// A collapsible section for displaying overdue or today's tasks
struct TaskSection: View {
// MARK: - Properties
/// Section title (e.g., "OVERDUE", "TODAY")
let title: String
/// Tasks to display in this section
let tasks: [CareTask]
/// Whether to show red warning styling for overdue tasks
let isOverdue: Bool
/// Closure to get the plant name for a given task
let plantName: (CareTask) -> String
/// Closure called when a task is marked as complete
let onComplete: (CareTask) -> Void
// MARK: - State
@State private var isExpanded: Bool = true
// MARK: - Body
var body: some View {
Section {
if isExpanded {
ForEach(tasks) { task in
CareTaskRow(
task: task,
plantName: plantName(task),
onComplete: { onComplete(task) }
)
}
}
} header: {
sectionHeader
}
}
// MARK: - Section Header
@ViewBuilder
private var sectionHeader: some View {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
isExpanded.toggle()
}
} label: {
HStack(spacing: DesignSystem.Spacing.xs) {
if isOverdue {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(DesignSystem.Colors.destructive)
.accessibilityHidden(true)
}
Text("\(title) (\(tasks.count))")
.font(DesignSystem.Typography.footnote)
.fontWeight(.semibold)
.foregroundStyle(isOverdue ? DesignSystem.Colors.destructive : DesignSystem.Colors.textSecondary)
Spacer()
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
.font(.caption)
.foregroundStyle(DesignSystem.Colors.textTertiary)
.accessibilityHidden(true)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("\(title), \(tasks.count) tasks")
.accessibilityHint(isExpanded ? "Double tap to collapse" : "Double tap to expand")
.accessibilityAddTraits(.isButton)
}
}
// MARK: - Preview
#Preview("Overdue Section") {
let samplePlantID = UUID()
let overdueTasks = [
CareTask(
plantID: samplePlantID,
type: .watering,
scheduledDate: Date().addingTimeInterval(-172800) // 2 days ago
),
CareTask(
plantID: samplePlantID,
type: .fertilizing,
scheduledDate: Date().addingTimeInterval(-86400) // 1 day ago
)
]
return List {
TaskSection(
title: "OVERDUE",
tasks: overdueTasks,
isOverdue: true,
plantName: { _ in "Monstera Deliciosa" },
onComplete: { _ in }
)
}
}
#Preview("Today Section") {
let samplePlantID = UUID()
let todayTasks = [
CareTask(
plantID: samplePlantID,
type: .watering,
scheduledDate: Date()
),
CareTask(
plantID: samplePlantID,
type: .pruning,
scheduledDate: Date().addingTimeInterval(3600)
)
]
return List {
TaskSection(
title: "TODAY",
tasks: todayTasks,
isOverdue: false,
plantName: { _ in "Pothos" },
onComplete: { _ in }
)
}
}

View File

@@ -0,0 +1,137 @@
//
// TodayView.swift
// PlantGuide
//
// Created for PlantGuide plant identification app.
//
import SwiftUI
// MARK: - TodayView
/// Main Today View dashboard showing overdue tasks and today's tasks grouped by room.
/// This view replaces the Care tab as the primary task management interface.
@MainActor
struct TodayView: View {
// MARK: - Properties
@State private var viewModel: TodayViewModel
// MARK: - Initialization
init() {
_viewModel = State(initialValue: DIContainer.shared.makeTodayViewModel())
}
// MARK: - Computed Properties
/// Rooms sorted by their sort order for consistent display
private var sortedRooms: [Room] {
viewModel.todayTasksByRoom.keys.sorted { $0.sortOrder < $1.sortOrder }
}
// MARK: - Body
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView("Loading tasks...")
} else {
taskContent
}
}
.navigationTitle(viewModel.greeting)
.overlay {
emptyStateOverlay
}
.task {
await viewModel.loadTasks()
}
.refreshable {
await viewModel.loadTasks()
}
}
}
// MARK: - Task Content
@ViewBuilder
private var taskContent: some View {
ScrollView {
VStack(spacing: 20) {
// Greeting + Stats
QuickStatsBar(
completedCount: viewModel.completedTodayCount,
totalCount: viewModel.totalTodayCount
)
// Overdue section (if any)
if !viewModel.overdueTasks.isEmpty {
TaskSection(
title: "OVERDUE",
tasks: viewModel.overdueTasks,
isOverdue: true,
plantName: viewModel.plantName,
onComplete: { task in
Task { await viewModel.markComplete(task) }
}
)
}
// Today's tasks grouped by room
if !viewModel.todayTasks.isEmpty {
todayTasksSection
}
}
.padding()
}
}
// MARK: - Today Tasks Section
@ViewBuilder
private var todayTasksSection: some View {
VStack(alignment: .leading, spacing: 16) {
// Section header
Text("TODAY")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.padding(.horizontal, 4)
// Tasks grouped by room
ForEach(sortedRooms, id: \.id) { room in
if let tasks = viewModel.todayTasksByRoom[room] {
RoomTaskGroup(
room: room,
tasks: tasks,
plantName: viewModel.plantName,
onComplete: { task in
Task { await viewModel.markComplete(task) }
}
)
}
}
}
}
// MARK: - Empty State
@ViewBuilder
private var emptyStateOverlay: some View {
if viewModel.allTasksEmpty && !viewModel.isLoading {
ContentUnavailableView(
"All caught up!",
systemImage: "checkmark.circle",
description: Text("You have no tasks for today")
)
}
}
}
// MARK: - Preview
#Preview {
TodayView()
}

View File

@@ -0,0 +1,229 @@
//
// TodayViewModel.swift
// PlantGuide
//
// ViewModel for the Today View feature that shows overdue and today's tasks
// grouped by room, with completion tracking for the QuickStatsBar.
//
import SwiftUI
// MARK: - TodayViewModel
/// ViewModel for the Today View screen that displays care tasks for today grouped by room
@MainActor
@Observable
final class TodayViewModel {
// MARK: - Dependencies
private let careScheduleRepository: CareScheduleRepositoryProtocol
private let plantRepository: PlantRepositoryProtocol
private let roomRepository: RoomRepositoryProtocol
// MARK: - Properties
/// All care tasks loaded from the data source
private(set) var allTasks: [CareTask] = []
/// Mapping of plant IDs to their corresponding Plant objects
private(set) var plants: [UUID: Plant] = [:]
/// Mapping of room IDs to their corresponding Room objects
private(set) var rooms: [UUID: Room] = [:]
/// Indicates whether tasks are currently being loaded
private(set) var isLoading = false
/// A sentinel Room used for plants without an assigned room
static let unassignedRoom = Room(
id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!,
name: "Unassigned",
icon: "questionmark.circle",
sortOrder: Int.max,
isDefault: false
)
// MARK: - Initialization
init(
careScheduleRepository: CareScheduleRepositoryProtocol,
plantRepository: PlantRepositoryProtocol,
roomRepository: RoomRepositoryProtocol
) {
self.careScheduleRepository = careScheduleRepository
self.plantRepository = plantRepository
self.roomRepository = roomRepository
}
// MARK: - Computed Properties
/// Tasks that are overdue (past scheduled date and not completed)
var overdueTasks: [CareTask] {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
return allTasks.filter { task in
guard !task.isCompleted else { return false }
let taskDay = calendar.startOfDay(for: task.scheduledDate)
return taskDay < today
}
.sorted { $0.scheduledDate < $1.scheduledDate }
}
/// Tasks scheduled for today (not including overdue)
var todayTasks: [CareTask] {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
return allTasks.filter { task in
guard !task.isCompleted else { return false }
let taskDay = calendar.startOfDay(for: task.scheduledDate)
return calendar.isDate(taskDay, inSameDayAs: today)
}
.sorted { $0.scheduledDate < $1.scheduledDate }
}
/// Today's tasks (including overdue) grouped by room
var todayTasksByRoom: [Room: [CareTask]] {
let tasksForToday = overdueTasks + todayTasks
var grouped: [Room: [CareTask]] = [:]
for task in tasksForToday {
let taskRoom = room(for: task) ?? Self.unassignedRoom
grouped[taskRoom, default: []].append(task)
}
// Sort tasks within each room group by scheduled date
for (room, tasks) in grouped {
grouped[room] = tasks.sorted { $0.scheduledDate < $1.scheduledDate }
}
return grouped
}
/// Sorted array of rooms for section headers (by sortOrder, unassigned last)
var sortedRooms: [Room] {
todayTasksByRoom.keys.sorted { lhs, rhs in
// Unassigned room always goes last
if lhs.id == Self.unassignedRoom.id { return false }
if rhs.id == Self.unassignedRoom.id { return true }
return lhs.sortOrder < rhs.sortOrder
}
}
/// Number of tasks completed today
var completedTodayCount: Int {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
return allTasks.filter { task in
guard let completedDate = task.completedDate else { return false }
return calendar.isDate(completedDate, inSameDayAs: today)
}.count
}
/// Total tasks for today (today's tasks + overdue tasks)
var totalTodayCount: Int {
overdueTasks.count + todayTasks.count + completedTodayCount
}
/// Time-based greeting message
var greeting: String {
let hour = Calendar.current.component(.hour, from: Date())
switch hour {
case 5..<12:
return "Good morning!"
case 12..<17:
return "Good afternoon!"
case 17..<22:
return "Good evening!"
default:
return "Good night!"
}
}
/// Progress text showing completion status
var progressText: String {
let completed = completedTodayCount
let total = totalTodayCount
if total == 0 {
return "No tasks for today"
}
return "\(completed) of \(total) tasks completed"
}
/// Whether there are no tasks at all (for empty state)
var allTasksEmpty: Bool {
overdueTasks.isEmpty && todayTasks.isEmpty && completedTodayCount == 0
}
// MARK: - Methods
/// Loads all care tasks, plants, and rooms from the data source
func loadTasks() async {
isLoading = true
defer { isLoading = false }
do {
// Load all tasks from the repository
allTasks = try await careScheduleRepository.fetchAllTasks()
// Load all plants for name and room lookup
let plantList = try await plantRepository.fetchAll()
plants = Dictionary(uniqueKeysWithValues: plantList.map { ($0.id, $0) })
// Load all rooms for grouping
let roomList = try await roomRepository.fetchAll()
rooms = Dictionary(uniqueKeysWithValues: roomList.map { ($0.id, $0) })
} catch {
// Log error but don't crash - just show empty state
print("Failed to load tasks: \(error)")
allTasks = []
plants = [:]
rooms = [:]
}
}
/// Marks a care task as complete
/// - Parameter task: The task to mark as complete
func markComplete(_ task: CareTask) async {
guard let index = allTasks.firstIndex(where: { $0.id == task.id }) else { return }
// Update the task with completion date
let completedTask = task.completed()
allTasks[index] = completedTask
// Persist the change to the repository
do {
try await careScheduleRepository.updateTask(completedTask)
} catch {
// Log error - in a production app, you might want to show an alert
print("Failed to persist task completion: \(error)")
}
}
/// Returns the plant name for a given task
/// - Parameter task: The care task
/// - Returns: The plant name or a default string if not found
func plantName(for task: CareTask) -> String {
if let plant = plants[task.plantID] {
return plant.displayName
}
return "Unknown Plant"
}
/// Returns the room for a given task's plant
/// - Parameter task: The care task
/// - Returns: The room if found, or nil if the plant has no assigned room
func room(for task: CareTask) -> Room? {
guard let plant = plants[task.plantID],
let roomID = plant.roomID else {
return nil
}
return rooms[roomID]
}
}