Fetch and display weather data (temp, condition, hi/lo, humidity) when users log a mood. Weather is stored as JSON on MoodEntryModel and shown as a card in EntryDetailView. Premium-gated with location permission prompt. Includes BGTask retry for failed fetches and full analytics. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
150 lines
4.8 KiB
Swift
150 lines
4.8 KiB
Swift
//
|
|
// WeatherManager.swift
|
|
// Reflect
|
|
//
|
|
// WeatherKit fetch service for attaching weather data to mood entries.
|
|
//
|
|
|
|
import Foundation
|
|
import WeatherKit
|
|
import CoreLocation
|
|
import os.log
|
|
|
|
@MainActor
|
|
final class WeatherManager {
|
|
static let shared = WeatherManager()
|
|
|
|
private static let logger = Logger(
|
|
subsystem: Bundle.main.bundleIdentifier ?? "com.88oakapps.reflect",
|
|
category: "WeatherManager"
|
|
)
|
|
|
|
private static let retryQueueKey = "weatherRetryQueue"
|
|
|
|
private init() {}
|
|
|
|
// MARK: - Fetch Weather
|
|
|
|
func fetchWeather(for date: Date, at location: CLLocation) async throws -> WeatherData {
|
|
let calendar = Calendar.current
|
|
let startOfDay = calendar.startOfDay(for: date)
|
|
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
|
|
|
|
let (daily, current) = try await WeatherService.shared.weather(
|
|
for: location,
|
|
including: .daily(startDate: startOfDay, endDate: endOfDay), .current
|
|
)
|
|
|
|
guard let dayWeather = daily.forecast.first else {
|
|
throw WeatherError.noDataAvailable
|
|
}
|
|
|
|
return WeatherData(
|
|
conditionSymbol: dayWeather.symbolName,
|
|
condition: dayWeather.condition.description,
|
|
temperature: (dayWeather.highTemperature.value + dayWeather.lowTemperature.value) / 2.0,
|
|
highTemperature: dayWeather.highTemperature.value,
|
|
lowTemperature: dayWeather.lowTemperature.value,
|
|
humidity: current.humidity,
|
|
latitude: location.coordinate.latitude,
|
|
longitude: location.coordinate.longitude,
|
|
fetchedAt: Date()
|
|
)
|
|
}
|
|
|
|
// MARK: - Fetch and Save
|
|
|
|
func fetchAndSaveWeather(for date: Date) async {
|
|
do {
|
|
let location = try await LocationManager.shared.currentLocation
|
|
|
|
let weatherData = try await fetchWeather(for: date, at: location)
|
|
|
|
guard let json = weatherData.encode() else {
|
|
Self.logger.error("Failed to encode weather data")
|
|
return
|
|
}
|
|
|
|
DataController.shared.updateWeather(forDate: date, weatherJSON: json)
|
|
AnalyticsManager.shared.track(.weatherFetched)
|
|
|
|
Self.logger.info("Weather saved for \(date)")
|
|
} catch {
|
|
Self.logger.error("Weather fetch failed: \(error.localizedDescription)")
|
|
AnalyticsManager.shared.track(.weatherFetchFailed(error: error.localizedDescription))
|
|
addToRetryQueue(date: date)
|
|
}
|
|
}
|
|
|
|
// MARK: - Retry Queue
|
|
|
|
func retryPendingWeatherFetches() async {
|
|
let dates = getRetryQueue()
|
|
guard !dates.isEmpty else { return }
|
|
|
|
Self.logger.info("Retrying weather fetch for \(dates.count) entries")
|
|
|
|
var remainingDates: [Date] = []
|
|
|
|
for date in dates {
|
|
do {
|
|
let location = try await LocationManager.shared.currentLocation
|
|
let weatherData = try await fetchWeather(for: date, at: location)
|
|
|
|
guard let json = weatherData.encode() else { continue }
|
|
|
|
DataController.shared.updateWeather(forDate: date, weatherJSON: json)
|
|
Self.logger.info("Retry succeeded for \(date)")
|
|
} catch {
|
|
Self.logger.error("Retry failed for \(date): \(error.localizedDescription)")
|
|
remainingDates.append(date)
|
|
}
|
|
}
|
|
|
|
saveRetryQueue(remainingDates)
|
|
}
|
|
|
|
private func addToRetryQueue(date: Date) {
|
|
var queue = getRetryQueue()
|
|
let startOfDay = Calendar.current.startOfDay(for: date)
|
|
|
|
// Don't add duplicates
|
|
guard !queue.contains(where: { Calendar.current.isDate($0, inSameDayAs: startOfDay) }) else { return }
|
|
|
|
queue.append(startOfDay)
|
|
|
|
// Keep only last 7 days of retries
|
|
let sevenDaysAgo = Calendar.current.date(byAdding: .day, value: -7, to: Date())!
|
|
queue = queue.filter { $0 > sevenDaysAgo }
|
|
|
|
saveRetryQueue(queue)
|
|
}
|
|
|
|
private func getRetryQueue() -> [Date] {
|
|
guard let strings = GroupUserDefaults.groupDefaults.stringArray(forKey: Self.retryQueueKey) else {
|
|
return []
|
|
}
|
|
let formatter = ISO8601DateFormatter()
|
|
return strings.compactMap { formatter.date(from: $0) }
|
|
}
|
|
|
|
private func saveRetryQueue(_ dates: [Date]) {
|
|
let formatter = ISO8601DateFormatter()
|
|
let strings = dates.map { formatter.string(from: $0) }
|
|
GroupUserDefaults.groupDefaults.set(strings, forKey: Self.retryQueueKey)
|
|
}
|
|
|
|
// MARK: - Error
|
|
|
|
enum WeatherError: LocalizedError {
|
|
case noDataAvailable
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .noDataAvailable:
|
|
return "No weather data available for the requested date"
|
|
}
|
|
}
|
|
}
|
|
}
|