-
Notifications
You must be signed in to change notification settings - Fork 80
Fix ToS checkbox state desync using @AppStorage #23419
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c7fc6c4
54bd3ad
ff5de16
9000a7a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,9 +13,9 @@ struct ImproveExperienceStepView: View { | |
|
|
||
| @State private var showTitle = false | ||
| @State private var showContent = false | ||
| @State private var collectUsageData: Bool = UserDefaults.standard.object(forKey: "collectUsageData") as? Bool ?? true | ||
| @State private var sendDiagnostics: Bool = UserDefaults.standard.object(forKey: "sendDiagnostics") as? Bool ?? true | ||
| @State private var tosAccepted: Bool = UserDefaults.standard.bool(forKey: "tosAccepted") | ||
| @AppStorage("collectUsageData") private var collectUsageData: Bool = true | ||
| @AppStorage("sendDiagnostics") private var sendDiagnostics: Bool = true | ||
| @AppStorage("tosAccepted") private var tosAccepted: Bool = false | ||
|
Comment on lines
+16
to
+18
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 @AppStorage immediately persists privacy preferences and ToS acceptance before user clicks "Accept and Start" The migration from This means if a user toggles "Share Analytics" off and checks the ToS checkbox, then presses "Back" to navigate away, those changes are already persisted in UserDefaults. The old code only wrote to UserDefaults inside Impact on tosAccepted specificallyThe Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a known behavioral change that I flagged to the reviewer. The old code deferred persistence to In practice this is acceptable because:
If transactional (deferred) semantics are truly needed in the future, the approach would be to use |
||
|
|
||
| var body: some View { | ||
| Text("Before You Start") | ||
|
|
@@ -61,7 +61,7 @@ struct ImproveExperienceStepView: View { | |
| // ToS consent checkbox | ||
| VCard { | ||
| HStack(spacing: VSpacing.md) { | ||
| VCheckbox(isOn: $tosAccepted) | ||
| tosCheckbox | ||
| tosConsentText | ||
| } | ||
| .frame(maxWidth: .infinity, alignment: .leading) | ||
|
|
@@ -90,6 +90,35 @@ struct ImproveExperienceStepView: View { | |
| } | ||
| } | ||
|
|
||
| // MARK: - ToS Consent Checkbox | ||
|
|
||
| private var tosCheckbox: some View { | ||
| Button { | ||
| withAnimation(VAnimation.fast) { | ||
| tosAccepted.toggle() | ||
| } | ||
| } label: { | ||
| ZStack { | ||
| RoundedRectangle(cornerRadius: VRadius.sm) | ||
| .fill(tosAccepted ? VColor.primaryBase : Color.clear) | ||
|
|
||
| RoundedRectangle(cornerRadius: VRadius.sm) | ||
| .strokeBorder(tosAccepted ? Color.clear : VColor.borderBase, lineWidth: 1.5) | ||
|
|
||
| if tosAccepted { | ||
| VIconView(.check, size: 12) | ||
| .foregroundStyle(VColor.auxWhite) | ||
| } | ||
| } | ||
| .frame(width: 20, height: 20) | ||
| .contentShape(Rectangle()) | ||
| } | ||
| .buttonStyle(.plain) | ||
| .accessibilityLabel("Agree to Terms of Service and Privacy Policy") | ||
| .accessibilityValue(tosAccepted ? "Checked" : "Unchecked") | ||
| .accessibilityAddTraits(.isToggle) | ||
| } | ||
|
|
||
| // MARK: - ToS Consent Text | ||
|
|
||
| private var tosConsentText: some View { | ||
|
|
@@ -119,9 +148,10 @@ struct ImproveExperienceStepView: View { | |
| // MARK: - Actions | ||
|
|
||
| private func saveAndContinue() { | ||
| UserDefaults.standard.set(collectUsageData, forKey: "collectUsageData") | ||
| UserDefaults.standard.set(sendDiagnostics, forKey: "sendDiagnostics") | ||
| UserDefaults.standard.set(true, forKey: "tosAccepted") | ||
| // @AppStorage already persists collectUsageData, sendDiagnostics, and | ||
| // tosAccepted. Explicitly set tosAccepted = true here as a safeguard | ||
| // so acceptance is recorded even if the user somehow bypasses the toggle. | ||
| tosAccepted = true | ||
|
|
||
| if sendDiagnostics { | ||
| MetricKitManager.startSentry() | ||
|
|
@@ -143,44 +173,3 @@ struct ImproveExperienceStepView: View { | |
| } | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚩 OnboardingState.resetOnboarding() resets tosAccepted but goBack() does not There's an existing (Refers to lines 169-173) Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| } | ||
| } | ||
|
|
||
| // MARK: - Checkbox | ||
|
|
||
| /// A styled checkbox matching the V* component aesthetic: primary-filled with | ||
| /// white checkmark when checked, outlined rounded square when unchecked. | ||
| private struct VCheckbox: View { | ||
| @Binding var isOn: Bool | ||
|
|
||
| private let size: CGFloat = 20 | ||
| private let cornerRadius: CGFloat = VRadius.sm | ||
|
|
||
| var body: some View { | ||
| Button { | ||
| isOn.toggle() | ||
| } label: { | ||
| ZStack { | ||
| if isOn { | ||
| RoundedRectangle(cornerRadius: cornerRadius) | ||
| .fill(VColor.primaryBase) | ||
|
|
||
| VIconView(.check, size: 12) | ||
| .foregroundStyle(VColor.auxWhite) | ||
| } else { | ||
| RoundedRectangle(cornerRadius: cornerRadius) | ||
| .fill(Color.clear) | ||
| .overlay( | ||
| RoundedRectangle(cornerRadius: cornerRadius) | ||
| .stroke(VColor.borderBase, lineWidth: 1.5) | ||
| ) | ||
| } | ||
| } | ||
| .frame(width: size, height: size) | ||
| .contentShape(Rectangle()) | ||
| } | ||
| .buttonStyle(.plain) | ||
| .animation(VAnimation.fast, value: isOn) | ||
| .accessibilityLabel("Agree to Terms of Service and Privacy Policy") | ||
| .accessibilityValue(isOn ? "Checked" : "Unchecked") | ||
| .accessibilityAddTraits(.isToggle) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚩 SettingsStore won't reflect @AppStorage changes made during onboarding
The
SettingsStoreatFeatures/Settings/SettingsStore.swift:309-316initializes its@Published var sendDiagnosticsandcollectUsageDatafrom UserDefaults at init time, and writes back via Combine sink pipelines. It does NOT subscribe to external UserDefaults changes for those keys. If@AppStoragein the onboarding view writes new values to UserDefaults, an already-initializedSettingsStorewon't pick them up. In practice this is unlikely to cause issues becauseSettingsStoreis typically initialized after onboarding completes, so it will read the correct persisted values. But if there's a code path where SettingsStore is already alive when onboarding is shown (e.g. re-hatch from developer tab), the two could desync.Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Investigated —
SettingsStoreis declared aslazy varinAppServices.swift:16, meaning it's only instantiated when first accessed. During onboarding, nothing accessessettingsStore, so it doesn't exist yet. By the timeSettingsStoreis initialized (after onboarding completes), it reads the correct persisted values from UserDefaults.The re-hatch scenario mentioned is also safe:
OnboardingState.resetForRetry()explicitly resets the UserDefaults keys, and a newSettingsStorewould be created for the new session. Even if the sameSettingsStoreinstance survives a re-hatch, its Combine sinks write to UserDefaults (not read from it), so the direction of data flow means@AppStoragewrites won't be overwritten by staleSettingsStorestate.No action needed — this is a pre-existing architectural limitation (not a regression from this PR) and doesn't manifest in practice.