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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
118
PlantGuide/Data/Repositories/InMemoryRoomRepository.swift
Normal file
118
PlantGuide/Data/Repositories/InMemoryRoomRepository.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
137
PlantGuide/Presentation/Scenes/TodayView/TodayView.swift
Normal file
137
PlantGuide/Presentation/Scenes/TodayView/TodayView.swift
Normal 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()
|
||||
}
|
||||
229
PlantGuide/Presentation/Scenes/TodayView/TodayViewModel.swift
Normal file
229
PlantGuide/Presentation/Scenes/TodayView/TodayViewModel.swift
Normal 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]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user