From efd935568aab6f58dac3b5ed36b7631009087297 Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 23 Jan 2026 15:05:55 -0600 Subject: [PATCH] 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 --- PlantGuide.xcodeproj/project.pbxproj | 100 +++++++- PlantGuide/Core/DI/DIContainer.swift | 18 ++ .../Repositories/InMemoryRoomRepository.swift | 118 +++++++++ .../Presentation/Navigation/MainTabView.swift | 8 +- .../TodayView/Components/QuickStatsBar.swift | 89 +++++++ .../TodayView/Components/RoomTaskGroup.swift | 174 +++++++++++++ .../TodayView/Components/TaskSection.swift | 135 +++++++++++ .../Scenes/TodayView/TodayView.swift | 137 +++++++++++ .../Scenes/TodayView/TodayViewModel.swift | 229 ++++++++++++++++++ 9 files changed, 998 insertions(+), 10 deletions(-) create mode 100644 PlantGuide/Data/Repositories/InMemoryRoomRepository.swift create mode 100644 PlantGuide/Presentation/Scenes/TodayView/Components/QuickStatsBar.swift create mode 100644 PlantGuide/Presentation/Scenes/TodayView/Components/RoomTaskGroup.swift create mode 100644 PlantGuide/Presentation/Scenes/TodayView/Components/TaskSection.swift create mode 100644 PlantGuide/Presentation/Scenes/TodayView/TodayView.swift create mode 100644 PlantGuide/Presentation/Scenes/TodayView/TodayViewModel.swift diff --git a/PlantGuide.xcodeproj/project.pbxproj b/PlantGuide.xcodeproj/project.pbxproj index 648e2bb..77cd3e3 100644 --- a/PlantGuide.xcodeproj/project.pbxproj +++ b/PlantGuide.xcodeproj/project.pbxproj @@ -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 = ""; }; + 678533C17B8C3244E2001F4F /* RoomTaskGroup.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoomTaskGroup.swift; sourceTree = ""; }; + 7A9D5ED974C43A2EC68CD03B /* TodayView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = ""; }; + 8A34C0DA41757D0CD1B7AB99 /* TodayViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodayViewModel.swift; sourceTree = ""; }; + C50ADB17181CA3A5FB44BBE2 /* QuickStatsBar.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = QuickStatsBar.swift; sourceTree = ""; }; + DADA0723BB8443C632252796 /* TaskSection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TaskSection.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -50,11 +65,15 @@ }; 1C4B79FC2F21C37C00ED69CF /* PlantGuideTests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = PlantGuideTests; sourceTree = ""; }; 1C4B7A062F21C37C00ED69CF /* PlantGuideUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = PlantGuideUITests; sourceTree = ""; }; @@ -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 = ""; + }; 1C4B79DF2F21C37A00ED69CF = { isa = PBXGroup; children = ( @@ -92,6 +122,7 @@ 1C4B79FC2F21C37C00ED69CF /* PlantGuideTests */, 1C4B7A062F21C37C00ED69CF /* PlantGuideUITests */, 1C4B79E92F21C37A00ED69CF /* Products */, + 7B600A469FEF4379984ED673 /* PlantGuide */, ); sourceTree = ""; }; @@ -105,6 +136,63 @@ name = Products; sourceTree = ""; }; + 43A10090BDB504EEA8160579 /* Data */ = { + isa = PBXGroup; + children = ( + 775B6E967C5DFFD7F1871824 /* Repositories */, + ); + name = Data; + path = Data; + sourceTree = ""; + }; + 775B6E967C5DFFD7F1871824 /* Repositories */ = { + isa = PBXGroup; + children = ( + 52FD1E71E8C36B0075A932F2 /* InMemoryRoomRepository.swift */, + ); + name = Repositories; + path = Repositories; + sourceTree = ""; + }; + 7B600A469FEF4379984ED673 /* PlantGuide */ = { + isa = PBXGroup; + children = ( + DEFE3CA84863FD85C7F7BB48 /* Presentation */, + 43A10090BDB504EEA8160579 /* Data */, + ); + name = PlantGuide; + path = PlantGuide; + sourceTree = ""; + }; + 96D83367DDD373621B7CA753 /* Scenes */ = { + isa = PBXGroup; + children = ( + EB55B50C41964C736A4FF8A3 /* TodayView */, + ); + name = Scenes; + path = Scenes; + sourceTree = ""; + }; + DEFE3CA84863FD85C7F7BB48 /* Presentation */ = { + isa = PBXGroup; + children = ( + 96D83367DDD373621B7CA753 /* Scenes */, + ); + name = Presentation; + path = Presentation; + sourceTree = ""; + }; + EB55B50C41964C736A4FF8A3 /* TodayView */ = { + isa = PBXGroup; + children = ( + 8A34C0DA41757D0CD1B7AB99 /* TodayViewModel.swift */, + 7A9D5ED974C43A2EC68CD03B /* TodayView.swift */, + 1A0266DEC4BEC766E4813767 /* Components */, + ); + name = TodayView; + path = TodayView; + sourceTree = ""; + }; /* 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; }; diff --git a/PlantGuide/Core/DI/DIContainer.swift b/PlantGuide/Core/DI/DIContainer.swift index 05073aa..c8b9c8d 100644 --- a/PlantGuide/Core/DI/DIContainer.swift +++ b/PlantGuide/Core/DI/DIContainer.swift @@ -22,6 +22,7 @@ protocol DIContainerProtocol: AnyObject, Sendable { func makeCollectionViewModel() -> CollectionViewModel func makeSettingsViewModel() -> SettingsViewModel func makeBrowsePlantsViewModel() -> BrowsePlantsViewModel + func makeTodayViewModel() -> TodayViewModel // MARK: - Registration func register(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(type: T.Type, factory: @escaping @MainActor () -> T) { factories[String(describing: type)] = factory } diff --git a/PlantGuide/Data/Repositories/InMemoryRoomRepository.swift b/PlantGuide/Data/Repositories/InMemoryRoomRepository.swift new file mode 100644 index 0000000..80267a7 --- /dev/null +++ b/PlantGuide/Data/Repositories/InMemoryRoomRepository.swift @@ -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() + } +} diff --git a/PlantGuide/Presentation/Navigation/MainTabView.swift b/PlantGuide/Presentation/Navigation/MainTabView.swift index 4202d3d..d6ca12c 100644 --- a/PlantGuide/Presentation/Navigation/MainTabView.swift +++ b/PlantGuide/Presentation/Navigation/MainTabView.swift @@ -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 { diff --git a/PlantGuide/Presentation/Scenes/TodayView/Components/QuickStatsBar.swift b/PlantGuide/Presentation/Scenes/TodayView/Components/QuickStatsBar.swift new file mode 100644 index 0000000..71bb780 --- /dev/null +++ b/PlantGuide/Presentation/Scenes/TodayView/Components/QuickStatsBar.swift @@ -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) +} diff --git a/PlantGuide/Presentation/Scenes/TodayView/Components/RoomTaskGroup.swift b/PlantGuide/Presentation/Scenes/TodayView/Components/RoomTaskGroup.swift new file mode 100644 index 0000000..0555730 --- /dev/null +++ b/PlantGuide/Presentation/Scenes/TodayView/Components/RoomTaskGroup.swift @@ -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 } + ) + } +} diff --git a/PlantGuide/Presentation/Scenes/TodayView/Components/TaskSection.swift b/PlantGuide/Presentation/Scenes/TodayView/Components/TaskSection.swift new file mode 100644 index 0000000..992caa9 --- /dev/null +++ b/PlantGuide/Presentation/Scenes/TodayView/Components/TaskSection.swift @@ -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 } + ) + } +} diff --git a/PlantGuide/Presentation/Scenes/TodayView/TodayView.swift b/PlantGuide/Presentation/Scenes/TodayView/TodayView.swift new file mode 100644 index 0000000..84cb4ec --- /dev/null +++ b/PlantGuide/Presentation/Scenes/TodayView/TodayView.swift @@ -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() +} diff --git a/PlantGuide/Presentation/Scenes/TodayView/TodayViewModel.swift b/PlantGuide/Presentation/Scenes/TodayView/TodayViewModel.swift new file mode 100644 index 0000000..f49fb73 --- /dev/null +++ b/PlantGuide/Presentation/Scenes/TodayView/TodayViewModel.swift @@ -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] + } +}