Fix plant care caching and add PlantNet API debug logging

- Fix Core Data transformer conflict causing cache decode failures
  - Changed PlantCareInfoMO attributes from Transformable to Binary type
  - Removed valueTransformerName references that conflicted with manual JSON encoding

- Fix cache key mismatch causing cache misses
  - Normalize scientificName to lookup name when saving to cache

- Remove cache expiration (cache now persistent until plant deleted)
  - Removed cacheExpiration property and isCacheStale check
  - Added forceRefresh parameter to FetchPlantCareUseCase for manual refresh

- Add cache cleanup when deleting plants
  - Added PlantCareInfoRepositoryProtocol dependency to DeletePlantUseCase
  - Clean up cached care info when plant is deleted

- Add extensive debug logging to PlantNetAPIService
  - Log request/response details, status codes, and decoding errors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-23 13:13:48 -06:00
parent 136dfbae33
commit eb1d61a746
6 changed files with 152 additions and 55 deletions

View File

@@ -410,7 +410,8 @@ final class DIContainer: DIContainerProtocol, ObservableObject {
plantRepository: plantCollectionRepository,
imageStorage: imageStorage,
notificationService: notificationService,
careScheduleRepository: careScheduleRepository
careScheduleRepository: careScheduleRepository,
plantCareInfoRepository: plantCareInfoRepository
)
}

View File

@@ -72,9 +72,9 @@
</entity>
<entity name="PlantCareInfoMO" representedClassName="PlantCareInfoMO" syncable="YES">
<attribute name="additionalNotes" optional="YES" attributeType="String"/>
<attribute name="bloomingSeasonData" optional="YES" attributeType="Transformable" valueTransformerName="SeasonArrayTransformer"/>
<attribute name="bloomingSeasonData" optional="YES" attributeType="Binary"/>
<attribute name="commonName" optional="YES" attributeType="String"/>
<attribute name="fertilizerScheduleData" optional="YES" attributeType="Transformable" valueTransformerName="FertilizerScheduleTransformer"/>
<attribute name="fertilizerScheduleData" optional="YES" attributeType="Binary"/>
<attribute name="fetchedAt" attributeType="Date" usesScalarType="NO"/>
<attribute name="growthRate" optional="YES" attributeType="String"/>
<attribute name="humidity" optional="YES" attributeType="String"/>
@@ -82,9 +82,9 @@
<attribute name="lightRequirement" attributeType="String"/>
<attribute name="scientificName" attributeType="String"/>
<attribute name="sourceURL" optional="YES" attributeType="URI"/>
<attribute name="temperatureRangeData" attributeType="Transformable" valueTransformerName="TemperatureRangeTransformer"/>
<attribute name="temperatureRangeData" attributeType="Binary"/>
<attribute name="trefleID" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarType="YES"/>
<attribute name="wateringScheduleData" attributeType="Transformable" valueTransformerName="WateringScheduleTransformer"/>
<attribute name="wateringScheduleData" attributeType="Binary"/>
<relationship name="plant" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="plantCareInfo" inverseEntity="PlantMO"/>
<uniquenessConstraints>
<uniquenessConstraint>

View File

