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:
Trey t
2026-01-23 15:40:50 -06:00
parent f41c77876a
commit 4fcec31c02
17 changed files with 3315 additions and 12 deletions

View File

@@ -65,15 +65,11 @@
};
1C4B79FC2F21C37C00ED69CF /* PlantGuideTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = PlantGuideTests;
sourceTree = "<group>";
};
1C4B7A062F21C37C00ED69CF /* PlantGuideUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = PlantGuideUITests;
sourceTree = "<group>";
};
@@ -111,7 +107,6 @@
DADA0723BB8443C632252796 /* TaskSection.swift */,
678533C17B8C3244E2001F4F /* RoomTaskGroup.swift */,
);
name = Components;
path = Components;
sourceTree = "<group>";
};
@@ -141,7 +136,6 @@
children = (
775B6E967C5DFFD7F1871824 /* Repositories */,
);
name = Data;
path = Data;
sourceTree = "<group>";
};
@@ -150,7 +144,6 @@
children = (
52FD1E71E8C36B0075A932F2 /* InMemoryRoomRepository.swift */,
);
name = Repositories;
path = Repositories;
sourceTree = "<group>";
};
@@ -160,7 +153,6 @@
DEFE3CA84863FD85C7F7BB48 /* Presentation */,
43A10090BDB504EEA8160579 /* Data */,
);
name = PlantGuide;
path = PlantGuide;
sourceTree = "<group>";
};
@@ -169,7 +161,6 @@
children = (
EB55B50C41964C736A4FF8A3 /* TodayView */,
);
name = Scenes;
path = Scenes;
sourceTree = "<group>";
};
@@ -178,7 +169,6 @@
children = (
96D83367DDD373621B7CA753 /* Scenes */,
);
name = Presentation;
path = Presentation;
sourceTree = "<group>";
};
@@ -189,7 +179,6 @@
7A9D5ED974C43A2EC68CD03B /* TodayView.swift */,
1A0266DEC4BEC766E4813767 /* Components */,
);
name = TodayView;
path = TodayView;
sourceTree = "<group>";
};

View File

