Files
PlantGuide/PlantGuide/Domain/UseCases/Identification/HybridIdentificationUseCase.swift
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

292 lines
9.9 KiB
Swift

//
// HybridIdentificationUseCase.swift
// PlantGuide
//
// Created on 1/21/26.
//
import Foundation
import UIKit
// MARK: - HybridStrategy
/// Strategy for hybrid plant identification
enum HybridStrategy: Sendable {
/// Use only on-device ML for identification
case onDeviceOnly
/// Use only online API for identification (requires network)
case onlineOnly
/// Run on-device first, use API if confidence is below threshold
case onDeviceFirst(apiThreshold: Double)
/// Run both concurrently, prefer online results if available
case parallel
}
// MARK: - HybridIdentificationResult
/// Result of a hybrid identification operation
struct HybridIdentificationResult: Sendable {
/// The plant predictions from identification
let predictions: [ViewPlantPrediction]
/// The source that provided the final results
let source: IdentificationSource
/// Whether on-device identification was available
let onDeviceAvailable: Bool
/// Whether online identification was available
let onlineAvailable: Bool
}
// MARK: - HybridIdentificationError
/// Errors that can occur during hybrid identification
enum HybridIdentificationError: Error, LocalizedError {
/// Online-only strategy was requested but no network is available
case noNetworkForOnlineOnly
/// Both on-device and online identification failed
case bothSourcesFailed
var errorDescription: String? {
switch self {
case .noNetworkForOnlineOnly:
return "No network connection available for online identification"
case .bothSourcesFailed:
return "Unable to identify plant using either on-device or online identification"
}
}
}
// MARK: - HybridIdentificationUseCaseProtocol
/// Protocol for hybrid plant identification combining on-device and online sources
protocol HybridIdentificationUseCaseProtocol: Sendable {
/// Identifies a plant using the specified hybrid strategy
/// - Parameters:
/// - image: The UIImage containing the plant to identify
/// - strategy: The hybrid strategy to use for identification
/// - Returns: The result containing predictions and source information
/// - Throws: HybridIdentificationError or underlying identification errors
func execute(
image: UIImage,
strategy: HybridStrategy
) async throws -> HybridIdentificationResult
}
// MARK: - HybridIdentificationUseCase
/// Use case for hybrid plant identification combining on-device ML and online API
struct HybridIdentificationUseCase: HybridIdentificationUseCaseProtocol {
// MARK: - Dependencies
private let onDeviceUseCase: IdentifyPlantUseCaseProtocol
private let onlineUseCase: IdentifyPlantOnlineUseCaseProtocol
private let networkMonitor: NetworkMonitor
// MARK: - Initialization
/// Creates a new HybridIdentificationUseCase
/// - Parameters:
/// - onDeviceUseCase: The use case for on-device ML identification
/// - onlineUseCase: The use case for online API identification
/// - networkMonitor: The network monitor for checking connectivity
init(
onDeviceUseCase: IdentifyPlantUseCaseProtocol,
onlineUseCase: IdentifyPlantOnlineUseCaseProtocol,
networkMonitor: NetworkMonitor
) {
self.onDeviceUseCase = onDeviceUseCase
self.onlineUseCase = onlineUseCase
self.networkMonitor = networkMonitor
}
// MARK: - HybridIdentificationUseCaseProtocol
/// Executes hybrid plant identification using the specified strategy
func execute(
image: UIImage,
strategy: HybridStrategy
) async throws -> HybridIdentificationResult {
switch strategy {
case .onDeviceOnly:
return try await executeOnDeviceOnly(image: image)
case .onlineOnly:
return try await executeOnlineOnly(image: image)
case .onDeviceFirst(let apiThreshold):
return try await executeOnDeviceFirst(image: image, apiThreshold: apiThreshold)
case .parallel:
return try await executeParallel(image: image)
}
}
// MARK: - Strategy Implementations
/// Executes on-device only identification
private func executeOnDeviceOnly(image: UIImage) async throws -> HybridIdentificationResult {
let predictions = try await onDeviceUseCase.execute(image: image)
return HybridIdentificationResult(
predictions: predictions,
source: .onDeviceML,
onDeviceAvailable: true,
onlineAvailable: networkMonitor.isConnected
)
}
/// Executes online only identification
private func executeOnlineOnly(image: UIImage) async throws -> HybridIdentificationResult {
guard networkMonitor.isConnected else {
throw HybridIdentificationError.noNetworkForOnlineOnly
}
let predictions = try await onlineUseCase.execute(image: image)
return HybridIdentificationResult(
predictions: predictions,
source: .plantNetAPI,
onDeviceAvailable: true,
onlineAvailable: true
)
}
/// Executes on-device first, falling back to online if confidence is below threshold
private func executeOnDeviceFirst(
image: UIImage,
apiThreshold: Double
) async throws -> HybridIdentificationResult {
// Always run on-device first
let onDevicePredictions = try await onDeviceUseCase.execute(image: image)
// Check if top prediction confidence meets threshold
let topConfidence = onDevicePredictions.first?.confidence ?? 0.0
let isOnlineAvailable = networkMonitor.isConnected
// If confidence is above threshold or network unavailable, return on-device results
if topConfidence >= apiThreshold || !isOnlineAvailable {
return HybridIdentificationResult(
predictions: onDevicePredictions,
source: .onDeviceML,
onDeviceAvailable: true,
onlineAvailable: isOnlineAvailable
)
}
// Try online identification for better results
do {
let onlinePredictions = try await onlineUseCase.execute(image: image)
return HybridIdentificationResult(
predictions: onlinePredictions,
source: .plantNetAPI,
onDeviceAvailable: true,
onlineAvailable: true
)
} catch {
// If online fails, fall back to on-device results
return HybridIdentificationResult(
predictions: onDevicePredictions,
source: .onDeviceML,
onDeviceAvailable: true,
onlineAvailable: true
)
}
}
/// Executes both on-device and online identification in parallel
private func executeParallel(image: UIImage) async throws -> HybridIdentificationResult {
let isOnlineAvailable = networkMonitor.isConnected
// If offline, only run on-device
guard isOnlineAvailable else {
let predictions = try await onDeviceUseCase.execute(image: image)
return HybridIdentificationResult(
predictions: predictions,
source: .onDeviceML,
onDeviceAvailable: true,
onlineAvailable: false
)
}
// Run both in parallel using TaskGroup
return try await withThrowingTaskGroup(
of: IdentificationTaskResult.self,
returning: HybridIdentificationResult.self
) { group in
// Add on-device task
group.addTask {
do {
let predictions = try await onDeviceUseCase.execute(image: image)
return .onDevice(predictions)
} catch {
return .onDeviceFailed(error)
}
}
// Add online task
group.addTask {
do {
let predictions = try await onlineUseCase.execute(image: image)
return .online(predictions)
} catch {
return .onlineFailed(error)
}
}
// Collect results
var onDevicePredictions: [ViewPlantPrediction]?
var onlinePredictions: [ViewPlantPrediction]?
var onDeviceError: Error?
var onlineError: Error?
for try await result in group {
switch result {
case .onDevice(let predictions):
onDevicePredictions = predictions
case .online(let predictions):
onlinePredictions = predictions
case .onDeviceFailed(let error):
onDeviceError = error
case .onlineFailed(let error):
onlineError = error
}
}
// Prefer online results if available
if let onlinePredictions {
return HybridIdentificationResult(
predictions: onlinePredictions,
source: .plantNetAPI,
onDeviceAvailable: onDevicePredictions != nil,
onlineAvailable: true
)
}
// Fall back to on-device results
if let onDevicePredictions {
return HybridIdentificationResult(
predictions: onDevicePredictions,
source: .onDeviceML,
onDeviceAvailable: true,
onlineAvailable: false
)
}
// Both failed
throw HybridIdentificationError.bothSourcesFailed
}
}
}
// MARK: - IdentificationTaskResult
/// Internal result type for parallel task execution
private enum IdentificationTaskResult: Sendable {
case onDevice([ViewPlantPrediction])
case online([ViewPlantPrediction])
case onDeviceFailed(Error)
case onlineFailed(Error)
}