Commit 48999737a3

48999737a37ef16cad0d84e71fa1b229bb04903f

parent: 321a10f854

Unsigned

cmc <hello@cleberg.net> · 2026-07-25 05:15 UTC

refactor: extract Settings screens into SettingsViews.swift (v5 step 4, 6/n)

Pivots the god-file decomposition to ContentView.swift (the other ~3.9k-line
file). Unlike DomainViewModel, this is a set of independent SwiftUI View structs,
so it splits with no shared-state entanglement.

- Moves the Settings surface into SettingsViews.swift: SettingsView (the tab
  root, presented from RootTabView) plus its nine file-private section screens
  (Display, History & Network, iCloud Sync, Local API, Monitoring, Import &
  Export, Data Management, About) and the DataImportPreviewSheet. Pure move.
- The private section screens stay file-private together in the new file, reached
  only through SettingsView's navigation links. Zero visibility changes were
  needed: the block references nothing file-private to ContentView.swift, so the
  ContentView diff is pure deletion.

ContentView.swift: 3881 -> 2739 lines. App builds clean; unit suite 58/58.
No project.pbxproj change (DomainDig/ is a synchronized group).

Layout: unified · split

DomainDig/ContentView.swift −1143
@@ -2703,1149 +2703,6 @@ private extension String {
2703 } 2703 }
2704} 2704}
2705 2705
2706struct SettingsView: View {
2707 @Environment(\.appDensity) private var appDensity
2708 @Bindable var viewModel: DomainViewModel
2709 @State private var purchaseService = PurchaseService.shared
2710
2711 var body: some View {
2712 let _ = purchaseService.currentTier
2713
2714 List {
2715 Section("Tier") {
2716 LabeledContent("Status", value: purchaseService.currentTier.title)
2717
2718 if purchaseService.currentTier == .free {
2719 Button("Upgrade") {
2720 viewModel.isPaywallPresented = true
2721 }
2722 } else {
2723 Button("Manage Subscription") {
2724 Task {
2725 await purchaseService.manageSubscription()
2726 }
2727 }
2728 }
2729
2730 Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") {
2731 Task {
2732 await purchaseService.restorePurchases()
2733 }
2734 }
2735 .disabled(purchaseService.isRestoring || purchaseService.isPurchasing)
2736
2737 if let statusMessage = purchaseService.statusMessage {
2738 Text(statusMessage)
2739 .font(appDensity.font(.caption, design: .default))
2740 .foregroundStyle(Color(.appTextSecondary))
2741 }
2742
2743 if let errorMessage = purchaseService.errorMessage {
2744 Text(errorMessage)
2745 .font(appDensity.font(.caption, design: .default))
2746 .foregroundStyle(Color(.statusCritical))
2747 }
2748 }
2749
2750 Section("Preferences") {
2751 NavigationLink("Tracked Domains") {
2752 WatchlistView(viewModel: viewModel)
2753 }
2754
2755 NavigationLink("Workflows") {
2756 WorkflowsView(viewModel: viewModel)
2757 }
2758
2759 NavigationLink("Display") {
2760 DisplaySettingsView()
2761 }
2762
2763 NavigationLink("History & Network") {
2764 HistoryNetworkSettingsView(viewModel: viewModel)
2765 }
2766 }
2767
2768 Section("Services") {
2769 NavigationLink("Monitoring Activity") {
2770 MonitoringView(viewModel: viewModel)
2771 }
2772
2773 NavigationLink("Integrations") {
2774 IntegrationsSettingsView()
2775 }
2776
2777 NavigationLink("Local API") {
2778 LocalAPISettingsView()
2779 }
2780
2781 NavigationLink("iCloud Sync") {
2782 CloudSyncSettingsView()
2783 }
2784
2785 NavigationLink("Monitoring") {
2786 MonitoringSettingsView(viewModel: viewModel)
2787 }
2788
2789 NavigationLink("Scheduled Reports") {
2790 ScheduledReportsView()
2791 }
2792 }
2793
2794 Section("Data") {
2795 NavigationLink("Import & Export") {
2796 DataPortabilitySettingsView(viewModel: viewModel)
2797 }
2798
2799 NavigationLink("Data Management") {
2800 DataManagementSettingsView(viewModel: viewModel)
2801 }
2802 }
2803
2804 Section("About") {
2805 NavigationLink("App Info") {
2806 AboutSettingsView()
2807 }
2808 }
2809 }
2810 .navigationTitle("Settings")
2811 }
2812}
2813
2814private struct DisplaySettingsView: View {
2815 @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue
2816 @AppStorage(AppAppearance.userDefaultsKey) private var storedAppearance = AppAppearance.system.rawValue
2817
2818 var body: some View {
2819 Form {
2820 Section("Display") {
2821 Picker("Appearance", selection: $storedAppearance) {
2822 ForEach(AppAppearance.allCases) { appearance in
2823 Text(appearance.title).tag(appearance.rawValue)
2824 }
2825 }
2826
2827 Picker("Density", selection: $storedDensity) {
2828 ForEach(AppDensity.allCases) { density in
2829 Text(density.title).tag(density.rawValue)
2830 }
2831 }
2832 }
2833 }
2834 .navigationTitle("Display")
2835 }
2836}
2837
2838private struct HistoryNetworkSettingsView: View {
2839 @Environment(\.appDensity) private var appDensity
2840 @Bindable var viewModel: DomainViewModel
2841 @AppStorage(DNSResolverOption.userDefaultsKey) private var storedResolverURL = DNSResolverOption.defaultURLString
2842 @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue
2843
2844 @State private var resolverOption: DNSResolverOption = .cloudflare
2845 @State private var customResolverURL = DNSResolverOption.defaultURLString
2846
2847 private var customResolverError: String? {
2848 guard resolverOption == .custom else { return nil }
2849 return DNSResolverOption.isValidCustomURL(customResolverURL) ? nil : "Resolver URL must start with https://"
2850 }
2851
2852 var body: some View {
2853 Form {
2854 Section("History") {
2855 Picker(
2856 "Auto-prune",
2857 selection: Binding(
2858 get: { viewModel.historyAutoPruneOption },
2859 set: { viewModel.setHistoryAutoPruneOption($0) }
2860 )
2861 ) {
2862 ForEach(HistoryAutoPruneOption.allCases) { option in
2863 Text(option.title).tag(option)
2864 }
2865 }
2866
2867 Text("History remains local-first. Auto-prune only trims older local snapshots on this device and defaults to unlimited.")
2868 .font(appDensity.font(.caption, design: .default))
2869 .foregroundStyle(Color(.appTextSecondary))
2870 }
2871
2872 Section("Network") {
2873 Picker("Resolver", selection: $resolverOption) {
2874 ForEach(DNSResolverOption.allCases) { option in
2875 Text(option.title).tag(option)
2876 }
2877 }
2878
2879 if resolverOption == .custom {
2880 TextField("https://resolver.example/dns-query", text: $customResolverURL)
2881 .textInputAutocapitalization(.never)
2882 .autocorrectionDisabled()
2883 .keyboardType(.URL)
2884
2885 if let customResolverError {
2886 Text(customResolverError)
2887 .font(appDensity.font(.caption, design: .default))
2888 .foregroundStyle(Color(.statusCritical))
2889 }
2890 }
2891 }
2892 }
2893 .navigationTitle("History & Network")
2894 .onAppear {
2895 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
2896 resolverOption = DNSResolverOption.option(for: currentResolverURL)
2897 customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString
2898 }
2899 .onChange(of: resolverOption) { _, newValue in
2900 guard let presetURL = newValue.urlString else {
2901 storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
2902 viewModel.persistCurrentAppSettings(
2903 resolverURLString: storedResolverURL,
2904 appDensityRawValue: storedDensity
2905 )
2906 return
2907 }
2908 storedResolverURL = presetURL
2909 viewModel.persistCurrentAppSettings(
2910 resolverURLString: storedResolverURL,
2911 appDensityRawValue: storedDensity
2912 )
2913 }
2914 .onChange(of: customResolverURL) { _, newValue in
2915 guard resolverOption == .custom else { return }
2916 storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
2917 viewModel.persistCurrentAppSettings(
2918 resolverURLString: storedResolverURL,
2919 appDensityRawValue: storedDensity
2920 )
2921 }
2922 .onChange(of: storedDensity) { _, newValue in
2923 viewModel.persistCurrentAppSettings(
2924 resolverURLString: storedResolverURL,
2925 appDensityRawValue: newValue
2926 )
2927 }
2928 }
2929}
2930
2931private struct CloudSyncSettingsView: View {
2932 @Environment(\.appDensity) private var appDensity
2933 @State private var cloudSyncService = CloudSyncService.shared
2934
2935 var body: some View {
2936 Form {
2937 Section("iCloud Sync") {
2938 Toggle(
2939 "Enable iCloud Sync",
2940 isOn: Binding(
2941 get: { cloudSyncService.isEnabled },
2942 set: { cloudSyncService.setSyncEnabled($0) }
2943 )
2944 )
2945
2946 LabeledContent("Status", value: cloudSyncService.status.title)
2947 LabeledContent(
2948 "Last Sync",
2949 value: cloudSyncService.lastSyncDate?.formatted(date: .abbreviated, time: .shortened) ?? "Not yet synced"
2950 )
2951
2952 Button(cloudSyncService.status == .syncing ? "Syncing…" : "Sync Now") {
2953 Task {
2954 await cloudSyncService.syncNow(trigger: .manual)
2955 }
2956 }
2957 .disabled(!cloudSyncService.isEnabled || cloudSyncService.status == .syncing)
2958
2959 Text("iCloud Sync stores DomainDig data in your private iCloud account. DomainDig does not operate a sync server. Disabling sync keeps local data on this device.")
2960 .font(appDensity.font(.caption, design: .default))
2961 .foregroundStyle(Color(.appTextSecondary))
2962
2963 Text(cloudSyncService.detailMessage)
2964 .font(appDensity.font(.caption, design: .default))
2965 .foregroundStyle(Color(.appTextSecondary))
2966
2967 if let lastErrorMessage = cloudSyncService.lastErrorMessage {
2968 Text(lastErrorMessage)
2969 .font(appDensity.font(.caption, design: .default))
2970 .foregroundStyle(Color(.statusCritical))
2971 }
2972 }
2973 }
2974 .navigationTitle("iCloud Sync")
2975 .task {
2976 await cloudSyncService.refreshAvailability()
2977 }
2978 }
2979}
2980
2981private struct LocalAPISettingsView: View {
2982 @Environment(\.appDensity) private var appDensity
2983 @State private var localAPIService = LocalAPIService.shared
2984 @State private var portText = ""
2985
2986 private var statusText: String {
2987 if localAPIService.isRunning { return "Running" }
2988 return localAPIService.config.isEnabled ? "Stopped" : "Disabled"
2989 }
2990
2991 var body: some View {
2992 Form {
2993 Section("Local API") {
2994 Toggle(
2995 "Enable Local API",
2996 isOn: Binding(
2997 get: { localAPIService.config.isEnabled },
2998 set: { localAPIService.setEnabled($0) }
2999 )
3000 )
3001
3002 TextField(
3003 "Port",
3004 text: Binding(
3005 get: { portText },
3006 set: { newValue in
3007 portText = newValue
3008 if let port = Int(newValue) {
3009 localAPIService.setPort(port)
3010 }
3011 }
3012 )
3013 )
3014 .keyboardType(.numberPad)
3015
3016 LabeledContent("Address", value: localAPIService.address)
3017 LabeledContent("Status", value: statusText)
3018 LabeledContent("Token", value: localAPIService.maskedToken)
3019
3020 if let statusMessage = localAPIService.statusMessage {
3021 Text(statusMessage)
3022 .font(appDensity.font(.caption, design: .default))
3023 .foregroundStyle(Color(.appTextSecondary))
3024 }
3025 }
3026
3027 Section("Authentication") {
3028 Button("Copy Token") {
3029 localAPIService.copyToken()
3030 }
3031
3032 Button("Copy cURL Command") {
3033 localAPIService.copyCurlCommand()
3034 }
3035
3036 Button("Rotate Token") {
3037 localAPIService.rotateToken()
3038 }
3039
3040 Text("Every request requires either `Authorization: Bearer <token>` or `X-API-Token`. DomainDig stores the token in Keychain and only binds the server to localhost.")
3041 .font(appDensity.font(.caption, design: .default))
3042 .foregroundStyle(Color(.appTextSecondary))
3043 }
3044
3045 Section("Request Logging") {
3046 Toggle(
3047 "Log Requests",
3048 isOn: Binding(
3049 get: { localAPIService.config.requestLoggingEnabled },
3050 set: { localAPIService.setRequestLoggingEnabled($0) }
3051 )
3052 )
3053
3054 if localAPIService.requestLogs.isEmpty {
3055 Text("No local API requests logged yet.")
3056 .font(appDensity.font(.caption, design: .default))
3057 .foregroundStyle(Color(.appTextSecondary))
3058 } else {
3059 ForEach(localAPIService.requestLogs.prefix(25)) { log in
3060 VStack(alignment: .leading, spacing: 4) {
3061 HStack {
3062 Text("\(log.method) \(log.path)")
3063 .font(appDensity.font(.callout, design: .monospaced))
3064 Spacer()
3065 Text("\(log.statusCode)")
3066 .font(appDensity.font(.caption, design: .default))
3067 .foregroundStyle(log.statusCode >= 400 ? Color(.statusCritical) : .secondary)
3068 }
3069
3070 Text(log.timestamp.formatted(date: .abbreviated, time: .standard))
3071 .font(appDensity.font(.caption2, design: .default))
3072 .foregroundStyle(Color(.appTextSecondary))
3073
3074 Text("\(Int(log.duration * 1000)) ms")
3075 .font(appDensity.font(.caption2, design: .default))
3076 .foregroundStyle(Color(.appTextSecondary))
3077 }
3078 }
3079 }
3080
3081 Button("Clear Logs", role: .destructive) {
3082 localAPIService.clearRequestLogs()
3083 }
3084 }
3085
3086 Section("Control") {
3087 Button("Restart Server") {
3088 localAPIService.setEnabled(false)
3089 localAPIService.setEnabled(true)
3090 }
3091 .disabled(!localAPIService.config.isEnabled)
3092
3093 Button("Stop Server") {
3094 localAPIService.stopServer()
3095 }
3096 .disabled(!localAPIService.isRunning)
3097 }
3098 }
3099 .navigationTitle("Local API")
3100 .onAppear {
3101 portText = String(localAPIService.config.port)
3102 localAPIService.refresh()
3103 }
3104 }
3105}
3106
3107private struct MonitoringSettingsView: View {
3108 @Environment(\.appDensity) private var appDensity
3109 @Bindable var viewModel: DomainViewModel
3110
3111 private var notificationAuthorizationLabel: String {
3112 switch viewModel.monitoringNotificationStatus {
3113 case .authorized, .provisional, .ephemeral:
3114 return "Allowed"
3115 case .denied:
3116 return "Denied"
3117 case .notDetermined:
3118 return "Not Requested"
3119 @unknown default:
3120 return "Unknown"
3121 }
3122 }
3123
3124 var body: some View {
3125 Form {
3126 Section("Monitoring") {
3127 Toggle(
3128 "Enable Background Monitoring",
3129 isOn: Binding(
3130 get: { viewModel.monitoringSettings.isEnabled },
3131 set: { viewModel.setMonitoringEnabled($0) }
3132 )
3133 )
3134
3135 Picker(
3136 "Base Interval",
3137 selection: Binding(
3138 get: { MonitoringBaseInterval.nearest(to: viewModel.monitoringSettings.baseInterval) },
3139 set: { viewModel.setMonitoringBaseInterval($0) }
3140 )
3141 ) {
3142 ForEach(MonitoringBaseInterval.allCases) { interval in
3143 Text(interval.title).tag(interval)
3144 }
3145 }
3146
3147 Toggle(
3148 "Adaptive Monitoring",
3149 isOn: Binding(
3150 get: { viewModel.monitoringSettings.adaptiveEnabled },
3151 set: { viewModel.setMonitoringAdaptiveEnabled($0) }
3152 )
3153 )
3154
3155 Picker(
3156 "Sensitivity",
3157 selection: Binding(
3158 get: { viewModel.monitoringSettings.sensitivity },
3159 set: { viewModel.setMonitoringSensitivity($0) }
3160 )
3161 ) {
3162 ForEach(MonitoringSensitivity.allCases) { sensitivity in
3163 Text(sensitivity.title).tag(sensitivity)
3164 }
3165 }
3166
3167 let quietHoursStart = viewModel.monitoringSettings.quietHours?.startHour ?? 22
3168 let quietHoursEnd = viewModel.monitoringSettings.quietHours?.endHour ?? 7
3169 Toggle(
3170 "Quiet Hours",
3171 isOn: Binding(
3172 get: { viewModel.monitoringSettings.quietHours != nil },
3173 set: { isEnabled in
3174 viewModel.setMonitoringQuietHours(
3175 startHour: quietHoursStart,
3176 endHour: quietHoursEnd,
3177 isEnabled: isEnabled
3178 )
3179 }
3180 )
3181 )
3182
3183 if viewModel.monitoringSettings.quietHours != nil {
3184 Picker(
3185 "Quiet Starts",
3186 selection: Binding(
3187 get: { quietHoursStart },
3188 set: { startHour in
3189 viewModel.setMonitoringQuietHours(
3190 startHour: startHour,
3191 endHour: quietHoursEnd,
3192 isEnabled: true
3193 )
3194 }
3195 )
3196 ) {
3197 ForEach(0..<24, id: \.self) { hour in
3198 Text(Self.monitoringHourLabel(for: hour)).tag(hour)
3199 }
3200 }
3201
3202 Picker(
3203 "Quiet Ends",
3204 selection: Binding(
3205 get: { quietHoursEnd },
3206 set: { endHour in
3207 viewModel.setMonitoringQuietHours(
3208 startHour: quietHoursStart,
3209 endHour: endHour,
3210 isEnabled: true
3211 )
3212 }
3213 )
3214 ) {
3215 ForEach(0..<24, id: \.self) { hour in
3216 Text(Self.monitoringHourLabel(for: hour)).tag(hour)
3217 }
3218 }
3219 }
3220
3221 Picker(
3222 "Domains",
3223 selection: Binding(
3224 get: { viewModel.monitoringSettings.scope },
3225 set: { viewModel.setMonitoringScope($0) }
3226 )
3227 ) {
3228 ForEach(MonitoringScope.allCases) { scope in
3229 Text(scope.title).tag(scope)
3230 }
3231 }
3232
3233 if viewModel.monitoringSettings.scope == .selectedOnly {
3234 ForEach(viewModel.trackedDomains) { trackedDomain in
3235 Toggle(
3236 trackedDomain.domain,
3237 isOn: Binding(
3238 get: { viewModel.monitoringSettings.selectedDomainIDs.contains(trackedDomain.id) },
3239 set: { viewModel.setMonitoringSelection(for: trackedDomain, isSelected: $0) }
3240 )
3241 )
3242 }
3243 }
3244
3245 Toggle(
3246 "Local Alerts",
3247 isOn: Binding(
3248 get: { viewModel.monitoringSettings.alertsEnabled },
3249 set: { isEnabled in
3250 if isEnabled {
3251 Task {
3252 await viewModel.requestMonitoringNotificationAuthorization()
3253 }
3254 } else {
3255 viewModel.setMonitoringAlertsEnabled(false)
3256 }
3257 }
3258 )
3259 )
3260
3261 Picker(
3262 "Notify For",
3263 selection: Binding(
3264 get: { viewModel.monitoringSettings.alertFilter },
3265 set: { viewModel.setMonitoringAlertFilter($0) }
3266 )
3267 ) {
3268 ForEach(MonitoringAlertFilter.allCases) { filter in
3269 Text(filter.title).tag(filter)
3270 }
3271 }
3272
3273 LabeledContent("Background Refresh", value: DomainMonitoringScheduler.shared.backgroundRefreshStatusDescription())
3274 LabeledContent("Notification Access", value: notificationAuthorizationLabel)
3275
3276 if let monitoringStatusMessage = viewModel.monitoringStatusMessage {
3277 Text(monitoringStatusMessage)
3278 .font(appDensity.font(.caption, design: .default))
3279 .foregroundStyle(Color(.appTextSecondary))
3280 }
3281
3282 if !FeatureAccessService.hasAccess(to: .automatedMonitoring) {
3283 Text("Background monitoring and alerts are available in Pro.")
3284 .font(appDensity.font(.caption, design: .default))
3285 .foregroundStyle(Color(.appTextSecondary))
3286 }
3287 }
3288 }
3289 .navigationTitle("Monitoring")
3290 .onAppear {
3291 viewModel.refreshMonitoringState()
3292 Task {
3293 await viewModel.refreshMonitoringAuthorizationStatus()
3294 }
3295 }
3296 }
3297
3298 private static func monitoringHourLabel(for hour: Int) -> String {
3299 let formatter = DateFormatter()
3300 formatter.dateFormat = "h a"
3301 let components = DateComponents(calendar: .current, hour: hour)
3302 return components.date.map(formatter.string(from:)) ?? "\(hour):00"
3303 }
3304}
3305
3306private struct DataPortabilitySettingsView: View {
3307 private enum ImportTarget {
3308 case backup
3309 case trackedDomains
3310 case workflows
3311
3312 var expectedKind: DataPortabilityImportKind {
3313 switch self {
3314 case .backup:
3315 return .backup
3316 case .trackedDomains:
3317 return .trackedDomains
3318 case .workflows:
3319 return .workflows
3320 }
3321 }
3322
3323 var allowedContentTypes: [UTType] {
3324 switch self {
3325 case .backup:
3326 return [UTType.json]
3327 case .trackedDomains, .workflows:
3328 return [UTType.json, UTType.commaSeparatedText]
3329 }
3330 }
3331 }
3332
3333 @Environment(\.appDensity) private var appDensity
3334 @Bindable var viewModel: DomainViewModel
3335
3336 @State private var importMode: DataPortabilityImportMode = .merge
3337 @State private var activeImportTarget: ImportTarget?
3338 @State private var pendingImportTarget: ImportTarget?
3339 @State private var importDebugStatus: String?
3340 @State private var pendingImportPreview: DataImportPreview?
3341 @State private var pendingImportError: String?
3342 @State private var showReplaceImportConfirmation = false
3343
3344 var body: some View {
3345 Form {
3346 Section("Import & Export") {
3347 Picker("Import Mode", selection: $importMode) {
3348 ForEach(DataPortabilityImportMode.allCases) { mode in
3349 Text(mode.title).tag(mode)
3350 }
3351 }
3352
3353 Text(importMode.explanation)
3354 .font(appDensity.font(.caption, design: .default))
3355 .foregroundStyle(Color(.appTextSecondary))
3356
3357 Button("Export Full Backup") {
3358 exportFullBackup()
3359 }
3360
3361 Button("Import Backup") {
3362 recordImportDebugStatus("Tapped Import Backup")
3363 pendingImportTarget = .backup
3364 activeImportTarget = .backup
3365 }
3366
3367 Menu("Export Tracked Domains") {
3368 Button("JSON") {
3369 exportPortableTrackedDomainsJSON()
3370 }
3371 Button("CSV") {
3372 exportPortableTrackedDomainsCSV()
3373 }
3374 }
3375
3376 Button("Import Tracked Domains") {
3377 recordImportDebugStatus("Tapped Import Tracked Domains")
3378 pendingImportTarget = .trackedDomains
3379 activeImportTarget = .trackedDomains
3380 }
3381
3382 Menu("Export Workflows") {
3383 Button("JSON") {
3384 exportPortableWorkflowsJSON()
3385 }
3386 Button("CSV") {
3387 exportPortableWorkflowsCSV()
3388 }
3389 }
3390
3391 Button("Import Workflows") {
3392 recordImportDebugStatus("Tapped Import Workflows")
3393 pendingImportTarget = .workflows
3394 activeImportTarget = .workflows
3395 }
3396
3397 Button("Export History") {
3398 exportPortableHistoryJSON()
3399 }
3400 }
3401
3402 Section("Local Data") {
3403 LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)")
3404 LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)")
3405 LabeledContent("Audit Sessions", value: "\(viewModel.dataLifecycleSummary.auditSessions)")
3406 LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)")
3407 LabeledContent("Cached Items", value: "\(viewModel.dataLifecycleSummary.cachedItems)")
3408 LabeledContent("Monitoring Logs", value: "\(viewModel.dataLifecycleSummary.monitoringLogs)")
3409
3410 Text("Data stays on this device unless you export it. Backup files can include domain history, monitoring settings, and notes. Imported files are processed on-device.")
3411 .font(appDensity.font(.caption, design: .default))
3412 .foregroundStyle(Color(.appTextSecondary))
3413
3414 if let portabilityStatusMessage = viewModel.portabilityStatusMessage {
3415 Text(portabilityStatusMessage)
3416 .font(appDensity.font(.caption, design: .default))
3417 .foregroundStyle(Color(.appTextSecondary))
3418 }
3419 }
3420
3421 #if DEBUG
3422 if let importDebugStatus {
3423 Section("Import Debug") {
3424 Text(importDebugStatus)
3425 .font(appDensity.font(.caption, design: .default))
3426 .foregroundStyle(Color(.appTextSecondary))
3427 .textSelection(.enabled)
3428 }
3429 }
3430 #endif
3431 }
3432 .navigationTitle("Import & Export")
3433 .alert("Replace local data?", isPresented: $showReplaceImportConfirmation) {
3434 Button("Replace", role: .destructive) {
3435 applyPendingImport()
3436 }
3437 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3438 } message: {
3439 Text("Replace mode overwrites local data covered by the imported file and may remove items that are only on this device.")
3440 }
3441 .alert("Import Error", isPresented: Binding(
3442 get: { pendingImportError != nil },
3443 set: { if !$0 { pendingImportError = nil } }
3444 )) {
3445 Button("OK", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3446 } message: {
3447 Text(pendingImportError ?? "The import could not be completed.")
3448 }
3449 .sheet(isPresented: Binding(
3450 get: { pendingImportPreview != nil },
3451 set: { if !$0 { pendingImportPreview = nil } }
3452 )) {
3453 if let pendingImportPreview {
3454 DataImportPreviewSheet(
3455 preview: pendingImportPreview,
3456 mode: importMode,
3457 onCancel: {
3458 self.pendingImportPreview = nil
3459 },
3460 onApply: {
3461 if importMode == .replace {
3462 showReplaceImportConfirmation = true
3463 } else {
3464 applyPendingImport()
3465 }
3466 }
3467 )
3468 }
3469 }
3470 .fileImporter(
3471 isPresented: Binding(
3472 get: { activeImportTarget != nil },
3473 set: { if !$0 { activeImportTarget = nil } }
3474 ),
3475 allowedContentTypes: activeImportTarget?.allowedContentTypes ?? [UTType.json],
3476 allowsMultipleSelection: false
3477 ) { result in
3478 guard let pendingImportTarget else {
3479 recordImportDebugStatus("fileImporter returned with no active target")
3480 return
3481 }
3482 recordImportDebugStatus("fileImporter returned for \(pendingImportTarget.expectedKind.rawValue)")
3483 handleImportResult(result, expectedKind: pendingImportTarget.expectedKind)
3484 self.pendingImportTarget = nil
3485 self.activeImportTarget = nil
3486 }
3487 .onAppear {
3488 viewModel.refreshDataLifecycleSummary()
3489 }
3490 }
3491
3492 private func exportFullBackup() {
3493 guard let data = viewModel.exportFullBackupData() else { return }
3494 ExportPresenter.share(filename: portabilityFilename(suffix: "backup", fileExtension: "json"), data: data)
3495 }
3496
3497 private func exportPortableTrackedDomainsJSON() {
3498 guard let data = viewModel.exportPortableTrackedDomainsJSONData() else { return }
3499 ExportPresenter.share(filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "json"), data: data)
3500 }
3501
3502 private func exportPortableTrackedDomainsCSV() {
3503 ExportPresenter.share(
3504 filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "csv"),
3505 contents: viewModel.exportPortableTrackedDomainsCSV()
3506 )
3507 }
3508
3509 private func exportPortableWorkflowsJSON() {
3510 guard let data = viewModel.exportPortableWorkflowsJSONData() else { return }
3511 ExportPresenter.share(filename: portabilityFilename(suffix: "workflows", fileExtension: "json"), data: data)
3512 }
3513
3514 private func exportPortableWorkflowsCSV() {
3515 ExportPresenter.share(
3516 filename: portabilityFilename(suffix: "workflows", fileExtension: "csv"),
3517 contents: viewModel.exportPortableWorkflowsCSV()
3518 )
3519 }
3520
3521 private func exportPortableHistoryJSON() {
3522 guard let data = viewModel.exportPortableHistoryJSONData() else { return }
3523 ExportPresenter.share(filename: portabilityFilename(suffix: "history", fileExtension: "json"), data: data)
3524 }
3525
3526 private func handleImportResult(
3527 _ result: Result<[URL], Error>,
3528 expectedKind: DataPortabilityImportKind
3529 ) {
3530 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult expectedKind=\(expectedKind.rawValue)")
3531 recordImportDebugStatus("handleImportResult started for \(expectedKind.rawValue)")
3532 do {
3533 let urls = try result.get()
3534 guard let url = urls.first else {
3535 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult noURLReturned")
3536 recordImportDebugStatus("No URL returned from picker")
3537 return
3538 }
3539 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult selectedURL=\(url.absoluteString)")
3540 recordImportDebugStatus("Selected \(url.lastPathComponent)")
3541 let shouldStopAccessing = url.startAccessingSecurityScopedResource()
3542 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeGranted=\(shouldStopAccessing)")
3543 recordImportDebugStatus("Security scope granted: \(shouldStopAccessing)")
3544 defer {
3545 if shouldStopAccessing {
3546 url.stopAccessingSecurityScopedResource()
3547 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeReleased")
3548 }
3549 }
3550
3551 let data = try Data(contentsOf: url)
3552 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult dataRead bytes=\(data.count) fileName=\(url.lastPathComponent)")
3553 recordImportDebugStatus("Read \(data.count) bytes from \(url.lastPathComponent)")
3554 let preview = try viewModel.prepareDataImport(
3555 data: data,
3556 fileName: url.lastPathComponent,
3557 mode: importMode
3558 )
3559 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult previewReady previewKind=\(preview.kind.rawValue) expectedKind=\(expectedKind.rawValue)")
3560 recordImportDebugStatus("Preview ready: \(preview.kind.rawValue)")
3561
3562 guard preview.kind == expectedKind else {
3563 let message = preview.kind == .backup
3564 ? "That file is a full backup. Use Import Backup."
3565 : "That file type does not match this import action."
3566 DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult kindMismatch message=\(message)")
3567 recordImportDebugStatus("Kind mismatch: \(message)")
3568 presentImportError(message)
3569 return
3570 }
3571
3572 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult presentingPreview kind=\(preview.kind.rawValue)")
3573 recordImportDebugStatus("Presenting preview for \(preview.kind.rawValue)")
3574 presentImportPreview(preview)
3575 } catch {
3576 DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult failed error=\(error.localizedDescription)")
3577 recordImportDebugStatus("Import failed: \(error.localizedDescription)")
3578 presentImportError(error.localizedDescription)
3579 }
3580 }
3581
3582 private func applyPendingImport() {
3583 guard let pendingImportPreview else { return }
3584 do {
3585 _ = try viewModel.applyDataImport(pendingImportPreview, mode: importMode)
3586 self.pendingImportPreview = nil
3587 } catch {
3588 pendingImportError = error.localizedDescription
3589 }
3590 }
3591
3592 private func portabilityFilename(suffix: String, fileExtension: String) -> String {
3593 let formatter = DateFormatter()
3594 formatter.dateFormat = "yyyyMMdd_HHmmss"
3595 return "\(formatter.string(from: Date()))_domaindig_\(suffix).\(fileExtension)"
3596 }
3597
3598 private func presentImportPreview(_ preview: DataImportPreview) {
3599 Task { @MainActor in
3600 try? await Task.sleep(for: .milliseconds(300))
3601 DomainDebugLog.debug("DataPortabilitySettingsView.presentImportPreview kind=\(preview.kind.rawValue) fileName=\(preview.fileName)")
3602 recordImportDebugStatus("Preview presented for \(preview.fileName)")
3603 pendingImportPreview = preview
3604 }
3605 }
3606
3607 private func presentImportError(_ message: String) {
3608 Task { @MainActor in
3609 try? await Task.sleep(for: .milliseconds(300))
3610 DomainDebugLog.error("DataPortabilitySettingsView.presentImportError message=\(message)")
3611 recordImportDebugStatus("Error presented: \(message)")
3612 pendingImportError = message
3613 }
3614 }
3615
3616 private func recordImportDebugStatus(_ message: String) {
3617 #if DEBUG
3618 let status = "[Import Debug] \(message)"
3619 importDebugStatus = status
3620 print(status)
3621 #endif
3622 }
3623}
3624
3625private struct DataManagementSettingsView: View {
3626 @Bindable var viewModel: DomainViewModel
3627 @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
3628
3629 @State private var showClearHistoryConfirmation = false
3630 @State private var showClearCacheConfirmation = false
3631 @State private var showClearWorkflowsConfirmation = false
3632 @State private var showClearTrackedDomainsConfirmation = false
3633 @State private var showDeleteAllConfirmation = false
3634 @State private var deleteAllErrorMessage: String?
3635 @State private var deleteAllSuccessMessage: String?
3636 @State private var isDeletingAllData = false
3637
3638 var body: some View {
3639 Form {
3640 Section("Data") {
3641 Button("Clear History", role: .destructive) {
3642 showClearHistoryConfirmation = true
3643 }
3644
3645 Button("Clear Cache", role: .destructive) {
3646 showClearCacheConfirmation = true
3647 }
3648
3649 Button("Clear Workflows", role: .destructive) {
3650 showClearWorkflowsConfirmation = true
3651 }
3652
3653 Button("Clear Tracked Domains", role: .destructive) {
3654 showClearTrackedDomainsConfirmation = true
3655 }
3656 }
3657
3658 Section {
3659 Button(role: .destructive) {
3660 showDeleteAllConfirmation = true
3661 } label: {
3662 HStack {
3663 Text("Delete All Data")
3664 Spacer()
3665 if isDeletingAllData {
3666 ProgressView()
3667 .controlSize(.small)
3668 }
3669 }
3670 }
3671 .disabled(isDeletingAllData)
3672 } header: {
3673 Text("Danger Zone")
3674 } footer: {
3675 Text("Permanently removes all local DomainDig data from this device.")
3676 }
3677 }
3678 .disabled(isDeletingAllData)
3679 .navigationTitle("Data Management")
3680 .alert("Clear history?", isPresented: $showClearHistoryConfirmation) {
3681 Button("Clear", role: .destructive) {
3682 viewModel.clearHistory()
3683 }
3684 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3685 } message: {
3686 Text("This removes saved lookup snapshots and clears monitoring run history on this device.")
3687 }
3688 .alert("Clear cache?", isPresented: $showClearCacheConfirmation) {
3689 Button("Clear", role: .destructive) {
3690 viewModel.clearLookupCache()
3691 }
3692 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3693 } message: {
3694 Text("This clears the in-memory lookup cache and cancels any cached in-flight work.")
3695 }
3696 .alert("Clear workflows?", isPresented: $showClearWorkflowsConfirmation) {
3697 Button("Clear", role: .destructive) {
3698 viewModel.clearWorkflows()
3699 }
3700 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3701 } message: {
3702 Text("This removes saved workflows only. History, tracked domains, and saved reports stay intact.")
3703 }
3704 .alert("Clear tracked domains?", isPresented: $showClearTrackedDomainsConfirmation) {
3705 Button("Clear", role: .destructive) {
3706 viewModel.clearTrackedDomains()
3707 }
3708 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3709 } message: {
3710 Text("This removes the watchlist and clears monitoring run history. History and workflows stay intact.")
3711 }
3712 .alert("Delete All Data?", isPresented: $showDeleteAllConfirmation) {
3713 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3714 Button("Delete All Data", role: .destructive) {
3715 deleteAllData()
3716 }
3717 } message: {
3718 Text("This will permanently remove all saved DomainDig data from this device. This includes tracked domains, monitoring history, snapshots, exports, cached reports, and local settings. This action cannot be undone.")
3719 }
3720 .alert("Delete Failed", isPresented: Binding(
3721 get: { deleteAllErrorMessage != nil },
3722 set: { if !$0 { deleteAllErrorMessage = nil } }
3723 )) {
3724 Button("OK", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3725 } message: {
3726 Text(deleteAllErrorMessage ?? "The local data reset could not be completed.")
3727 }
3728 .safeAreaInset(edge: .bottom) {
3729 if let deleteAllSuccessMessage {
3730 Text(deleteAllSuccessMessage)
3731 .font(.footnote.weight(.medium))
3732 .foregroundStyle(Color(.appTextSecondary))
3733 .padding(.horizontal, 14)
3734 .padding(.vertical, 10)
3735 // Reduce Transparency swaps the blur for an opaque surface.
3736 // On iOS 26+ the system also composites its own translucency
3737 // that the app cannot declare — verify there too (Phase 6).
3738 .background(
3739 Capsule().fill(reduceTransparency ? AnyShapeStyle(Color(.appSurfaceElevated)) : AnyShapeStyle(.thinMaterial))
3740 )
3741 .padding(.bottom, 8)
3742 .transition(.move(edge: .bottom).combined(with: .opacity))
3743 }
3744 }
3745 }
3746
3747 private func deleteAllData() {
3748 guard !isDeletingAllData else { return }
3749
3750 isDeletingAllData = true
3751 deleteAllErrorMessage = nil
3752 deleteAllSuccessMessage = nil
3753
3754 Task {
3755 do {
3756 try await DataResetService.wipeAllLocalData(viewModel: viewModel)
3757 deleteAllSuccessMessage = "All local data removed."
3758 try? await Task.sleep(for: .seconds(2))
3759 if deleteAllSuccessMessage == "All local data removed." {
3760 deleteAllSuccessMessage = nil
3761 }
3762 } catch {
3763 deleteAllErrorMessage = error.localizedDescription
3764 }
3765
3766 isDeletingAllData = false
3767 }
3768 }
3769}
3770
3771private struct AboutSettingsView: View {
3772 @State private var cloudSyncService = CloudSyncService.shared
3773
3774 private var appVersion: String {
3775 AppVersion.current
3776 }
3777
3778 var body: some View {
3779 Form {
3780 Section("About") {
3781 LabeledContent("Version", value: appVersion)
3782 LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only")
3783 LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)")
3784 }
3785 }
3786 .navigationTitle("App Info")
3787 .task {
3788 await cloudSyncService.refreshAvailability()
3789 }
3790 }
3791}
3792
3793private struct DataImportPreviewSheet: View {
3794 @Environment(\.dismiss) private var dismiss
3795
3796 let preview: DataImportPreview
3797 let mode: DataPortabilityImportMode
3798 let onCancel: () -> Void
3799 let onApply: () -> Void
3800
3801 var body: some View {
3802 NavigationStack {
3803 List {
3804 Section("Summary") {
3805 ForEach(preview.summaryLines, id: \.self) { line in
3806 Text(line)
3807 }
3808 }
3809
3810 Section("Projected Counts") {
3811 LabeledContent("Tracked Domains", value: "\(preview.projectedCounts.trackedDomains)")
3812 LabeledContent("History Snapshots", value: "\(preview.projectedCounts.historySnapshots)")
3813 LabeledContent("Audit Sessions", value: "\(preview.projectedCounts.auditSessions)")
3814 LabeledContent("Workflows", value: "\(preview.projectedCounts.workflows)")
3815 LabeledContent("Cached Items", value: "\(preview.projectedCounts.cachedItems)")
3816 LabeledContent("Monitoring Logs", value: "\(preview.projectedCounts.monitoringLogs)")
3817 }
3818
3819 if !preview.warnings.isEmpty {
3820 Section("Warnings") {
3821 ForEach(preview.warnings, id: \.self) { warning in
3822 Text(warning)
3823 .foregroundStyle(Color(.appTextSecondary))
3824 }
3825 }
3826 }
3827 }
3828 .navigationTitle("Import Preview")
3829 .toolbar {
3830 ToolbarItem(placement: .cancellationAction) {
3831 Button("Cancel") {
3832 onCancel()
3833 dismiss()
3834 }
3835 }
3836 ToolbarItem(placement: .confirmationAction) {
3837 Button(mode == .replace ? "Replace" : "Import") {
3838 onApply()
3839 if mode == .merge {
3840 dismiss()
3841 }
3842 }
3843 }
3844 }
3845 }
3846 }
3847}
3848
3849extension ChangeImpactClassification { 2706extension ChangeImpactClassification {
3850 var tone: AppStatusTone { 2707 var tone: AppStatusTone {
3851 switch self { 2708 switch self {
DomainDig/SettingsViews.swift added +1149
@@ -0,0 +1,1149 @@
1import SwiftUI
2import UniformTypeIdentifiers
3
4// Settings screens extracted from ContentView.swift. `SettingsView` is the
5// Settings tab root (presented from RootTabView); the per-section screens are
6// file-private, reached only through its navigation links.
7
8struct SettingsView: View {
9 @Environment(\.appDensity) private var appDensity
10 @Bindable var viewModel: DomainViewModel
11 @State private var purchaseService = PurchaseService.shared
12
13 var body: some View {
14 let _ = purchaseService.currentTier
15
16 List {
17 Section("Tier") {
18 LabeledContent("Status", value: purchaseService.currentTier.title)
19
20 if purchaseService.currentTier == .free {
21 Button("Upgrade") {
22 viewModel.isPaywallPresented = true
23 }
24 } else {
25 Button("Manage Subscription") {
26 Task {
27 await purchaseService.manageSubscription()
28 }
29 }
30 }
31
32 Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") {
33 Task {
34 await purchaseService.restorePurchases()
35 }
36 }
37 .disabled(purchaseService.isRestoring || purchaseService.isPurchasing)
38
39 if let statusMessage = purchaseService.statusMessage {
40 Text(statusMessage)
41 .font(appDensity.font(.caption, design: .default))
42 .foregroundStyle(Color(.appTextSecondary))
43 }
44
45 if let errorMessage = purchaseService.errorMessage {
46 Text(errorMessage)
47 .font(appDensity.font(.caption, design: .default))
48 .foregroundStyle(Color(.statusCritical))
49 }
50 }
51
52 Section("Preferences") {
53 NavigationLink("Tracked Domains") {
54 WatchlistView(viewModel: viewModel)
55 }
56
57 NavigationLink("Workflows") {
58 WorkflowsView(viewModel: viewModel)
59 }
60
61 NavigationLink("Display") {
62 DisplaySettingsView()
63 }
64
65 NavigationLink("History & Network") {
66 HistoryNetworkSettingsView(viewModel: viewModel)
67 }
68 }
69
70 Section("Services") {
71 NavigationLink("Monitoring Activity") {
72 MonitoringView(viewModel: viewModel)
73 }
74
75 NavigationLink("Integrations") {
76 IntegrationsSettingsView()
77 }
78
79 NavigationLink("Local API") {
80 LocalAPISettingsView()
81 }
82
83 NavigationLink("iCloud Sync") {
84 CloudSyncSettingsView()
85 }
86
87 NavigationLink("Monitoring") {
88 MonitoringSettingsView(viewModel: viewModel)
89 }
90
91 NavigationLink("Scheduled Reports") {
92 ScheduledReportsView()
93 }
94 }
95
96 Section("Data") {
97 NavigationLink("Import & Export") {
98 DataPortabilitySettingsView(viewModel: viewModel)
99 }
100
101 NavigationLink("Data Management") {
102 DataManagementSettingsView(viewModel: viewModel)
103 }
104 }
105
106 Section("About") {
107 NavigationLink("App Info") {
108 AboutSettingsView()
109 }
110 }
111 }
112 .navigationTitle("Settings")
113 }
114}
115
116private struct DisplaySettingsView: View {
117 @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue
118 @AppStorage(AppAppearance.userDefaultsKey) private var storedAppearance = AppAppearance.system.rawValue
119
120 var body: some View {
121 Form {
122 Section("Display") {
123 Picker("Appearance", selection: $storedAppearance) {
124 ForEach(AppAppearance.allCases) { appearance in
125 Text(appearance.title).tag(appearance.rawValue)
126 }
127 }
128
129 Picker("Density", selection: $storedDensity) {
130 ForEach(AppDensity.allCases) { density in
131 Text(density.title).tag(density.rawValue)
132 }
133 }
134 }
135 }
136 .navigationTitle("Display")
137 }
138}
139
140private struct HistoryNetworkSettingsView: View {
141 @Environment(\.appDensity) private var appDensity
142 @Bindable var viewModel: DomainViewModel
143 @AppStorage(DNSResolverOption.userDefaultsKey) private var storedResolverURL = DNSResolverOption.defaultURLString
144 @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue
145
146 @State private var resolverOption: DNSResolverOption = .cloudflare
147 @State private var customResolverURL = DNSResolverOption.defaultURLString
148
149 private var customResolverError: String? {
150 guard resolverOption == .custom else { return nil }
151 return DNSResolverOption.isValidCustomURL(customResolverURL) ? nil : "Resolver URL must start with https://"
152 }
153
154 var body: some View {
155 Form {
156 Section("History") {
157 Picker(
158 "Auto-prune",
159 selection: Binding(
160 get: { viewModel.historyAutoPruneOption },
161 set: { viewModel.setHistoryAutoPruneOption($0) }
162 )
163 ) {
164 ForEach(HistoryAutoPruneOption.allCases) { option in
165 Text(option.title).tag(option)
166 }
167 }
168
169 Text("History remains local-first. Auto-prune only trims older local snapshots on this device and defaults to unlimited.")
170 .font(appDensity.font(.caption, design: .default))
171 .foregroundStyle(Color(.appTextSecondary))
172 }
173
174 Section("Network") {
175 Picker("Resolver", selection: $resolverOption) {
176 ForEach(DNSResolverOption.allCases) { option in
177 Text(option.title).tag(option)
178 }
179 }
180
181 if resolverOption == .custom {
182 TextField("https://resolver.example/dns-query", text: $customResolverURL)
183 .textInputAutocapitalization(.never)
184 .autocorrectionDisabled()
185 .keyboardType(.URL)
186
187 if let customResolverError {
188 Text(customResolverError)
189 .font(appDensity.font(.caption, design: .default))
190 .foregroundStyle(Color(.statusCritical))
191 }
192 }
193 }
194 }
195 .navigationTitle("History & Network")
196 .onAppear {
197 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
198 resolverOption = DNSResolverOption.option(for: currentResolverURL)
199 customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString
200 }
201 .onChange(of: resolverOption) { _, newValue in
202 guard let presetURL = newValue.urlString else {
203 storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
204 viewModel.persistCurrentAppSettings(
205 resolverURLString: storedResolverURL,
206 appDensityRawValue: storedDensity
207 )
208 return
209 }
210 storedResolverURL = presetURL
211 viewModel.persistCurrentAppSettings(
212 resolverURLString: storedResolverURL,
213 appDensityRawValue: storedDensity
214 )
215 }
216 .onChange(of: customResolverURL) { _, newValue in
217 guard resolverOption == .custom else { return }
218 storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
219 viewModel.persistCurrentAppSettings(
220 resolverURLString: storedResolverURL,
221 appDensityRawValue: storedDensity
222 )
223 }
224 .onChange(of: storedDensity) { _, newValue in
225 viewModel.persistCurrentAppSettings(
226 resolverURLString: storedResolverURL,
227 appDensityRawValue: newValue
228 )
229 }
230 }
231}
232
233private struct CloudSyncSettingsView: View {
234 @Environment(\.appDensity) private var appDensity
235 @State private var cloudSyncService = CloudSyncService.shared
236
237 var body: some View {
238 Form {
239 Section("iCloud Sync") {
240 Toggle(
241 "Enable iCloud Sync",
242 isOn: Binding(
243 get: { cloudSyncService.isEnabled },
244 set: { cloudSyncService.setSyncEnabled($0) }
245 )
246 )
247
248 LabeledContent("Status", value: cloudSyncService.status.title)
249 LabeledContent(
250 "Last Sync",
251 value: cloudSyncService.lastSyncDate?.formatted(date: .abbreviated, time: .shortened) ?? "Not yet synced"
252 )
253
254 Button(cloudSyncService.status == .syncing ? "Syncing…" : "Sync Now") {
255 Task {
256 await cloudSyncService.syncNow(trigger: .manual)
257 }
258 }
259 .disabled(!cloudSyncService.isEnabled || cloudSyncService.status == .syncing)
260
261 Text("iCloud Sync stores DomainDig data in your private iCloud account. DomainDig does not operate a sync server. Disabling sync keeps local data on this device.")
262 .font(appDensity.font(.caption, design: .default))
263 .foregroundStyle(Color(.appTextSecondary))
264
265 Text(cloudSyncService.detailMessage)
266 .font(appDensity.font(.caption, design: .default))
267 .foregroundStyle(Color(.appTextSecondary))
268
269 if let lastErrorMessage = cloudSyncService.lastErrorMessage {
270 Text(lastErrorMessage)
271 .font(appDensity.font(.caption, design: .default))
272 .foregroundStyle(Color(.statusCritical))
273 }
274 }
275 }
276 .navigationTitle("iCloud Sync")
277 .task {
278 await cloudSyncService.refreshAvailability()
279 }
280 }
281}
282
283private struct LocalAPISettingsView: View {
284 @Environment(\.appDensity) private var appDensity
285 @State private var localAPIService = LocalAPIService.shared
286 @State private var portText = ""
287
288 private var statusText: String {
289 if localAPIService.isRunning { return "Running" }
290 return localAPIService.config.isEnabled ? "Stopped" : "Disabled"
291 }
292
293 var body: some View {
294 Form {
295 Section("Local API") {
296 Toggle(
297 "Enable Local API",
298 isOn: Binding(
299 get: { localAPIService.config.isEnabled },
300 set: { localAPIService.setEnabled($0) }
301 )
302 )
303
304 TextField(
305 "Port",
306 text: Binding(
307 get: { portText },
308 set: { newValue in
309 portText = newValue
310 if let port = Int(newValue) {
311 localAPIService.setPort(port)
312 }
313 }
314 )
315 )
316 .keyboardType(.numberPad)
317
318 LabeledContent("Address", value: localAPIService.address)
319 LabeledContent("Status", value: statusText)
320 LabeledContent("Token", value: localAPIService.maskedToken)
321
322 if let statusMessage = localAPIService.statusMessage {
323 Text(statusMessage)
324 .font(appDensity.font(.caption, design: .default))
325 .foregroundStyle(Color(.appTextSecondary))
326 }
327 }
328
329 Section("Authentication") {
330 Button("Copy Token") {
331 localAPIService.copyToken()
332 }
333
334 Button("Copy cURL Command") {
335 localAPIService.copyCurlCommand()
336 }
337
338 Button("Rotate Token") {
339 localAPIService.rotateToken()
340 }
341
342 Text("Every request requires either `Authorization: Bearer <token>` or `X-API-Token`. DomainDig stores the token in Keychain and only binds the server to localhost.")
343 .font(appDensity.font(.caption, design: .default))
344 .foregroundStyle(Color(.appTextSecondary))
345 }
346
347 Section("Request Logging") {
348 Toggle(
349 "Log Requests",
350 isOn: Binding(
351 get: { localAPIService.config.requestLoggingEnabled },
352 set: { localAPIService.setRequestLoggingEnabled($0) }
353 )
354 )
355
356 if localAPIService.requestLogs.isEmpty {
357 Text("No local API requests logged yet.")
358 .font(appDensity.font(.caption, design: .default))
359 .foregroundStyle(Color(.appTextSecondary))
360 } else {
361 ForEach(localAPIService.requestLogs.prefix(25)) { log in
362 VStack(alignment: .leading, spacing: 4) {
363 HStack {
364 Text("\(log.method) \(log.path)")
365 .font(appDensity.font(.callout, design: .monospaced))
366 Spacer()
367 Text("\(log.statusCode)")
368 .font(appDensity.font(.caption, design: .default))
369 .foregroundStyle(log.statusCode >= 400 ? Color(.statusCritical) : .secondary)
370 }
371
372 Text(log.timestamp.formatted(date: .abbreviated, time: .standard))
373 .font(appDensity.font(.caption2, design: .default))
374 .foregroundStyle(Color(.appTextSecondary))
375
376 Text("\(Int(log.duration * 1000)) ms")
377 .font(appDensity.font(.caption2, design: .default))
378 .foregroundStyle(Color(.appTextSecondary))
379 }
380 }
381 }
382
383 Button("Clear Logs", role: .destructive) {
384 localAPIService.clearRequestLogs()
385 }
386 }
387
388 Section("Control") {
389 Button("Restart Server") {
390 localAPIService.setEnabled(false)
391 localAPIService.setEnabled(true)
392 }
393 .disabled(!localAPIService.config.isEnabled)
394
395 Button("Stop Server") {
396 localAPIService.stopServer()
397 }
398 .disabled(!localAPIService.isRunning)
399 }
400 }
401 .navigationTitle("Local API")
402 .onAppear {
403 portText = String(localAPIService.config.port)
404 localAPIService.refresh()
405 }
406 }
407}
408
409private struct MonitoringSettingsView: View {
410 @Environment(\.appDensity) private var appDensity
411 @Bindable var viewModel: DomainViewModel
412
413 private var notificationAuthorizationLabel: String {
414 switch viewModel.monitoringNotificationStatus {
415 case .authorized, .provisional, .ephemeral:
416 return "Allowed"
417 case .denied:
418 return "Denied"
419 case .notDetermined:
420 return "Not Requested"
421 @unknown default:
422 return "Unknown"
423 }
424 }
425
426 var body: some View {
427 Form {
428 Section("Monitoring") {
429 Toggle(
430 "Enable Background Monitoring",
431 isOn: Binding(
432 get: { viewModel.monitoringSettings.isEnabled },
433 set: { viewModel.setMonitoringEnabled($0) }
434 )
435 )
436
437 Picker(
438 "Base Interval",
439 selection: Binding(
440 get: { MonitoringBaseInterval.nearest(to: viewModel.monitoringSettings.baseInterval) },
441 set: { viewModel.setMonitoringBaseInterval($0) }
442 )
443 ) {
444 ForEach(MonitoringBaseInterval.allCases) { interval in
445 Text(interval.title).tag(interval)
446 }
447 }
448
449 Toggle(
450 "Adaptive Monitoring",
451 isOn: Binding(
452 get: { viewModel.monitoringSettings.adaptiveEnabled },
453 set: { viewModel.setMonitoringAdaptiveEnabled($0) }
454 )
455 )
456
457 Picker(
458 "Sensitivity",
459 selection: Binding(
460 get: { viewModel.monitoringSettings.sensitivity },
461 set: { viewModel.setMonitoringSensitivity($0) }
462 )
463 ) {
464 ForEach(MonitoringSensitivity.allCases) { sensitivity in
465 Text(sensitivity.title).tag(sensitivity)
466 }
467 }
468
469 let quietHoursStart = viewModel.monitoringSettings.quietHours?.startHour ?? 22
470 let quietHoursEnd = viewModel.monitoringSettings.quietHours?.endHour ?? 7
471 Toggle(
472 "Quiet Hours",
473 isOn: Binding(
474 get: { viewModel.monitoringSettings.quietHours != nil },
475 set: { isEnabled in
476 viewModel.setMonitoringQuietHours(
477 startHour: quietHoursStart,
478 endHour: quietHoursEnd,
479 isEnabled: isEnabled
480 )
481 }
482 )
483 )
484
485 if viewModel.monitoringSettings.quietHours != nil {
486 Picker(
487 "Quiet Starts",
488 selection: Binding(
489 get: { quietHoursStart },
490 set: { startHour in
491 viewModel.setMonitoringQuietHours(
492 startHour: startHour,
493 endHour: quietHoursEnd,
494 isEnabled: true
495 )
496 }
497 )
498 ) {
499 ForEach(0..<24, id: \.self) { hour in
500 Text(Self.monitoringHourLabel(for: hour)).tag(hour)
501 }
502 }
503
504 Picker(
505 "Quiet Ends",
506 selection: Binding(
507 get: { quietHoursEnd },
508 set: { endHour in
509 viewModel.setMonitoringQuietHours(
510 startHour: quietHoursStart,
511 endHour: endHour,
512 isEnabled: true
513 )
514 }
515 )
516 ) {
517 ForEach(0..<24, id: \.self) { hour in
518 Text(Self.monitoringHourLabel(for: hour)).tag(hour)
519 }
520 }
521 }
522
523 Picker(
524 "Domains",
525 selection: Binding(
526 get: { viewModel.monitoringSettings.scope },
527 set: { viewModel.setMonitoringScope($0) }
528 )
529 ) {
530 ForEach(MonitoringScope.allCases) { scope in
531 Text(scope.title).tag(scope)
532 }
533 }
534
535 if viewModel.monitoringSettings.scope == .selectedOnly {
536 ForEach(viewModel.trackedDomains) { trackedDomain in
537 Toggle(
538 trackedDomain.domain,
539 isOn: Binding(
540 get: { viewModel.monitoringSettings.selectedDomainIDs.contains(trackedDomain.id) },
541 set: { viewModel.setMonitoringSelection(for: trackedDomain, isSelected: $0) }
542 )
543 )
544 }
545 }
546
547 Toggle(
548 "Local Alerts",
549 isOn: Binding(
550 get: { viewModel.monitoringSettings.alertsEnabled },
551 set: { isEnabled in
552 if isEnabled {
553 Task {
554 await viewModel.requestMonitoringNotificationAuthorization()
555 }
556 } else {
557 viewModel.setMonitoringAlertsEnabled(false)
558 }
559 }
560 )
561 )
562
563 Picker(
564 "Notify For",
565 selection: Binding(
566 get: { viewModel.monitoringSettings.alertFilter },
567 set: { viewModel.setMonitoringAlertFilter($0) }
568 )
569 ) {
570 ForEach(MonitoringAlertFilter.allCases) { filter in
571 Text(filter.title).tag(filter)
572 }
573 }
574
575 LabeledContent("Background Refresh", value: DomainMonitoringScheduler.shared.backgroundRefreshStatusDescription())
576 LabeledContent("Notification Access", value: notificationAuthorizationLabel)
577
578 if let monitoringStatusMessage = viewModel.monitoringStatusMessage {
579 Text(monitoringStatusMessage)
580 .font(appDensity.font(.caption, design: .default))
581 .foregroundStyle(Color(.appTextSecondary))
582 }
583
584 if !FeatureAccessService.hasAccess(to: .automatedMonitoring) {
585 Text("Background monitoring and alerts are available in Pro.")
586 .font(appDensity.font(.caption, design: .default))
587 .foregroundStyle(Color(.appTextSecondary))
588 }
589 }
590 }
591 .navigationTitle("Monitoring")
592 .onAppear {
593 viewModel.refreshMonitoringState()
594 Task {
595 await viewModel.refreshMonitoringAuthorizationStatus()
596 }
597 }
598 }
599
600 private static func monitoringHourLabel(for hour: Int) -> String {
601 let formatter = DateFormatter()
602 formatter.dateFormat = "h a"
603 let components = DateComponents(calendar: .current, hour: hour)
604 return components.date.map(formatter.string(from:)) ?? "\(hour):00"
605 }
606}
607
608private struct DataPortabilitySettingsView: View {
609 private enum ImportTarget {
610 case backup
611 case trackedDomains
612 case workflows
613
614 var expectedKind: DataPortabilityImportKind {
615 switch self {
616 case .backup:
617 return .backup
618 case .trackedDomains:
619 return .trackedDomains
620 case .workflows:
621 return .workflows
622 }
623 }
624
625 var allowedContentTypes: [UTType] {
626 switch self {
627 case .backup:
628 return [UTType.json]
629 case .trackedDomains, .workflows:
630 return [UTType.json, UTType.commaSeparatedText]
631 }
632 }
633 }
634
635 @Environment(\.appDensity) private var appDensity
636 @Bindable var viewModel: DomainViewModel
637
638 @State private var importMode: DataPortabilityImportMode = .merge
639 @State private var activeImportTarget: ImportTarget?
640 @State private var pendingImportTarget: ImportTarget?
641 @State private var importDebugStatus: String?
642 @State private var pendingImportPreview: DataImportPreview?
643 @State private var pendingImportError: String?
644 @State private var showReplaceImportConfirmation = false
645
646 var body: some View {
647 Form {
648 Section("Import & Export") {
649 Picker("Import Mode", selection: $importMode) {
650 ForEach(DataPortabilityImportMode.allCases) { mode in
651 Text(mode.title).tag(mode)
652 }
653 }
654
655 Text(importMode.explanation)
656 .font(appDensity.font(.caption, design: .default))
657 .foregroundStyle(Color(.appTextSecondary))
658
659 Button("Export Full Backup") {
660 exportFullBackup()
661 }
662
663 Button("Import Backup") {
664 recordImportDebugStatus("Tapped Import Backup")
665 pendingImportTarget = .backup
666 activeImportTarget = .backup
667 }
668
669 Menu("Export Tracked Domains") {
670 Button("JSON") {
671 exportPortableTrackedDomainsJSON()
672 }
673 Button("CSV") {
674 exportPortableTrackedDomainsCSV()
675 }
676 }
677
678 Button("Import Tracked Domains") {
679 recordImportDebugStatus("Tapped Import Tracked Domains")
680 pendingImportTarget = .trackedDomains
681 activeImportTarget = .trackedDomains
682 }
683
684 Menu("Export Workflows") {
685 Button("JSON") {
686 exportPortableWorkflowsJSON()
687 }
688 Button("CSV") {
689 exportPortableWorkflowsCSV()
690 }
691 }
692
693 Button("Import Workflows") {
694 recordImportDebugStatus("Tapped Import Workflows")
695 pendingImportTarget = .workflows
696 activeImportTarget = .workflows
697 }
698
699 Button("Export History") {
700 exportPortableHistoryJSON()
701 }
702 }
703
704 Section("Local Data") {
705 LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)")
706 LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)")
707 LabeledContent("Audit Sessions", value: "\(viewModel.dataLifecycleSummary.auditSessions)")
708 LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)")
709 LabeledContent("Cached Items", value: "\(viewModel.dataLifecycleSummary.cachedItems)")
710 LabeledContent("Monitoring Logs", value: "\(viewModel.dataLifecycleSummary.monitoringLogs)")
711
712 Text("Data stays on this device unless you export it. Backup files can include domain history, monitoring settings, and notes. Imported files are processed on-device.")
713 .font(appDensity.font(.caption, design: .default))
714 .foregroundStyle(Color(.appTextSecondary))
715
716 if let portabilityStatusMessage = viewModel.portabilityStatusMessage {
717 Text(portabilityStatusMessage)
718 .font(appDensity.font(.caption, design: .default))
719 .foregroundStyle(Color(.appTextSecondary))
720 }
721 }
722
723 #if DEBUG
724 if let importDebugStatus {
725 Section("Import Debug") {
726 Text(importDebugStatus)
727 .font(appDensity.font(.caption, design: .default))
728 .foregroundStyle(Color(.appTextSecondary))
729 .textSelection(.enabled)
730 }
731 }
732 #endif
733 }
734 .navigationTitle("Import & Export")
735 .alert("Replace local data?", isPresented: $showReplaceImportConfirmation) {
736 Button("Replace", role: .destructive) {
737 applyPendingImport()
738 }
739 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
740 } message: {
741 Text("Replace mode overwrites local data covered by the imported file and may remove items that are only on this device.")
742 }
743 .alert("Import Error", isPresented: Binding(
744 get: { pendingImportError != nil },
745 set: { if !$0 { pendingImportError = nil } }
746 )) {
747 Button("OK", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
748 } message: {
749 Text(pendingImportError ?? "The import could not be completed.")
750 }
751 .sheet(isPresented: Binding(
752 get: { pendingImportPreview != nil },
753 set: { if !$0 { pendingImportPreview = nil } }
754 )) {
755 if let pendingImportPreview {
756 DataImportPreviewSheet(
757 preview: pendingImportPreview,
758 mode: importMode,
759 onCancel: {
760 self.pendingImportPreview = nil
761 },
762 onApply: {
763 if importMode == .replace {
764 showReplaceImportConfirmation = true
765 } else {
766 applyPendingImport()
767 }
768 }
769 )
770 }
771 }
772 .fileImporter(
773 isPresented: Binding(
774 get: { activeImportTarget != nil },
775 set: { if !$0 { activeImportTarget = nil } }
776 ),
777 allowedContentTypes: activeImportTarget?.allowedContentTypes ?? [UTType.json],
778 allowsMultipleSelection: false
779 ) { result in
780 guard let pendingImportTarget else {
781 recordImportDebugStatus("fileImporter returned with no active target")
782 return
783 }
784 recordImportDebugStatus("fileImporter returned for \(pendingImportTarget.expectedKind.rawValue)")
785 handleImportResult(result, expectedKind: pendingImportTarget.expectedKind)
786 self.pendingImportTarget = nil
787 self.activeImportTarget = nil
788 }
789 .onAppear {
790 viewModel.refreshDataLifecycleSummary()
791 }
792 }
793
794 private func exportFullBackup() {
795 guard let data = viewModel.exportFullBackupData() else { return }
796 ExportPresenter.share(filename: portabilityFilename(suffix: "backup", fileExtension: "json"), data: data)
797 }
798
799 private func exportPortableTrackedDomainsJSON() {
800 guard let data = viewModel.exportPortableTrackedDomainsJSONData() else { return }
801 ExportPresenter.share(filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "json"), data: data)
802 }
803
804 private func exportPortableTrackedDomainsCSV() {
805 ExportPresenter.share(
806 filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "csv"),
807 contents: viewModel.exportPortableTrackedDomainsCSV()
808 )
809 }
810
811 private func exportPortableWorkflowsJSON() {
812 guard let data = viewModel.exportPortableWorkflowsJSONData() else { return }
813 ExportPresenter.share(filename: portabilityFilename(suffix: "workflows", fileExtension: "json"), data: data)
814 }
815
816 private func exportPortableWorkflowsCSV() {
817 ExportPresenter.share(
818 filename: portabilityFilename(suffix: "workflows", fileExtension: "csv"),
819 contents: viewModel.exportPortableWorkflowsCSV()
820 )
821 }
822
823 private func exportPortableHistoryJSON() {
824 guard let data = viewModel.exportPortableHistoryJSONData() else { return }
825 ExportPresenter.share(filename: portabilityFilename(suffix: "history", fileExtension: "json"), data: data)
826 }
827
828 private func handleImportResult(
829 _ result: Result<[URL], Error>,
830 expectedKind: DataPortabilityImportKind
831 ) {
832 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult expectedKind=\(expectedKind.rawValue)")
833 recordImportDebugStatus("handleImportResult started for \(expectedKind.rawValue)")
834 do {
835 let urls = try result.get()
836 guard let url = urls.first else {
837 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult noURLReturned")
838 recordImportDebugStatus("No URL returned from picker")
839 return
840 }
841 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult selectedURL=\(url.absoluteString)")
842 recordImportDebugStatus("Selected \(url.lastPathComponent)")
843 let shouldStopAccessing = url.startAccessingSecurityScopedResource()
844 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeGranted=\(shouldStopAccessing)")
845 recordImportDebugStatus("Security scope granted: \(shouldStopAccessing)")
846 defer {
847 if shouldStopAccessing {
848 url.stopAccessingSecurityScopedResource()
849 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeReleased")
850 }
851 }
852
853 let data = try Data(contentsOf: url)
854 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult dataRead bytes=\(data.count) fileName=\(url.lastPathComponent)")
855 recordImportDebugStatus("Read \(data.count) bytes from \(url.lastPathComponent)")
856 let preview = try viewModel.prepareDataImport(
857 data: data,
858 fileName: url.lastPathComponent,
859 mode: importMode
860 )
861 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult previewReady previewKind=\(preview.kind.rawValue) expectedKind=\(expectedKind.rawValue)")
862 recordImportDebugStatus("Preview ready: \(preview.kind.rawValue)")
863
864 guard preview.kind == expectedKind else {
865 let message = preview.kind == .backup
866 ? "That file is a full backup. Use Import Backup."
867 : "That file type does not match this import action."
868 DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult kindMismatch message=\(message)")
869 recordImportDebugStatus("Kind mismatch: \(message)")
870 presentImportError(message)
871 return
872 }
873
874 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult presentingPreview kind=\(preview.kind.rawValue)")
875 recordImportDebugStatus("Presenting preview for \(preview.kind.rawValue)")
876 presentImportPreview(preview)
877 } catch {
878 DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult failed error=\(error.localizedDescription)")
879 recordImportDebugStatus("Import failed: \(error.localizedDescription)")
880 presentImportError(error.localizedDescription)
881 }
882 }
883
884 private func applyPendingImport() {
885 guard let pendingImportPreview else { return }
886 do {
887 _ = try viewModel.applyDataImport(pendingImportPreview, mode: importMode)
888 self.pendingImportPreview = nil
889 } catch {
890 pendingImportError = error.localizedDescription
891 }
892 }
893
894 private func portabilityFilename(suffix: String, fileExtension: String) -> String {
895 let formatter = DateFormatter()
896 formatter.dateFormat = "yyyyMMdd_HHmmss"
897 return "\(formatter.string(from: Date()))_domaindig_\(suffix).\(fileExtension)"
898 }
899
900 private func presentImportPreview(_ preview: DataImportPreview) {
901 Task { @MainActor in
902 try? await Task.sleep(for: .milliseconds(300))
903 DomainDebugLog.debug("DataPortabilitySettingsView.presentImportPreview kind=\(preview.kind.rawValue) fileName=\(preview.fileName)")
904 recordImportDebugStatus("Preview presented for \(preview.fileName)")
905 pendingImportPreview = preview
906 }
907 }
908
909 private func presentImportError(_ message: String) {
910 Task { @MainActor in
911 try? await Task.sleep(for: .milliseconds(300))
912 DomainDebugLog.error("DataPortabilitySettingsView.presentImportError message=\(message)")
913 recordImportDebugStatus("Error presented: \(message)")
914 pendingImportError = message
915 }
916 }
917
918 private func recordImportDebugStatus(_ message: String) {
919 #if DEBUG
920 let status = "[Import Debug] \(message)"
921 importDebugStatus = status
922 print(status)
923 #endif
924 }
925}
926
927private struct DataManagementSettingsView: View {
928 @Bindable var viewModel: DomainViewModel
929 @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
930
931 @State private var showClearHistoryConfirmation = false
932 @State private var showClearCacheConfirmation = false
933 @State private var showClearWorkflowsConfirmation = false
934 @State private var showClearTrackedDomainsConfirmation = false
935 @State private var showDeleteAllConfirmation = false
936 @State private var deleteAllErrorMessage: String?
937 @State private var deleteAllSuccessMessage: String?
938 @State private var isDeletingAllData = false
939
940 var body: some View {
941 Form {
942 Section("Data") {
943 Button("Clear History", role: .destructive) {
944 showClearHistoryConfirmation = true
945 }
946
947 Button("Clear Cache", role: .destructive) {
948 showClearCacheConfirmation = true
949 }
950
951 Button("Clear Workflows", role: .destructive) {
952 showClearWorkflowsConfirmation = true
953 }
954
955 Button("Clear Tracked Domains", role: .destructive) {
956 showClearTrackedDomainsConfirmation = true
957 }
958 }
959
960 Section {
961 Button(role: .destructive) {
962 showDeleteAllConfirmation = true
963 } label: {
964 HStack {
965 Text("Delete All Data")
966 Spacer()
967 if isDeletingAllData {
968 ProgressView()
969 .controlSize(.small)
970 }
971 }
972 }
973 .disabled(isDeletingAllData)
974 } header: {
975 Text("Danger Zone")
976 } footer: {
977 Text("Permanently removes all local DomainDig data from this device.")
978 }
979 }
980 .disabled(isDeletingAllData)
981 .navigationTitle("Data Management")
982 .alert("Clear history?", isPresented: $showClearHistoryConfirmation) {
983 Button("Clear", role: .destructive) {
984 viewModel.clearHistory()
985 }
986 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
987 } message: {
988 Text("This removes saved lookup snapshots and clears monitoring run history on this device.")
989 }
990 .alert("Clear cache?", isPresented: $showClearCacheConfirmation) {
991 Button("Clear", role: .destructive) {
992 viewModel.clearLookupCache()
993 }
994 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
995 } message: {
996 Text("This clears the in-memory lookup cache and cancels any cached in-flight work.")
997 }
998 .alert("Clear workflows?", isPresented: $showClearWorkflowsConfirmation) {
999 Button("Clear", role: .destructive) {
1000 viewModel.clearWorkflows()
1001 }
1002 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
1003 } message: {
1004 Text("This removes saved workflows only. History, tracked domains, and saved reports stay intact.")
1005 }
1006 .alert("Clear tracked domains?", isPresented: $showClearTrackedDomainsConfirmation) {
1007 Button("Clear", role: .destructive) {
1008 viewModel.clearTrackedDomains()
1009 }
1010 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
1011 } message: {
1012 Text("This removes the watchlist and clears monitoring run history. History and workflows stay intact.")
1013 }
1014 .alert("Delete All Data?", isPresented: $showDeleteAllConfirmation) {
1015 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
1016 Button("Delete All Data", role: .destructive) {
1017 deleteAllData()
1018 }
1019 } message: {
1020 Text("This will permanently remove all saved DomainDig data from this device. This includes tracked domains, monitoring history, snapshots, exports, cached reports, and local settings. This action cannot be undone.")
1021 }
1022 .alert("Delete Failed", isPresented: Binding(
1023 get: { deleteAllErrorMessage != nil },
1024 set: { if !$0 { deleteAllErrorMessage = nil } }
1025 )) {
1026 Button("OK", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
1027 } message: {
1028 Text(deleteAllErrorMessage ?? "The local data reset could not be completed.")
1029 }
1030 .safeAreaInset(edge: .bottom) {
1031 if let deleteAllSuccessMessage {
1032 Text(deleteAllSuccessMessage)
1033 .font(.footnote.weight(.medium))
1034 .foregroundStyle(Color(.appTextSecondary))
1035 .padding(.horizontal, 14)
1036 .padding(.vertical, 10)
1037 // Reduce Transparency swaps the blur for an opaque surface.
1038 // On iOS 26+ the system also composites its own translucency
1039 // that the app cannot declare — verify there too (Phase 6).
1040 .background(
1041 Capsule().fill(reduceTransparency ? AnyShapeStyle(Color(.appSurfaceElevated)) : AnyShapeStyle(.thinMaterial))
1042 )
1043 .padding(.bottom, 8)
1044 .transition(.move(edge: .bottom).combined(with: .opacity))
1045 }
1046 }
1047 }
1048
1049 private func deleteAllData() {
1050 guard !isDeletingAllData else { return }
1051
1052 isDeletingAllData = true
1053 deleteAllErrorMessage = nil
1054 deleteAllSuccessMessage = nil
1055
1056 Task {
1057 do {
1058 try await DataResetService.wipeAllLocalData(viewModel: viewModel)
1059 deleteAllSuccessMessage = "All local data removed."
1060 try? await Task.sleep(for: .seconds(2))
1061 if deleteAllSuccessMessage == "All local data removed." {
1062 deleteAllSuccessMessage = nil
1063 }
1064 } catch {
1065 deleteAllErrorMessage = error.localizedDescription
1066 }
1067
1068 isDeletingAllData = false
1069 }
1070 }
1071}
1072
1073private struct AboutSettingsView: View {
1074 @State private var cloudSyncService = CloudSyncService.shared
1075
1076 private var appVersion: String {
1077 AppVersion.current
1078 }
1079
1080 var body: some View {
1081 Form {
1082 Section("About") {
1083 LabeledContent("Version", value: appVersion)
1084 LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only")
1085 LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)")
1086 }
1087 }
1088 .navigationTitle("App Info")
1089 .task {
1090 await cloudSyncService.refreshAvailability()
1091 }
1092 }
1093}
1094
1095private struct DataImportPreviewSheet: View {
1096 @Environment(\.dismiss) private var dismiss
1097
1098 let preview: DataImportPreview
1099 let mode: DataPortabilityImportMode
1100 let onCancel: () -> Void
1101 let onApply: () -> Void
1102
1103 var body: some View {
1104 NavigationStack {
1105 List {
1106 Section("Summary") {
1107 ForEach(preview.summaryLines, id: \.self) { line in
1108 Text(line)
1109 }
1110 }
1111
1112 Section("Projected Counts") {
1113 LabeledContent("Tracked Domains", value: "\(preview.projectedCounts.trackedDomains)")
1114 LabeledContent("History Snapshots", value: "\(preview.projectedCounts.historySnapshots)")
1115 LabeledContent("Audit Sessions", value: "\(preview.projectedCounts.auditSessions)")
1116 LabeledContent("Workflows", value: "\(preview.projectedCounts.workflows)")
1117 LabeledContent("Cached Items", value: "\(preview.projectedCounts.cachedItems)")
1118 LabeledContent("Monitoring Logs", value: "\(preview.projectedCounts.monitoringLogs)")
1119 }
1120
1121 if !preview.warnings.isEmpty {
1122 Section("Warnings") {
1123 ForEach(preview.warnings, id: \.self) { warning in
1124 Text(warning)
1125 .foregroundStyle(Color(.appTextSecondary))
1126 }
1127 }
1128 }
1129 }
1130 .navigationTitle("Import Preview")
1131 .toolbar {
1132 ToolbarItem(placement: .cancellationAction) {
1133 Button("Cancel") {
1134 onCancel()
1135 dismiss()
1136 }
1137 }
1138 ToolbarItem(placement: .confirmationAction) {
1139 Button(mode == .replace ? "Replace" : "Import") {
1140 onApply()
1141 if mode == .merge {
1142 dismiss()
1143 }
1144 }
1145 }
1146 }
1147 }
1148 }
1149}