94 lines
4.0 KiB
Swift
94 lines
4.0 KiB
Swift
|
|
// 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"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|