383 lines
13 KiB
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
|
|
}
|
|
}
|
|
}
|