import StoreKit import SwiftUI import UIKit struct SettingsView: View { private let iapService: IAPService @StateObject private var viewModel: SettingsViewModel @EnvironmentObject private var accountStore: AccountStore @EnvironmentObject private var tabSelection: TabSelectionStore @AppStorage("showForecast") private var showForecast = true @AppStorage("smoothChartGaps") private var smoothChartGaps = 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 @ObservedObject private var updateService = AppUpdateService.shared @ObservedObject private var balancePrivacy = BalancePrivacyManager.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 { if updateService.updateAvailable { updateAvailableSection } brandSection // Premium Section premiumSection // Notifications Section notificationsSection // Data Section dataSection if viewModel.backupsEnabled { backupsSection } // Privacy Section privacySection // 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) { // Sheets don't inherit @EnvironmentObject on Mac Catalyst → re-inject. ImportDataView() .environmentObject(iapService) .environmentObject(accountStore) .environmentObject(tabSelection) } .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(Text(String(format: String(localized: "biometric_unavailable_title"), biometryName)), isPresented: $showingBiometricAlert) { Button("OK") {} } message: { Text(String(format: String(localized: "biometric_unavailable_body"), biometryName)) } .alert("PIN Required", isPresented: $showingPinRequiredAlert) { Button("OK") {} } message: { Text(String(format: String(localized: "biometric_pin_required"), biometryName)) } .alert(Text(String(format: String(localized: "biometric_disable_first_title"), biometryName)), isPresented: $showingPinDisableAlert) { Button("OK") {} } message: { Text(String(format: String(localized: "biometric_disable_first_body"), biometryName)) } .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: String(format: String(localized: "biometric_pin_to_disable"), biometryName)) { success in if success { faceIdEnabled = false } } } .onAppear { didLoadCloudSync = true if viewModel.backupsEnabled { viewModel.refreshBackups() } updateService.checkForUpdate() } } } // MARK: - Update Available Section private var updateAvailableSection: some View { Section { Link(destination: GoalShareService.appStoreURL) { HStack { Image(systemName: "arrow.down.circle.fill") .foregroundStyle(Color.appPrimary) VStack(alignment: .leading, spacing: 2) { Text(String(localized: "update_available_title")) .font(.subheadline.weight(.semibold)) if let v = updateService.latestVersion { Text(String(format: String(localized: "update_available_body"), v)) .font(.caption) .foregroundStyle(.secondary) } } Spacer() Image(systemName: "chevron.right") .foregroundStyle(.secondary) .font(.caption) } } } } // 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)) .clipShape(RoundedRectangle(cornerRadius: 10)) VStack(alignment: .leading, spacing: 2) { Text(appDisplayName) .font(.headline) Text("Long-term portfolio tracker") .font(.caption) .foregroundStyle(.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) .foregroundStyle(.secondary) } } Spacer() Image(systemName: "checkmark.seal.fill") .foregroundStyle(Color.positiveGreen) } } else { Button { viewModel.upgradeToPremium() } label: { HStack { ZStack { Circle() .fill(Color.appPrimary.opacity(0.1)) .frame(width: 44, height: 44) Image(systemName: "crown.fill") .foregroundStyle(Color.appPrimary) } VStack(alignment: .leading, spacing: 2) { Text("Upgrade to Premium") .font(.headline) .foregroundStyle(.primary) Text("Unlock all features for \(iapService.formattedPrice)") .font(.caption) .foregroundStyle(.secondary) } Spacer() Image(systemName: "chevron.right") .foregroundStyle(.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") .foregroundStyle(.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) .foregroundStyle(.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) .foregroundStyle(.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) .foregroundStyle(.secondary) } else { Label("No import yet", systemImage: "icloud.and.arrow.down") .font(.caption) .foregroundStyle(.orange) } if let date = cloudStack.lastExportDate { Label("↑ \(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.up") .font(.caption) .foregroundStyle(.secondary) } else { Label("No export yet", systemImage: "icloud.and.arrow.up") .font(.caption) .foregroundStyle(.orange) } } } // CloudKit status: only alarm (red) when sync is genuinely // broken. A partial failure while data is syncing is shown as // a calm "still syncing" note, not a scary error. if let error = cloudStack.lastSyncError { let critical = cloudStack.syncErrorIsCritical VStack(alignment: .leading, spacing: 4) { Label( critical ? String(localized: "icloud_status_error") : String(localized: "icloud_status_syncing"), systemImage: critical ? "exclamationmark.icloud" : "arrow.triangle.2.circlepath.icloud" ) .font(.caption.weight(.semibold)) .foregroundStyle(critical ? .red : .secondary) if critical, let hint = cloudStack.lastSyncErrorHint { Text(hint) .font(.caption) .foregroundStyle(.primary) } // Technical detail is opt-in behind a disclosure, so it // never dominates the screen for a benign partial sync. DisclosureGroup(String(localized: "icloud_show_details")) { if let hint = cloudStack.lastSyncErrorHint, !critical { Text(hint) .font(.caption2) .foregroundStyle(.secondary) } Text(error) .font(.caption2) .foregroundStyle(.secondary) .textSelection(.enabled) } .font(.caption2) .tint(.secondary) } } // Always-available diagnostics: copies status + last error (with // the inner CloudKit codes) so it can be shared for support. Button { UIPasteboard.general.string = iCloudDiagnostics() forceUploadResult = String(localized: "icloud_diagnostics_copied") DispatchQueue.main.asyncAfter(deadline: .now() + 4) { forceUploadResult = nil } } label: { Label(String(localized: "icloud_copy_diagnostics"), systemImage: "doc.on.doc") .font(.caption) .foregroundStyle(Color.appPrimary) } // 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) .foregroundStyle(.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) .foregroundStyle(Color.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)) .foregroundStyle(Color.appWarning) } } } if viewModel.backupsEnabled { Picker("Auto Backup", selection: Binding( get: { viewModel.autoBackupFrequencyDays }, set: { viewModel.updateAutoBackupFrequency($0) } )) { Text("Off").tag(0) Text("Daily").tag(1) Text("Weekly").tag(7) Text("Monthly").tag(30) } if let last = viewModel.lastAutoBackupDate { HStack { Text("Last auto backup") Spacer() Text(last, style: .date) .foregroundStyle(.secondary) } .font(.caption) } } NavigationLink { CategoriesView() } label: { Label("Categories", systemImage: "tag") } 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) .foregroundStyle(Color.appWarning) } } } Button { viewModel.showingImportSheet = true } label: { HStack { Label("Import Data", systemImage: "square.and.arrow.down") Spacer() } } HStack { Text("Total Sources") Spacer() Text("\(viewModel.totalSources)") .foregroundStyle(.secondary) } HStack { Text("Total Snapshots") Spacer() Text("\(viewModel.totalSnapshots)") .foregroundStyle(.secondary) } HStack { Text("Storage Used") Spacer() Text(viewModel.storageUsedText) .foregroundStyle(.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) .foregroundStyle(.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) .foregroundStyle(.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 privacySection: some View { Section { Toggle("Hide Balances", isOn: $balancePrivacy.balancesHidden) Toggle(String(format: String(localized: "biometric_require_to_reveal"), biometryName), isOn: $balancePrivacy.requireBiometricToReveal) .onChange(of: balancePrivacy.requireBiometricToReveal) { _, enabled in if enabled && !AppLockService.canUseBiometrics() { balancePrivacy.requireBiometricToReveal = false showingBiometricAlert = true } } } header: { Text("Privacy") } footer: { Text(String(format: String(localized: "privacy_footer_biometric"), biometryName)) } } /// Face ID / Touch ID / Optic ID — resolved from the hardware, never assumed. private var biometryName: String { AppLockService.biometryName } 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(String(format: String(localized: "biometric_enable"), biometryName), 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(String(format: String(localized: "biometric_lock_footer"), biometryName)) } } // 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) } } 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("Show Forecast", isOn: $showForecast) Toggle("Smooth Gaps in Charts", isOn: $smoothChartGaps) NavigationLink { AllocationTargetsView() } label: { HStack { Label("Allocation Targets", systemImage: "target") Spacer() Image(systemName: "chevron.right") .foregroundStyle(.secondary) } } } header: { Text("Long-Term Focus") } footer: { Text("The dashboard shows returns since your last check-in — steady progress over daily noise.") } } // 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) .foregroundStyle(.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) .foregroundStyle(.secondary) } Link(destination: URL(string: AppConstants.URLs.privacyPolicy)!) { HStack { Text("Privacy Policy") Spacer() Image(systemName: "arrow.up.right") .font(.caption) .foregroundStyle(.secondary) } } Link(destination: URL(string: AppConstants.URLs.termsOfService)!) { HStack { Text("Terms of Service") Spacer() Image(systemName: "arrow.up.right") .font(.caption) .foregroundStyle(.secondary) } } Link(destination: URL(string: AppConstants.URLs.support)!) { HStack { Text("Support") Spacer() Image(systemName: "arrow.up.right") .font(.caption) .foregroundStyle(.secondary) } } Button { requestAppReview() } label: { HStack { Text("Rate App") Spacer() Image(systemName: "star.fill") .foregroundStyle(.yellow) } } // Only shown when MetricKit has actually left something behind. // Sharing is the only way a report leaves the device. if let latestReport = diagnosticReports.first { Button { viewModel.shareItem = SettingsViewModel.ShareItem(url: latestReport) } label: { HStack { Text("Share Diagnostics") Spacer() Image(systemName: "square.and.arrow.up") .font(.caption) .foregroundStyle(.secondary) } } } } header: { Text("About") } footer: { if !diagnosticReports.isEmpty { Text("diagnostics_footer") } } } /// Crash/hang reports MetricKit left in the app container. They never leave /// the device unless the user shares one from here. private var diagnosticReports: [URL] { DiagnosticsCollector.shared.storedReports() } // 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) } /// Copyable iCloud diagnostics snapshot for support/debugging. private func iCloudDiagnostics() -> String { let df = ISO8601DateFormatter() var lines: [String] = ["Portfolio Journal — iCloud diagnostics"] lines.append("app: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"))") lines.append("local: \(cloudStack.localSourceCount) sources · \(cloudStack.localSnapshotCount) snapshots") lines.append("lastImport: \(cloudStack.lastImportDate.map { df.string(from: $0) } ?? "never")") lines.append("lastExport: \(cloudStack.lastExportDate.map { df.string(from: $0) } ?? "never")") lines.append("critical: \(cloudStack.syncErrorIsCritical)") lines.append("hint: \(cloudStack.lastSyncErrorHint ?? "—")") lines.append("error: \(cloudStack.lastSyncError ?? "none this session")") lines.append("") lines.append("— integrity —") lines.append(cloudStack.integrityReport()) lines.append("") lines.append("— error detail —") lines.append(cloudStack.lastSyncErrorDetail ?? "none this session") return lines.joined(separator: "\n") } } // 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) .foregroundStyle(.secondary) } .padding(.vertical, 8) } header: { Text("Exporting...") } } else { Section { Button { viewModel.exportData(format: .csv) } label: { HStack { Image(systemName: "tablecells") .foregroundStyle(Color.positiveGreen) .frame(width: 30) VStack(alignment: .leading) { Text("CSV") .font(.headline) Text("Compatible with Excel, Google Sheets") .font(.caption) .foregroundStyle(.secondary) } } } Button { viewModel.exportData(format: .json) } label: { HStack { Image(systemName: "doc.text") .foregroundStyle(Color.appPrimary) .frame(width: 30) VStack(alignment: .leading) { Text("JSON") .font(.headline) Text("Full data structure for backup") .font(.caption) .foregroundStyle(.secondary) } } } } header: { Text("Select Format") } } } .navigationTitle(viewModel.isExporting ? "Exporting" : "Export Data") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { 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) .foregroundStyle(Color.negativeRed) } } .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .topBarTrailing) { 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) .foregroundStyle(Color.negativeRed) } } .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Cancel") { onResult(false) dismiss() } } ToolbarItem(placement: .topBarTrailing) { 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())) }