@@ -234,9 +234,16 @@ final class PlantNetAPIService: PlantNetAPIServiceProtocol, @unchecked Sendable
organs: [PlantOrgan],
project: PlantNetProject
) async throws -> PlantNetIdentifyResponseDTO {
print("[PlantNet DEBUG] ========== Starting Identification ==========")
print("[PlantNet DEBUG] Image data size: \(imageData.count) bytes")
print("[PlantNet DEBUG] Organs: \(organs.map { $0.rawValue })")
print("[PlantNet DEBUG] Project: \(project.rawValue)")
print("[PlantNet DEBUG] API Key present: \(!apiKey.isEmpty), length: \(apiKey.count)")
// Check rate limit before making request
if let tracker = rateLimitTracker {
guard await tracker.canMakeRequest() else {
print("[PlantNet DEBUG] ERROR: Rate limit exhausted")
logError("Rate limit exhausted")
throw PlantNetAPIError.rateLimitExceeded
}
@@ -244,10 +251,15 @@ final class PlantNetAPIService: PlantNetAPIServiceProtocol, @unchecked Sendable
// Build URL with query parameters
guard let url = buildIdentifyURL(project: project) else {
print("[PlantNet DEBUG] ERROR: Failed to build identify URL")
logError("Failed to build identify URL")
throw PlantNetAPIError.invalidResponse
}
// Log URL without API key for security
let sanitizedURL = url.absoluteString.replacingOccurrences(of: apiKey, with: "***API_KEY***")
print("[PlantNet DEBUG] Request URL: \(sanitizedURL)")
// Create multipart request
var request = URLRequest(url: url)
request.httpMethod = "POST"
@@ -261,6 +273,9 @@ final class PlantNetAPIService: PlantNetAPIServiceProtocol, @unchecked Sendable
boundary: boundary
)
print("[PlantNet DEBUG] Request body size: \(request.httpBody?.count ?? 0) bytes")
print("[PlantNet DEBUG] Content-Type: \(request.value(forHTTPHeaderField: "Content-Type") ?? "nil")")
logRequest(request)
// Perform request
@@ -268,34 +283,70 @@ final class PlantNetAPIService: PlantNetAPIServiceProtocol, @unchecked Sendable
let response: URLResponse
do {
print("[PlantNet DEBUG] Sending request...")
(data, response) = try await session.data(for: request)
print("[PlantNet DEBUG] Request completed, received \(data.count) bytes")
} catch let error as URLError {
print("[PlantNet DEBUG] URLError: \(error.code.rawValue) - \(error.localizedDescription)")
logError("Request failed: \(error.localizedDescription)")
if error.code == .notConnectedToInternet || error.code == .networkConnectionLost {
throw PlantNetAPIError.networkUnavailable
}
throw PlantNetAPIError.imageUploadFailed
} catch {
print("[PlantNet DEBUG] Request error: \(error)")
logError("Request failed: \(error.localizedDescription)")
throw PlantNetAPIError.imageUploadFailed
}
// Log response details
if let httpResponse = response as? HTTPURLResponse {
print("[PlantNet DEBUG] Response status code: \(httpResponse.statusCode)")
print("[PlantNet DEBUG] Response headers: \(httpResponse.allHeaderFields)")
}
// Always log response body for debugging
if let responseBody = String(data: data, encoding: .utf8) {
print("[PlantNet DEBUG] Response body: \(responseBody)")
} else {
print("[PlantNet DEBUG] Response body: (non-UTF8 data, \(data.count) bytes)")
}
// Validate response
try await validateResponse(response, data: data)
logResponse(response, data: data)
// Decode response
do {
print("[PlantNet DEBUG] Decoding response...")
let identifyResponse = try decoder.decode(PlantNetIdentifyResponseDTO.self, from: data)
print("[PlantNet DEBUG] Decoded successfully! Results count: \(identifyResponse.results.count)")
// Update rate limit tracker with remaining count
if let tracker = rateLimitTracker,
let remaining = identifyResponse.remainingIdentificationRequests {
print("[PlantNet DEBUG] Remaining requests: \(remaining)")
await tracker.recordUsage(remaining: remaining)
}
print("[PlantNet DEBUG] ========== Identification Complete ==========")
return identifyResponse
} catch {
print("[PlantNet DEBUG] Decoding failed: \(error)")
if let decodingError = error as? DecodingError {
switch decodingError {
case .keyNotFound(let key, let context):
print("[PlantNet DEBUG] Key not found: \(key.stringValue), path: \(context.codingPath)")
case .typeMismatch(let type, let context):
print("[PlantNet DEBUG] Type mismatch: expected \(type), path: \(context.codingPath)")
case .valueNotFound(let type, let context):
print("[PlantNet DEBUG] Value not found: \(type), path: \(context.codingPath)")
case .dataCorrupted(let context):
print("[PlantNet DEBUG] Data corrupted: \(context.debugDescription)")
@unknown default:
print("[PlantNet DEBUG] Unknown decoding error")
}
}
logError("Decoding failed: \(error)")
throw PlantNetAPIError.invalidResponse
}

View File

@@ -111,8 +111,9 @@ enum DeletePlantError: Error, LocalizedError {
/// 1. Validates that the plant exists
/// 2. Cancels all scheduled notifications for the plant
/// 3. Deletes cached images from local storage
/// 4. Deletes the care schedule
/// 5. Deletes the plant entity from the repository
/// 4. Deletes cached plant care info
/// 5. Deletes the care schedule
/// 6. Deletes the plant entity from the repository
///
/// The deletion is performed in order to ensure proper cleanup even if
/// some operations fail. The plant is deleted last to ensure all associated
@@ -124,7 +125,8 @@ enum DeletePlantError: Error, LocalizedError {
/// plantRepository: plantRepository,
/// imageStorage: imageStorage,
/// notificationService: notificationService,
/// careScheduleRepository: careScheduleRepository
/// careScheduleRepository: careScheduleRepository,
/// plantCareInfoRepository: plantCareInfoRepository
/// )
///
/// try await useCase.execute(plantID: plant.id)
@@ -137,6 +139,7 @@ final class DeletePlantUseCase: DeletePlantUseCaseProtocol, @unchecked Sendable
private let imageStorage: ImageStorageProtocol
private let notificationService: NotificationServiceProtocol
private let careScheduleRepository: CareScheduleRepositoryProtocol
private let plantCareInfoRepository: PlantCareInfoRepositoryProtocol?
// MARK: - Initialization
@@ -147,16 +150,19 @@ final class DeletePlantUseCase: DeletePlantUseCaseProtocol, @unchecked Sendable
/// - imageStorage: Service for deleting stored plant images.
/// - notificationService: Service for cancelling scheduled notifications.
/// - careScheduleRepository: Repository for deleting care schedules.
/// - plantCareInfoRepository: Optional repository for deleting cached care info.
init(
plantRepository: PlantCollectionRepositoryProtocol,
imageStorage: ImageStorageProtocol,
notificationService: NotificationServiceProtocol,
careScheduleRepository: CareScheduleRepositoryProtocol
careScheduleRepository: CareScheduleRepositoryProtocol,
plantCareInfoRepository: PlantCareInfoRepositoryProtocol? = nil
) {
self.plantRepository = plantRepository
self.imageStorage = imageStorage
self.notificationService = notificationService
self.careScheduleRepository = careScheduleRepository
self.plantCareInfoRepository = plantCareInfoRepository
}
// MARK: - DeletePlantUseCaseProtocol
@@ -175,11 +181,15 @@ final class DeletePlantUseCase: DeletePlantUseCaseProtocol, @unchecked Sendable
// Log errors but continue with deletion
await deleteImages(for: plantID)
// Step 4: Delete care schedule
// Step 4: Delete cached plant care info
// Log errors but continue with deletion
await deleteCachedCareInfo(for: plantID)
// Step 5: Delete care schedule
// This should cascade from the repository but we explicitly delete for safety
await deleteCareSchedule(for: plantID)
// Step 5: Delete the plant from repository
// Step 6: Delete the plant from repository
do {
try await plantRepository.delete(id: plantID)
} catch {
@@ -226,4 +236,20 @@ final class DeletePlantUseCase: DeletePlantUseCaseProtocol, @unchecked Sendable
print("Warning: Failed to delete care schedule for plant \(plantID): \(error.localizedDescription)")
}
}
/// Deletes cached plant care info for a plant.
///
/// Errors are logged but do not prevent plant deletion.
///
/// - Parameter plantID: The unique identifier of the plant.
private func deleteCachedCareInfo(for plantID: UUID) async {
guard let repository = plantCareInfoRepository else { return }
do {
try await repository.delete(for: plantID)
} catch {
// Log the error but don't prevent deletion
print("Warning: Failed to delete cached care info for plant \(plantID): \(error.localizedDescription)")
}
}
}

View File

@@ -21,8 +21,9 @@ protocol FetchPlantCareUseCaseProtocol: Sendable {
/// the scientific name, retrieves detailed species information,
/// and maps it to a `PlantCareInfo` domain entity.
///
/// - Parameter scientificName: The scientific (botanical) name of the plant
/// (e.g., "Rosa gallica").
/// - Parameters:
/// - scientificName: The scientific (botanical) name of the plant (e.g., "Rosa gallica").
/// - forceRefresh: If true, bypasses the cache and fetches fresh data from the API.
/// - Returns: A `PlantCareInfo` entity containing care requirements.
/// - Throws: `FetchPlantCareError` if the plant cannot be found or data retrieval fails.
///
@@ -31,7 +32,7 @@ protocol FetchPlantCareUseCaseProtocol: Sendable {
/// let careInfo = try await useCase.execute(scientificName: "Rosa gallica")
/// print("Light: \(careInfo.lightRequirement)")
/// ```
func execute(scientificName: String) async throws -> PlantCareInfo
func execute(scientificName: String, forceRefresh: Bool) async throws -> PlantCareInfo
/// Fetches care information for a plant by its Trefle species ID.
///
@@ -39,7 +40,9 @@ protocol FetchPlantCareUseCaseProtocol: Sendable {
/// the numeric Trefle ID and maps it to a `PlantCareInfo` domain entity.
/// This is more efficient than searching by name when the ID is already known.
///
/// - Parameter trefleId: The numeric identifier for the species in the Trefle database.
/// - Parameters:
/// - trefleId: The numeric identifier for the species in the Trefle database.
/// - forceRefresh: If true, bypasses the cache and fetches fresh data from the API.
/// - Returns: A `PlantCareInfo` entity containing care requirements.
/// - Throws: `FetchPlantCareError` if the species cannot be found or data retrieval fails.
///
@@ -48,7 +51,7 @@ protocol FetchPlantCareUseCaseProtocol: Sendable {
/// let careInfo = try await useCase.execute(trefleId: 123456)
/// print("Watering: \(careInfo.wateringSchedule.frequency)")
/// ```
func execute(trefleId: Int) async throws -> PlantCareInfo
func execute(trefleId: Int, forceRefresh: Bool) async throws -> PlantCareInfo
}
// MARK: - FetchPlantCareError
@@ -118,13 +121,15 @@ enum FetchPlantCareError: Error, LocalizedError {
/// Use case for fetching plant care information from the Trefle botanical API.
///
/// This use case coordinates the retrieval of plant care data by:
/// 1. Checking local cache first for previously fetched care info
/// 2. Validating cache freshness (7-day expiration by default)
/// 3. Searching for plants by scientific name or fetching directly by ID
/// 4. Retrieving detailed species information including growth requirements
/// 5. Mapping API responses to domain entities using `TrefleMapper`
/// 6. Caching API responses for future use
/// 7. Providing fallback default care information when API data is incomplete
/// 1. Checking local cache first for previously fetched care info (persistent, no expiration)
/// 2. Searching for plants by scientific name or fetching directly by ID
/// 3. Retrieving detailed species information including growth requirements
/// 4. Mapping API responses to domain entities using `TrefleMapper`
/// 5. Caching API responses for future use
///
/// Cache is persistent until:
/// - The user manually refreshes via forceRefresh parameter
/// - The plant is deleted from the collection
///
/// Usage:
/// ```swift
@@ -136,8 +141,8 @@ enum FetchPlantCareError: Error, LocalizedError {
/// // Fetch by scientific name (checks cache first)
/// let careInfo = try await useCase.execute(scientificName: "Rosa gallica")
///
/// // Or fetch by Trefle ID
/// let careInfo = try await useCase.execute(trefleId: 123456)
/// // Force refresh from API (bypasses cache)
/// let freshCareInfo = try await useCase.execute(scientificName: "Rosa gallica", forceRefresh: true)
/// ```
final class FetchPlantCareUseCase: FetchPlantCareUseCaseProtocol, @unchecked Sendable {
@@ -146,11 +151,6 @@ final class FetchPlantCareUseCase: FetchPlantCareUseCaseProtocol, @unchecked Sen
private let trefleAPIService: TrefleAPIServiceProtocol
private let cacheRepository: PlantCareInfoRepositoryProtocol?
// MARK: - Configuration
/// Cache expiration duration (7 days in seconds)
private let cacheExpiration: TimeInterval = 7 * 24 * 60 * 60
// MARK: - Initialization
/// Creates a new FetchPlantCareUseCase instance.
@@ -168,42 +168,54 @@ final class FetchPlantCareUseCase: FetchPlantCareUseCaseProtocol, @unchecked Sen
// MARK: - FetchPlantCareUseCaseProtocol
func execute(scientificName: String) async throws -> PlantCareInfo {
// 1. Check cache first
if let cached = try? await fetchFromCache(scientificName: scientificName) {
func execute(scientificName: String, forceRefresh: Bool = false) async throws -> PlantCareInfo {
// 1. Check cache first (unless force refresh is requested)
if !forceRefresh, let cached = try? await fetchFromCache(scientificName: scientificName) {
return cached
}
// 2. Fetch from API
let careInfo = try await fetchFromAPI(scientificName: scientificName)
// 3. Cache the result (fire and forget, don't block on cache errors)
// 3. Cache the result with the lookup name as the key
// The API may return a different scientific name (e.g., with author suffix),
// so we normalize to the lookup name to ensure cache hits on subsequent lookups.
let careInfoForCache = PlantCareInfo(
id: careInfo.id,
scientificName: scientificName, // Use lookup name as cache key
commonName: careInfo.commonName,
lightRequirement: careInfo.lightRequirement,
wateringSchedule: careInfo.wateringSchedule,
temperatureRange: careInfo.temperatureRange,
fertilizerSchedule: careInfo.fertilizerSchedule,
humidity: careInfo.humidity,
growthRate: careInfo.growthRate,
bloomingSeason: careInfo.bloomingSeason,
additionalNotes: careInfo.additionalNotes,
sourceURL: careInfo.sourceURL,
trefleID: careInfo.trefleID
)
// Cache the result (fire and forget, don't block on cache errors)
Task {
try? await cacheRepository?.save(careInfo, for: nil)
try? await cacheRepository?.save(careInfoForCache, for: nil)
}
return careInfo
}
/// Fetches care info from cache if it exists and is not stale.
/// Fetches care info from cache if it exists.
///
/// Cache is persistent (no expiration) - it persists until the plant is deleted
/// or the user manually refreshes.
///
/// - Parameter scientificName: The scientific name of the plant.
/// - Returns: Cached PlantCareInfo if valid, nil otherwise.
/// - Returns: Cached PlantCareInfo if available, nil otherwise.
private func fetchFromCache(scientificName: String) async throws -> PlantCareInfo? {
guard let repository = cacheRepository else {
return nil
}
// Check if cache is stale
let isStale = try await repository.isCacheStale(
scientificName: scientificName,
cacheExpiration: cacheExpiration
)
if isStale {
return nil
}
return try await repository.fetch(scientificName: scientificName)
}
@@ -245,9 +257,9 @@ final class FetchPlantCareUseCase: FetchPlantCareUseCaseProtocol, @unchecked Sen
}
}
func execute(trefleId: Int) async throws -> PlantCareInfo {
// 1. Check cache first
if let cached = try? await cacheRepository?.fetch(trefleID: trefleId) {
func execute(trefleId: Int, forceRefresh: Bool = false) async throws -> PlantCareInfo {
// 1. Check cache first (unless force refresh is requested)
if !forceRefresh, let cached = try? await cacheRepository?.fetch(trefleID: trefleId) {
return cached
}

View File

@@ -108,14 +108,19 @@ final class PlantDetailViewModel {
///
/// This method fetches care details from the Trefle botanical API
/// and checks for existing care schedules.
func loadCareInfo() async {
///
/// - Parameter forceRefresh: If true, bypasses the cache and fetches fresh data from the API.
func loadCareInfo(forceRefresh: Bool = false) async {
isLoading = true
error = nil
successMessage = nil
do {
// Fetch care info from API
let info = try await fetchPlantCareUseCase.execute(scientificName: plant.scientificName)
// Fetch care info (from cache or API based on forceRefresh)
let info = try await fetchPlantCareUseCase.execute(
scientificName: plant.scientificName,
forceRefresh: forceRefresh
)
careInfo = info
// Check for existing schedule
@@ -289,9 +294,11 @@ final class PlantDetailViewModel {
successMessage = nil
}
/// Refreshes all plant data
/// Refreshes all plant data by fetching fresh care info from the API
///
/// This bypasses the cache and fetches updated data from the Trefle API.
func refresh() async {
await loadCareInfo()
await loadCareInfo(forceRefresh: true)
}
/// Requests notification permission from the user