169 lines
7.3 KiB
Swift
169 lines
7.3 KiB
Swift
//
|
|
// CandidatesViewModel.swift
|
|
// iris
|
|
//
|
|
// Created by Codex.
|
|
//
|
|
|
|
import CoreLocation
|
|
import Foundation
|
|
import os
|
|
|
|
@MainActor
|
|
final class CandidatesViewModel: ObservableObject {
|
|
@Published private(set) var candidates: [FeedItem] = []
|
|
@Published private(set) var lastUpdatedAt: Date? = nil
|
|
@Published private(set) var isLoading = false
|
|
@Published private(set) var lastError: String? = nil
|
|
@Published private(set) var diagnostics: [String: String] = [:]
|
|
|
|
var demoLatitude: Double = 51.5074
|
|
var demoLongitude: Double = -0.1278
|
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "CandidatesViewModel")
|
|
|
|
func refresh() {
|
|
guard !isLoading else { return }
|
|
isLoading = true
|
|
lastError = nil
|
|
diagnostics = [:]
|
|
|
|
let location = CLLocation(latitude: demoLatitude, longitude: demoLongitude)
|
|
let now = Int(Date().timeIntervalSince1970)
|
|
logger.info("Refresh start lat=\(self.demoLatitude, format: .fixed(precision: 4)) lon=\(self.demoLongitude, format: .fixed(precision: 4)) now=\(now)")
|
|
|
|
Task {
|
|
defer {
|
|
Task { @MainActor in
|
|
self.isLoading = false
|
|
self.lastUpdatedAt = Date()
|
|
}
|
|
}
|
|
|
|
if #available(iOS 16.0, *) {
|
|
let ds = WeatherDataSource()
|
|
let output: WeatherDataSource.Snapshot
|
|
do {
|
|
output = try await ds.dataWithDiagnostics(for: location, now: now)
|
|
} catch let error as WeatherDataSource.WeatherError {
|
|
switch error {
|
|
case .weatherKitFailed(_, let diagnostics):
|
|
await MainActor.run {
|
|
self.candidates = []
|
|
self.diagnostics = diagnostics
|
|
self.lastError = "WeatherKit error: \(error.localizedDescription)"
|
|
}
|
|
return
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
self.candidates = []
|
|
self.diagnostics = ["weatherkit_error": String(describing: error)]
|
|
self.lastError = "WeatherKit error: \(error)"
|
|
}
|
|
return
|
|
}
|
|
let data = output.data
|
|
|
|
var items: [FeedItem] = []
|
|
if let current = data.current {
|
|
items.append(
|
|
FeedItem(
|
|
id: "wx:now:\(now / 60)",
|
|
type: .currentWeather,
|
|
title: "Now \(current.temperatureC)°C \(current.condition.description)".truncated(maxLength: TextConstraints.titleMax),
|
|
subtitle: "Feels \(current.feelsLikeC)°C".truncated(maxLength: TextConstraints.subtitleMax),
|
|
priority: 0.8,
|
|
ttlSec: 1800,
|
|
condition: current.condition,
|
|
startsAt: nil,
|
|
bucket: .fyi,
|
|
actions: ["DISMISS"]
|
|
)
|
|
)
|
|
}
|
|
|
|
if let rainSoon = data.rainSoon {
|
|
let minutes = max(0, Int(((TimeInterval(rainSoon.startAt - now)) / 60.0).rounded()))
|
|
let title = (minutes <= 0) ? "Rain now" : "Rain in ~\(minutes) min"
|
|
let subtitle = (rainSoon.source == .minutely) ? "Carry an umbrella" : "Rain likely soon"
|
|
let confidence: Double = (rainSoon.source == .minutely) ? 0.9 : 0.6
|
|
items.append(
|
|
FeedItem(
|
|
id: "wx:rain:\(rainSoon.startAt)",
|
|
type: .weatherAlert,
|
|
title: title.truncated(maxLength: TextConstraints.titleMax),
|
|
subtitle: subtitle.truncated(maxLength: TextConstraints.subtitleMax),
|
|
priority: confidence,
|
|
ttlSec: max(1, rainSoon.ttlSec),
|
|
condition: nil,
|
|
startsAt: nil,
|
|
bucket: .rightNow,
|
|
actions: ["DISMISS"]
|
|
)
|
|
)
|
|
}
|
|
|
|
if let wind = data.windAlert {
|
|
let mph = Int((wind.gustMps * 2.236936).rounded())
|
|
items.append(
|
|
FeedItem(
|
|
id: "wx:wind:\(now):\(Int(wind.thresholdMps * 10))",
|
|
type: .weatherAlert,
|
|
title: "Wind gusts ~\(mph) mph".truncated(maxLength: TextConstraints.titleMax),
|
|
subtitle: "Use caution outside".truncated(maxLength: TextConstraints.subtitleMax),
|
|
priority: 0.8,
|
|
ttlSec: max(1, wind.ttlSec),
|
|
condition: nil,
|
|
startsAt: nil,
|
|
bucket: .rightNow,
|
|
actions: ["DISMISS"]
|
|
)
|
|
)
|
|
}
|
|
|
|
for warning in data.warnings {
|
|
items.append(
|
|
FeedItem(
|
|
id: warning.id,
|
|
type: .weatherWarning,
|
|
title: warning.title.truncated(maxLength: TextConstraints.titleMax),
|
|
subtitle: warning.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
|
|
priority: min(max(warning.confidence, 0.0), 1.0),
|
|
ttlSec: max(1, warning.ttlSec),
|
|
condition: nil,
|
|
startsAt: nil,
|
|
bucket: .rightNow,
|
|
actions: ["DISMISS"]
|
|
)
|
|
)
|
|
}
|
|
|
|
items.sort { $0.priority > $1.priority }
|
|
await MainActor.run {
|
|
self.candidates = items
|
|
self.diagnostics = output.diagnostics
|
|
if let error = output.diagnostics["weatherkit_error"] {
|
|
self.lastError = "WeatherKit error: \(error)"
|
|
}
|
|
}
|
|
|
|
if let error = output.diagnostics["weatherkit_error"] {
|
|
self.logger.error("WeatherKit error: \(error)")
|
|
}
|
|
self.logger.info("Produced feed items count=\(items.count)")
|
|
for item in items {
|
|
self.logger.info("FeedItem id=\(item.id, privacy: .public) type=\(item.type.rawValue, privacy: .public) prio=\(item.priority, format: .fixed(precision: 2)) ttl=\(item.ttlSec) title=\(item.title, privacy: .public)")
|
|
}
|
|
if items.isEmpty {
|
|
self.logger.info("Diagnostics: \(String(describing: output.diagnostics), privacy: .public)")
|
|
}
|
|
} else {
|
|
await MainActor.run {
|
|
self.candidates = []
|
|
self.lastError = "WeatherKit requires iOS 16+."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|