Files
Reflect/Shared/Services/WeatherManager.swift
Trey t 78d09803c3 Fix location/weather error handling and complete localization
Add authorization pre-check and 15s timeout to LocationManager to
prevent hanging continuations. WeatherManager now skips retry queue
when location permission is denied. Settings weather toggle shows
alert directing users to Settings when location is denied. Fill
remaining 32 untranslated strings to reach 100% localization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:37:28 -05:00

165 lines
5.6 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 let error as LocationManager.LocationError where error == .permissionDenied {
Self.logger.warning("Weather fetch skipped: location permission denied")
AnalyticsManager.shared.track(.weatherFetchFailed(error: "location_permission_denied"))
// Don't add to retry queue retrying won't help until user grants permission
} 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 {
// Bail early if location permission is denied retrying will always fail
let status = LocationManager.shared.authorizationStatus
if status == .denied || status == .restricted {
Self.logger.info("Clearing weather retry queue — location permission denied")
saveRetryQueue([])
return
}
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
case locationDenied
var errorDescription: String? {
switch self {
case .noDataAvailable:
return "No weather data available for the requested date"
case .locationDenied:
return "Location permission denied — cannot fetch weather"
}
}
}
}