krz/domain-dig

an ios app for DNS & SSL analysis

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

v5.0.0: DomainDig/ResultSectionViews.swift · raw

   1import MapKit
   2import SwiftUI
   3
   4// Result detail section views extracted from ContentView.swift  one per
   5// inspection domain (availability/DNS, ownership, intelligence, subdomains,
   6// DNS records, web, email, network, ports). Pure presentation over the
   7// view-model report data; the shared primitives (CardView, SectionTitleView,
   8// LabeledValueRow, ResultColors, appLoadingStyle) remain in ContentView.swift.
   9
  10struct DomainSectionView: View {
  11    @Environment(\.appDensity) private var appDensity
  12    @Binding var isCollapsed: Bool
  13    let rows: [InfoRowViewData]
  14    let suggestions: [DomainSuggestionViewData]
  15    let showSuggestions: Bool
  16    let availabilityLoading: Bool
  17    let suggestionsLoading: Bool
  18    let provenance: SectionProvenance?
  19    let confidence: ConfidenceLevel?
  20    let snapshotNote: String?
  21    let trackedDomain: TrackedDomain?
  22    let workflows: [DomainWorkflow]
  23    let trackingLimitMessage: String?
  24    let pricingLoading: Bool
  25    let pricingError: String?
  26    let showsPricingPlaceholder: Bool
  27    let onTrack: () -> Void
  28    let onTogglePinned: () -> Void
  29    let onEditNote: (() -> Void)?
  30    let onAddToWorkflow: (() -> Void)?
  31    let onOpenWorkflow: ((DomainWorkflow) -> Void)?
  32    let onRunWorkflow: ((DomainWorkflow) -> Void)?
  33
  34    var body: some View {
  35        CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) {
  36            if let trackedDomain {
  37                HStack(spacing: 8) {
  38                    // Icon-only: the header also carries Pin and Note, and the
  39                    // full "Tracked" pill compresses at larger text sizes.
  40                    // VoiceOver still hears the word via the label.
  41                    Image(systemName: "eye.fill")
  42                        .font(appDensity.font(.caption))
  43                        .foregroundStyle(Color(.statusPositive))
  44                        .padding(6)
  45                        .background(Color(.statusPositiveSurface), in: Circle())
  46                        .fixedSize()
  47                        .accessibilityLabel("Tracked")
  48                    Button {
  49                        onTogglePinned()
  50                    } label: {
  51                        Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin")
  52                    }
  53                    .buttonStyle(.bordered)
  54                    .font(appDensity.font(.caption))
  55                    .accessibilityLabel("Pin domain")
  56                    .accessibilityValue(trackedDomain.isPinned ? "Pinned" : "Not pinned")
  57                    .accessibilityAddTraits(trackedDomain.isPinned ? .isSelected : [])
  58                    if let onEditNote {
  59                        Button("Note") {
  60                            onEditNote()
  61                        }
  62                        .buttonStyle(.bordered)
  63                        .font(appDensity.font(.caption))
  64                        // Never compress into a vertical letter column.
  65                        .fixedSize()
  66                    }
  67                }
  68            } else {
  69                Button("Track") {
  70                    AppHaptics.track()
  71                    onTrack()
  72                }
  73                .buttonStyle(.bordered)
  74                .font(appDensity.font(.caption))
  75                .fixedSize()
  76            }
  77        } content: {
  78            CardView(allowsHorizontalScroll: false) {
  79                SectionTrustMetadataView(
  80                    provenance: provenance,
  81                    confidence: confidence,
  82                    note: snapshotNote == nil ? nil : "Audit note present"
  83                )
  84                ForEach(rows) { row in
  85                    LabeledValueRow(row: row)
  86                }
  87                if let trackedDomain {
  88                    TrackedDomainDetailHeaderView(trackedDomain: trackedDomain)
  89                        .padding(.top, 4)
  90                } else if let trackingLimitMessage {
  91                    MessageRowView(text: trackingLimitMessage, isError: false)
  92                        .padding(.top, 4)
  93                }
  94                if let onAddToWorkflow {
  95                    Button {
  96                        onAddToWorkflow()
  97                    } label: {
  98                        Label("Add to workflow", systemImage: "plus.rectangle.on.folder")
  99                            .font(appDensity.font(.caption))
 100                    }
 101                    .buttonStyle(.bordered)
 102                    .padding(.top, 4)
 103                }
 104                if !workflows.isEmpty {
 105                    VStack(alignment: .leading, spacing: 8) {
 106                        Text("Part of workflow")
 107                            .font(appDensity.font(.caption))
 108                            .foregroundStyle(Color(.appTextSecondary))
 109
 110                        ForEach(workflows) { workflow in
 111                            HStack {
 112                                Text(workflow.name)
 113                                    .font(appDensity.font(.caption))
 114                                    .foregroundStyle(.primary)
 115                                Spacer()
 116                                if let onOpenWorkflow {
 117                                    Button("Open") {
 118                                        onOpenWorkflow(workflow)
 119                                    }
 120                                    .buttonStyle(.bordered)
 121                                    .font(appDensity.font(.caption2))
 122                                }
 123                                if let onRunWorkflow {
 124                                    Button("Run") {
 125                                        onRunWorkflow(workflow)
 126                                    }
 127                                    .buttonStyle(.bordered)
 128                                    .font(appDensity.font(.caption2))
 129                                }
 130                            }
 131                        }
 132                    }
 133                    .padding(.top, 4)
 134                }
 135                if availabilityLoading {
 136                    ProgressView("Checking availability…")
 137                        .appLoadingStyle()
 138                        .padding(.top, 4)
 139                }
 140                if showSuggestions {
 141                    Text("Suggestions")
 142                        .font(appDensity.font(.caption))
 143                        .foregroundStyle(Color(.appTextSecondary))
 144                        .padding(.top, 4)
 145                    if suggestionsLoading {
 146                        ProgressView("Checking alternatives…")
 147                            .appLoadingStyle()
 148                    } else if suggestions.isEmpty {
 149                        MessageRowView(text: "No suggestions", isError: false)
 150                    } else {
 151                        ForEach(suggestions) { suggestion in
 152                            HStack {
 153                                Text(suggestion.domain)
 154                                    .font(appDensity.font(.caption))
 155                                    .foregroundStyle(.primary)
 156                                    .textSelection(.enabled)
 157                                Spacer()
 158                                AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.availabilityStatus))
 159                            }
 160                        }
 161                    }
 162                }
 163                if pricingLoading {
 164                    ProgressView("Loading external pricing…")
 165                        .appLoadingStyle()
 166                        .padding(.top, 4)
 167                } else if let pricingError {
 168                    MessageRowView(text: pricingError, isError: false)
 169                        .padding(.top, 4)
 170                } else if showsPricingPlaceholder {
 171                    MessageRowView(text: "Pricing signals available in Pro+", isError: false)
 172                        .padding(.top, 4)
 173                }
 174            }
 175        }
 176    }
 177}
 178
 179struct OwnershipSectionView: View {
 180    @Environment(\.appDensity) private var appDensity
 181    @Binding var isCollapsed: Bool
 182    let rows: [InfoRowViewData]
 183    let loading: Bool
 184    let error: String?
 185    let provenance: SectionProvenance?
 186    let confidence: ConfidenceLevel?
 187    let showsHistoryPlaceholder: Bool
 188    let history: [DomainOwnershipHistoryEvent]
 189    let historyLoading: Bool
 190    let historyError: String?
 191    let historyCreditStatus: UsageCreditStatus?
 192    let onLoadHistory: (() -> Void)?
 193
 194    init(
 195        isCollapsed: Binding<Bool>,
 196        rows: [InfoRowViewData],
 197        loading: Bool,
 198        error: String?,
 199        provenance: SectionProvenance?,
 200        confidence: ConfidenceLevel?,
 201        showsHistoryPlaceholder: Bool,
 202        history: [DomainOwnershipHistoryEvent] = [],
 203        historyLoading: Bool = false,
 204        historyError: String? = nil,
 205        historyCreditStatus: UsageCreditStatus? = nil,
 206        onLoadHistory: (() -> Void)? = nil
 207    ) {
 208        _isCollapsed = isCollapsed
 209        self.rows = rows
 210        self.loading = loading
 211        self.error = error
 212        self.provenance = provenance
 213        self.confidence = confidence
 214        self.showsHistoryPlaceholder = showsHistoryPlaceholder
 215        self.history = history
 216        self.historyLoading = historyLoading
 217        self.historyError = historyError
 218        self.historyCreditStatus = historyCreditStatus
 219        self.onLoadHistory = onLoadHistory
 220    }
 221
 222    var body: some View {
 223        CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) {
 224            CardView(allowsHorizontalScroll: false) {
 225                SectionTrustMetadataView(provenance: provenance, confidence: confidence)
 226                if loading {
 227                    ProgressView("Fetching RDAP ownership…")
 228                        .appLoadingStyle()
 229                } else {
 230                    ForEach(rows) { row in
 231                        LabeledValueRow(row: row)
 232                    }
 233                    if let error, rows.allSatisfy({ $0.value == "Unavailable" }) {
 234                        MessageRowView(text: error, isError: error != "Unavailable")
 235                            .padding(.top, 4)
 236                    }
 237                    VStack(alignment: .leading, spacing: 8) {
 238                        HStack {
 239                            Text("History")
 240                                .font(appDensity.font(.caption))
 241                                .foregroundStyle(Color(.appTextSecondary))
 242                            Spacer()
 243                            if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
 244                                Button("Load") {
 245                                    onLoadHistory()
 246                                }
 247                                .buttonStyle(.bordered)
 248                                .font(appDensity.font(.caption2))
 249                            }
 250                        }
 251                        if historyLoading {
 252                            ProgressView("Loading history…")
 253                                .appLoadingStyle()
 254                        } else if !history.isEmpty {
 255                            ForEach(history) { event in
 256                                VStack(alignment: .leading, spacing: 3) {
 257                                    Text(event.date.formatted(date: .abbreviated, time: .omitted))
 258                                        .font(appDensity.font(.caption2))
 259                                        .foregroundStyle(Color(.appTextSecondary))
 260                                    Text(event.summary)
 261                                        .font(appDensity.font(.caption))
 262                                    Text(event.source)
 263                                        .font(appDensity.font(.caption2))
 264                                        .foregroundStyle(Color(.appTextSecondary))
 265                                }
 266                            }
 267                        } else if let historyError {
 268                            MessageRowView(text: historyError, isError: false)
 269                        } else if showsHistoryPlaceholder {
 270                            MessageRowView(text: "Ownership history available in Pro+", isError: false)
 271                        }
 272                    }
 273                }
 274            }
 275        }
 276    }
 277}
 278
 279struct IntelligenceSectionView: View {
 280    @Environment(\.appDensity) private var appDensity
 281    @Binding var isCollapsed: Bool
 282    let report: DomainReport
 283    let showsPlaceholder: Bool
 284
 285    var body: some View {
 286        CollapsibleSectionView(title: "Data+ Intelligence", isCollapsed: $isCollapsed) {
 287            CardView(allowsHorizontalScroll: false) {
 288                if showsPlaceholder {
 289                    MessageRowView(text: "Richer intelligence history, hosting analysis, and risk signals are available in Pro+", isError: false)
 290                } else {
 291                    if let provider = report.inferredProvider {
 292                        intelligenceBlock(title: "Infrastructure") {
 293                            LabeledValueRow(row: .init(label: "Provider", value: provider.name, tone: .primary))
 294                            if !provider.evidence.isEmpty {
 295                                MessageRowView(text: provider.evidence.joined(separator: ""), isError: false)
 296                            }
 297                            if !report.priorProviders.isEmpty {
 298                                LabeledValueRow(row: .init(label: "Prior", value: report.priorProviders.joined(separator: ", "), tone: .secondary))
 299                            }
 300                        }
 301                    }
 302                    if let classification = report.domainClassification {
 303                        intelligenceBlock(title: "Classification") {
 304                            LabeledValueRow(row: .init(label: "Purpose", value: classification.kind.title, tone: .primary))
 305                            MessageRowView(text: classification.reasons.joined(separator: ""), isError: false)
 306                        }
 307                    }
 308                    intelligenceBlock(title: "Risk Signals") {
 309                        if report.riskSignals.isEmpty {
 310                            MessageRowView(text: "No material historical risk signals detected", isError: false)
 311                        } else {
 312                            ForEach(report.riskSignals.prefix(4)) { signal in
 313                                VStack(alignment: .leading, spacing: 3) {
 314                                    Text(signal.title)
 315                                        .font(appDensity.font(.caption, weight: .semibold))
 316                                    Text(signal.detail)
 317                                        .font(appDensity.font(.caption2))
 318                                        .foregroundStyle(Color(.appTextSecondary))
 319                                }
 320                            }
 321                        }
 322                    }
 323                    intelligenceBlock(title: "Ownership History") {
 324                        if report.ownershipTransitions.isEmpty {
 325                            MessageRowView(text: "No ownership transitions observed locally", isError: false)
 326                        } else {
 327                            ForEach(report.ownershipTransitions.prefix(4)) { event in
 328                                intelligenceEventRow(date: event.date, title: event.summary)
 329                            }
 330                        }
 331                    }
 332                    intelligenceBlock(title: "Hosting History") {
 333                        if report.hostingTransitions.isEmpty {
 334                            MessageRowView(text: "No hosting transitions observed locally", isError: false)
 335                        } else {
 336                            ForEach(report.hostingTransitions.prefix(4)) { event in
 337                                intelligenceEventRow(date: event.date, title: event.summary)
 338                            }
 339                        }
 340                    }
 341                    intelligenceBlock(title: "Subdomain Intelligence") {
 342                        if report.subdomainHistory.isEmpty {
 343                            MessageRowView(text: "No subdomain history available", isError: false)
 344                        } else {
 345                            ForEach(report.subdomainHistory.prefix(5)) { item in
 346                                VStack(alignment: .leading, spacing: 3) {
 347                                    HStack {
 348                                        Text(item.hostname)
 349                                            .font(appDensity.font(.caption))
 350                                        Spacer()
 351                                        if item.isEphemeral {
 352                                            Text("Ephemeral")
 353                                                .font(appDensity.font(.caption2))
 354                                                .foregroundStyle(Color(.statusWarning))
 355                                        }
 356                                    }
 357                                    Text("First \(item.firstSeen.formatted(date: .abbreviated, time: .omitted)) • Last \(item.lastSeen.formatted(date: .abbreviated, time: .omitted)) • Seen \(item.recurrenceCount)x")
 358                                        .font(appDensity.font(.caption2))
 359                                        .foregroundStyle(Color(.appTextSecondary))
 360                                }
 361                            }
 362                        }
 363                    }
 364                    intelligenceBlock(title: "Timeline") {
 365                        if report.intelligenceTimeline.isEmpty {
 366                            MessageRowView(text: "No inferred intelligence events yet", isError: false)
 367                        } else {
 368                            ForEach(report.intelligenceTimeline.prefix(5)) { event in
 369                                intelligenceEventRow(date: event.date, title: "\(event.title): \(event.detail)")
 370                            }
 371                        }
 372                    }
 373                }
 374            }
 375        }
 376    }
 377
 378    @ViewBuilder
 379    private func intelligenceBlock<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
 380        VStack(alignment: .leading, spacing: 8) {
 381            Text(title)
 382                .font(appDensity.font(.subheadline, weight: .semibold))
 383                .foregroundStyle(Color(.statusInfo))
 384            content()
 385        }
 386    }
 387
 388    private func intelligenceEventRow(date: Date, title: String) -> some View {
 389        VStack(alignment: .leading, spacing: 3) {
 390            Text(date.formatted(date: .abbreviated, time: .omitted))
 391                .font(appDensity.font(.caption2))
 392                .foregroundStyle(Color(.appTextSecondary))
 393            Text(title)
 394                .font(appDensity.font(.caption))
 395        }
 396    }
 397}
 398
 399struct SubdomainsSectionView: View {
 400    @Environment(\.appDensity) private var appDensity
 401    @Binding var isCollapsed: Bool
 402    let rows: [SubdomainRowViewData]
 403    let groups: [SubdomainGroup]
 404    let loading: Bool
 405    let error: String?
 406    let provenance: SectionProvenance?
 407    let confidence: ConfidenceLevel?
 408    let showsExtendedPlaceholder: Bool
 409    let extendedCount: Int
 410    let extendedLoading: Bool
 411    let extendedError: String?
 412    let extendedCreditStatus: UsageCreditStatus?
 413    let onLoadExtended: (() -> Void)?
 414
 415    init(
 416        isCollapsed: Binding<Bool>,
 417        rows: [SubdomainRowViewData],
 418        groups: [SubdomainGroup],
 419        loading: Bool,
 420        error: String?,
 421        provenance: SectionProvenance?,
 422        confidence: ConfidenceLevel?,
 423        showsExtendedPlaceholder: Bool,
 424        extendedCount: Int = 0,
 425        extendedLoading: Bool = false,
 426        extendedError: String? = nil,
 427        extendedCreditStatus: UsageCreditStatus? = nil,
 428        onLoadExtended: (() -> Void)? = nil
 429    ) {
 430        _isCollapsed = isCollapsed
 431        self.rows = rows
 432        self.groups = groups
 433        self.loading = loading
 434        self.error = error
 435        self.provenance = provenance
 436        self.confidence = confidence
 437        self.showsExtendedPlaceholder = showsExtendedPlaceholder
 438        self.extendedCount = extendedCount
 439        self.extendedLoading = extendedLoading
 440        self.extendedError = extendedError
 441        self.extendedCreditStatus = extendedCreditStatus
 442        self.onLoadExtended = onLoadExtended
 443    }
 444
 445    var body: some View {
 446        CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") {
 447            CardView(allowsHorizontalScroll: false) {
 448                SectionTrustMetadataView(provenance: provenance, confidence: confidence)
 449                if loading {
 450                    ProgressView("Checking certificate transparency…")
 451                        .appLoadingStyle()
 452                } else if rows.isEmpty {
 453                    MessageRowView(text: error ?? "No passive subdomains found", isError: false)
 454                    if showsExtendedPlaceholder {
 455                        MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false)
 456                            .padding(.top, 4)
 457                    }
 458                } else {
 459                    if let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder {
 460                        Button("Load extended results") {
 461                            onLoadExtended()
 462                        }
 463                        .buttonStyle(.bordered)
 464                        .font(appDensity.font(.caption2))
 465                    }
 466                    if !groups.isEmpty {
 467                        Text("Groups")
 468                            .font(appDensity.font(.caption2))
 469                            .foregroundStyle(Color(.appTextSecondary))
 470                        ForEach(groups) { group in
 471                            HStack {
 472                                Text("\(group.label).*")
 473                                    .font(appDensity.font(.caption))
 474                                    .foregroundStyle(Color(.statusInfo))
 475                                Spacer()
 476                                Text("\(group.subdomains.count)")
 477                                    .font(appDensity.font(.caption2))
 478                                    .foregroundStyle(Color(.appTextSecondary))
 479                            }
 480                        }
 481                    }
 482                    ForEach(rows) { row in
 483                        HStack(spacing: 8) {
 484                            Text(row.hostname)
 485                                .font(appDensity.font(.caption))
 486                                .foregroundStyle(.primary)
 487                                .textSelection(.enabled)
 488                            Spacer()
 489                            if row.isInteresting {
 490                                Text("Interesting")
 491                                    .font(.system(.caption2, design: .monospaced))
 492                                    .foregroundStyle(Color(.statusWarning))
 493                                    .padding(.horizontal, 8)
 494                                    .padding(.vertical, 4)
 495                                    .background(Color(.statusWarningSurface))
 496                                    .clipShape(Capsule())
 497                            }
 498                        }
 499                    }
 500                    if extendedLoading {
 501                        ProgressView("Loading extended subdomains…")
 502                            .appLoadingStyle()
 503                            .padding(.top, 4)
 504                    } else if extendedCount > 0 {
 505                        MessageRowView(text: "\(extendedCount) extended results included", isError: false)
 506                            .padding(.top, 4)
 507                    } else if let extendedError {
 508                        MessageRowView(text: extendedError, isError: false)
 509                            .padding(.top, 4)
 510                    } else if showsExtendedPlaceholder {
 511                        MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false)
 512                            .padding(.top, 4)
 513                    }
 514                }
 515            }
 516        }
 517    }
 518}
 519
 520struct DNSSectionView: View {
 521    @Environment(\.appDensity) private var appDensity
 522    @Binding var isCollapsed: Bool
 523    let dnssecLabel: String?
 524    let patternSummary: DNSPatternSummary?
 525    let sections: [DNSRecordSectionViewData]
 526    let ptrMessage: SectionMessageViewData?
 527    let loading: Bool
 528    let dnsProvenance: SectionProvenance?
 529    let ptrProvenance: SectionProvenance?
 530    let sectionError: String?
 531    let history: [DNSHistoryEvent]
 532    let historyLoading: Bool
 533    let historyError: String?
 534    let showsHistoryPlaceholder: Bool
 535    let historyCreditStatus: UsageCreditStatus?
 536    let onLoadHistory: (() -> Void)?
 537
 538    init(
 539        isCollapsed: Binding<Bool>,
 540        dnssecLabel: String?,
 541        patternSummary: DNSPatternSummary?,
 542        sections: [DNSRecordSectionViewData],
 543        ptrMessage: SectionMessageViewData?,
 544        loading: Bool,
 545        dnsProvenance: SectionProvenance?,
 546        ptrProvenance: SectionProvenance?,
 547        sectionError: String?,
 548        history: [DNSHistoryEvent] = [],
 549        historyLoading: Bool = false,
 550        historyError: String? = nil,
 551        showsHistoryPlaceholder: Bool = false,
 552        historyCreditStatus: UsageCreditStatus? = nil,
 553        onLoadHistory: (() -> Void)? = nil
 554    ) {
 555        _isCollapsed = isCollapsed
 556        self.dnssecLabel = dnssecLabel
 557        self.patternSummary = patternSummary
 558        self.sections = sections
 559        self.ptrMessage = ptrMessage
 560        self.loading = loading
 561        self.dnsProvenance = dnsProvenance
 562        self.ptrProvenance = ptrProvenance
 563        self.sectionError = sectionError
 564        self.history = history
 565        self.historyLoading = historyLoading
 566        self.historyError = historyError
 567        self.showsHistoryPlaceholder = showsHistoryPlaceholder
 568        self.historyCreditStatus = historyCreditStatus
 569        self.onLoadHistory = onLoadHistory
 570    }
 571
 572    var body: some View {
 573        CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) {
 574            if loading {
 575                LoadingCardView(text: "Querying DNS…")
 576            } else if let sectionError, sections.isEmpty {
 577                MessageCardView(text: sectionError, isError: true)
 578            } else {
 579                if dnsProvenance != nil {
 580                    CardView(allowsHorizontalScroll: false) {
 581                        SectionTrustMetadataView(provenance: dnsProvenance, confidence: nil)
 582                        if let patternSummary {
 583                            if !patternSummary.providers.isEmpty {
 584                                MessageRowView(text: "Providers: \(patternSummary.providers.joined(separator: ", "))", isError: false)
 585                            }
 586                            if !patternSummary.patterns.isEmpty {
 587                                ForEach(Array(patternSummary.patterns.enumerated()), id: \.offset) { _, pattern in
 588                                    MessageRowView(text: pattern, isError: false)
 589                                }
 590                            }
 591                        }
 592                    }
 593                }
 594                ForEach(sections) { section in
 595                    CardView {
 596                        Text(section.title)
 597                            .font(.system(.subheadline, design: .monospaced))
 598                            .fontWeight(.semibold)
 599                            .foregroundStyle(Color(.statusInfo))
 600
 601                        if let message = section.message {
 602                            MessageRowView(text: message.text, isError: message.isError)
 603                        }
 604
 605                        ForEach(section.rows) { row in
 606                            LabeledValueRow(row: row)
 607                        }
 608
 609                        if let wildcardTitle = section.wildcardTitle {
 610                            Text(wildcardTitle)
 611                                .font(.system(.caption, design: .monospaced))
 612                                .foregroundStyle(Color(.appTextSecondary))
 613                                .padding(.top, 4)
 614                            ForEach(section.wildcardRows) { row in
 615                                LabeledValueRow(row: row)
 616                            }
 617                        }
 618                    }
 619                }
 620
 621                if let ptrMessage {
 622                    CardView {
 623                        Text("PTR")
 624                            .font(.system(.subheadline, design: .monospaced))
 625                            .fontWeight(.semibold)
 626                            .foregroundStyle(Color(.statusInfo))
 627                        SectionTrustMetadataView(provenance: ptrProvenance, confidence: nil)
 628                        MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError)
 629                    }
 630                }
 631
 632                CardView(allowsHorizontalScroll: false) {
 633                    HStack {
 634                        Text("History")
 635                            .font(appDensity.font(.subheadline, weight: .semibold))
 636                            .foregroundStyle(Color(.statusInfo))
 637                        Spacer()
 638                        if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
 639                            Button("Load") {
 640                                onLoadHistory()
 641                            }
 642                            .buttonStyle(.bordered)
 643                            .font(appDensity.font(.caption2))
 644                        }
 645                    }
 646                    if historyLoading {
 647                        ProgressView("Loading DNS history…")
 648                            .appLoadingStyle()
 649                    } else if !history.isEmpty {
 650                        ForEach(history) { event in
 651                            VStack(alignment: .leading, spacing: 3) {
 652                                Text(event.date.formatted(date: .abbreviated, time: .omitted))
 653                                    .font(appDensity.font(.caption2))
 654                                    .foregroundStyle(Color(.appTextSecondary))
 655                                Text(event.summary)
 656                                    .font(appDensity.font(.caption))
 657                                if !event.aRecords.isEmpty {
 658                                    Text("A: \(event.aRecords.joined(separator: ", "))")
 659                                        .font(appDensity.font(.caption2))
 660                                        .foregroundStyle(Color(.appTextSecondary))
 661                                }
 662                                if !event.nameservers.isEmpty {
 663                                    Text("NS: \(event.nameservers.joined(separator: ", "))")
 664                                        .font(appDensity.font(.caption2))
 665                                        .foregroundStyle(Color(.appTextSecondary))
 666                                }
 667                            }
 668                        }
 669                    } else if let historyError {
 670                        MessageRowView(text: historyError, isError: false)
 671                    } else if showsHistoryPlaceholder {
 672                        MessageRowView(text: "DNS history available in Pro+", isError: false)
 673                    }
 674                }
 675            }
 676        }
 677    }
 678}
 679
 680struct WebSectionView: View {
 681    @Environment(\.appDensity) private var appDensity
 682    @Binding var isCollapsed: Bool
 683    let certificateRows: [InfoRowViewData]
 684    let sslInfo: SSLCertificateInfo?
 685    let tlsSummary: WebResultSummary?
 686    let sslLoading: Bool
 687    let sslError: String?
 688    let tlsProvenance: SectionProvenance?
 689    let responseRows: [InfoRowViewData]
 690    let headers: [HTTPHeader]
 691    let headersLoading: Bool
 692    let headersError: String?
 693    let httpProvenance: SectionProvenance?
 694    let redirects: [RedirectHopViewData]
 695    let redirectLoading: Bool
 696    let redirectError: String?
 697    let redirectProvenance: SectionProvenance?
 698    let finalURL: String?
 699
 700    var body: some View {
 701        CollapsibleSectionView(title: "Web", isCollapsed: $isCollapsed) {
 702            CardView {
 703                HStack {
 704                    Text("TLS")
 705                        .font(appDensity.font(.subheadline, weight: .semibold))
 706                        .foregroundStyle(Color(.statusInfo))
 707                    Spacer()
 708                    if !sslLoading {
 709                        AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError))
 710                    }
 711                }
 712                SectionTrustMetadataView(provenance: tlsProvenance, confidence: nil)
 713                if !sslLoading, let tlsSummary {
 714                    LabeledValueRow(row: InfoRowViewData(label: "TLS Grade", value: tlsSummary.tlsGrade.rawValue, tone: tlsSummary.tlsGrade.tone))
 715                    ForEach(Array(tlsSummary.tlsHighlights.enumerated()), id: \.offset) { _, highlight in
 716                        MessageRowView(text: highlight, isError: isTLSHighlightError(highlight))
 717                    }
 718                }
 719                if sslLoading {
 720                    ProgressView("Checking certificate…")
 721                        .appLoadingStyle()
 722                } else if let sslError {
 723                    MessageRowView(text: sslError, isError: true)
 724                } else {
 725                    ForEach(certificateRows) { row in
 726                        LabeledValueRow(row: row)
 727                    }
 728                    if let sslInfo, !sslInfo.subjectAltNames.isEmpty {
 729                        Text("SANs")
 730                            .font(appDensity.font(.caption2))
 731                            .foregroundStyle(Color(.appTextSecondary))
 732                        ForEach(sslInfo.subjectAltNames, id: \.self) { san in
 733                            HStack(alignment: .top, spacing: 8) {
 734                                Text(san)
 735                                    .font(appDensity.font(.caption))
 736                                    .lineLimit(nil)
 737                                    .fixedSize(horizontal: false, vertical: true)
 738                                    .textSelection(.enabled)
 739                                Spacer()
 740                                AppCopyButton(value: san, label: "Copy certificate SAN")
 741                            }
 742                        }
 743                    }
 744                }
 745            }
 746
 747            CardView {
 748                Text("Headers")
 749                    .font(appDensity.font(.subheadline, weight: .semibold))
 750                    .foregroundStyle(Color(.statusInfo))
 751                SectionTrustMetadataView(provenance: httpProvenance, confidence: nil)
 752                if headersLoading {
 753                    ProgressView("Fetching headers…")
 754                        .appLoadingStyle()
 755                } else if let headersError {
 756                    MessageRowView(text: headersError, isError: true)
 757                } else {
 758                    ForEach(responseRows) { row in
 759                        LabeledValueRow(row: row)
 760                    }
 761                    if headers.isEmpty {
 762                        MessageRowView(text: "No HTTP headers returned", isError: false)
 763                    } else {
 764                        ForEach(headers) { header in
 765                            HStack(alignment: .top, spacing: 4) {
 766                                Text(header.name + ":")
 767                                    .font(appDensity.font(.caption))
 768                                    .foregroundStyle(header.isSecurityHeader ? Color(.statusWarning) : Color(.statusInfo))
 769                                Text(header.value)
 770                                    .font(appDensity.font(.caption))
 771                                    .foregroundStyle(.primary)
 772                                    .textSelection(.enabled)
 773                            }
 774                        }
 775                    }
 776                }
 777            }
 778
 779            CardView {
 780                HStack {
 781                    Text("Redirects")
 782                        .font(appDensity.font(.subheadline, weight: .semibold))
 783                        .foregroundStyle(Color(.statusInfo))
 784                    Spacer()
 785                    if let finalURL {
 786                        AppCopyButton(value: finalURL, label: "Copy redirect URL")
 787                    }
 788                }
 789                SectionTrustMetadataView(provenance: redirectProvenance, confidence: nil)
 790                if redirectLoading {
 791                    ProgressView("Tracing redirects…")
 792                        .appLoadingStyle()
 793                } else if let redirectError {
 794                    MessageRowView(text: redirectError, isError: true)
 795                } else if redirects.isEmpty {
 796                    MessageRowView(text: "No redirect data available", isError: false)
 797                } else {
 798                    if let finalURL {
 799                        LabeledValueRow(row: InfoRowViewData(label: "Final URL", value: finalURL, tone: .secondary))
 800                    }
 801                    ForEach(redirects) { redirect in
 802                        HStack(alignment: .top, spacing: 6) {
 803                            Text(redirect.stepLabel)
 804                                .font(appDensity.font(.caption))
 805                                .foregroundStyle(Color(.appTextSecondary))
 806                                .frame(width: 16, alignment: .trailing)
 807                            Text(redirect.statusCode)
 808                                .font(appDensity.font(.caption))
 809                                .foregroundStyle(Color(.statusInfo))
 810                                .frame(width: 36, alignment: .leading)
 811                            Text(redirect.url)
 812                                .font(appDensity.font(.caption))
 813                                .textSelection(.enabled)
 814                            AppCopyButton(value: redirect.url, label: "Copy redirect URL")
 815                            if redirect.isFinal {
 816                                Text("(final)")
 817                                    .font(appDensity.font(.caption2))
 818                                    .foregroundStyle(Color(.appTextSecondary))
 819                            }
 820                        }
 821                    }
 822                }
 823            }
 824        }
 825    }
 826
 827    private func isTLSHighlightError(_ highlight: String) -> Bool {
 828        let normalized = highlight.lowercased()
 829        if normalized.contains("no weak tls indicators were detected") {
 830            return false
 831        }
 832        return normalized.contains("expires")
 833            || normalized.contains("weak")
 834            || normalized.contains("tls 1.0")
 835            || normalized.contains("tls 1.1")
 836    }
 837}
 838
 839struct EmailSectionView: View {
 840    @Environment(\.appDensity) private var appDensity
 841    @Binding var isCollapsed: Bool
 842    let rows: [EmailRowViewData]
 843    let assessment: EmailSecuritySummary?
 844    let loading: Bool
 845    let provenance: SectionProvenance?
 846    let confidence: ConfidenceLevel?
 847    let error: String?
 848
 849    var body: some View {
 850        CollapsibleSectionView(title: "Email", isCollapsed: $isCollapsed) {
 851            CardView {
 852                SectionTrustMetadataView(provenance: provenance, confidence: confidence)
 853                HStack {
 854                    Spacer()
 855                    AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error))
 856                        .opacity(loading ? 0 : 1)
 857                }
 858                if let assessment, let grade = assessment.grade {
 859                    LabeledValueRow(row: InfoRowViewData(label: "Grade", value: grade.rawValue, tone: grade.tone))
 860                    if !assessment.reasons.isEmpty {
 861                        Text(assessment.reasons.joined(separator: " | "))
 862                            .font(appDensity.font(.caption2))
 863                            .foregroundStyle(Color(.appTextSecondary))
 864                    }
 865                }
 866                if loading {
 867                    ProgressView("Checking email records…")
 868                        .appLoadingStyle()
 869                } else if let error {
 870                    MessageRowView(text: error, isError: true)
 871                } else if rows.isEmpty {
 872                    MessageRowView(text: "No email security records found", isError: false)
 873                } else {
 874                    ForEach(rows) { row in
 875                        VStack(alignment: .leading, spacing: 4) {
 876                            HStack(spacing: 8) {
 877                                Text(row.label)
 878                                    .font(appDensity.font(.caption))
 879                                    .foregroundStyle(Color(.statusInfo))
 880                                    .frame(width: 76, alignment: .leading)
 881                                AppStatusBadgeView(model: emailRowBadge(row))
 882                            }
 883                            Text(row.detail)
 884                                .font(appDensity.font(.caption2))
 885                                .foregroundStyle(.primary)
 886                                .textSelection(.enabled)
 887                            if let auxiliaryDetail = row.auxiliaryDetail {
 888                                Text(auxiliaryDetail)
 889                                    .font(appDensity.font(.caption2))
 890                                    .foregroundStyle(Color(.appTextSecondary))
 891                            }
 892                        }
 893                    }
 894                }
 895            }
 896        }
 897    }
 898
 899    private func emailRowBadge(_ row: EmailRowViewData) -> AppStatusBadgeModel {
 900        switch row.statusTone {
 901        case .success:
 902            return .init(title: row.status, systemImage: "checkmark.shield.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
 903        case .warning:
 904            return .init(title: row.status, systemImage: "shield.lefthalf.filled", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
 905        case .failure:
 906            return .init(title: row.status, systemImage: "minus.circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
 907        case .primary, .secondary:
 908            return .init(title: row.status, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
 909        }
 910    }
 911}
 912
 913struct NetworkSectionView: View {
 914    @Environment(\.appDensity) private var appDensity
 915    @Binding var isCollapsed: Bool
 916    let reachabilityRows: [ReachabilityRowViewData]
 917    let reachabilityLoading: Bool
 918    let reachabilityError: String?
 919    let reachabilityProvenance: SectionProvenance?
 920    let locationRows: [InfoRowViewData]
 921    let geolocation: IPGeolocation?
 922    let geolocationLoading: Bool
 923    let geolocationError: String?
 924    let geolocationProvenance: SectionProvenance?
 925    let geolocationConfidence: ConfidenceLevel?
 926    let standardPortRows: [PortScanRowViewData]
 927    let customPortRows: [PortScanRowViewData]
 928    let portScanLoading: Bool
 929    let portScanError: String?
 930    let portScanProvenance: SectionProvenance?
 931    let customPortScanLoading: Bool
 932    let customPortScanError: String?
 933    let isCloudflareProxied: Bool
 934    @Binding var customPortsExpanded: Bool
 935    @Binding var customPortInput: String
 936    let onScanCustomPorts: () -> Void
 937
 938    var body: some View {
 939        CollapsibleSectionView(title: "Network", isCollapsed: $isCollapsed) {
 940            CardView {
 941                Text("Reachability")
 942                    .font(appDensity.font(.subheadline, weight: .semibold))
 943                    .foregroundStyle(Color(.statusInfo))
 944                SectionTrustMetadataView(provenance: reachabilityProvenance, confidence: nil)
 945                if reachabilityLoading {
 946                    ProgressView("Checking ports…")
 947                        .appLoadingStyle()
 948                } else if let reachabilityError {
 949                    MessageRowView(text: reachabilityError, isError: true)
 950                } else {
 951                    ForEach(reachabilityRows) { row in
 952                        HStack {
 953                            Text(row.portLabel)
 954                                .font(appDensity.font(.caption))
 955                            Spacer()
 956                            Text(row.latencyLabel)
 957                                .font(appDensity.font(.caption2))
 958                                .foregroundStyle(Color(.appTextSecondary))
 959                            AppStatusBadgeView(model: reachabilityBadge(row))
 960                        }
 961                    }
 962                }
 963            }
 964
 965            CardView(allowsHorizontalScroll: false) {
 966                Text("Location")
 967                    .font(appDensity.font(.subheadline, weight: .semibold))
 968                    .foregroundStyle(Color(.statusInfo))
 969                SectionTrustMetadataView(provenance: geolocationProvenance, confidence: geolocationConfidence)
 970                if geolocationLoading {
 971                    ProgressView("Looking up location…")
 972                        .appLoadingStyle()
 973                } else if let geolocationError, geolocation == nil {
 974                    MessageRowView(text: geolocationError, isError: geolocationError != "No A record available")
 975                } else if let geolocation {
 976                    ForEach(locationRows) { row in
 977                        LabeledValueRow(row: row)
 978                    }
 979                    if let latitude = geolocation.latitude, let longitude = geolocation.longitude {
 980                        let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
 981                        Map(initialPosition: .region(MKCoordinateRegion(
 982                            center: coordinate,
 983                            span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
 984                        ))) {
 985                            Marker(geolocation.ip, coordinate: coordinate)
 986                        }
 987                        .mapStyle(.standard)
 988                        .frame(maxWidth: .infinity)
 989                        .frame(height: 180)
 990                        .cornerRadius(8)
 991                    }
 992                } else {
 993                    MessageRowView(text: "No location data available", isError: false)
 994                }
 995            }
 996
 997            CardView(allowsHorizontalScroll: false) {
 998                Text("Port Scan")
 999                    .font(appDensity.font(.subheadline, weight: .semibold))
1000                    .foregroundStyle(Color(.statusInfo))
1001                SectionTrustMetadataView(provenance: portScanProvenance, confidence: nil)
1002
1003                if isCloudflareProxied {
1004                    Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.")
1005                        .font(appDensity.font(.caption2))
1006                        .foregroundStyle(Color(.statusWarning))
1007                        .fixedSize(horizontal: false, vertical: true)
1008                }
1009
1010                if portScanLoading {
1011                    ProgressView("Scanning ports…")
1012                        .appLoadingStyle()
1013                } else if let portScanError, standardPortRows.isEmpty {
1014                    MessageRowView(text: portScanError, isError: true)
1015                } else {
1016                    Text("Standard Ports")
1017                        .font(.system(.caption, design: .monospaced))
1018                        .foregroundStyle(Color(.appTextSecondary))
1019                    PortRowsView(rows: standardPortRows)
1020                }
1021
1022                DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) {
1023                    VStack(alignment: .leading, spacing: 10) {
1024                        TextField("8888, 9000, 27017", text: $customPortInput)
1025                            .font(appDensity.font(.caption))
1026                            .textInputAutocapitalization(.never)
1027                            .autocorrectionDisabled()
1028                            .keyboardType(.numberPad)
1029                            .padding(10)
1030                            .background(Color(.appSurface))
1031                            .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
1032
1033                        Button("Scan") {
1034                            AppHaptics.refresh()
1035                            onScanCustomPorts()
1036                        }
1037                        .buttonStyle(.borderedProminent)
1038                        .tint(Color(.accentFill))
1039                        .disabled(customPortScanLoading)
1040
1041                        if customPortScanLoading {
1042                            ProgressView("Scanning custom ports…")
1043                                .appLoadingStyle()
1044                        } else if let customPortScanError {
1045                            MessageRowView(text: customPortScanError, isError: true)
1046                        } else {
1047                            PortRowsView(rows: customPortRows)
1048                        }
1049                    }
1050                    .padding(.top, 8)
1051                }
1052                .font(.system(.caption, design: .monospaced))
1053                .tint(.secondary)
1054            }
1055        }
1056    }
1057
1058    private func reachabilityBadge(_ row: ReachabilityRowViewData) -> AppStatusBadgeModel {
1059        switch row.statusTone {
1060        case .success:
1061            return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
1062        case .warning:
1063            return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
1064        case .failure:
1065            return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface))
1066        case .primary, .secondary:
1067            return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
1068        }
1069    }
1070}
1071
1072struct PortRowsView: View {
1073    @Environment(\.appDensity) private var appDensity
1074    let rows: [PortScanRowViewData]
1075
1076    var body: some View {
1077        if rows.isEmpty {
1078            MessageRowView(text: "No results", isError: false)
1079        } else {
1080            ForEach(rows) { row in
1081                VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
1082                    HStack {
1083                        Text(row.portLabel)
1084                            .font(appDensity.font(.caption))
1085                            .frame(width: 52, alignment: .leading)
1086                        Text(row.service)
1087                            .font(appDensity.font(.caption))
1088                            .foregroundStyle(.primary)
1089                        Spacer()
1090                        if let durationLabel = row.durationLabel {
1091                            Text(durationLabel)
1092                                .font(appDensity.font(.caption2))
1093                                .foregroundStyle(Color(.appTextSecondary))
1094                        }
1095                        AppStatusBadgeView(model: portBadge(row))
1096                    }
1097                    if let banner = row.banner {
1098                        Text(banner)
1099                            .font(appDensity.font(.caption2))
1100                            .foregroundStyle(Color(.appTextSecondary))
1101                            .padding(.leading, 8)
1102                    }
1103                }
1104                .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
1105            }
1106        }
1107    }
1108
1109    private func portBadge(_ row: PortScanRowViewData) -> AppStatusBadgeModel {
1110        switch row.statusTone {
1111        case .success:
1112            return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
1113        case .warning:
1114            return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
1115        case .failure:
1116            return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface))
1117        case .primary, .secondary:
1118            return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
1119        }
1120    }
1121}