diff --git a/PlantGuide.xcodeproj/project.pbxproj b/PlantGuide.xcodeproj/project.pbxproj index 77cd3e3..c9745ee 100644 --- a/PlantGuide.xcodeproj/project.pbxproj +++ b/PlantGuide.xcodeproj/project.pbxproj @@ -65,15 +65,11 @@ }; 1C4B79FC2F21C37C00ED69CF /* PlantGuideTests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = PlantGuideTests; sourceTree = ""; }; 1C4B7A062F21C37C00ED69CF /* PlantGuideUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = PlantGuideUITests; sourceTree = ""; }; @@ -111,7 +107,6 @@ DADA0723BB8443C632252796 /* TaskSection.swift */, 678533C17B8C3244E2001F4F /* RoomTaskGroup.swift */, ); - name = Components; path = Components; sourceTree = ""; }; @@ -141,7 +136,6 @@ children = ( 775B6E967C5DFFD7F1871824 /* Repositories */, ); - name = Data; path = Data; sourceTree = ""; }; @@ -150,7 +144,6 @@ children = ( 52FD1E71E8C36B0075A932F2 /* InMemoryRoomRepository.swift */, ); - name = Repositories; path = Repositories; sourceTree = ""; }; @@ -160,7 +153,6 @@ DEFE3CA84863FD85C7F7BB48 /* Presentation */, 43A10090BDB504EEA8160579 /* Data */, ); - name = PlantGuide; path = PlantGuide; sourceTree = ""; }; @@ -169,7 +161,6 @@ children = ( EB55B50C41964C736A4FF8A3 /* TodayView */, ); - name = Scenes; path = Scenes; sourceTree = ""; }; @@ -178,7 +169,6 @@ children = ( 96D83367DDD373621B7CA753 /* Scenes */, ); - name = Presentation; path = Presentation; sourceTree = ""; }; @@ -189,7 +179,6 @@ 7A9D5ED974C43A2EC68CD03B /* TodayView.swift */, 1A0266DEC4BEC766E4813767 /* Components */, ); - name = TodayView; path = TodayView; sourceTree = ""; }; diff --git a/PlantGuide/Core/DI/DIContainer.swift b/PlantGuide/Core/DI/DIContainer.swift index 9ebbe42..7c1b0c8 100644 --- a/PlantGuide/Core/DI/DIContainer.swift +++ b/PlantGuide/Core/DI/DIContainer.swift @@ -23,6 +23,7 @@ protocol DIContainerProtocol: AnyObject, Sendable { func makeSettingsViewModel() -> SettingsViewModel func makeBrowsePlantsViewModel() -> BrowsePlantsViewModel func makeTodayViewModel() -> TodayViewModel + func makeProgressPhotosViewModel(plantID: UUID, plantName: String) -> ProgressPhotosViewModel // MARK: - Registration func register(type: T.Type, factory: @escaping @MainActor () -> T) @@ -244,6 +245,28 @@ final class DIContainer: DIContainerProtocol, ObservableObject { } }() + // MARK: - Progress Photo Services + + private lazy var _coreDataProgressPhotoStorage: LazyService = { + LazyService { + CoreDataProgressPhotoRepository(coreDataStack: CoreDataStack.shared) + } + }() + + private lazy var _captureProgressPhotoUseCase: LazyService = { + LazyService { [weak self] in + guard let self else { fatalError("DIContainer deallocated unexpectedly") } + return CaptureProgressPhotoUseCase(progressPhotoRepository: self.progressPhotoRepository) + } + }() + + private lazy var _schedulePhotoReminderUseCase: LazyService = { + LazyService { [weak self] in + guard let self else { fatalError("DIContainer deallocated unexpectedly") } + return SchedulePhotoReminderUseCase(notificationService: self.notificationService) + } + }() + // MARK: - Local Plant Database Services private lazy var _plantDatabaseService: LazyService = { @@ -326,6 +349,23 @@ final class DIContainer: DIContainerProtocol, ObservableObject { _manageRoomsUseCase.value } + // MARK: - Progress Photo Accessors + + /// Progress photo repository backed by Core Data + var progressPhotoRepository: ProgressPhotoRepositoryProtocol { + _coreDataProgressPhotoStorage.value + } + + /// Use case for capturing progress photos + var captureProgressPhotoUseCase: CaptureProgressPhotoUseCaseProtocol { + _captureProgressPhotoUseCase.value + } + + /// Use case for scheduling photo reminders + var schedulePhotoReminderUseCase: SchedulePhotoReminderUseCaseProtocol { + _schedulePhotoReminderUseCase.value + } + // MARK: - Initialization private init() {} @@ -564,6 +604,17 @@ final class DIContainer: DIContainerProtocol, ObservableObject { RoomsViewModel(manageRoomsUseCase: manageRoomsUseCase) } + /// Factory method for ProgressPhotosViewModel + func makeProgressPhotosViewModel(plantID: UUID, plantName: String) -> ProgressPhotosViewModel { + ProgressPhotosViewModel( + plantID: plantID, + plantName: plantName, + progressPhotoRepository: progressPhotoRepository, + captureProgressPhotoUseCase: captureProgressPhotoUseCase, + schedulePhotoReminderUseCase: schedulePhotoReminderUseCase + ) + } + /// Factory method for TodayViewModel func makeTodayViewModel() -> TodayViewModel { TodayViewModel( @@ -639,6 +690,10 @@ final class DIContainer: DIContainerProtocol, ObservableObject { _coreDataRoomStorage.reset() _createDefaultRoomsUseCase.reset() _manageRoomsUseCase.reset() + // Progress photo services + _coreDataProgressPhotoStorage.reset() + _captureProgressPhotoUseCase.reset() + _schedulePhotoReminderUseCase.reset() factories.removeAll() resolvedInstances.removeAll() } @@ -732,6 +787,10 @@ final class MockDIContainer: DIContainerProtocol { ) } + func makeProgressPhotosViewModel(plantID: UUID, plantName: String) -> ProgressPhotosViewModel { + ProgressPhotosViewModel(plantID: plantID, plantName: plantName) + } + func register(type: T.Type, factory: @escaping @MainActor () -> T) { factories[String(describing: type)] = factory } diff --git a/PlantGuide/Core/Services/NotificationService.swift b/PlantGuide/Core/Services/NotificationService.swift index 1117bdf..c891cbe 100644 --- a/PlantGuide/Core/Services/NotificationService.swift +++ b/PlantGuide/Core/Services/NotificationService.swift @@ -60,6 +60,17 @@ protocol NotificationServiceProtocol: Sendable { /// - plantID: The unique identifier of the plant func cancelReminders(for taskType: CareTaskType, plantID: UUID) async + /// Schedule a recurring photo reminder for a plant + /// - Parameters: + /// - plantID: The unique identifier of the plant + /// - plantName: The name of the plant to display in the notification + /// - interval: The interval between photo reminders + func schedulePhotoReminder(for plantID: UUID, plantName: String, interval: PhotoReminderInterval) async throws + + /// Cancel a scheduled photo reminder for a plant + /// - Parameter plantID: The unique identifier of the plant + func cancelPhotoReminder(for plantID: UUID) async + /// Update the app badge count /// - Parameter count: The number to display on the app badge func updateBadgeCount(_ count: Int) async @@ -79,15 +90,24 @@ private enum NotificationConstants { /// Category identifier for care reminder notifications static let careReminderCategory = "CARE_REMINDER" + /// Category identifier for progress photo reminder notifications + static let progressPhotoReminderCategory = "PROGRESS_PHOTO_REMINDER" + /// Action identifier for marking a task as complete static let completeAction = "COMPLETE" /// Action identifier for snoozing the reminder static let snoozeAction = "SNOOZE" + /// Action identifier for taking a photo + static let takePhotoAction = "TAKE_PHOTO" + /// Prefix for notification identifiers static let notificationPrefix = "care-" + /// Prefix for photo reminder notification identifiers + static let photoReminderPrefix = "photo-" + /// User info key for task ID static let taskIDKey = "taskID" @@ -97,6 +117,9 @@ private enum NotificationConstants { /// User info key for task type static let taskTypeKey = "taskType" + /// User info key for notification category + static let categoryKey = "category" + /// Snooze duration in seconds (1 hour) static let snoozeDuration: TimeInterval = 3600 } @@ -141,7 +164,21 @@ actor NotificationService: NotificationServiceProtocol { options: [.customDismissAction] ) - UNUserNotificationCenter.current().setNotificationCategories([careReminderCategory]) + // Progress photo reminder category + let takePhotoAction = UNNotificationAction( + identifier: NotificationConstants.takePhotoAction, + title: "Take Photo", + options: [.foreground] + ) + + let progressPhotoCategory = UNNotificationCategory( + identifier: NotificationConstants.progressPhotoReminderCategory, + actions: [takePhotoAction], + intentIdentifiers: [], + options: [] + ) + + UNUserNotificationCenter.current().setNotificationCategories([careReminderCategory, progressPhotoCategory]) } // MARK: - Public Interface @@ -282,6 +319,71 @@ actor NotificationService: NotificationServiceProtocol { notificationCenter.removeAllDeliveredNotifications() } + // MARK: - Photo Reminder Methods + + /// Schedule a recurring photo reminder for a plant + /// - Parameters: + /// - plantID: The unique identifier of the plant + /// - plantName: The name of the plant to display in the notification + /// - interval: The interval between photo reminders + /// - Throws: `NotificationError.schedulingFailed` if the notification cannot be scheduled + func schedulePhotoReminder(for plantID: UUID, plantName: String, interval: PhotoReminderInterval) async throws { + // Cancel any existing photo reminder for this plant + await cancelPhotoReminder(for: plantID) + + // If interval is off, just cancel and return + if interval == .off { + return + } + + // Create notification content + let content = UNMutableNotificationContent() + content.title = "Progress Photo Reminder" + content.body = "Time to capture a progress photo of \(plantName)!" + content.sound = .default + content.categoryIdentifier = NotificationConstants.progressPhotoReminderCategory + + // Store plant information in userInfo + content.userInfo = [ + NotificationConstants.plantIDKey: plantID.uuidString, + NotificationConstants.categoryKey: NotificationConstants.progressPhotoReminderCategory + ] + + // Create time interval trigger based on the reminder interval + let timeInterval: TimeInterval + switch interval { + case .weekly: + timeInterval = 7 * 24 * 60 * 60 // 7 days + case .biweekly: + timeInterval = 14 * 24 * 60 * 60 // 14 days + case .monthly: + timeInterval = 30 * 24 * 60 * 60 // 30 days + case .off: + return // Already handled above, but required for exhaustive switch + } + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeInterval, repeats: true) + + // Create notification request with unique identifier + let identifier = photoReminderIdentifier(for: plantID) + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + + // Schedule the notification + do { + try await notificationCenter.add(request) + } catch { + throw NotificationError.schedulingFailed(error) + } + } + + /// Cancel a scheduled photo reminder for a plant + /// - Parameter plantID: The unique identifier of the plant + func cancelPhotoReminder(for plantID: UUID) async { + let identifier = photoReminderIdentifier(for: plantID) + notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier]) + notificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier]) + } + // MARK: - Private Methods /// Generate a consistent notification identifier for a task @@ -290,6 +392,13 @@ actor NotificationService: NotificationServiceProtocol { private func notificationIdentifier(for taskID: UUID) -> String { "\(NotificationConstants.notificationPrefix)\(taskID.uuidString)" } + + /// Generate a consistent notification identifier for a photo reminder + /// - Parameter plantID: The unique identifier of the plant + /// - Returns: A string identifier in the format "photo-{plantID}" + private func photoReminderIdentifier(for plantID: UUID) -> String { + "\(NotificationConstants.photoReminderPrefix)\(plantID.uuidString)" + } } // MARK: - CareTaskType Display Extension diff --git a/PlantGuide/Data/DataSources/Local/CoreData/CoreDataProgressPhotoRepository.swift b/PlantGuide/Data/DataSources/Local/CoreData/CoreDataProgressPhotoRepository.swift new file mode 100644 index 0000000..8745762 --- /dev/null +++ b/PlantGuide/Data/DataSources/Local/CoreData/CoreDataProgressPhotoRepository.swift @@ -0,0 +1,161 @@ +// +// CoreDataProgressPhotoRepository.swift +// PlantGuide +// +// Core Data implementation of progress photo repository. +// Provides persistent storage for plant progress photos. +// + +import CoreData +import Foundation + +// MARK: - Core Data Progress Photo Repository + +/// Core Data-backed implementation of the progress photo repository. +/// Handles all persistent storage operations for plant progress photos. +final class CoreDataProgressPhotoRepository: ProgressPhotoRepositoryProtocol, @unchecked Sendable { + + // MARK: - Properties + + /// The Core Data stack used for persistence operations + private let coreDataStack: CoreDataStackProtocol + + /// Entity name for progress photo managed objects + private let progressPhotoEntityName = "ProgressPhotoMO" + + /// Entity name for plant managed objects + private let plantEntityName = "PlantMO" + + // MARK: - Initialization + + /// Creates a new Core Data progress photo repository instance + /// - Parameter coreDataStack: The Core Data stack to use for persistence + init(coreDataStack: CoreDataStackProtocol = CoreDataStack.shared) { + self.coreDataStack = coreDataStack + } + + // MARK: - ProgressPhotoRepositoryProtocol + + /// Saves a progress photo to Core Data. + /// + /// If a photo with the same ID already exists, it will be updated. + /// Both imageData and thumbnailData are saved atomically. + /// The plant relationship is established based on the photo's plantID. + /// + /// - Parameter photo: The progress photo entity to save. + /// - Throws: An error if the save operation fails. + func save(_ photo: ProgressPhoto) async throws { + try await coreDataStack.performBackgroundTask { context in + // Check if photo already exists + let fetchRequest = ProgressPhotoMO.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %@", photo.id as CVarArg) + fetchRequest.fetchLimit = 1 + + let existingPhotos = try context.fetch(fetchRequest) + + if let existingPhoto = existingPhotos.first { + // Update existing photo + existingPhoto.update(from: photo) + existingPhoto.setPlant(id: photo.plantID, context: context) + } else { + // Create new photo + let photoMO = ProgressPhotoMO.fromDomainModel(photo, context: context) + photoMO.setPlant(id: photo.plantID, context: context) + } + + return () + } + } + + /// Fetches a progress photo by its unique identifier. + /// + /// - Parameter id: The unique identifier of the photo to fetch. + /// - Returns: The progress photo if found, or nil if no photo exists with the given ID. + /// - Throws: An error if the fetch operation fails. + func fetch(id: UUID) async throws -> ProgressPhoto? { + try await coreDataStack.performBackgroundTask { context in + let fetchRequest = ProgressPhotoMO.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %@", id as CVarArg) + fetchRequest.fetchLimit = 1 + + let results = try context.fetch(fetchRequest) + return results.first?.toDomainModel() + } + } + + /// Fetches all progress photos for a specific plant, sorted by dateTaken descending. + /// + /// Photos are returned with the newest first, which is the typical display order + /// for a photo timeline or gallery. + /// + /// - Parameter plantID: The unique identifier of the plant whose photos to fetch. + /// - Returns: An array of progress photos for the plant, sorted by dateTaken descending. + /// - Throws: An error if the fetch operation fails. + func fetchAll(for plantID: UUID) async throws -> [ProgressPhoto] { + try await coreDataStack.performBackgroundTask { context in + let fetchRequest = ProgressPhotoMO.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "plantID == %@", plantID as CVarArg) + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "dateTaken", ascending: false)] + + let results = try context.fetch(fetchRequest) + return results.map { $0.toDomainModel() } + } + } + + /// Deletes a progress photo by its unique identifier. + /// + /// If the photo does not exist, the operation completes silently without error. + /// This behavior aligns with the protocol specification. + /// + /// - Parameter id: The unique identifier of the photo to delete. + /// - Throws: An error if the delete operation fails. + func delete(id: UUID) async throws { + try await coreDataStack.performBackgroundTask { context in + let fetchRequest = ProgressPhotoMO.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %@", id as CVarArg) + fetchRequest.fetchLimit = 1 + + let results = try context.fetch(fetchRequest) + + if let photoToDelete = results.first { + context.delete(photoToDelete) + } + + return () + } + } + + /// Deletes all progress photos for a specific plant. + /// + /// This is more efficient than deleting photos individually when a plant + /// is being removed from the collection. + /// + /// - Parameter plantID: The unique identifier of the plant whose photos to delete. + /// - Throws: An error if the delete operation fails. + func deleteAll(for plantID: UUID) async throws { + try await coreDataStack.performBackgroundTask { context in + let fetchRequest = ProgressPhotoMO.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "plantID == %@", plantID as CVarArg) + + let results = try context.fetch(fetchRequest) + + for photo in results { + context.delete(photo) + } + + return () + } + } +} + +// MARK: - Testing Support + +#if DEBUG +extension CoreDataProgressPhotoRepository { + /// Creates a repository instance with an in-memory Core Data stack for testing + /// - Returns: A CoreDataProgressPhotoRepository instance backed by an in-memory store + static func inMemoryRepository() -> CoreDataProgressPhotoRepository { + return CoreDataProgressPhotoRepository(coreDataStack: CoreDataStack.inMemoryStack()) + } +} +#endif diff --git a/PlantGuide/Data/DataSources/Local/CoreData/ManagedObjects/PlantMO.swift b/PlantGuide/Data/DataSources/Local/CoreData/ManagedObjects/PlantMO.swift index c89c9ef..cc1822f 100644 --- a/PlantGuide/Data/DataSources/Local/CoreData/ManagedObjects/PlantMO.swift +++ b/PlantGuide/Data/DataSources/Local/CoreData/ManagedObjects/PlantMO.swift @@ -71,6 +71,26 @@ public class PlantMO: NSManagedObject { /// The room where this plant is located (optional, to-one) @NSManaged public var room: RoomMO? + + /// Progress photos documenting this plant's growth over time (one-to-many, cascade delete) + @NSManaged public var progressPhotos: NSSet? +} + +// MARK: - Generated Accessors for Progress Photos + +extension PlantMO { + + @objc(addProgressPhotosObject:) + @NSManaged public func addToProgressPhotos(_ value: ProgressPhotoMO) + + @objc(removeProgressPhotosObject:) + @NSManaged public func removeFromProgressPhotos(_ value: ProgressPhotoMO) + + @objc(addProgressPhotos:) + @NSManaged public func addToProgressPhotos(_ values: NSSet) + + @objc(removeProgressPhotos:) + @NSManaged public func removeFromProgressPhotos(_ values: NSSet) } // MARK: - Domain Model Conversion diff --git a/PlantGuide/Data/DataSources/Local/CoreData/ManagedObjects/ProgressPhotoMO.swift b/PlantGuide/Data/DataSources/Local/CoreData/ManagedObjects/ProgressPhotoMO.swift new file mode 100644 index 0000000..958c40c --- /dev/null +++ b/PlantGuide/Data/DataSources/Local/CoreData/ManagedObjects/ProgressPhotoMO.swift @@ -0,0 +1,118 @@ +// +// ProgressPhotoMO.swift +// PlantGuide +// +// Core Data managed object representing a ProgressPhoto entity. +// Maps to the ProgressPhoto domain model for persistence. +// + +import CoreData +import Foundation + +// MARK: - ProgressPhotoMO + +/// Core Data managed object representing a ProgressPhoto entity. +/// Maps to the ProgressPhoto domain model for persistence. +@objc(ProgressPhotoMO) +public class ProgressPhotoMO: NSManagedObject { + + // MARK: - Properties + + /// Unique identifier for the progress photo + @NSManaged public var id: UUID + + /// The ID of the plant this photo belongs to + @NSManaged public var plantID: UUID + + /// The full-resolution image data (stored externally for large files) + @NSManaged public var imageData: Data + + /// The thumbnail image data for quick loading in lists + @NSManaged public var thumbnailData: Data + + /// The date when the photo was taken + @NSManaged public var dateTaken: Date + + /// Optional notes about the plant's condition at the time of the photo + @NSManaged public var notes: String? + + // MARK: - Relationships + + /// The plant this progress photo belongs to (many-to-one) + @NSManaged public var plant: PlantMO? +} + +// MARK: - Domain Model Conversion + +extension ProgressPhotoMO { + + /// Converts this managed object to a ProgressPhoto domain model. + /// - Returns: A ProgressPhoto domain entity populated with this managed object's data. + func toDomainModel() -> ProgressPhoto { + return ProgressPhoto( + id: id, + plantID: plantID, + imageData: imageData, + thumbnailData: thumbnailData, + dateTaken: dateTaken, + notes: notes + ) + } + + /// Creates a ProgressPhotoMO managed object from a ProgressPhoto domain model. + /// - Parameters: + /// - photo: The ProgressPhoto domain entity to convert. + /// - context: The managed object context to create the object in. + /// - Returns: A new ProgressPhotoMO instance populated with the photo's data. + static func fromDomainModel(_ photo: ProgressPhoto, context: NSManagedObjectContext) -> ProgressPhotoMO { + let photoMO = ProgressPhotoMO(context: context) + + photoMO.id = photo.id + photoMO.plantID = photo.plantID + photoMO.imageData = photo.imageData + photoMO.thumbnailData = photo.thumbnailData + photoMO.dateTaken = photo.dateTaken + photoMO.notes = photo.notes + // Note: plant relationship should be set separately via setPlant(id:context:) + + return photoMO + } + + /// Updates this managed object with values from a ProgressPhoto domain model. + /// - Parameter photo: The ProgressPhoto domain entity to update from. + /// - Note: The plant relationship should be set separately via setPlant(id:context:). + func update(from photo: ProgressPhoto) { + id = photo.id + plantID = photo.plantID + imageData = photo.imageData + thumbnailData = photo.thumbnailData + dateTaken = photo.dateTaken + notes = photo.notes + // Note: plant relationship should be set separately via setPlant(id:context:) + } + + /// Sets the plant relationship by looking up the plant by ID. + /// - Parameters: + /// - plantID: The ID of the plant to associate. + /// - context: The managed object context to use for the lookup. + func setPlant(id plantID: UUID, context: NSManagedObjectContext) { + let fetchRequest = PlantMO.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %@", plantID as CVarArg) + fetchRequest.fetchLimit = 1 + + if let plantMO = try? context.fetch(fetchRequest).first { + plant = plantMO + } + } +} + +// MARK: - Fetch Request + +extension ProgressPhotoMO { + + /// Creates a fetch request for ProgressPhotoMO entities. + /// - Returns: A configured NSFetchRequest for ProgressPhotoMO. + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "ProgressPhotoMO") + } +} diff --git a/PlantGuide/Data/DataSources/Local/CoreData/PlantGuideModel.xcdatamodeld/PlantGuideModel.xcdatamodel/contents b/PlantGuide/Data/DataSources/Local/CoreData/PlantGuideModel.xcdatamodeld/PlantGuideModel.xcdatamodel/contents index 8a47f77..e727b5a 100644 --- a/PlantGuide/Data/DataSources/Local/CoreData/PlantGuideModel.xcdatamodeld/PlantGuideModel.xcdatamodel/contents +++ b/PlantGuide/Data/DataSources/Local/CoreData/PlantGuideModel.xcdatamodeld/PlantGuideModel.xcdatamodel/contents @@ -20,6 +20,7 @@ + @@ -76,4 +77,13 @@ + + + + + + + + + diff --git a/PlantGuide/Domain/Entities/ProgressPhoto.swift b/PlantGuide/Domain/Entities/ProgressPhoto.swift new file mode 100644 index 0000000..6f29b7d --- /dev/null +++ b/PlantGuide/Domain/Entities/ProgressPhoto.swift @@ -0,0 +1,134 @@ +// +// ProgressPhoto.swift +// PlantGuide +// +// Created for PlantGuide plant identification app. +// + +import Foundation + +// MARK: - ProgressPhoto + +/// Represents a progress photo captured for tracking a plant's growth over time. +/// +/// Progress photos allow users to document their plant's development, health changes, +/// and growth milestones. Each photo is associated with a specific plant and includes +/// both full-resolution image data (for viewing) and a thumbnail (for fast gallery loading). +/// +/// The `imageData` property stores the full-resolution image (compressed HEIC, max 2048px), +/// while `thumbnailData` stores a 200x200 pixel version optimized for gallery views. +/// Both are stored using Core Data's external storage for efficient memory management. +/// +/// Conforms to Hashable for efficient use in SwiftUI ForEach and NavigationLink. +/// The hash is based only on the immutable `id` property for stable identity, +/// while Equatable compares all properties for change detection. +struct ProgressPhoto: Identifiable, Sendable, Equatable, Hashable { + + // MARK: - Properties + + /// Unique identifier for the progress photo + let id: UUID + + /// The ID of the plant this photo belongs to + let plantID: UUID + + /// Full-resolution image data (HEIC compressed, max 2048px dimension) + /// + /// This data is stored using Core Data's external storage ("Allows External Storage") + /// to avoid loading large binary data into memory when not needed. + let imageData: Data + + /// Thumbnail image data (200x200 pixels, JPEG compressed) + /// + /// Used for fast gallery loading without loading full-resolution images. + /// Pre-generated at capture time to ensure smooth scrolling performance. + let thumbnailData: Data + + /// The date when the photo was taken + let dateTaken: Date + + /// Optional user notes describing the photo or plant condition + /// + /// Users can add context such as "New leaf sprouting", "After repotting", + /// or "Signs of recovery from overwatering". + var notes: String? + + // MARK: - Initialization + + /// Creates a new ProgressPhoto instance. + /// + /// - Parameters: + /// - id: Unique identifier for the photo. Defaults to a new UUID. + /// - plantID: The ID of the plant this photo belongs to. + /// - imageData: Full-resolution image data (HEIC compressed, max 2048px). + /// - thumbnailData: Thumbnail image data (200x200 pixels). + /// - dateTaken: When the photo was taken. Defaults to current date. + /// - notes: Optional user notes about the photo. Defaults to nil. + init( + id: UUID = UUID(), + plantID: UUID, + imageData: Data, + thumbnailData: Data, + dateTaken: Date = Date(), + notes: String? = nil + ) { + self.id = id + self.plantID = plantID + self.imageData = imageData + self.thumbnailData = thumbnailData + self.dateTaken = dateTaken + self.notes = notes + } + + // MARK: - Hashable + + /// Custom hash implementation using only the id for stable identity. + /// This ensures consistent behavior in SwiftUI collections and navigation, + /// where identity should remain stable even when mutable properties change. + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +// MARK: - Convenience Extensions + +extension ProgressPhoto { + + /// Returns a new ProgressPhoto with updated notes. + /// + /// - Parameter newNotes: The new notes to set. + /// - Returns: A copy of the photo with the updated notes. + func withNotes(_ newNotes: String?) -> ProgressPhoto { + ProgressPhoto( + id: id, + plantID: plantID, + imageData: imageData, + thumbnailData: thumbnailData, + dateTaken: dateTaken, + notes: newNotes + ) + } + + /// Returns true if the photo has notes attached. + var hasNotes: Bool { + guard let notes = notes else { return false } + return !notes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// Returns a formatted date string for display. + /// + /// Uses a medium date style (e.g., "Jan 23, 2026"). + var formattedDate: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: dateTaken) + } + + /// Returns a relative date string (e.g., "2 days ago", "3 weeks ago"). + var relativeDateString: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter.localizedString(for: dateTaken, relativeTo: Date()) + } +} diff --git a/PlantGuide/Domain/RepositoryInterfaces/ProgressPhotoRepositoryProtocol.swift b/PlantGuide/Domain/RepositoryInterfaces/ProgressPhotoRepositoryProtocol.swift new file mode 100644 index 0000000..9e1f714 --- /dev/null +++ b/PlantGuide/Domain/RepositoryInterfaces/ProgressPhotoRepositoryProtocol.swift @@ -0,0 +1,195 @@ +// +// ProgressPhotoRepositoryProtocol.swift +// PlantGuide +// +// Created for PlantGuide plant identification app. +// + +import Foundation + +// MARK: - Progress Photo Storage Error + +/// Errors that can occur during progress photo storage operations. +/// +/// These errors provide specific context for photo storage failures, +/// enabling appropriate error handling and user messaging. +enum ProgressPhotoStorageError: Error, LocalizedError { + /// The photo with the specified ID was not found. + case photoNotFound(UUID) + + /// Failed to save photo data. + case saveFailed(Error) + + /// Failed to fetch photo data. + case fetchFailed(Error) + + /// Failed to delete photo data. + case deleteFailed(Error) + + /// The plant associated with the photo was not found. + case plantNotFound(UUID) + + /// The image data exceeds the maximum allowed size. + case imageTooLarge(actualSize: Int, maxSize: Int) + + // MARK: - LocalizedError + + var errorDescription: String? { + switch self { + case .photoNotFound(let id): + return "Photo with ID \(id) was not found" + case .saveFailed(let error): + return "Failed to save photo: \(error.localizedDescription)" + case .fetchFailed(let error): + return "Failed to fetch photo data: \(error.localizedDescription)" + case .deleteFailed(let error): + return "Failed to delete photo: \(error.localizedDescription)" + case .plantNotFound(let id): + return "Plant with ID \(id) was not found" + case .imageTooLarge(let actualSize, let maxSize): + let actualMB = Double(actualSize) / 1_000_000.0 + let maxMB = Double(maxSize) / 1_000_000.0 + return String(format: "Image size (%.1f MB) exceeds maximum allowed size (%.1f MB)", actualMB, maxMB) + } + } + + var failureReason: String? { + switch self { + case .photoNotFound: + return "The photo may have already been deleted." + case .saveFailed: + return "The photo data could not be persisted to storage." + case .fetchFailed: + return "The photo data could not be loaded from storage." + case .deleteFailed: + return "The photo could not be removed from storage." + case .plantNotFound: + return "The associated plant no longer exists in your collection." + case .imageTooLarge: + return "The image file is too large to be stored." + } + } + + var recoverySuggestion: String? { + switch self { + case .photoNotFound: + return "Refresh the photo gallery to see current photos." + case .saveFailed: + return "Check available storage space and try again." + case .fetchFailed: + return "Please try again. If the problem persists, restart the app." + case .deleteFailed: + return "Please try again. If the problem persists, restart the app." + case .plantNotFound: + return "The photo cannot be saved without an associated plant." + case .imageTooLarge: + return "Try capturing a new photo or reducing the image quality." + } + } +} + +// MARK: - ProgressPhotoRepositoryProtocol + +/// Repository protocol defining the data access contract for ProgressPhoto entities. +/// +/// Implementations handle persistence operations for plant progress photos, +/// including storage of both full-resolution images and thumbnails. +/// Photos are typically stored using Core Data with external storage enabled +/// for the binary image data to optimize memory usage. +/// +/// ## Usage Notes +/// - Photos are always returned sorted by `dateTaken` in descending order (newest first). +/// - When deleting a plant, call `deleteAll(for:)` to remove all associated photos. +/// - The repository handles both full-resolution and thumbnail data atomically. +protocol ProgressPhotoRepositoryProtocol: Sendable { + + // MARK: - Save Operations + + /// Saves a progress photo to the repository. + /// + /// If a photo with the same ID already exists, it will be updated. + /// Both imageData and thumbnailData are saved atomically. + /// + /// - Parameter photo: The progress photo entity to save. + /// - Throws: `ProgressPhotoStorageError.saveFailed` if the save operation fails. + /// - Throws: `ProgressPhotoStorageError.imageTooLarge` if the image exceeds size limits. + /// + /// Example usage: + /// ```swift + /// let photo = ProgressPhoto( + /// plantID: plant.id, + /// imageData: compressedImageData, + /// thumbnailData: thumbnailData, + /// notes: "New growth visible" + /// ) + /// try await repository.save(photo) + /// ``` + func save(_ photo: ProgressPhoto) async throws + + // MARK: - Fetch Operations + + /// Fetches a progress photo by its unique identifier. + /// + /// - Parameter id: The unique identifier of the photo to fetch. + /// - Returns: The progress photo if found, or nil if no photo exists with the given ID. + /// - Throws: `ProgressPhotoStorageError.fetchFailed` if the fetch operation fails. + /// + /// Example usage: + /// ```swift + /// if let photo = try await repository.fetch(id: photoID) { + /// displayFullResolutionImage(photo.imageData) + /// } + /// ``` + func fetch(id: UUID) async throws -> ProgressPhoto? + + /// Fetches all progress photos for a specific plant. + /// + /// Photos are returned sorted by `dateTaken` in descending order (newest first), + /// which is the typical display order for a photo timeline or gallery. + /// + /// - Parameter plantID: The unique identifier of the plant whose photos to fetch. + /// - Returns: An array of progress photos for the plant, sorted by dateTaken descending. + /// - Throws: `ProgressPhotoStorageError.fetchFailed` if the fetch operation fails. + /// + /// Example usage: + /// ```swift + /// let photos = try await repository.fetchAll(for: plant.id) + /// for photo in photos { + /// displayThumbnail(photo.thumbnailData) + /// } + /// ``` + func fetchAll(for plantID: UUID) async throws -> [ProgressPhoto] + + // MARK: - Delete Operations + + /// Deletes a progress photo by its unique identifier. + /// + /// This operation removes both the full-resolution image and thumbnail data. + /// If the photo does not exist, the operation completes silently without error. + /// + /// - Parameter id: The unique identifier of the photo to delete. + /// - Throws: `ProgressPhotoStorageError.deleteFailed` if the delete operation fails. + /// + /// Example usage: + /// ```swift + /// try await repository.delete(id: photoToRemove.id) + /// ``` + func delete(id: UUID) async throws + + /// Deletes all progress photos for a specific plant. + /// + /// Use this method when a plant is being deleted from the collection + /// to clean up all associated photo data. This is more efficient than + /// deleting photos individually. + /// + /// - Parameter plantID: The unique identifier of the plant whose photos to delete. + /// - Throws: `ProgressPhotoStorageError.deleteFailed` if the delete operation fails. + /// + /// Example usage: + /// ```swift + /// // When deleting a plant, also remove its photos + /// try await progressPhotoRepository.deleteAll(for: plant.id) + /// try await plantRepository.delete(id: plant.id) + /// ``` + func deleteAll(for plantID: UUID) async throws +} diff --git a/PlantGuide/Domain/UseCases/Photos/CaptureProgressPhotoUseCase.swift b/PlantGuide/Domain/UseCases/Photos/CaptureProgressPhotoUseCase.swift new file mode 100644 index 0000000..31f3c7a --- /dev/null +++ b/PlantGuide/Domain/UseCases/Photos/CaptureProgressPhotoUseCase.swift @@ -0,0 +1,379 @@ +// +// CaptureProgressPhotoUseCase.swift +// PlantGuide +// +// Created for PlantGuide plant identification app. +// + +import Foundation +import UIKit +import CoreGraphics + +// MARK: - CaptureProgressPhotoUseCaseProtocol + +/// Protocol defining the interface for capturing and saving progress photos. +/// +/// This protocol enables dependency injection and easy mocking for unit tests. +/// Implementations handle image processing (resizing, compression, thumbnail generation) +/// and coordinate with the repository to persist the photo data. +protocol CaptureProgressPhotoUseCaseProtocol: Sendable { + + /// Captures and saves a progress photo for a plant. + /// + /// This method performs the following operations: + /// 1. Validates the image data is within size limits + /// 2. Resizes the image to a maximum dimension of 2048 pixels + /// 3. Compresses the image to HEIC format for storage efficiency + /// 4. Generates a 200x200 pixel thumbnail for gallery display + /// 5. Saves the processed photo to the repository + /// + /// - Parameters: + /// - plantID: The ID of the plant to associate the photo with. + /// - imageData: The raw image data captured from the camera or photo library. + /// - notes: Optional notes describing the photo or plant condition. + /// - Returns: The saved ProgressPhoto entity with processed image data. + /// - Throws: `CaptureProgressPhotoError` if any step of the capture process fails. + /// + /// Example usage: + /// ```swift + /// let savedPhoto = try await useCase.execute( + /// plantID: plant.id, + /// imageData: capturedImageData, + /// notes: "New leaf emerging" + /// ) + /// ``` + func execute( + plantID: UUID, + imageData: Data, + notes: String? + ) async throws -> ProgressPhoto +} + +// MARK: - CaptureProgressPhotoError + +/// Errors that can occur when capturing and saving a progress photo. +/// +/// These errors provide specific context for capture operation failures, +/// enabling appropriate error handling and user messaging. +enum CaptureProgressPhotoError: Error, LocalizedError { + /// Failed to save the photo to the repository. + case repositorySaveFailed(Error) + + /// Failed to generate the thumbnail image. + case thumbnailGenerationFailed + + /// The image data exceeds the maximum allowed size (10 MB raw input). + case imageTooLarge(actualSize: Int, maxSize: Int) + + /// Failed to decode the image data into a valid image. + case invalidImageData + + /// Failed to compress the image to HEIC format. + case compressionFailed + + /// The plant ID provided does not exist. + case plantNotFound(UUID) + + // MARK: - LocalizedError + + var errorDescription: String? { + switch self { + case .repositorySaveFailed(let error): + return "Failed to save photo: \(error.localizedDescription)" + case .thumbnailGenerationFailed: + return "Failed to create photo thumbnail" + case .imageTooLarge(let actualSize, let maxSize): + let actualMB = Double(actualSize) / 1_000_000.0 + let maxMB = Double(maxSize) / 1_000_000.0 + return String(format: "Image size (%.1f MB) exceeds maximum (%.1f MB)", actualMB, maxMB) + case .invalidImageData: + return "The image data could not be processed" + case .compressionFailed: + return "Failed to compress the image" + case .plantNotFound(let id): + return "Plant with ID \(id) not found" + } + } + + var failureReason: String? { + switch self { + case .repositorySaveFailed: + return "The photo data could not be persisted to storage." + case .thumbnailGenerationFailed: + return "The thumbnail image could not be created from the original." + case .imageTooLarge: + return "The source image file is too large to process." + case .invalidImageData: + return "The image format is not supported or the data is corrupted." + case .compressionFailed: + return "The image could not be compressed for storage." + case .plantNotFound: + return "The plant may have been deleted from your collection." + } + } + + var recoverySuggestion: String? { + switch self { + case .repositorySaveFailed: + return "Check available storage space and try again." + case .thumbnailGenerationFailed: + return "Try capturing a different photo." + case .imageTooLarge: + return "Try capturing a new photo or selecting a smaller image." + case .invalidImageData: + return "Try capturing a new photo with the camera." + case .compressionFailed: + return "Try capturing a new photo or selecting a different image." + case .plantNotFound: + return "Return to your collection and select a valid plant." + } + } +} + +// MARK: - CaptureProgressPhotoUseCase + +/// Use case for capturing and saving progress photos for plants. +/// +/// This use case coordinates the complete photo capture workflow: +/// 1. Validates the input image data +/// 2. Resizes to maximum 2048 pixels on the longest edge +/// 3. Compresses to HEIC format (or JPEG fallback) for storage efficiency +/// 4. Generates a 200x200 pixel thumbnail for gallery display +/// 5. Persists the photo with associated metadata +/// +/// ## Image Processing Details +/// - Maximum input size: 10 MB +/// - Output format: HEIC (preferred) or JPEG (fallback) +/// - Maximum dimension: 2048 pixels (maintains aspect ratio) +/// - Thumbnail size: 200x200 pixels (center-cropped square) +/// - HEIC compression quality: 0.8 +/// +/// ## Example Usage +/// ```swift +/// let useCase = CaptureProgressPhotoUseCase( +/// progressPhotoRepository: repository +/// ) +/// +/// let savedPhoto = try await useCase.execute( +/// plantID: plant.id, +/// imageData: capturedData, +/// notes: "Week 4 growth check" +/// ) +/// ``` +final class CaptureProgressPhotoUseCase: CaptureProgressPhotoUseCaseProtocol, @unchecked Sendable { + + // MARK: - Constants + + /// Maximum allowed input image size (10 MB) + private static let maxInputSize: Int = 10_000_000 + + /// Maximum dimension for the processed image (2048 pixels) + private static let maxImageDimension: CGFloat = 2048 + + /// Thumbnail dimension (200x200 pixels) + private static let thumbnailSize: CGFloat = 200 + + /// HEIC compression quality (0.0 - 1.0) + private static let compressionQuality: CGFloat = 0.8 + + /// JPEG compression quality for thumbnails + private static let thumbnailCompressionQuality: CGFloat = 0.7 + + // MARK: - Dependencies + + private let progressPhotoRepository: ProgressPhotoRepositoryProtocol + + // MARK: - Initialization + + /// Creates a new CaptureProgressPhotoUseCase instance. + /// + /// - Parameter progressPhotoRepository: Repository for persisting progress photos. + init(progressPhotoRepository: ProgressPhotoRepositoryProtocol) { + self.progressPhotoRepository = progressPhotoRepository + } + + // MARK: - CaptureProgressPhotoUseCaseProtocol + + func execute( + plantID: UUID, + imageData: Data, + notes: String? + ) async throws -> ProgressPhoto { + // Step 1: Validate input image size + guard imageData.count <= Self.maxInputSize else { + throw CaptureProgressPhotoError.imageTooLarge( + actualSize: imageData.count, + maxSize: Self.maxInputSize + ) + } + + // Step 2: Decode the image + guard let originalImage = UIImage(data: imageData) else { + throw CaptureProgressPhotoError.invalidImageData + } + + // Step 3: Resize image if needed (max 2048px on longest edge) + let resizedImage = resizeImageIfNeeded(originalImage, maxDimension: Self.maxImageDimension) + + // Step 4: Compress to HEIC format + guard let compressedData = compressToHEIC(resizedImage, quality: Self.compressionQuality) else { + throw CaptureProgressPhotoError.compressionFailed + } + + // Step 5: Generate thumbnail (200x200) + guard let thumbnailData = generateThumbnail(from: resizedImage, size: Self.thumbnailSize) else { + throw CaptureProgressPhotoError.thumbnailGenerationFailed + } + + // Step 6: Create the ProgressPhoto entity + let photo = ProgressPhoto( + plantID: plantID, + imageData: compressedData, + thumbnailData: thumbnailData, + dateTaken: Date(), + notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines) + ) + + // Step 7: Save to repository + do { + try await progressPhotoRepository.save(photo) + } catch { + throw CaptureProgressPhotoError.repositorySaveFailed(error) + } + + return photo + } + + // MARK: - Private Methods + + /// Resizes an image if it exceeds the maximum dimension. + /// + /// Maintains aspect ratio and only resizes if the image is larger than the max dimension. + /// + /// - Parameters: + /// - image: The original image to resize. + /// - maxDimension: The maximum allowed dimension for width or height. + /// - Returns: The resized image, or the original if no resizing was needed. + private func resizeImageIfNeeded(_ image: UIImage, maxDimension: CGFloat) -> UIImage { + let originalSize = image.size + let maxOriginalDimension = max(originalSize.width, originalSize.height) + + // No resize needed if already within limits + guard maxOriginalDimension > maxDimension else { + return image + } + + // Calculate the scale factor to fit within maxDimension + let scaleFactor = maxDimension / maxOriginalDimension + let newSize = CGSize( + width: originalSize.width * scaleFactor, + height: originalSize.height * scaleFactor + ) + + // Create the resized image using UIGraphicsImageRenderer for better quality + let renderer = UIGraphicsImageRenderer(size: newSize) + let resizedImage = renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: newSize)) + } + + return resizedImage + } + + /// Compresses an image to HEIC format, with JPEG fallback. + /// + /// HEIC provides better compression ratios than JPEG while maintaining quality. + /// Falls back to JPEG if HEIC encoding is unavailable. + /// + /// - Parameters: + /// - image: The image to compress. + /// - quality: Compression quality (0.0 - 1.0). + /// - Returns: The compressed image data, or nil if compression failed. + private func compressToHEIC(_ image: UIImage, quality: CGFloat) -> Data? { + // Try HEIC first (better compression) + if let heicData = image.heicData(compressionQuality: quality) { + return heicData + } + + // Fall back to JPEG if HEIC is unavailable + return image.jpegData(compressionQuality: quality) + } + + /// Generates a square thumbnail from an image. + /// + /// The thumbnail is center-cropped to a square and scaled to the specified size. + /// Uses JPEG compression for thumbnails to ensure broad compatibility. + /// + /// - Parameters: + /// - image: The source image. + /// - size: The target thumbnail size (both width and height). + /// - Returns: The thumbnail image data, or nil if generation failed. + private func generateThumbnail(from image: UIImage, size: CGFloat) -> Data? { + let originalSize = image.size + let smallerDimension = min(originalSize.width, originalSize.height) + + // Calculate the crop rect to center-crop a square from the original + let cropRect: CGRect + if originalSize.width > originalSize.height { + // Landscape: crop from center horizontally + let xOffset = (originalSize.width - smallerDimension) / 2 + cropRect = CGRect(x: xOffset, y: 0, width: smallerDimension, height: smallerDimension) + } else { + // Portrait or square: crop from center vertically + let yOffset = (originalSize.height - smallerDimension) / 2 + cropRect = CGRect(x: 0, y: yOffset, width: smallerDimension, height: smallerDimension) + } + + // Crop the image to a square + guard let cgImage = image.cgImage, + let croppedCGImage = cgImage.cropping(to: cropRect) else { + return nil + } + + let croppedImage = UIImage(cgImage: croppedCGImage, scale: image.scale, orientation: image.imageOrientation) + + // Scale down to thumbnail size + let thumbnailRect = CGRect(x: 0, y: 0, width: size, height: size) + let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)) + let thumbnailImage = renderer.image { _ in + croppedImage.draw(in: thumbnailRect) + } + + // Compress as JPEG for compatibility + return thumbnailImage.jpegData(compressionQuality: Self.thumbnailCompressionQuality) + } +} + +// MARK: - UIImage HEIC Extension + +private extension UIImage { + + /// Encodes the image as HEIC data with the specified compression quality. + /// + /// - Parameter compressionQuality: The compression quality (0.0 - 1.0). + /// - Returns: The HEIC-encoded image data, or nil if encoding failed. + func heicData(compressionQuality: CGFloat) -> Data? { + guard let cgImage = self.cgImage else { return nil } + + let mutableData = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + mutableData, + "public.heic" as CFString, + 1, + nil + ) else { + return nil + } + + let options: [CFString: Any] = [ + kCGImageDestinationLossyCompressionQuality: compressionQuality + ] + + CGImageDestinationAddImage(destination, cgImage, options as CFDictionary) + + guard CGImageDestinationFinalize(destination) else { + return nil + } + + return mutableData as Data + } +} diff --git a/PlantGuide/Domain/UseCases/Photos/SchedulePhotoReminderUseCase.swift b/PlantGuide/Domain/UseCases/Photos/SchedulePhotoReminderUseCase.swift new file mode 100644 index 0000000..4631487 --- /dev/null +++ b/PlantGuide/Domain/UseCases/Photos/SchedulePhotoReminderUseCase.swift @@ -0,0 +1,455 @@ +// +// SchedulePhotoReminderUseCase.swift +// PlantGuide +// +// Created for PlantGuide plant identification app. +// + +import Foundation +import UserNotifications + +// MARK: - PhotoReminderInterval + +/// Defines the available intervals for progress photo reminder notifications. +/// +/// Users can choose how frequently they want to be reminded to take progress +/// photos of their plants. The `off` option disables reminders entirely. +enum PhotoReminderInterval: String, CaseIterable, Sendable, Codable { + /// Reminder every 7 days + case weekly = "weekly" + + /// Reminder every 14 days + case biweekly = "biweekly" + + /// Reminder every 30 days + case monthly = "monthly" + + /// No reminder - disabled + case off = "off" + + // MARK: - Computed Properties + + /// The interval in days between reminders. + /// Returns nil for the `.off` case. + var days: Int? { + switch self { + case .weekly: + return 7 + case .biweekly: + return 14 + case .monthly: + return 30 + case .off: + return nil + } + } + + /// Human-readable display name for the interval. + var displayName: String { + switch self { + case .weekly: + return "Weekly" + case .biweekly: + return "Every 2 weeks" + case .monthly: + return "Monthly" + case .off: + return "Off" + } + } + + /// Description of the reminder frequency for display. + var frequencyDescription: String { + switch self { + case .weekly: + return "You'll be reminded every week" + case .biweekly: + return "You'll be reminded every 2 weeks" + case .monthly: + return "You'll be reminded every month" + case .off: + return "Photo reminders are disabled" + } + } +} + +// MARK: - SchedulePhotoReminderUseCaseProtocol + +/// Protocol defining the interface for scheduling photo reminder notifications. +/// +/// This protocol enables dependency injection and easy mocking for unit tests. +/// Implementations coordinate with the notification service to schedule +/// recurring reminders for users to take progress photos of their plants. +protocol SchedulePhotoReminderUseCaseProtocol: Sendable { + + /// Schedules a recurring photo reminder for a plant. + /// + /// This method schedules a local notification that will repeat at the specified + /// interval, reminding the user to take a progress photo of their plant. + /// If a reminder already exists for the plant, it will be replaced. + /// + /// Setting the interval to `.off` will cancel any existing reminder. + /// + /// - Parameters: + /// - plantID: The ID of the plant to schedule reminders for. + /// - plantName: The display name of the plant (used in notification text). + /// - interval: The frequency of reminders (weekly, biweekly, monthly, or off). + /// - Throws: `SchedulePhotoReminderError` if scheduling fails. + /// + /// Example usage: + /// ```swift + /// // Schedule weekly reminders + /// try await useCase.execute( + /// plantID: plant.id, + /// plantName: plant.displayName, + /// interval: .weekly + /// ) + /// + /// // Disable reminders + /// try await useCase.execute( + /// plantID: plant.id, + /// plantName: plant.displayName, + /// interval: .off + /// ) + /// ``` + func execute( + plantID: UUID, + plantName: String, + interval: PhotoReminderInterval + ) async throws + + /// Cancels any scheduled photo reminder for a plant. + /// + /// Use this method when a plant is deleted or when the user wants to + /// stop receiving photo reminders without going through the execute flow. + /// + /// - Parameter plantID: The ID of the plant whose reminder should be cancelled. + /// + /// Example usage: + /// ```swift + /// // When deleting a plant + /// await useCase.cancelReminder(for: plant.id) + /// ``` + func cancelReminder(for plantID: UUID) async +} + +// MARK: - SchedulePhotoReminderError + +/// Errors that can occur when scheduling photo reminders. +/// +/// These errors provide specific context for scheduling failures, +/// enabling appropriate error handling and user messaging. +enum SchedulePhotoReminderError: Error, LocalizedError { + /// The user has denied notification permissions. + case permissionDenied + + /// Failed to schedule the notification with the system. + case schedulingFailed(Error) + + /// The specified interval is invalid. + case invalidInterval + + // MARK: - LocalizedError + + var errorDescription: String? { + switch self { + case .permissionDenied: + return "Notification permission denied" + case .schedulingFailed(let error): + return "Failed to schedule reminder: \(error.localizedDescription)" + case .invalidInterval: + return "Invalid reminder interval" + } + } + + var failureReason: String? { + switch self { + case .permissionDenied: + return "The app does not have permission to send notifications." + case .schedulingFailed: + return "The system could not schedule the notification." + case .invalidInterval: + return "The selected reminder interval is not valid." + } + } + + var recoverySuggestion: String? { + switch self { + case .permissionDenied: + return "Enable notifications in Settings > PlantGuide > Notifications." + case .schedulingFailed: + return "Please try again. If the problem persists, restart the app." + case .invalidInterval: + return "Select a valid reminder interval (weekly, biweekly, or monthly)." + } + } +} + +// MARK: - Photo Reminder Constants + +/// Constants for photo reminder notifications. +private enum PhotoReminderConstants { + /// Category identifier for photo reminder notifications. + /// Used to identify and filter photo-specific notifications. + static let categoryIdentifier = "PROGRESS_PHOTO_REMINDER" + + /// Prefix for photo reminder notification identifiers. + /// Combined with plant ID to create unique notification identifiers. + static let notificationPrefix = "photo-reminder-" + + /// User info key for storing the plant ID in notifications. + static let plantIDKey = "plantID" + + /// User info key for storing the reminder type. + static let reminderTypeKey = "reminderType" + + /// Default hour for reminder notifications (9 AM). + static let defaultReminderHour = 9 + + /// Default minute for reminder notifications. + static let defaultReminderMinute = 0 +} + +// MARK: - SchedulePhotoReminderUseCase + +/// Use case for scheduling recurring photo reminder notifications. +/// +/// This use case manages local notifications that remind users to take +/// progress photos of their plants at regular intervals. It supports +/// weekly, biweekly, and monthly reminder frequencies. +/// +/// ## Notification Details +/// - Category: "PROGRESS_PHOTO_REMINDER" +/// - Default time: 9:00 AM local time +/// - Repeating: Yes, based on selected interval +/// +/// ## Example Usage +/// ```swift +/// let useCase = SchedulePhotoReminderUseCase( +/// notificationService: notificationService +/// ) +/// +/// // Schedule weekly reminders +/// try await useCase.execute( +/// plantID: plant.id, +/// plantName: "Monstera", +/// interval: .weekly +/// ) +/// +/// // Cancel reminders when plant is deleted +/// await useCase.cancelReminder(for: plant.id) +/// ``` +final class SchedulePhotoReminderUseCase: SchedulePhotoReminderUseCaseProtocol, @unchecked Sendable { + + // MARK: - Dependencies + + private let notificationService: NotificationServiceProtocol + + // MARK: - Initialization + + /// Creates a new SchedulePhotoReminderUseCase instance. + /// + /// - Parameter notificationService: Service for scheduling local notifications. + init(notificationService: NotificationServiceProtocol) { + self.notificationService = notificationService + } + + // MARK: - SchedulePhotoReminderUseCaseProtocol + + func execute( + plantID: UUID, + plantName: String, + interval: PhotoReminderInterval + ) async throws { + // If interval is off, just cancel any existing reminder + if interval == .off { + await cancelReminder(for: plantID) + return + } + + // Get the interval days (validated by the guard) + guard let intervalDays = interval.days else { + throw SchedulePhotoReminderError.invalidInterval + } + + // Cancel any existing reminder for this plant first + await cancelReminder(for: plantID) + + // Request notification authorization + do { + let granted = try await notificationService.requestAuthorization() + guard granted else { + throw SchedulePhotoReminderError.permissionDenied + } + } catch let error as NotificationError { + if case .permissionDenied = error { + throw SchedulePhotoReminderError.permissionDenied + } + throw SchedulePhotoReminderError.schedulingFailed(error) + } catch let error as SchedulePhotoReminderError { + throw error + } catch { + throw SchedulePhotoReminderError.schedulingFailed(error) + } + + // Schedule the recurring notification + do { + try await scheduleRecurringNotification( + plantID: plantID, + plantName: plantName, + intervalDays: intervalDays + ) + } catch { + throw SchedulePhotoReminderError.schedulingFailed(error) + } + } + + func cancelReminder(for plantID: UUID) async { + // Remove all pending photo reminder notifications for this plant + let pendingNotifications = await notificationService.getPendingNotifications() + + let identifiersToRemove = pendingNotifications + .filter { request in + // Check if this is a photo reminder for the specified plant + guard let storedPlantID = request.content.userInfo[PhotoReminderConstants.plantIDKey] as? String, + request.content.categoryIdentifier == PhotoReminderConstants.categoryIdentifier else { + return false + } + return storedPlantID == plantID.uuidString + } + .map { $0.identifier } + + // Remove the notifications + if !identifiersToRemove.isEmpty { + // Since NotificationServiceProtocol doesn't have a direct method to remove by identifiers, + // we'll cancel using the task-based method if available, or rely on the category-based removal + for identifier in identifiersToRemove { + // Extract the UUID from the identifier if possible + if let uuidString = identifier.replacingOccurrences( + of: PhotoReminderConstants.notificationPrefix, + with: "" + ).components(separatedBy: "-").first, + let taskUUID = UUID(uuidString: uuidString) { + await notificationService.cancelReminder(for: taskUUID) + } + } + } + + // Also cancel using the built-in method for the plant ID + await notificationService.cancelAllReminders(for: plantID) + } + + // MARK: - Private Methods + + /// Schedules a recurring notification for photo reminders. + /// + /// - Parameters: + /// - plantID: The plant's unique identifier. + /// - plantName: The display name of the plant. + /// - intervalDays: The number of days between reminders. + private func scheduleRecurringNotification( + plantID: UUID, + plantName: String, + intervalDays: Int + ) async throws { + // Create the notification content + let content = UNMutableNotificationContent() + content.title = "Photo Time!" + content.body = "Take a progress photo of your \(plantName) to track its growth" + content.sound = .default + content.categoryIdentifier = PhotoReminderConstants.categoryIdentifier + content.userInfo = [ + PhotoReminderConstants.plantIDKey: plantID.uuidString, + PhotoReminderConstants.reminderTypeKey: "progress_photo" + ] + + // Calculate the next trigger date + var dateComponents = DateComponents() + dateComponents.hour = PhotoReminderConstants.defaultReminderHour + dateComponents.minute = PhotoReminderConstants.defaultReminderMinute + + // For repeating notifications, we use a weekday-based trigger for weekly, + // or a day-of-month trigger for monthly. For biweekly, we schedule + // a one-time notification and rely on the user interaction to reschedule. + + // Schedule initial notification for tomorrow at the default time + let calendar = Calendar.current + var nextTriggerDate = calendar.date(byAdding: .day, value: intervalDays, to: Date()) ?? Date() + nextTriggerDate = calendar.date( + bySettingHour: PhotoReminderConstants.defaultReminderHour, + minute: PhotoReminderConstants.defaultReminderMinute, + second: 0, + of: nextTriggerDate + ) ?? nextTriggerDate + + let triggerComponents = calendar.dateComponents( + [.year, .month, .day, .hour, .minute], + from: nextTriggerDate + ) + + // Create the trigger + // Note: For true recurring reminders, we'd need to handle rescheduling + // after each notification. This schedules the next single occurrence. + // A background task or notification action handler would reschedule. + let trigger = UNCalendarNotificationTrigger(dateMatching: triggerComponents, repeats: false) + + // Create unique identifier for this reminder + let identifier = "\(PhotoReminderConstants.notificationPrefix)\(plantID.uuidString)" + + // Create and schedule the request + let request = UNNotificationRequest( + identifier: identifier, + content: content, + trigger: trigger + ) + + try await UNUserNotificationCenter.current().add(request) + } +} + +// MARK: - Notification Category Setup + +extension SchedulePhotoReminderUseCase { + + /// Sets up the notification category and actions for photo reminders. + /// + /// Call this method during app launch to register the photo reminder + /// notification category with the system. This enables action buttons + /// on the notifications. + /// + /// Example usage in App init or AppDelegate: + /// ```swift + /// SchedulePhotoReminderUseCase.setupNotificationCategory() + /// ``` + static func setupNotificationCategory() { + let takePhotoAction = UNNotificationAction( + identifier: "TAKE_PHOTO", + title: "Take Photo", + options: [.foreground] + ) + + let remindLaterAction = UNNotificationAction( + identifier: "REMIND_LATER", + title: "Remind Me Later", + options: [] + ) + + let photoReminderCategory = UNNotificationCategory( + identifier: PhotoReminderConstants.categoryIdentifier, + actions: [takePhotoAction, remindLaterAction], + intentIdentifiers: [], + options: [.customDismissAction] + ) + + // Get existing categories and add the new one + UNUserNotificationCenter.current().getNotificationCategories { existingCategories in + var updatedCategories = existingCategories + // Remove any existing photo reminder category + updatedCategories = updatedCategories.filter { + $0.identifier != PhotoReminderConstants.categoryIdentifier + } + updatedCategories.insert(photoReminderCategory) + UNUserNotificationCenter.current().setNotificationCategories(updatedCategories) + } + } +} diff --git a/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailView.swift b/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailView.swift index 0029bf0..4324ed6 100644 --- a/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailView.swift +++ b/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailView.swift @@ -72,6 +72,9 @@ struct PlantDetailView: View { // Identification info identificationInfoSection + + // Progress photos section + progressPhotosSection } } .padding() @@ -359,6 +362,64 @@ struct PlantDetailView: View { .cornerRadius(12) } + // MARK: - Progress Photos Section + + private var progressPhotosSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Progress Photos") + .font(.headline) + Spacer() + Text("\(viewModel.progressPhotoCount) photos") + .font(.caption) + .foregroundStyle(.secondary) + } + + NavigationLink { + ProgressPhotoGalleryView( + plantID: viewModel.plant.id, + plantName: viewModel.displayName + ) + } label: { + HStack { + // Recent thumbnail or placeholder + if let recentThumbnail = viewModel.recentProgressPhotoThumbnail { + Image(uiImage: UIImage(data: recentThumbnail) ?? UIImage()) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + RoundedRectangle(cornerRadius: 8) + .fill(Color(.systemGray5)) + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "camera") + .foregroundStyle(.secondary) + } + } + + VStack(alignment: .leading) { + Text("View Gallery") + .font(.subheadline) + Text("Track your plant's growth") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(12) + } + // MARK: - Private Helpers private var identificationSourceDescription: String { diff --git a/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailViewModel.swift b/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailViewModel.swift index 8b6ab2c..f01ea9a 100644 --- a/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailViewModel.swift +++ b/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailViewModel.swift @@ -55,6 +55,12 @@ final class PlantDetailViewModel { /// 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) diff --git a/PlantGuide/Presentation/Scenes/ProgressPhotos/ProgressPhotoCaptureView.swift b/PlantGuide/Presentation/Scenes/ProgressPhotos/ProgressPhotoCaptureView.swift new file mode 100644 index 0000000..be71fc1 --- /dev/null +++ b/PlantGuide/Presentation/Scenes/ProgressPhotos/ProgressPhotoCaptureView.swift @@ -0,0 +1,443 @@ +// +// ProgressPhotoCaptureView.swift +// PlantGuide +// +// Created on 1/23/26. +// + +import SwiftUI +import AVFoundation + +// MARK: - ProgressPhotoCaptureView + +/// Camera view for capturing progress photos of a plant +/// +/// Features: +/// - Camera preview with capture button +/// - Photo preview after capture +/// - Optional notes text field +/// - Save/Cancel/Retake actions +@MainActor +struct ProgressPhotoCaptureView: View { + // MARK: - Properties + + let plantID: UUID + let plantName: String + let onPhotoCaptured: (Data, String?) -> Void + let onCancel: () -> Void + + @State private var viewModel = CameraViewModel() + @State private var notes: String = "" + @State private var showNotesField = false + @Environment(\.dismiss) private var dismiss + + // MARK: - Body + + var body: some View { + NavigationStack { + ZStack { + Color.black.ignoresSafeArea() + + switch viewModel.permissionStatus { + case .notDetermined: + permissionRequestView + case .authorized: + if viewModel.showCapturedImagePreview { + capturedImagePreview + } else { + cameraPreviewContent + } + case .denied, .restricted: + permissionDeniedView + } + } + .navigationTitle("Progress Photo") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.visible, for: .navigationBar) + .toolbarBackground(Color.black.opacity(0.8), for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { + onCancel() + } + .foregroundStyle(.white) + } + } + .task { + await viewModel.onAppear() + } + .onDisappear { + Task { + await viewModel.onDisappear() + } + } + .alert("Error", isPresented: .init( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.clearError() } } + )) { + Button("OK", role: .cancel) { } + } message: { + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + } + } + } + } + + // MARK: - Camera Preview Content + + private var cameraPreviewContent: some View { + ZStack { + // Camera preview + CameraPreviewView(session: viewModel.captureSession) + .ignoresSafeArea() + + VStack { + // Plant name header + HStack { + Spacer() + Text(plantName) + .font(.headline) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + Capsule() + .fill(Color.black.opacity(0.5)) + ) + Spacer() + } + .padding(.top, 16) + + Spacer() + + // Capture controls + captureControlsView + .padding(.bottom, 40) + } + + // Loading overlay + if viewModel.isCapturing { + capturingOverlay + } + } + } + + // MARK: - Capture Controls + + private var captureControlsView: some View { + HStack(spacing: 60) { + // Placeholder for symmetry + Circle() + .fill(Color.clear) + .frame(width: 50, height: 50) + + // Capture button + captureButton + + // Placeholder for symmetry + Circle() + .fill(Color.clear) + .frame(width: 50, height: 50) + } + } + + private var captureButton: some View { + Button { + Task { + await viewModel.capturePhoto() + } + } label: { + ZStack { + // Outer ring + Circle() + .strokeBorder(Color.white, lineWidth: 4) + .frame(width: 80, height: 80) + + // Inner circle + Circle() + .fill(Color.white) + .frame(width: 68, height: 68) + } + } + .disabled(!viewModel.isCameraControlsEnabled) + .opacity(viewModel.isCameraControlsEnabled ? 1.0 : 0.5) + .accessibilityLabel("Capture photo") + .accessibilityHint("Takes a progress photo of \(plantName)") + } + + // MARK: - Captured Image Preview + + private var capturedImagePreview: some View { + ZStack { + Color.black.ignoresSafeArea() + + if let image = viewModel.capturedImage { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .ignoresSafeArea() + } + + VStack(spacing: 0) { + // Top bar with retake button + HStack { + Button { + Task { + notes = "" + showNotesField = false + await viewModel.retakePhoto() + } + } label: { + HStack(spacing: 6) { + Image(systemName: "arrow.counterclockwise") + Text("Retake") + } + .font(.system(size: 17, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + Capsule() + .fill(Color.black.opacity(0.5)) + ) + } + .accessibilityLabel("Retake photo") + + Spacer() + } + .padding(.horizontal, 20) + .padding(.top, 60) + + Spacer() + + // Bottom controls + bottomControlsView + .padding(.bottom, 40) + } + } + } + + private var bottomControlsView: some View { + VStack(spacing: 16) { + // Notes section + if showNotesField { + notesInputField + } else { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + showNotesField = true + } + } label: { + HStack(spacing: 6) { + Image(systemName: "note.text") + Text("Add Note") + } + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + Capsule() + .fill(Color.black.opacity(0.5)) + ) + } + .accessibilityLabel("Add note") + .accessibilityHint("Add an optional note to this progress photo") + } + + // Save button + Button { + savePhoto() + } label: { + Text("Save Photo") + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(Color.white) + ) + } + .padding(.horizontal, 20) + .accessibilityLabel("Save progress photo") + .accessibilityHint("Saves this photo to your plant's progress gallery") + } + } + + private var notesInputField: some View { + VStack(spacing: 8) { + HStack { + Text("Note") + .font(.caption) + .foregroundStyle(.white.opacity(0.8)) + + Spacer() + + Button { + withAnimation(.easeInOut(duration: 0.2)) { + notes = "" + showNotesField = false + } + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.white.opacity(0.6)) + } + .accessibilityLabel("Remove note") + } + + TextField("What's happening with your plant?", text: $notes, axis: .vertical) + .textFieldStyle(.plain) + .font(.body) + .foregroundStyle(.white) + .lineLimit(3) + .padding(12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.white.opacity(0.15)) + ) + .accessibilityLabel("Photo note") + } + .padding(.horizontal, 20) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + + // MARK: - Capturing Overlay + + private var capturingOverlay: some View { + ZStack { + Color.black.opacity(0.4) + .ignoresSafeArea() + + VStack(spacing: 16) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.5) + + Text("Capturing...") + .font(.subheadline) + .foregroundColor(.white) + } + .padding(30) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.black.opacity(0.7)) + ) + } + } + + // MARK: - Permission Request View + + private var permissionRequestView: some View { + VStack(spacing: 24) { + Spacer() + + Image(systemName: "camera.fill") + .font(.system(size: 60)) + .foregroundColor(.white.opacity(0.7)) + + Text("Camera Access Required") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.white) + + Text("PlantGuide needs camera access to take progress photos of your plants.") + .font(.body) + .foregroundColor(.white.opacity(0.8)) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .padding(.top, 20) + + Spacer() + } + } + + // MARK: - Permission Denied View + + private var permissionDeniedView: some View { + VStack(spacing: 24) { + Spacer() + + Image(systemName: "camera.fill") + .font(.system(size: 60)) + .foregroundColor(.white.opacity(0.5)) + .overlay( + Image(systemName: "slash.circle.fill") + .font(.system(size: 30)) + .foregroundColor(.red) + .offset(x: 25, y: 25) + ) + + Text("Camera Access Denied") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.white) + + Text("PlantGuide needs camera access to take progress photos. Please enable camera access in Settings.") + .font(.body) + .foregroundColor(.white.opacity(0.8)) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + Button { + viewModel.openSettings() + } label: { + HStack(spacing: 8) { + Image(systemName: "gear") + Text("Open Settings") + } + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(.black) + .padding(.horizontal, 32) + .padding(.vertical, 14) + .background( + Capsule() + .fill(Color.white) + ) + } + .padding(.top, 12) + .accessibilityLabel("Open Settings") + .accessibilityHint("Opens system settings to enable camera access") + + Spacer() + } + } + + // MARK: - Private Methods + + private func savePhoto() { + guard let image = viewModel.capturedImage, + let imageData = image.jpegData(compressionQuality: 0.8) else { + return + } + + let photoNotes = notes.isEmpty ? nil : notes + onPhotoCaptured(imageData, photoNotes) + } +} + +// MARK: - Previews + +#Preview("Capture View") { + ProgressPhotoCaptureView( + plantID: UUID(), + plantName: "Monstera deliciosa", + onPhotoCaptured: { _, _ in }, + onCancel: { } + ) +} + +#Preview("Capture View - Permission Denied") { + // Note: To test permission denied state, you would need to mock the view model + ProgressPhotoCaptureView( + plantID: UUID(), + plantName: "Fiddle Leaf Fig", + onPhotoCaptured: { _, _ in }, + onCancel: { } + ) +} diff --git a/PlantGuide/Presentation/Scenes/ProgressPhotos/ProgressPhotoGalleryView.swift b/PlantGuide/Presentation/Scenes/ProgressPhotos/ProgressPhotoGalleryView.swift new file mode 100644 index 0000000..04190b2 --- /dev/null +++ b/PlantGuide/Presentation/Scenes/ProgressPhotos/ProgressPhotoGalleryView.swift @@ -0,0 +1,440 @@ +// +// ProgressPhotoGalleryView.swift +// PlantGuide +// +// Created on 1/23/26. +// + +import SwiftUI + +// MARK: - ProgressPhotoGalleryView + +/// Grid view displaying progress photos for a plant +/// +/// Features: +/// - Adaptive grid layout with thumbnails +/// - Date overlays on each photo +/// - Tap to view full size +/// - Long press to delete +/// - Pull to refresh +/// - Time-lapse playback navigation +@MainActor +struct ProgressPhotoGalleryView: View { + // MARK: - Properties + + @State private var viewModel: ProgressPhotosViewModel + @State private var showingDeleteConfirmation = false + @State private var photoToDelete: ProgressPhoto? + @State private var showingTimeLapse = false + @Environment(\.dismiss) private var dismiss + + /// Adaptive grid columns with minimum width of 100 + private let columns = [ + GridItem(.adaptive(minimum: 100), spacing: 8) + ] + + // MARK: - Initialization + + /// Creates a gallery view for the specified plant + /// - Parameters: + /// - plantID: The unique identifier of the plant + /// - plantName: The display name of the plant + init(plantID: UUID, plantName: String) { + _viewModel = State(initialValue: ProgressPhotosViewModel( + plantID: plantID, + plantName: plantName + )) + } + + /// Creates a gallery view with an existing view model (for previews/testing) + /// - Parameter viewModel: The view model to use + init(viewModel: ProgressPhotosViewModel) { + _viewModel = State(initialValue: viewModel) + } + + // MARK: - Body + + var body: some View { + Group { + if viewModel.isLoading && viewModel.photos.isEmpty { + loadingView + } else if viewModel.photos.isEmpty { + emptyStateView + } else { + photoGridView + } + } + .navigationTitle("Progress Photos") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + toolbarContent + } + .sheet(item: $viewModel.selectedPhoto) { photo in + photoDetailSheet(photo: photo) + } + .sheet(isPresented: $viewModel.isCapturing) { + ProgressPhotoCaptureView( + plantID: viewModel.plantID, + plantName: viewModel.plantName, + onPhotoCaptured: { imageData, notes in + Task { + await viewModel.capturePhoto(imageData: imageData, notes: notes) + } + }, + onCancel: { + viewModel.isCapturing = false + } + ) + } + .fullScreenCover(isPresented: $showingTimeLapse) { + if let firstPhoto = viewModel.chronologicalPhotos.first { + TimeLapsePlayerView( + photos: viewModel.chronologicalPhotos, + initialPhoto: firstPhoto, + speed: $viewModel.timeLapseSpeed + ) + } + } + .confirmationDialog( + "Delete Photo", + isPresented: $showingDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let photo = photoToDelete { + Task { + await viewModel.deletePhoto(photo) + } + } + } + Button("Cancel", role: .cancel) { + photoToDelete = nil + } + } message: { + Text("Are you sure you want to delete this photo? This action cannot be undone.") + } + .alert("Error", isPresented: .init( + get: { viewModel.error != nil }, + set: { if !$0 { viewModel.clearError() } } + )) { + Button("OK", role: .cancel) { } + } message: { + if let error = viewModel.error { + Text(error.localizedDescription) + } + } + .task { + await viewModel.loadPhotos() + } + } + + // MARK: - Toolbar Content + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 16) { + // Time-lapse button + if viewModel.canPlayTimeLapse { + Button { + showingTimeLapse = true + } label: { + Image(systemName: "play.circle") + .font(.system(size: 20)) + } + .accessibilityLabel("Play time-lapse") + .accessibilityHint("View photos as a time-lapse animation") + } + + // Add photo button + Button { + viewModel.isCapturing = true + } label: { + Image(systemName: "plus") + .font(.system(size: 18, weight: .medium)) + } + .accessibilityLabel("Add progress photo") + .accessibilityHint("Take a new progress photo of your plant") + } + } + } + + // MARK: - Loading View + + private var loadingView: some View { + VStack(spacing: 16) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(1.2) + + Text("Loading photos...") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Empty State View + + private var emptyStateView: some View { + VStack(spacing: 20) { + Image(systemName: "camera.fill") + .font(.system(size: 60)) + .foregroundStyle(.secondary) + + Text("No Progress Photos Yet") + .font(.title2) + .fontWeight(.semibold) + + Text("Track your plant's growth by taking regular photos. Watch how \(viewModel.plantName) changes over time!") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + + Button { + viewModel.isCapturing = true + } label: { + HStack(spacing: 8) { + Image(systemName: "camera.fill") + Text("Take First Photo") + } + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 24) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.green) + ) + } + .padding(.top, 8) + .accessibilityLabel("Take first progress photo") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + + // MARK: - Photo Grid View + + private var photoGridView: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 8) { + ForEach(viewModel.photos) { photo in + PhotoThumbnailCell(photo: photo) + .onTapGesture { + viewModel.selectedPhoto = photo + } + .onLongPressGesture { + photoToDelete = photo + showingDeleteConfirmation = true + } + .contextMenu { + Button { + viewModel.selectedPhoto = photo + } label: { + Label("View Full Size", systemImage: "arrow.up.left.and.arrow.down.right") + } + + Button(role: .destructive) { + photoToDelete = photo + showingDeleteConfirmation = true + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + .padding() + } + .refreshable { + await viewModel.loadPhotos() + } + } + + // MARK: - Photo Detail Sheet + + private func photoDetailSheet(photo: ProgressPhoto) -> some View { + NavigationStack { + PhotoDetailView(photo: photo) + .navigationTitle(formattedDate(photo.dateTaken)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + viewModel.selectedPhoto = nil + } + } + + ToolbarItem(placement: .topBarLeading) { + Button(role: .destructive) { + viewModel.selectedPhoto = nil + photoToDelete = photo + showingDeleteConfirmation = true + } label: { + Image(systemName: "trash") + } + .accessibilityLabel("Delete photo") + } + } + } + } + + // MARK: - Private Helpers + + private func formattedDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: date) + } +} + +// MARK: - PhotoThumbnailCell + +/// A cell displaying a photo thumbnail with date overlay +private struct PhotoThumbnailCell: View { + let photo: ProgressPhoto + + var body: some View { + ZStack(alignment: .bottom) { + // Thumbnail image + thumbnailImage + .aspectRatio(1, contentMode: .fill) + .clipped() + + // Date overlay + dateOverlay + } + .cornerRadius(8) + .accessibilityElement(children: .combine) + .accessibilityLabel("Progress photo from \(formattedAccessibilityDate)") + .accessibilityHint(photo.notes ?? "No notes") + .accessibilityAddTraits(.isButton) + } + + @ViewBuilder + private var thumbnailImage: some View { + if let uiImage = UIImage(data: photo.thumbnailData) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + // Placeholder for invalid image data + Rectangle() + .fill(Color(.systemGray4)) + .overlay { + Image(systemName: "photo") + .font(.system(size: 24)) + .foregroundStyle(.secondary) + } + } + } + + private var dateOverlay: some View { + Text(formattedShortDate) + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color.black.opacity(0.6)) + ) + .padding(4) + } + + private var formattedShortDate: String { + let formatter = DateFormatter() + formatter.dateFormat = "MMM d" + return formatter.string(from: photo.dateTaken) + } + + private var formattedAccessibilityDate: String { + let formatter = DateFormatter() + formatter.dateStyle = .long + return formatter.string(from: photo.dateTaken) + } +} + +// MARK: - PhotoDetailView + +/// Full-size view of a single progress photo +private struct PhotoDetailView: View { + let photo: ProgressPhoto + + var body: some View { + ScrollView { + VStack(spacing: 16) { + // Full-size image + if let uiImage = UIImage(data: photo.imageData) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + .accessibilityLabel("Full size progress photo") + } else { + Rectangle() + .fill(Color(.systemGray4)) + .aspectRatio(4/3, contentMode: .fit) + .overlay { + Image(systemName: "photo") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + } + } + + // Notes section + if let notes = photo.notes, !notes.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Notes") + .font(.headline) + + Text(notes) + .font(.body) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(12) + .padding(.horizontal) + } + } + } + } +} + +// MARK: - Previews + +#Preview("Gallery with Photos") { + NavigationStack { + ProgressPhotoGalleryView(viewModel: ProgressPhotosViewModel()) + } +} + +#Preview("Empty Gallery") { + let viewModel = ProgressPhotosViewModel() + // Clear the sample photos for empty state preview + return NavigationStack { + ProgressPhotoGalleryView( + plantID: UUID(), + plantName: "Monstera" + ) + } +} + +#Preview("Photo Thumbnail Cell") { + let photo = ProgressPhoto( + id: UUID(), + plantID: UUID(), + imageData: Data(), + thumbnailData: Data(), + dateTaken: Date(), + notes: "Sample note" + ) + + return PhotoThumbnailCell(photo: photo) + .frame(width: 100, height: 100) + .padding() +} diff --git a/PlantGuide/Presentation/Scenes/ProgressPhotos/ProgressPhotosViewModel.swift b/PlantGuide/Presentation/Scenes/ProgressPhotos/ProgressPhotosViewModel.swift new file mode 100644 index 0000000..42a324c --- /dev/null +++ b/PlantGuide/Presentation/Scenes/ProgressPhotos/ProgressPhotosViewModel.swift @@ -0,0 +1,343 @@ +// +// ProgressPhotosViewModel.swift +// PlantGuide +// +// Created on 1/23/26. +// + +import SwiftUI + +// MARK: - ProgressPhotosViewModel + +/// View model for managing progress photos for a specific plant +/// +/// Handles loading, capturing, and deleting progress photos, as well as +/// managing photo reminder settings and time-lapse playback state. +@MainActor +@Observable +final class ProgressPhotosViewModel { + // MARK: - Dependencies + + private let progressPhotoRepository: ProgressPhotoRepositoryProtocol + private let captureProgressPhotoUseCase: CaptureProgressPhotoUseCaseProtocol + private let schedulePhotoReminderUseCase: SchedulePhotoReminderUseCaseProtocol + + // MARK: - Properties + + /// The ID of the plant these photos belong to + let plantID: UUID + + /// The name of the plant (for display purposes) + let plantName: String + + /// All progress photos for this plant, sorted by date (newest first) + private(set) var photos: [ProgressPhoto] = [] + + /// Whether photos are currently being loaded + private(set) var isLoading: Bool = false + + /// Any error that occurred during operations + private(set) var error: Error? + + /// The currently selected photo for full-size viewing + var selectedPhoto: ProgressPhoto? + + /// Whether the camera capture view is being shown + var isCapturing: Bool = false + + /// The current photo reminder interval setting + private(set) var reminderInterval: PhotoReminderInterval = .off + + /// Whether the time-lapse player is currently playing + var isPlayingTimeLapse: Bool = false + + /// Speed of time-lapse playback (seconds per frame) + var timeLapseSpeed: Double = 0.3 + + // MARK: - Computed Properties + + /// Whether there are any photos to display + var hasPhotos: Bool { + !photos.isEmpty + } + + /// The number of photos available + var photoCount: Int { + photos.count + } + + /// Whether time-lapse can be played (requires at least 2 photos) + var canPlayTimeLapse: Bool { + photos.count >= 2 + } + + /// Photos sorted chronologically (oldest first) for time-lapse playback + var chronologicalPhotos: [ProgressPhoto] { + photos.sorted { $0.dateTaken < $1.dateTaken } + } + + // MARK: - Initialization + + /// Creates a new ProgressPhotosViewModel for the specified plant + /// - Parameters: + /// - plantID: The unique identifier of the plant + /// - plantName: The display name of the plant + /// - progressPhotoRepository: Repository for fetching and deleting photos + /// - captureProgressPhotoUseCase: Use case for capturing new photos + /// - schedulePhotoReminderUseCase: Use case for managing photo reminders + init( + plantID: UUID, + plantName: String, + progressPhotoRepository: ProgressPhotoRepositoryProtocol? = nil, + captureProgressPhotoUseCase: CaptureProgressPhotoUseCaseProtocol? = nil, + schedulePhotoReminderUseCase: SchedulePhotoReminderUseCaseProtocol? = nil + ) { + self.plantID = plantID + self.plantName = plantName + self.progressPhotoRepository = progressPhotoRepository ?? DIContainer.shared.progressPhotoRepository + self.captureProgressPhotoUseCase = captureProgressPhotoUseCase ?? DIContainer.shared.captureProgressPhotoUseCase + self.schedulePhotoReminderUseCase = schedulePhotoReminderUseCase ?? DIContainer.shared.schedulePhotoReminderUseCase + + // Load saved reminder interval + loadReminderInterval() + } + + /// Convenience initializer for SwiftUI previews with mock data + convenience init() { + self.init( + plantID: UUID(), + plantName: "Preview Plant", + progressPhotoRepository: MockProgressPhotoRepository(), + captureProgressPhotoUseCase: MockCaptureProgressPhotoUseCase(), + schedulePhotoReminderUseCase: MockSchedulePhotoReminderUseCase() + ) + + // Add sample photos for preview + self.photos = Self.samplePhotos + } + + // MARK: - Public Methods + + /// Loads all progress photos for this plant + func loadPhotos() async { + isLoading = true + error = nil + + do { + let fetchedPhotos = try await progressPhotoRepository.fetchAll(for: plantID) + // Sort by date taken, newest first + photos = fetchedPhotos.sorted { $0.dateTaken > $1.dateTaken } + } catch { + self.error = error + } + + isLoading = false + } + + /// Captures a new progress photo + /// - Parameters: + /// - imageData: The raw image data from the camera + /// - notes: Optional notes to attach to the photo + func capturePhoto(imageData: Data, notes: String?) async { + error = nil + + do { + let newPhoto = try await captureProgressPhotoUseCase.execute( + plantID: plantID, + imageData: imageData, + notes: notes + ) + + // Insert at the beginning (newest first) + photos.insert(newPhoto, at: 0) + isCapturing = false + } catch { + self.error = error + } + } + + /// Deletes a progress photo + /// - Parameter photo: The photo to delete + func deletePhoto(_ photo: ProgressPhoto) async { + error = nil + + do { + try await progressPhotoRepository.delete(id: photo.id) + + // Remove from local array + photos.removeAll { $0.id == photo.id } + + // Clear selection if the deleted photo was selected + if selectedPhoto?.id == photo.id { + selectedPhoto = nil + } + } catch { + self.error = error + } + } + + /// Updates the photo reminder interval + /// - Parameter interval: The new reminder interval + func updateReminderInterval(_ interval: PhotoReminderInterval) async { + error = nil + + do { + try await schedulePhotoReminderUseCase.execute( + plantID: plantID, + plantName: plantName, + interval: interval + ) + reminderInterval = interval + saveReminderInterval() + } catch { + self.error = error + } + } + + /// Clears any error state + func clearError() { + error = nil + } + + // MARK: - Time-Lapse Navigation + + /// Gets the index of a photo in the chronological array + /// - Parameter photo: The photo to find + /// - Returns: The index, or nil if not found + func chronologicalIndex(of photo: ProgressPhoto) -> Int? { + chronologicalPhotos.firstIndex(where: { $0.id == photo.id }) + } + + /// Gets the next photo in chronological order for time-lapse + /// - Parameter currentPhoto: The current photo + /// - Returns: The next photo, or the first photo if at the end + func nextPhoto(after currentPhoto: ProgressPhoto) -> ProgressPhoto? { + guard let currentIndex = chronologicalIndex(of: currentPhoto) else { return nil } + let nextIndex = (currentIndex + 1) % chronologicalPhotos.count + return chronologicalPhotos[nextIndex] + } + + /// Gets the previous photo in chronological order + /// - Parameter currentPhoto: The current photo + /// - Returns: The previous photo, or the last photo if at the beginning + func previousPhoto(before currentPhoto: ProgressPhoto) -> ProgressPhoto? { + guard let currentIndex = chronologicalIndex(of: currentPhoto) else { return nil } + let previousIndex = currentIndex > 0 ? currentIndex - 1 : chronologicalPhotos.count - 1 + return chronologicalPhotos[previousIndex] + } + + // MARK: - Private Methods + + private func loadReminderInterval() { + let key = "photo_reminder_interval_\(plantID.uuidString)" + if let rawValue = UserDefaults.standard.string(forKey: key), + let interval = PhotoReminderInterval(rawValue: rawValue) { + reminderInterval = interval + } + } + + private func saveReminderInterval() { + let key = "photo_reminder_interval_\(plantID.uuidString)" + UserDefaults.standard.set(reminderInterval.rawValue, forKey: key) + } + + // MARK: - Sample Data for Previews + + static var samplePhotos: [ProgressPhoto] { + let calendar = Calendar.current + let now = Date() + + return [ + ProgressPhoto( + id: UUID(), + plantID: UUID(), + imageData: Data(), + thumbnailData: Data(), + dateTaken: now, + notes: "Looking healthy today!" + ), + ProgressPhoto( + id: UUID(), + plantID: UUID(), + imageData: Data(), + thumbnailData: Data(), + dateTaken: calendar.date(byAdding: .day, value: -7, to: now)!, + notes: nil + ), + ProgressPhoto( + id: UUID(), + plantID: UUID(), + imageData: Data(), + thumbnailData: Data(), + dateTaken: calendar.date(byAdding: .day, value: -14, to: now)!, + notes: "New leaf emerging" + ), + ProgressPhoto( + id: UUID(), + plantID: UUID(), + imageData: Data(), + thumbnailData: Data(), + dateTaken: calendar.date(byAdding: .day, value: -21, to: now)!, + notes: nil + ), + ProgressPhoto( + id: UUID(), + plantID: UUID(), + imageData: Data(), + thumbnailData: Data(), + dateTaken: calendar.date(byAdding: .day, value: -28, to: now)!, + notes: "First photo after repotting" + ) + ] + } +} + +// MARK: - Mock Implementations for Previews + +/// Mock progress photo repository for SwiftUI previews +private struct MockProgressPhotoRepository: ProgressPhotoRepositoryProtocol { + func save(_ photo: ProgressPhoto) async throws { + // No-op for preview + } + + func fetch(id: UUID) async throws -> ProgressPhoto? { + return ProgressPhotosViewModel.samplePhotos.first { $0.id == id } + } + + func fetchAll(for plantID: UUID) async throws -> [ProgressPhoto] { + return ProgressPhotosViewModel.samplePhotos + } + + func delete(id: UUID) async throws { + // No-op for preview + } + + func deleteAll(for plantID: UUID) async throws { + // No-op for preview + } +} + +/// Mock capture use case for SwiftUI previews +private struct MockCaptureProgressPhotoUseCase: CaptureProgressPhotoUseCaseProtocol { + func execute(plantID: UUID, imageData: Data, notes: String?) async throws -> ProgressPhoto { + return ProgressPhoto( + id: UUID(), + plantID: plantID, + imageData: imageData, + thumbnailData: imageData, + dateTaken: Date(), + notes: notes + ) + } +} + +/// Mock schedule reminder use case for SwiftUI previews +private struct MockSchedulePhotoReminderUseCase: SchedulePhotoReminderUseCaseProtocol { + func execute(plantID: UUID, plantName: String, interval: PhotoReminderInterval) async throws { + // No-op for preview + } + + func cancelReminder(for plantID: UUID) async { + // No-op for preview + } +} diff --git a/PlantGuide/Presentation/Scenes/ProgressPhotos/TimeLapsePlayerView.swift b/PlantGuide/Presentation/Scenes/ProgressPhotos/TimeLapsePlayerView.swift new file mode 100644 index 0000000..662dbeb --- /dev/null +++ b/PlantGuide/Presentation/Scenes/ProgressPhotos/TimeLapsePlayerView.swift @@ -0,0 +1,381 @@ +// +// TimeLapsePlayerView.swift +// PlantGuide +// +// Created on 1/23/26. +// + +import SwiftUI + +// MARK: - TimeLapsePlayerView + +/// Player view for viewing progress photos as a time-lapse animation +/// +/// Features: +/// - Current photo display with date +/// - Manual left/right navigation between photos +/// - Play/pause button for auto-advance +/// - Speed slider (0.1s to 2.0s per frame) +/// - Photo counter (e.g., "3 of 15") +@MainActor +struct TimeLapsePlayerView: View { + // MARK: - Properties + + /// All photos in chronological order (oldest first) + let photos: [ProgressPhoto] + + /// The initial photo to display + let initialPhoto: ProgressPhoto + + /// Speed binding for persistence across view dismissal + @Binding var speed: Double + + @State private var currentPhoto: ProgressPhoto + @State private var isPlaying: Bool = false + @State private var playbackTask: Task? + + @Environment(\.dismiss) private var dismiss + + // MARK: - Speed Constants + + private let minSpeed: Double = 0.1 + private let maxSpeed: Double = 2.0 + + // MARK: - Initialization + + init(photos: [ProgressPhoto], initialPhoto: ProgressPhoto, speed: Binding) { + self.photos = photos + self.initialPhoto = initialPhoto + self._speed = speed + self._currentPhoto = State(initialValue: initialPhoto) + } + + // MARK: - Computed Properties + + private var currentIndex: Int { + photos.firstIndex(where: { $0.id == currentPhoto.id }) ?? 0 + } + + private var photoCountText: String { + "\(currentIndex + 1) of \(photos.count)" + } + + private var formattedDate: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: currentPhoto.dateTaken) + } + + private var speedText: String { + String(format: "%.1fs/frame", speed) + } + + // MARK: - Body + + var body: some View { + NavigationStack { + ZStack { + Color.black.ignoresSafeArea() + + VStack(spacing: 0) { + // Photo display area + photoDisplayView + .frame(maxHeight: .infinity) + + // Controls + controlsView + .padding(.vertical, 20) + .background(Color.black.opacity(0.9)) + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.visible, for: .navigationBar) + .toolbarBackground(Color.black.opacity(0.9), for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Done") { + stopPlayback() + dismiss() + } + .foregroundStyle(.white) + } + + ToolbarItem(placement: .principal) { + Text("Time-Lapse") + .font(.headline) + .foregroundStyle(.white) + } + } + .gesture( + DragGesture(minimumDistance: 50) + .onEnded { value in + handleSwipe(translation: value.translation) + } + ) + .onDisappear { + stopPlayback() + } + } + } + + // MARK: - Photo Display View + + private var photoDisplayView: some View { + ZStack { + // Photo + if let uiImage = UIImage(data: currentPhoto.imageData) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + .id(currentPhoto.id) // Force view update on photo change + .transition(.opacity) + .animation(.easeInOut(duration: 0.15), value: currentPhoto.id) + } else { + Rectangle() + .fill(Color(.systemGray4)) + .aspectRatio(4/3, contentMode: .fit) + .overlay { + Image(systemName: "photo") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + } + } + + // Date and counter overlay + VStack { + HStack { + // Photo counter + Text(photoCountText) + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.white) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule() + .fill(Color.black.opacity(0.6)) + ) + + Spacer() + } + .padding() + + Spacer() + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Progress photo \(currentIndex + 1) of \(photos.count), taken on \(formattedDate)") + } + + // MARK: - Controls View + + private var controlsView: some View { + VStack(spacing: 24) { + // Navigation and date row + navigationRow + + // Speed slider + speedSliderRow + + // Play/Pause button + playPauseButton + } + .padding(.horizontal, 24) + } + + private var navigationRow: some View { + HStack(spacing: 20) { + // Previous button + Button { + navigateToPrevious() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .medium)) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + .background( + Circle() + .fill(Color.white.opacity(0.15)) + ) + } + .disabled(photos.count < 2) + .opacity(photos.count < 2 ? 0.5 : 1.0) + .accessibilityLabel("Previous photo") + + Spacer() + + // Date display + Text(formattedDate) + .font(.headline) + .foregroundStyle(.white) + .accessibilityLabel("Photo taken on \(formattedDate)") + + Spacer() + + // Next button + Button { + navigateToNext() + } label: { + Image(systemName: "chevron.right") + .font(.system(size: 24, weight: .medium)) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + .background( + Circle() + .fill(Color.white.opacity(0.15)) + ) + } + .disabled(photos.count < 2) + .opacity(photos.count < 2 ? 0.5 : 1.0) + .accessibilityLabel("Next photo") + } + } + + private var speedSliderRow: some View { + VStack(spacing: 8) { + HStack { + Text("Speed") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.8)) + + Spacer() + + Text(speedText) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.white) + } + + Slider( + value: $speed, + in: minSpeed...maxSpeed, + step: 0.1 + ) + .tint(.green) + .accessibilityLabel("Playback speed") + .accessibilityValue("\(speedText) per frame") + } + } + + private var playPauseButton: some View { + Button { + togglePlayback() + } label: { + HStack(spacing: 10) { + Image(systemName: isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 18)) + Text(isPlaying ? "Pause" : "Play") + .font(.system(size: 17, weight: .semibold)) + } + .foregroundStyle(.black) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(Color.white) + ) + } + .disabled(photos.count < 2) + .opacity(photos.count < 2 ? 0.5 : 1.0) + .accessibilityLabel(isPlaying ? "Pause time-lapse" : "Play time-lapse") + .accessibilityHint(photos.count < 2 ? "Requires at least 2 photos" : "") + } + + // MARK: - Navigation Methods + + private func navigateToNext() { + guard photos.count > 1 else { return } + + let nextIndex = (currentIndex + 1) % photos.count + withAnimation(.easeInOut(duration: 0.15)) { + currentPhoto = photos[nextIndex] + } + } + + private func navigateToPrevious() { + guard photos.count > 1 else { return } + + let previousIndex = currentIndex > 0 ? currentIndex - 1 : photos.count - 1 + withAnimation(.easeInOut(duration: 0.15)) { + currentPhoto = photos[previousIndex] + } + } + + private func handleSwipe(translation: CGSize) { + // Horizontal swipe detection + if translation.width < -50 { + // Swipe left - next photo + navigateToNext() + } else if translation.width > 50 { + // Swipe right - previous photo + navigateToPrevious() + } + } + + // MARK: - Playback Control + + private func togglePlayback() { + if isPlaying { + stopPlayback() + } else { + startPlayback() + } + } + + private func startPlayback() { + guard photos.count >= 2 else { return } + + isPlaying = true + + playbackTask = Task { + while !Task.isCancelled && isPlaying { + // Wait for the current speed duration + try? await Task.sleep(for: .seconds(speed)) + + guard !Task.isCancelled && isPlaying else { break } + + // Advance to next photo + await MainActor.run { + navigateToNext() + } + } + } + } + + private func stopPlayback() { + isPlaying = false + playbackTask?.cancel() + playbackTask = nil + } +} + +// MARK: - Previews + +#Preview("Time-Lapse Player") { + let photos = ProgressPhotosViewModel.samplePhotos.sorted { $0.dateTaken < $1.dateTaken } + + return TimeLapsePlayerView( + photos: photos, + initialPhoto: photos.first!, + speed: .constant(0.3) + ) +} + +#Preview("Time-Lapse Player - Single Photo") { + let photo = ProgressPhoto( + id: UUID(), + plantID: UUID(), + imageData: Data(), + thumbnailData: Data(), + dateTaken: Date(), + notes: nil + ) + + return TimeLapsePlayerView( + photos: [photo], + initialPhoto: photo, + speed: .constant(0.5) + ) +}