// // 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) -> 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) -> 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) { 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) } } }