- 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>
292 lines
9.9 KiB
Swift
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)
|
|
}
|