krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v5.0.0: DomainDig/SettingsViews.swift · raw

   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}