210 lines
7.5 KiB
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) }
|
|
}
|
|
}
|