Files
PlantGuide/PlantGuide/Data/Mappers/TrefleMapper.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

362 lines
14 KiB
Swift

//
// TrefleMapper.swift
// PlantGuide
//
// Created on 2026-01-21.
//
import Foundation
// MARK: - TrefleMapper
/// Maps Trefle API DTOs to domain entities.
///
/// This mapper provides conversion functions for transforming Trefle API
/// responses into PlantCareInfo domain entities and related care requirement types.
/// All mapping functions handle nil/missing data gracefully with sensible defaults.
struct TrefleMapper {
// MARK: - Primary Mapping
/// Maps a Trefle species DTO to a PlantCareInfo domain entity.
///
/// This function extracts all available care information from the Trefle species data,
/// including growth requirements, environmental preferences, and blooming seasons.
///
/// - Parameter species: The detailed species information from the Trefle API.
/// - Returns: A `PlantCareInfo` domain entity populated with care requirements.
static func mapToPlantCareInfo(from species: TrefleSpeciesDTO) -> PlantCareInfo {
let growth = species.growth
let specifications = species.specifications
return PlantCareInfo(
id: UUID(),
scientificName: species.scientificName,
commonName: species.commonName,
lightRequirement: mapToLightRequirement(from: growth?.light),
wateringSchedule: mapToWateringSchedule(from: growth),
temperatureRange: mapToTemperatureRange(from: growth),
fertilizerSchedule: mapToFertilizerSchedule(from: growth),
humidity: mapToHumidityLevel(from: growth?.atmosphericHumidity),
growthRate: mapToGrowthRate(from: specifications?.growthRate),
bloomingSeason: mapToBloomingSeason(from: growth?.bloomMonths),
additionalNotes: buildAdditionalNotes(from: species),
sourceURL: URL(string: "https://trefle.io/api/v1/species/\(species.id)"),
trefleID: species.id
)
}
// MARK: - Light Requirement Mapping
/// Maps a Trefle light scale value to a LightRequirement enum.
///
/// Trefle uses a 0-10 scale where 0 is full shade and 10 is full sun.
/// This function maps that scale to the app's LightRequirement categories.
///
/// - Parameter light: The Trefle light value (0-10 scale), or nil if not available.
/// - Returns: The corresponding `LightRequirement` enum value.
/// Defaults to `.partialShade` if the input is nil.
///
/// Mapping:
/// - 0-2: `.fullShade` - Very low light conditions
/// - 3-4: `.lowLight` - Low light but not complete shade
/// - 5-6: `.partialShade` - Moderate, indirect light
/// - 7-10: `.fullSun` - Direct sunlight for most of the day
static func mapToLightRequirement(from light: Int?) -> LightRequirement {
guard let light = light else {
return .partialShade
}
switch light {
case 0...2:
return .fullShade
case 3...4:
return .lowLight
case 5...6:
return .partialShade
case 7...10:
return .fullSun
default:
return .partialShade
}
}
// MARK: - Watering Schedule Mapping
/// Maps Trefle growth data to a WateringSchedule.
///
/// This function determines watering frequency and amount based on the plant's
/// atmospheric and soil humidity requirements. Plants with higher humidity needs
/// typically require more frequent but lighter watering, while those with lower
/// needs benefit from less frequent but thorough watering.
///
/// - Parameter growth: The Trefle growth data containing humidity requirements.
/// - Returns: A `WateringSchedule` with appropriate frequency and amount.
/// Defaults to weekly/moderate if growth data is nil.
///
/// Mapping based on humidity levels (0-10 scale):
/// - High humidity (7-10): Weekly frequency with light watering
/// - Medium humidity (4-6): Twice weekly with moderate watering
/// - Low humidity (0-3): Weekly with thorough watering
static func mapToWateringSchedule(from growth: TrefleGrowthDTO?) -> WateringSchedule {
guard let growth = growth else {
return WateringSchedule(frequency: .weekly, amount: .moderate)
}
// Use atmospheric humidity as primary indicator, fall back to soil humidity
let humidityLevel = growth.atmosphericHumidity ?? growth.soilHumidity
guard let humidity = humidityLevel else {
return WateringSchedule(frequency: .weekly, amount: .moderate)
}
switch humidity {
case 7...10:
// High humidity plants need frequent, light watering
return WateringSchedule(frequency: .weekly, amount: .light)
case 4...6:
// Medium humidity plants need regular, moderate watering
return WateringSchedule(frequency: .twiceWeekly, amount: .moderate)
case 0...3:
// Low humidity plants (often drought-tolerant) need less frequent but thorough watering
return WateringSchedule(frequency: .weekly, amount: .thorough)
default:
return WateringSchedule(frequency: .weekly, amount: .moderate)
}
}
// MARK: - Temperature Range Mapping
/// Maps Trefle growth data to a TemperatureRange.
///
/// This function extracts minimum and maximum temperature tolerances from the
/// Trefle growth data and determines frost tolerance based on whether the plant
/// can survive temperatures below 0 degrees Celsius.
///
/// - Parameter growth: The Trefle growth data containing temperature information.
/// - Returns: A `TemperatureRange` with min/max values and frost tolerance.
/// Defaults to 15-30 degrees Celsius if growth data is nil.
static func mapToTemperatureRange(from growth: TrefleGrowthDTO?) -> TemperatureRange {
guard let growth = growth else {
return TemperatureRange(
minimumCelsius: 15.0,
maximumCelsius: 30.0,
optimalCelsius: nil,
frostTolerant: false
)
}
let minTemp = growth.minimumTemperature?.degC ?? 15.0
let maxTemp = growth.maximumTemperature?.degC ?? 30.0
// Calculate optimal as midpoint between min and max if both are available
let optimalTemp: Double?
if growth.minimumTemperature?.degC != nil && growth.maximumTemperature?.degC != nil {
optimalTemp = (minTemp + maxTemp) / 2.0
} else {
optimalTemp = nil
}
// Plant is frost tolerant if it can survive temperatures below 0 degrees Celsius
let frostTolerant = minTemp < 0.0
return TemperatureRange(
minimumCelsius: minTemp,
maximumCelsius: maxTemp,
optimalCelsius: optimalTemp,
frostTolerant: frostTolerant
)
}
// MARK: - Fertilizer Schedule Mapping
/// Maps Trefle growth data to a FertilizerSchedule.
///
/// This function determines fertilizer frequency and type based on the plant's
/// soil nutrient requirements. Plants with higher nutrient needs require more
/// frequent fertilization, while those with lower needs can use organic fertilizers
/// applied less frequently.
///
/// - Parameter growth: The Trefle growth data containing soil nutrient requirements.
/// - Returns: A `FertilizerSchedule` with appropriate frequency and type,
/// or nil if soil nutrient data is not available.
///
/// Mapping based on soil nutriment levels (0-10 scale):
/// - High needs (7-10): Biweekly with balanced fertilizer
/// - Medium needs (4-6): Monthly with balanced fertilizer
/// - Low needs (0-3): Quarterly with organic fertilizer
static func mapToFertilizerSchedule(from growth: TrefleGrowthDTO?) -> FertilizerSchedule? {
guard let soilNutriments = growth?.soilNutriments else {
return nil
}
switch soilNutriments {
case 7...10:
// High nutrient needs - frequent balanced fertilization
return FertilizerSchedule(frequency: .biweekly, type: .balanced)
case 4...6:
// Medium nutrient needs - monthly balanced fertilization
return FertilizerSchedule(frequency: .monthly, type: .balanced)
case 0...3:
// Low nutrient needs - occasional organic fertilization
return FertilizerSchedule(frequency: .quarterly, type: .organic)
default:
return FertilizerSchedule(frequency: .monthly, type: .balanced)
}
}
// MARK: - Humidity Level Mapping
/// Maps a Trefle atmospheric humidity value to a HumidityLevel enum.
///
/// Trefle uses a 0-10 scale for atmospheric humidity requirements.
/// This function maps that scale to the app's HumidityLevel categories.
///
/// - Parameter humidity: The Trefle atmospheric humidity value (0-10 scale),
/// or nil if not available.
/// - Returns: The corresponding `HumidityLevel` enum value, or nil if input is nil.
///
/// Mapping:
/// - 0-2: `.low` - Below 30% humidity
/// - 3-5: `.moderate` - 30-50% humidity
/// - 6-8: `.high` - 50-70% humidity
/// - 9-10: `.veryHigh` - Above 70% humidity
static func mapToHumidityLevel(from humidity: Int?) -> HumidityLevel? {
guard let humidity = humidity else {
return nil
}
switch humidity {
case 0...2:
return .low
case 3...5:
return .moderate
case 6...8:
return .high
case 9...10:
return .veryHigh
default:
return .moderate
}
}
// MARK: - Growth Rate Mapping
/// Maps a Trefle growth rate string to a GrowthRate enum.
///
/// Trefle provides growth rate as a string (e.g., "slow", "moderate", "rapid").
/// This function maps those values to the app's GrowthRate enum.
///
/// - Parameter growthRate: The Trefle growth rate string, or nil if not available.
/// - Returns: The corresponding `GrowthRate` enum value, or nil if input is nil
/// or doesn't match a known value.
static func mapToGrowthRate(from growthRate: String?) -> GrowthRate? {
guard let growthRate = growthRate?.lowercased() else {
return nil
}
switch growthRate {
case "slow":
return .slow
case "moderate", "medium":
return .moderate
case "rapid", "fast":
return .fast
default:
return nil
}
}
// MARK: - Blooming Season Mapping
/// Maps Trefle bloom months to an array of Season values.
///
/// Trefle provides bloom months as an array of three-letter month abbreviations
/// (e.g., ["mar", "apr", "may"]). This function converts those months to the
/// corresponding seasons based on Northern Hemisphere conventions.
///
/// - Parameter bloomMonths: An array of month abbreviations, or nil if not available.
/// - Returns: An array of unique `Season` values when the plant blooms,
/// or nil if bloom month data is not available.
///
/// Month to Season Mapping (Northern Hemisphere):
/// - December, January, February: `.winter`
/// - March, April, May: `.spring`
/// - June, July, August: `.summer`
/// - September, October, November: `.fall`
static func mapToBloomingSeason(from bloomMonths: [String]?) -> [Season]? {
guard let bloomMonths = bloomMonths, !bloomMonths.isEmpty else {
return nil
}
var seasons = Set<Season>()
for month in bloomMonths {
if let season = monthToSeason(month.lowercased()) {
seasons.insert(season)
}
}
guard !seasons.isEmpty else {
return nil
}
// Return seasons in natural order: spring, summer, fall, winter
let orderedSeasons: [Season] = [.spring, .summer, .fall, .winter]
return orderedSeasons.filter { seasons.contains($0) }
}
// MARK: - Private Helpers
/// Converts a month abbreviation to its corresponding season.
///
/// - Parameter month: A three-letter month abbreviation (lowercase).
/// - Returns: The corresponding `Season`, or nil if the abbreviation is not recognized.
private static func monthToSeason(_ month: String) -> Season? {
switch month {
case "dec", "jan", "feb":
return .winter
case "mar", "apr", "may":
return .spring
case "jun", "jul", "aug":
return .summer
case "sep", "oct", "nov":
return .fall
default:
return nil
}
}
/// Builds additional care notes from species data.
///
/// This function compiles relevant information that doesn't fit into other
/// structured fields, such as pH requirements and toxicity warnings.
///
/// - Parameter species: The Trefle species DTO.
/// - Returns: A string containing additional care notes, or nil if no relevant data.
private static func buildAdditionalNotes(from species: TrefleSpeciesDTO) -> String? {
var notes: [String] = []
// Add pH range information if available
if let phMin = species.growth?.phMinimum, let phMax = species.growth?.phMaximum {
notes.append("Soil pH: \(String(format: "%.1f", phMin)) - \(String(format: "%.1f", phMax))")
} else if let phMin = species.growth?.phMinimum {
notes.append("Minimum soil pH: \(String(format: "%.1f", phMin))")
} else if let phMax = species.growth?.phMaximum {
notes.append("Maximum soil pH: \(String(format: "%.1f", phMax))")
}
// Add toxicity warning if available
if let toxicity = species.specifications?.toxicity?.lowercased(), toxicity != "none" {
notes.append("Toxicity: \(toxicity.capitalized)")
}
// Add family information for reference
if let family = species.family {
notes.append("Family: \(family)")
}
return notes.isEmpty ? nil : notes.joined(separator: ". ")
}
}