- Implement camera capture and plant identification workflow - Add Core Data persistence for plants, care schedules, and cached API data - Create collection view with grid/list layouts and filtering - Build plant detail views with care information display - Integrate Trefle botanical API for plant care data - Add local image storage for captured plant photos - Implement dependency injection container for testability - Include accessibility support throughout the app Bug fixes in this commit: - Fix Trefle API decoding by removing duplicate CodingKeys - Fix LocalCachedImage to load from correct PlantImages directory - Set dateAdded when saving plants for proper collection sorting Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
18 KiB
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 ✅
- Navigate to my.plantnet.org
- Create developer account
- Generate API key
- Review API documentation and rate limits
- Create
App/Configuration/APIKeys.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 }() } - Add
PLANTNET_API_KEYto Info.plist (via xcconfig for security) - Create
.xcconfigfile 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 ✅
- Create
Data/DataSources/Remote/PlantNetAPI/PlantNetEndpoints.swift - Define endpoint configuration:
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] } } - Support multiple project types:
all- All floraweurope- Western Europecanada- Canadauseful- Useful plants
- Define organ types:
leaf,flower,fruit,bark,auto - Create query parameter builder for organs
Acceptance Criteria: Endpoint builds correct URL with headers and query params ✅
3.3 Implement PlantNet API Service ✅
- Create
Data/DataSources/Remote/PlantNetAPI/PlantNetAPIService.swift - Define protocol:
protocol PlantNetAPIServiceProtocol: Sendable { func identify( imageData: Data, organs: [PlantOrgan], project: PlantNetProject ) async throws -> PlantNetIdentifyResponseDTO } - 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
- Handle response parsing
- Implement retry logic with exponential backoff (1 retry)
- Add request timeout (30 seconds)
- Log request/response for debugging
Acceptance Criteria: Service can upload image and receive valid response from API ✅
3.4 Create PlantNet DTOs ✅
- Create
Data/DataSources/Remote/PlantNetAPI/DTOs/PlantNetDTOs.swift:struct PlantNetIdentifyResponseDTO: Decodable { let query: QueryDTO let language: String let preferedReferential: String let results: [PlantNetResultDTO] let version: String let remainingIdentificationRequests: Int } - Create
PlantNetResultDTO(consolidated in PlantNetDTOs.swift) - Create
PlantNetSpeciesDTO(consolidated in PlantNetDTOs.swift) - Create supporting DTOs:
PlantNetGenusDTO,PlantNetFamilyDTO,PlantNetGBIFDataDTO,PlantNetQueryDTO - 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 ✅
- Create
Data/Mappers/PlantNetMapper.swift - Implement mapping functions:
struct PlantNetMapper { static func mapToIdentification( from response: PlantNetIdentifyResponseDTO, imageData: Data ) -> PlantIdentification static func mapToPredictions( from results: [PlantNetResultDTO] ) -> [PlantPrediction] } - Map API confidence scores (0.0-1.0) to percentage
- Handle missing optional fields gracefully
- Map common names (may be empty array)
- Set identification source to
.plantNetAPI - Include remaining API requests in metadata
Acceptance Criteria: Mapper produces valid domain entities from all DTO variations ✅
3.6 Implement Online Identification Use Case ✅
- Create
Domain/UseCases/Identification/IdentifyPlantOnlineUseCase.swift - Define protocol:
protocol IdentifyPlantOnlineUseCaseProtocol: Sendable { func execute( image: UIImage, organs: [PlantOrgan], project: PlantNetProject ) async throws -> PlantIdentification } - 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
- 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 ✅
- Create
Domain/UseCases/Identification/HybridIdentificationUseCase.swift - Define protocol:
protocol HybridIdentificationUseCaseProtocol: Sendable { func execute( image: UIImage, strategy: HybridStrategy ) async throws -> HybridIdentificationResult } - Define
HybridStrategyenum: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:struct HybridIdentificationResult: Sendable { let onDeviceResult: PlantIdentification? let apiResult: PlantIdentification? let preferredResult: PlantIdentification let source: IdentificationSource let processingTime: TimeInterval } - Implement strategies:
onDeviceFirst: Run on-device, call API if top confidence < threshold (default 70%)parallel: Run both concurrently, merge results
- Handle offline gracefully (fall back to on-device only)
- Track timing for analytics
- Add to DIContainer
Acceptance Criteria: Hybrid use case correctly implements all strategies ✅
3.8 Add Network Reachability Monitoring ✅
- Create
Core/Utilities/NetworkMonitor.swift - Implement using
NWPathMonitor:@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 } } - Start monitoring on app launch
- Publish connectivity changes
- 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 ✅
- Create
Data/DataSources/Remote/PlantNetAPI/RateLimitTracker.swift - Track remaining requests from API response header
- Persist daily count to UserDefaults:
actor RateLimitTracker { private(set) var remainingRequests: Int private(set) var resetDate: Date func recordUsage(remaining: Int) func canMakeRequest() -> Bool } - 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
- Reset counter daily at midnight UTC
- Handle 429 Too Many Requests response
Acceptance Criteria: App tracks usage, warns user, blocks when exhausted ✅
3.10 Implement Identification Cache ✅
- Create
Data/DataSources/Local/Cache/IdentificationCache.swift - Define protocol:
protocol IdentificationCacheProtocol: Sendable { func get(for imageHash: String) async -> PlantIdentification? func store(_ identification: PlantIdentification, imageHash: String) async func clear() async func clearExpired() async } - Implement cache with:
- Image hash as key (SHA256)
- TTL: 7 days for cached results
- Max entries: 100 (LRU eviction)
- Persistence: file-based JSON
- Create
ImageHasherfor 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
- All 10 tasks completed (core implementation)
- All functional tests pass (requires runtime verification)
- All code quality checks pass
- All performance targets met (requires runtime verification)
- API integration verified with real requests (requires runtime verification)
- Hybrid strategies working correctly (code complete)
- Rate limiting tracked and enforced (code complete)
- Cache reduces redundant API calls (code complete)
- Offline mode works seamlessly (code complete)
- API key secured (not in git)
- Unit tests for DTOs, mappers, and use cases
- Code committed with descriptive message
- Ready for Phase 4 (Trefle API & Plant Care)
Error Handling
API-Specific Errors
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
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
{
"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
}