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 @ObservedObject private var updateService = AppUpdateService.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 } // 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() } updateService.checkForUpdate() } } } // MARK: - Update Available Section private var updateAvailableSection: some View { Section { Link(destination: URL(string: "https://apps.apple.com/app/id6741412965")!) { HStack { Image(systemName: "arrow.down.circle.fill") .foregroundColor(.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) .foregroundColor(.secondary) } } Spacer() Image(systemName: "chevron.right") .foregroundColor(.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)) .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) } } } 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) .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())) }