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 {