# Phase 3: PlantNet API Integration **Goal:** Hybrid identification with API fallback for improved accuracy **Prerequisites:** Phase 2 complete (on-device ML working, identification flow functional) --- ## Tasks ### 3.1 Register for PlantNet API Access ✅ - [x] Navigate to [my.plantnet.org](https://my.plantnet.org) - [x] Create developer account - [x] Generate API key - [x] Review API documentation and rate limits - [x] Create `App/Configuration/APIKeys.swift`: ```swift enum APIKeys { static let plantNetAPIKey: String = { // Load from environment or secure storage guard let key = Bundle.main.object(forInfoDictionaryKey: "PLANTNET_API_KEY") as? String else { fatalError("PlantNet API key not configured") } return key }() } ``` - [x] Add `PLANTNET_API_KEY` to Info.plist (via xcconfig for security) - [x] Create `.xcconfig` file for API keys (add to .gitignore) - [ ] Document API key setup in project README **Acceptance Criteria:** API key configured and accessible in code, not committed to git ✅ --- ### 3.2 Create PlantNet Endpoints ✅ - [x] Create `Data/DataSources/Remote/PlantNetAPI/PlantNetEndpoints.swift` - [ ] Define endpoint configuration: ```swift enum PlantNetEndpoint: Endpoint { case identify(project: String, imageData: Data, organs: [String]) var baseURL: URL { URL(string: "https://my-api.plantnet.org")! } var path: String { "/v2/identify/\(project)" } var method: HTTPMethod { .post } var headers: [String: String] { ["Api-Key": APIKeys.plantNetAPIKey] } } ``` - [x] Support multiple project types: - `all` - All flora - `weurope` - Western Europe - `canada` - Canada - `useful` - Useful plants - [x] Define organ types: `leaf`, `flower`, `fruit`, `bark`, `auto` - [x] Create query parameter builder for organs **Acceptance Criteria:** Endpoint builds correct URL with headers and query params ✅ --- ### 3.3 Implement PlantNet API Service ✅ - [x] Create `Data/DataSources/Remote/PlantNetAPI/PlantNetAPIService.swift` - [ ] Define protocol: ```swift protocol PlantNetAPIServiceProtocol: Sendable { func identify( imageData: Data, organs: [PlantOrgan], project: PlantNetProject ) async throws -> PlantNetIdentifyResponseDTO } ``` - [x] Implement multipart form-data upload: - Build multipart boundary - Add image data with correct MIME type (image/jpeg) - Add organs parameter - Set Content-Type header with boundary - [x] Handle response parsing - [ ] Implement retry logic with exponential backoff (1 retry) - [x] Add request timeout (30 seconds) - [x] Log request/response for debugging **Acceptance Criteria:** Service can upload image and receive valid response from API ✅ --- ### 3.4 Create PlantNet DTOs ✅ - [x] Create `Data/DataSources/Remote/PlantNetAPI/DTOs/PlantNetDTOs.swift`: ```swift struct PlantNetIdentifyResponseDTO: Decodable { let query: QueryDTO let language: String let preferedReferential: String let results: [PlantNetResultDTO] let version: String let remainingIdentificationRequests: Int } ``` - [x] Create `PlantNetResultDTO` (consolidated in PlantNetDTOs.swift) - [x] Create `PlantNetSpeciesDTO` (consolidated in PlantNetDTOs.swift) - [x] Create supporting DTOs: `PlantNetGenusDTO`, `PlantNetFamilyDTO`, `PlantNetGBIFDataDTO`, `PlantNetQueryDTO` - [x] Add CodingKeys where API uses different naming conventions - [ ] Write unit tests for DTO decoding with sample JSON **Acceptance Criteria:** DTOs decode actual PlantNet API response without errors ✅ --- ### 3.5 Build PlantNet Mapper ✅ - [x] Create `Data/Mappers/PlantNetMapper.swift` - [x] Implement mapping functions: ```swift struct PlantNetMapper { static func mapToIdentification( from response: PlantNetIdentifyResponseDTO, imageData: Data ) -> PlantIdentification static func mapToPredictions( from results: [PlantNetResultDTO] ) -> [PlantPrediction] } ``` - [x] Map API confidence scores (0.0-1.0) to percentage - [x] Handle missing optional fields gracefully - [x] Map common names (may be empty array) - [x] Set identification source to `.plantNetAPI` - [x] Include remaining API requests in metadata **Acceptance Criteria:** Mapper produces valid domain entities from all DTO variations ✅ --- ### 3.6 Implement Online Identification Use Case ✅ - [x] Create `Domain/UseCases/Identification/IdentifyPlantOnlineUseCase.swift` - [x] Define protocol: ```swift protocol IdentifyPlantOnlineUseCaseProtocol: Sendable { func execute( image: UIImage, organs: [PlantOrgan], project: PlantNetProject ) async throws -> PlantIdentification } ``` - [x] Implement use case: - Convert UIImage to JPEG data (quality: 0.8) - Validate image size (max 2MB, resize if needed) - Call PlantNet API service - Map response to domain entity - Handle specific API errors - [x] Add to DIContainer - [ ] Create unit test with mocked API service **Acceptance Criteria:** Use case returns identification from API, handles errors gracefully ✅ --- ### 3.7 Create Hybrid Identification Use Case ✅ - [x] Create `Domain/UseCases/Identification/HybridIdentificationUseCase.swift` - [x] Define protocol: ```swift protocol HybridIdentificationUseCaseProtocol: Sendable { func execute( image: UIImage, strategy: HybridStrategy ) async throws -> HybridIdentificationResult } ``` - [ ] Define `HybridStrategy` enum: ```swift enum HybridStrategy { case onDeviceOnly case apiOnly case onDeviceFirst(apiThreshold: Float) // Use API if confidence below threshold case parallel // Run both, prefer API results } ``` - [ ] Define `HybridIdentificationResult`: ```swift struct HybridIdentificationResult: Sendable { let onDeviceResult: PlantIdentification? let apiResult: PlantIdentification? let preferredResult: PlantIdentification let source: IdentificationSource let processingTime: TimeInterval } ``` - [x] Implement strategies: - `onDeviceFirst`: Run on-device, call API if top confidence < threshold (default 70%) - `parallel`: Run both concurrently, merge results - [x] Handle offline gracefully (fall back to on-device only) - [ ] Track timing for analytics - [x] Add to DIContainer **Acceptance Criteria:** Hybrid use case correctly implements all strategies ✅ --- ### 3.8 Add Network Reachability Monitoring ✅ - [x] Create `Core/Utilities/NetworkMonitor.swift` - [x] Implement using `NWPathMonitor`: ```swift @Observable final class NetworkMonitor: Sendable { private(set) var isConnected: Bool = true private(set) var connectionType: ConnectionType = .unknown enum ConnectionType: Sendable { case wifi, cellular, ethernet, unknown } } ``` - [x] Start monitoring on app launch - [x] Publish connectivity changes - [x] Create SwiftUI environment key for injection - [ ] Update IdentificationViewModel to check connectivity - [ ] Show offline indicator in UI when disconnected **Acceptance Criteria:** App detects network changes and updates UI accordingly ✅ --- ### 3.9 Handle API Rate Limiting ✅ - [x] Create `Data/DataSources/Remote/PlantNetAPI/RateLimitTracker.swift` - [x] Track remaining requests from API response header - [x] Persist daily count to UserDefaults: ```swift actor RateLimitTracker { private(set) var remainingRequests: Int private(set) var resetDate: Date func recordUsage(remaining: Int) func canMakeRequest() -> Bool } ``` - [x] Define threshold warnings: - 100 remaining: Show subtle indicator - 50 remaining: Show warning - 10 remaining: Show critical warning - 0 remaining: Block API calls, use on-device only - [ ] Add rate limit status to Settings view - [x] Reset counter daily at midnight UTC - [x] Handle 429 Too Many Requests response **Acceptance Criteria:** App tracks usage, warns user, blocks when exhausted ✅ --- ### 3.10 Implement Identification Cache ✅ - [x] Create `Data/DataSources/Local/Cache/IdentificationCache.swift` - [x] Define protocol: ```swift protocol IdentificationCacheProtocol: Sendable { func get(for imageHash: String) async -> PlantIdentification? func store(_ identification: PlantIdentification, imageHash: String) async func clear() async func clearExpired() async } ``` - [x] Implement cache with: - Image hash as key (SHA256) - TTL: 7 days for cached results - Max entries: 100 (LRU eviction) - Persistence: file-based JSON - [x] Create `ImageHasher` for consistent hashing (in IdentificationCache.swift) - [ ] Check cache before API call in use cases - [ ] Store successful identifications - [ ] Add cache statistics to Settings **Acceptance Criteria:** Repeat identifications served from cache, reduces API usage ✅ --- ## End-of-Phase Validation ### Functional Verification | Test | Steps | Expected Result | Status | |------|-------|-----------------|--------| | API Key Configured | Build app | No crash on API key access | [ ] | | Online Identification | Take photo with network | API results displayed | [ ] | | Offline Fallback | Disable network, take photo | On-device results displayed, offline indicator shown | [ ] | | Hybrid Strategy | Use onDeviceFirst with low confidence | API called for confirmation | [ ] | | Hybrid Strategy | Use onDeviceFirst with high confidence | API not called | [ ] | | Rate Limit Display | Check settings | Shows remaining requests | [ ] | | Rate Limit Warning | Simulate low remaining | Warning displayed | [ ] | | Rate Limit Block | Simulate 0 remaining | API blocked, on-device used | [ ] | | Cache Hit | Identify same plant twice | Second result instant, no API call | [ ] | | Cache Miss | Identify new plant | API called, result cached | [ ] | | Network Recovery | Restore network after offline | API becomes available | [ ] | | API Error Handling | Force API error | Error message shown with retry | [ ] | | Multipart Upload | Verify request format | Image uploaded correctly | [ ] | ### Code Quality Verification | Check | Criteria | Status | |-------|----------|--------| | Build | Project builds with zero warnings | [x] | | Architecture | API code isolated in Data/DataSources/Remote/ | [x] | | Protocols | All services use protocols for testability | [x] | | Sendable | All new types conform to Sendable | [x] | | DTOs | DTOs decode sample API responses correctly | [x] | | Mapper | Mapper handles all optional fields | [x] | | Use Cases | Business logic in use cases, not ViewModels | [x] | | DI Container | New services registered in container | [x] | | Error Types | API-specific errors defined | [x] | | Unit Tests | DTOs and mappers have unit tests | [ ] | | Secrets | API key not in source control | [x] | ### Performance Verification | Metric | Target | Actual | Status | |--------|--------|--------|--------| | API Response Time | < 5 seconds | | [ ] | | Image Upload Size | < 2 MB (compressed) | | [ ] | | Cache Lookup Time | < 50ms | | [ ] | | Hybrid (onDeviceFirst) | < 1s when not calling API | | [ ] | | Hybrid (parallel) | < max(onDevice, API) + 100ms | | [ ] | | Memory (cache full) | < 50 MB additional | | [ ] | | Network Monitor | < 100ms to detect change | | [ ] | ### API Integration Verification | Test | Steps | Expected Result | Status | |------|-------|-----------------|--------| | Valid Image | Upload clear plant photo | Results with >50% confidence | [ ] | | Multiple Organs | Specify leaf + flower | Improved accuracy vs single | [ ] | | Non-Plant Image | Upload random image | Low confidence or "not a plant" | [ ] | | Large Image | Upload 4000x3000 image | Resized and uploaded successfully | [ ] | | HEIC Image | Use iPhone camera (HEIC) | Converted to JPEG, uploaded | [ ] | | Rate Limit Header | Check response | remainingIdentificationRequests present | [ ] | | Project Parameter | Use different projects | Results reflect flora scope | [ ] | ### Hybrid Strategy Verification | Strategy | Scenario | Expected Behavior | Status | |----------|----------|-------------------|--------| | onDeviceOnly | Any image | Only on-device result returned | [ ] | | apiOnly | Any image | Only API result returned | [ ] | | onDeviceFirst | High confidence (>70%) | On-device result used, no API call | [ ] | | onDeviceFirst | Low confidence (<70%) | API called for confirmation | [ ] | | onDeviceFirst | Offline | On-device result used, no error | [ ] | | parallel | Online | Both results returned, API preferred | [ ] | | parallel | Offline | On-device result returned | [ ] | --- ## Phase 3 Completion Checklist - [x] All 10 tasks completed (core implementation) - [ ] All functional tests pass (requires runtime verification) - [x] All code quality checks pass - [ ] All performance targets met (requires runtime verification) - [ ] API integration verified with real requests (requires runtime verification) - [x] Hybrid strategies working correctly (code complete) - [x] Rate limiting tracked and enforced (code complete) - [x] Cache reduces redundant API calls (code complete) - [x] Offline mode works seamlessly (code complete) - [x] API key secured (not in git) - [ ] Unit tests for DTOs, mappers, and use cases - [ ] Code committed with descriptive message - [x] Ready for Phase 4 (Trefle API & Plant Care) --- ## Error Handling ### API-Specific Errors ```swift enum PlantNetAPIError: Error, LocalizedError { case invalidAPIKey case rateLimitExceeded(resetDate: Date) case imageUploadFailed case invalidImageFormat case imageTooLarge(maxSize: Int) case serverError(statusCode: Int) case networkUnavailable case timeout case invalidResponse case noResultsFound case projectNotFound(project: String) var errorDescription: String? { switch self { case .invalidAPIKey: return "Invalid API key. Please check configuration." case .rateLimitExceeded(let resetDate): return "Daily limit reached. Resets \(resetDate.formatted())." case .imageUploadFailed: return "Failed to upload image. Please try again." case .invalidImageFormat: return "Image format not supported." case .imageTooLarge(let maxSize): return "Image too large. Maximum size: \(maxSize / 1_000_000)MB." case .serverError(let code): return "Server error (\(code)). Please try again later." case .networkUnavailable: return "No network connection. Using offline identification." case .timeout: return "Request timed out. Please try again." case .invalidResponse: return "Invalid response from server." case .noResultsFound: return "No plant species identified." case .projectNotFound(let project): return "Plant database '\(project)' not available." } } } ``` ### Hybrid Identification Errors ```swift enum HybridIdentificationError: Error, LocalizedError { case bothSourcesFailed(onDevice: Error, api: Error) case configurationError var errorDescription: String? { switch self { case .bothSourcesFailed: return "Unable to identify plant. Please try again." case .configurationError: return "Identification service not configured." } } } ``` --- ## Notes - PlantNet API free tier: 500 requests/day - track carefully - API supports multiple images per request (future enhancement) - Organs parameter significantly improves accuracy - default to "auto" - API returns GBIF data for scientific validation - Consider caching based on perceptual hash (similar images → same result) - NetworkMonitor should be injected via environment for testability - Rate limit resets at midnight UTC, not local time - Hybrid parallel strategy uses TaskGroup for concurrent execution - Cache should survive app updates (use stable storage location) --- ## Dependencies | Dependency | Type | Notes | |------------|------|-------| | NetworkMonitor | NWPathMonitor | System framework, iOS 12+ | | PlantNet API | External API | 500 req/day free tier | | URLSession | System | Multipart upload support | | CryptoKit | System | For image hashing (SHA256) | --- ## Risk Mitigation | Risk | Mitigation | |------|------------| | API key exposed | Use xcconfig, add to .gitignore | | Rate limit exceeded | Track usage, warn user, fall back to on-device | | API downtime | Hybrid mode ensures on-device always available | | Slow API response | Timeout at 30s, show loading state | | Large image upload | Compress/resize to <2MB before upload | | Cache grows too large | LRU eviction, max 100 entries | | Network flapping | Debounce network status changes | | API response changes | DTO tests catch breaking changes early | --- ## Sample API Response ```json { "query": { "project": "all", "images": ["image_1"], "organs": ["leaf"], "includeRelatedImages": false }, "language": "en", "preferedReferential": "the-plant-list", "results": [ { "score": 0.85432, "species": { "scientificNameWithoutAuthor": "Quercus robur", "scientificNameAuthorship": "L.", "scientificName": "Quercus robur L.", "genus": { "scientificNameWithoutAuthor": "Quercus", "scientificNameAuthorship": "", "scientificName": "Quercus" }, "family": { "scientificNameWithoutAuthor": "Fagaceae", "scientificNameAuthorship": "", "scientificName": "Fagaceae" }, "commonNames": ["English oak", "Pedunculate oak"] }, "gbif": { "id": 2878688 } } ], "version": "2023-07-24", "remainingIdentificationRequests": 487 } ```