Files
PlantGuide/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailViewModel.swift

383 lines
13 KiB
Swift

//
// PlantDetailViewModel.swift
// PlantGuide
//
// Created by Trey Tartt on 1/21/26.
//
import SwiftUI
// MARK: - PlantDetailViewModel
/// View model for the plant detail screen
///
/// Manages the state and business logic for displaying plant details,
/// care information, and upcoming care tasks.
@MainActor
@Observable
final class PlantDetailViewModel {
// MARK: - Dependencies
private let fetchPlantCareUseCase: FetchPlantCareUseCaseProtocol
private let createCareScheduleUseCase: CreateCareScheduleUseCaseProtocol
private let careScheduleRepository: CareScheduleRepositoryProtocol
private let notificationService: NotificationServiceProtocol
// MARK: - Properties
/// The plant being displayed
private(set) var plant: Plant
/// Care information for the plant, loaded asynchronously
private(set) var careInfo: PlantCareInfo?
/// Care schedule with tasks for the plant
private(set) var careSchedule: PlantCareSchedule?
/// Indicates whether care information is being loaded
private(set) var isLoading: Bool = false
/// Indicates whether a care schedule is being created
private(set) var isCreatingSchedule: Bool = false
/// Whether a care schedule already exists for this plant
private(set) var hasExistingSchedule: Bool = false
/// The number of tasks in the existing schedule
private(set) var scheduleTaskCount: Int = 0
/// Notification preferences for this plant's care tasks
var notificationPreferences: CareNotificationPreferences
/// Any error that occurred during loading
private(set) var error: Error?
/// Success message to display after schedule creation
private(set) var successMessage: String?
/// Number of progress photos for this plant
private(set) var progressPhotoCount: Int = 0
/// Thumbnail data for the most recent progress photo
private(set) var recentProgressPhotoThumbnail: Data?
// MARK: - Computed Properties
/// The next upcoming care tasks (up to 5)
var upcomingTasks: [CareTask] {
careSchedule?.pendingTasks.prefix(5).map { $0 } ?? []
}
/// All pending care tasks for the plant
var allPendingTasks: [CareTask] {
careSchedule?.pendingTasks ?? []
}
/// The display name for the plant (common name or scientific name)
var displayName: String {
plant.commonNames.first ?? plant.scientificName
}
/// Whether there are any overdue tasks
var hasOverdueTasks: Bool {
careSchedule?.overdueTasks.isEmpty == false
}
/// The number of overdue tasks
var overdueTaskCount: Int {
careSchedule?.overdueTasks.count ?? 0
}
// MARK: - Initialization
/// Creates a new PlantDetailViewModel with the specified plant
/// - Parameters:
/// - plant: The plant to display details for
/// - fetchPlantCareUseCase: Use case for fetching plant care information
/// - createCareScheduleUseCase: Use case for creating care schedules
/// - careScheduleRepository: Repository for persisting care schedules
/// - notificationService: Service for scheduling notifications
@MainActor
init(
plant: Plant,
fetchPlantCareUseCase: FetchPlantCareUseCaseProtocol? = nil,
createCareScheduleUseCase: CreateCareScheduleUseCaseProtocol? = nil,
careScheduleRepository: CareScheduleRepositoryProtocol? = nil,
notificationService: NotificationServiceProtocol? = nil
) {
self.plant = plant
self.fetchPlantCareUseCase = fetchPlantCareUseCase ?? DIContainer.shared.fetchPlantCareUseCase
self.createCareScheduleUseCase = createCareScheduleUseCase ?? DIContainer.shared.createCareScheduleUseCase
self.careScheduleRepository = careScheduleRepository ?? DIContainer.shared.careScheduleRepository
self.notificationService = notificationService ?? DIContainer.shared.notificationService
self.notificationPreferences = CareNotificationPreferences.load(for: plant.id)
}
// MARK: - Public Methods
/// Loads care information for the plant
///
/// This method fetches care details from the Trefle botanical API
/// and checks for existing care schedules.
///
/// - Parameter forceRefresh: If true, bypasses the cache and fetches fresh data from the API.
func loadCareInfo(forceRefresh: Bool = false) async {
isLoading = true
error = nil
successMessage = nil
do {
// Fetch care info (from cache or API based on forceRefresh)
let info = try await fetchPlantCareUseCase.execute(
scientificName: plant.scientificName,
forceRefresh: forceRefresh
)
careInfo = info
// Check for existing schedule
await loadExistingSchedule()
} catch {
self.error = error
}
isLoading = false
}
/// Loads an existing care schedule from the repository
private func loadExistingSchedule() async {
do {
if let existingSchedule = try await careScheduleRepository.fetch(for: plant.id) {
careSchedule = existingSchedule
hasExistingSchedule = true
scheduleTaskCount = existingSchedule.tasks.count
} else {
hasExistingSchedule = false
scheduleTaskCount = 0
}
} catch {
// Non-fatal: schedule check failed but we can still show care info
hasExistingSchedule = false
scheduleTaskCount = 0
}
}
/// Creates and persists a care schedule, optionally scheduling notifications
///
/// This method:
/// 1. Creates the care schedule from plant care info
/// 2. Persists it to the repository
/// 3. Schedules notifications for enabled task types
func createAndPersistSchedule() async {
guard let careInfo = careInfo else { return }
guard !isCreatingSchedule else { return }
isCreatingSchedule = true
error = nil
successMessage = nil
do {
// Get notification time from settings
let notificationHour = UserDefaults.standard.object(forKey: "settings_notification_time_hour") as? Int ?? 8
let notificationMinute = UserDefaults.standard.object(forKey: "settings_notification_time_minute") as? Int ?? 0
// Create preferences with user's preferred time
let preferences = CarePreferences(
preferredWateringHour: notificationHour,
preferredWateringMinute: notificationMinute
)
// Create the schedule
let schedule = try await createCareScheduleUseCase.execute(
for: plant,
careInfo: careInfo,
preferences: preferences
)
// Persist to repository
try await careScheduleRepository.save(schedule)
// Update local state
careSchedule = schedule
hasExistingSchedule = true
scheduleTaskCount = schedule.tasks.count
// Schedule notifications for enabled task types
await scheduleNotifications(for: schedule.tasks)
// Save notification preferences
notificationPreferences.save(for: plant.id)
successMessage = "Created \(schedule.tasks.count) care tasks"
} catch {
self.error = error
}
isCreatingSchedule = false
}
/// Creates a care schedule based on user preferences (legacy method for compatibility)
/// - Parameter preferences: Optional care preferences to customize the schedule
func createSchedule(preferences: CarePreferences?) async {
guard let careInfo = careInfo else { return }
do {
let schedule = try await createCareScheduleUseCase.execute(
for: plant,
careInfo: careInfo,
preferences: preferences
)
careSchedule = schedule
} catch {
self.error = error
}
}
/// Updates the notification preference for a specific task type
/// - Parameters:
/// - taskType: The type of care task to update
/// - enabled: Whether notifications should be enabled for this type
func updateNotificationPreference(for taskType: CareTaskType, enabled: Bool) async {
// Update preferences
notificationPreferences = notificationPreferences.setting(taskType, enabled: enabled)
notificationPreferences.save(for: plant.id)
// Reschedule or cancel notifications for this task type
guard let schedule = careSchedule else { return }
let tasksOfType = schedule.tasks.filter { $0.type == taskType && !$0.isCompleted }
if enabled {
// Schedule notifications for this task type
await scheduleNotifications(for: tasksOfType)
} else {
// Cancel notifications for this task type
await cancelNotifications(for: tasksOfType)
}
}
/// Schedules notifications for the given tasks based on notification preferences
private func scheduleNotifications(for tasks: [CareTask]) async {
let plantName = plant.commonNames.first ?? plant.scientificName
for task in tasks where notificationPreferences.isEnabled(for: task.type) {
// Only schedule for future tasks
guard task.scheduledDate > Date() else { continue }
do {
try await notificationService.scheduleReminder(
for: task,
plantName: plantName,
plantID: plant.id
)
} catch {
// Non-fatal: continue scheduling other notifications
print("Failed to schedule notification for task \(task.id): \(error)")
}
}
}
/// Cancels notifications for the given tasks
private func cancelNotifications(for tasks: [CareTask]) async {
for task in tasks {
await notificationService.cancelReminder(for: task.id)
}
}
/// Marks a care task as completed
/// - Parameter task: The task to mark as complete
func markTaskComplete(_ task: CareTask) async {
guard var schedule = careSchedule else { return }
// Find and update the task
if let index = schedule.tasks.firstIndex(where: { $0.id == task.id }) {
let completedTask = task.completed()
schedule.tasks[index] = completedTask
careSchedule = schedule
// Persist the change to the repository
do {
try await careScheduleRepository.updateTask(completedTask)
} catch {
print("Failed to persist task completion: \(error)")
}
}
}
/// Clears any error state
func clearError() {
error = nil
}
/// Clears any success message
func clearSuccessMessage() {
successMessage = nil
}
/// Refreshes all plant data by fetching fresh care info from the API
///
/// This bypasses the cache and fetches updated data from the Trefle API.
func refresh() async {
await loadCareInfo(forceRefresh: true)
}
/// Requests notification permission from the user
/// - Returns: `true` if permission was granted
@discardableResult
func requestNotificationPermission() async -> Bool {
do {
return try await notificationService.requestAuthorization()
} catch {
return false
}
}
// MARK: - Room Management
/// Updates the room assignment for this plant
/// - Parameter roomID: The new room ID, or nil to remove room assignment
func updateRoom(to roomID: UUID?) async {
plant.roomID = roomID
do {
// Update the plant in the repository
try await DIContainer.shared.plantCollectionRepository.updatePlant(plant)
} catch {
self.error = error
}
}
// MARK: - Plant Editing
/// Updates the plant's custom name and notes
/// - Parameters:
/// - customName: The new custom name, or nil to clear it
/// - notes: The new notes, or nil to clear them
func updatePlant(customName: String?, notes: String?) async {
plant.customName = customName
plant.notes = notes
do {
_ = try await DIContainer.shared.updatePlantUseCase.execute(plant: plant)
} catch {
self.error = error
}
}
// MARK: - Plant Deletion
/// Deletes the plant from the collection
///
/// This removes the plant and all associated data including:
/// - Images stored locally
/// - Care schedules and tasks
/// - Scheduled notifications
/// - Cached care information
func deletePlant() async {
do {
try await DIContainer.shared.deletePlantUseCase.execute(plantID: plant.id)
} catch {
self.error = error
}
}
}