299 lines
9.2 KiB
Swift
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
|
|
}
|
|
}
|