// WeatherService.swift // Jarvis — weather via Open-Meteo (free, no API key, no entitlement). Used to put // a "will it rain today" line in the morning/evening briefing. import Foundation struct DailyWeather { let place: String let tempMax: Double let tempMin: Double let precipProbability: Int // % let code: Int // WMO weather code var willRain: Bool { precipProbability >= 40 } /// e.g. "Kampala 26°/18°, light rain · 70% — take an umbrella ☔️" var summary: String { let temps = "\(Int(tempMax.rounded()))°/\(Int(tempMin.rounded()))°" let cond = WeatherService.codeText(code) let rain = "\(precipProbability)% rain" let tail = willRain ? " — take an umbrella ☔️" : "" return "\(place) \(temps), \(cond) · \(rain)\(tail)" } } enum WeatherService { struct GeoResult: Decodable { let latitude: Double; let longitude: Double; let name: String; let country: String? } private struct GeoResponse: Decodable { let results: [GeoResult]? } private struct ForecastResponse: Decodable { struct Daily: Decodable { let temperature_2m_max: [Double] let temperature_2m_min: [Double] let precipitation_probability_max: [Int?] let weathercode: [Int] } let daily: Daily } private static let session = URLSession(configuration: .default) /// Geocode a free-text place to coordinates. static func geocode(_ query: String) async -> GeoResult? { let q = query.trimmingCharacters(in: .whitespaces) guard !q.isEmpty, var c = URLComponents(string: "https://geocoding-api.open-meteo.com/v1/search") else { return nil } c.queryItems = [.init(name: "name", value: q), .init(name: "count", value: "1"), .init(name: "language", value: "en"), .init(name: "format", value: "json")] guard let url = c.url else { return nil } do { let (data, _) = try await session.data(from: url) return try JSONDecoder().decode(GeoResponse.self, from: data).results?.first } catch { return nil } } /// Today's forecast for coordinates. static func forecast(latitude: Double, longitude: Double, place: String) async -> DailyWeather? { guard var c = URLComponents(string: "https://api.open-meteo.com/v1/forecast") else { return nil } c.queryItems = [ .init(name: "latitude", value: "\(latitude)"), .init(name: "longitude", value: "\(longitude)"), .init(name: "daily", value: "temperature_2m_max,temperature_2m_min,precipitation_probability_max,weathercode"), .init(name: "timezone", value: "auto"), .init(name: "forecast_days", value: "1"), ] guard let url = c.url else { return nil } do { let (data, _) = try await session.data(from: url) let r = try JSONDecoder().decode(ForecastResponse.self, from: data) guard let tmax = r.daily.temperature_2m_max.first, let tmin = r.daily.temperature_2m_min.first, let code = r.daily.weathercode.first else { return nil } let prob = r.daily.precipitation_probability_max.first.flatMap { $0 } ?? 0 return DailyWeather(place: place, tempMax: tmax, tempMin: tmin, precipProbability: prob, code: code) } catch { return nil } } /// Coarse WMO weather-code → words. static func codeText(_ code: Int) -> String { switch code { case 0: return "clear" case 1, 2: return "partly cloudy" case 3: return "overcast" case 45, 48: return "fog" case 51, 53, 55, 56, 57: return "drizzle" case 61, 63, 65, 66, 67: return "rain" case 71, 73, 75, 77: return "snow" case 80, 81, 82: return "rain showers" case 95, 96, 99: return "thunderstorms" default: return "mixed" } } }