diff --git a/Reflect--iOS--Info.plist b/Reflect--iOS--Info.plist
index aaeefa3..062fe43 100644
--- a/Reflect--iOS--Info.plist
+++ b/Reflect--iOS--Info.plist
@@ -5,7 +5,10 @@
BGTaskSchedulerPermittedIdentifiers
com.88oakapps.reflect.dbUpdateMissing
+ com.88oakapps.reflect.weatherRetry
+ NSLocationWhenInUseUsageDescription
+ Reflect uses your location to show weather details for your mood entries.
CFBundleURLTypes
diff --git a/Shared/Analytics.swift b/Shared/Analytics.swift
index 822e2ff..ea3ccd3 100644
--- a/Shared/Analytics.swift
+++ b/Shared/Analytics.swift
@@ -451,6 +451,11 @@ extension AnalyticsManager {
case healthKitNotAuthorized
case healthKitSyncCompleted(total: Int, success: Int, failed: Int)
+ // MARK: Weather
+ case weatherToggled(enabled: Bool)
+ case weatherFetched
+ case weatherFetchFailed(error: String)
+
// MARK: Navigation
case tabSwitched(tab: String)
case viewHeaderChanged(header: String)
@@ -604,6 +609,14 @@ extension AnalyticsManager {
case .healthKitSyncCompleted(let total, let success, let 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
case .tabSwitched(let tab):
return ("tab_switched", ["tab": tab])
diff --git a/Shared/BGTask.swift b/Shared/BGTask.swift
index 7190a8d..a01c74d 100644
--- a/Shared/BGTask.swift
+++ b/Shared/BGTask.swift
@@ -10,6 +10,7 @@ import BackgroundTasks
class BGTask {
static let updateDBMissingID = "com.88oakapps.reflect.dbUpdateMissing"
+ static let weatherRetryID = "com.88oakapps.reflect.weatherRetry"
@MainActor
class func runFillInMissingDatesTask(task: BGProcessingTask) {
@@ -27,6 +28,33 @@ class BGTask {
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() {
let request = BGProcessingTaskRequest(identifier: BGTask.updateDBMissingID)
request.requiresNetworkConnectivity = false
diff --git a/Shared/Models/MoodEntryModel.swift b/Shared/Models/MoodEntryModel.swift
index 0eb5479..950cd04 100644
--- a/Shared/Models/MoodEntryModel.swift
+++ b/Shared/Models/MoodEntryModel.swift
@@ -42,6 +42,9 @@ final class MoodEntryModel {
var notes: String?
var photoID: UUID?
+ // Weather
+ var weatherJSON: String?
+
// Computed properties
var mood: Mood {
Mood(rawValue: moodValue) ?? .missing
@@ -58,7 +61,8 @@ final class MoodEntryModel {
canEdit: Bool = true,
canDelete: Bool = true,
notes: String? = nil,
- photoID: UUID? = nil
+ photoID: UUID? = nil,
+ weatherJSON: String? = nil
) {
self.forDate = forDate
self.moodValue = mood.rawValue
@@ -69,6 +73,7 @@ final class MoodEntryModel {
self.canDelete = canDelete
self.notes = notes
self.photoID = photoID
+ self.weatherJSON = weatherJSON
}
// Convenience initializer for raw values
@@ -81,7 +86,8 @@ final class MoodEntryModel {
canEdit: Bool = true,
canDelete: Bool = true,
notes: String? = nil,
- photoID: UUID? = nil
+ photoID: UUID? = nil,
+ weatherJSON: String? = nil
) {
self.forDate = forDate
self.moodValue = moodValue
@@ -92,5 +98,6 @@ final class MoodEntryModel {
self.canDelete = canDelete
self.notes = notes
self.photoID = photoID
+ self.weatherJSON = weatherJSON
}
}
diff --git a/Shared/Models/UserDefaultsStore.swift b/Shared/Models/UserDefaultsStore.swift
index a3dab09..ccb7c5d 100644
--- a/Shared/Models/UserDefaultsStore.swift
+++ b/Shared/Models/UserDefaultsStore.swift
@@ -207,6 +207,7 @@ class UserDefaultsStore {
case lockScreenStyle
case celebrationAnimation
case hapticFeedbackEnabled
+ case weatherEnabled
case contentViewCurrentSelectedHeaderViewBackDays
case contentViewHeaderTag
diff --git a/Shared/Models/WeatherData.swift b/Shared/Models/WeatherData.swift
new file mode 100644
index 0000000..f6396fb
--- /dev/null
+++ b/Shared/Models/WeatherData.swift
@@ -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)
+ }
+}
diff --git a/Shared/MoodLogger.swift b/Shared/MoodLogger.swift
index b9c1d92..56a0400 100644
--- a/Shared/MoodLogger.swift
+++ b/Shared/MoodLogger.swift
@@ -65,10 +65,11 @@ final class MoodLogger {
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
if syncHealthKit {
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
- let hasAccess = !IAPManager.shared.shouldShowPaywall
if healthKitEnabled && hasAccess {
Task {
try? await HealthKitManager.shared.saveMood(mood, for: date)
@@ -101,6 +102,14 @@ final class MoodLogger {
// 8. Mark side effects as applied for this 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.
diff --git a/Shared/Persisence/DataControllerProtocol.swift b/Shared/Persisence/DataControllerProtocol.swift
index 9f5659f..b52cf97 100644
--- a/Shared/Persisence/DataControllerProtocol.swift
+++ b/Shared/Persisence/DataControllerProtocol.swift
@@ -49,6 +49,10 @@ protocol MoodDataWriting {
@discardableResult
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
func fillInMissingDates()
diff --git a/Shared/Persisence/DataControllerUPDATE.swift b/Shared/Persisence/DataControllerUPDATE.swift
index d0a5427..d42fc56 100644
--- a/Shared/Persisence/DataControllerUPDATE.swift
+++ b/Shared/Persisence/DataControllerUPDATE.swift
@@ -38,6 +38,19 @@ extension DataController {
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
@discardableResult
diff --git a/Shared/ReflectApp.swift b/Shared/ReflectApp.swift
index 6cfce52..bbc580d 100644
--- a/Shared/ReflectApp.swift
+++ b/Shared/ReflectApp.swift
@@ -36,6 +36,10 @@ struct ReflectApp: App {
guard let processingTask = task as? BGProcessingTask else { return }
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)
// Reset tips session on app launch
diff --git a/Shared/Services/LocationManager.swift b/Shared/Services/LocationManager.swift
new file mode 100644
index 0000000..c27e947
--- /dev/null
+++ b/Shared/Services/LocationManager.swift
@@ -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?
+
+ 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
+ }
+ }
+}
diff --git a/Shared/Services/WeatherManager.swift b/Shared/Services/WeatherManager.swift
new file mode 100644
index 0000000..fb63adf
--- /dev/null
+++ b/Shared/Services/WeatherManager.swift
@@ -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"
+ }
+ }
+ }
+}
diff --git a/Shared/Views/DayView/WeatherCardView.swift b/Shared/Views/DayView/WeatherCardView.swift
new file mode 100644
index 0000000..5588f23
--- /dev/null
+++ b/Shared/Views/DayView/WeatherCardView.swift
@@ -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)
+ }
+}
diff --git a/Shared/Views/NoteEditorView.swift b/Shared/Views/NoteEditorView.swift
index 78cfd1a..5706261 100644
--- a/Shared/Views/NoteEditorView.swift
+++ b/Shared/Views/NoteEditorView.swift
@@ -187,6 +187,12 @@ struct EntryDetailView: View {
// Notes section
notesSection
+ // Weather section
+ if let weatherJSON = entry.weatherJSON,
+ let weatherData = WeatherData.decode(from: weatherJSON) {
+ weatherSection(weatherData)
+ }
+
// Photo section
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 {
VStack(alignment: .leading, spacing: 12) {
HStack {
diff --git a/Shared/Views/SettingsView/SettingsView.swift b/Shared/Views/SettingsView/SettingsView.swift
index a0cacab..53bb2f5 100644
--- a/Shared/Views/SettingsView/SettingsView.swift
+++ b/Shared/Views/SettingsView/SettingsView.swift
@@ -39,6 +39,8 @@ struct SettingsContentView: View {
@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.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 }
@@ -49,6 +51,7 @@ struct SettingsContentView: View {
featuresSectionHeader
privacyLockToggle
healthKitToggle
+ weatherToggle
exportDataButton
// 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
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.privacyLockEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var privacyLockEnabled = false
@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 }
@@ -1257,6 +1328,7 @@ struct SettingsView: View {
featuresSectionHeader
privacyLockToggle
healthKitToggle
+ weatherToggle
exportDataButton
// 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
private var exportDataButton: some View {