Add Progress Photos feature for plant growth tracking (Phase 8)
Implement progress photo capture with HEIC compression and thumbnail generation, gallery view with grid display and full-size viewing, time-lapse playback with adjustable speed, and photo reminder notifications at weekly/biweekly/monthly intervals. New files: - ProgressPhoto domain entity with imageData and thumbnailData - ProgressPhotoRepositoryProtocol and CoreDataProgressPhotoRepository - CaptureProgressPhotoUseCase with image compression/resizing - SchedulePhotoReminderUseCase with notification scheduling - ProgressPhotosViewModel, ProgressPhotoGalleryView - ProgressPhotoCaptureView, TimeLapsePlayerView Modified: - PlantMO with progressPhotos relationship - Core Data model with ProgressPhotoMO entity - NotificationService with photo reminder support - PlantDetailView with Progress Photos section - DIContainer with photo service registrations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -65,15 +65,11 @@
|
||||
};
|
||||
1C4B79FC2F21C37C00ED69CF /* PlantGuideTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = PlantGuideTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1C4B7A062F21C37C00ED69CF /* PlantGuideUITests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = PlantGuideUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -111,7 +107,6 @@
|
||||
DADA0723BB8443C632252796 /* TaskSection.swift */,
|
||||
678533C17B8C3244E2001F4F /* RoomTaskGroup.swift */,
|
||||
);
|
||||
name = Components;
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -141,7 +136,6 @@
|
||||
children = (
|
||||
775B6E967C5DFFD7F1871824 /* Repositories */,
|
||||
);
|
||||
name = Data;
|
||||
path = Data;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -150,7 +144,6 @@
|
||||
children = (
|
||||
52FD1E71E8C36B0075A932F2 /* InMemoryRoomRepository.swift */,
|
||||
);
|
||||
name = Repositories;
|
||||
path = Repositories;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -160,7 +153,6 @@
|
||||
DEFE3CA84863FD85C7F7BB48 /* Presentation */,
|
||||
43A10090BDB504EEA8160579 /* Data */,
|
||||
);
|
||||
name = PlantGuide;
|
||||
path = PlantGuide;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -169,7 +161,6 @@
|
||||
children = (
|
||||
EB55B50C41964C736A4FF8A3 /* TodayView */,
|
||||
);
|
||||
name = Scenes;
|
||||
path = Scenes;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -178,7 +169,6 @@
|
||||
children = (
|
||||
96D83367DDD373621B7CA753 /* Scenes */,
|
||||
);
|
||||
name = Presentation;
|
||||
path = Presentation;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -189,7 +179,6 @@
|
||||
7A9D5ED974C43A2EC68CD03B /* TodayView.swift */,
|
||||
1A0266DEC4BEC766E4813767 /* Components */,
|
||||
);
|
||||
name = TodayView;
|
||||
path = TodayView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
|
||||
@@ -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<T>(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<CoreDataProgressPhotoRepository> = {
|
||||
LazyService {
|
||||
CoreDataProgressPhotoRepository(coreDataStack: CoreDataStack.shared)
|
||||
}
|
||||
}()
|
||||
|
||||
private lazy var _captureProgressPhotoUseCase: LazyService<CaptureProgressPhotoUseCase> = {
|
||||
LazyService { [weak self] in
|
||||
guard let self else { fatalError("DIContainer deallocated unexpectedly") }
|
||||
return CaptureProgressPhotoUseCase(progressPhotoRepository: self.progressPhotoRepository)
|
||||
}
|
||||
}()
|
||||
|
||||
private lazy var _schedulePhotoReminderUseCase: LazyService<SchedulePhotoReminderUseCase> = {
|
||||
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<PlantDatabaseService> = {
|
||||
@@ -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<T>(type: T.Type, factory: @escaping @MainActor () -> T) {
|
||||
factories[String(describing: type)] = factory
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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<ProgressPhotoMO> {
|
||||
return NSFetchRequest<ProgressPhotoMO>(entityName: "ProgressPhotoMO")
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
<relationship name="identifications" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="IdentificationMO" inverseName="plant" inverseEntity="IdentificationMO"/>
|
||||
<relationship name="plantCareInfo" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="PlantCareInfoMO" inverseName="plant" inverseEntity="PlantCareInfoMO"/>
|
||||
<relationship name="room" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="RoomMO" inverseName="plants" inverseEntity="RoomMO"/>
|
||||
<relationship name="progressPhotos" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="ProgressPhotoMO" inverseName="plant" inverseEntity="ProgressPhotoMO"/>
|
||||
</entity>
|
||||
<entity name="IdentificationMO" representedClassName="IdentificationMO" syncable="YES">
|
||||
<attribute name="confidenceScore" attributeType="Double" defaultValueString="0.0" usesScalarType="YES"/>
|
||||
@@ -76,4 +77,13 @@
|
||||
<attribute name="sortOrder" attributeType="Integer 32" defaultValueString="0" usesScalarType="YES"/>
|
||||
<relationship name="plants" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="room" inverseEntity="PlantMO"/>
|
||||
</entity>
|
||||
<entity name="ProgressPhotoMO" representedClassName="ProgressPhotoMO" syncable="YES">
|
||||
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
|
||||
<attribute name="plantID" attributeType="UUID" usesScalarType="NO"/>
|
||||
<attribute name="imageData" attributeType="Binary" allowsExternalBinaryDataStorage="YES"/>
|
||||
<attribute name="thumbnailData" attributeType="Binary"/>
|
||||
<attribute name="dateTaken" attributeType="Date" usesScalarType="NO"/>
|
||||
<attribute name="notes" optional="YES" attributeType="String"/>
|
||||
<relationship name="plant" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="progressPhotos" inverseEntity="PlantMO"/>
|
||||
</entity>
|
||||
</model>
|
||||
|
||||
134
PlantGuide/Domain/Entities/ProgressPhoto.swift
Normal file
134
PlantGuide/Domain/Entities/ProgressPhoto.swift
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: { }
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Void, Never>?
|
||||
|
||||
@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<Double>) {
|
||||
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)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user