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 SettingsManager with a didSet that writes to UserDefaults and a key in the private Keys enum, hydrate it in init, then add a Toggle(isOn: $settings.yourProperty) row. That's the entire round trip.
  • Wire a scaffolded row. Turn the Language, Help, Rate, Share, or Play in Background labels into real actions — e.g. make Feedback present the feedback sheet with router.presentSheet(.feedbackForm), or Rate call requestReview.
  • Make the profile editable elsewhere. EditProfileView is the template — bind any SettingsManager field with @Bindable and 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.

  • 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 hasUnlockedPro gate and the paywall.
  • Sign In — where the profile name and email originate.
  • Onboarding — replayed by SettingsManager.resetAllSettings().
Previous
LandingView (Main Flow)