Views and Components
Settings
SettingsView is the third tab of LandingView and the app's control center: profile header, live preference toggles, an inline RevenueCat upsell, and — importantly — App Store-compliant account deletion. It's a thin Form whose every interactive row is bound to the @Observable SettingsManager, so toggles persist to UserDefaults the instant they flip, with no save button and no glue code.
The file is ShipThatApp/Views/Settings/SettingsView.swift.
Ships ready for App Store review
Settings includes the things reviewers actually check for: an in-app Delete Account flow (App Store Guideline 5.1.1(v) requires it for any app with account creation), a clear Pro/subscription entry point, and a profile editor. That's hours of compliance work already done.
Dependencies it pulls from the environment
SettingsView reads three managers — all injected by the app, none constructed here — plus one @State flag for the delete confirmation:
struct SettingsView: View {
@Environment(PurchaseManager.self) private var purchaseManager
@Environment(SettingsManager.self) private var settingsManager
@Environment(AppRouter.self) private var router
@State private var showDeleteAccountConfirmation = false
var body: some View {
@Bindable var settings = settingsManager
Form { /* sections below */ }
}
}
The @Bindable var settings = settingsManager line is the key idiom: it lets the Form create two-way $settings.someProperty bindings into an @Observable class — the modern replacement for @ObservableObject + @Published.
The sections
The screen is a single Form divided into purpose-built sections.
Profile header
The first section centers the logo, the user's full name, and email (sourced from SettingsManager, populated at sign-in), with an Edit Profile button that presents a sheet through the router:
Text(settingsManager.userFullName)
.font(.title2).fontWeight(.semibold)
if !settingsManager.userEmail.isEmpty {
Text(settingsManager.userEmail)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Button { router.presentSheet(.editProfile) } label: {
Text("Edit Profile")
}
router.presentSheet(.editProfile) presents EditProfileView, a small Form that edits userFirstName, userLastName, and userEmail straight through @Bindable bindings on SettingsManager — changes persist live, so there's no "save" step.
Preferences — live, persisted toggles
The Preferences section is where the @Bindable payoff shows. Two real toggles bind directly to settings; flipping them writes to UserDefaults immediately:
Section("Preferences") {
Label("Language", systemImage: "globe")
Toggle(isOn: $settings.isDarkModeEnabled) {
Label("Dark Mode", systemImage: "moon.circle.fill")
}
Toggle(isOn: $settings.preferOnDeviceAI) {
Label("On-Device AI", systemImage: "cpu")
}
Label("Play in Background", systemImage: "play.circle.fill")
}
The On-Device AI toggle (preferOnDeviceAI) is wired into ShipThatApp's hybrid AI story: it lets users prefer Apple's private, offline on-device path where available. The Language and Play in Background rows are scaffolded labels for you to wire to your own behavior.
Pro — an inline RevenueCat upsell
Rather than hide purchasing behind a separate screen, Settings surfaces it inline. It shows a StoreKit ProductView for a specific product and, when the user isn't Pro, a button that presents the full paywall via the router:
Section("Pro") {
Label {
Text(purchaseManager.hasUnlockedPro
? "Thank you for your support 🙏"
: "Select how you can support us 👇")
} icon: { Image(systemName: "giftcard.fill") }
ProductView(id: "sta_999_1m_1w0")
.productViewStyle(.compact)
if !purchaseManager.hasUnlockedPro {
Button("See all subscription options!") {
router.presentSheet(.paywall)
}
}
}
purchaseManager.hasUnlockedPro (from PurchaseManager) drives the copy and whether the upsell shows at all. router.presentSheet(.paywall) opens the paywall as a sheet from LandingView's single sheet host. See In-App Purchases.
Account — required deletion flow
The final section is the compliance piece: a destructive Delete Account button guarded by a confirmation dialog:
Section("Account") {
Button(role: .destructive) {
showDeleteAccountConfirmation = true
} label: {
Label("Delete Account", systemImage: "trash")
}
}
.confirmationDialog("Delete your account?",
isPresented: $showDeleteAccountConfirmation,
titleVisibility: .visible) {
Button("Delete Account", role: .destructive) {
Task { try? await AuthManager.shared.deleteAccount() }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This permanently deletes your account and data. This cannot be undone.")
}
Confirming calls AuthManager.shared.deleteAccount() — see AuthManager — which removes the Supabase user and ends the session. Because the session goes nil, LandingView drops back to the sign-in screen automatically.
SettingsManager: the source of truth
Every persisted preference and profile field on this screen lives on SettingsManager (ShipThatApp/Services/Settings/SettingsManager.swift), an @Observable @MainActor singleton. Each setting is a stored property that writes to UserDefaults in its didSet:
@MainActor
@Observable
final class SettingsManager {
static let shared = SettingsManager()
var isDarkModeEnabled: Bool {
didSet { UserDefaults.standard.set(isDarkModeEnabled, forKey: Keys.isDarkModeEnabled) }
}
var preferOnDeviceAI: Bool {
didSet { UserDefaults.standard.set(preferOnDeviceAI, forKey: Keys.preferOnDeviceAI) }
}
// userFirstName, userLastName, userEmail, onboardingComplete, ...
}
Why stored properties, not @AppStorage computed reads
@Observable only tracks stored properties. If these were computed properties reading UserDefaults directly, SwiftUI would never observe a change and your toggles would silently fail to update the UI. The stored-property-plus-didSet pattern is what makes the bindings on this screen actually work — it's a deliberate choice, not boilerplate.
SettingsManager also exposes helpers worth knowing: userFullName (falls back to "Guest"), resetAllSettings() (replays onboarding by clearing the flag), clearUserProfile(), and updateUserProfile(firstName:lastName:email:) (used by Apple Sign In).
Customize / extend it
- Add a persisted preference. Add a stored property to
SettingsManagerwith adidSetthat writes toUserDefaultsand a key in the privateKeysenum, hydrate it ininit, then add aToggle(isOn: $settings.yourProperty)row. That's the entire round trip. - Wire a scaffolded row. Turn the
Language,Help,Rate,Share, orPlay in Backgroundlabels into real actions — e.g. makeFeedbackpresent the feedback sheet withrouter.presentSheet(.feedbackForm), orRatecallrequestReview. - Make the profile editable elsewhere.
EditProfileViewis the template — bind anySettingsManagerfield with@Bindableand it persists live. - Reskin the Pro section. Point
ProductView(id:)at your own product identifier and route the button to whichever paywall you ship.
Keep deletion in-app
The Delete Account flow is not optional polish — Apple rejects apps that let users create an account but offer no in-app way to delete it. If you re-skin Settings, keep this section (or move it, but keep it) and make sure deleteAccount() truly removes the backend user.
Related
- LandingView — hosts Settings as a tab and owns the sheet presentation for Edit Profile, feedback, and paywall.
- AuthManager — backs account deletion and sign-out.
- PurchaseManager & In-App Purchases — the
hasUnlockedProgate and the paywall. - Sign In — where the profile name and email originate.
- Onboarding — replayed by
SettingsManager.resetAllSettings().