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, plantRepository: plantCollectionRepository,
imageStorage: imageStorage, imageStorage: imageStorage,
notificationService: notificationService, notificationService: notificationService,
careScheduleRepository: careScheduleRepository careScheduleRepository: careScheduleRepository,
plantCareInfoRepository: plantCareInfoRepository
) )
} }

View File

@@ -72,9 +72,9 @@
</entity> </entity>
<entity name="PlantCareInfoMO" representedClassName="PlantCareInfoMO" syncable="YES"> <entity name="PlantCareInfoMO" representedClassName="PlantCareInfoMO" syncable="YES">
<attribute name="additionalNotes" optional="YES" attributeType="String"/> <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="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="fetchedAt" attributeType="Date" usesScalarType="NO"/>
<attribute name="growthRate" optional="YES" attributeType="String"/> <attribute name="growthRate" optional="YES" attributeType="String"/>
<attribute name="humidity" optional="YES" attributeType="String"/> <attribute name="humidity" optional="YES" attributeType="String"/>
@@ -82,9 +82,9 @@
<attribute name="lightRequirement" attributeType="String"/> <attribute name="lightRequirement" attributeType="String"/>
<attribute name="scientificName" attributeType="String"/> <attribute name="scientificName" attributeType="String"/>
<attribute name="sourceURL" optional="YES" attributeType="URI"/> <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="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"/> <relationship name="plant" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="plantCareInfo" inverseEntity="PlantMO"/>
<uniquenessConstraints> <uniquenessConstraints>
<uniquenessConstraint> <uniquenessConstraint>

View File

