Add PlantGuide iOS app with plant identification and care management

- Implement camera capture and plant identification workflow
- Add Core Data persistence for plants, care schedules, and cached API data
- Create collection view with grid/list layouts and filtering
- Build plant detail views with care information display
- Integrate Trefle botanical API for plant care data
- Add local image storage for captured plant photos
- Implement dependency injection container for testability
- Include accessibility support throughout the app

Bug fixes in this commit:
- Fix Trefle API decoding by removing duplicate CodingKeys
- Fix LocalCachedImage to load from correct PlantImages directory
- Set dateAdded when saving plants for proper collection sorting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-23 12:18:01 -06:00
parent d3ab29eb84
commit 136dfbae33
187 changed files with 69001 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
//
// MockNotificationService.swift
// PlantGuideTests
//
// Mock implementation of NotificationServiceProtocol for unit testing.
// Provides configurable behavior and call tracking for verification.
//
import Foundation
import UserNotifications
@testable import PlantGuide
// MARK: - MockNotificationService
/// Mock implementation of NotificationServiceProtocol for testing
final actor MockNotificationService: NotificationServiceProtocol {
// MARK: - Storage
private var scheduledReminders: [UUID: (task: CareTask, plantName: String, plantID: UUID)] = [:]
private var pendingNotifications: [UNNotificationRequest] = []
// MARK: - Call Tracking
private(set) var requestAuthorizationCallCount = 0
private(set) var scheduleReminderCallCount = 0
private(set) var cancelReminderCallCount = 0
private(set) var cancelAllRemindersCallCount = 0
private(set) var updateBadgeCountCallCount = 0
private(set) var getPendingNotificationsCallCount = 0
private(set) var removeAllDeliveredNotificationsCallCount = 0
// MARK: - Error Configuration
var shouldThrowOnRequestAuthorization = false
var shouldThrowOnScheduleReminder = false
var errorToThrow: Error = NotificationError.schedulingFailed(
NSError(domain: "MockError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Mock notification error"])
)
// MARK: - Return Value Configuration
var authorizationGranted = true
// MARK: - Captured Values
private(set) var lastScheduledTask: CareTask?
private(set) var lastScheduledPlantName: String?
private(set) var lastScheduledPlantID: UUID?
private(set) var lastCancelledTaskID: UUID?
private(set) var lastCancelledAllPlantID: UUID?
private(set) var lastBadgeCount: Int?
// MARK: - NotificationServiceProtocol
func requestAuthorization() async throws -> Bool {
requestAuthorizationCallCount += 1
if shouldThrowOnRequestAuthorization {
throw errorToThrow
}
if !authorizationGranted {
throw NotificationError.permissionDenied
}
return authorizationGranted
}
func scheduleReminder(for task: CareTask, plantName: String, plantID: UUID) async throws {
scheduleReminderCallCount += 1
lastScheduledTask = task
lastScheduledPlantName = plantName
lastScheduledPlantID = plantID
if shouldThrowOnScheduleReminder {
throw errorToThrow
}
// Validate that the scheduled date is in the future
guard task.scheduledDate > Date() else {
throw NotificationError.invalidTriggerDate
}
scheduledReminders[task.id] = (task, plantName, plantID)
}
func cancelReminder(for taskID: UUID) async {
cancelReminderCallCount += 1
lastCancelledTaskID = taskID
scheduledReminders.removeValue(forKey: taskID)
}
func cancelAllReminders(for plantID: UUID) async {
cancelAllRemindersCallCount += 1
lastCancelledAllPlantID = plantID
// Remove all reminders for this plant
let keysToRemove = scheduledReminders.filter { $0.value.plantID == plantID }.map { $0.key }
for key in keysToRemove {
scheduledReminders.removeValue(forKey: key)
}
}
func updateBadgeCount(_ count: Int) async {
updateBadgeCountCallCount += 1
lastBadgeCount = count
}
func getPendingNotifications() async -> [UNNotificationRequest] {
getPendingNotificationsCallCount += 1
return pendingNotifications
}
func removeAllDeliveredNotifications() async {
removeAllDeliveredNotificationsCallCount += 1
}
// MARK: - Helper Methods
/// Resets all state for clean test setup
func reset() {
scheduledReminders = [:]
pendingNotifications = []
requestAuthorizationCallCount = 0
scheduleReminderCallCount = 0
cancelReminderCallCount = 0
cancelAllRemindersCallCount = 0
updateBadgeCountCallCount = 0
getPendingNotificationsCallCount = 0
removeAllDeliveredNotificationsCallCount = 0
shouldThrowOnRequestAuthorization = false
shouldThrowOnScheduleReminder = false
authorizationGranted = true
lastScheduledTask = nil
lastScheduledPlantName = nil
lastScheduledPlantID = nil
lastCancelledTaskID = nil
lastCancelledAllPlantID = nil
lastBadgeCount = nil
}
/// Gets the count of scheduled reminders
var scheduledReminderCount: Int {
scheduledReminders.count
}
/// Gets scheduled reminders for a specific plant
func reminders(for plantID: UUID) -> [CareTask] {
scheduledReminders.values
.filter { $0.plantID == plantID }
.map { $0.task }
}
/// Checks if a reminder is scheduled for a task
func hasReminder(for taskID: UUID) -> Bool {
scheduledReminders[taskID] != nil
}
/// Gets all scheduled task IDs
var scheduledTaskIDs: [UUID] {
Array(scheduledReminders.keys)
}
/// Adds a pending notification for testing getPendingNotifications
func addPendingNotification(_ request: UNNotificationRequest) {
pendingNotifications.append(request)
}
}