Files
PlantGuide/PlantGuide/Presentation/Scenes/TodayView/TodayViewModel.swift
treyt 60189a5406 fix: issue #17 - upcoming tasks
Automated fix by Tony CI v3.
Refs #17

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-09 22:23:56 -05:00

299 lines
9.2 KiB
Swift

//
// 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
/// Set of currently selected task IDs for batch operations
private(set) var selectedTaskIDs: Set<UUID> = []
/// Whether the view is in edit/selection mode
private(set) var isEditMode = false
/// Whether to show confirmation dialog for batch complete
var showBatchConfirmation = 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
}
/// Number of selected tasks
var selectedCount: Int { selectedTaskIDs.count }
/// Whether any tasks are selected
var hasSelection: Bool { !selectedTaskIDs.isEmpty }
// 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 {
// Revert in-memory state so UI stays consistent with Core Data
allTasks[index] = task
}
}
/// 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]
}
// MARK: - Selection Methods
/// Toggles edit/selection mode
func toggleEditMode() {
isEditMode.toggle()
if !isEditMode {
selectedTaskIDs.removeAll()
}
}
/// Toggles selection state for a task
func toggleSelection(for taskID: UUID) {
if selectedTaskIDs.contains(taskID) {
selectedTaskIDs.remove(taskID)
} else {
selectedTaskIDs.insert(taskID)
}
}
/// Selects all tasks in a room
func selectAllInRoom(_ room: Room) {
guard let tasks = todayTasksByRoom[room] else { return }
for task in tasks where !task.isCompleted {
selectedTaskIDs.insert(task.id)
}
}
/// Clears all selections
func clearSelection() {
selectedTaskIDs.removeAll()
}
/// Completes all selected tasks
func batchCompleteSelected() async {
// If more than 3 tasks, show confirmation first
if selectedCount > 3 {
showBatchConfirmation = true
return
}
await performBatchComplete()
}
/// Actually performs the batch complete after confirmation
func performBatchComplete() async {
let tasksToComplete = selectedTaskIDs
for taskID in tasksToComplete {
if let task = allTasks.first(where: { $0.id == taskID }) {
await markComplete(task)
}
}
selectedTaskIDs.removeAll()
isEditMode = false
}
}