Files
InvestmentTrackerApp/PortfolioJournal/Views/Settings/SettingsView.swift
T
alexandrev-tibco 773da6800b Release 1.3.1 (build 20): iCloud sync fix + sources search/filter
iCloud sync:
- Fix: deploy CloudKit schema to production so exports work
- Fix: PredictionCache marked syncable=NO (binary attr caused partial failures)
- Fix: forceExportToiCloud uses createdAt+1ms for guaranteed persistent history
- Fix: auto-deduplication on CloudKit import (processRemoteChanges)
- Add: NSPersistentHistoryTrackingKey always enabled regardless of CloudKit state
- Add: cloudKitForceReload notification so repositories re-fetch on remote changes

Sources UI:
- Add: horizontal category filter chips in SourceListView
- Add: search toggle button with animated TextField

Other:
- Add: ITSAppUsesNonExemptEncryption=NO in Info.plist (skip manual compliance)
- Add: fastlane submit lane with run_precheck_before_submit=false
- Update: release notes all locales for 1.3.1
- Update: fastlane API key via pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:19:33 +02:00

1039 lines
36 KiB
Swift

import StoreKit
import SwiftUI
import UIKit
struct SettingsView: View {
private let iapService: IAPService
@StateObject private var viewModel: SettingsViewModel
@EnvironmentObject private var adMobService: AdMobService
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
@AppStorage("showForecast") private var showForecast = true
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
@AppStorage("faceIdEnabled") private var faceIdEnabled = false
@AppStorage("pinEnabled") private var pinEnabled = false
@AppStorage("lockOnLaunch") private var lockOnLaunch = true
@AppStorage("lockOnBackground") private var lockOnBackground = false
@ObservedObject private var cloudStack = CoreDataStack.shared
@State private var showingPinSetup = false
@State private var showingPinChange = false
@State private var showingBiometricAlert = false
@State private var showingPinRequiredAlert = false
@State private var showingPinDisableAlert = false
@State private var showingPinVerifyForFaceId = false
@State private var showingRestartAlert = false
@State private var didLoadCloudSync = false
@State private var isForceUploading = false
@State private var forceUploadResult: String?
@State private var backupToRestore: BackupRecord?
init(iapService: IAPService) {
self.iapService = iapService
_viewModel = StateObject(wrappedValue: SettingsViewModel(iapService: iapService))
}
var body: some View {
NavigationStack {
ZStack {
AppBackground()
List {
brandSection
// Premium Section
premiumSection
// Notifications Section
notificationsSection
// Data Section
dataSection
if viewModel.backupsEnabled {
backupsSection
}
// Security Section
securitySection
// Preferences Section
preferencesSection
// Long-Term Focus
longTermSection
// Accounts Section
accountsSection
// About Section
aboutSection
// Danger Zone
dangerZoneSection
}
.scrollContentBackground(.hidden)
}
.navigationTitle("Settings")
.sheet(isPresented: $viewModel.showingPaywall) {
PaywallView()
}
.sheet(isPresented: $viewModel.showingExportOptions) {
ExportOptionsSheet(viewModel: viewModel)
}
.sheet(item: $viewModel.shareItem) { shareItem in
ActivityView(activityItems: [shareItem.url])
}
.sheet(isPresented: $viewModel.showingImportSheet) {
ImportDataView()
}
.confirmationDialog(
"Reset All Data",
isPresented: $viewModel.showingResetConfirmation,
titleVisibility: .visible
) {
Button("Reset Everything", role: .destructive) {
viewModel.resetAllData()
}
} message: {
Text("This will permanently delete all your investment data. This action cannot be undone.")
}
.confirmationDialog(
"Restore Backup",
isPresented: Binding(
get: { backupToRestore != nil },
set: { if !$0 { backupToRestore = nil } }
),
titleVisibility: .visible
) {
Button("Restore", role: .destructive) {
if let backup = backupToRestore {
viewModel.restoreBackup(backup)
backupToRestore = nil
}
}
Button("Cancel", role: .cancel) {
backupToRestore = nil
}
} message: {
Text("This will replace your current data with the selected backup.")
}
.alert("Success", isPresented: .constant(viewModel.successMessage != nil)) {
Button("OK") {
viewModel.successMessage = nil
}
} message: {
Text(viewModel.successMessage ?? "")
}
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") {
viewModel.errorMessage = nil
}
} message: {
Text(viewModel.errorMessage ?? "")
}
.alert("Restart Required", isPresented: $showingRestartAlert) {
Button("OK") {}
} message: {
Text("Restart the app to apply iCloud sync changes.")
}
.alert("Face ID Unavailable", isPresented: $showingBiometricAlert) {
Button("OK") {}
} message: {
Text("Face ID isn't available on this device.")
}
.alert("PIN Required", isPresented: $showingPinRequiredAlert) {
Button("OK") {}
} message: {
Text("Enable a PIN before turning on Face ID.")
}
.alert("Disable Face ID First", isPresented: $showingPinDisableAlert) {
Button("OK") {}
} message: {
Text("Turn off Face ID before disabling your PIN.")
}
.sheet(isPresented: $showingPinSetup) {
PinSetupView(title: "Set PIN") { pin in
if KeychainService.savePin(pin) {
pinEnabled = true
} else {
pinEnabled = false
}
}
.onDisappear {
if KeychainService.readPin() == nil {
pinEnabled = false
}
}
}
.sheet(isPresented: $showingPinChange) {
PinSetupView(title: "Change PIN") { pin in
_ = KeychainService.savePin(pin)
}
}
.sheet(isPresented: $showingPinVerifyForFaceId) {
PinVerifyView(title: "Enter PIN to Disable Face ID") { success in
if success {
faceIdEnabled = false
}
}
}
.onAppear {
didLoadCloudSync = true
if viewModel.backupsEnabled {
viewModel.refreshBackups()
}
}
}
}
// MARK: - Brand Section
private var brandSection: some View {
Section {
HStack(spacing: 12) {
Image("BrandMark")
.resizable()
.scaledToFit()
.frame(width: 36, height: 36)
.padding(6)
.background(Color.appPrimary.opacity(0.08))
.cornerRadius(10)
VStack(alignment: .leading, spacing: 2) {
Text(appDisplayName)
.font(.headline)
Text("Long-term portfolio tracker")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
}
}
// MARK: - Premium Section
private var premiumSection: some View {
Section {
if viewModel.isPremium {
HStack {
ZStack {
Circle()
.fill(Color.yellow.opacity(0.2))
.frame(width: 44, height: 44)
Image(systemName: "crown.fill")
.foregroundStyle(
LinearGradient(
colors: [.yellow, .orange],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
VStack(alignment: .leading, spacing: 2) {
Text("Premium Active")
.font(.headline)
if viewModel.isFamilyShared {
Text("Family Sharing")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
Image(systemName: "checkmark.seal.fill")
.foregroundColor(.positiveGreen)
}
} else {
Button {
viewModel.upgradeToPremium()
} label: {
HStack {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 44, height: 44)
Image(systemName: "crown.fill")
.foregroundColor(.appPrimary)
}
VStack(alignment: .leading, spacing: 2) {
Text("Upgrade to Premium")
.font(.headline)
.foregroundColor(.primary)
Text("Unlock all features for \(iapService.formattedPrice)")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
}
}
Button {
Task {
await viewModel.restorePurchases()
}
} label: {
Text("Restore Purchases")
}
}
} header: {
Text("Subscription")
} footer: {
if !viewModel.isPremium {
Text("Free: \(viewModel.sourceLimitText)\(viewModel.historyLimitText)")
}
}
}
// MARK: - Notifications Section
private var notificationsSection: some View {
Section {
HStack {
Text("Notifications")
Spacer()
Text(viewModel.notificationsEnabled ? "Enabled" : "Disabled")
.foregroundColor(.secondary)
}
.contentShape(Rectangle())
.onTapGesture {
if !viewModel.notificationsEnabled {
Task {
await viewModel.requestNotificationPermission()
}
} else {
viewModel.openSystemSettings()
}
}
if viewModel.notificationsEnabled {
DatePicker(
"Default Reminder Time",
selection: $viewModel.defaultNotificationTime,
displayedComponents: .hourAndMinute
)
.onChange(of: viewModel.defaultNotificationTime) { _, newTime in
viewModel.updateNotificationTime(newTime)
}
}
} header: {
Text("Notifications")
} footer: {
Text("Set when you'd like to receive investment update reminders.")
}
}
// MARK: - Data Section
private var dataSection: some View {
Section {
Toggle("Sync with iCloud", isOn: $cloudSyncEnabled)
.onChange(of: cloudSyncEnabled) { _, _ in
if didLoadCloudSync {
showingRestartAlert = true
}
viewModel.refreshBackups()
}
if cloudSyncEnabled {
VStack(alignment: .leading, spacing: 6) {
// Local data counts
HStack {
Label(
"\(cloudStack.localSourceCount) sources · \(cloudStack.localSnapshotCount) snapshots",
systemImage: "internaldrive"
)
.font(.subheadline)
.foregroundColor(.primary)
Spacer()
Button("Refresh") {
CoreDataStack.shared.forceReload()
}
.font(.subheadline)
.disabled(cloudStack.isSyncing)
}
// Import / export status
if cloudStack.isSyncing {
Label("Syncing with iCloud...", systemImage: "arrow.triangle.2.circlepath.icloud")
.font(.caption)
.foregroundColor(.secondary)
} else {
HStack(spacing: 12) {
if let date = cloudStack.lastImportDate {
Label("\(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.down")
.font(.caption)
.foregroundColor(.secondary)
} else {
Label("No import yet", systemImage: "icloud.and.arrow.down")
.font(.caption)
.foregroundColor(.orange)
}
if let date = cloudStack.lastExportDate {
Label("\(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.up")
.font(.caption)
.foregroundColor(.secondary)
} else {
Label("No export yet", systemImage: "icloud.and.arrow.up")
.font(.caption)
.foregroundColor(.orange)
}
}
}
// CloudKit error (if any)
if let error = cloudStack.lastSyncError {
VStack(alignment: .leading, spacing: 2) {
Label("CloudKit error", systemImage: "exclamationmark.icloud")
.font(.caption.weight(.semibold))
.foregroundColor(.red)
Text(error)
.font(.caption2)
.foregroundColor(.red)
.lineLimit(6)
.textSelection(.enabled)
}
}
// Force upload use when data exists locally but hasn't reached iCloud
if cloudStack.localSourceCount > 0 {
if let result = forceUploadResult {
Label(result, systemImage: "checkmark.icloud")
.font(.caption)
.foregroundColor(.secondary)
} else {
Button {
isForceUploading = true
CoreDataStack.shared.forceExportToiCloud { count in
isForceUploading = false
forceUploadResult = String(
format: NSLocalizedString("save_n_snapshots", comment: ""),
count
)
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
forceUploadResult = nil
}
}
} label: {
if isForceUploading {
Label("Uploading...", systemImage: "arrow.triangle.2.circlepath.icloud")
} else {
Label("Force Upload to iCloud", systemImage: "icloud.and.arrow.up")
}
}
.font(.caption)
.foregroundColor(.appPrimary)
.disabled(isForceUploading)
}
}
}
.padding(.vertical, 2)
}
Toggle(
isOn: Binding(
get: { viewModel.backupsEnabled },
set: { viewModel.setBackupsEnabled($0) }
)
) {
HStack(spacing: 8) {
Text("Enable Backups")
if !viewModel.isPremium {
Text("Premium")
.font(.caption.weight(.semibold))
.foregroundColor(.appWarning)
}
}
}
Button {
if viewModel.canExport {
viewModel.showingExportOptions = true
} else {
viewModel.showingPaywall = true
}
} label: {
HStack {
Label("Export Data", systemImage: "square.and.arrow.up")
Spacer()
if !viewModel.canExport {
Image(systemName: "lock.fill")
.font(.caption)
.foregroundColor(.appWarning)
}
}
}
Button {
viewModel.showingImportSheet = true
} label: {
HStack {
Label("Import Data", systemImage: "square.and.arrow.down")
Spacer()
}
}
HStack {
Text("Total Sources")
Spacer()
Text("\(viewModel.totalSources)")
.foregroundColor(.secondary)
}
HStack {
Text("Total Snapshots")
Spacer()
Text("\(viewModel.totalSnapshots)")
.foregroundColor(.secondary)
}
HStack {
Text("Storage Used")
Spacer()
Text(viewModel.storageUsedText)
.foregroundColor(.secondary)
}
} header: {
Text("Data")
}
}
// MARK: - Backups Section
private var backupsSection: some View {
Section {
Picker("Keep Backups", selection: $viewModel.backupRetentionCount) {
Text("5").tag(5)
Text("10").tag(10)
Text("20").tag(20)
}
.onChange(of: viewModel.backupRetentionCount) { _, newValue in
viewModel.updateBackupRetention(newValue)
}
Button {
viewModel.createBackupNow()
} label: {
HStack {
Label("Create Backup Now", systemImage: "arrow.clockwise")
Spacer()
if viewModel.isBackupInProgress {
ProgressView()
}
}
}
.disabled(viewModel.isBackupInProgress || viewModel.isRestoreInProgress)
if viewModel.backups.isEmpty {
Text("No backups yet.")
.font(.caption)
.foregroundColor(.secondary)
} else {
ForEach(viewModel.backups) { backup in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(backup.date.formatted(date: .abbreviated, time: .shortened))
.font(.subheadline)
Text("\(backup.location.rawValue) · \(formatBytes(backup.size))")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button("Restore") {
backupToRestore = backup
}
.disabled(viewModel.isRestoreInProgress)
}
.contextMenu {
Button("Share Backup") {
viewModel.shareItem = SettingsViewModel.ShareItem(url: backup.url)
}
}
}
}
} header: {
Text("Backups")
} footer: {
Text("Backups are stored locally and in iCloud (when enabled).")
}
}
// MARK: - Security Section
private var securitySection: some View {
Section {
Toggle("Require PIN", isOn: $pinEnabled)
.onChange(of: pinEnabled) { _, enabled in
if enabled {
if KeychainService.readPin() == nil {
showingPinSetup = true
}
} else {
if faceIdEnabled {
pinEnabled = true
showingPinDisableAlert = true
} else {
KeychainService.deletePin()
}
}
}
Toggle("Enable Face ID", isOn: $faceIdEnabled)
.onChange(of: faceIdEnabled) { _, enabled in
if enabled {
guard AppLockService.canUseBiometrics() else {
faceIdEnabled = false
showingBiometricAlert = true
return
}
if !pinEnabled {
faceIdEnabled = false
showingPinRequiredAlert = true
}
} else {
faceIdEnabled = true
showingPinVerifyForFaceId = true
}
}
if pinEnabled {
Button("Change PIN") {
showingPinChange = true
}
}
if faceIdEnabled || pinEnabled {
Toggle("Lock on App Launch", isOn: $lockOnLaunch)
Toggle("Lock When Backgrounded", isOn: $lockOnBackground)
}
} header: {
Text("App Lock")
} footer: {
Text("Use Face ID or a 4-digit PIN to protect your data.")
}
}
// MARK: - Preferences Section
private var preferencesSection: some View {
Section {
Picker("Currency", selection: $viewModel.currencyCode) {
ForEach(CurrencyPicker.commonCodes, id: \.self) { code in
Text(code).tag(code)
}
}
.onChange(of: viewModel.currencyCode) { _, newValue in
viewModel.updateCurrency(newValue)
}
Picker("Input Mode", selection: $viewModel.inputMode) {
ForEach(InputMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
.onChange(of: viewModel.inputMode) { _, newValue in
viewModel.updateInputMode(newValue)
}
if !viewModel.isPremium {
Button("Manage Ad Consent") {
Task {
await adMobService.presentPrivacyOptions()
}
}
}
} header: {
Text("Preferences")
} footer: {
Text("Currency and input mode apply globally unless overridden per account.")
}
}
// MARK: - Long-Term Focus Section
private var longTermSection: some View {
Section {
Toggle("Calm Mode", isOn: $calmModeEnabled)
Toggle("Show Forecast", isOn: $showForecast)
NavigationLink {
AllocationTargetsView()
} label: {
HStack {
Label("Allocation Targets", systemImage: "target")
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
}
}
} header: {
Text("Long-Term Focus")
} footer: {
Text("Calm Mode hides short-term noise and advanced charts. Show Forecast controls whether prediction data appears in portfolio and charts.")
}
}
// MARK: - Accounts Section
private var accountsSection: some View {
Section {
NavigationLink {
AccountsView()
} label: {
HStack {
Label("Manage Accounts", systemImage: "person.2")
Spacer()
if !viewModel.isPremium {
Text("Premium")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
} header: {
Text("Accounts")
} footer: {
Text(viewModel.isPremium ? "Create multiple accounts and switch between them." : "Free users can use one account.")
}
}
// MARK: - About Section
private var aboutSection: some View {
Section {
HStack {
Text("Version")
Spacer()
Text(viewModel.appVersion)
.foregroundColor(.secondary)
}
Link(destination: URL(string: AppConstants.URLs.privacyPolicy)!) {
HStack {
Text("Privacy Policy")
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
Link(destination: URL(string: AppConstants.URLs.termsOfService)!) {
HStack {
Text("Terms of Service")
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
Link(destination: URL(string: AppConstants.URLs.support)!) {
HStack {
Text("Support")
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
Button {
requestAppReview()
} label: {
HStack {
Text("Rate App")
Spacer()
Image(systemName: "star.fill")
.foregroundColor(.yellow)
}
}
} header: {
Text("About")
}
}
// MARK: - Danger Zone Section
private var dangerZoneSection: some View {
Section {
Button(role: .destructive) {
viewModel.showingResetConfirmation = true
} label: {
HStack {
Image(systemName: "trash")
Text("Reset All Data")
}
}
} header: {
Text("Danger Zone")
} footer: {
Text("This will permanently delete all your investment sources, snapshots, and settings.")
}
}
// MARK: - Helpers
private func requestAppReview() {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
if #available(iOS 18.0, *) {
AppStore.requestReview(in: scene)
} else {
SKStoreReviewController.requestReview(in: scene)
}
}
private var appDisplayName: String {
if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String {
return name
}
if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String {
return name
}
return "Portfolio Journal"
}
private func formatBytes(_ bytes: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: bytes)
}
}
// MARK: - Activity View
struct ActivityView: UIViewControllerRepresentable {
let activityItems: [Any]
let applicationActivities: [UIActivity]? = nil
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(
activityItems: activityItems,
applicationActivities: applicationActivities
)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
// MARK: - Export Options Sheet
struct ExportOptionsSheet: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var viewModel: SettingsViewModel
var body: some View {
NavigationStack {
List {
if viewModel.isExporting {
Section {
VStack(alignment: .leading, spacing: 12) {
ProgressView(value: viewModel.exportProgress)
.progressViewStyle(.linear)
Text(viewModel.exportStatus)
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 8)
} header: {
Text("Exporting...")
}
} else {
Section {
Button {
viewModel.exportData(format: .csv)
} label: {
HStack {
Image(systemName: "tablecells")
.foregroundColor(.positiveGreen)
.frame(width: 30)
VStack(alignment: .leading) {
Text("CSV")
.font(.headline)
Text("Compatible with Excel, Google Sheets")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
Button {
viewModel.exportData(format: .json)
} label: {
HStack {
Image(systemName: "doc.text")
.foregroundColor(.appPrimary)
.frame(width: 30)
VStack(alignment: .leading) {
Text("JSON")
.font(.headline)
Text("Full data structure for backup")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
} header: {
Text("Select Format")
}
}
}
.navigationTitle(viewModel.isExporting ? "Exporting" : "Export Data")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Cancel") {
dismiss()
}
.disabled(viewModel.isExporting)
}
}
}
.presentationDetents([.medium])
.interactiveDismissDisabled(viewModel.isExporting)
}
}
// MARK: - PIN Setup View
struct PinSetupView: View {
@Environment(\.dismiss) private var dismiss
let title: String
let onSave: (String) -> Void
@State private var pin = ""
@State private var confirmPin = ""
@State private var errorMessage: String?
var body: some View {
NavigationStack {
Form {
Section {
SecureField("New PIN", text: $pin)
.keyboardType(.numberPad)
.onChange(of: pin) { _, newValue in
pin = String(newValue.filter(\.isNumber).prefix(4))
}
SecureField("Confirm PIN", text: $confirmPin)
.keyboardType(.numberPad)
.onChange(of: confirmPin) { _, newValue in
confirmPin = String(newValue.filter(\.isNumber).prefix(4))
}
} header: {
Text("4-Digit PIN")
}
if let errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundColor(.negativeRed)
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") { savePin() }
.disabled(pin.count < 4 || confirmPin.count < 4)
}
}
}
.presentationDetents([.medium])
}
private func savePin() {
guard pin.count == 4, pin == confirmPin else {
errorMessage = "PINs do not match."
confirmPin = ""
return
}
onSave(pin)
dismiss()
}
}
struct PinVerifyView: View {
@Environment(\.dismiss) private var dismiss
let title: String
let onResult: (Bool) -> Void
@State private var pin = ""
@State private var errorMessage: String?
var body: some View {
NavigationStack {
Form {
Section {
SecureField("Enter PIN", text: $pin)
.keyboardType(.numberPad)
.onChange(of: pin) { _, newValue in
pin = String(newValue.filter(\.isNumber).prefix(4))
}
} header: {
Text("4-Digit PIN")
}
if let errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundColor(.negativeRed)
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
onResult(false)
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Verify") { verifyPin() }
.disabled(pin.count < 4)
}
}
}
.presentationDetents([.medium])
}
private func verifyPin() {
guard let savedPin = KeychainService.readPin(), pin == savedPin else {
errorMessage = "Incorrect PIN."
pin = ""
return
}
onResult(true)
dismiss()
}
}
#Preview {
SettingsView(iapService: IAPService())
.environmentObject(AccountStore(iapService: IAPService()))
}