Files
PlantGuide/Docs/Phase3_plan.md
Trey t 136dfbae33 Add PlantGuide iOS app with plant identification and care management
- 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>
2026-01-23 12:18:01 -06:00

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_KEY to Info.plist (via xcconfig for security)
  • 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

  • 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 flora
    • weurope - Western Europe
    • canada - Canada
    • useful - 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 HybridStrategy enum:
    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 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

  • 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
}