krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.9.0: DomainDig/ContentView.swift · raw

   1import MapKit
   2import SwiftUI
   3import UniformTypeIdentifiers
   4
   5enum LookupInputMode: String, CaseIterable, Identifiable {
   6    case single
   7    case bulk
   8
   9    var id: String { rawValue }
  10}
  11
  12enum ResultSection: String, Hashable {
  13    case domain
  14    case intelligence
  15    case ownership
  16    case dns
  17    case web
  18    case email
  19    case network
  20    case subdomains
  21}
  22
  23private enum LookupInputField: Hashable {
  24    case singleDomain
  25    case bulkDomains
  26}
  27
  28private struct WorkflowNavigationTarget: Hashable {
  29    let workflowID: UUID
  30}
  31
  32struct ContentView: View {
  33    @Environment(\.appDensity) private var appDensity
  34    @Bindable var viewModel: DomainViewModel
  35    @State private var purchaseService = PurchaseService.shared
  36    @State private var navigationPath = NavigationPath()
  37    @FocusState private var focusedInputField: LookupInputField?
  38    @State private var customPortInput = ""
  39    @State private var customPortsExpanded = false
  40    @State private var trackingNoteDraft = ""
  41    @State private var editingTrackedDomain: TrackedDomain?
  42    @State private var inputMode: LookupInputMode = .single
  43    @State private var collapsedSections: Set<ResultSection> = [.network]
  44    @State private var showingCurrentDomainWorkflowSheet = false
  45    @State private var showingBatchWorkflowSheet = false
  46    @State private var showingTimeline = false
  47    @State private var showingAuditTimeline = false
  48    @State private var auditStartInFlight = false
  49
  50    var body: some View {
  51        let _ = purchaseService.currentTier
  52
  53        NavigationStack(path: $navigationPath) {
  54            ScrollView(.vertical) {
  55                VStack(spacing: 0) {
  56                    inputSection
  57                    if !viewModel.batchResults.isEmpty || viewModel.batchLookupRunning {
  58                        batchSection
  59                            .padding(.top, appDensity.metrics.cardSpacing)
  60                    }
  61                    if viewModel.hasRun {
  62                        actionButtons
  63                        if !viewModel.resultsLoaded {
  64                            LookupProgressOverviewView(steps: viewModel.activeLoadingLabels)
  65                                .padding(.top, appDensity.metrics.cardSpacing)
  66                        } else if let statusMessage = resultStatusMessage {
  67                            LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource)
  68                                .padding(.top, appDensity.metrics.cardSpacing)
  69                        }
  70                        if viewModel.resultsLoaded {
  71                            SummaryView(fields: viewModel.summaryFields)
  72                                .padding(.top, appDensity.metrics.cardSpacing)
  73                            if let report = viewModel.currentReport {
  74                                RiskSummaryCardView(report: report)
  75                                    .padding(.top, appDensity.metrics.cardSpacing)
  76                                InsightsSummaryCardView(insights: report.insights)
  77                                    .padding(.top, appDensity.metrics.cardSpacing)
  78                            }
  79                            if let changeSummary = viewModel.currentChangeSummary {
  80                                DomainChangeSummaryView(summary: changeSummary)
  81                                    .padding(.top, appDensity.metrics.cardSpacing)
  82                            }
  83                        }
  84                        if let report = viewModel.currentReport {
  85                            intelligenceSection(report: report)
  86                                .padding(.top, appDensity.metrics.sectionSpacing)
  87                        }
  88                        domainOverviewSection
  89                            .padding(.top, appDensity.metrics.sectionSpacing)
  90                        ownershipSection
  91                        .padding(.top, appDensity.metrics.sectionSpacing)
  92                        subdomainsSection
  93                        .padding(.top, appDensity.metrics.sectionSpacing)
  94                        if !viewModel.currentDiffSections.isEmpty {
  95                            DomainDiffView(
  96                                title: "Latest Changes",
  97                                sections: viewModel.currentDiffSections,
  98                                contextNote: viewModel.currentChangeSummary?.contextNote,
  99                                showsUnchanged: false,
 100                                highlightedSectionID: nil
 101                            )
 102                            .padding(.top, appDensity.metrics.sectionSpacing)
 103                        }
 104                        dnsSection
 105                        .padding(.top, appDensity.metrics.sectionSpacing)
 106                        WebSectionView(
 107                            isCollapsed: sectionCollapsedBinding(.web),
 108                            certificateRows: viewModel.webCertificateRows,
 109                            sslInfo: viewModel.sslInfo,
 110                            tlsSummary: viewModel.currentTLSSummary,
 111                            sslLoading: viewModel.sslLoading || viewModel.hstsLoading,
 112                            sslError: viewModel.sslError,
 113                            tlsProvenance: viewModel.currentSnapshot.provenanceBySection[.ssl],
 114                            responseRows: viewModel.webResponseRows,
 115                            headers: viewModel.httpHeaders,
 116                            headersLoading: viewModel.httpHeadersLoading,
 117                            headersError: viewModel.httpHeadersError,
 118                            httpProvenance: viewModel.currentSnapshot.provenanceBySection[.httpHeaders],
 119                            redirects: viewModel.redirectRows,
 120                            redirectLoading: viewModel.redirectChainLoading,
 121                            redirectError: viewModel.redirectChainError,
 122                            redirectProvenance: viewModel.currentSnapshot.provenanceBySection[.redirectChain],
 123                            finalURL: viewModel.currentSnapshot.redirectChain.last?.url
 124                        )
 125                        .padding(.top, appDensity.metrics.sectionSpacing)
 126                        EmailSectionView(
 127                            isCollapsed: sectionCollapsedBinding(.email),
 128                            rows: viewModel.emailRows,
 129                            assessment: viewModel.currentEmailAssessment,
 130                            loading: viewModel.emailSecurityLoading,
 131                            provenance: viewModel.currentSnapshot.provenanceBySection[.emailSecurity],
 132                            confidence: viewModel.currentSnapshot.emailSecurityConfidence,
 133                            error: viewModel.emailSecurityError
 134                        )
 135                        .padding(.top, appDensity.metrics.sectionSpacing)
 136                        NetworkSectionView(
 137                            isCollapsed: sectionCollapsedBinding(.network),
 138                            reachabilityRows: viewModel.reachabilityRows,
 139                            reachabilityLoading: viewModel.reachabilityLoading,
 140                            reachabilityError: viewModel.reachabilityError,
 141                            reachabilityProvenance: viewModel.currentSnapshot.provenanceBySection[.reachability],
 142                            locationRows: viewModel.locationRows,
 143                            geolocation: viewModel.ipGeolocation,
 144                            geolocationLoading: viewModel.ipGeolocationLoading,
 145                            geolocationError: viewModel.ipGeolocationError,
 146                            geolocationProvenance: viewModel.currentSnapshot.provenanceBySection[.ipGeolocation],
 147                            geolocationConfidence: viewModel.currentSnapshot.geolocationConfidence,
 148                            standardPortRows: viewModel.standardPortRows,
 149                            customPortRows: viewModel.customPortRows,
 150                            portScanLoading: viewModel.portScanLoading,
 151                            portScanError: viewModel.portScanError,
 152                            portScanProvenance: viewModel.currentSnapshot.provenanceBySection[.portScan],
 153                            customPortScanLoading: viewModel.customPortScanLoading,
 154                            customPortScanError: viewModel.customPortScanError,
 155                            isCloudflareProxied: viewModel.isCloudflareProxied,
 156                            customPortsExpanded: $customPortsExpanded,
 157                            customPortInput: $customPortInput,
 158                            onScanCustomPorts: runCustomPortScan
 159                        )
 160                        .padding(.top, appDensity.metrics.sectionSpacing)
 161                    } else if !viewModel.recentSearches.isEmpty {
 162                        recentSearchesSection
 163                    }
 164                }
 165                .padding(.horizontal)
 166                .padding(.bottom, 32)
 167            }
 168            .background(
 169                LinearGradient(
 170                    colors: [Color(.appBackground), Color(.appSurface)],
 171                    startPoint: .top,
 172                    endPoint: .bottom
 173                )
 174            )
 175            .navigationTitle("DomainDig")
 176            .toolbar {
 177                ToolbarItem(placement: .topBarTrailing) {
 178                    if viewModel.hasRun {
 179                        Button {
 180                            viewModel.reset()
 181                        } label: {
 182                            Image(systemName: "xmark.circle")
 183                                .foregroundStyle(Color(.appTextSecondary))
 184                        }
 185                        .accessibilityLabel("Clear results")
 186                    }
 187                }
 188            }
 189            .navigationDestination(for: WorkflowNavigationTarget.self) { target in
 190                WorkflowDetailView(viewModel: viewModel, workflowID: target.workflowID)
 191            }
 192        }
 193        .task {
 194            await viewModel.refreshUsageCredits()
 195        }
 196        .onChange(of: viewModel.searchedDomain) { _, _ in
 197            collapsedSections = defaultCollapsedSections
 198        }
 199        .onChange(of: viewModel.resultsLoaded) { wasLoaded, isLoaded in
 200            // Single-lookup completion has no single view-model moment
 201            // (`resultsLoaded` is derived from many loading flags), so the
 202            // announcement is posted from the view where the transition is
 203            // observable. The batch path announces from the view model directly.
 204            guard !wasLoaded, isLoaded, viewModel.hasRun else { return }
 205            let summary = AppStatusFactory.availability(viewModel.availabilityResult?.status).title
 206            AppAccessibility.announce("Lookup complete for \(viewModel.searchedDomain). \(summary).")
 207        }
 208        .onChange(of: viewModel.rerunNavigationToken) { _, _ in
 209            navigationPath = NavigationPath()
 210            focusedInputField = nil
 211        }
 212        .onChange(of: inputMode) { _, newValue in
 213            viewModel.clearPresentedResults()
 214            focusedInputField = newValue == .single ? .singleDomain : .bulkDomains
 215        }
 216        .onChange(of: viewModel.domain) { _, newValue in
 217            guard inputMode == .single else { return }
 218            let normalized = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
 219            guard normalized != viewModel.searchedDomain else { return }
 220            guard viewModel.hasRun || !viewModel.batchResults.isEmpty else { return }
 221            viewModel.clearPresentedResults()
 222        }
 223        .onChange(of: viewModel.bulkInput) { _, newValue in
 224            guard inputMode == .bulk else { return }
 225            let normalized = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
 226            guard !normalized.isEmpty || viewModel.hasRun || !viewModel.batchResults.isEmpty else { return }
 227            viewModel.clearPresentedResults()
 228        }
 229        .sheet(item: $editingTrackedDomain) { trackedDomain in
 230            NavigationStack {
 231                Form {
 232                    Section("Tracking Note") {
 233                        TextField("Optional note", text: $trackingNoteDraft, axis: .vertical)
 234                            .lineLimit(3...6)
 235                            .textInputAutocapitalization(.never)
 236                            .autocorrectionDisabled()
 237                    }
 238                }
 239                .navigationTitle(trackedDomain.domain)
 240                .toolbar {
 241                    ToolbarItem(placement: .cancellationAction) {
 242                        Button("Cancel") {
 243                            editingTrackedDomain = nil
 244                        }
 245                    }
 246                    ToolbarItem(placement: .confirmationAction) {
 247                        Button("Save") {
 248                            viewModel.updateNote(trackingNoteDraft, for: trackedDomain)
 249                            editingTrackedDomain = nil
 250                        }
 251                    }
 252                }
 253            }
 254        }
 255        .sheet(isPresented: $showingCurrentDomainWorkflowSheet) {
 256            WorkflowBulkAddSheet(
 257                viewModel: viewModel,
 258                title: "Add Domain to Workflow",
 259                availableDomains: [viewModel.searchedDomain]
 260            )
 261        }
 262        .sheet(isPresented: $showingBatchWorkflowSheet) {
 263            WorkflowBulkAddSheet(
 264                viewModel: viewModel,
 265                title: "Add Batch Domains",
 266                availableDomains: viewModel.batchResults.map(\.domain)
 267            )
 268        }
 269        .sheet(item: manualBatchSummaryBinding) { summary in
 270            BatchSweepSummaryView(viewModel: viewModel, summary: summary)
 271        }
 272        .sheet(isPresented: $showingTimeline) {
 273            NavigationStack {
 274                TimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
 275            }
 276        }
 277        .sheet(isPresented: $showingAuditTimeline) {
 278            NavigationStack {
 279                AuditDomainTimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
 280            }
 281        }
 282    }
 283
 284    private var manualBatchSummaryBinding: Binding<BatchSweepSummary?> {
 285        Binding(
 286            get: {
 287                guard let summary = viewModel.latestBatchSweepSummary,
 288                      summary.source == .manual else {
 289                    return nil
 290                }
 291                return summary
 292            },
 293            set: { viewModel.latestBatchSweepSummary = $0 }
 294        )
 295    }
 296
 297    private var inputSection: some View {
 298        VStack(spacing: appDensity.metrics.cardSpacing + 2) {
 299            Picker("Mode", selection: $inputMode) {
 300                Text("Single").tag(LookupInputMode.single)
 301                Text("Bulk").tag(LookupInputMode.bulk)
 302            }
 303            .pickerStyle(.segmented)
 304
 305            if inputMode == .single {
 306                TextField("e.g. cleberg.net", text: $viewModel.domain)
 307                    .font(appDensity.font(.title3, design: .monospaced))
 308                    .textInputAutocapitalization(.never)
 309                    .autocorrectionDisabled()
 310                    .keyboardType(.URL)
 311                    .padding(.horizontal, 12)
 312                    .padding(.vertical, appDensity.metrics.controlVerticalPadding)
 313                    .background(Color(.appSurfaceElevated))
 314                    .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
 315                    .focused($focusedInputField, equals: .singleDomain)
 316                    .onSubmit {
 317                        focusedInputField = nil
 318                        viewModel.run()
 319                    }
 320
 321                Button {
 322                    focusedInputField = nil
 323                    viewModel.run()
 324                } label: {
 325                    Text("Run")
 326                        .font(appDensity.font(.headline, design: .default, weight: .semibold))
 327                        .frame(maxWidth: .infinity)
 328                        .frame(minHeight: appDensity.metrics.controlMinHeight)
 329                }
 330                .buttonStyle(.borderedProminent)
 331                .tint(Color(.accentFill))
 332                .disabled(viewModel.trimmedDomain.isEmpty)
 333            } else {
 334                if FeatureAccessService.hasAccess(to: .batchOperations) {
 335                    VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 336                        Text("Paste domains separated by new lines or commas.")
 337                            .font(appDensity.font(.caption))
 338                            .foregroundStyle(Color(.appTextSecondary))
 339
 340                        if let batchAllowanceSummary = FeatureAccessService.batchAllowanceSummary() {
 341                            Text(batchAllowanceSummary)
 342                                .font(appDensity.font(.caption2))
 343                                .foregroundStyle(Color(.appTextSecondary))
 344                        }
 345
 346                        TextField(
 347                            "example.com\napple.com, openai.com",
 348                            text: $viewModel.bulkInput,
 349                            axis: .vertical
 350                        )
 351                        .font(appDensity.font(.body))
 352                        .textInputAutocapitalization(.never)
 353                        .autocorrectionDisabled()
 354                        .keyboardType(.URL)
 355                        .lineLimit(4...10)
 356                        .padding(.horizontal, 12)
 357                        .padding(.vertical, appDensity.metrics.controlVerticalPadding)
 358                        .background(Color(.appSurfaceElevated))
 359                        .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
 360                        .focused($focusedInputField, equals: .bulkDomains)
 361
 362                        Button {
 363                            focusedInputField = nil
 364                            viewModel.runBulkLookup()
 365                        } label: {
 366                            Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch")
 367                                .font(appDensity.font(.headline, design: .default, weight: .semibold))
 368                                .frame(maxWidth: .infinity)
 369                                .frame(minHeight: appDensity.metrics.controlMinHeight)
 370                        }
 371                        .buttonStyle(.borderedProminent)
 372                        .tint(Color(.accentFill))
 373                        .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning)
 374                    }
 375                } else {
 376                    lockedFeatureCard(
 377                        title: "Batch Operations",
 378                        message: FeatureAccessService.upgradeMessage(for: .batchOperations)
 379                    )
 380                }
 381            }
 382        }
 383        .padding(.vertical, appDensity.metrics.sectionSpacing)
 384    }
 385
 386    private var domainOverviewSection: some View {
 387        let trackedDomain = viewModel.currentTrackedDomain
 388        let workflows = viewModel.currentDomainWorkflows
 389
 390        return DomainSectionView(
 391            isCollapsed: sectionCollapsedBinding(.domain),
 392            rows: viewModel.domainRows,
 393            suggestions: viewModel.suggestionRows,
 394            showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading,
 395            availabilityLoading: viewModel.availabilityLoading,
 396            suggestionsLoading: viewModel.suggestionsLoading,
 397            provenance: viewModel.currentSnapshot.provenanceBySection[.availability],
 398            confidence: viewModel.currentSnapshot.availabilityConfidence,
 399            snapshotNote: viewModel.currentSnapshot.note,
 400            trackedDomain: trackedDomain,
 401            workflows: workflows,
 402            trackingLimitMessage: viewModel.trackingLimitMessage,
 403            pricingLoading: viewModel.domainPricingLoading,
 404            pricingError: viewModel.domainPricingError,
 405            showsPricingPlaceholder: !DataAccessService.hasAccess(to: .domainPricing),
 406            onTrack: {
 407                _ = viewModel.trackCurrentDomain()
 408            },
 409            onTogglePinned: {
 410                guard let trackedDomain else { return }
 411                viewModel.togglePinned(for: trackedDomain)
 412            },
 413            onEditNote: {
 414                guard let trackedDomain else { return }
 415                trackingNoteDraft = trackedDomain.note ?? ""
 416                editingTrackedDomain = trackedDomain
 417            },
 418            onAddToWorkflow: {
 419                showingCurrentDomainWorkflowSheet = true
 420            },
 421            onOpenWorkflow: { workflow in
 422                navigationPath.append(WorkflowNavigationTarget(workflowID: workflow.id))
 423            },
 424            onRunWorkflow: { workflow in
 425                viewModel.rerunCurrentDomain(in: workflow)
 426            }
 427        )
 428    }
 429
 430    private func intelligenceSection(report: DomainReport) -> some View {
 431        IntelligenceSectionView(
 432            isCollapsed: sectionCollapsedBinding(.intelligence),
 433            report: report,
 434            showsPlaceholder: FeatureAccessService.currentTier != .proPlus
 435        )
 436    }
 437
 438    private var ownershipSection: some View {
 439        OwnershipSectionView(
 440            isCollapsed: sectionCollapsedBinding(.ownership),
 441            rows: viewModel.ownershipRows,
 442            loading: viewModel.ownershipLoading,
 443            error: viewModel.ownershipError,
 444            provenance: viewModel.currentSnapshot.provenanceBySection[.ownership],
 445            confidence: viewModel.currentSnapshot.ownershipConfidence,
 446            showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory),
 447            history: viewModel.ownershipHistory,
 448            historyLoading: viewModel.ownershipHistoryLoading,
 449            historyError: viewModel.ownershipHistoryError,
 450            historyCreditStatus: viewModel.ownershipHistoryCreditStatus,
 451            onLoadHistory: {
 452                Task {
 453                    await viewModel.loadOwnershipHistory()
 454                }
 455            }
 456        )
 457    }
 458
 459    private var subdomainsSection: some View {
 460        SubdomainsSectionView(
 461            isCollapsed: sectionCollapsedBinding(.subdomains),
 462            rows: viewModel.subdomainRows,
 463            groups: viewModel.currentSubdomainGroups,
 464            loading: viewModel.subdomainsLoading,
 465            error: viewModel.subdomainsError,
 466            provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains],
 467            confidence: viewModel.currentSnapshot.subdomainConfidence,
 468            showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains),
 469            extendedCount: viewModel.extendedSubdomains.count,
 470            extendedLoading: viewModel.extendedSubdomainsLoading,
 471            extendedError: viewModel.extendedSubdomainsError,
 472            extendedCreditStatus: viewModel.extendedSubdomainsCreditStatus,
 473            onLoadExtended: {
 474                Task {
 475                    await viewModel.loadExtendedSubdomains()
 476                }
 477            }
 478        )
 479    }
 480
 481    private var dnsSection: some View {
 482        DNSSectionView(
 483            isCollapsed: sectionCollapsedBinding(.dns),
 484            dnssecLabel: viewModel.dnssecLabel,
 485            patternSummary: viewModel.currentDNSPatterns,
 486            sections: viewModel.dnsRows,
 487            ptrMessage: viewModel.ptrMessage,
 488            loading: viewModel.dnsLoading || viewModel.ptrLoading,
 489            dnsProvenance: viewModel.currentSnapshot.provenanceBySection[.dns],
 490            ptrProvenance: viewModel.currentSnapshot.provenanceBySection[.ptr],
 491            sectionError: viewModel.dnsError,
 492            history: viewModel.dnsHistory,
 493            historyLoading: viewModel.dnsHistoryLoading,
 494            historyError: viewModel.dnsHistoryError,
 495            showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .dnsHistory),
 496            historyCreditStatus: viewModel.dnsHistoryCreditStatus,
 497            onLoadHistory: {
 498                Task {
 499                    await viewModel.loadDNSHistory()
 500                }
 501            }
 502        )
 503    }
 504
 505    private var actionButtons: some View {
 506        HStack {
 507            Spacer()
 508            if viewModel.resultsLoaded {
 509                Menu {
 510                    if !viewModel.isCurrentDomainTracked {
 511                        Button("Track this domain") {
 512                            _ = viewModel.trackCurrentDomain()
 513                        }
 514                    }
 515                    Button("Add to workflow") {
 516                        showingCurrentDomainWorkflowSheet = true
 517                    }
 518                    Button(auditStartInFlight ? "Starting audit…" : "Start audit") {
 519                        Task {
 520                            auditStartInFlight = true
 521                            if await viewModel.startAudit(for: viewModel.searchedDomain) != nil {
 522                                showingAuditTimeline = true
 523                            }
 524                            auditStartInFlight = false
 525                        }
 526                    }
 527                    .disabled(auditStartInFlight)
 528                    if !viewModel.audits(for: viewModel.searchedDomain).isEmpty {
 529                        Button("View audits") {
 530                            showingAuditTimeline = true
 531                        }
 532                    }
 533                    if !viewModel.historyEntries(for: viewModel.searchedDomain).isEmpty {
 534                        Button("Open timeline") {
 535                            showingTimeline = true
 536                        }
 537                    }
 538                    if FeatureAccessService.hasAccess(to: .advancedExports) {
 539                        Button("Copy report JSON") {
 540                            guard let json = viewModel.exportJSONString() else { return }
 541                            AppClipboard.copy(json)
 542                            AppHaptics.copy()
 543                        }
 544                    } else {
 545                        Button("Copy report JSON") {
 546                            viewModel.upgradePrompt = FeatureAccessService.upgradePrompt(for: .advancedExports)
 547                        }
 548                    }
 549                    Button("Export report") {
 550                        shareSingleResults(format: .text)
 551                    }
 552                } label: {
 553                    Image(systemName: "bolt.circle")
 554                        .font(appDensity.font(.body, design: .default))
 555                        .foregroundStyle(Color(.appTextSecondary))
 556                }
 557                .accessibilityLabel("Actions")
 558                Button {
 559                    viewModel.toggleSavedDomain()
 560                } label: {
 561                    Image(systemName: viewModel.isCurrentDomainSaved ? "bookmark.fill" : "bookmark")
 562                        .font(appDensity.font(.body, design: .default))
 563                        .foregroundStyle(viewModel.isCurrentDomainSaved ? Color(.statusWarning) : .secondary)
 564                }
 565                .accessibilityLabel("Save domain")
 566                .accessibilityValue(viewModel.isCurrentDomainSaved ? "Saved" : "Not saved")
 567                .accessibilityAddTraits(viewModel.isCurrentDomainSaved ? .isSelected : [])
 568                Menu {
 569                    Button("Export TXT") {
 570                        shareSingleResults(format: .text)
 571                    }
 572                    if FeatureAccessService.hasAccess(to: .advancedExports) {
 573                        Button("Export CSV") {
 574                            shareSingleResults(format: .csv)
 575                        }
 576                        Button("Export JSON") {
 577                            shareSingleResults(format: .json)
 578                        }
 579                        Button("Export Markdown") {
 580                            shareSingleResults(format: .markdown)
 581                        }
 582                        Button("Export PDF") {
 583                            shareSingleResults(format: .pdf)
 584                        }
 585                    } else {
 586                        Button("CSV Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
 587                            .disabled(true)
 588                        Button("JSON Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
 589                            .disabled(true)
 590                        Button("Markdown Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
 591                            .disabled(true)
 592                        Button("PDF Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
 593                            .disabled(true)
 594                    }
 595                } label: {
 596                    Image(systemName: "square.and.arrow.up")
 597                        .font(appDensity.font(.body, design: .default))
 598                        .foregroundStyle(Color(.appTextSecondary))
 599                }
 600                .accessibilityLabel("Export")
 601            }
 602        }
 603    }
 604
 605    private var batchSection: some View {
 606        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 607            HStack {
 608                Spacer()
 609                if viewModel.batchLookupRunning {
 610                    Button("Cancel") {
 611                        viewModel.cancelBatchLookup()
 612                    }
 613                    .buttonStyle(.bordered)
 614                    .font(appDensity.font(.caption))
 615                }
 616                if !viewModel.currentBatchResultEntries.isEmpty {
 617                    Menu {
 618                        Button("Add to Workflow") {
 619                            showingBatchWorkflowSheet = true
 620                        }
 621                        Divider()
 622                        Button("Export Batch TXT") {
 623                            shareBatchResults(format: .text)
 624                        }
 625                        if FeatureAccessService.hasAccess(to: .advancedExports) {
 626                            Button("Export Batch CSV") {
 627                                shareBatchResults(format: .csv)
 628                            }
 629                            Button("Export Batch JSON") {
 630                                shareBatchResults(format: .json)
 631                            }
 632                            Button("Export Batch Markdown") {
 633                                shareBatchResults(format: .markdown)
 634                            }
 635                            Button("Export Batch PDF") {
 636                                shareBatchResults(format: .pdf)
 637                            }
 638                        } else {
 639                            Button("Batch CSV • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
 640                                .disabled(true)
 641                            Button("Batch JSON • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
 642                                .disabled(true)
 643                            Button("Batch Markdown • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
 644                                .disabled(true)
 645                            Button("Batch PDF • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
 646                                .disabled(true)
 647                        }
 648                    } label: {
 649                        Label("Export", systemImage: "square.and.arrow.up")
 650                            .font(appDensity.font(.caption))
 651                    }
 652                    .buttonStyle(.bordered)
 653                }
 654            }
 655
 656            BatchResultsView(
 657                viewModel: viewModel,
 658                title: viewModel.batchLookupSource == .watchlistRefresh ? "Tracked Domain Refresh" : "Batch Results"
 659            )
 660        }
 661    }
 662
 663    private var recentSearchesSection: some View {
 664        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 665            HStack {
 666                Text("RECENT")
 667                    .font(appDensity.font(.caption2))
 668                    .foregroundStyle(Color(.appTextSecondary))
 669                Spacer()
 670                Button("Clear") {
 671                    viewModel.clearRecentSearches()
 672                }
 673                .font(appDensity.font(.caption2))
 674                .foregroundStyle(Color(.appTextSecondary))
 675            }
 676
 677            ForEach(viewModel.recentSearches, id: \.self) { domain in
 678                Button {
 679                    viewModel.domain = domain
 680                    focusedInputField = nil
 681                    viewModel.run()
 682                } label: {
 683                    Text(domain)
 684                        .font(appDensity.font(.callout))
 685                        .foregroundStyle(.primary)
 686                        .frame(maxWidth: .infinity, alignment: .leading)
 687                        .padding(.vertical, 8)
 688                        .padding(.horizontal, 10)
 689                        .background(Color(.appSurface))
 690                        .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
 691                }
 692            }
 693        }
 694        .padding(.top, appDensity.metrics.cardSpacing)
 695    }
 696
 697    private func runCustomPortScan() {
 698        let ports = parsedCustomPorts(from: customPortInput)
 699        Task {
 700            await viewModel.runCustomPortScan(ports: ports)
 701        }
 702    }
 703
 704    private func lockedFeatureCard(title: String, message: String) -> some View {
 705        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 706            Text(title)
 707                .font(appDensity.font(.headline, design: .default, weight: .semibold))
 708            Text(message)
 709                .font(appDensity.font(.callout, design: .default))
 710                .foregroundStyle(Color(.appTextSecondary))
 711        }
 712        .frame(maxWidth: .infinity, alignment: .leading)
 713        .padding(appDensity.metrics.cardPadding)
 714        .background(Color(.appSurfaceElevated))
 715        .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
 716    }
 717
 718    private func parsedCustomPorts(from input: String) -> [UInt16] {
 719        let parts = input.split(separator: ",", omittingEmptySubsequences: true)
 720        var seen = Set<UInt16>()
 721        var ports: [UInt16] = []
 722
 723        for part in parts {
 724            let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
 725            guard let value = UInt16(trimmed), seen.insert(value).inserted else {
 726                continue
 727            }
 728            ports.append(value)
 729            if ports.count == 20 {
 730                break
 731            }
 732        }
 733
 734        return ports
 735    }
 736
 737    private func shareSingleResults(format: DomainExportFormat) {
 738        guard let data = viewModel.exportSingleReportData(format: format) else { return }
 739        ExportPresenter.share(filename: exportFilename(prefix: "domaindig_single", format: format), data: data)
 740    }
 741
 742    private func shareBatchResults(format: DomainExportFormat) {
 743        guard let data = viewModel.exportBatchReportData(format: format) else { return }
 744        ExportPresenter.share(filename: exportFilename(prefix: "domaindig_batch", format: format), data: data)
 745    }
 746
 747    private func exportFilename(prefix: String, format: DomainExportFormat) -> String {
 748        let formatter = DateFormatter()
 749        formatter.dateFormat = "yyyyMMdd_HHmmss"
 750        let timestamp = formatter.string(from: Date())
 751        return "\(timestamp)_\(prefix).\(format.fileExtension)"
 752    }
 753
 754    private var defaultCollapsedSections: Set<ResultSection> {
 755        []
 756    }
 757
 758    private var currentPrimaryIP: String? {
 759        viewModel.currentSnapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
 760    }
 761
 762    private var resultStatusMessage: String? {
 763        if let currentStatusMessage = viewModel.currentStatusMessage {
 764            return currentStatusMessage
 765        }
 766
 767        if viewModel.currentResultSource != .live {
 768            return viewModel.currentResultSource.label
 769        }
 770
 771        return nil
 772    }
 773
 774    private func sectionCollapsedBinding(_ section: ResultSection) -> Binding<Bool> {
 775        Binding(
 776            get: { collapsedSections.contains(section) },
 777            set: { isCollapsed in
 778                if isCollapsed {
 779                    collapsedSections.insert(section)
 780                } else {
 781                    collapsedSections.remove(section)
 782                }
 783            }
 784        )
 785    }
 786}
 787
 788struct SummaryView: View {
 789    @Environment(\.appDensity) private var appDensity
 790    let fields: [SummaryFieldViewData]
 791
 792    var body: some View {
 793        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 794            SectionTitleView(title: "Summary")
 795            LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: appDensity.metrics.cardSpacing) {
 796                ForEach(fields) { field in
 797                    VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing) {
 798                        Text(field.label)
 799                            .font(appDensity.font(.caption2))
 800                            .foregroundStyle(Color(.appTextSecondary))
 801                        Text(field.value)
 802                            .font(appDensity.font(.caption))
 803                            .foregroundStyle(ResultColors.color(for: field.tone))
 804                            .lineLimit(2)
 805                            .textSelection(.enabled)
 806                    }
 807                    .frame(minHeight: appDensity.metrics.rowMinHeight + 12, alignment: .topLeading)
 808                    .frame(maxWidth: .infinity, alignment: .leading)
 809                    .padding(appDensity.metrics.cardPadding)
 810                    .background(Color(.appSurface))
 811                    .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
 812                }
 813            }
 814        }
 815    }
 816}
 817
 818struct RiskSummaryCardView: View {
 819    @Environment(\.appDensity) private var appDensity
 820    let report: DomainReport
 821
 822    private var topFactors: [RiskFactor] {
 823        Array(report.riskAssessment.factors.prefix(3))
 824    }
 825
 826    var body: some View {
 827        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 828            SectionTitleView(title: "Risk")
 829            CardView(allowsHorizontalScroll: false) {
 830                HStack(alignment: .firstTextBaseline) {
 831                    VStack(alignment: .leading, spacing: 4) {
 832                        Text("\(report.riskAssessment.score)")
 833                            .font(appDensity.font(.largeTitle, weight: .bold))
 834                            .foregroundStyle(levelColor)
 835                        Text(report.riskAssessment.level.title)
 836                            .font(appDensity.font(.caption))
 837                            .foregroundStyle(levelColor)
 838                    }
 839                    Spacer()
 840                    Text("Deterministic")
 841                        .font(appDensity.font(.caption2))
 842                        .foregroundStyle(Color(.appTextSecondary))
 843                }
 844
 845                if topFactors.isEmpty {
 846                    Text("No major risk factors identified")
 847                        .font(appDensity.font(.caption))
 848                        .foregroundStyle(Color(.appTextSecondary))
 849                } else {
 850                    ForEach(Array(topFactors.enumerated()), id: \.offset) { _, factor in
 851                        HStack(alignment: .top, spacing: 8) {
 852                            Circle()
 853                                .fill(factorColor(factor.impact))
 854                                .frame(width: 8, height: 8)
 855                                .padding(.top, 5)
 856                            Text(factor.description)
 857                                .font(appDensity.font(.caption))
 858                                .foregroundStyle(.primary)
 859                        }
 860                    }
 861                }
 862            }
 863        }
 864    }
 865
 866    private var levelColor: Color {
 867        switch report.riskAssessment.level {
 868        case .low:
 869            return Color(.statusPositive)
 870        case .medium:
 871            return Color(.statusWarning)
 872        case .high:
 873            return Color(.statusCritical)
 874        }
 875    }
 876
 877    private func factorColor(_ impact: RiskImpact) -> Color {
 878        switch impact {
 879        case .positive:
 880            return Color(.statusPositive)
 881        case .neutral:
 882            return .secondary
 883        case .negative:
 884            return Color(.statusCritical)
 885        }
 886    }
 887}
 888
 889struct InsightsSummaryCardView: View {
 890    @Environment(\.appDensity) private var appDensity
 891    let insights: [String]
 892
 893    var body: some View {
 894        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 895            SectionTitleView(title: "Insights")
 896            CardView(allowsHorizontalScroll: false) {
 897                if insights.isEmpty {
 898                    Text("No deterministic insights triggered")
 899                        .font(appDensity.font(.caption))
 900                        .foregroundStyle(Color(.appTextSecondary))
 901                } else {
 902                    ForEach(Array(insights.enumerated()), id: \.offset) { _, insight in
 903                        HStack(alignment: .top, spacing: 8) {
 904                            Image(systemName: "chart.line.uptrend.xyaxis")
 905                                .font(appDensity.font(.caption2))
 906                                .foregroundStyle(Color(.statusInfo))
 907                                .padding(.top, 2)
 908                            Text(insight)
 909                                .font(appDensity.font(.caption))
 910                                .foregroundStyle(.primary)
 911                        }
 912                    }
 913                }
 914            }
 915        }
 916    }
 917}
 918
 919struct StickyLookupSummaryView: View {
 920    @Environment(\.appDensity) private var appDensity
 921
 922    let domain: String
 923    let availability: DomainAvailabilityStatus?
 924    let primaryIP: String?
 925    let sslInfo: SSLCertificateInfo?
 926    let sslError: String?
 927    let emailSecurity: EmailSecurityResult?
 928    let emailError: String?
 929    let changeSummary: DomainChangeSummary?
 930
 931    var body: some View {
 932        CardView(allowsHorizontalScroll: false) {
 933            VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 934                HStack(alignment: .center, spacing: 10) {
 935                    Text(domain)
 936                        .font(appDensity.font(.headline, weight: .semibold))
 937                        .foregroundStyle(.primary)
 938                        .lineLimit(1)
 939                    Spacer(minLength: 6)
 940                    AppCopyButton(value: domain, label: "Copy domain")
 941                }
 942
 943                ScrollView(.horizontal, showsIndicators: false) {
 944                    HStack(spacing: 8) {
 945                        AppStatusBadgeView(model: AppStatusFactory.availability(availability))
 946                        AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError))
 947                        AppStatusBadgeView(model: AppStatusFactory.email(emailSecurity, error: emailError))
 948                        AppStatusBadgeView(model: AppStatusFactory.change(changeSummary))
 949                    }
 950                }
 951
 952                if let primaryIP {
 953                    HStack(spacing: 8) {
 954                        Label(primaryIP, systemImage: "network")
 955                            .font(appDensity.font(.caption))
 956                            .foregroundStyle(Color(.appTextSecondary))
 957                        Spacer(minLength: 6)
 958                        AppCopyButton(value: primaryIP, label: "Copy IP")
 959                    }
 960                }
 961            }
 962        }
 963        .shadow(color: .black.opacity(0.12), radius: 14, y: 6)
 964    }
 965}
 966
 967struct LookupProgressOverviewView: View {
 968    @Environment(\.appDensity) private var appDensity
 969    let steps: [String]
 970
 971    var body: some View {
 972        CardView(allowsHorizontalScroll: false) {
 973            HStack(spacing: 8) {
 974                ProgressView()
 975                    .controlSize(.small)
 976                VStack(alignment: .leading, spacing: 4) {
 977                    Text("Running lookup…")
 978                        .font(appDensity.font(.caption))
 979                        .foregroundStyle(.primary)
 980                    Text(steps.isEmpty ? "Preparing requests" : steps.joined(separator: ""))
 981                        .font(appDensity.font(.caption2))
 982                        .foregroundStyle(Color(.appTextSecondary))
 983                }
 984                Spacer()
 985            }
 986        }
 987    }
 988}
 989
 990struct LookupStatusBannerView: View {
 991    @Environment(\.appDensity) private var appDensity
 992    let message: String
 993    let resultSource: LookupResultSource
 994
 995    var body: some View {
 996        HStack(spacing: 8) {
 997            Image(systemName: iconName)
 998                .font(.caption)
 999            Text(message)
1000                .font(appDensity.font(.caption))
1001            Spacer()
1002        }
1003        .foregroundStyle(color)
1004        .padding(appDensity.metrics.cardPadding - 2)
1005        .frame(maxWidth: .infinity, alignment: .leading)
1006        .background(color.opacity(0.12))
1007        .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
1008    }
1009
1010    private var color: Color {
1011        switch resultSource {
1012        case .live:
1013            return Color(.statusPositive)
1014        case .cached:
1015            return .secondary
1016        case .mixed:
1017            return Color(.statusWarning)
1018        case .snapshot:
1019            return Color(.statusWarning)
1020        }
1021    }
1022
1023    private var iconName: String {
1024        switch resultSource {
1025        case .live:
1026            return "bolt.horizontal"
1027        case .cached:
1028            return "clock.arrow.trianglehead.counterclockwise.rotate.90"
1029        case .mixed:
1030            return "arrow.triangle.branch"
1031        case .snapshot:
1032            return "archivebox"
1033        }
1034    }
1035}
1036
1037struct DomainChangeSummaryView: View {
1038    @Environment(\.appDensity) private var appDensity
1039    let summary: DomainChangeSummary
1040    @State private var showsDetails = false
1041
1042    var body: some View {
1043        CardView(allowsHorizontalScroll: false) {
1044            HStack {
1045                Label(summary.hasChanges ? "Changed" : "Stable", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle")
1046                    .font(appDensity.font(.caption))
1047                    .foregroundStyle(summary.hasChanges ? severityTone(summary.severity).foreground : Color(.statusPositive))
1048                Spacer()
1049                Text(summary.severity.title.uppercased())
1050                    .font(appDensity.font(.caption2))
1051                    .foregroundStyle(summary.hasChanges ? severityTone(summary.severity).foreground : Color(.appTextSecondary))
1052                    .padding(.horizontal, 8)
1053                    .padding(.vertical, 4)
1054                    .background((summary.hasChanges ? severityTone(summary.severity) : AppStatusTone.neutral).surface)
1055                    .clipShape(Capsule())
1056                Text(summary.impactClassification.title.uppercased())
1057                    .font(appDensity.font(.caption2))
1058                    .foregroundStyle(summary.impactClassification.tone.foreground)
1059                    .padding(.horizontal, 8)
1060                    .padding(.vertical, 4)
1061                    .background(summary.impactClassification.tone.surface)
1062                    .clipShape(Capsule())
1063                Text(summary.generatedAt, style: .time)
1064                    .font(appDensity.font(.caption2))
1065                    .foregroundStyle(Color(.appTextSecondary))
1066            }
1067
1068            VStack(alignment: .leading, spacing: 4) {
1069                Text("Inference")
1070                    .font(appDensity.font(.caption2))
1071                    .foregroundStyle(Color(.appTextSecondary))
1072                Text(summary.message)
1073                    .font(appDensity.font(.caption))
1074                    .foregroundStyle(.primary)
1075                    .lineLimit(2)
1076            }
1077
1078            if !summary.observedFacts.isEmpty || summary.contextNote != nil {
1079                DisclosureGroup(showsDetails ? "Hide Details" : "Show Details", isExpanded: $showsDetails) {
1080                    VStack(alignment: .leading, spacing: 8) {
1081                        if !summary.observedFacts.isEmpty {
1082                            VStack(alignment: .leading, spacing: 4) {
1083                                Text("Observed")
1084                                    .font(appDensity.font(.caption2))
1085                                    .foregroundStyle(Color(.appTextSecondary))
1086                                ForEach(Array(summary.observedFacts.enumerated()), id: \.offset) { _, fact in
1087                                    Text(fact)
1088                                        .font(appDensity.font(.caption))
1089                                        .foregroundStyle(.primary)
1090                                }
1091                            }
1092                        }
1093
1094                        if let riskScoreDelta = summary.riskScoreDelta {
1095                            Text("Risk delta: \(riskScoreDelta >= 0 ? "+" : "")\(riskScoreDelta)")
1096                                .font(appDensity.font(.caption2))
1097                                .foregroundStyle(riskScoreDelta > 0 ? Color(.statusWarning) : .secondary)
1098                        }
1099
1100                        if let contextNote = summary.contextNote {
1101                            Text(contextNote)
1102                                .font(appDensity.font(.caption2))
1103                                .foregroundStyle(Color(.statusWarning))
1104                        }
1105                    }
1106                    .padding(.top, 4)
1107                }
1108                .font(appDensity.font(.caption))
1109                .tint(.secondary)
1110            }
1111        }
1112    }
1113
1114    private func severityTone(_ severity: ChangeSeverity) -> AppStatusTone {
1115        switch severity {
1116        case .low:
1117            return .neutral
1118        case .medium:
1119            return .warning
1120        case .high:
1121            return .critical
1122        }
1123    }
1124
1125}
1126
1127struct DomainDiffView: View {
1128    let title: String
1129    let sections: [DomainDiffSection]
1130    let contextNote: String?
1131    let showsUnchanged: Bool
1132    let highlightedSectionID: String?
1133
1134    @State private var collapsedSections = Set<String>()
1135    @State private var showsLowSeverity = false
1136
1137    private var filteredSections: [DomainDiffSection] {
1138        sections
1139            .map { section in
1140                let items = section.items.filter { item in
1141                    if !showsUnchanged, !item.hasChanges {
1142                        return false
1143                    }
1144                    if showsLowSeverity {
1145                        return true
1146                    }
1147                    return item.severity >= .medium || (showsUnchanged && item.changeType == .unchanged)
1148                }
1149                return DomainDiffSection(id: section.id, title: section.title, items: items)
1150            }
1151            .filter { !$0.items.isEmpty }
1152    }
1153
1154    private var hasLowSeverityChanges: Bool {
1155        sections.flatMap(\.items).contains { $0.hasChanges && $0.severity == .low }
1156    }
1157
1158    var body: some View {
1159        VStack(alignment: .leading, spacing: 12) {
1160            HStack {
1161                SectionTitleView(title: title)
1162                Spacer()
1163                if hasLowSeverityChanges {
1164                    Button(showsLowSeverity ? "Hide Low" : "Show Low") {
1165                        showsLowSeverity.toggle()
1166                    }
1167                    .buttonStyle(.bordered)
1168                    .font(.system(.caption, design: .monospaced))
1169                }
1170            }
1171            if let contextNote {
1172                MessageCardView(text: contextNote, isError: false)
1173            }
1174            if filteredSections.isEmpty {
1175                MessageCardView(text: "No comparison data available", isError: false)
1176            } else {
1177                ForEach(filteredSections) { section in
1178                    CardView(allowsHorizontalScroll: false) {
1179                        DisclosureGroup(isExpanded: binding(for: section)) {
1180                            let visibleItems = showsUnchanged ? section.items : section.items.filter(\.hasChanges)
1181
1182                            ForEach(visibleItems) { item in
1183                                VStack(alignment: .leading, spacing: 6) {
1184                                    HStack {
1185                                        Text(item.label)
1186                                            .font(.system(.caption, design: .monospaced))
1187                                            .foregroundStyle(Color(.appTextSecondary))
1188                                        Spacer()
1189                                        Text("\(item.changeType.marker) \(item.severity.title)\(changeLabel(for: item.changeType))")
1190                                            .font(.system(.caption2, design: .monospaced))
1191                                            .foregroundStyle(changeTone(for: item).foreground)
1192                                            .padding(.horizontal, 8)
1193                                            .padding(.vertical, 4)
1194                                            .background(changeTone(for: item).surface)
1195                                            .clipShape(Capsule())
1196                                    }
1197
1198                                    if let oldValue = item.oldValue {
1199                                        VStack(alignment: .leading, spacing: 2) {
1200                                            Text("Old")
1201                                                .font(.system(.caption2, design: .monospaced))
1202                                                .foregroundStyle(Color(.appTextSecondary))
1203                                            Text(oldValue)
1204                                                .font(.system(.caption2, design: .monospaced))
1205                                                .foregroundStyle(Color(.appTextSecondary))
1206                                                .textSelection(.enabled)
1207                                        }
1208                                    }
1209
1210                                    if let newValue = item.newValue {
1211                                        VStack(alignment: .leading, spacing: 2) {
1212                                            Text("New")
1213                                                .font(.system(.caption2, design: .monospaced))
1214                                                .foregroundStyle(Color(.appTextSecondary))
1215                                            Text(newValue)
1216                                                .font(.system(.caption, design: .monospaced))
1217                                                .foregroundStyle(item.hasChanges ? .primary : .secondary)
1218                                                .textSelection(.enabled)
1219                                        }
1220                                    }
1221                                }
1222                                .padding(10)
1223                                .background(item.hasChanges ? changeTone(for: item).surface : Color(.appSurface))
1224                                .cornerRadius(8)
1225                            }
1226                        } label: {
1227                            HStack {
1228                                Text(section.title)
1229                                    .font(.system(.subheadline, design: .monospaced))
1230                                    .fontWeight(.semibold)
1231                                    .foregroundStyle(sectionColor(section))
1232                                Spacer()
1233                                Text(section.severity.title)
1234                                    .font(.system(.caption2, design: .monospaced))
1235                                    .foregroundStyle(sectionColor(section))
1236                            }
1237                        }
1238                    }
1239                    .id(section.id)
1240                    .overlay {
1241                        if highlightedSectionID == section.id {
1242                            RoundedRectangle(cornerRadius: 12)
1243                                .stroke(Color(.statusInfo).opacity(0.55), lineWidth: 1)
1244                        }
1245                    }
1246                }
1247            }
1248        }
1249        .onAppear {
1250            collapsedSections = Set(sections.filter { !showsUnchanged && !$0.hasChanges }.map(\.id))
1251        }
1252    }
1253
1254    private func changeLabel(for changeType: DiffChangeType) -> String {
1255        switch changeType {
1256        case .added:
1257            return "Added"
1258        case .removed:
1259            return "Removed"
1260        case .changed:
1261            return "Changed"
1262        case .unchanged:
1263            return "Unchanged"
1264        }
1265    }
1266
1267    private func changeTone(for item: DomainDiffItem) -> AppStatusTone {
1268        if item.changeType == .unchanged {
1269            return .neutral
1270        }
1271
1272        switch item.severity {
1273        case .low:
1274            return .info
1275        case .medium:
1276            return .warning
1277        case .high:
1278            return .critical
1279        }
1280    }
1281
1282    private func sectionColor(_ section: DomainDiffSection) -> Color {
1283        switch section.severity {
1284        case .low:
1285            return .blue
1286        case .medium:
1287            return Color(.statusWarning)
1288        case .high:
1289            return Color(.statusCritical)
1290        }
1291    }
1292
1293    private func binding(for section: DomainDiffSection) -> Binding<Bool> {
1294        Binding(
1295            get: { !collapsedSections.contains(section.id) },
1296            set: { isExpanded in
1297                if isExpanded {
1298                    collapsedSections.remove(section.id)
1299                } else {
1300                    collapsedSections.insert(section.id)
1301                }
1302            }
1303        )
1304    }
1305}
1306
1307struct TrackedDomainDetailHeaderView: View {
1308    let trackedDomain: TrackedDomain
1309
1310    var body: some View {
1311        VStack(alignment: .leading, spacing: 4) {
1312            if let note = trackedDomain.note?.nilIfEmpty {
1313                LabeledValueRow(row: InfoRowViewData(label: "Tracking Note", value: note, tone: .secondary))
1314            }
1315            HStack(spacing: 8) {
1316                if trackedDomain.isPinned {
1317                    Label("Pinned", systemImage: "pin.fill")
1318                }
1319                Text("Last refresh \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
1320            }
1321            .font(.system(.caption2, design: .monospaced))
1322            .foregroundStyle(Color(.appTextSecondary))
1323        }
1324    }
1325}
1326
1327struct DomainSectionView: View {
1328    @Environment(\.appDensity) private var appDensity
1329    @Binding var isCollapsed: Bool
1330    let rows: [InfoRowViewData]
1331    let suggestions: [DomainSuggestionViewData]
1332    let showSuggestions: Bool
1333    let availabilityLoading: Bool
1334    let suggestionsLoading: Bool
1335    let provenance: SectionProvenance?
1336    let confidence: ConfidenceLevel?
1337    let snapshotNote: String?
1338    let trackedDomain: TrackedDomain?
1339    let workflows: [DomainWorkflow]
1340    let trackingLimitMessage: String?
1341    let pricingLoading: Bool
1342    let pricingError: String?
1343    let showsPricingPlaceholder: Bool
1344    let onTrack: () -> Void
1345    let onTogglePinned: () -> Void
1346    let onEditNote: (() -> Void)?
1347    let onAddToWorkflow: (() -> Void)?
1348    let onOpenWorkflow: ((DomainWorkflow) -> Void)?
1349    let onRunWorkflow: ((DomainWorkflow) -> Void)?
1350
1351    var body: some View {
1352        CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) {
1353            if let trackedDomain {
1354                HStack(spacing: 8) {
1355                    // Icon-only: the header also carries Pin and Note, and the
1356                    // full "Tracked" pill compresses at larger text sizes.
1357                    // VoiceOver still hears the word via the label.
1358                    Image(systemName: "eye.fill")
1359                        .font(appDensity.font(.caption))
1360                        .foregroundStyle(Color(.statusPositive))
1361                        .padding(6)
1362                        .background(Color(.statusPositiveSurface), in: Circle())
1363                        .fixedSize()
1364                        .accessibilityLabel("Tracked")
1365                    Button {
1366                        onTogglePinned()
1367                    } label: {
1368                        Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin")
1369                    }
1370                    .buttonStyle(.bordered)
1371                    .font(appDensity.font(.caption))
1372                    .accessibilityLabel("Pin domain")
1373                    .accessibilityValue(trackedDomain.isPinned ? "Pinned" : "Not pinned")
1374                    .accessibilityAddTraits(trackedDomain.isPinned ? .isSelected : [])
1375                    if let onEditNote {
1376                        Button("Note") {
1377                            onEditNote()
1378                        }
1379                        .buttonStyle(.bordered)
1380                        .font(appDensity.font(.caption))
1381                        // Never compress into a vertical letter column.
1382                        .fixedSize()
1383                    }
1384                }
1385            } else {
1386                Button("Track") {
1387                    AppHaptics.track()
1388                    onTrack()
1389                }
1390                .buttonStyle(.bordered)
1391                .font(appDensity.font(.caption))
1392                .fixedSize()
1393            }
1394        } content: {
1395            CardView(allowsHorizontalScroll: false) {
1396                SectionTrustMetadataView(
1397                    provenance: provenance,
1398                    confidence: confidence,
1399                    note: snapshotNote == nil ? nil : "Audit note present"
1400                )
1401                ForEach(rows) { row in
1402                    LabeledValueRow(row: row)
1403                }
1404                if let trackedDomain {
1405                    TrackedDomainDetailHeaderView(trackedDomain: trackedDomain)
1406                        .padding(.top, 4)
1407                } else if let trackingLimitMessage {
1408                    MessageRowView(text: trackingLimitMessage, isError: false)
1409                        .padding(.top, 4)
1410                }
1411                if let onAddToWorkflow {
1412                    Button {
1413                        onAddToWorkflow()
1414                    } label: {
1415                        Label("Add to workflow", systemImage: "plus.rectangle.on.folder")
1416                            .font(appDensity.font(.caption))
1417                    }
1418                    .buttonStyle(.bordered)
1419                    .padding(.top, 4)
1420                }
1421                if !workflows.isEmpty {
1422                    VStack(alignment: .leading, spacing: 8) {
1423                        Text("Part of workflow")
1424                            .font(appDensity.font(.caption))
1425                            .foregroundStyle(Color(.appTextSecondary))
1426
1427                        ForEach(workflows) { workflow in
1428                            HStack {
1429                                Text(workflow.name)
1430                                    .font(appDensity.font(.caption))
1431                                    .foregroundStyle(.primary)
1432                                Spacer()
1433                                if let onOpenWorkflow {
1434                                    Button("Open") {
1435                                        onOpenWorkflow(workflow)
1436                                    }
1437                                    .buttonStyle(.bordered)
1438                                    .font(appDensity.font(.caption2))
1439                                }
1440                                if let onRunWorkflow {
1441                                    Button("Run") {
1442                                        onRunWorkflow(workflow)
1443                                    }
1444                                    .buttonStyle(.bordered)
1445                                    .font(appDensity.font(.caption2))
1446                                }
1447                            }
1448                        }
1449                    }
1450                    .padding(.top, 4)
1451                }
1452                if availabilityLoading {
1453                    ProgressView("Checking availability…")
1454                        .appLoadingStyle()
1455                        .padding(.top, 4)
1456                }
1457                if showSuggestions {
1458                    Text("Suggestions")
1459                        .font(appDensity.font(.caption))
1460                        .foregroundStyle(Color(.appTextSecondary))
1461                        .padding(.top, 4)
1462                    if suggestionsLoading {
1463                        ProgressView("Checking alternatives…")
1464                            .appLoadingStyle()
1465                    } else if suggestions.isEmpty {
1466                        MessageRowView(text: "No suggestions", isError: false)
1467                    } else {
1468                        ForEach(suggestions) { suggestion in
1469                            HStack {
1470                                Text(suggestion.domain)
1471                                    .font(appDensity.font(.caption))
1472                                    .foregroundStyle(.primary)
1473                                    .textSelection(.enabled)
1474                                Spacer()
1475                                AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.availabilityStatus))
1476                            }
1477                        }
1478                    }
1479                }
1480                if pricingLoading {
1481                    ProgressView("Loading external pricing…")
1482                        .appLoadingStyle()
1483                        .padding(.top, 4)
1484                } else if let pricingError {
1485                    MessageRowView(text: pricingError, isError: false)
1486                        .padding(.top, 4)
1487                } else if showsPricingPlaceholder {
1488                    MessageRowView(text: "Pricing signals available in Pro+", isError: false)
1489                        .padding(.top, 4)
1490                }
1491            }
1492        }
1493    }
1494}
1495
1496struct OwnershipSectionView: View {
1497    @Environment(\.appDensity) private var appDensity
1498    @Binding var isCollapsed: Bool
1499    let rows: [InfoRowViewData]
1500    let loading: Bool
1501    let error: String?
1502    let provenance: SectionProvenance?
1503    let confidence: ConfidenceLevel?
1504    let showsHistoryPlaceholder: Bool
1505    let history: [DomainOwnershipHistoryEvent]
1506    let historyLoading: Bool
1507    let historyError: String?
1508    let historyCreditStatus: UsageCreditStatus?
1509    let onLoadHistory: (() -> Void)?
1510
1511    init(
1512        isCollapsed: Binding<Bool>,
1513        rows: [InfoRowViewData],
1514        loading: Bool,
1515        error: String?,
1516        provenance: SectionProvenance?,
1517        confidence: ConfidenceLevel?,
1518        showsHistoryPlaceholder: Bool,
1519        history: [DomainOwnershipHistoryEvent] = [],
1520        historyLoading: Bool = false,
1521        historyError: String? = nil,
1522        historyCreditStatus: UsageCreditStatus? = nil,
1523        onLoadHistory: (() -> Void)? = nil
1524    ) {
1525        _isCollapsed = isCollapsed
1526        self.rows = rows
1527        self.loading = loading
1528        self.error = error
1529        self.provenance = provenance
1530        self.confidence = confidence
1531        self.showsHistoryPlaceholder = showsHistoryPlaceholder
1532        self.history = history
1533        self.historyLoading = historyLoading
1534        self.historyError = historyError
1535        self.historyCreditStatus = historyCreditStatus
1536        self.onLoadHistory = onLoadHistory
1537    }
1538
1539    var body: some View {
1540        CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) {
1541            CardView(allowsHorizontalScroll: false) {
1542                SectionTrustMetadataView(provenance: provenance, confidence: confidence)
1543                if loading {
1544                    ProgressView("Fetching RDAP ownership…")
1545                        .appLoadingStyle()
1546                } else {
1547                    ForEach(rows) { row in
1548                        LabeledValueRow(row: row)
1549                    }
1550                    if let error, rows.allSatisfy({ $0.value == "Unavailable" }) {
1551                        MessageRowView(text: error, isError: error != "Unavailable")
1552                            .padding(.top, 4)
1553                    }
1554                    VStack(alignment: .leading, spacing: 8) {
1555                        HStack {
1556                            Text("History")
1557                                .font(appDensity.font(.caption))
1558                                .foregroundStyle(Color(.appTextSecondary))
1559                            Spacer()
1560                            if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
1561                                Button("Load") {
1562                                    onLoadHistory()
1563                                }
1564                                .buttonStyle(.bordered)
1565                                .font(appDensity.font(.caption2))
1566                            }
1567                        }
1568                        if historyLoading {
1569                            ProgressView("Loading history…")
1570                                .appLoadingStyle()
1571                        } else if !history.isEmpty {
1572                            ForEach(history) { event in
1573                                VStack(alignment: .leading, spacing: 3) {
1574                                    Text(event.date.formatted(date: .abbreviated, time: .omitted))
1575                                        .font(appDensity.font(.caption2))
1576                                        .foregroundStyle(Color(.appTextSecondary))
1577                                    Text(event.summary)
1578                                        .font(appDensity.font(.caption))
1579                                    Text(event.source)
1580                                        .font(appDensity.font(.caption2))
1581                                        .foregroundStyle(Color(.appTextSecondary))
1582                                }
1583                            }
1584                        } else if let historyError {
1585                            MessageRowView(text: historyError, isError: false)
1586                        } else if showsHistoryPlaceholder {
1587                            MessageRowView(text: "Ownership history available in Pro+", isError: false)
1588                        }
1589                    }
1590                }
1591            }
1592        }
1593    }
1594}
1595
1596struct IntelligenceSectionView: View {
1597    @Environment(\.appDensity) private var appDensity
1598    @Binding var isCollapsed: Bool
1599    let report: DomainReport
1600    let showsPlaceholder: Bool
1601
1602    var body: some View {
1603        CollapsibleSectionView(title: "Data+ Intelligence", isCollapsed: $isCollapsed) {
1604            CardView(allowsHorizontalScroll: false) {
1605                if showsPlaceholder {
1606                    MessageRowView(text: "Richer intelligence history, hosting analysis, and risk signals are available in Pro+", isError: false)
1607                } else {
1608                    if let provider = report.inferredProvider {
1609                        intelligenceBlock(title: "Infrastructure") {
1610                            LabeledValueRow(row: .init(label: "Provider", value: provider.name, tone: .primary))
1611                            if !provider.evidence.isEmpty {
1612                                MessageRowView(text: provider.evidence.joined(separator: ""), isError: false)
1613                            }
1614                            if !report.priorProviders.isEmpty {
1615                                LabeledValueRow(row: .init(label: "Prior", value: report.priorProviders.joined(separator: ", "), tone: .secondary))
1616                            }
1617                        }
1618                    }
1619                    if let classification = report.domainClassification {
1620                        intelligenceBlock(title: "Classification") {
1621                            LabeledValueRow(row: .init(label: "Purpose", value: classification.kind.title, tone: .primary))
1622                            MessageRowView(text: classification.reasons.joined(separator: ""), isError: false)
1623                        }
1624                    }
1625                    intelligenceBlock(title: "Risk Signals") {
1626                        if report.riskSignals.isEmpty {
1627                            MessageRowView(text: "No material historical risk signals detected", isError: false)
1628                        } else {
1629                            ForEach(report.riskSignals.prefix(4)) { signal in
1630                                VStack(alignment: .leading, spacing: 3) {
1631                                    Text(signal.title)
1632                                        .font(appDensity.font(.caption, weight: .semibold))
1633                                    Text(signal.detail)
1634                                        .font(appDensity.font(.caption2))
1635                                        .foregroundStyle(Color(.appTextSecondary))
1636                                }
1637                            }
1638                        }
1639                    }
1640                    intelligenceBlock(title: "Ownership History") {
1641                        if report.ownershipTransitions.isEmpty {
1642                            MessageRowView(text: "No ownership transitions observed locally", isError: false)
1643                        } else {
1644                            ForEach(report.ownershipTransitions.prefix(4)) { event in
1645                                intelligenceEventRow(date: event.date, title: event.summary)
1646                            }
1647                        }
1648                    }
1649                    intelligenceBlock(title: "Hosting History") {
1650                        if report.hostingTransitions.isEmpty {
1651                            MessageRowView(text: "No hosting transitions observed locally", isError: false)
1652                        } else {
1653                            ForEach(report.hostingTransitions.prefix(4)) { event in
1654                                intelligenceEventRow(date: event.date, title: event.summary)
1655                            }
1656                        }
1657                    }
1658                    intelligenceBlock(title: "Subdomain Intelligence") {
1659                        if report.subdomainHistory.isEmpty {
1660                            MessageRowView(text: "No subdomain history available", isError: false)
1661                        } else {
1662                            ForEach(report.subdomainHistory.prefix(5)) { item in
1663                                VStack(alignment: .leading, spacing: 3) {
1664                                    HStack {
1665                                        Text(item.hostname)
1666                                            .font(appDensity.font(.caption))
1667                                        Spacer()
1668                                        if item.isEphemeral {
1669                                            Text("Ephemeral")
1670                                                .font(appDensity.font(.caption2))
1671                                                .foregroundStyle(Color(.statusWarning))
1672                                        }
1673                                    }
1674                                    Text("First \(item.firstSeen.formatted(date: .abbreviated, time: .omitted)) • Last \(item.lastSeen.formatted(date: .abbreviated, time: .omitted)) • Seen \(item.recurrenceCount)x")
1675                                        .font(appDensity.font(.caption2))
1676                                        .foregroundStyle(Color(.appTextSecondary))
1677                                }
1678                            }
1679                        }
1680                    }
1681                    intelligenceBlock(title: "Timeline") {
1682                        if report.intelligenceTimeline.isEmpty {
1683                            MessageRowView(text: "No inferred intelligence events yet", isError: false)
1684                        } else {
1685                            ForEach(report.intelligenceTimeline.prefix(5)) { event in
1686                                intelligenceEventRow(date: event.date, title: "\(event.title): \(event.detail)")
1687                            }
1688                        }
1689                    }
1690                }
1691            }
1692        }
1693    }
1694
1695    @ViewBuilder
1696    private func intelligenceBlock<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
1697        VStack(alignment: .leading, spacing: 8) {
1698            Text(title)
1699                .font(appDensity.font(.subheadline, weight: .semibold))
1700                .foregroundStyle(Color(.statusInfo))
1701            content()
1702        }
1703    }
1704
1705    private func intelligenceEventRow(date: Date, title: String) -> some View {
1706        VStack(alignment: .leading, spacing: 3) {
1707            Text(date.formatted(date: .abbreviated, time: .omitted))
1708                .font(appDensity.font(.caption2))
1709                .foregroundStyle(Color(.appTextSecondary))
1710            Text(title)
1711                .font(appDensity.font(.caption))
1712        }
1713    }
1714}
1715
1716struct SubdomainsSectionView: View {
1717    @Environment(\.appDensity) private var appDensity
1718    @Binding var isCollapsed: Bool
1719    let rows: [SubdomainRowViewData]
1720    let groups: [SubdomainGroup]
1721    let loading: Bool
1722    let error: String?
1723    let provenance: SectionProvenance?
1724    let confidence: ConfidenceLevel?
1725    let showsExtendedPlaceholder: Bool
1726    let extendedCount: Int
1727    let extendedLoading: Bool
1728    let extendedError: String?
1729    let extendedCreditStatus: UsageCreditStatus?
1730    let onLoadExtended: (() -> Void)?
1731
1732    init(
1733        isCollapsed: Binding<Bool>,
1734        rows: [SubdomainRowViewData],
1735        groups: [SubdomainGroup],
1736        loading: Bool,
1737        error: String?,
1738        provenance: SectionProvenance?,
1739        confidence: ConfidenceLevel?,
1740        showsExtendedPlaceholder: Bool,
1741        extendedCount: Int = 0,
1742        extendedLoading: Bool = false,
1743        extendedError: String? = nil,
1744        extendedCreditStatus: UsageCreditStatus? = nil,
1745        onLoadExtended: (() -> Void)? = nil
1746    ) {
1747        _isCollapsed = isCollapsed
1748        self.rows = rows
1749        self.groups = groups
1750        self.loading = loading
1751        self.error = error
1752        self.provenance = provenance
1753        self.confidence = confidence
1754        self.showsExtendedPlaceholder = showsExtendedPlaceholder
1755        self.extendedCount = extendedCount
1756        self.extendedLoading = extendedLoading
1757        self.extendedError = extendedError
1758        self.extendedCreditStatus = extendedCreditStatus
1759        self.onLoadExtended = onLoadExtended
1760    }
1761
1762    var body: some View {
1763        CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") {
1764            CardView(allowsHorizontalScroll: false) {
1765                SectionTrustMetadataView(provenance: provenance, confidence: confidence)
1766                if loading {
1767                    ProgressView("Checking certificate transparency…")
1768                        .appLoadingStyle()
1769                } else if rows.isEmpty {
1770                    MessageRowView(text: error ?? "No passive subdomains found", isError: false)
1771                    if showsExtendedPlaceholder {
1772                        MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false)
1773                            .padding(.top, 4)
1774                    }
1775                } else {
1776                    if let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder {
1777                        Button("Load extended results") {
1778                            onLoadExtended()
1779                        }
1780                        .buttonStyle(.bordered)
1781                        .font(appDensity.font(.caption2))
1782                    }
1783                    if !groups.isEmpty {
1784                        Text("Groups")
1785                            .font(appDensity.font(.caption2))
1786                            .foregroundStyle(Color(.appTextSecondary))
1787                        ForEach(groups) { group in
1788                            HStack {
1789                                Text("\(group.label).*")
1790                                    .font(appDensity.font(.caption))
1791                                    .foregroundStyle(Color(.statusInfo))
1792                                Spacer()
1793                                Text("\(group.subdomains.count)")
1794                                    .font(appDensity.font(.caption2))
1795                                    .foregroundStyle(Color(.appTextSecondary))
1796                            }
1797                        }
1798                    }
1799                    ForEach(rows) { row in
1800                        HStack(spacing: 8) {
1801                            Text(row.hostname)
1802                                .font(appDensity.font(.caption))
1803                                .foregroundStyle(.primary)
1804                                .textSelection(.enabled)
1805                            Spacer()
1806                            if row.isInteresting {
1807                                Text("Interesting")
1808                                    .font(.system(.caption2, design: .monospaced))
1809                                    .foregroundStyle(Color(.statusWarning))
1810                                    .padding(.horizontal, 8)
1811                                    .padding(.vertical, 4)
1812                                    .background(Color(.statusWarningSurface))
1813                                    .clipShape(Capsule())
1814                            }
1815                        }
1816                    }
1817                    if extendedLoading {
1818                        ProgressView("Loading extended subdomains…")
1819                            .appLoadingStyle()
1820                            .padding(.top, 4)
1821                    } else if extendedCount > 0 {
1822                        MessageRowView(text: "\(extendedCount) extended results included", isError: false)
1823                            .padding(.top, 4)
1824                    } else if let extendedError {
1825                        MessageRowView(text: extendedError, isError: false)
1826                            .padding(.top, 4)
1827                    } else if showsExtendedPlaceholder {
1828                        MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false)
1829                            .padding(.top, 4)
1830                    }
1831                }
1832            }
1833        }
1834    }
1835}
1836
1837struct DNSSectionView: View {
1838    @Environment(\.appDensity) private var appDensity
1839    @Binding var isCollapsed: Bool
1840    let dnssecLabel: String?
1841    let patternSummary: DNSPatternSummary?
1842    let sections: [DNSRecordSectionViewData]
1843    let ptrMessage: SectionMessageViewData?
1844    let loading: Bool
1845    let dnsProvenance: SectionProvenance?
1846    let ptrProvenance: SectionProvenance?
1847    let sectionError: String?
1848    let history: [DNSHistoryEvent]
1849    let historyLoading: Bool
1850    let historyError: String?
1851    let showsHistoryPlaceholder: Bool
1852    let historyCreditStatus: UsageCreditStatus?
1853    let onLoadHistory: (() -> Void)?
1854
1855    init(
1856        isCollapsed: Binding<Bool>,
1857        dnssecLabel: String?,
1858        patternSummary: DNSPatternSummary?,
1859        sections: [DNSRecordSectionViewData],
1860        ptrMessage: SectionMessageViewData?,
1861        loading: Bool,
1862        dnsProvenance: SectionProvenance?,
1863        ptrProvenance: SectionProvenance?,
1864        sectionError: String?,
1865        history: [DNSHistoryEvent] = [],
1866        historyLoading: Bool = false,
1867        historyError: String? = nil,
1868        showsHistoryPlaceholder: Bool = false,
1869        historyCreditStatus: UsageCreditStatus? = nil,
1870        onLoadHistory: (() -> Void)? = nil
1871    ) {
1872        _isCollapsed = isCollapsed
1873        self.dnssecLabel = dnssecLabel
1874        self.patternSummary = patternSummary
1875        self.sections = sections
1876        self.ptrMessage = ptrMessage
1877        self.loading = loading
1878        self.dnsProvenance = dnsProvenance
1879        self.ptrProvenance = ptrProvenance
1880        self.sectionError = sectionError
1881        self.history = history
1882        self.historyLoading = historyLoading
1883        self.historyError = historyError
1884        self.showsHistoryPlaceholder = showsHistoryPlaceholder
1885        self.historyCreditStatus = historyCreditStatus
1886        self.onLoadHistory = onLoadHistory
1887    }
1888
1889    var body: some View {
1890        CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) {
1891            if loading {
1892                LoadingCardView(text: "Querying DNS…")
1893            } else if let sectionError, sections.isEmpty {
1894                MessageCardView(text: sectionError, isError: true)
1895            } else {
1896                if dnsProvenance != nil {
1897                    CardView(allowsHorizontalScroll: false) {
1898                        SectionTrustMetadataView(provenance: dnsProvenance, confidence: nil)
1899                        if let patternSummary {
1900                            if !patternSummary.providers.isEmpty {
1901                                MessageRowView(text: "Providers: \(patternSummary.providers.joined(separator: ", "))", isError: false)
1902                            }
1903                            if !patternSummary.patterns.isEmpty {
1904                                ForEach(Array(patternSummary.patterns.enumerated()), id: \.offset) { _, pattern in
1905                                    MessageRowView(text: pattern, isError: false)
1906                                }
1907                            }
1908                        }
1909                    }
1910                }
1911                ForEach(sections) { section in
1912                    CardView {
1913                        Text(section.title)
1914                            .font(.system(.subheadline, design: .monospaced))
1915                            .fontWeight(.semibold)
1916                            .foregroundStyle(Color(.statusInfo))
1917
1918                        if let message = section.message {
1919                            MessageRowView(text: message.text, isError: message.isError)
1920                        }
1921
1922                        ForEach(section.rows) { row in
1923                            LabeledValueRow(row: row)
1924                        }
1925
1926                        if let wildcardTitle = section.wildcardTitle {
1927                            Text(wildcardTitle)
1928                                .font(.system(.caption, design: .monospaced))
1929                                .foregroundStyle(Color(.appTextSecondary))
1930                                .padding(.top, 4)
1931                            ForEach(section.wildcardRows) { row in
1932                                LabeledValueRow(row: row)
1933                            }
1934                        }
1935                    }
1936                }
1937
1938                if let ptrMessage {
1939                    CardView {
1940                        Text("PTR")
1941                            .font(.system(.subheadline, design: .monospaced))
1942                            .fontWeight(.semibold)
1943                            .foregroundStyle(Color(.statusInfo))
1944                        SectionTrustMetadataView(provenance: ptrProvenance, confidence: nil)
1945                        MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError)
1946                    }
1947                }
1948
1949                CardView(allowsHorizontalScroll: false) {
1950                    HStack {
1951                        Text("History")
1952                            .font(appDensity.font(.subheadline, weight: .semibold))
1953                            .foregroundStyle(Color(.statusInfo))
1954                        Spacer()
1955                        if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
1956                            Button("Load") {
1957                                onLoadHistory()
1958                            }
1959                            .buttonStyle(.bordered)
1960                            .font(appDensity.font(.caption2))
1961                        }
1962                    }
1963                    if historyLoading {
1964                        ProgressView("Loading DNS history…")
1965                            .appLoadingStyle()
1966                    } else if !history.isEmpty {
1967                        ForEach(history) { event in
1968                            VStack(alignment: .leading, spacing: 3) {
1969                                Text(event.date.formatted(date: .abbreviated, time: .omitted))
1970                                    .font(appDensity.font(.caption2))
1971                                    .foregroundStyle(Color(.appTextSecondary))
1972                                Text(event.summary)
1973                                    .font(appDensity.font(.caption))
1974                                if !event.aRecords.isEmpty {
1975                                    Text("A: \(event.aRecords.joined(separator: ", "))")
1976                                        .font(appDensity.font(.caption2))
1977                                        .foregroundStyle(Color(.appTextSecondary))
1978                                }
1979                                if !event.nameservers.isEmpty {
1980                                    Text("NS: \(event.nameservers.joined(separator: ", "))")
1981                                        .font(appDensity.font(.caption2))
1982                                        .foregroundStyle(Color(.appTextSecondary))
1983                                }
1984                            }
1985                        }
1986                    } else if let historyError {
1987                        MessageRowView(text: historyError, isError: false)
1988                    } else if showsHistoryPlaceholder {
1989                        MessageRowView(text: "DNS history available in Pro+", isError: false)
1990                    }
1991                }
1992            }
1993        }
1994    }
1995}
1996
1997struct WebSectionView: View {
1998    @Environment(\.appDensity) private var appDensity
1999    @Binding var isCollapsed: Bool
2000    let certificateRows: [InfoRowViewData]
2001    let sslInfo: SSLCertificateInfo?
2002    let tlsSummary: WebResultSummary?
2003    let sslLoading: Bool
2004    let sslError: String?
2005    let tlsProvenance: SectionProvenance?
2006    let responseRows: [InfoRowViewData]
2007    let headers: [HTTPHeader]
2008    let headersLoading: Bool
2009    let headersError: String?
2010    let httpProvenance: SectionProvenance?
2011    let redirects: [RedirectHopViewData]
2012    let redirectLoading: Bool
2013    let redirectError: String?
2014    let redirectProvenance: SectionProvenance?
2015    let finalURL: String?
2016
2017    var body: some View {
2018        CollapsibleSectionView(title: "Web", isCollapsed: $isCollapsed) {
2019            CardView {
2020                HStack {
2021                    Text("TLS")
2022                        .font(appDensity.font(.subheadline, weight: .semibold))
2023                        .foregroundStyle(Color(.statusInfo))
2024                    Spacer()
2025                    if !sslLoading {
2026                        AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError))
2027                    }
2028                }
2029                SectionTrustMetadataView(provenance: tlsProvenance, confidence: nil)
2030                if !sslLoading, let tlsSummary {
2031                    LabeledValueRow(row: InfoRowViewData(label: "TLS Grade", value: tlsSummary.tlsGrade.rawValue, tone: tlsSummary.tlsGrade.tone))
2032                    ForEach(Array(tlsSummary.tlsHighlights.enumerated()), id: \.offset) { _, highlight in
2033                        MessageRowView(text: highlight, isError: isTLSHighlightError(highlight))
2034                    }
2035                }
2036                if sslLoading {
2037                    ProgressView("Checking certificate…")
2038                        .appLoadingStyle()
2039                } else if let sslError {
2040                    MessageRowView(text: sslError, isError: true)
2041                } else {
2042                    ForEach(certificateRows) { row in
2043                        LabeledValueRow(row: row)
2044                    }
2045                    if let sslInfo, !sslInfo.subjectAltNames.isEmpty {
2046                        Text("SANs")
2047                            .font(appDensity.font(.caption2))
2048                            .foregroundStyle(Color(.appTextSecondary))
2049                        ForEach(sslInfo.subjectAltNames, id: \.self) { san in
2050                            HStack(alignment: .top, spacing: 8) {
2051                                Text(san)
2052                                    .font(appDensity.font(.caption))
2053                                    .lineLimit(nil)
2054                                    .fixedSize(horizontal: false, vertical: true)
2055                                    .textSelection(.enabled)
2056                                Spacer()
2057                                AppCopyButton(value: san, label: "Copy certificate SAN")
2058                            }
2059                        }
2060                    }
2061                }
2062            }
2063
2064            CardView {
2065                Text("Headers")
2066                    .font(appDensity.font(.subheadline, weight: .semibold))
2067                    .foregroundStyle(Color(.statusInfo))
2068                SectionTrustMetadataView(provenance: httpProvenance, confidence: nil)
2069                if headersLoading {
2070                    ProgressView("Fetching headers…")
2071                        .appLoadingStyle()
2072                } else if let headersError {
2073                    MessageRowView(text: headersError, isError: true)
2074                } else {
2075                    ForEach(responseRows) { row in
2076                        LabeledValueRow(row: row)
2077                    }
2078                    if headers.isEmpty {
2079                        MessageRowView(text: "No HTTP headers returned", isError: false)
2080                    } else {
2081                        ForEach(headers) { header in
2082                            HStack(alignment: .top, spacing: 4) {
2083                                Text(header.name + ":")
2084                                    .font(appDensity.font(.caption))
2085                                    .foregroundStyle(header.isSecurityHeader ? Color(.statusWarning) : Color(.statusInfo))
2086                                Text(header.value)
2087                                    .font(appDensity.font(.caption))
2088                                    .foregroundStyle(.primary)
2089                                    .textSelection(.enabled)
2090                            }
2091                        }
2092                    }
2093                }
2094            }
2095
2096            CardView {
2097                HStack {
2098                    Text("Redirects")
2099                        .font(appDensity.font(.subheadline, weight: .semibold))
2100                        .foregroundStyle(Color(.statusInfo))
2101                    Spacer()
2102                    if let finalURL {
2103                        AppCopyButton(value: finalURL, label: "Copy redirect URL")
2104                    }
2105                }
2106                SectionTrustMetadataView(provenance: redirectProvenance, confidence: nil)
2107                if redirectLoading {
2108                    ProgressView("Tracing redirects…")
2109                        .appLoadingStyle()
2110                } else if let redirectError {
2111                    MessageRowView(text: redirectError, isError: true)
2112                } else if redirects.isEmpty {
2113                    MessageRowView(text: "No redirect data available", isError: false)
2114                } else {
2115                    if let finalURL {
2116                        LabeledValueRow(row: InfoRowViewData(label: "Final URL", value: finalURL, tone: .secondary))
2117                    }
2118                    ForEach(redirects) { redirect in
2119                        HStack(alignment: .top, spacing: 6) {
2120                            Text(redirect.stepLabel)
2121                                .font(appDensity.font(.caption))
2122                                .foregroundStyle(Color(.appTextSecondary))
2123                                .frame(width: 16, alignment: .trailing)
2124                            Text(redirect.statusCode)
2125                                .font(appDensity.font(.caption))
2126                                .foregroundStyle(Color(.statusInfo))
2127                                .frame(width: 36, alignment: .leading)
2128                            Text(redirect.url)
2129                                .font(appDensity.font(.caption))
2130                                .textSelection(.enabled)
2131                            AppCopyButton(value: redirect.url, label: "Copy redirect URL")
2132                            if redirect.isFinal {
2133                                Text("(final)")
2134                                    .font(appDensity.font(.caption2))
2135                                    .foregroundStyle(Color(.appTextSecondary))
2136                            }
2137                        }
2138                    }
2139                }
2140            }
2141        }
2142    }
2143
2144    private func isTLSHighlightError(_ highlight: String) -> Bool {
2145        let normalized = highlight.lowercased()
2146        if normalized.contains("no weak tls indicators were detected") {
2147            return false
2148        }
2149        return normalized.contains("expires")
2150            || normalized.contains("weak")
2151            || normalized.contains("tls 1.0")
2152            || normalized.contains("tls 1.1")
2153    }
2154}
2155
2156struct EmailSectionView: View {
2157    @Environment(\.appDensity) private var appDensity
2158    @Binding var isCollapsed: Bool
2159    let rows: [EmailRowViewData]
2160    let assessment: EmailSecuritySummary?
2161    let loading: Bool
2162    let provenance: SectionProvenance?
2163    let confidence: ConfidenceLevel?
2164    let error: String?
2165
2166    var body: some View {
2167        CollapsibleSectionView(title: "Email", isCollapsed: $isCollapsed) {
2168            CardView {
2169                SectionTrustMetadataView(provenance: provenance, confidence: confidence)
2170                HStack {
2171                    Spacer()
2172                    AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error))
2173                        .opacity(loading ? 0 : 1)
2174                }
2175                if let assessment, let grade = assessment.grade {
2176                    LabeledValueRow(row: InfoRowViewData(label: "Grade", value: grade.rawValue, tone: grade.tone))
2177                    if !assessment.reasons.isEmpty {
2178                        Text(assessment.reasons.joined(separator: " | "))
2179                            .font(appDensity.font(.caption2))
2180                            .foregroundStyle(Color(.appTextSecondary))
2181                    }
2182                }
2183                if loading {
2184                    ProgressView("Checking email records…")
2185                        .appLoadingStyle()
2186                } else if let error {
2187                    MessageRowView(text: error, isError: true)
2188                } else if rows.isEmpty {
2189                    MessageRowView(text: "No email security records found", isError: false)
2190                } else {
2191                    ForEach(rows) { row in
2192                        VStack(alignment: .leading, spacing: 4) {
2193                            HStack(spacing: 8) {
2194                                Text(row.label)
2195                                    .font(appDensity.font(.caption))
2196                                    .foregroundStyle(Color(.statusInfo))
2197                                    .frame(width: 76, alignment: .leading)
2198                                AppStatusBadgeView(model: emailRowBadge(row))
2199                            }
2200                            Text(row.detail)
2201                                .font(appDensity.font(.caption2))
2202                                .foregroundStyle(.primary)
2203                                .textSelection(.enabled)
2204                            if let auxiliaryDetail = row.auxiliaryDetail {
2205                                Text(auxiliaryDetail)
2206                                    .font(appDensity.font(.caption2))
2207                                    .foregroundStyle(Color(.appTextSecondary))
2208                            }
2209                        }
2210                    }
2211                }
2212            }
2213        }
2214    }
2215
2216    private func emailRowBadge(_ row: EmailRowViewData) -> AppStatusBadgeModel {
2217        switch row.statusTone {
2218        case .success:
2219            return .init(title: row.status, systemImage: "checkmark.shield.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
2220        case .warning:
2221            return .init(title: row.status, systemImage: "shield.lefthalf.filled", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
2222        case .failure:
2223            return .init(title: row.status, systemImage: "minus.circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
2224        case .primary, .secondary:
2225            return .init(title: row.status, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
2226        }
2227    }
2228}
2229
2230struct NetworkSectionView: View {
2231    @Environment(\.appDensity) private var appDensity
2232    @Binding var isCollapsed: Bool
2233    let reachabilityRows: [ReachabilityRowViewData]
2234    let reachabilityLoading: Bool
2235    let reachabilityError: String?
2236    let reachabilityProvenance: SectionProvenance?
2237    let locationRows: [InfoRowViewData]
2238    let geolocation: IPGeolocation?
2239    let geolocationLoading: Bool
2240    let geolocationError: String?
2241    let geolocationProvenance: SectionProvenance?
2242    let geolocationConfidence: ConfidenceLevel?
2243    let standardPortRows: [PortScanRowViewData]
2244    let customPortRows: [PortScanRowViewData]
2245    let portScanLoading: Bool
2246    let portScanError: String?
2247    let portScanProvenance: SectionProvenance?
2248    let customPortScanLoading: Bool
2249    let customPortScanError: String?
2250    let isCloudflareProxied: Bool
2251    @Binding var customPortsExpanded: Bool
2252    @Binding var customPortInput: String
2253    let onScanCustomPorts: () -> Void
2254
2255    var body: some View {
2256        CollapsibleSectionView(title: "Network", isCollapsed: $isCollapsed) {
2257            CardView {
2258                Text("Reachability")
2259                    .font(appDensity.font(.subheadline, weight: .semibold))
2260                    .foregroundStyle(Color(.statusInfo))
2261                SectionTrustMetadataView(provenance: reachabilityProvenance, confidence: nil)
2262                if reachabilityLoading {
2263                    ProgressView("Checking ports…")
2264                        .appLoadingStyle()
2265                } else if let reachabilityError {
2266                    MessageRowView(text: reachabilityError, isError: true)
2267                } else {
2268                    ForEach(reachabilityRows) { row in
2269                        HStack {
2270                            Text(row.portLabel)
2271                                .font(appDensity.font(.caption))
2272                            Spacer()
2273                            Text(row.latencyLabel)
2274                                .font(appDensity.font(.caption2))
2275                                .foregroundStyle(Color(.appTextSecondary))
2276                            AppStatusBadgeView(model: reachabilityBadge(row))
2277                        }
2278                    }
2279                }
2280            }
2281
2282            CardView(allowsHorizontalScroll: false) {
2283                Text("Location")
2284                    .font(appDensity.font(.subheadline, weight: .semibold))
2285                    .foregroundStyle(Color(.statusInfo))
2286                SectionTrustMetadataView(provenance: geolocationProvenance, confidence: geolocationConfidence)
2287                if geolocationLoading {
2288                    ProgressView("Looking up location…")
2289                        .appLoadingStyle()
2290                } else if let geolocationError, geolocation == nil {
2291                    MessageRowView(text: geolocationError, isError: geolocationError != "No A record available")
2292                } else if let geolocation {
2293                    ForEach(locationRows) { row in
2294                        LabeledValueRow(row: row)
2295                    }
2296                    if let latitude = geolocation.latitude, let longitude = geolocation.longitude {
2297                        let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
2298                        Map(initialPosition: .region(MKCoordinateRegion(
2299                            center: coordinate,
2300                            span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
2301                        ))) {
2302                            Marker(geolocation.ip, coordinate: coordinate)
2303                        }
2304                        .mapStyle(.standard)
2305                        .frame(maxWidth: .infinity)
2306                        .frame(height: 180)
2307                        .cornerRadius(8)
2308                    }
2309                } else {
2310                    MessageRowView(text: "No location data available", isError: false)
2311                }
2312            }
2313
2314            CardView(allowsHorizontalScroll: false) {
2315                Text("Port Scan")
2316                    .font(appDensity.font(.subheadline, weight: .semibold))
2317                    .foregroundStyle(Color(.statusInfo))
2318                SectionTrustMetadataView(provenance: portScanProvenance, confidence: nil)
2319
2320                if isCloudflareProxied {
2321                    Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.")
2322                        .font(appDensity.font(.caption2))
2323                        .foregroundStyle(Color(.statusWarning))
2324                        .fixedSize(horizontal: false, vertical: true)
2325                }
2326
2327                if portScanLoading {
2328                    ProgressView("Scanning ports…")
2329                        .appLoadingStyle()
2330                } else if let portScanError, standardPortRows.isEmpty {
2331                    MessageRowView(text: portScanError, isError: true)
2332                } else {
2333                    Text("Standard Ports")
2334                        .font(.system(.caption, design: .monospaced))
2335                        .foregroundStyle(Color(.appTextSecondary))
2336                    PortRowsView(rows: standardPortRows)
2337                }
2338
2339                DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) {
2340                    VStack(alignment: .leading, spacing: 10) {
2341                        TextField("8888, 9000, 27017", text: $customPortInput)
2342                            .font(appDensity.font(.caption))
2343                            .textInputAutocapitalization(.never)
2344                            .autocorrectionDisabled()
2345                            .keyboardType(.numberPad)
2346                            .padding(10)
2347                            .background(Color(.appSurface))
2348                            .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
2349
2350                        Button("Scan") {
2351                            AppHaptics.refresh()
2352                            onScanCustomPorts()
2353                        }
2354                        .buttonStyle(.borderedProminent)
2355                        .tint(Color(.accentFill))
2356                        .disabled(customPortScanLoading)
2357
2358                        if customPortScanLoading {
2359                            ProgressView("Scanning custom ports…")
2360                                .appLoadingStyle()
2361                        } else if let customPortScanError {
2362                            MessageRowView(text: customPortScanError, isError: true)
2363                        } else {
2364                            PortRowsView(rows: customPortRows)
2365                        }
2366                    }
2367                    .padding(.top, 8)
2368                }
2369                .font(.system(.caption, design: .monospaced))
2370                .tint(.secondary)
2371            }
2372        }
2373    }
2374
2375    private func reachabilityBadge(_ row: ReachabilityRowViewData) -> AppStatusBadgeModel {
2376        switch row.statusTone {
2377        case .success:
2378            return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
2379        case .warning:
2380            return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
2381        case .failure:
2382            return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface))
2383        case .primary, .secondary:
2384            return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
2385        }
2386    }
2387}
2388
2389struct PortRowsView: View {
2390    @Environment(\.appDensity) private var appDensity
2391    let rows: [PortScanRowViewData]
2392
2393    var body: some View {
2394        if rows.isEmpty {
2395            MessageRowView(text: "No results", isError: false)
2396        } else {
2397            ForEach(rows) { row in
2398                VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
2399                    HStack {
2400                        Text(row.portLabel)
2401                            .font(appDensity.font(.caption))
2402                            .frame(width: 52, alignment: .leading)
2403                        Text(row.service)
2404                            .font(appDensity.font(.caption))
2405                            .foregroundStyle(.primary)
2406                        Spacer()
2407                        if let durationLabel = row.durationLabel {
2408                            Text(durationLabel)
2409                                .font(appDensity.font(.caption2))
2410                                .foregroundStyle(Color(.appTextSecondary))
2411                        }
2412                        AppStatusBadgeView(model: portBadge(row))
2413                    }
2414                    if let banner = row.banner {
2415                        Text(banner)
2416                            .font(appDensity.font(.caption2))
2417                            .foregroundStyle(Color(.appTextSecondary))
2418                            .padding(.leading, 8)
2419                    }
2420                }
2421                .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
2422            }
2423        }
2424    }
2425
2426    private func portBadge(_ row: PortScanRowViewData) -> AppStatusBadgeModel {
2427        switch row.statusTone {
2428        case .success:
2429            return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
2430        case .warning:
2431            return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
2432        case .failure:
2433            return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface))
2434        case .primary, .secondary:
2435            return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
2436        }
2437    }
2438}
2439
2440struct SectionTitleView: View {
2441    @Environment(\.appDensity) private var appDensity
2442    let title: String
2443
2444    var body: some View {
2445        Text(title)
2446            .font(appDensity.font(.headline, design: .default, weight: .semibold))
2447            .foregroundStyle(.primary)
2448            .accessibilityAddTraits(.isHeader)
2449    }
2450}
2451
2452/// A card that wraps its content by default.
2453///
2454/// `allowsHorizontalScroll` used to default to `true`, so nine call sites put
2455/// their content behind a horizontal gesture instead of letting it wrap  a
2456/// WCAG 1.4.10 (Reflow) failure, and the mechanism behind clipped rows at large
2457/// text sizes. It also forced VoiceOver and Switch Control users onto a nested
2458/// scroll axis to reach data.
2459///
2460/// The default is now `false`. Where horizontal scrolling genuinely suits wide
2461/// tabular content, it is still opt-in  but it is suppressed at accessibility
2462/// text sizes, where wrapping always beats a hidden axis.
2463struct CardView<Content: View>: View {
2464    @Environment(\.appDensity) private var appDensity
2465    @Environment(\.dynamicTypeSize) private var dynamicTypeSize
2466    let allowsHorizontalScroll: Bool
2467    let content: Content
2468
2469    init(allowsHorizontalScroll: Bool = false, @ViewBuilder content: () -> Content) {
2470        self.allowsHorizontalScroll = allowsHorizontalScroll
2471        self.content = content()
2472    }
2473
2474    var body: some View {
2475        Group {
2476            if allowsHorizontalScroll, !dynamicTypeSize.isAccessibilitySize {
2477                ScrollView(.horizontal) {
2478                    cardContent
2479                        .scrollTargetLayout()
2480                }
2481                .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
2482            } else {
2483                cardContent
2484                    .frame(maxWidth: .infinity, alignment: .leading)
2485            }
2486        }
2487        .frame(maxWidth: .infinity, alignment: .leading)
2488        .padding(appDensity.metrics.cardPadding)
2489        .background(Color(.appSurface))
2490        .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
2491    }
2492
2493    private var cardContent: some View {
2494        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
2495            content
2496        }
2497    }
2498}
2499
2500struct LoadingCardView: View {
2501    let text: String
2502
2503    var body: some View {
2504        CardView {
2505            ProgressView(text)
2506                .appLoadingStyle()
2507                .frame(maxWidth: .infinity, alignment: .center)
2508        }
2509    }
2510}
2511
2512struct MessageCardView: View {
2513    let text: String
2514    let isError: Bool
2515
2516    var body: some View {
2517        CardView {
2518            MessageRowView(text: text, isError: isError)
2519        }
2520    }
2521}
2522
2523struct MessageRowView: View {
2524    @Environment(\.appDensity) private var appDensity
2525    let text: String
2526    let isError: Bool
2527
2528    var body: some View {
2529        Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle")
2530            .font(appDensity.font(.caption))
2531            .foregroundStyle(isError ? Color(.statusCritical) : .secondary)
2532            .lineLimit(nil)
2533            .fixedSize(horizontal: false, vertical: true)
2534    }
2535}
2536
2537struct SectionTrustMetadataView: View {
2538    @Environment(\.appDensity) private var appDensity
2539    let provenance: SectionProvenance?
2540    let confidence: ConfidenceLevel?
2541    let note: String?
2542
2543    init(provenance: SectionProvenance?, confidence: ConfidenceLevel?, note: String? = nil) {
2544        self.provenance = provenance
2545        self.confidence = confidence
2546        self.note = note
2547    }
2548
2549    var body: some View {
2550        if provenance != nil || confidence != nil || note != nil {
2551            VStack(alignment: .leading, spacing: 6) {
2552                HStack(spacing: 8) {
2553                    if let confidence {
2554                        Text("Confidence \(confidence.title)")
2555                            .font(appDensity.font(.caption2))
2556                            .foregroundStyle(Color(.appTextSecondary))
2557                    }
2558                    if let provenance {
2559                        Text(provenance.provider ?? provenance.source)
2560                            .font(appDensity.font(.caption2))
2561                            .foregroundStyle(Color(.appTextSecondary))
2562                        Text(provenance.resultSource.label)
2563                            .font(appDensity.font(.caption2))
2564                            .foregroundStyle(Color(.appTextSecondary))
2565                    }
2566                }
2567                DisclosureGroup("Details") {
2568                    VStack(alignment: .leading, spacing: 4) {
2569                        if let provenance {
2570                            LabeledValueRow(row: .init(label: "Method", value: provenance.source, tone: .secondary))
2571                            if let provider = provenance.provider {
2572                                LabeledValueRow(row: .init(label: "Provider", value: provider, tone: .secondary))
2573                            }
2574                            if let resolver = provenance.resolver {
2575                                LabeledValueRow(row: .init(label: "Resolver", value: resolver, tone: .secondary))
2576                            }
2577                            LabeledValueRow(row: .init(label: "Collected", value: provenance.collectedAt.formatted(date: .abbreviated, time: .shortened), tone: .secondary))
2578                            LabeledValueRow(row: .init(label: "Mode", value: provenance.resultSource.label, tone: .secondary))
2579                        }
2580                        if let note {
2581                            LabeledValueRow(row: .init(label: "Note", value: note, tone: .secondary))
2582                        }
2583                    }
2584                    .padding(.top, 4)
2585                }
2586                .font(appDensity.font(.caption))
2587                .tint(.secondary)
2588            }
2589        }
2590    }
2591}
2592
2593struct LabeledValueRow: View {
2594    @Environment(\.appDensity) private var appDensity
2595    @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor
2596    let row: InfoRowViewData
2597
2598    var body: some View {
2599        VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
2600            HStack(alignment: .top, spacing: 8) {
2601                VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
2602                    Text(row.label)
2603                        .font(appDensity.font(.caption2))
2604                        .foregroundStyle(Color(.appTextSecondary))
2605                    HStack(alignment: .firstTextBaseline, spacing: 4) {
2606                        if differentiateWithoutColor, let symbol = toneSymbol {
2607                            Image(systemName: symbol)
2608                                .font(appDensity.font(.caption2))
2609                                .foregroundStyle(ResultColors.color(for: row.tone))
2610                                .accessibilityHidden(true)
2611                        }
2612                        valueText
2613                    }
2614                }
2615                .frame(maxWidth: .infinity, alignment: .leading)
2616                .layoutPriority(1)
2617                Spacer(minLength: 6)
2618                if !row.value.isEmpty, row.value != "Unavailable" {
2619                    AppCopyButton(value: row.value, label: "Copy \(row.label)")
2620                }
2621            }
2622        }
2623        .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
2624    }
2625
2626    /// A leading symbol for warning/failure tones, shown only under Differentiate
2627    /// Without Color so tone is not conveyed by text colour alone. Hidden from
2628    /// VoiceOver  the value text already carries the meaning.
2629    private var toneSymbol: String? {
2630        switch row.tone {
2631        case .warning: return "exclamationmark.triangle.fill"
2632        case .failure: return "xmark.octagon.fill"
2633        default: return nil
2634        }
2635    }
2636
2637    @ViewBuilder
2638    private var valueText: some View {
2639        let base = Text(row.value)
2640            .font(appDensity.font(.caption))
2641            .foregroundStyle(ResultColors.color(for: row.tone))
2642
2643        switch row.speechStyle {
2644        case .plain:
2645            base
2646                .lineLimit(nil)
2647                .fixedSize(horizontal: false, vertical: true)
2648                .textSelection(.enabled)
2649        case .technical:
2650            // Record values and identifiers: keep punctuation audible (SPF/DMARC
2651            // separators are semantically load-bearing) and let VoiceOver use its
2652            // code-reading heuristics.
2653            base
2654                .speechAlwaysIncludesPunctuation()
2655                .accessibilityTextContentType(.sourceCode)
2656                .lineLimit(nil)
2657                .fixedSize(horizontal: false, vertical: true)
2658                .textSelection(.enabled)
2659        }
2660    }
2661}
2662
2663/// Maps a row's semantic tone onto the app palette.
2664///
2665/// See `Docs/ACCESSIBILITY.md` for the measured contrast ratios behind these
2666/// colours. Never reach for a literal (`Color(.statusCritical)`, `Color(.statusWarning)`, )  the system
2667/// palette fails WCAG AA badly in light mode (systemYellow is 1.28:1 on white).
2668enum ResultColors {
2669    static func color(for tone: ResultTone) -> Color {
2670        switch tone {
2671        case .primary:
2672            return .primary
2673        case .secondary:
2674            return .secondary
2675        case .success:
2676            return Color(.statusPositive)
2677        case .warning:
2678            return Color(.statusWarning)
2679        case .failure:
2680            return Color(.statusCritical)
2681        }
2682    }
2683}
2684
2685extension DateFormatter {
2686    static let certDate: DateFormatter = {
2687        let formatter = DateFormatter()
2688        formatter.dateStyle = .medium
2689        formatter.timeStyle = .short
2690        return formatter
2691    }()
2692}
2693
2694private extension View {
2695    func appLoadingStyle() -> some View {
2696        font(.system(.caption, design: .monospaced))
2697    }
2698}
2699
2700private extension String {
2701    var nilIfEmpty: String? {
2702        isEmpty ? nil : self
2703    }
2704}
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("Rotate Token") {
3033                    localAPIService.rotateToken()
3034                }
3035
3036                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.")
3037                    .font(appDensity.font(.caption, design: .default))
3038                    .foregroundStyle(Color(.appTextSecondary))
3039            }
3040
3041            Section("Request Logging") {
3042                Toggle(
3043                    "Log Requests",
3044                    isOn: Binding(
3045                        get: { localAPIService.config.requestLoggingEnabled },
3046                        set: { localAPIService.setRequestLoggingEnabled($0) }
3047                    )
3048                )
3049
3050                if localAPIService.requestLogs.isEmpty {
3051                    Text("No local API requests logged yet.")
3052                        .font(appDensity.font(.caption, design: .default))
3053                        .foregroundStyle(Color(.appTextSecondary))
3054                } else {
3055                    ForEach(localAPIService.requestLogs.prefix(25)) { log in
3056                        VStack(alignment: .leading, spacing: 4) {
3057                            HStack {
3058                                Text("\(log.method) \(log.path)")
3059                                    .font(appDensity.font(.callout, design: .monospaced))
3060                                Spacer()
3061                                Text("\(log.statusCode)")
3062                                    .font(appDensity.font(.caption, design: .default))
3063                                    .foregroundStyle(log.statusCode >= 400 ? Color(.statusCritical) : .secondary)
3064                            }
3065
3066                            Text(log.timestamp.formatted(date: .abbreviated, time: .standard))
3067                                .font(appDensity.font(.caption2, design: .default))
3068                                .foregroundStyle(Color(.appTextSecondary))
3069
3070                            Text("\(Int(log.duration * 1000)) ms")
3071                                .font(appDensity.font(.caption2, design: .default))
3072                                .foregroundStyle(Color(.appTextSecondary))
3073                        }
3074                    }
3075                }
3076
3077                Button("Clear Logs", role: .destructive) {
3078                    localAPIService.clearRequestLogs()
3079                }
3080            }
3081
3082            Section("Control") {
3083                Button("Restart Server") {
3084                    localAPIService.setEnabled(false)
3085                    localAPIService.setEnabled(true)
3086                }
3087                .disabled(!localAPIService.config.isEnabled)
3088
3089                Button("Stop Server") {
3090                    localAPIService.stopServer()
3091                }
3092                .disabled(!localAPIService.isRunning)
3093            }
3094        }
3095        .navigationTitle("Local API")
3096        .onAppear {
3097            portText = String(localAPIService.config.port)
3098            localAPIService.refresh()
3099        }
3100    }
3101}
3102
3103private struct MonitoringSettingsView: View {
3104    @Environment(\.appDensity) private var appDensity
3105    @Bindable var viewModel: DomainViewModel
3106
3107    private var notificationAuthorizationLabel: String {
3108        switch viewModel.monitoringNotificationStatus {
3109        case .authorized, .provisional, .ephemeral:
3110            return "Allowed"
3111        case .denied:
3112            return "Denied"
3113        case .notDetermined:
3114            return "Not Requested"
3115        @unknown default:
3116            return "Unknown"
3117        }
3118    }
3119
3120    var body: some View {
3121        Form {
3122            Section("Monitoring") {
3123                Toggle(
3124                    "Enable Background Monitoring",
3125                    isOn: Binding(
3126                        get: { viewModel.monitoringSettings.isEnabled },
3127                        set: { viewModel.setMonitoringEnabled($0) }
3128                    )
3129                )
3130
3131                Picker(
3132                    "Base Interval",
3133                    selection: Binding(
3134                        get: { MonitoringBaseInterval.nearest(to: viewModel.monitoringSettings.baseInterval) },
3135                        set: { viewModel.setMonitoringBaseInterval($0) }
3136                    )
3137                ) {
3138                    ForEach(MonitoringBaseInterval.allCases) { interval in
3139                        Text(interval.title).tag(interval)
3140                    }
3141                }
3142
3143                Toggle(
3144                    "Adaptive Monitoring",
3145                    isOn: Binding(
3146                        get: { viewModel.monitoringSettings.adaptiveEnabled },
3147                        set: { viewModel.setMonitoringAdaptiveEnabled($0) }
3148                    )
3149                )
3150
3151                Picker(
3152                    "Sensitivity",
3153                    selection: Binding(
3154                        get: { viewModel.monitoringSettings.sensitivity },
3155                        set: { viewModel.setMonitoringSensitivity($0) }
3156                    )
3157                ) {
3158                    ForEach(MonitoringSensitivity.allCases) { sensitivity in
3159                        Text(sensitivity.title).tag(sensitivity)
3160                    }
3161                }
3162
3163                let quietHoursStart = viewModel.monitoringSettings.quietHours?.startHour ?? 22
3164                let quietHoursEnd = viewModel.monitoringSettings.quietHours?.endHour ?? 7
3165                Toggle(
3166                    "Quiet Hours",
3167                    isOn: Binding(
3168                        get: { viewModel.monitoringSettings.quietHours != nil },
3169                        set: { isEnabled in
3170                            viewModel.setMonitoringQuietHours(
3171                                startHour: quietHoursStart,
3172                                endHour: quietHoursEnd,
3173                                isEnabled: isEnabled
3174                            )
3175                        }
3176                    )
3177                )
3178
3179                if viewModel.monitoringSettings.quietHours != nil {
3180                    Picker(
3181                        "Quiet Starts",
3182                        selection: Binding(
3183                            get: { quietHoursStart },
3184                            set: { startHour in
3185                                viewModel.setMonitoringQuietHours(
3186                                    startHour: startHour,
3187                                    endHour: quietHoursEnd,
3188                                    isEnabled: true
3189                                )
3190                            }
3191                        )
3192                    ) {
3193                        ForEach(0..<24, id: \.self) { hour in
3194                            Text(Self.monitoringHourLabel(for: hour)).tag(hour)
3195                        }
3196                    }
3197
3198                    Picker(
3199                        "Quiet Ends",
3200                        selection: Binding(
3201                            get: { quietHoursEnd },
3202                            set: { endHour in
3203                                viewModel.setMonitoringQuietHours(
3204                                    startHour: quietHoursStart,
3205                                    endHour: endHour,
3206                                    isEnabled: true
3207                                )
3208                            }
3209                        )
3210                    ) {
3211                        ForEach(0..<24, id: \.self) { hour in
3212                            Text(Self.monitoringHourLabel(for: hour)).tag(hour)
3213                        }
3214                    }
3215                }
3216
3217                Picker(
3218                    "Domains",
3219                    selection: Binding(
3220                        get: { viewModel.monitoringSettings.scope },
3221                        set: { viewModel.setMonitoringScope($0) }
3222                    )
3223                ) {
3224                    ForEach(MonitoringScope.allCases) { scope in
3225                        Text(scope.title).tag(scope)
3226                    }
3227                }
3228
3229                if viewModel.monitoringSettings.scope == .selectedOnly {
3230                    ForEach(viewModel.trackedDomains) { trackedDomain in
3231                        Toggle(
3232                            trackedDomain.domain,
3233                            isOn: Binding(
3234                                get: { viewModel.monitoringSettings.selectedDomainIDs.contains(trackedDomain.id) },
3235                                set: { viewModel.setMonitoringSelection(for: trackedDomain, isSelected: $0) }
3236                            )
3237                        )
3238                    }
3239                }
3240
3241                Toggle(
3242                    "Local Alerts",
3243                    isOn: Binding(
3244                        get: { viewModel.monitoringSettings.alertsEnabled },
3245                        set: { isEnabled in
3246                            if isEnabled {
3247                                Task {
3248                                    await viewModel.requestMonitoringNotificationAuthorization()
3249                                }
3250                            } else {
3251                                viewModel.setMonitoringAlertsEnabled(false)
3252                            }
3253                        }
3254                    )
3255                )
3256
3257                Picker(
3258                    "Notify For",
3259                    selection: Binding(
3260                        get: { viewModel.monitoringSettings.alertFilter },
3261                        set: { viewModel.setMonitoringAlertFilter($0) }
3262                    )
3263                ) {
3264                    ForEach(MonitoringAlertFilter.allCases) { filter in
3265                        Text(filter.title).tag(filter)
3266                    }
3267                }
3268
3269                LabeledContent("Background Refresh", value: DomainMonitoringScheduler.shared.backgroundRefreshStatusDescription())
3270                LabeledContent("Notification Access", value: notificationAuthorizationLabel)
3271
3272                if let monitoringStatusMessage = viewModel.monitoringStatusMessage {
3273                    Text(monitoringStatusMessage)
3274                        .font(appDensity.font(.caption, design: .default))
3275                        .foregroundStyle(Color(.appTextSecondary))
3276                }
3277
3278                if !FeatureAccessService.hasAccess(to: .automatedMonitoring) {
3279                    Text("Background monitoring and alerts are available in Pro.")
3280                        .font(appDensity.font(.caption, design: .default))
3281                        .foregroundStyle(Color(.appTextSecondary))
3282                }
3283            }
3284        }
3285        .navigationTitle("Monitoring")
3286        .onAppear {
3287            viewModel.refreshMonitoringState()
3288            Task {
3289                await viewModel.refreshMonitoringAuthorizationStatus()
3290            }
3291        }
3292    }
3293
3294    private static func monitoringHourLabel(for hour: Int) -> String {
3295        let formatter = DateFormatter()
3296        formatter.dateFormat = "h a"
3297        let components = DateComponents(calendar: .current, hour: hour)
3298        return components.date.map(formatter.string(from:)) ?? "\(hour):00"
3299    }
3300}
3301
3302private struct DataPortabilitySettingsView: View {
3303    private enum ImportTarget {
3304        case backup
3305        case trackedDomains
3306        case workflows
3307
3308        var expectedKind: DataPortabilityImportKind {
3309            switch self {
3310            case .backup:
3311                return .backup
3312            case .trackedDomains:
3313                return .trackedDomains
3314            case .workflows:
3315                return .workflows
3316            }
3317        }
3318
3319        var allowedContentTypes: [UTType] {
3320            switch self {
3321            case .backup:
3322                return [UTType.json]
3323            case .trackedDomains, .workflows:
3324                return [UTType.json, UTType.commaSeparatedText]
3325            }
3326        }
3327    }
3328
3329    @Environment(\.appDensity) private var appDensity
3330    @Bindable var viewModel: DomainViewModel
3331
3332    @State private var importMode: DataPortabilityImportMode = .merge
3333    @State private var activeImportTarget: ImportTarget?
3334    @State private var pendingImportTarget: ImportTarget?
3335    @State private var importDebugStatus: String?
3336    @State private var pendingImportPreview: DataImportPreview?
3337    @State private var pendingImportError: String?
3338    @State private var showReplaceImportConfirmation = false
3339
3340    var body: some View {
3341        Form {
3342            Section("Import & Export") {
3343                Picker("Import Mode", selection: $importMode) {
3344                    ForEach(DataPortabilityImportMode.allCases) { mode in
3345                        Text(mode.title).tag(mode)
3346                    }
3347                }
3348
3349                Text(importMode.explanation)
3350                    .font(appDensity.font(.caption, design: .default))
3351                    .foregroundStyle(Color(.appTextSecondary))
3352
3353                Button("Export Full Backup") {
3354                    exportFullBackup()
3355                }
3356
3357                Button("Import Backup") {
3358                    recordImportDebugStatus("Tapped Import Backup")
3359                    pendingImportTarget = .backup
3360                    activeImportTarget = .backup
3361                }
3362
3363                Menu("Export Tracked Domains") {
3364                    Button("JSON") {
3365                        exportPortableTrackedDomainsJSON()
3366                    }
3367                    Button("CSV") {
3368                        exportPortableTrackedDomainsCSV()
3369                    }
3370                }
3371
3372                Button("Import Tracked Domains") {
3373                    recordImportDebugStatus("Tapped Import Tracked Domains")
3374                    pendingImportTarget = .trackedDomains
3375                    activeImportTarget = .trackedDomains
3376                }
3377
3378                Menu("Export Workflows") {
3379                    Button("JSON") {
3380                        exportPortableWorkflowsJSON()
3381                    }
3382                    Button("CSV") {
3383                        exportPortableWorkflowsCSV()
3384                    }
3385                }
3386
3387                Button("Import Workflows") {
3388                    recordImportDebugStatus("Tapped Import Workflows")
3389                    pendingImportTarget = .workflows
3390                    activeImportTarget = .workflows
3391                }
3392
3393                Button("Export History") {
3394                    exportPortableHistoryJSON()
3395                }
3396            }
3397
3398            Section("Local Data") {
3399                LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)")
3400                LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)")
3401                LabeledContent("Audit Sessions", value: "\(viewModel.dataLifecycleSummary.auditSessions)")
3402                LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)")
3403                LabeledContent("Cached Items", value: "\(viewModel.dataLifecycleSummary.cachedItems)")
3404                LabeledContent("Monitoring Logs", value: "\(viewModel.dataLifecycleSummary.monitoringLogs)")
3405
3406                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.")
3407                    .font(appDensity.font(.caption, design: .default))
3408                    .foregroundStyle(Color(.appTextSecondary))
3409
3410                if let portabilityStatusMessage = viewModel.portabilityStatusMessage {
3411                    Text(portabilityStatusMessage)
3412                        .font(appDensity.font(.caption, design: .default))
3413                        .foregroundStyle(Color(.appTextSecondary))
3414                }
3415            }
3416
3417            #if DEBUG
3418            if let importDebugStatus {
3419                Section("Import Debug") {
3420                    Text(importDebugStatus)
3421                        .font(appDensity.font(.caption, design: .default))
3422                        .foregroundStyle(Color(.appTextSecondary))
3423                        .textSelection(.enabled)
3424                }
3425            }
3426            #endif
3427        }
3428        .navigationTitle("Import & Export")
3429        .alert("Replace local data?", isPresented: $showReplaceImportConfirmation) {
3430            Button("Replace", role: .destructive) {
3431                applyPendingImport()
3432            }
3433            Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3434        } message: {
3435            Text("Replace mode overwrites local data covered by the imported file and may remove items that are only on this device.")
3436        }
3437        .alert("Import Error", isPresented: Binding(
3438            get: { pendingImportError != nil },
3439            set: { if !$0 { pendingImportError = nil } }
3440        )) {
3441            Button("OK", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3442        } message: {
3443            Text(pendingImportError ?? "The import could not be completed.")
3444        }
3445        .sheet(isPresented: Binding(
3446            get: { pendingImportPreview != nil },
3447            set: { if !$0 { pendingImportPreview = nil } }
3448        )) {
3449            if let pendingImportPreview {
3450                DataImportPreviewSheet(
3451                    preview: pendingImportPreview,
3452                    mode: importMode,
3453                    onCancel: {
3454                        self.pendingImportPreview = nil
3455                    },
3456                    onApply: {
3457                        if importMode == .replace {
3458                            showReplaceImportConfirmation = true
3459                        } else {
3460                            applyPendingImport()
3461                        }
3462                    }
3463                )
3464            }
3465        }
3466        .fileImporter(
3467            isPresented: Binding(
3468                get: { activeImportTarget != nil },
3469                set: { if !$0 { activeImportTarget = nil } }
3470            ),
3471            allowedContentTypes: activeImportTarget?.allowedContentTypes ?? [UTType.json],
3472            allowsMultipleSelection: false
3473        ) { result in
3474            guard let pendingImportTarget else {
3475                recordImportDebugStatus("fileImporter returned with no active target")
3476                return
3477            }
3478            recordImportDebugStatus("fileImporter returned for \(pendingImportTarget.expectedKind.rawValue)")
3479            handleImportResult(result, expectedKind: pendingImportTarget.expectedKind)
3480            self.pendingImportTarget = nil
3481            self.activeImportTarget = nil
3482        }
3483        .onAppear {
3484            viewModel.refreshDataLifecycleSummary()
3485        }
3486    }
3487
3488    private func exportFullBackup() {
3489        guard let data = viewModel.exportFullBackupData() else { return }
3490        ExportPresenter.share(filename: portabilityFilename(suffix: "backup", fileExtension: "json"), data: data)
3491    }
3492
3493    private func exportPortableTrackedDomainsJSON() {
3494        guard let data = viewModel.exportPortableTrackedDomainsJSONData() else { return }
3495        ExportPresenter.share(filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "json"), data: data)
3496    }
3497
3498    private func exportPortableTrackedDomainsCSV() {
3499        ExportPresenter.share(
3500            filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "csv"),
3501            contents: viewModel.exportPortableTrackedDomainsCSV()
3502        )
3503    }
3504
3505    private func exportPortableWorkflowsJSON() {
3506        guard let data = viewModel.exportPortableWorkflowsJSONData() else { return }
3507        ExportPresenter.share(filename: portabilityFilename(suffix: "workflows", fileExtension: "json"), data: data)
3508    }
3509
3510    private func exportPortableWorkflowsCSV() {
3511        ExportPresenter.share(
3512            filename: portabilityFilename(suffix: "workflows", fileExtension: "csv"),
3513            contents: viewModel.exportPortableWorkflowsCSV()
3514        )
3515    }
3516
3517    private func exportPortableHistoryJSON() {
3518        guard let data = viewModel.exportPortableHistoryJSONData() else { return }
3519        ExportPresenter.share(filename: portabilityFilename(suffix: "history", fileExtension: "json"), data: data)
3520    }
3521
3522    private func handleImportResult(
3523        _ result: Result<[URL], Error>,
3524        expectedKind: DataPortabilityImportKind
3525    ) {
3526        DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult expectedKind=\(expectedKind.rawValue)")
3527        recordImportDebugStatus("handleImportResult started for \(expectedKind.rawValue)")
3528        do {
3529            let urls = try result.get()
3530            guard let url = urls.first else {
3531                DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult noURLReturned")
3532                recordImportDebugStatus("No URL returned from picker")
3533                return
3534            }
3535            DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult selectedURL=\(url.absoluteString)")
3536            recordImportDebugStatus("Selected \(url.lastPathComponent)")
3537            let shouldStopAccessing = url.startAccessingSecurityScopedResource()
3538            DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeGranted=\(shouldStopAccessing)")
3539            recordImportDebugStatus("Security scope granted: \(shouldStopAccessing)")
3540            defer {
3541                if shouldStopAccessing {
3542                    url.stopAccessingSecurityScopedResource()
3543                    DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeReleased")
3544                }
3545            }
3546
3547            let data = try Data(contentsOf: url)
3548            DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult dataRead bytes=\(data.count) fileName=\(url.lastPathComponent)")
3549            recordImportDebugStatus("Read \(data.count) bytes from \(url.lastPathComponent)")
3550            let preview = try viewModel.prepareDataImport(
3551                data: data,
3552                fileName: url.lastPathComponent,
3553                mode: importMode
3554            )
3555            DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult previewReady previewKind=\(preview.kind.rawValue) expectedKind=\(expectedKind.rawValue)")
3556            recordImportDebugStatus("Preview ready: \(preview.kind.rawValue)")
3557
3558            guard preview.kind == expectedKind else {
3559                let message = preview.kind == .backup
3560                    ? "That file is a full backup. Use Import Backup."
3561                    : "That file type does not match this import action."
3562                DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult kindMismatch message=\(message)")
3563                recordImportDebugStatus("Kind mismatch: \(message)")
3564                presentImportError(message)
3565                return
3566            }
3567
3568            DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult presentingPreview kind=\(preview.kind.rawValue)")
3569            recordImportDebugStatus("Presenting preview for \(preview.kind.rawValue)")
3570            presentImportPreview(preview)
3571        } catch {
3572            DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult failed error=\(error.localizedDescription)")
3573            recordImportDebugStatus("Import failed: \(error.localizedDescription)")
3574            presentImportError(error.localizedDescription)
3575        }
3576    }
3577
3578    private func applyPendingImport() {
3579        guard let pendingImportPreview else { return }
3580        do {
3581            _ = try viewModel.applyDataImport(pendingImportPreview, mode: importMode)
3582            self.pendingImportPreview = nil
3583        } catch {
3584            pendingImportError = error.localizedDescription
3585        }
3586    }
3587
3588    private func portabilityFilename(suffix: String, fileExtension: String) -> String {
3589        let formatter = DateFormatter()
3590        formatter.dateFormat = "yyyyMMdd_HHmmss"
3591        return "\(formatter.string(from: Date()))_domaindig_\(suffix).\(fileExtension)"
3592    }
3593
3594    private func presentImportPreview(_ preview: DataImportPreview) {
3595        Task { @MainActor in
3596            try? await Task.sleep(for: .milliseconds(300))
3597            DomainDebugLog.debug("DataPortabilitySettingsView.presentImportPreview kind=\(preview.kind.rawValue) fileName=\(preview.fileName)")
3598            recordImportDebugStatus("Preview presented for \(preview.fileName)")
3599            pendingImportPreview = preview
3600        }
3601    }
3602
3603    private func presentImportError(_ message: String) {
3604        Task { @MainActor in
3605            try? await Task.sleep(for: .milliseconds(300))
3606            DomainDebugLog.error("DataPortabilitySettingsView.presentImportError message=\(message)")
3607            recordImportDebugStatus("Error presented: \(message)")
3608            pendingImportError = message
3609        }
3610    }
3611
3612    private func recordImportDebugStatus(_ message: String) {
3613        #if DEBUG
3614        let status = "[Import Debug] \(message)"
3615        importDebugStatus = status
3616        print(status)
3617        #endif
3618    }
3619}
3620
3621private struct DataManagementSettingsView: View {
3622    @Bindable var viewModel: DomainViewModel
3623    @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
3624
3625    @State private var showClearHistoryConfirmation = false
3626    @State private var showClearCacheConfirmation = false
3627    @State private var showClearWorkflowsConfirmation = false
3628    @State private var showClearTrackedDomainsConfirmation = false
3629    @State private var showDeleteAllConfirmation = false
3630    @State private var deleteAllErrorMessage: String?
3631    @State private var deleteAllSuccessMessage: String?
3632    @State private var isDeletingAllData = false
3633
3634    var body: some View {
3635        Form {
3636            Section("Data") {
3637                Button("Clear History", role: .destructive) {
3638                    showClearHistoryConfirmation = true
3639                }
3640
3641                Button("Clear Cache", role: .destructive) {
3642                    showClearCacheConfirmation = true
3643                }
3644
3645                Button("Clear Workflows", role: .destructive) {
3646                    showClearWorkflowsConfirmation = true
3647                }
3648
3649                Button("Clear Tracked Domains", role: .destructive) {
3650                    showClearTrackedDomainsConfirmation = true
3651                }
3652            }
3653
3654            Section {
3655                Button(role: .destructive) {
3656                    showDeleteAllConfirmation = true
3657                } label: {
3658                    HStack {
3659                        Text("Delete All Data")
3660                        Spacer()
3661                        if isDeletingAllData {
3662                            ProgressView()
3663                                .controlSize(.small)
3664                        }
3665                    }
3666                }
3667                .disabled(isDeletingAllData)
3668            } header: {
3669                Text("Danger Zone")
3670            } footer: {
3671                Text("Permanently removes all local DomainDig data from this device.")
3672            }
3673        }
3674        .disabled(isDeletingAllData)
3675        .navigationTitle("Data Management")
3676        .alert("Clear history?", isPresented: $showClearHistoryConfirmation) {
3677            Button("Clear", role: .destructive) {
3678                viewModel.clearHistory()
3679            }
3680            Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3681        } message: {
3682            Text("This removes saved lookup snapshots and clears monitoring run history on this device.")
3683        }
3684        .alert("Clear cache?", isPresented: $showClearCacheConfirmation) {
3685            Button("Clear", role: .destructive) {
3686                viewModel.clearLookupCache()
3687            }
3688            Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3689        } message: {
3690            Text("This clears the in-memory lookup cache and cancels any cached in-flight work.")
3691        }
3692        .alert("Clear workflows?", isPresented: $showClearWorkflowsConfirmation) {
3693            Button("Clear", role: .destructive) {
3694                viewModel.clearWorkflows()
3695            }
3696            Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3697        } message: {
3698            Text("This removes saved workflows only. History, tracked domains, and saved reports stay intact.")
3699        }
3700        .alert("Clear tracked domains?", isPresented: $showClearTrackedDomainsConfirmation) {
3701            Button("Clear", role: .destructive) {
3702                viewModel.clearTrackedDomains()
3703            }
3704            Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3705        } message: {
3706            Text("This removes the watchlist and clears monitoring run history. History and workflows stay intact.")
3707        }
3708        .alert("Delete All Data?", isPresented: $showDeleteAllConfirmation) {
3709            Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3710            Button("Delete All Data", role: .destructive) {
3711                deleteAllData()
3712            }
3713        } message: {
3714            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.")
3715        }
3716        .alert("Delete Failed", isPresented: Binding(
3717            get: { deleteAllErrorMessage != nil },
3718            set: { if !$0 { deleteAllErrorMessage = nil } }
3719        )) {
3720            Button("OK", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3721        } message: {
3722            Text(deleteAllErrorMessage ?? "The local data reset could not be completed.")
3723        }
3724        .safeAreaInset(edge: .bottom) {
3725            if let deleteAllSuccessMessage {
3726                Text(deleteAllSuccessMessage)
3727                    .font(.footnote.weight(.medium))
3728                    .foregroundStyle(Color(.appTextSecondary))
3729                    .padding(.horizontal, 14)
3730                    .padding(.vertical, 10)
3731                    // Reduce Transparency swaps the blur for an opaque surface.
3732                    // On iOS 26+ the system also composites its own translucency
3733                    // that the app cannot declare  verify there too (Phase 6).
3734                    .background(
3735                        Capsule().fill(reduceTransparency ? AnyShapeStyle(Color(.appSurfaceElevated)) : AnyShapeStyle(.thinMaterial))
3736                    )
3737                    .padding(.bottom, 8)
3738                    .transition(.move(edge: .bottom).combined(with: .opacity))
3739            }
3740        }
3741    }
3742
3743    private func deleteAllData() {
3744        guard !isDeletingAllData else { return }
3745
3746        isDeletingAllData = true
3747        deleteAllErrorMessage = nil
3748        deleteAllSuccessMessage = nil
3749
3750        Task {
3751            do {
3752                try await DataResetService.wipeAllLocalData(viewModel: viewModel)
3753                deleteAllSuccessMessage = "All local data removed."
3754                try? await Task.sleep(for: .seconds(2))
3755                if deleteAllSuccessMessage == "All local data removed." {
3756                    deleteAllSuccessMessage = nil
3757                }
3758            } catch {
3759                deleteAllErrorMessage = error.localizedDescription
3760            }
3761
3762            isDeletingAllData = false
3763        }
3764    }
3765}
3766
3767private struct AboutSettingsView: View {
3768    @State private var cloudSyncService = CloudSyncService.shared
3769
3770    private var appVersion: String {
3771        AppVersion.current
3772    }
3773
3774    var body: some View {
3775        Form {
3776            Section("About") {
3777                LabeledContent("Version", value: appVersion)
3778                LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only")
3779                LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)")
3780            }
3781        }
3782        .navigationTitle("App Info")
3783        .task {
3784            await cloudSyncService.refreshAvailability()
3785        }
3786    }
3787}
3788
3789private struct DataImportPreviewSheet: View {
3790    @Environment(\.dismiss) private var dismiss
3791
3792    let preview: DataImportPreview
3793    let mode: DataPortabilityImportMode
3794    let onCancel: () -> Void
3795    let onApply: () -> Void
3796
3797    var body: some View {
3798        NavigationStack {
3799            List {
3800                Section("Summary") {
3801                    ForEach(preview.summaryLines, id: \.self) { line in
3802                        Text(line)
3803                    }
3804                }
3805
3806                Section("Projected Counts") {
3807                    LabeledContent("Tracked Domains", value: "\(preview.projectedCounts.trackedDomains)")
3808                    LabeledContent("History Snapshots", value: "\(preview.projectedCounts.historySnapshots)")
3809                    LabeledContent("Audit Sessions", value: "\(preview.projectedCounts.auditSessions)")
3810                    LabeledContent("Workflows", value: "\(preview.projectedCounts.workflows)")
3811                    LabeledContent("Cached Items", value: "\(preview.projectedCounts.cachedItems)")
3812                    LabeledContent("Monitoring Logs", value: "\(preview.projectedCounts.monitoringLogs)")
3813                }
3814
3815                if !preview.warnings.isEmpty {
3816                    Section("Warnings") {
3817                        ForEach(preview.warnings, id: \.self) { warning in
3818                            Text(warning)
3819                                .foregroundStyle(Color(.appTextSecondary))
3820                        }
3821                    }
3822                }
3823            }
3824            .navigationTitle("Import Preview")
3825            .toolbar {
3826                ToolbarItem(placement: .cancellationAction) {
3827                    Button("Cancel") {
3828                        onCancel()
3829                        dismiss()
3830                    }
3831                }
3832                ToolbarItem(placement: .confirmationAction) {
3833                    Button(mode == .replace ? "Replace" : "Import") {
3834                        onApply()
3835                        if mode == .merge {
3836                            dismiss()
3837                        }
3838                    }
3839                }
3840            }
3841        }
3842    }
3843}
3844
3845extension ChangeImpactClassification {
3846    var tone: AppStatusTone {
3847        switch self {
3848        case .informational: return .neutral
3849        case .warning: return .warning
3850        case .critical: return .critical
3851        }
3852    }
3853}
3854
3855extension TLSGrade {
3856    var tone: ResultTone {
3857        switch self {
3858        case .a: return .success
3859        case .f: return .failure
3860        default: return .warning
3861        }
3862    }
3863}
3864
3865extension EmailSecurityGrade {
3866    var tone: ResultTone {
3867        switch self {
3868        case .a: return .success
3869        case .f: return .failure
3870        default: return .warning
3871        }
3872    }
3873}
3874
3875#Preview {
3876    ContentView(viewModel: DomainViewModel())
3877}