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:
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user