@@ -23,6 +23,7 @@ protocol DIContainerProtocol: AnyObject, Sendable {
func makeSettingsViewModel() -> SettingsViewModel
func makeBrowsePlantsViewModel() -> BrowsePlantsViewModel
func makeTodayViewModel() -> TodayViewModel
func makeProgressPhotosViewModel(plantID: UUID, plantName: String) -> ProgressPhotosViewModel
// MARK: - Registration
func register<T>(type: T.Type, factory: @escaping @MainActor () -> T)
@@ -244,6 +245,28 @@ final class DIContainer: DIContainerProtocol, ObservableObject {
}
}()
// MARK: - Progress Photo Services
private lazy var _coreDataProgressPhotoStorage: LazyService<CoreDataProgressPhotoRepository> = {
LazyService {
CoreDataProgressPhotoRepository(coreDataStack: CoreDataStack.shared)
}
}()
private lazy var _captureProgressPhotoUseCase: LazyService<CaptureProgressPhotoUseCase> = {
LazyService { [weak self] in
guard let self else { fatalError("DIContainer deallocated unexpectedly") }
return CaptureProgressPhotoUseCase(progressPhotoRepository: self.progressPhotoRepository)
}
}()
private lazy var _schedulePhotoReminderUseCase: LazyService<SchedulePhotoReminderUseCase> = {
LazyService { [weak self] in
guard let self else { fatalError("DIContainer deallocated unexpectedly") }
return SchedulePhotoReminderUseCase(notificationService: self.notificationService)
}
}()
// MARK: - Local Plant Database Services
private lazy var _plantDatabaseService: LazyService<PlantDatabaseService> = {
@@ -326,6 +349,23 @@ final class DIContainer: DIContainerProtocol, ObservableObject {
_manageRoomsUseCase.value
}
// MARK: - Progress Photo Accessors
/// Progress photo repository backed by Core Data
var progressPhotoRepository: ProgressPhotoRepositoryProtocol {
_coreDataProgressPhotoStorage.value
}
/// Use case for capturing progress photos
var captureProgressPhotoUseCase: CaptureProgressPhotoUseCaseProtocol {
_captureProgressPhotoUseCase.value
}
/// Use case for scheduling photo reminders
var schedulePhotoReminderUseCase: SchedulePhotoReminderUseCaseProtocol {
_schedulePhotoReminderUseCase.value
}
// MARK: - Initialization
private init() {}
@@ -564,6 +604,17 @@ final class DIContainer: DIContainerProtocol, ObservableObject {
RoomsViewModel(manageRoomsUseCase: manageRoomsUseCase)
}
/// Factory method for ProgressPhotosViewModel
func makeProgressPhotosViewModel(plantID: UUID, plantName: String) -> ProgressPhotosViewModel {
ProgressPhotosViewModel(
plantID: plantID,
plantName: plantName,
progressPhotoRepository: progressPhotoRepository,
captureProgressPhotoUseCase: captureProgressPhotoUseCase,
schedulePhotoReminderUseCase: schedulePhotoReminderUseCase
)
}
/// Factory method for TodayViewModel
func makeTodayViewModel() -> TodayViewModel {
TodayViewModel(
@@ -639,6 +690,10 @@ final class DIContainer: DIContainerProtocol, ObservableObject {
_coreDataRoomStorage.reset()
_createDefaultRoomsUseCase.reset()
_manageRoomsUseCase.reset()
// Progress photo services
_coreDataProgressPhotoStorage.reset()
_captureProgressPhotoUseCase.reset()
_schedulePhotoReminderUseCase.reset()
factories.removeAll()
resolvedInstances.removeAll()
}
@@ -732,6 +787,10 @@ final class MockDIContainer: DIContainerProtocol {
)
}
func makeProgressPhotosViewModel(plantID: UUID, plantName: String) -> ProgressPhotosViewModel {
ProgressPhotosViewModel(plantID: plantID, plantName: plantName)
}
func register<T>(type: T.Type, factory: @escaping @MainActor () -> T) {
factories[String(describing: type)] = factory
}

View File

@@ -60,6 +60,17 @@ protocol NotificationServiceProtocol: Sendable {
/// - plantID: The unique identifier of the plant
func cancelReminders(for taskType: CareTaskType, plantID: UUID) async
/// Schedule a recurring photo reminder for a plant
/// - Parameters:
/// - plantID: The unique identifier of the plant
/// - plantName: The name of the plant to display in the notification
/// - interval: The interval between photo reminders
func schedulePhotoReminder(for plantID: UUID, plantName: String, interval: PhotoReminderInterval) async throws
/// Cancel a scheduled photo reminder for a plant
/// - Parameter plantID: The unique identifier of the plant
func cancelPhotoReminder(for plantID: UUID) async
/// Update the app badge count
/// - Parameter count: The number to display on the app badge
func updateBadgeCount(_ count: Int) async
@@ -79,15 +90,24 @@ private enum NotificationConstants {
/// Category identifier for care reminder notifications
static let careReminderCategory = "CARE_REMINDER"
/// Category identifier for progress photo reminder notifications
static let progressPhotoReminderCategory = "PROGRESS_PHOTO_REMINDER"
/// Action identifier for marking a task as complete
static let completeAction = "COMPLETE"
/// Action identifier for snoozing the reminder
static let snoozeAction = "SNOOZE"
/// Action identifier for taking a photo
static let takePhotoAction = "TAKE_PHOTO"
/// Prefix for notification identifiers
static let notificationPrefix = "care-"
/// Prefix for photo reminder notification identifiers
static let photoReminderPrefix = "photo-"
/// User info key for task ID
static let taskIDKey = "taskID"
@@ -97,6 +117,9 @@ private enum NotificationConstants {
/// User info key for task type
static let taskTypeKey = "taskType"
/// User info key for notification category
static let categoryKey = "category"
/// Snooze duration in seconds (1 hour)
static let snoozeDuration: TimeInterval = 3600
}
@@ -141,7 +164,21 @@ actor NotificationService: NotificationServiceProtocol {
options: [.customDismissAction]
)
UNUserNotificationCenter.current().setNotificationCategories([careReminderCategory])
// Progress photo reminder category
let takePhotoAction = UNNotificationAction(
identifier: NotificationConstants.takePhotoAction,
title: "Take Photo",
options: [.foreground]
)
let progressPhotoCategory = UNNotificationCategory(
identifier: NotificationConstants.progressPhotoReminderCategory,
actions: [takePhotoAction],
intentIdentifiers: [],
options: []
)
UNUserNotificationCenter.current().setNotificationCategories([careReminderCategory, progressPhotoCategory])
}
// MARK: - Public Interface
@@ -282,6 +319,71 @@ actor NotificationService: NotificationServiceProtocol {
notificationCenter.removeAllDeliveredNotifications()
}
// MARK: - Photo Reminder Methods
/// Schedule a recurring photo reminder for a plant
/// - Parameters:
/// - plantID: The unique identifier of the plant
/// - plantName: The name of the plant to display in the notification
/// - interval: The interval between photo reminders
/// - Throws: `NotificationError.schedulingFailed` if the notification cannot be scheduled
func schedulePhotoReminder(for plantID: UUID, plantName: String, interval: PhotoReminderInterval) async throws {
// Cancel any existing photo reminder for this plant
await cancelPhotoReminder(for: plantID)
// If interval is off, just cancel and return
if interval == .off {
return
}
// Create notification content
let content = UNMutableNotificationContent()
content.title = "Progress Photo Reminder"
content.body = "Time to capture a progress photo of \(plantName)!"
content.sound = .default
content.categoryIdentifier = NotificationConstants.progressPhotoReminderCategory
// Store plant information in userInfo
content.userInfo = [
NotificationConstants.plantIDKey: plantID.uuidString,
NotificationConstants.categoryKey: NotificationConstants.progressPhotoReminderCategory
]
// Create time interval trigger based on the reminder interval
let timeInterval: TimeInterval
switch interval {
case .weekly:
timeInterval = 7 * 24 * 60 * 60 // 7 days
case .biweekly:
timeInterval = 14 * 24 * 60 * 60 // 14 days
case .monthly:
timeInterval = 30 * 24 * 60 * 60 // 30 days
case .off:
return // Already handled above, but required for exhaustive switch
}
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeInterval, repeats: true)
// Create notification request with unique identifier
let identifier = photoReminderIdentifier(for: plantID)
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
// Schedule the notification
do {
try await notificationCenter.add(request)
} catch {
throw NotificationError.schedulingFailed(error)
}
}
/// Cancel a scheduled photo reminder for a plant
/// - Parameter plantID: The unique identifier of the plant
func cancelPhotoReminder(for plantID: UUID) async {
let identifier = photoReminderIdentifier(for: plantID)
notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier])
notificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier])
}
// MARK: - Private Methods
/// Generate a consistent notification identifier for a task
@@ -290,6 +392,13 @@ actor NotificationService: NotificationServiceProtocol {
private func notificationIdentifier(for taskID: UUID) -> String {
"\(NotificationConstants.notificationPrefix)\(taskID.uuidString)"
}
/// Generate a consistent notification identifier for a photo reminder
/// - Parameter plantID: The unique identifier of the plant
/// - Returns: A string identifier in the format "photo-{plantID}"
private func photoReminderIdentifier(for plantID: UUID) -> String {
"\(NotificationConstants.photoReminderPrefix)\(plantID.uuidString)"
}
}
// MARK: - CareTaskType Display Extension

