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>
165 lines
5.6 KiB
Swift
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"
|
|
}
|
|
}
|
|
}
|
|
}
|