Files
Reflect/Shared/Services/WeatherManager.swift
Trey t 31fb2a7fe2 Add weather feature with WeatherKit integration for mood entries
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>
2026-03-11 00:16:26 -05:00

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"
}
}
}
}