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:
@@ -5,7 +5,10 @@
|
|||||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
<array>
|
<array>
|
||||||
<string>com.88oakapps.reflect.dbUpdateMissing</string>
|
<string>com.88oakapps.reflect.dbUpdateMissing</string>
|
||||||
|
<string>com.88oakapps.reflect.weatherRetry</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>Reflect uses your location to show weather details for your mood entries.</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
@@ -451,6 +451,11 @@ extension AnalyticsManager {
|
|||||||
case healthKitNotAuthorized
|
case healthKitNotAuthorized
|
||||||
case healthKitSyncCompleted(total: Int, success: Int, failed: Int)
|
case healthKitSyncCompleted(total: Int, success: Int, failed: Int)
|
||||||
|
|
||||||
|
// MARK: Weather
|
||||||
|
case weatherToggled(enabled: Bool)
|
||||||
|
case weatherFetched
|
||||||
|
case weatherFetchFailed(error: String)
|
||||||
|
|
||||||
// MARK: Navigation
|
// MARK: Navigation
|
||||||
case tabSwitched(tab: String)
|
case tabSwitched(tab: String)
|
||||||
case viewHeaderChanged(header: String)
|
case viewHeaderChanged(header: String)
|
||||||
@@ -604,6 +609,14 @@ extension AnalyticsManager {
|
|||||||
case .healthKitSyncCompleted(let total, let success, let failed):
|
case .healthKitSyncCompleted(let total, let success, let failed):
|
||||||
return ("healthkit_sync_completed", ["total": total, "success": success, "failed": failed])
|
return ("healthkit_sync_completed", ["total": total, "success": success, "failed": failed])
|
||||||
|
|
||||||
|
// Weather
|
||||||
|
case .weatherToggled(let enabled):
|
||||||
|
return ("weather_toggled", ["enabled": enabled])
|
||||||
|
case .weatherFetched:
|
||||||
|
return ("weather_fetched", nil)
|
||||||
|
case .weatherFetchFailed(let error):
|
||||||
|
return ("weather_fetch_failed", ["error": error])
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
case .tabSwitched(let tab):
|
case .tabSwitched(let tab):
|
||||||
return ("tab_switched", ["tab": tab])
|
return ("tab_switched", ["tab": tab])
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import BackgroundTasks
|
|||||||
|
|
||||||
class BGTask {
|
class BGTask {
|
||||||
static let updateDBMissingID = "com.88oakapps.reflect.dbUpdateMissing"
|
static let updateDBMissingID = "com.88oakapps.reflect.dbUpdateMissing"
|
||||||
|
static let weatherRetryID = "com.88oakapps.reflect.weatherRetry"
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class func runFillInMissingDatesTask(task: BGProcessingTask) {
|
class func runFillInMissingDatesTask(task: BGProcessingTask) {
|
||||||
@@ -27,6 +28,33 @@ class BGTask {
|
|||||||
task.setTaskCompleted(success: true)
|
task.setTaskCompleted(success: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class func runWeatherRetryTask(task: BGProcessingTask) {
|
||||||
|
BGTask.scheduleWeatherRetry()
|
||||||
|
|
||||||
|
task.expirationHandler = {
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await WeatherManager.shared.retryPendingWeatherFetches()
|
||||||
|
task.setTaskCompleted(success: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class func scheduleWeatherRetry() {
|
||||||
|
let request = BGProcessingTaskRequest(identifier: BGTask.weatherRetryID)
|
||||||
|
request.requiresNetworkConnectivity = true
|
||||||
|
request.requiresExternalPower = false
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try BGTaskScheduler.shared.submit(request)
|
||||||
|
} catch {
|
||||||
|
print("Could not schedule weather retry: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class func scheduleBackgroundProcessing() {
|
class func scheduleBackgroundProcessing() {
|
||||||
let request = BGProcessingTaskRequest(identifier: BGTask.updateDBMissingID)
|
let request = BGProcessingTaskRequest(identifier: BGTask.updateDBMissingID)
|
||||||
request.requiresNetworkConnectivity = false
|
request.requiresNetworkConnectivity = false
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ final class MoodEntryModel {
|
|||||||
var notes: String?
|
var notes: String?
|
||||||
var photoID: UUID?
|
var photoID: UUID?
|
||||||
|
|
||||||
|
// Weather
|
||||||
|
var weatherJSON: String?
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
var mood: Mood {
|
var mood: Mood {
|
||||||
Mood(rawValue: moodValue) ?? .missing
|
Mood(rawValue: moodValue) ?? .missing
|
||||||
@@ -58,7 +61,8 @@ final class MoodEntryModel {
|
|||||||
canEdit: Bool = true,
|
canEdit: Bool = true,
|
||||||
canDelete: Bool = true,
|
canDelete: Bool = true,
|
||||||
notes: String? = nil,
|
notes: String? = nil,
|
||||||
photoID: UUID? = nil
|
photoID: UUID? = nil,
|
||||||
|
weatherJSON: String? = nil
|
||||||
) {
|
) {
|
||||||
self.forDate = forDate
|
self.forDate = forDate
|
||||||
self.moodValue = mood.rawValue
|
self.moodValue = mood.rawValue
|
||||||
@@ -69,6 +73,7 @@ final class MoodEntryModel {
|
|||||||
self.canDelete = canDelete
|
self.canDelete = canDelete
|
||||||
self.notes = notes
|
self.notes = notes
|
||||||
self.photoID = photoID
|
self.photoID = photoID
|
||||||
|
self.weatherJSON = weatherJSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience initializer for raw values
|
// Convenience initializer for raw values
|
||||||
@@ -81,7 +86,8 @@ final class MoodEntryModel {
|
|||||||
canEdit: Bool = true,
|
canEdit: Bool = true,
|
||||||
canDelete: Bool = true,
|
canDelete: Bool = true,
|
||||||
notes: String? = nil,
|
notes: String? = nil,
|
||||||
photoID: UUID? = nil
|
photoID: UUID? = nil,
|
||||||
|
weatherJSON: String? = nil
|
||||||
) {
|
) {
|
||||||
self.forDate = forDate
|
self.forDate = forDate
|
||||||
self.moodValue = moodValue
|
self.moodValue = moodValue
|
||||||
@@ -92,5 +98,6 @@ final class MoodEntryModel {
|
|||||||
self.canDelete = canDelete
|
self.canDelete = canDelete
|
||||||
self.notes = notes
|
self.notes = notes
|
||||||
self.photoID = photoID
|
self.photoID = photoID
|
||||||
|
self.weatherJSON = weatherJSON
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ class UserDefaultsStore {
|
|||||||
case lockScreenStyle
|
case lockScreenStyle
|
||||||
case celebrationAnimation
|
case celebrationAnimation
|
||||||
case hapticFeedbackEnabled
|
case hapticFeedbackEnabled
|
||||||
|
case weatherEnabled
|
||||||
|
|
||||||
case contentViewCurrentSelectedHeaderViewBackDays
|
case contentViewCurrentSelectedHeaderViewBackDays
|
||||||
case contentViewHeaderTag
|
case contentViewHeaderTag
|
||||||
|
|||||||
32
Shared/Models/WeatherData.swift
Normal file
32
Shared/Models/WeatherData.swift
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
//
|
||||||
|
// WeatherData.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// Codable weather model stored as JSON string in MoodEntryModel.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct WeatherData: Codable {
|
||||||
|
let conditionSymbol: String // SF Symbol from WeatherKit (e.g. "cloud.sun.fill")
|
||||||
|
let condition: String // "Partly Cloudy"
|
||||||
|
let temperature: Double // Current/average in Celsius
|
||||||
|
let highTemperature: Double // Day high in Celsius
|
||||||
|
let lowTemperature: Double // Day low in Celsius
|
||||||
|
let humidity: Double // 0.0–1.0
|
||||||
|
let latitude: Double
|
||||||
|
let longitude: Double
|
||||||
|
let fetchedAt: Date
|
||||||
|
|
||||||
|
// MARK: - JSON Helpers
|
||||||
|
|
||||||
|
func encode() -> String? {
|
||||||
|
guard let data = try? JSONEncoder().encode(self) else { return nil }
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(from json: String) -> WeatherData? {
|
||||||
|
guard let data = json.data(using: .utf8) else { return nil }
|
||||||
|
return try? JSONDecoder().decode(WeatherData.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,10 +65,11 @@ final class MoodLogger {
|
|||||||
|
|
||||||
Self.logger.info("Applying side effects for mood \(mood.rawValue) on \(date)")
|
Self.logger.info("Applying side effects for mood \(mood.rawValue) on \(date)")
|
||||||
|
|
||||||
|
let hasAccess = !IAPManager.shared.shouldShowPaywall
|
||||||
|
|
||||||
// 1. Sync to HealthKit if enabled, requested, and user has full access
|
// 1. Sync to HealthKit if enabled, requested, and user has full access
|
||||||
if syncHealthKit {
|
if syncHealthKit {
|
||||||
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
|
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
|
||||||
let hasAccess = !IAPManager.shared.shouldShowPaywall
|
|
||||||
if healthKitEnabled && hasAccess {
|
if healthKitEnabled && hasAccess {
|
||||||
Task {
|
Task {
|
||||||
try? await HealthKitManager.shared.saveMood(mood, for: date)
|
try? await HealthKitManager.shared.saveMood(mood, for: date)
|
||||||
@@ -101,6 +102,14 @@ final class MoodLogger {
|
|||||||
|
|
||||||
// 8. Mark side effects as applied for this date
|
// 8. Mark side effects as applied for this date
|
||||||
markSideEffectsApplied(for: date)
|
markSideEffectsApplied(for: date)
|
||||||
|
|
||||||
|
// 9. Fetch weather if enabled and user has full access
|
||||||
|
let weatherEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.weatherEnabled.rawValue)
|
||||||
|
if weatherEnabled && hasAccess {
|
||||||
|
Task {
|
||||||
|
await WeatherManager.shared.fetchAndSaveWeather(for: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a mood entry for a specific date with all associated cleanup.
|
/// Delete a mood entry for a specific date with all associated cleanup.
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ protocol MoodDataWriting {
|
|||||||
@discardableResult
|
@discardableResult
|
||||||
func updatePhoto(forDate date: Date, photoID: UUID?) -> Bool
|
func updatePhoto(forDate date: Date, photoID: UUID?) -> Bool
|
||||||
|
|
||||||
|
/// Update weather for an entry
|
||||||
|
@discardableResult
|
||||||
|
func updateWeather(forDate date: Date, weatherJSON: String?) -> Bool
|
||||||
|
|
||||||
/// Fill in missing dates with placeholder entries
|
/// Fill in missing dates with placeholder entries
|
||||||
func fillInMissingDates()
|
func fillInMissingDates()
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,19 @@ extension DataController {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Weather
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func updateWeather(forDate date: Date, weatherJSON: String?) -> Bool {
|
||||||
|
guard let entry = getEntry(byDate: date) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.weatherJSON = weatherJSON
|
||||||
|
saveAndRunDataListeners()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Photo
|
// MARK: - Photo
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ struct ReflectApp: App {
|
|||||||
guard let processingTask = task as? BGProcessingTask else { return }
|
guard let processingTask = task as? BGProcessingTask else { return }
|
||||||
BGTask.runFillInMissingDatesTask(task: processingTask)
|
BGTask.runFillInMissingDatesTask(task: processingTask)
|
||||||
}
|
}
|
||||||
|
BGTaskScheduler.shared.register(forTaskWithIdentifier: BGTask.weatherRetryID, using: nil) { task in
|
||||||
|
guard let processingTask = task as? BGProcessingTask else { return }
|
||||||
|
BGTask.runWeatherRetryTask(task: processingTask)
|
||||||
|
}
|
||||||
UNUserNotificationCenter.current().setBadgeCount(0)
|
UNUserNotificationCenter.current().setBadgeCount(0)
|
||||||
|
|
||||||
// Reset tips session on app launch
|
// Reset tips session on app launch
|
||||||
|
|||||||
71
Shared/Services/LocationManager.swift
Normal file
71
Shared/Services/LocationManager.swift
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
//
|
||||||
|
// LocationManager.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// CoreLocation wrapper with async/await for one-shot location requests.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreLocation
|
||||||
|
import os.log
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LocationManager: NSObject {
|
||||||
|
static let shared = LocationManager()
|
||||||
|
|
||||||
|
private static let logger = Logger(
|
||||||
|
subsystem: Bundle.main.bundleIdentifier ?? "com.88oakapps.reflect",
|
||||||
|
category: "LocationManager"
|
||||||
|
)
|
||||||
|
|
||||||
|
private let manager = CLLocationManager()
|
||||||
|
private var locationContinuation: CheckedContinuation<CLLocation, Error>?
|
||||||
|
|
||||||
|
var authorizationStatus: CLAuthorizationStatus {
|
||||||
|
manager.authorizationStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
manager.delegate = self
|
||||||
|
manager.desiredAccuracy = kCLLocationAccuracyKilometer
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestAuthorization() {
|
||||||
|
manager.requestWhenInUseAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentLocation: CLLocation {
|
||||||
|
get async throws {
|
||||||
|
// Return last known location if recent enough (within 10 minutes)
|
||||||
|
if let last = manager.location,
|
||||||
|
abs(last.timestamp.timeIntervalSinceNow) < 600 {
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
self.locationContinuation = continuation
|
||||||
|
self.manager.requestLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CLLocationManagerDelegate
|
||||||
|
|
||||||
|
extension LocationManager: CLLocationManagerDelegate {
|
||||||
|
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let location = locations.first else { return }
|
||||||
|
locationContinuation?.resume(returning: location)
|
||||||
|
locationContinuation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||||
|
Task { @MainActor in
|
||||||
|
Self.logger.error("Location request failed: \(error.localizedDescription)")
|
||||||
|
locationContinuation?.resume(throwing: error)
|
||||||
|
locationContinuation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
Shared/Views/DayView/WeatherCardView.swift
Normal file
70
Shared/Views/DayView/WeatherCardView.swift
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
//
|
||||||
|
// WeatherCardView.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// Visual weather card shown in EntryDetailView.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WeatherCardView: View {
|
||||||
|
let weatherData: WeatherData
|
||||||
|
|
||||||
|
private var highTemp: String {
|
||||||
|
formatTemperature(weatherData.highTemperature)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lowTemp: String {
|
||||||
|
formatTemperature(weatherData.lowTemperature)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var humidityPercent: String {
|
||||||
|
"\(Int(weatherData.humidity * 100))%"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: weatherData.conditionSymbol)
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.symbolRenderingMode(.multicolor)
|
||||||
|
.frame(width: 44)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(weatherData.condition)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Label(String(localized: "H: \(highTemp)"), systemImage: "thermometer.high")
|
||||||
|
Label(String(localized: "L: \(lowTemp)"), systemImage: "thermometer.low")
|
||||||
|
Label(humidityPercent, systemImage: "humidity")
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.labelStyle(.titleOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(Color(.systemBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatTemperature(_ celsius: Double) -> String {
|
||||||
|
let measurement = Measurement(value: celsius, unit: UnitTemperature.celsius)
|
||||||
|
let formatter = MeasurementFormatter()
|
||||||
|
formatter.unitOptions = .providedUnit
|
||||||
|
formatter.numberFormatter.maximumFractionDigits = 0
|
||||||
|
formatter.unitStyle = .short
|
||||||
|
// Use locale-aware conversion
|
||||||
|
let locale = Locale.current
|
||||||
|
if locale.measurementSystem == .us {
|
||||||
|
let fahrenheit = measurement.converted(to: .fahrenheit)
|
||||||
|
return formatter.string(from: fahrenheit)
|
||||||
|
}
|
||||||
|
return formatter.string(from: measurement)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -187,6 +187,12 @@ struct EntryDetailView: View {
|
|||||||
// Notes section
|
// Notes section
|
||||||
notesSection
|
notesSection
|
||||||
|
|
||||||
|
// Weather section
|
||||||
|
if let weatherJSON = entry.weatherJSON,
|
||||||
|
let weatherData = WeatherData.decode(from: weatherJSON) {
|
||||||
|
weatherSection(weatherData)
|
||||||
|
}
|
||||||
|
|
||||||
// Photo section
|
// Photo section
|
||||||
photoSection
|
photoSection
|
||||||
|
|
||||||
@@ -411,6 +417,16 @@ struct EntryDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func weatherSection(_ weatherData: WeatherData) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Weather")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
|
WeatherCardView(weatherData: weatherData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var photoSection: some View {
|
private var photoSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ struct SettingsContentView: View {
|
|||||||
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
|
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
|
||||||
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||||
@AppStorage(UserDefaultsStore.Keys.hapticFeedbackEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var hapticFeedbackEnabled = true
|
@AppStorage(UserDefaultsStore.Keys.hapticFeedbackEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var hapticFeedbackEnabled = true
|
||||||
|
@AppStorage(UserDefaultsStore.Keys.weatherEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var weatherEnabled = true
|
||||||
|
@State private var showWeatherSubscriptionStore = false
|
||||||
|
|
||||||
private var textColor: Color { theme.currentTheme.labelColor }
|
private var textColor: Color { theme.currentTheme.labelColor }
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ struct SettingsContentView: View {
|
|||||||
featuresSectionHeader
|
featuresSectionHeader
|
||||||
privacyLockToggle
|
privacyLockToggle
|
||||||
healthKitToggle
|
healthKitToggle
|
||||||
|
weatherToggle
|
||||||
exportDataButton
|
exportDataButton
|
||||||
|
|
||||||
// Settings section
|
// Settings section
|
||||||
@@ -959,6 +962,72 @@ struct SettingsContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Weather Toggle
|
||||||
|
|
||||||
|
private var weatherToggle: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "cloud.sun.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.frame(width: 32)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Weather")
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
|
Text("Show weather details for each day")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Toggle("", isOn: Binding(
|
||||||
|
get: { iapManager.shouldShowPaywall ? false : weatherEnabled },
|
||||||
|
set: { newValue in
|
||||||
|
if iapManager.shouldShowPaywall {
|
||||||
|
showWeatherSubscriptionStore = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
weatherEnabled = newValue
|
||||||
|
|
||||||
|
if newValue {
|
||||||
|
LocationManager.shared.requestAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
AnalyticsManager.shared.track(.weatherToggled(enabled: newValue))
|
||||||
|
}
|
||||||
|
))
|
||||||
|
.labelsHidden()
|
||||||
|
.disabled(iapManager.shouldShowPaywall)
|
||||||
|
.accessibilityLabel(String(localized: "Weather"))
|
||||||
|
.accessibilityHint(String(localized: "Show weather details for each day"))
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
if iapManager.shouldShowPaywall {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "crown.fill")
|
||||||
|
.foregroundColor(.yellow)
|
||||||
|
Text("Premium Feature")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel(String(localized: "Premium feature, subscription required"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
|
.sheet(isPresented: $showWeatherSubscriptionStore) {
|
||||||
|
ReflectSubscriptionStoreView(source: "settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Export Data Button
|
// MARK: - Export Data Button
|
||||||
|
|
||||||
private var exportDataButton: some View {
|
private var exportDataButton: some View {
|
||||||
@@ -1240,6 +1309,8 @@ struct SettingsView: View {
|
|||||||
@AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date()
|
@AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date()
|
||||||
@AppStorage(UserDefaultsStore.Keys.privacyLockEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var privacyLockEnabled = false
|
@AppStorage(UserDefaultsStore.Keys.privacyLockEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var privacyLockEnabled = false
|
||||||
@AppStorage(UserDefaultsStore.Keys.hapticFeedbackEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var hapticFeedbackEnabled = true
|
@AppStorage(UserDefaultsStore.Keys.hapticFeedbackEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var hapticFeedbackEnabled = true
|
||||||
|
@AppStorage(UserDefaultsStore.Keys.weatherEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var weatherEnabled = true
|
||||||
|
@State private var showWeatherSubscriptionStore = false
|
||||||
|
|
||||||
private var textColor: Color { theme.currentTheme.labelColor }
|
private var textColor: Color { theme.currentTheme.labelColor }
|
||||||
|
|
||||||
@@ -1257,6 +1328,7 @@ struct SettingsView: View {
|
|||||||
featuresSectionHeader
|
featuresSectionHeader
|
||||||
privacyLockToggle
|
privacyLockToggle
|
||||||
healthKitToggle
|
healthKitToggle
|
||||||
|
weatherToggle
|
||||||
exportDataButton
|
exportDataButton
|
||||||
|
|
||||||
// Settings section
|
// Settings section
|
||||||
@@ -1560,6 +1632,68 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Weather Toggle
|
||||||
|
|
||||||
|
private var weatherToggle: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "cloud.sun.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.frame(width: 32)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Weather")
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
|
Text("Show weather details for each day")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Toggle("", isOn: Binding(
|
||||||
|
get: { iapManager.shouldShowPaywall ? false : weatherEnabled },
|
||||||
|
set: { newValue in
|
||||||
|
if iapManager.shouldShowPaywall {
|
||||||
|
showWeatherSubscriptionStore = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
weatherEnabled = newValue
|
||||||
|
|
||||||
|
if newValue {
|
||||||
|
LocationManager.shared.requestAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
AnalyticsManager.shared.track(.weatherToggled(enabled: newValue))
|
||||||
|
}
|
||||||
|
))
|
||||||
|
.labelsHidden()
|
||||||
|
.disabled(iapManager.shouldShowPaywall)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
if iapManager.shouldShowPaywall {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "crown.fill")
|
||||||
|
.foregroundColor(.yellow)
|
||||||
|
Text("Premium Feature")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
|
.sheet(isPresented: $showWeatherSubscriptionStore) {
|
||||||
|
ReflectSubscriptionStoreView(source: "settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Export Data Button
|
// MARK: - Export Data Button
|
||||||
|
|
||||||
private var exportDataButton: some View {
|
private var exportDataButton: some View {
|
||||||
|
|||||||
Reference in New Issue
Block a user