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 */ = {
|
1C4B79FC2F21C37C00ED69CF /* PlantGuideTests */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = PlantGuideTests;
|
path = PlantGuideTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
1C4B7A062F21C37C00ED69CF /* PlantGuideUITests */ = {
|
1C4B7A062F21C37C00ED69CF /* PlantGuideUITests */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = PlantGuideUITests;
|
path = PlantGuideUITests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -111,7 +107,6 @@
|
|||||||
DADA0723BB8443C632252796 /* TaskSection.swift */,
|
DADA0723BB8443C632252796 /* TaskSection.swift */,
|
||||||
678533C17B8C3244E2001F4F /* RoomTaskGroup.swift */,
|
678533C17B8C3244E2001F4F /* RoomTaskGroup.swift */,
|
||||||
);
|
);
|
||||||
name = Components;
|
|
||||||
path = Components;
|
path = Components;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -141,7 +136,6 @@
|
|||||||
children = (
|
children = (
|
||||||
775B6E967C5DFFD7F1871824 /* Repositories */,
|
775B6E967C5DFFD7F1871824 /* Repositories */,
|
||||||
);
|
);
|
||||||
name = Data;
|
|
||||||
path = Data;
|
path = Data;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -150,7 +144,6 @@
|
|||||||
children = (
|
children = (
|
||||||
52FD1E71E8C36B0075A932F2 /* InMemoryRoomRepository.swift */,
|
52FD1E71E8C36B0075A932F2 /* InMemoryRoomRepository.swift */,
|
||||||
);
|
);
|
||||||
name = Repositories;
|
|
||||||
path = Repositories;
|
path = Repositories;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -160,7 +153,6 @@
|
|||||||
DEFE3CA84863FD85C7F7BB48 /* Presentation */,
|
DEFE3CA84863FD85C7F7BB48 /* Presentation */,
|
||||||
43A10090BDB504EEA8160579 /* Data */,
|
43A10090BDB504EEA8160579 /* Data */,
|
||||||
);
|
);
|
||||||
name = PlantGuide;
|
|
||||||
path = PlantGuide;
|
path = PlantGuide;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -169,7 +161,6 @@
|
|||||||
children = (
|
children = (
|
||||||
EB55B50C41964C736A4FF8A3 /* TodayView */,
|
EB55B50C41964C736A4FF8A3 /* TodayView */,
|
||||||
);
|
);
|
||||||
name = Scenes;
|
|
||||||
path = Scenes;
|
path = Scenes;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -178,7 +169,6 @@
|
|||||||
children = (
|
children = (
|
||||||
96D83367DDD373621B7CA753 /* Scenes */,
|
96D83367DDD373621B7CA753 /* Scenes */,
|
||||||
);
|
);
|
||||||
name = Presentation;
|
|
||||||
path = Presentation;
|
path = Presentation;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -189,7 +179,6 @@
|
|||||||
7A9D5ED974C43A2EC68CD03B /* TodayView.swift */,
|
7A9D5ED974C43A2EC68CD03B /* TodayView.swift */,
|
||||||
1A0266DEC4BEC766E4813767 /* Components */,
|
1A0266DEC4BEC766E4813767 /* Components */,
|
||||||
);
|
);
|
||||||
name = TodayView;
|
|
||||||
path = TodayView;
|
path = TodayView;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ protocol DIContainerProtocol: AnyObject, Sendable {
|
|||||||
func makeSettingsViewModel() -> SettingsViewModel
|
func makeSettingsViewModel() -> SettingsViewModel
|
||||||
func makeBrowsePlantsViewModel() -> BrowsePlantsViewModel
|
func makeBrowsePlantsViewModel() -> BrowsePlantsViewModel
|
||||||
func makeTodayViewModel() -> TodayViewModel
|
func makeTodayViewModel() -> TodayViewModel
|
||||||
|
func makeProgressPhotosViewModel(plantID: UUID, plantName: String) -> ProgressPhotosViewModel
|
||||||
|
|
||||||
// MARK: - Registration
|
// MARK: - Registration
|
||||||
func register<T>(type: T.Type, factory: @escaping @MainActor () -> T)
|
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
|
// MARK: - Local Plant Database Services
|
||||||
|
|
||||||
private lazy var _plantDatabaseService: LazyService<PlantDatabaseService> = {
|
private lazy var _plantDatabaseService: LazyService<PlantDatabaseService> = {
|
||||||
@@ -326,6 +349,23 @@ final class DIContainer: DIContainerProtocol, ObservableObject {
|
|||||||
_manageRoomsUseCase.value
|
_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
|
// MARK: - Initialization
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
@@ -564,6 +604,17 @@ final class DIContainer: DIContainerProtocol, ObservableObject {
|
|||||||
RoomsViewModel(manageRoomsUseCase: manageRoomsUseCase)
|
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
|
/// Factory method for TodayViewModel
|
||||||
func makeTodayViewModel() -> TodayViewModel {
|
func makeTodayViewModel() -> TodayViewModel {
|
||||||
TodayViewModel(
|
TodayViewModel(
|
||||||
@@ -639,6 +690,10 @@ final class DIContainer: DIContainerProtocol, ObservableObject {
|
|||||||
_coreDataRoomStorage.reset()
|
_coreDataRoomStorage.reset()
|
||||||
_createDefaultRoomsUseCase.reset()
|
_createDefaultRoomsUseCase.reset()
|
||||||
_manageRoomsUseCase.reset()
|
_manageRoomsUseCase.reset()
|
||||||
|
// Progress photo services
|
||||||
|
_coreDataProgressPhotoStorage.reset()
|
||||||
|
_captureProgressPhotoUseCase.reset()
|
||||||
|
_schedulePhotoReminderUseCase.reset()
|
||||||
factories.removeAll()
|
factories.removeAll()
|
||||||
resolvedInstances.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) {
|
func register<T>(type: T.Type, factory: @escaping @MainActor () -> T) {
|
||||||
factories[String(describing: type)] = factory
|
factories[String(describing: type)] = factory
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,17 @@ protocol NotificationServiceProtocol: Sendable {
|
|||||||
/// - plantID: The unique identifier of the plant
|
/// - plantID: The unique identifier of the plant
|
||||||
func cancelReminders(for taskType: CareTaskType, plantID: UUID) async
|
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
|
/// Update the app badge count
|
||||||
/// - Parameter count: The number to display on the app badge
|
/// - Parameter count: The number to display on the app badge
|
||||||
func updateBadgeCount(_ count: Int) async
|
func updateBadgeCount(_ count: Int) async
|
||||||
@@ -79,15 +90,24 @@ private enum NotificationConstants {
|
|||||||
/// Category identifier for care reminder notifications
|
/// Category identifier for care reminder notifications
|
||||||
static let careReminderCategory = "CARE_REMINDER"
|
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
|
/// Action identifier for marking a task as complete
|
||||||
static let completeAction = "COMPLETE"
|
static let completeAction = "COMPLETE"
|
||||||
|
|
||||||
/// Action identifier for snoozing the reminder
|
/// Action identifier for snoozing the reminder
|
||||||
static let snoozeAction = "SNOOZE"
|
static let snoozeAction = "SNOOZE"
|
||||||
|
|
||||||
|
/// Action identifier for taking a photo
|
||||||
|
static let takePhotoAction = "TAKE_PHOTO"
|
||||||
|
|
||||||
/// Prefix for notification identifiers
|
/// Prefix for notification identifiers
|
||||||
static let notificationPrefix = "care-"
|
static let notificationPrefix = "care-"
|
||||||
|
|
||||||
|
/// Prefix for photo reminder notification identifiers
|
||||||
|
static let photoReminderPrefix = "photo-"
|
||||||
|
|
||||||
/// User info key for task ID
|
/// User info key for task ID
|
||||||
static let taskIDKey = "taskID"
|
static let taskIDKey = "taskID"
|
||||||
|
|
||||||
@@ -97,6 +117,9 @@ private enum NotificationConstants {
|
|||||||
/// User info key for task type
|
/// User info key for task type
|
||||||
static let taskTypeKey = "taskType"
|
static let taskTypeKey = "taskType"
|
||||||
|
|
||||||
|
/// User info key for notification category
|
||||||
|
static let categoryKey = "category"
|
||||||
|
|
||||||
/// Snooze duration in seconds (1 hour)
|
/// Snooze duration in seconds (1 hour)
|
||||||
static let snoozeDuration: TimeInterval = 3600
|
static let snoozeDuration: TimeInterval = 3600
|
||||||
}
|
}
|
||||||
@@ -141,7 +164,21 @@ actor NotificationService: NotificationServiceProtocol {
|
|||||||
options: [.customDismissAction]
|
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
|
// MARK: - Public Interface
|
||||||
@@ -282,6 +319,71 @@ actor NotificationService: NotificationServiceProtocol {
|
|||||||
notificationCenter.removeAllDeliveredNotifications()
|
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
|
// MARK: - Private Methods
|
||||||
|
|
||||||
/// Generate a consistent notification identifier for a task
|
/// Generate a consistent notification identifier for a task
|
||||||
@@ -290,6 +392,13 @@ actor NotificationService: NotificationServiceProtocol {
|
|||||||
private func notificationIdentifier(for taskID: UUID) -> String {
|
private func notificationIdentifier(for taskID: UUID) -> String {
|
||||||
"\(NotificationConstants.notificationPrefix)\(taskID.uuidString)"
|
"\(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
|
// 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)
|
/// The room where this plant is located (optional, to-one)
|
||||||
@NSManaged public var room: RoomMO?
|
@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
|
// 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="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="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="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>
|
||||||
<entity name="IdentificationMO" representedClassName="IdentificationMO" syncable="YES">
|
<entity name="IdentificationMO" representedClassName="IdentificationMO" syncable="YES">
|
||||||
<attribute name="confidenceScore" attributeType="Double" defaultValueString="0.0" usesScalarType="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"/>
|
<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"/>
|
<relationship name="plants" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="room" inverseEntity="PlantMO"/>
|
||||||
</entity>
|
</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>
|
</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
|
// Identification info
|
||||||
identificationInfoSection
|
identificationInfoSection
|
||||||
|
|
||||||
|
// Progress photos section
|
||||||
|
progressPhotosSection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
@@ -359,6 +362,64 @@ struct PlantDetailView: View {
|
|||||||
.cornerRadius(12)
|
.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
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
private var identificationSourceDescription: String {
|
private var identificationSourceDescription: String {
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ final class PlantDetailViewModel {
|
|||||||
/// Success message to display after schedule creation
|
/// Success message to display after schedule creation
|
||||||
private(set) var successMessage: String?
|
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
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
/// The next upcoming care tasks (up to 5)
|
/// 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