jsdw_ios/QuickLocation/Manager/MQTT/MemberWeatherService.swift

210 lines
7.5 KiB
Swift

//
// MemberWeatherService.swift
// QuickLocation
//
import CoreLocation
import Foundation
import WeatherKit
struct MemberWeatherSnapshot: Equatable {
let text: String
let assetName: String
}
final class MemberWeatherService {
static let shared = MemberWeatherService()
private struct CacheEntry {
let snapshot: MemberWeatherSnapshot
let date: Date
}
private enum WeatherError: Error {
case invalidLocation
}
private typealias Completion = (Result<MemberWeatherSnapshot, Error>) -> Void
private let cacheDuration: TimeInterval = 15 * 60
private let stateQueue = DispatchQueue(label: "com.quicklocation.weather.state")
private var cache: [String: CacheEntry] = [:]
private var pendingCompletions: [String: [Completion]] = [:]
private init() {}
static func snapshot(reportedText: String) -> MemberWeatherSnapshot {
let text = reportedText.trimmingCharacters(in: .whitespacesAndNewlines)
return MemberWeatherSnapshot(
text: text,
assetName: assetName(reportedText: text)
)
}
func weather(
for location: CLLocation,
completion: @escaping (Result<MemberWeatherSnapshot, Error>) -> Void
) {
guard CLLocationCoordinate2DIsValid(location.coordinate) else {
DispatchQueue.main.async {
completion(.failure(WeatherError.invalidLocation))
}
return
}
let key = Self.cacheKey(for: location.coordinate)
stateQueue.async { [weak self] in
guard let self else { return }
if let entry = self.cache[key],
Date().timeIntervalSince(entry.date) < self.cacheDuration {
DispatchQueue.main.async {
completion(.success(entry.snapshot))
}
return
}
if self.pendingCompletions[key] != nil {
self.pendingCompletions[key]?.append(completion)
return
}
self.pendingCompletions[key] = [completion]
Task { [weak self] in
do {
let weather = try await WeatherKit.WeatherService.shared.weather(
for: location,
including: .current
)
let snapshot = Self.snapshot(from: weather)
self?.finish(key: key, result: .success(snapshot))
} catch {
#if DEBUG
let error = error as NSError
print("WeatherKit request failed [\(error.domain):\(error.code)] at \(key): \(error.localizedDescription)")
#endif
self?.finish(key: key, result: .failure(error))
}
}
}
}
private func finish(key: String, result: Result<MemberWeatherSnapshot, Error>) {
stateQueue.async { [weak self] in
guard let self else { return }
if case let .success(snapshot) = result {
self.cache[key] = CacheEntry(snapshot: snapshot, date: Date())
}
let completions = self.pendingCompletions.removeValue(forKey: key) ?? []
DispatchQueue.main.async {
completions.forEach { $0(result) }
}
}
}
private static func cacheKey(for coordinate: CLLocationCoordinate2D) -> String {
let latitude = Int((coordinate.latitude * 1_000).rounded())
let longitude = Int((coordinate.longitude * 1_000).rounded())
return "\(latitude):\(longitude)"
}
private static func snapshot(from weather: CurrentWeather) -> MemberWeatherSnapshot {
let temperature = Int(weather.temperature.converted(to: .celsius).value.rounded())
return MemberWeatherSnapshot(
text: "\(conditionText(weather.condition)) \(temperature)°C",
assetName: assetName(weather.condition)
)
}
private static func assetName(_ condition: WeatherCondition) -> String {
switch condition {
case .clear, .mostlyClear, .hot:
return "Home/member_weather_sunny"
case .partlyCloudy:
return "Home/member_weather_cloudy"
case .drizzle, .freezingDrizzle, .freezingRain, .hail, .heavyRain,
.rain, .sunShowers:
return "Home/member_weather_rain"
case .isolatedThunderstorms, .scatteredThunderstorms, .strongStorms,
.thunderstorms, .tropicalStorm, .hurricane:
return "Home/member_weather_thunderstorm"
case .blizzard, .blowingSnow, .flurries, .frigid, .heavySnow,
.sleet, .snow, .sunFlurries, .wintryMix:
return "Home/member_weather_snow"
case .blowingDust, .breezy, .cloudy, .foggy, .haze, .mostlyCloudy,
.smoky, .windy:
return "Home/member_weather_overcast"
@unknown default:
return "Home/member_weather_overcast"
}
}
private static func assetName(reportedText: String) -> String {
guard !reportedText.isEmpty,
!reportedText.contains("未知") else {
return "Home/member_weather_unknown"
}
if reportedText.containsAny(of: ["雷雨", "雷暴", "强风暴", "热带风暴", "飓风"]) {
return "Home/member_weather_thunderstorm"
}
if reportedText.containsAny(of: ["", "冻雨", "冰雹", "阵雨", "太阳雨"]) {
return "Home/member_weather_rain"
}
if reportedText.containsAny(of: ["阵雪", "风雪", "暴风雪", "", "严寒"]) {
return "Home/member_weather_snow"
}
if reportedText.contains("晴间多云") {
return "Home/member_weather_cloudy"
}
if reportedText.containsAny(of: ["多云", "", "", "", "扬尘", "烟霾", "大风", "微风"]) {
return "Home/member_weather_overcast"
}
if reportedText.containsAny(of: ["", "炎热"]) {
return "Home/member_weather_sunny"
}
return "Home/member_weather_unknown"
}
private static func conditionText(_ condition: WeatherCondition) -> String {
switch condition {
case .blizzard: return "暴风雪"
case .blowingDust: return "扬尘"
case .blowingSnow: return "风雪"
case .breezy: return "微风"
case .clear, .mostlyClear: return ""
case .cloudy, .mostlyCloudy: return "多云"
case .drizzle: return "小雨"
case .flurries: return "阵雪"
case .foggy: return ""
case .freezingDrizzle, .freezingRain: return "冻雨"
case .frigid: return "严寒"
case .hail: return "冰雹"
case .heavyRain: return "大雨"
case .heavySnow: return "大雪"
case .hot: return "炎热"
case .hurricane: return "飓风"
case .isolatedThunderstorms, .scatteredThunderstorms, .thunderstorms: return "雷雨"
case .partlyCloudy: return "晴间多云"
case .rain: return ""
case .sleet, .wintryMix: return "雨夹雪"
case .smoky: return "烟霾"
case .snow: return ""
case .strongStorms: return "强风暴"
case .sunFlurries: return "晴间阵雪"
case .sunShowers: return "太阳雨"
case .tropicalStorm: return "热带风暴"
case .windy: return "大风"
case .haze: return ""
@unknown default: return "多云"
}
}
}
private extension String {
func containsAny(of values: [String]) -> Bool {
values.contains { contains($0) }
}
}