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>
This commit is contained in:
149
Shared/Services/WeatherManager.swift
Normal file
149
Shared/Services/WeatherManager.swift
Normal file
@@ -0,0 +1,149 @@
|
||||
//
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user