Files
jarvis/Jarvis/Notifications/WeatherService.swift
Robin Kutesa 3388eb7944 Add notifications: morning/evening briefings + breaking alerts
- Settings hub (gear → SettingsView) hosting Notifications and Connectivity
  (Connectivity previously had no entry point).
- NotificationsView: permission flow, breaking/new-story alerts with a
  min-signal threshold, morning & evening briefings with time pickers, and a
  weather toggle + location, plus a "send test briefing" action.
- NotificationManager: schedules daily briefings, builds "Jarvis" content
  (weather + top signal stories), fires breaking alerts off WebSocket
  story.created events, and refreshes via a BGAppRefresh task.
- WeatherService: Open-Meteo geocoding + daily forecast (no API key/entitlement).
- AppDelegate registers the background-refresh handler at launch; Info.plist
  gains UIBackgroundModes + BGTaskSchedulerPermittedIdentifiers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:17:41 +03:00

94 lines
4.0 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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"
}
}
}