diff --git a/PlantGuide/Core/DI/DIContainer.swift b/PlantGuide/Core/DI/DIContainer.swift index 85b3e00..30c68fe 100644 --- a/PlantGuide/Core/DI/DIContainer.swift +++ b/PlantGuide/Core/DI/DIContainer.swift @@ -410,7 +410,8 @@ final class DIContainer: DIContainerProtocol, ObservableObject { plantRepository: plantCollectionRepository, imageStorage: imageStorage, notificationService: notificationService, - careScheduleRepository: careScheduleRepository + careScheduleRepository: careScheduleRepository, + plantCareInfoRepository: plantCareInfoRepository ) } diff --git a/PlantGuide/Data/DataSources/Local/CoreData/PlantGuideModel.xcdatamodeld/PlantGuideModel.xcdatamodel/contents b/PlantGuide/Data/DataSources/Local/CoreData/PlantGuideModel.xcdatamodeld/PlantGuideModel.xcdatamodel/contents index 72d2f77..611bf72 100644 --- a/PlantGuide/Data/DataSources/Local/CoreData/PlantGuideModel.xcdatamodeld/PlantGuideModel.xcdatamodel/contents +++ b/PlantGuide/Data/DataSources/Local/CoreData/PlantGuideModel.xcdatamodeld/PlantGuideModel.xcdatamodel/contents @@ -72,9 +72,9 @@ - + - + @@ -82,9 +82,9 @@ - + - + diff --git a/PlantGuide/Data/DataSources/Remote/PlantNetAPI/PlantNetAPIService.swift b/PlantGuide/Data/DataSources/Remote/PlantNetAPI/PlantNetAPIService.swift index f47a15e..95cd2a5 100644 --- a/PlantGuide/Data/DataSources/Remote/PlantNetAPI/PlantNetAPIService.swift +++ b/PlantGuide/Data/DataSources/Remote/PlantNetAPI/PlantNetAPIService.swift @@ -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 } diff --git a/PlantGuide/Domain/UseCases/Collection/DeletePlantUseCase.swift b/PlantGuide/Domain/UseCases/Collection/DeletePlantUseCase.swift index c52e2cf..46d264d 100644 --- a/PlantGuide/Domain/UseCases/Collection/DeletePlantUseCase.swift +++ b/PlantGuide/Domain/UseCases/Collection/DeletePlantUseCase.swift @@ -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)") + } + } } diff --git a/PlantGuide/Domain/UseCases/PlantCare/FetchPlantCareUseCase.swift b/PlantGuide/Domain/UseCases/PlantCare/FetchPlantCareUseCase.swift index 794b1e8..a2e4d47 100644 --- a/PlantGuide/Domain/UseCases/PlantCare/FetchPlantCareUseCase.swift +++ b/PlantGuide/Domain/UseCases/PlantCare/FetchPlantCareUseCase.swift @@ -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 } diff --git a/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailViewModel.swift b/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailViewModel.swift index 2401ab6..6586228 100644 --- a/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailViewModel.swift +++ b/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailViewModel.swift @@ -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