@@ -234,9 +234,16 @@ final class PlantNetAPIService: PlantNetAPIServiceProtocol, @unchecked Sendable
organs: [PlantOrgan], organs: [PlantOrgan],
project: PlantNetProject project: PlantNetProject
) async throws -> PlantNetIdentifyResponseDTO { ) 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 // Check rate limit before making request
if let tracker = rateLimitTracker { if let tracker = rateLimitTracker {
guard await tracker.canMakeRequest() else { guard await tracker.canMakeRequest() else {
print("[PlantNet DEBUG] ERROR: Rate limit exhausted")
logError("Rate limit exhausted") logError("Rate limit exhausted")
throw PlantNetAPIError.rateLimitExceeded throw PlantNetAPIError.rateLimitExceeded
} }
@@ -244,10 +251,15 @@ final class PlantNetAPIService: PlantNetAPIServiceProtocol, @unchecked Sendable
// Build URL with query parameters // Build URL with query parameters
guard let url = buildIdentifyURL(project: project) else { guard let url = buildIdentifyURL(project: project) else {
print("[PlantNet DEBUG] ERROR: Failed to build identify URL")
logError("Failed to build identify URL") logError("Failed to build identify URL")
throw PlantNetAPIError.invalidResponse 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 // Create multipart request
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
@@ -261,6 +273,9 @@ final class PlantNetAPIService: PlantNetAPIServiceProtocol, @unchecked Sendable
boundary: boundary 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) logRequest(request)
// Perform request // Perform request
@@ -268,34 +283,70 @@ final class PlantNetAPIService: PlantNetAPIServiceProtocol, @unchecked Sendable
let response: URLResponse let response: URLResponse
do { do {
print("[PlantNet DEBUG] Sending request...")
(data, response) = try await session.data(for: request) (data, response) = try await session.data(for: request)
print("[PlantNet DEBUG] Request completed, received \(data.count) bytes")
} catch let error as URLError { } catch let error as URLError {
print("[PlantNet DEBUG] URLError: \(error.code.rawValue) - \(error.localizedDescription)")
logError("Request failed: \(error.localizedDescription)") logError("Request failed: \(error.localizedDescription)")
if error.code == .notConnectedToInternet || error.code == .networkConnectionLost { if error.code == .notConnectedToInternet || error.code == .networkConnectionLost {
throw PlantNetAPIError.networkUnavailable throw PlantNetAPIError.networkUnavailable
} }
throw PlantNetAPIError.imageUploadFailed throw PlantNetAPIError.imageUploadFailed
} catch { } catch {
print("[PlantNet DEBUG] Request error: \(error)")
logError("Request failed: \(error.localizedDescription)") logError("Request failed: \(error.localizedDescription)")
throw PlantNetAPIError.imageUploadFailed 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 // Validate response
try await validateResponse(response, data: data) try await validateResponse(response, data: data)
logResponse(response, data: data) logResponse(response, data: data)
// Decode response // Decode response
do { do {
print("[PlantNet DEBUG] Decoding response...")
let identifyResponse = try decoder.decode(PlantNetIdentifyResponseDTO.self, from: data) 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 // Update rate limit tracker with remaining count
if let tracker = rateLimitTracker, if let tracker = rateLimitTracker,
let remaining = identifyResponse.remainingIdentificationRequests { let remaining = identifyResponse.remainingIdentificationRequests {
print("[PlantNet DEBUG] Remaining requests: \(remaining)")
await tracker.recordUsage(remaining: remaining) await tracker.recordUsage(remaining: remaining)
} }
print("[PlantNet DEBUG] ========== Identification Complete ==========")
return identifyResponse return identifyResponse
} catch { } 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)") logError("Decoding failed: \(error)")
throw PlantNetAPIError.invalidResponse throw PlantNetAPIError.invalidResponse
} }

View File

@@ -111,8 +111,9 @@ enum DeletePlantError: Error, LocalizedError {
/// 1. Validates that the plant exists /// 1. Validates that the plant exists
/// 2. Cancels all scheduled notifications for the plant /// 2. Cancels all scheduled notifications for the plant
/// 3. Deletes cached images from local storage /// 3. Deletes cached images from local storage
/// 4. Deletes the care schedule /// 4. Deletes cached plant care info
/// 5. Deletes the plant entity from the repository /// 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 /// The deletion is performed in order to ensure proper cleanup even if
/// some operations fail. The plant is deleted last to ensure all associated /// some operations fail. The plant is deleted last to ensure all associated
@@ -124,7 +125,8 @@ enum DeletePlantError: Error, LocalizedError {
/// plantRepository: plantRepository, /// plantRepository: plantRepository,
/// imageStorage: imageStorage, /// imageStorage: imageStorage,
/// notificationService: notificationService, /// notificationService: notificationService,
/// careScheduleRepository: careScheduleRepository /// careScheduleRepository: careScheduleRepository,
/// plantCareInfoRepository: plantCareInfoRepository
/// ) /// )
/// ///
/// try await useCase.execute(plantID: plant.id) /// try await useCase.execute(plantID: plant.id)
@@ -137,6 +139,7 @@ final class DeletePlantUseCase: DeletePlantUseCaseProtocol, @unchecked Sendable
private let imageStorage: ImageStorageProtocol private let imageStorage: ImageStorageProtocol
private let notificationService: NotificationServiceProtocol private let notificationService: NotificationServiceProtocol
private let careScheduleRepository: CareScheduleRepositoryProtocol private let careScheduleRepository: CareScheduleRepositoryProtocol
private let plantCareInfoRepository: PlantCareInfoRepositoryProtocol?
// MARK: - Initialization // MARK: - Initialization
@@ -147,16 +150,19 @@ final class DeletePlantUseCase: DeletePlantUseCaseProtocol, @unchecked Sendable
/// - imageStorage: Service for deleting stored plant images. /// - imageStorage: Service for deleting stored plant images.
/// - notificationService: Service for cancelling scheduled notifications. /// - notificationService: Service for cancelling scheduled notifications.
/// - careScheduleRepository: Repository for deleting care schedules. /// - careScheduleRepository: Repository for deleting care schedules.
/// - plantCareInfoRepository: Optional repository for deleting cached care info.
init( init(
plantRepository: PlantCollectionRepositoryProtocol, plantRepository: PlantCollectionRepositoryProtocol,
imageStorage: ImageStorageProtocol, imageStorage: ImageStorageProtocol,
notificationService: NotificationServiceProtocol, notificationService: NotificationServiceProtocol,
careScheduleRepository: CareScheduleRepositoryProtocol careScheduleRepository: CareScheduleRepositoryProtocol,
plantCareInfoRepository: PlantCareInfoRepositoryProtocol? = nil
) { ) {
self.plantRepository = plantRepository self.plantRepository = plantRepository
self.imageStorage = imageStorage self.imageStorage = imageStorage
self.notificationService = notificationService self.notificationService = notificationService
self.careScheduleRepository = careScheduleRepository self.careScheduleRepository = careScheduleRepository
self.plantCareInfoRepository = plantCareInfoRepository
} }
// MARK: - DeletePlantUseCaseProtocol // MARK: - DeletePlantUseCaseProtocol
@@ -175,11 +181,15 @@ final class DeletePlantUseCase: DeletePlantUseCaseProtocol, @unchecked Sendable
// Log errors but continue with deletion // Log errors but continue with deletion
await deleteImages(for: plantID) 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 // This should cascade from the repository but we explicitly delete for safety
await deleteCareSchedule(for: plantID) await deleteCareSchedule(for: plantID)
// Step 5: Delete the plant from repository // Step 6: Delete the plant from repository
do { do {
try await plantRepository.delete(id: plantID) try await plantRepository.delete(id: plantID)
} catch { } catch {
@@ -226,4 +236,20 @@ final class DeletePlantUseCase: DeletePlantUseCaseProtocol, @unchecked Sendable
print("Warning: Failed to delete care schedule for plant \(plantID): \(error.localizedDescription)") 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, /// the scientific name, retrieves detailed species information,
/// and maps it to a `PlantCareInfo` domain entity. /// and maps it to a `PlantCareInfo` domain entity.
/// ///
/// - Parameter scientificName: The scientific (botanical) name of the plant /// - Parameters:
/// (e.g., "Rosa gallica"). /// - 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. /// - Returns: A `PlantCareInfo` entity containing care requirements.
/// - Throws: `FetchPlantCareError` if the plant cannot be found or data retrieval fails. /// - 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") /// let careInfo = try await useCase.execute(scientificName: "Rosa gallica")
/// print("Light: \(careInfo.lightRequirement)") /// 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. /// 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. /// 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. /// 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. /// - Returns: A `PlantCareInfo` entity containing care requirements.
/// - Throws: `FetchPlantCareError` if the species cannot be found or data retrieval fails. /// - 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) /// let careInfo = try await useCase.execute(trefleId: 123456)
/// print("Watering: \(careInfo.wateringSchedule.frequency)") /// print("Watering: \(careInfo.wateringSchedule.frequency)")
/// ``` /// ```
func execute(trefleId: Int) async throws -> PlantCareInfo func execute(trefleId: Int, forceRefresh: Bool) async throws -> PlantCareInfo
} }
// MARK: - FetchPlantCareError // MARK: - FetchPlantCareError
@@ -118,13 +121,15 @@ enum FetchPlantCareError: Error, LocalizedError {
/// Use case for fetching plant care information from the Trefle botanical API. /// Use case for fetching plant care information from the Trefle botanical API.
/// ///
/// This use case coordinates the retrieval of plant care data by: /// This use case coordinates the retrieval of plant care data by:
/// 1. Checking local cache first for previously fetched care info /// 1. Checking local cache first for previously fetched care info (persistent, no expiration)
/// 2. Validating cache freshness (7-day expiration by default) /// 2. Searching for plants by scientific name or fetching directly by ID
/// 3. Searching for plants by scientific name or fetching directly by ID /// 3. Retrieving detailed species information including growth requirements
/// 4. Retrieving detailed species information including growth requirements /// 4. Mapping API responses to domain entities using `TrefleMapper`
/// 5. Mapping API responses to domain entities using `TrefleMapper` /// 5. Caching API responses for future use
/// 6. Caching API responses for future use ///
/// 7. Providing fallback default care information when API data is incomplete /// Cache is persistent until:
/// - The user manually refreshes via forceRefresh parameter
/// - The plant is deleted from the collection
/// ///
/// Usage: /// Usage:
/// ```swift /// ```swift
@@ -136,8 +141,8 @@ enum FetchPlantCareError: Error, LocalizedError {
/// // Fetch by scientific name (checks cache first) /// // Fetch by scientific name (checks cache first)
/// let careInfo = try await useCase.execute(scientificName: "Rosa gallica") /// let careInfo = try await useCase.execute(scientificName: "Rosa gallica")
/// ///
/// // Or fetch by Trefle ID /// // Force refresh from API (bypasses cache)
/// let careInfo = try await useCase.execute(trefleId: 123456) /// let freshCareInfo = try await useCase.execute(scientificName: "Rosa gallica", forceRefresh: true)
/// ``` /// ```
final class FetchPlantCareUseCase: FetchPlantCareUseCaseProtocol, @unchecked Sendable { final class FetchPlantCareUseCase: FetchPlantCareUseCaseProtocol, @unchecked Sendable {
@@ -146,11 +151,6 @@ final class FetchPlantCareUseCase: FetchPlantCareUseCaseProtocol, @unchecked Sen
private let trefleAPIService: TrefleAPIServiceProtocol private let trefleAPIService: TrefleAPIServiceProtocol
private let cacheRepository: PlantCareInfoRepositoryProtocol? private let cacheRepository: PlantCareInfoRepositoryProtocol?
// MARK: - Configuration
/// Cache expiration duration (7 days in seconds)
private let cacheExpiration: TimeInterval = 7 * 24 * 60 * 60
// MARK: - Initialization // MARK: - Initialization
/// Creates a new FetchPlantCareUseCase instance. /// Creates a new FetchPlantCareUseCase instance.
@@ -168,42 +168,54 @@ final class FetchPlantCareUseCase: FetchPlantCareUseCaseProtocol, @unchecked Sen
// MARK: - FetchPlantCareUseCaseProtocol // MARK: - FetchPlantCareUseCaseProtocol
func execute(scientificName: String) async throws -> PlantCareInfo { func execute(scientificName: String, forceRefresh: Bool = false) async throws -> PlantCareInfo {
// 1. Check cache first // 1. Check cache first (unless force refresh is requested)
if let cached = try? await fetchFromCache(scientificName: scientificName) { if !forceRefresh, let cached = try? await fetchFromCache(scientificName: scientificName) {
return cached return cached
} }
// 2. Fetch from API // 2. Fetch from API
let careInfo = try await fetchFromAPI(scientificName: scientificName) 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 { Task {
try? await cacheRepository?.save(careInfo, for: nil) try? await cacheRepository?.save(careInfoForCache, for: nil)
} }
return careInfo 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. /// - 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? { private func fetchFromCache(scientificName: String) async throws -> PlantCareInfo? {
guard let repository = cacheRepository else { guard let repository = cacheRepository else {
return nil 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) return try await repository.fetch(scientificName: scientificName)
} }
@@ -245,9 +257,9 @@ final class FetchPlantCareUseCase: FetchPlantCareUseCaseProtocol, @unchecked Sen
} }
} }
func execute(trefleId: Int) async throws -> PlantCareInfo { func execute(trefleId: Int, forceRefresh: Bool = false) async throws -> PlantCareInfo {
// 1. Check cache first // 1. Check cache first (unless force refresh is requested)
if let cached = try? await cacheRepository?.fetch(trefleID: trefleId) { if !forceRefresh, let cached = try? await cacheRepository?.fetch(trefleID: trefleId) {
return cached return cached
} }

View File

@@ -108,14 +108,19 @@ final class PlantDetailViewModel {
/// ///
/// This method fetches care details from the Trefle botanical API /// This method fetches care details from the Trefle botanical API
/// and checks for existing care schedules. /// 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 isLoading = true
error = nil error = nil
successMessage = nil successMessage = nil
do { do {
// Fetch care info from API // Fetch care info (from cache or API based on forceRefresh)
let info = try await fetchPlantCareUseCase.execute(scientificName: plant.scientificName) let info = try await fetchPlantCareUseCase.execute(
scientificName: plant.scientificName,
forceRefresh: forceRefresh
)
careInfo = info careInfo = info
// Check for existing schedule // Check for existing schedule
@@ -289,9 +294,11 @@ final class PlantDetailViewModel {
successMessage = nil 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 { func refresh() async {
await loadCareInfo() await loadCareInfo(forceRefresh: true)
} }
/// Requests notification permission from the user /// Requests notification permission from the user