View File

@@ -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

View File

@@ -71,6 +71,26 @@ public class PlantMO: NSManagedObject {
/// The room where this plant is located (optional, to-one)
@NSManaged public var room: RoomMO?
/// Progress photos documenting this plant's growth over time (one-to-many, cascade delete)
@NSManaged public var progressPhotos: NSSet?
}
// MARK: - Generated Accessors for Progress Photos
extension PlantMO {
@objc(addProgressPhotosObject:)
@NSManaged public func addToProgressPhotos(_ value: ProgressPhotoMO)
@objc(removeProgressPhotosObject:)
@NSManaged public func removeFromProgressPhotos(_ value: ProgressPhotoMO)
@objc(addProgressPhotos:)
@NSManaged public func addToProgressPhotos(_ values: NSSet)
@objc(removeProgressPhotos:)
@NSManaged public func removeFromProgressPhotos(_ values: NSSet)
}
// MARK: - Domain Model Conversion

View File

@@ -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")
}
}

View File

@@ -20,6 +20,7 @@
<relationship name="identifications" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="IdentificationMO" inverseName="plant" inverseEntity="IdentificationMO"/>
<relationship name="plantCareInfo" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="PlantCareInfoMO" inverseName="plant" inverseEntity="PlantCareInfoMO"/>
<relationship name="room" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="RoomMO" inverseName="plants" inverseEntity="RoomMO"/>
<relationship name="progressPhotos" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="ProgressPhotoMO" inverseName="plant" inverseEntity="ProgressPhotoMO"/>
</entity>
<entity name="IdentificationMO" representedClassName="IdentificationMO" syncable="YES">
<attribute name="confidenceScore" attributeType="Double" defaultValueString="0.0" usesScalarType="YES"/>
@@ -76,4 +77,13 @@
<attribute name="sortOrder" attributeType="Integer 32" defaultValueString="0" usesScalarType="YES"/>
<relationship name="plants" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="room" inverseEntity="PlantMO"/>
</entity>
<entity name="ProgressPhotoMO" representedClassName="ProgressPhotoMO" syncable="YES">
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
<attribute name="plantID" attributeType="UUID" usesScalarType="NO"/>
<attribute name="imageData" attributeType="Binary" allowsExternalBinaryDataStorage="YES"/>
<attribute name="thumbnailData" attributeType="Binary"/>
<attribute name="dateTaken" attributeType="Date" usesScalarType="NO"/>
<attribute name="notes" optional="YES" attributeType="String"/>
<relationship name="plant" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="progressPhotos" inverseEntity="PlantMO"/>
</entity>
</model>

