From a9437430c35c56cc294204cea4565770888fbaa7 Mon Sep 17 00:00:00 2001 From: rgvxsthi <65727207+rgvxsthi@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:12:28 +1000 Subject: [PATCH 1/2] =?UTF-8?q?feat(settings):=20Privacy=20&=20Data=20rese?= =?UTF-8?q?t=20=E2=80=94=20sectioned=20page=20+=20unpair/reset=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure Privacy & Data into three iOS-grouped SettingsGroup sections (Diagnostics · App data · Demo data) and add reset/restore actions: - Unpair Ring — ble.forget() (ring forgets this phone; health data kept). - Reset App Data — factory reset: SeedData.clearAll (all SwiftData) + delete coach API keys from Keychain (OpenAI/Gemini/OpenRouter/MiniMax) + wipe the app's UserDefaults domain + reset the in-memory setting singletons to defaults. Emptying UserProfile returns the app to onboarding (RootViews gates on it). - Unpair Ring & Reset App Data — forget() first (flush the unbind), then reset. All three are confirmation-gated (destructive role, strong warning on reset). Export diagnostics + Clear/Reseed demo data preserved, re-sectioned. --- .../Settings/PrivacyDataSettingsView.swift | 217 ++++++++++++++++-- 1 file changed, 198 insertions(+), 19 deletions(-) diff --git a/PulseLoop/Views/Settings/PrivacyDataSettingsView.swift b/PulseLoop/Views/Settings/PrivacyDataSettingsView.swift index b42a078..9b83d3d 100644 --- a/PulseLoop/Views/Settings/PrivacyDataSettingsView.swift +++ b/PulseLoop/Views/Settings/PrivacyDataSettingsView.swift @@ -2,35 +2,113 @@ import SwiftUI import SwiftData import UIKit -/// Privacy & Data detail screen: export diagnostics, and clear/reseed local data. Reflects the app's -/// transparency/privacy ethos — everything here is local and explicit. +/// Privacy & Data detail screen. Grouped iOS-style sections: +/// • Diagnostics — export a diagnostics bundle (leaves room for a future Import). +/// • App data — destructive reset/restore: unpair the ring, factory-reset app data, or both. +/// • Demo data — clear or reseed the local demo dataset. +/// Everything here is local and explicit, reflecting the app's transparency/privacy ethos. struct PrivacyDataSettingsView: View { @Environment(\.modelContext) private var modelContext + @Environment(RingBLEClient.self) private var ble @State private var diagnosticsURL: URL? + /// Which destructive App-data action is awaiting confirmation. + @State private var pendingReset: ResetAction? + + private enum ResetAction: Identifiable { + case unpairRing + case resetAppData + case unpairAndReset + + var id: Int { hashValue } + + var title: String { + switch self { + case .unpairRing: return "Unpair ring?" + case .resetAppData: return "Reset app data?" + case .unpairAndReset: return "Unpair ring & reset app data?" + } + } + + var message: String { + switch self { + case .unpairRing: + return "Unpair your ring? The ring will forget this phone; your data stays." + case .resetAppData: + return "This permanently erases all your data — metrics, sleep, activity, coach history, settings, and saved API keys — and can't be undone." + case .unpairAndReset: + return "This unpairs your ring, then permanently erases all your data — metrics, sleep, activity, coach history, settings, and saved API keys — and can't be undone." + } + } + + /// Destructive-button label inside the confirmation dialog. + var confirmLabel: String { + switch self { + case .unpairRing: return "Unpair ring" + case .resetAppData: return "Reset app data" + case .unpairAndReset: return "Unpair & reset" + } + } + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 22) { - StatusCopy( - title: "Local-first", - body: "Ring data is stored only on this device. Nothing is uploaded except a Coach question you choose to send." - ) - - SecondaryButton(title: "Export diagnostics", systemImage: "square.and.arrow.up") { - diagnosticsURL = DiagnosticsExporter.exportFile(context: modelContext) + // MARK: Diagnostics + SettingsGroup( + header: "Diagnostics", + footer: "Ring data is stored only on this device. Nothing is uploaded except a Coach question you choose to send." + ) { + FormField { + SecondaryButton(title: "Export diagnostics", systemImage: "square.and.arrow.up") { + diagnosticsURL = DiagnosticsExporter.exportFile(context: modelContext) + } + } } - SecondaryButton(title: "Clear demo data", systemImage: "trash") { - SeedData.clearAll(modelContext) - let fresh = UserProfile() - fresh.onboardingCompleted = true - fresh.baselineCompleted = true - modelContext.insert(fresh) - try? modelContext.save() + // MARK: App data (reset & restore) + SettingsGroup( + header: "App data", + footer: "Unpair releases the ring for other apps and keeps your health data. Resetting erases everything on this device and returns the app to onboarding." + ) { + FormField { + DangerButton(title: "Unpair ring", systemImage: "wave.3.right.circle") { + pendingReset = .unpairRing + } + } + FormField { + DangerButton(title: "Reset app data", systemImage: "trash") { + pendingReset = .resetAppData + } + } + FormField { + DangerButton(title: "Unpair ring & reset app data", systemImage: "trash.slash") { + pendingReset = .unpairAndReset + } + } } - SecondaryButton(title: "Reseed demo data", systemImage: "arrow.clockwise") { - SeedData.clearAll(modelContext) - SeedData.seedDemo(modelContext, completeOnboarding: true) + + // MARK: Demo data + SettingsGroup( + header: "Demo data", + footer: "Load or clear the sample dataset used to explore the app without a ring." + ) { + FormField { + SecondaryButton(title: "Clear demo data", systemImage: "trash") { + SeedData.clearAll(modelContext) + let fresh = UserProfile() + fresh.onboardingCompleted = true + fresh.baselineCompleted = true + modelContext.insert(fresh) + try? modelContext.save() + } + } + FormField { + SecondaryButton(title: "Reseed demo data", systemImage: "arrow.clockwise") { + SeedData.clearAll(modelContext) + SeedData.seedDemo(modelContext, completeOnboarding: true) + } + } } } .padding() @@ -40,6 +118,107 @@ struct PrivacyDataSettingsView: View { .sheet(item: $diagnosticsURL) { url in DiagnosticsShareSheet(items: [url]) } + .confirmationDialog( + pendingReset?.title ?? "", + isPresented: Binding( + get: { pendingReset != nil }, + set: { if !$0 { pendingReset = nil } } + ), + titleVisibility: .visible, + presenting: pendingReset + ) { action in + Button(action.confirmLabel, role: .destructive) { + perform(action) + } + Button("Cancel", role: .cancel) {} + } message: { action in + Text(action.message) + } + } + + // MARK: - Reset actions + + private func perform(_ action: ResetAction) { + switch action { + case .unpairRing: + ble.forget() + case .resetAppData: + resetAppData() + case .unpairAndReset: + // Unbind the ring FIRST so the UNBOND write goes out before the store is wiped. + ble.forget() + resetAppData() + } + } + + /// Factory reset: wipe SwiftData, the coach API keys in Keychain, and all UserDefaults, then reset the + /// in-memory shared settings singletons so the wipe reflects without a relaunch. Clearing every + /// `UserProfile` makes RootViews' `profiles` @Query empty, so it switches back to onboarding on its own. + private func resetAppData() { + // 1. All SwiftData model types (this removes UserProfile → RootViews returns to onboarding). + SeedData.clearAll(modelContext) + + // 2. Coach API keys from the Keychain (survive UserDefaults wipe, so delete explicitly). + try? OpenAIKeychainStore().deleteKey() + try? GeminiKeychainStore().deleteKey() + try? OpenRouterKeychainStore().deleteKey() + try? MiniMaxKeychainStore().deleteKey() + + // 3. Wipe UserDefaults (metric prefs, coach settings, calibration, remembered ring, flags, …). + if let bundleID = Bundle.main.bundleIdentifier { + UserDefaults.standard.removePersistentDomain(forName: bundleID) + } + + // 4. Reset the in-memory shared settings singletons so the cleared state shows immediately + // (they cache their `settings` in memory; reassigning to `.default` re-persists the fresh + // defaults into the now-empty UserDefaults too). + MetricPrefsStore.shared.settings = .default + CoachSettingsStore.shared.settings = .default + CalibrationStore.shared.settings = .default + + // 5. RootViews reacts to the emptied `profiles` @Query and swaps MainTabView → OnboardingFlowView, + // tearing down this pushed page automatically — no manual navigation needed. In DEBUG the + // `forceOnboarding` flag also gates onboarding, so re-arm it after the domain wipe cleared it. + #if DEBUG + UserDefaults.standard.set(true, forKey: "forceOnboarding") + #endif + } +} + +/// Full-width destructive action styled to match `SecondaryButton` (capsule + glass/card) but tinted +/// `PulseColors.danger`. The app has no shared destructive button, so this local one keeps the three +/// App-data actions visually consistent with the rest of the settings while reading clearly as danger. +private struct DangerButton: View { + let title: String + var systemImage: String? + let action: () -> Void + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + + private var label: some View { + Label(title, systemImage: systemImage ?? "exclamationmark.triangle") + .font(PulseFont.callout.weight(.semibold)) + .frame(maxWidth: .infinity) + .frame(height: 52) + } + + var body: some View { + if #available(iOS 26, *), !reduceTransparency { + Button(role: .destructive, action: action) { + label.foregroundStyle(PulseColors.danger) + } + .buttonStyle(.glass) + .clipShape(Capsule()) + } else { + Button(role: .destructive, action: action) { + label + .foregroundStyle(PulseColors.danger) + .background(PulseColors.card) + .clipShape(Capsule()) + .overlay { + Capsule().stroke(PulseColors.danger.opacity(0.4), lineWidth: 1) + } + } + } } } From 208ce539102e806f45eeab869bce833414c78ad9 Mon Sep 17 00:00:00 2001 From: rgvxsthi <65727207+rgvxsthi@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:26:53 +1000 Subject: [PATCH 2/2] =?UTF-8?q?refactor(settings):=20Privacy=20&=20Data=20?= =?UTF-8?q?=E2=80=94=20clean=20grouped=20rows=20instead=20of=20nested=20pi?= =?UTF-8?q?lls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reset/export/demo actions were capsule buttons wrapped in FormField inside SettingsGroup, which nested a pill inside each group card (redundant surfaces + wasted space). Replace them with flat full-width action rows placed directly in SettingsGroup, so each section is one inset card of rows with hairline dividers and a one-line footer description — matching the app's other grouped settings. Destructive App-data actions are tinted danger; behavior (confirmations, wipe, export, demo) is unchanged. Drops the local DangerButton in favor of the row's tint. --- .../Settings/PrivacyDataSettingsView.swift | 125 ++++++++---------- 1 file changed, 52 insertions(+), 73 deletions(-) diff --git a/PulseLoop/Views/Settings/PrivacyDataSettingsView.swift b/PulseLoop/Views/Settings/PrivacyDataSettingsView.swift index 9b83d3d..89ca1dd 100644 --- a/PulseLoop/Views/Settings/PrivacyDataSettingsView.swift +++ b/PulseLoop/Views/Settings/PrivacyDataSettingsView.swift @@ -2,7 +2,8 @@ import SwiftUI import SwiftData import UIKit -/// Privacy & Data detail screen. Grouped iOS-style sections: +/// Privacy & Data detail screen. Grouped iOS-style sections, each a single inset card of full-width +/// rows with a one-line description footer: /// • Diagnostics — export a diagnostics bundle (leaves room for a future Import). /// • App data — destructive reset/restore: unpair the ring, factory-reset app data, or both. /// • Demo data — clear or reseed the local demo dataset. @@ -54,60 +55,45 @@ struct PrivacyDataSettingsView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 22) { - // MARK: Diagnostics SettingsGroup( header: "Diagnostics", - footer: "Ring data is stored only on this device. Nothing is uploaded except a Coach question you choose to send." + footer: "A local snapshot for troubleshooting — nothing leaves the device unless you share it." ) { - FormField { - SecondaryButton(title: "Export diagnostics", systemImage: "square.and.arrow.up") { - diagnosticsURL = DiagnosticsExporter.exportFile(context: modelContext) - } + actionRow("Export diagnostics", systemImage: "square.and.arrow.up") { + diagnosticsURL = DiagnosticsExporter.exportFile(context: modelContext) } } - // MARK: App data (reset & restore) SettingsGroup( header: "App data", footer: "Unpair releases the ring for other apps and keeps your health data. Resetting erases everything on this device and returns the app to onboarding." ) { - FormField { - DangerButton(title: "Unpair ring", systemImage: "wave.3.right.circle") { - pendingReset = .unpairRing - } + actionRow("Unpair ring", systemImage: "wave.3.right.circle", tint: PulseColors.danger) { + pendingReset = .unpairRing } - FormField { - DangerButton(title: "Reset app data", systemImage: "trash") { - pendingReset = .resetAppData - } + actionRow("Reset app data", systemImage: "trash", tint: PulseColors.danger) { + pendingReset = .resetAppData } - FormField { - DangerButton(title: "Unpair ring & reset app data", systemImage: "trash.slash") { - pendingReset = .unpairAndReset - } + actionRow("Unpair ring & reset app data", systemImage: "trash.slash", tint: PulseColors.danger) { + pendingReset = .unpairAndReset } } - // MARK: Demo data SettingsGroup( header: "Demo data", - footer: "Load or clear the sample dataset used to explore the app without a ring." + footer: "Sample data for exploring the app without a ring." ) { - FormField { - SecondaryButton(title: "Clear demo data", systemImage: "trash") { - SeedData.clearAll(modelContext) - let fresh = UserProfile() - fresh.onboardingCompleted = true - fresh.baselineCompleted = true - modelContext.insert(fresh) - try? modelContext.save() - } + actionRow("Clear demo data", systemImage: "trash") { + SeedData.clearAll(modelContext) + let fresh = UserProfile() + fresh.onboardingCompleted = true + fresh.baselineCompleted = true + modelContext.insert(fresh) + try? modelContext.save() } - FormField { - SecondaryButton(title: "Reseed demo data", systemImage: "arrow.clockwise") { - SeedData.clearAll(modelContext) - SeedData.seedDemo(modelContext, completeOnboarding: true) - } + actionRow("Reseed demo data", systemImage: "arrow.clockwise") { + SeedData.clearAll(modelContext) + SeedData.seedDemo(modelContext, completeOnboarding: true) } } } @@ -136,6 +122,36 @@ struct PrivacyDataSettingsView: View { } } + // MARK: - Rows + + /// A flat, full-width settings row (icon + title) that runs `action`. No inner capsule — it sits + /// directly inside `SettingsGroup`, which supplies the grouped glass surface + hairline dividers, + /// so it matches the app's other grouped rows. `tint` colors both icon and title (danger for the + /// destructive App-data actions). + private func actionRow( + _ title: String, + systemImage: String, + tint: Color = PulseColors.textPrimary, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: systemImage) + .font(PulseFont.body) + .foregroundStyle(tint) + .frame(width: 24) + Text(title) + .font(PulseFont.body) + .foregroundStyle(tint) + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .frame(minHeight: 50) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + // MARK: - Reset actions private func perform(_ action: ResetAction) { @@ -185,43 +201,6 @@ struct PrivacyDataSettingsView: View { } } -/// Full-width destructive action styled to match `SecondaryButton` (capsule + glass/card) but tinted -/// `PulseColors.danger`. The app has no shared destructive button, so this local one keeps the three -/// App-data actions visually consistent with the rest of the settings while reading clearly as danger. -private struct DangerButton: View { - let title: String - var systemImage: String? - let action: () -> Void - @Environment(\.accessibilityReduceTransparency) private var reduceTransparency - - private var label: some View { - Label(title, systemImage: systemImage ?? "exclamationmark.triangle") - .font(PulseFont.callout.weight(.semibold)) - .frame(maxWidth: .infinity) - .frame(height: 52) - } - - var body: some View { - if #available(iOS 26, *), !reduceTransparency { - Button(role: .destructive, action: action) { - label.foregroundStyle(PulseColors.danger) - } - .buttonStyle(.glass) - .clipShape(Capsule()) - } else { - Button(role: .destructive, action: action) { - label - .foregroundStyle(PulseColors.danger) - .background(PulseColors.card) - .clipShape(Capsule()) - .overlay { - Capsule().stroke(PulseColors.danger.opacity(0.4), lineWidth: 1) - } - } - } - } -} - extension URL: @retroactive Identifiable { public var id: String { absoluteString } }