View 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())
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -72,6 +72,9 @@ struct PlantDetailView: View {
// Identification info
identificationInfoSection
// Progress photos section
progressPhotosSection
}
}
.padding()
@@ -359,6 +362,64 @@ struct PlantDetailView: View {
.cornerRadius(12)
}
// MARK: - Progress Photos Section
private var progressPhotosSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Progress Photos")
.font(.headline)
Spacer()
Text("\(viewModel.progressPhotoCount) photos")
.font(.caption)
.foregroundStyle(.secondary)
}
NavigationLink {
ProgressPhotoGalleryView(
plantID: viewModel.plant.id,
plantName: viewModel.displayName
)
} label: {
HStack {
// Recent thumbnail or placeholder
if let recentThumbnail = viewModel.recentProgressPhotoThumbnail {
Image(uiImage: UIImage(data: recentThumbnail) ?? UIImage())
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 60, height: 60)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray5))
.frame(width: 60, height: 60)
.overlay {
Image(systemName: "camera")
.foregroundStyle(.secondary)
}
}
VStack(alignment: .leading) {
Text("View Gallery")
.font(.subheadline)
Text("Track your plant's growth")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
// MARK: - Private Helpers
private var identificationSourceDescription: String {

View File

@@ -55,6 +55,12 @@ final class PlantDetailViewModel {
/// Success message to display after schedule creation
private(set) var successMessage: String?
/// Number of progress photos for this plant
private(set) var progressPhotoCount: Int = 0
/// Thumbnail data for the most recent progress photo
private(set) var recentProgressPhotoThumbnail: Data?
// MARK: - Computed Properties
/// The next upcoming care tasks (up to 5)

View File

@@ -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: { }
)
}

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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)
)
}