diff options
| author | Christian Cleberg <[email protected]> | 2026-07-25 00:22:34 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-25 00:23:45 -0500 |
| commit | 0571e751f8d6f2975b7ea9fdbd30f5b77c05e836 (patch) | |
| tree | 2f326746f5134d156823548e9788df6842cb5182 | |
| parent | 48999737a37ef16cad0d84e71fa1b229bb04903f (diff) | |
| download | domain-dig-0571e751f8d6f2975b7ea9fdbd30f5b77c05e836.tar.gz domain-dig-0571e751f8d6f2975b7ea9fdbd30f5b77c05e836.tar.bz2 domain-dig-0571e751f8d6f2975b7ea9fdbd30f5b77c05e836.zip | |
refactor: extract result section views into ResultSectionViews (v5 step 4, 7/n)
Seventh slice of the god-file decomposition; second ContentView.swift slice.
- Moves the nine result detail section views into ResultSectionViews.swift:
DomainSectionView, OwnershipSectionView, IntelligenceSectionView,
SubdomainsSectionView, DNSSectionView, WebSectionView, EmailSectionView,
NetworkSectionView, and PortRowsView. Pure move.
- Promotes the one-line appLoadingStyle() View helper private -> internal: the
moved section views and the staying LoadingCardView both use it. That is the
only non-deletion edit to ContentView.swift.
The shared primitives (CardView, SectionTitleView, LabeledValueRow,
ResultColors) stay in ContentView.swift and are reached cross-file.
ContentView.swift: 2738 -> 1626 lines (3881 at the start of the ContentView
pivot; -2255 total). App builds clean; unit suite 58/58. No project.pbxproj
change.
| -rw-r--r-- | DomainDig/ContentView.swift | 1115 | ||||
| -rw-r--r-- | DomainDig/ResultSectionViews.swift | 1121 |
2 files changed, 1122 insertions, 1114 deletions
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index 3b7e759..254bc99 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -1324,1119 +1324,6 @@ struct TrackedDomainDetailHeaderView: View { } } -struct DomainSectionView: View { - @Environment(\.appDensity) private var appDensity - @Binding var isCollapsed: Bool - let rows: [InfoRowViewData] - let suggestions: [DomainSuggestionViewData] - let showSuggestions: Bool - let availabilityLoading: Bool - let suggestionsLoading: Bool - let provenance: SectionProvenance? - let confidence: ConfidenceLevel? - let snapshotNote: String? - let trackedDomain: TrackedDomain? - let workflows: [DomainWorkflow] - let trackingLimitMessage: String? - let pricingLoading: Bool - let pricingError: String? - let showsPricingPlaceholder: Bool - let onTrack: () -> Void - let onTogglePinned: () -> Void - let onEditNote: (() -> Void)? - let onAddToWorkflow: (() -> Void)? - let onOpenWorkflow: ((DomainWorkflow) -> Void)? - let onRunWorkflow: ((DomainWorkflow) -> Void)? - - var body: some View { - CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) { - if let trackedDomain { - HStack(spacing: 8) { - // Icon-only: the header also carries Pin and Note, and the - // full "Tracked" pill compresses at larger text sizes. - // VoiceOver still hears the word via the label. - Image(systemName: "eye.fill") - .font(appDensity.font(.caption)) - .foregroundStyle(Color(.statusPositive)) - .padding(6) - .background(Color(.statusPositiveSurface), in: Circle()) - .fixedSize() - .accessibilityLabel("Tracked") - Button { - onTogglePinned() - } label: { - Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin") - } - .buttonStyle(.bordered) - .font(appDensity.font(.caption)) - .accessibilityLabel("Pin domain") - .accessibilityValue(trackedDomain.isPinned ? "Pinned" : "Not pinned") - .accessibilityAddTraits(trackedDomain.isPinned ? .isSelected : []) - if let onEditNote { - Button("Note") { - onEditNote() - } - .buttonStyle(.bordered) - .font(appDensity.font(.caption)) - // Never compress into a vertical letter column. - .fixedSize() - } - } - } else { - Button("Track") { - AppHaptics.track() - onTrack() - } - .buttonStyle(.bordered) - .font(appDensity.font(.caption)) - .fixedSize() - } - } content: { - CardView(allowsHorizontalScroll: false) { - SectionTrustMetadataView( - provenance: provenance, - confidence: confidence, - note: snapshotNote == nil ? nil : "Audit note present" - ) - ForEach(rows) { row in - LabeledValueRow(row: row) - } - if let trackedDomain { - TrackedDomainDetailHeaderView(trackedDomain: trackedDomain) - .padding(.top, 4) - } else if let trackingLimitMessage { - MessageRowView(text: trackingLimitMessage, isError: false) - .padding(.top, 4) - } - if let onAddToWorkflow { - Button { - onAddToWorkflow() - } label: { - Label("Add to workflow", systemImage: "plus.rectangle.on.folder") - .font(appDensity.font(.caption)) - } - .buttonStyle(.bordered) - .padding(.top, 4) - } - if !workflows.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("Part of workflow") - .font(appDensity.font(.caption)) - .foregroundStyle(Color(.appTextSecondary)) - - ForEach(workflows) { workflow in - HStack { - Text(workflow.name) - .font(appDensity.font(.caption)) - .foregroundStyle(.primary) - Spacer() - if let onOpenWorkflow { - Button("Open") { - onOpenWorkflow(workflow) - } - .buttonStyle(.bordered) - .font(appDensity.font(.caption2)) - } - if let onRunWorkflow { - Button("Run") { - onRunWorkflow(workflow) - } - .buttonStyle(.bordered) - .font(appDensity.font(.caption2)) - } - } - } - } - .padding(.top, 4) - } - if availabilityLoading { - ProgressView("Checking availability…") - .appLoadingStyle() - .padding(.top, 4) - } - if showSuggestions { - Text("Suggestions") - .font(appDensity.font(.caption)) - .foregroundStyle(Color(.appTextSecondary)) - .padding(.top, 4) - if suggestionsLoading { - ProgressView("Checking alternatives…") - .appLoadingStyle() - } else if suggestions.isEmpty { - MessageRowView(text: "No suggestions", isError: false) - } else { - ForEach(suggestions) { suggestion in - HStack { - Text(suggestion.domain) - .font(appDensity.font(.caption)) - .foregroundStyle(.primary) - .textSelection(.enabled) - Spacer() - AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.availabilityStatus)) - } - } - } - } - if pricingLoading { - ProgressView("Loading external pricing…") - .appLoadingStyle() - .padding(.top, 4) - } else if let pricingError { - MessageRowView(text: pricingError, isError: false) - .padding(.top, 4) - } else if showsPricingPlaceholder { - MessageRowView(text: "Pricing signals available in Pro+", isError: false) - .padding(.top, 4) - } - } - } - } -} - -struct OwnershipSectionView: View { - @Environment(\.appDensity) private var appDensity - @Binding var isCollapsed: Bool - let rows: [InfoRowViewData] - let loading: Bool - let error: String? - let provenance: SectionProvenance? - let confidence: ConfidenceLevel? - let showsHistoryPlaceholder: Bool - let history: [DomainOwnershipHistoryEvent] - let historyLoading: Bool - let historyError: String? - let historyCreditStatus: UsageCreditStatus? - let onLoadHistory: (() -> Void)? - - init( - isCollapsed: Binding<Bool>, - rows: [InfoRowViewData], - loading: Bool, - error: String?, - provenance: SectionProvenance?, - confidence: ConfidenceLevel?, - showsHistoryPlaceholder: Bool, - history: [DomainOwnershipHistoryEvent] = [], - historyLoading: Bool = false, - historyError: String? = nil, - historyCreditStatus: UsageCreditStatus? = nil, - onLoadHistory: (() -> Void)? = nil - ) { - _isCollapsed = isCollapsed - self.rows = rows - self.loading = loading - self.error = error - self.provenance = provenance - self.confidence = confidence - self.showsHistoryPlaceholder = showsHistoryPlaceholder - self.history = history - self.historyLoading = historyLoading - self.historyError = historyError - self.historyCreditStatus = historyCreditStatus - self.onLoadHistory = onLoadHistory - } - - var body: some View { - CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) { - CardView(allowsHorizontalScroll: false) { - SectionTrustMetadataView(provenance: provenance, confidence: confidence) - if loading { - ProgressView("Fetching RDAP ownership…") - .appLoadingStyle() - } else { - ForEach(rows) { row in - LabeledValueRow(row: row) - } - if let error, rows.allSatisfy({ $0.value == "Unavailable" }) { - MessageRowView(text: error, isError: error != "Unavailable") - .padding(.top, 4) - } - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("History") - .font(appDensity.font(.caption)) - .foregroundStyle(Color(.appTextSecondary)) - Spacer() - if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder { - Button("Load") { - onLoadHistory() - } - .buttonStyle(.bordered) - .font(appDensity.font(.caption2)) - } - } - if historyLoading { - ProgressView("Loading history…") - .appLoadingStyle() - } else if !history.isEmpty { - ForEach(history) { event in - VStack(alignment: .leading, spacing: 3) { - Text(event.date.formatted(date: .abbreviated, time: .omitted)) - .font(appDensity.font(.caption2)) - .foregroundStyle(Color(.appTextSecondary)) - Text(event.summary) - .font(appDensity.font(.caption)) - Text(event.source) - .font(appDensity.font(.caption2)) - .foregroundStyle(Color(.appTextSecondary)) - } - } - } else if let historyError { - MessageRowView(text: historyError, isError: false) - } else if showsHistoryPlaceholder { - MessageRowView(text: "Ownership history available in Pro+", isError: false) - } - } - } - } - } - } -} - -struct IntelligenceSectionView: View { - @Environment(\.appDensity) private var appDensity - @Binding var isCollapsed: Bool - let report: DomainReport - let showsPlaceholder: Bool - - var body: some View { - CollapsibleSectionView(title: "Data+ Intelligence", isCollapsed: $isCollapsed) { - CardView(allowsHorizontalScroll: false) { - if showsPlaceholder { - MessageRowView(text: "Richer intelligence history, hosting analysis, and risk signals are available in Pro+", isError: false) - } else { - if let provider = report.inferredProvider { - intelligenceBlock(title: "Infrastructure") { - LabeledValueRow(row: .init(label: "Provider", value: provider.name, tone: .primary)) - if !provider.evidence.isEmpty { - MessageRowView(text: provider.evidence.joined(separator: " • "), isError: false) - } - if !report.priorProviders.isEmpty { - LabeledValueRow(row: .init(label: "Prior", value: report.priorProviders.joined(separator: ", "), tone: .secondary)) - } - } - } - if let classification = report.domainClassification { - intelligenceBlock(title: "Classification") { - LabeledValueRow(row: .init(label: "Purpose", value: classification.kind.title, tone: .primary)) - MessageRowView(text: classification.reasons.joined(separator: " • "), isError: false) - } - } - intelligenceBlock(title: "Risk Signals") { - if report.riskSignals.isEmpty { - MessageRowView(text: "No material historical risk signals detected", isError: false) - } else { - ForEach(report.riskSignals.prefix(4)) { signal in - VStack(alignment: .leading, spacing: 3) { - Text(signal.title) - .font(appDensity.font(.caption, weight: .semibold)) - Text(signal.detail) - .font(appDensity.font(.caption2)) - .foregroundStyle(Color(.appTextSecondary)) - } - } - } - } - intelligenceBlock(title: "Ownership History") { - if report.ownershipTransitions.isEmpty { - MessageRowView(text: "No ownership transitions observed locally", isError: false) - } else { - ForEach(report.ownershipTransitions.prefix(4)) { event in - intelligenceEventRow(date: event.date, title: event.summary) - } - } - } - intelligenceBlock(title: "Hosting History") { - if report.hostingTransitions.isEmpty { - MessageRowView(text: "No hosting transitions observed locally", isError: false) - } else { - ForEach(report.hostingTransitions.prefix(4)) { event in - intelligenceEventRow(date: event.date, title: event.summary) - } - } - } - intelligenceBlock(title: "Subdomain Intelligence") { - if report.subdomainHistory.isEmpty { - MessageRowView(text: "No subdomain history available", isError: false) - } else { - ForEach(report.subdomainHistory.prefix(5)) { item in - VStack(alignment: .leading, spacing: 3) { - HStack { - Text(item.hostname) - .font(appDensity.font(.caption)) - Spacer() - if item.isEphemeral { - Text("Ephemeral") - .font(appDensity.font(.caption2)) - .foregroundStyle(Color(.statusWarning)) - } - } - Text("First \(item.firstSeen.formatted(date: .abbreviated, time: .omitted)) • Last \(item.lastSeen.formatted(date: .abbreviated, time: .omitted)) • Seen \(item.recurrenceCount)x") - .font(appDensity.font(.caption2)) - .foregroundStyle(Color(.appTextSecondary)) - } - } - } - } - intelligenceBlock(title: "Timeline") { - if report.intelligenceTimeline.isEmpty { - MessageRowView(text: "No inferred intelligence events yet", isError: false) - } else { - ForEach(report.intelligenceTimeline.prefix(5)) { event in - intelligenceEventRow(date: event.date, title: "\(event.title): \(event.detail)") - } - } - } - } - } - } - } - - @ViewBuilder - private func intelligenceBlock<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text(title) - .font(appDensity.font(.subheadline, weight: .semibold)) - .foregroundStyle(Color(.statusInfo)) - content() - } - } - - private func intelligenceEventRow(date: Date, title: String) -> some View { - VStack(alignment: .leading, spacing: 3) { - Text(date.formatted(date: .abbreviated, time: .omitted)) - .font(appDensity.font(.caption2)) - .foregroundStyle(Color(.appTextSecondary)) - Text(title) - .font(appDensity.font(.caption)) - } - } -} - -struct SubdomainsSectionView: View { - @Environment(\.appDensity) private var appDensity - @Binding var isCollapsed: Bool - let rows: [SubdomainRowViewData] - let groups: [SubdomainGroup] - let loading: Bool - let error: String? - let provenance: SectionProvenance? - let confidence: ConfidenceLevel? - let showsExtendedPlaceholder: Bool - let extendedCount: Int - let extendedLoading: Bool - let extendedError: String? - let extendedCreditStatus: UsageCreditStatus? - let onLoadExtended: (() -> Void)? - - init( - isCollapsed: Binding<Bool>, - rows: [SubdomainRowViewData], - groups: [SubdomainGroup], - loading: Bool, - error: String?, - provenance: SectionProvenance?, - confidence: ConfidenceLevel?, - showsExtendedPlaceholder: Bool, - extendedCount: Int = 0, - extendedLoading: Bool = false, - extendedError: String? = nil, - extendedCreditStatus: UsageCreditStatus? = nil, - onLoadExtended: (() -> Void)? = nil - ) { - _isCollapsed = isCollapsed - self.rows = rows - self.groups = groups - self.loading = loading - self.error = error - self.provenance = provenance - self.confidence = confidence - self.showsExtendedPlaceholder = showsExtendedPlaceholder - self.extendedCount = extendedCount - self.extendedLoading = extendedLoading - self.extendedError = extendedError - self.extendedCreditStatus = extendedCreditStatus - self.onLoadExtended = onLoadExtended - } - - var body: some View { - CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") { - CardView(allowsHorizontalScroll: false) { - SectionTrustMetadataView(provenance: provenance, confidence: confidence) - if loading { - ProgressView("Checking certificate transparency…") - .appLoadingStyle() - } else if rows.isEmpty { - MessageRowView(text: error ?? "No passive subdomains found", isError: false) - if showsExtendedPlaceholder { - MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false) - .padding(.top, 4) - } - } else { - if let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder { - Button("Load extended results") { - onLoadExtended() - } - .buttonStyle(.bordered) - .font(appDensity.font(.caption2)) - } - if !groups.isEmpty { - Text("Groups") - .font(appDensity.font(.caption2)) - .foregroundStyle(Color(.appTextSecondary)) - ForEach(groups) { group in - HStack { - Text("\(group.label).*") - .font(appDensity.font(.caption)) - .foregroundStyle(Color(.statusInfo)) - Spacer() - Text("\(group.subdomains.count)") - .font(appDensity.font(.caption2)) - .foregroundStyle(Color(.appTextSecondary)) - } - } - } - ForEach(rows) { row in - HStack(spacing: 8) { - Text(row.hostname) - .font(appDensity.font(.caption)) - .foregroundStyle(.primary) - .textSelection(.enabled) - Spacer() - if row.isInteresting { - Text("Interesting") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(Color(.statusWarning)) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color(.statusWarningSurface)) - .clipShape(Capsule()) - } - } - } - if extendedLoading { - ProgressView("Loading extended subdomains…") - .appLoadingStyle() - .padding(.top, 4) - } else if extendedCount > 0 { - MessageRowView(text: "\(extendedCount) extended results included", isError: false) - .padding(.top, 4) - } else if let extendedError { - MessageRowView(text: extendedError, isError: false) - .padding(.top, 4) - } else if showsExtendedPlaceholder { - MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false) - .padding(.top, 4) - } - } - } - } - } -} - -struct DNSSectionView: View { - @Environment(\.appDensity) private var appDensity - @Binding var isCollapsed: Bool - let dnssecLabel: String? - let patternSummary: DNSPatternSummary? - let sections: [DNSRecordSectionViewData] - let ptrMessage: SectionMessageViewData? - let loading: Bool - let dnsProvenance: SectionProvenance? - let ptrProvenance: SectionProvenance? - let sectionError: String? - let history: [DNSHistoryEvent] - let historyLoading: Bool - let historyError: String? - let showsHistoryPlaceholder: Bool - let historyCreditStatus: UsageCreditStatus? - let onLoadHistory: (() -> Void)? - - init( - isCollapsed: Binding<Bool>, - dnssecLabel: String?, - patternSummary: DNSPatternSummary?, - sections: [DNSRecordSectionViewData], - ptrMessage: SectionMessageViewData?, - loading: Bool, - dnsProvenance: SectionProvenance?, - ptrProvenance: SectionProvenance?, - sectionError: String?, - history: [DNSHistoryEvent] = [], - historyLoading: Bool = false, - historyError: String? = nil, - showsHistoryPlaceholder: Bool = false, - historyCreditStatus: UsageCreditStatus? = nil, - onLoadHistory: (() -> Void)? = nil - ) { - _isCollapsed = isCollapsed - self.dnssecLabel = dnssecLabel - self.patternSummary = patternSummary - self.sections = sections - self.ptrMessage = ptrMessage - self.loading = loading - self.dnsProvenance = dnsProvenance - self.ptrProvenance = ptrProvenance - self.sectionError = sectionError - self.history = history - self.historyLoading = historyLoading - self.historyError = historyError - self.showsHistoryPlaceholder = showsHistoryPlaceholder - self.historyCreditStatus = historyCreditStatus - self.onLoadHistory = onLoadHistory - } - - var body: some View { - CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) { - if loading { - LoadingCardView(text: "Querying DNS…") - } else if let sectionError, sections.isEmpty { - MessageCardView(text: sectionError, isError: true) - } else { - if dnsProvenance != nil { - CardView(allowsHorizontalScroll: false) { - SectionTrustMetadataView(provenance: dnsProvenance, confidence: nil) - if let patternSummary { - if !patternSummary.providers.isEmpty { - MessageRowView(text: "Providers: \(patternSummary.providers.joined(separator: ", "))", isError: false) - } - if !patternSummary.patterns.isEmpty { - ForEach(Array(patternSummary.patterns.enumerated()), id: \.offset) { _, pattern in - MessageRowView(text: pattern, isError: false) - } - } - } - } - } - ForEach(sections) { section in - CardView { - Text(section.title) - .font(.system(.subheadline, design: .monospaced)) - .fontWeight(.semibold) - .foregroundStyle(Color(.statusInfo)) - - if let message = section.message { - MessageRowView(text: message.text, isError: message.isError) - } - - ForEach(section.rows) { row in - LabeledValueRow(row: row) - } - - if let wildcardTitle = section.wildcardTitle { - Text(wildcardTitle) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(Color(.appTextSecondary)) - .padding(.top, 4) - ForEach(section.wildcardRows) { row in - LabeledValueRow(row: row) - } - } - } - } - - if let ptrMessage { - CardView { - Text("PTR") - .font(.system(.subheadline, design: .monospaced)) - .fontWeight(.semibold) - .foregroundStyle(Color(.statusInfo)) - SectionTrustMetadataView(provenance: ptrProvenance, confidence: nil) - MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError) - } - } - - CardView(allowsHorizontalScroll: false) { - HStack { - Text("History") - .font(appDensity.font(.subheadline, weight: .semibold)) - .foregroundStyle(Color(.statusInfo)) - Spacer() - if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder { - Button("Load") { - onLoadHistory() - } - .buttonStyle(.bordered) - .font(appDensity.font(.caption2)) - } - } - if historyLoading { - ProgressView("Loading DNS history…") - .appLoadingStyle() - } else if !history.isEmpty { - ForEach(history) { event in - VStack(alignment: .leading, spacing: 3) { - Text(event.date.formatted(date: .abbreviated, time: .omitted)) - .font(appDensity.font(.caption2)) - .foregroundStyle(Color(.appTextSecondary)) - Text(event.summary) - .font(appDensity.font(.caption)) - if !event.aRecords.isEmpty { - Text("A: \(event.aRecords.joined(separator: ", "))") - .font(appDensity.font(.caption2)) - .foregroundStyle(Color(.appTextSecondary)) - } - if !event.nameservers.isEmpty { - Text("NS: \(event.nameservers.joined(separator: ", "))") - .font(appDensity.font(.caption2)) - .foregroundStyle(Color(.appTextSecondary)) - } - } - } - } else if let historyError { - MessageRowView(text: historyError, isError: false) - } else if showsHistoryPlaceholder { - MessageRowView(text: "DNS history available in Pro+", isError: false) - } - } - } - } - } -} - -struct WebSectionView: View { - @Environment(\.appDensity) private var appDensity - @Binding var isCollapsed: Bool - let certificateRows: [InfoRowViewData] - let sslInfo: SSLCertificateInfo? - let tlsSummary: WebResultSummary? - let sslLoading: Bool - let sslError: String? - let tlsProvenance: SectionProvenance? - let responseRows: [InfoRowViewData] - let headers: [HTTPHeader] - let headersLoading: Bool - let headersError: String? - let httpProvenance: SectionProvenance? - let redirects: [RedirectHopViewData] - let redirectLoading: Bool - let redirectError: String? - let redirectProvenance: SectionProvenance? - let finalURL: String? - - var body: some View { - CollapsibleSectionView(title: "Web", isCollapsed: $isCollapsed) { - CardView { - HStack { - Text("TLS") - .font(appDensity.font(.subheadline, weight: .semibold)) - .foregroundStyle(Color(.statusInfo)) - Spacer() - if !sslLoading { - AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError)) - } - } - SectionTrustMetadataView(provenance: tlsProvenance, confidence: nil) - if !sslLoading, let tlsSummary { - LabeledValueRow(row: InfoRowViewData(label: "TLS Grade", value: tlsSummary.tlsGrade.rawValue, tone: tlsSummary.tlsGrade.tone)) - ForEach(Array(tlsSummary.tlsHighlights.enumerated()), id: \.offset) { _, highlight in - MessageRowView(text: highlight, isError: isTLSHighlightError(highlight)) - } - } - if sslLoading { - ProgressView("Checking certificate…") - .appLoadingStyle() - } else if let sslError { - MessageRowView(text: sslError, isError: true) - } else { - ForEach(certificateRows) { row in - LabeledValueRow(row: row) - } - if let sslInfo, !sslInfo.subjectAltNames.isEmpty { - Text("SANs") - .font(appDensity.font(.caption2)) - .foregroundStyle(Color(.appTextSecondary)) - ForEach(sslInfo.subjectAltNames, id: \.self) { san in - HStack(alignment: .top, spacing: 8) { - Text(san) - .font(appDensity.font(.caption)) - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .textSelection(.enabled) - Spacer() - AppCopyButton(value: san, label: "Copy certificate SAN") - } - } - } - } - } - - CardView { - Text("Headers") - .font(appDensity.font(.subheadline, weight: .semibold)) - .foregroundStyle(Color(.statusInfo)) - SectionTrustMetadataView(provenance: httpProvenance, confidence: nil) - if headersLoading { - ProgressView("Fetching headers…") - .appLoadingStyle() - } else if let headersError { - MessageRowView(text: headersError, isError: true) - } else { - ForEach(responseRows) { row in - LabeledValueRow(row: row) - } - if headers.isEmpty { - MessageRowView(text: "No HTTP headers returned", isError: false) - } else { - ForEach(headers) { header in - HStack(alignment: .top, spacing: 4) { - Text(header.name + ":") - .font(appDensity.font(.caption)) - .foregroundStyle(header.isSecurityHeader ? Color(.statusWarning) : Color(.statusInfo)) - Text(header.value) - .font(appDensity.font(.caption)) - .foregroundStyle(.primary) - .textSelection(.enabled) - } - } - } - } - } - - CardView { - HStack { - Text("Redirects") - .font(appDensity.font(.subheadline, weight: .semibold)) - .foregroundStyle(Color(.statusInfo)) - Spacer() - if let finalURL { - AppCopyButton(value: finalURL, label: "Copy redirect URL") - } - } - SectionTrustMetadataView(provenance: redirectProvenance, confidence: nil) - if redirectLoading { - ProgressView("Tracing redirects…") - .appLoadingStyle() - } else if let redirectError { - MessageRowView(text: redirectError, isError: true) - } else if redirects.isEmpty { - MessageRowView(text: "No redirect data available", isError: false) - } else { - if let finalURL { - LabeledValueRow(row: InfoRowViewData(label: "Final URL", value: finalURL, tone: .secondary)) - } - ForEach(redirects) { redirect in - HStack(alignment: .top, spacing: 6) { - Text(redirect.stepLabel) - .font(appDensity.font(.caption)) - .foregroundStyle(Color(.appTextSecondary)) - .frame(width: 16, alignment: .trailing) - Text(redirect.statusCode) - .font(appDensity.font(.caption)) - .foregroundStyle(Color(.statusInfo)) - .frame(width: 36, alignment: .leading) - Text(redirect.url) - .font(appDensity.font(.caption)) - .textSelection(.enabled) - AppCopyButton(value: redirect.url, label: "Copy redirect URL") - if redirect.isFinal { - Text("(final)") - .font(appDensity.font(.caption2)) - .foregroundStyle(Color(.appTextSecondary)) - } - } - } - } - } - } - } - - private func isTLSHighlightError(_ highlight: String) -> Bool { - let normalized = highlight.lowercased() - if normalized.contains("no weak tls indicators were detected") { - return false - } - return normalized.contains("expires") - || normalized.contains("weak") - || normalized.contains("tls 1.0") - || normalized.contains("tls 1.1") - } -} - -struct EmailSectionView: View { - @Environment(\.appDensity) private var appDensity - @Binding var isCollapsed: Bool - let rows: [EmailRowViewData] - let assessment: EmailSecuritySummary? - let loading: Bool - let provenance: SectionProvenance? - let confidence: ConfidenceLevel? - let error: String? - - var body: some View { - CollapsibleSectionView(title: "Email", isCollapsed: $isCollapsed) { - CardView { - SectionTrustMetadataView(provenance: provenance, confidence: confidence) - HStack { - Spacer() - AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error)) - .opacity(loading ? 0 : 1) - } - if let assessment, let grade = assessment.grade { - LabeledValueRow(row: InfoRowViewData(label: "Grade", value: grade.rawValue, tone: grade.tone)) - if !assessment.reasons.isEmpty { - Text(assessment.reasons.joined(separator: " | ")) - .font(appDensity.font(.caption2)) - .foregroundStyle(Color(.appTextSecondary)) - } - } - if loading { - ProgressView("Checking email records…") - .appLoadingStyle() - } else if let error { - MessageRowView(text: error, isError: true) - } else if rows.isEmpty { - MessageRowView(text: "No email security records found", isError: false) - } else { - ForEach(rows) { row in - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - Text(row.label) - .font(appDensity.font(.caption)) - .foregroundStyle(Color(.statusInfo)) - .frame(width: 76, alignment: .leading) - AppStatusBadgeView(model: emailRowBadge(row)) - } - Text(row.detail) - .font(appDensity.font(.caption2)) - .foregroundStyle(.primary) - .textSelection(.enabled) - if let auxiliaryDetail = row.auxiliaryDetail { - Text(auxiliaryDetail) - .font(appDensity.font(.caption2)) - .foregroundStyle(Color(.appTextSecondary)) - } - } - } - } - } - } - } - - private func emailRowBadge(_ row: EmailRowViewData) -> AppStatusBadgeModel { - switch row.statusTone { - case .success: - return .init(title: row.status, systemImage: "checkmark.shield.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface)) - case .warning: - return .init(title: row.status, systemImage: "shield.lefthalf.filled", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface)) - case .failure: - return .init(title: row.status, systemImage: "minus.circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated)) - case .primary, .secondary: - return .init(title: row.status, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated)) - } - } -} - -struct NetworkSectionView: View { - @Environment(\.appDensity) private var appDensity - @Binding var isCollapsed: Bool - let reachabilityRows: [ReachabilityRowViewData] - let reachabilityLoading: Bool - let reachabilityError: String? - let reachabilityProvenance: SectionProvenance? - let locationRows: [InfoRowViewData] - let geolocation: IPGeolocation? - let geolocationLoading: Bool - let geolocationError: String? - let geolocationProvenance: SectionProvenance? - let geolocationConfidence: ConfidenceLevel? - let standardPortRows: [PortScanRowViewData] - let customPortRows: [PortScanRowViewData] - let portScanLoading: Bool - let portScanError: String? - let portScanProvenance: SectionProvenance? - let customPortScanLoading: Bool - let customPortScanError: String? - let isCloudflareProxied: Bool - @Binding var customPortsExpanded: Bool - @Binding var customPortInput: String - let onScanCustomPorts: () -> Void - - var body: some View { - CollapsibleSectionView(title: "Network", isCollapsed: $isCollapsed) { - CardView { - Text("Reachability") - .font(appDensity.font(.subheadline, weight: .semibold)) - .foregroundStyle(Color(.statusInfo)) - SectionTrustMetadataView(provenance: reachabilityProvenance, confidence: nil) - if reachabilityLoading { - ProgressView("Checking ports…") - .appLoadingStyle() - } else if let reachabilityError { - MessageRowView(text: reachabilityError, isError: true) - } else { - ForEach(reachabilityRows) { row in - HStack { - Text(row.portLabel) - .font(appDensity.font(.caption)) - Spacer() - Text(row.latencyLabel) - .font(appDensity.font(.caption2)) - .foregroundStyle(Color(.appTextSecondary)) - AppStatusBadgeView(model: reachabilityBadge(row)) - } - } - } - } - - CardView(allowsHorizontalScroll: false) { - Text("Location") - .font(appDensity.font(.subheadline, weight: .semibold)) - .foregroundStyle(Color(.statusInfo)) - SectionTrustMetadataView(provenance: geolocationProvenance, confidence: geolocationConfidence) - if geolocationLoading { - ProgressView("Looking up location…") - .appLoadingStyle() - } else if let geolocationError, geolocation == nil { - MessageRowView(text: geolocationError, isError: geolocationError != "No A record available") - } else if let geolocation { - ForEach(locationRows) { row in - LabeledValueRow(row: row) - } - if let latitude = geolocation.latitude, let longitude = geolocation.longitude { - let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - Map(initialPosition: .region(MKCoordinateRegion( - center: coordinate, - span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1) - ))) { - Marker(geolocation.ip, coordinate: coordinate) - } - .mapStyle(.standard) - .frame(maxWidth: .infinity) - .frame(height: 180) - .cornerRadius(8) - } - } else { - MessageRowView(text: "No location data available", isError: false) - } - } - - CardView(allowsHorizontalScroll: false) { - Text("Port Scan") - .font(appDensity.font(.subheadline, weight: .semibold)) - .foregroundStyle(Color(.statusInfo)) - SectionTrustMetadataView(provenance: portScanProvenance, confidence: nil) - - if isCloudflareProxied { - Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.") - .font(appDensity.font(.caption2)) - .foregroundStyle(Color(.statusWarning)) - .fixedSize(horizontal: false, vertical: true) - } - - if portScanLoading { - ProgressView("Scanning ports…") - .appLoadingStyle() - } else if let portScanError, standardPortRows.isEmpty { - MessageRowView(text: portScanError, isError: true) - } else { - Text("Standard Ports") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(Color(.appTextSecondary)) - PortRowsView(rows: standardPortRows) - } - - DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) { - VStack(alignment: .leading, spacing: 10) { - TextField("8888, 9000, 27017", text: $customPortInput) - .font(appDensity.font(.caption)) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.numberPad) - .padding(10) - .background(Color(.appSurface)) - .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) - - Button("Scan") { - AppHaptics.refresh() - onScanCustomPorts() - } - .buttonStyle(.borderedProminent) - .tint(Color(.accentFill)) - .disabled(customPortScanLoading) - - if customPortScanLoading { - ProgressView("Scanning custom ports…") - .appLoadingStyle() - } else if let customPortScanError { - MessageRowView(text: customPortScanError, isError: true) - } else { - PortRowsView(rows: customPortRows) - } - } - .padding(.top, 8) - } - .font(.system(.caption, design: .monospaced)) - .tint(.secondary) - } - } - } - - private func reachabilityBadge(_ row: ReachabilityRowViewData) -> AppStatusBadgeModel { - switch row.statusTone { - case .success: - return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface)) - case .warning: - return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface)) - case .failure: - return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface)) - case .primary, .secondary: - return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated)) - } - } -} - -struct PortRowsView: View { - @Environment(\.appDensity) private var appDensity - let rows: [PortScanRowViewData] - - var body: some View { - if rows.isEmpty { - MessageRowView(text: "No results", isError: false) - } else { - ForEach(rows) { row in - VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) { - HStack { - Text(row.portLabel) - .font(appDensity.font(.caption)) - .frame(width: 52, alignment: .leading) - Text(row.service) - .font(appDensity.font(.caption)) - .foregroundStyle(.primary) - Spacer() - if let durationLabel = row.durationLabel { - Text(durationLabel) - .font(appDensity.font(.caption2)) - .foregroundStyle(Color(.appTextSecondary)) - } - AppStatusBadgeView(model: portBadge(row)) - } - if let banner = row.banner { - Text(banner) - .font(appDensity.font(.caption2)) - .foregroundStyle(Color(.appTextSecondary)) - .padding(.leading, 8) - } - } - .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading) - } - } - } - - private func portBadge(_ row: PortScanRowViewData) -> AppStatusBadgeModel { - switch row.statusTone { - case .success: - return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface)) - case .warning: - return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface)) - case .failure: - return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface)) - case .primary, .secondary: - return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated)) - } - } -} - struct SectionTitleView: View { @Environment(\.appDensity) private var appDensity let title: String @@ -2691,7 +1578,7 @@ extension DateFormatter { }() } -private extension View { +extension View { func appLoadingStyle() -> some View { font(.system(.caption, design: .monospaced)) } diff --git a/DomainDig/ResultSectionViews.swift b/DomainDig/ResultSectionViews.swift new file mode 100644 index 0000000..e27c4d5 --- /dev/null +++ b/DomainDig/ResultSectionViews.swift @@ -0,0 +1,1121 @@ +import MapKit +import SwiftUI + +// Result detail section views extracted from ContentView.swift — one per +// inspection domain (availability/DNS, ownership, intelligence, subdomains, +// DNS records, web, email, network, ports). Pure presentation over the +// view-model report data; the shared primitives (CardView, SectionTitleView, +// LabeledValueRow, ResultColors, appLoadingStyle) remain in ContentView.swift. + +struct DomainSectionView: View { + @Environment(\.appDensity) private var appDensity + @Binding var isCollapsed: Bool + let rows: [InfoRowViewData] + let suggestions: [DomainSuggestionViewData] + let showSuggestions: Bool + let availabilityLoading: Bool + let suggestionsLoading: Bool + let provenance: SectionProvenance? + let confidence: ConfidenceLevel? + let snapshotNote: String? + let trackedDomain: TrackedDomain? + let workflows: [DomainWorkflow] + let trackingLimitMessage: String? + let pricingLoading: Bool + let pricingError: String? + let showsPricingPlaceholder: Bool + let onTrack: () -> Void + let onTogglePinned: () -> Void + let onEditNote: (() -> Void)? + let onAddToWorkflow: (() -> Void)? + let onOpenWorkflow: ((DomainWorkflow) -> Void)? + let onRunWorkflow: ((DomainWorkflow) -> Void)? + + var body: some View { + CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) { + if let trackedDomain { + HStack(spacing: 8) { + // Icon-only: the header also carries Pin and Note, and the + // full "Tracked" pill compresses at larger text sizes. + // VoiceOver still hears the word via the label. + Image(systemName: "eye.fill") + .font(appDensity.font(.caption)) + .foregroundStyle(Color(.statusPositive)) + .padding(6) + .background(Color(.statusPositiveSurface), in: Circle()) + .fixedSize() + .accessibilityLabel("Tracked") + Button { + onTogglePinned() + } label: { + Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin") + } + .buttonStyle(.bordered) + .font(appDensity.font(.caption)) + .accessibilityLabel("Pin domain") + .accessibilityValue(trackedDomain.isPinned ? "Pinned" : "Not pinned") + .accessibilityAddTraits(trackedDomain.isPinned ? .isSelected : []) + if let onEditNote { + Button("Note") { + onEditNote() + } + .buttonStyle(.bordered) + .font(appDensity.font(.caption)) + // Never compress into a vertical letter column. + .fixedSize() + } + } + } else { + Button("Track") { + AppHaptics.track() + onTrack() + } + .buttonStyle(.bordered) + .font(appDensity.font(.caption)) + .fixedSize() + } + } content: { + CardView(allowsHorizontalScroll: false) { + SectionTrustMetadataView( + provenance: provenance, + confidence: confidence, + note: snapshotNote == nil ? nil : "Audit note present" + ) + ForEach(rows) { row in + LabeledValueRow(row: row) + } + if let trackedDomain { + TrackedDomainDetailHeaderView(trackedDomain: trackedDomain) + .padding(.top, 4) + } else if let trackingLimitMessage { + MessageRowView(text: trackingLimitMessage, isError: false) + .padding(.top, 4) + } + if let onAddToWorkflow { + Button { + onAddToWorkflow() + } label: { + Label("Add to workflow", systemImage: "plus.rectangle.on.folder") + .font(appDensity.font(.caption)) + } + .buttonStyle(.bordered) + .padding(.top, 4) + } + if !workflows.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Part of workflow") + .font(appDensity.font(.caption)) + .foregroundStyle(Color(.appTextSecondary)) + + ForEach(workflows) { workflow in + HStack { + Text(workflow.name) + .font(appDensity.font(.caption)) + .foregroundStyle(.primary) + Spacer() + if let onOpenWorkflow { + Button("Open") { + onOpenWorkflow(workflow) + } + .buttonStyle(.bordered) + .font(appDensity.font(.caption2)) + } + if let onRunWorkflow { + Button("Run") { + onRunWorkflow(workflow) + } + .buttonStyle(.bordered) + .font(appDensity.font(.caption2)) + } + } + } + } + .padding(.top, 4) + } + if availabilityLoading { + ProgressView("Checking availability…") + .appLoadingStyle() + .padding(.top, 4) + } + if showSuggestions { + Text("Suggestions") + .font(appDensity.font(.caption)) + .foregroundStyle(Color(.appTextSecondary)) + .padding(.top, 4) + if suggestionsLoading { + ProgressView("Checking alternatives…") + .appLoadingStyle() + } else if suggestions.isEmpty { + MessageRowView(text: "No suggestions", isError: false) + } else { + ForEach(suggestions) { suggestion in + HStack { + Text(suggestion.domain) + .font(appDensity.font(.caption)) + .foregroundStyle(.primary) + .textSelection(.enabled) + Spacer() + AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.availabilityStatus)) + } + } + } + } + if pricingLoading { + ProgressView("Loading external pricing…") + .appLoadingStyle() + .padding(.top, 4) + } else if let pricingError { + MessageRowView(text: pricingError, isError: false) + .padding(.top, 4) + } else if showsPricingPlaceholder { + MessageRowView(text: "Pricing signals available in Pro+", isError: false) + .padding(.top, 4) + } + } + } + } +} + +struct OwnershipSectionView: View { + @Environment(\.appDensity) private var appDensity + @Binding var isCollapsed: Bool + let rows: [InfoRowViewData] + let loading: Bool + let error: String? + let provenance: SectionProvenance? + let confidence: ConfidenceLevel? + let showsHistoryPlaceholder: Bool + let history: [DomainOwnershipHistoryEvent] + let historyLoading: Bool + let historyError: String? + let historyCreditStatus: UsageCreditStatus? + let onLoadHistory: (() -> Void)? + + init( + isCollapsed: Binding<Bool>, + rows: [InfoRowViewData], + loading: Bool, + error: String?, + provenance: SectionProvenance?, + confidence: ConfidenceLevel?, + showsHistoryPlaceholder: Bool, + history: [DomainOwnershipHistoryEvent] = [], + historyLoading: Bool = false, + historyError: String? = nil, + historyCreditStatus: UsageCreditStatus? = nil, + onLoadHistory: (() -> Void)? = nil + ) { + _isCollapsed = isCollapsed + self.rows = rows + self.loading = loading + self.error = error + self.provenance = provenance + self.confidence = confidence + self.showsHistoryPlaceholder = showsHistoryPlaceholder + self.history = history + self.historyLoading = historyLoading + self.historyError = historyError + self.historyCreditStatus = historyCreditStatus + self.onLoadHistory = onLoadHistory + } + + var body: some View { + CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) { + CardView(allowsHorizontalScroll: false) { + SectionTrustMetadataView(provenance: provenance, confidence: confidence) + if loading { + ProgressView("Fetching RDAP ownership…") + .appLoadingStyle() + } else { + ForEach(rows) { row in + LabeledValueRow(row: row) + } + if let error, rows.allSatisfy({ $0.value == "Unavailable" }) { + MessageRowView(text: error, isError: error != "Unavailable") + .padding(.top, 4) + } + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("History") + .font(appDensity.font(.caption)) + .foregroundStyle(Color(.appTextSecondary)) + Spacer() + if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder { + Button("Load") { + onLoadHistory() + } + .buttonStyle(.bordered) + .font(appDensity.font(.caption2)) + } + } + if historyLoading { + ProgressView("Loading history…") + .appLoadingStyle() + } else if !history.isEmpty { + ForEach(history) { event in + VStack(alignment: .leading, spacing: 3) { + Text(event.date.formatted(date: .abbreviated, time: .omitted)) + .font(appDensity.font(.caption2)) + .foregroundStyle(Color(.appTextSecondary)) + Text(event.summary) + .font(appDensity.font(.caption)) + Text(event.source) + .font(appDensity.font(.caption2)) + .foregroundStyle(Color(.appTextSecondary)) + } + } + } else if let historyError { + MessageRowView(text: historyError, isError: false) + } else if showsHistoryPlaceholder { + MessageRowView(text: "Ownership history available in Pro+", isError: false) + } + } + } + } + } + } +} + +struct IntelligenceSectionView: View { + @Environment(\.appDensity) private var appDensity + @Binding var isCollapsed: Bool + let report: DomainReport + let showsPlaceholder: Bool + + var body: some View { + CollapsibleSectionView(title: "Data+ Intelligence", isCollapsed: $isCollapsed) { + CardView(allowsHorizontalScroll: false) { + if showsPlaceholder { + MessageRowView(text: "Richer intelligence history, hosting analysis, and risk signals are available in Pro+", isError: false) + } else { + if let provider = report.inferredProvider { + intelligenceBlock(title: "Infrastructure") { + LabeledValueRow(row: .init(label: "Provider", value: provider.name, tone: .primary)) + if !provider.evidence.isEmpty { + MessageRowView(text: provider.evidence.joined(separator: " • "), isError: false) + } + if !report.priorProviders.isEmpty { + LabeledValueRow(row: .init(label: "Prior", value: report.priorProviders.joined(separator: ", "), tone: .secondary)) + } + } + } + if let classification = report.domainClassification { + intelligenceBlock(title: "Classification") { + LabeledValueRow(row: .init(label: "Purpose", value: classification.kind.title, tone: .primary)) + MessageRowView(text: classification.reasons.joined(separator: " • "), isError: false) + } + } + intelligenceBlock(title: "Risk Signals") { + if report.riskSignals.isEmpty { + MessageRowView(text: "No material historical risk signals detected", isError: false) + } else { + ForEach(report.riskSignals.prefix(4)) { signal in + VStack(alignment: .leading, spacing: 3) { + Text(signal.title) + .font(appDensity.font(.caption, weight: .semibold)) + Text(signal.detail) + .font(appDensity.font(.caption2)) + .foregroundStyle(Color(.appTextSecondary)) + } + } + } + } + intelligenceBlock(title: "Ownership History") { + if report.ownershipTransitions.isEmpty { + MessageRowView(text: "No ownership transitions observed locally", isError: false) + } else { + ForEach(report.ownershipTransitions.prefix(4)) { event in + intelligenceEventRow(date: event.date, title: event.summary) + } + } + } + intelligenceBlock(title: "Hosting History") { + if report.hostingTransitions.isEmpty { + MessageRowView(text: "No hosting transitions observed locally", isError: false) + } else { + ForEach(report.hostingTransitions.prefix(4)) { event in + intelligenceEventRow(date: event.date, title: event.summary) + } + } + } + intelligenceBlock(title: "Subdomain Intelligence") { + if report.subdomainHistory.isEmpty { + MessageRowView(text: "No subdomain history available", isError: false) + } else { + ForEach(report.subdomainHistory.prefix(5)) { item in + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(item.hostname) + .font(appDensity.font(.caption)) + Spacer() + if item.isEphemeral { + Text("Ephemeral") + .font(appDensity.font(.caption2)) + .foregroundStyle(Color(.statusWarning)) + } + } + Text("First \(item.firstSeen.formatted(date: .abbreviated, time: .omitted)) • Last \(item.lastSeen.formatted(date: .abbreviated, time: .omitted)) • Seen \(item.recurrenceCount)x") + .font(appDensity.font(.caption2)) + .foregroundStyle(Color(.appTextSecondary)) + } + } + } + } + intelligenceBlock(title: "Timeline") { + if report.intelligenceTimeline.isEmpty { + MessageRowView(text: "No inferred intelligence events yet", isError: false) + } else { + ForEach(report.intelligenceTimeline.prefix(5)) { event in + intelligenceEventRow(date: event.date, title: "\(event.title): \(event.detail)") + } + } + } + } + } + } + } + + @ViewBuilder + private func intelligenceBlock<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(appDensity.font(.subheadline, weight: .semibold)) + .foregroundStyle(Color(.statusInfo)) + content() + } + } + + private func intelligenceEventRow(date: Date, title: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(date.formatted(date: .abbreviated, time: .omitted)) + .font(appDensity.font(.caption2)) + .foregroundStyle(Color(.appTextSecondary)) + Text(title) + .font(appDensity.font(.caption)) + } + } +} + +struct SubdomainsSectionView: View { + @Environment(\.appDensity) private var appDensity + @Binding var isCollapsed: Bool + let rows: [SubdomainRowViewData] + let groups: [SubdomainGroup] + let loading: Bool + let error: String? + let provenance: SectionProvenance? + let confidence: ConfidenceLevel? + let showsExtendedPlaceholder: Bool + let extendedCount: Int + let extendedLoading: Bool + let extendedError: String? + let extendedCreditStatus: UsageCreditStatus? + let onLoadExtended: (() -> Void)? + + init( + isCollapsed: Binding<Bool>, + rows: [SubdomainRowViewData], + groups: [SubdomainGroup], + loading: Bool, + error: String?, + provenance: SectionProvenance?, + confidence: ConfidenceLevel?, + showsExtendedPlaceholder: Bool, + extendedCount: Int = 0, + extendedLoading: Bool = false, + extendedError: String? = nil, + extendedCreditStatus: UsageCreditStatus? = nil, + onLoadExtended: (() -> Void)? = nil + ) { + _isCollapsed = isCollapsed + self.rows = rows + self.groups = groups + self.loading = loading + self.error = error + self.provenance = provenance + self.confidence = confidence + self.showsExtendedPlaceholder = showsExtendedPlaceholder + self.extendedCount = extendedCount + self.extendedLoading = extendedLoading + self.extendedError = extendedError + self.extendedCreditStatus = extendedCreditStatus + self.onLoadExtended = onLoadExtended + } + + var body: some View { + CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") { + CardView(allowsHorizontalScroll: false) { + SectionTrustMetadataView(provenance: provenance, confidence: confidence) + if loading { + ProgressView("Checking certificate transparency…") + .appLoadingStyle() + } else if rows.isEmpty { + MessageRowView(text: error ?? "No passive subdomains found", isError: false) + if showsExtendedPlaceholder { + MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false) + .padding(.top, 4) + } + } else { + if let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder { + Button("Load extended results") { + onLoadExtended() + } + .buttonStyle(.bordered) + .font(appDensity.font(.caption2)) + } + if !groups.isEmpty { + Text("Groups") + .font(appDensity.font(.caption2)) + .foregroundStyle(Color(.appTextSecondary)) + ForEach(groups) { group in + HStack { + Text("\(group.label).*") + .font(appDensity.font(.caption)) + .foregroundStyle(Color(.statusInfo)) + Spacer() + Text("\(group.subdomains.count)") + .font(appDensity.font(.caption2)) + .foregroundStyle(Color(.appTextSecondary)) + } + } + } + ForEach(rows) { row in + HStack(spacing: 8) { + Text(row.hostname) + .font(appDensity.font(.caption)) + .foregroundStyle(.primary) + .textSelection(.enabled) + Spacer() + if row.isInteresting { + Text("Interesting") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(Color(.statusWarning)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(.statusWarningSurface)) + .clipShape(Capsule()) + } + } + } + if extendedLoading { + ProgressView("Loading extended subdomains…") + .appLoadingStyle() + .padding(.top, 4) + } else if extendedCount > 0 { + MessageRowView(text: "\(extendedCount) extended results included", isError: false) + .padding(.top, 4) + } else if let extendedError { + MessageRowView(text: extendedError, isError: false) + .padding(.top, 4) + } else if showsExtendedPlaceholder { + MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false) + .padding(.top, 4) + } + } + } + } + } +} + +struct DNSSectionView: View { + @Environment(\.appDensity) private var appDensity + @Binding var isCollapsed: Bool + let dnssecLabel: String? + let patternSummary: DNSPatternSummary? + let sections: [DNSRecordSectionViewData] + let ptrMessage: SectionMessageViewData? + let loading: Bool + let dnsProvenance: SectionProvenance? + let ptrProvenance: SectionProvenance? + let sectionError: String? + let history: [DNSHistoryEvent] + let historyLoading: Bool + let historyError: String? + let showsHistoryPlaceholder: Bool + let historyCreditStatus: UsageCreditStatus? + let onLoadHistory: (() -> Void)? + + init( + isCollapsed: Binding<Bool>, + dnssecLabel: String?, + patternSummary: DNSPatternSummary?, + sections: [DNSRecordSectionViewData], + ptrMessage: SectionMessageViewData?, + loading: Bool, + dnsProvenance: SectionProvenance?, + ptrProvenance: SectionProvenance?, + sectionError: String?, + history: [DNSHistoryEvent] = [], + historyLoading: Bool = false, + historyError: String? = nil, + showsHistoryPlaceholder: Bool = false, + historyCreditStatus: UsageCreditStatus? = nil, + onLoadHistory: (() -> Void)? = nil + ) { + _isCollapsed = isCollapsed + self.dnssecLabel = dnssecLabel + self.patternSummary = patternSummary + self.sections = sections + self.ptrMessage = ptrMessage + self.loading = loading + self.dnsProvenance = dnsProvenance + self.ptrProvenance = ptrProvenance + self.sectionError = sectionError + self.history = history + self.historyLoading = historyLoading + self.historyError = historyError + self.showsHistoryPlaceholder = showsHistoryPlaceholder + self.historyCreditStatus = historyCreditStatus + self.onLoadHistory = onLoadHistory + } + + var body: some View { + CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) { + if loading { + LoadingCardView(text: "Querying DNS…") + } else if let sectionError, sections.isEmpty { + MessageCardView(text: sectionError, isError: true) + } else { + if dnsProvenance != nil { + CardView(allowsHorizontalScroll: false) { + SectionTrustMetadataView(provenance: dnsProvenance, confidence: nil) + if let patternSummary { + if !patternSummary.providers.isEmpty { + MessageRowView(text: "Providers: \(patternSummary.providers.joined(separator: ", "))", isError: false) + } + if !patternSummary.patterns.isEmpty { + ForEach(Array(patternSummary.patterns.enumerated()), id: \.offset) { _, pattern in + MessageRowView(text: pattern, isError: false) + } + } + } + } + } + ForEach(sections) { section in + CardView { + Text(section.title) + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(Color(.statusInfo)) + + if let message = section.message { + MessageRowView(text: message.text, isError: message.isError) + } + + ForEach(section.rows) { row in + LabeledValueRow(row: row) + } + + if let wildcardTitle = section.wildcardTitle { + Text(wildcardTitle) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(Color(.appTextSecondary)) + .padding(.top, 4) + ForEach(section.wildcardRows) { row in + LabeledValueRow(row: row) + } + } + } + } + + if let ptrMessage { + CardView { + Text("PTR") + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(Color(.statusInfo)) + SectionTrustMetadataView(provenance: ptrProvenance, confidence: nil) + MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError) + } + } + + CardView(allowsHorizontalScroll: false) { + HStack { + Text("History") + .font(appDensity.font(.subheadline, weight: .semibold)) + .foregroundStyle(Color(.statusInfo)) + Spacer() + if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder { + Button("Load") { + onLoadHistory() + } + .buttonStyle(.bordered) + .font(appDensity.font(.caption2)) + } + } + if historyLoading { + ProgressView("Loading DNS history…") + .appLoadingStyle() + } else if !history.isEmpty { + ForEach(history) { event in + VStack(alignment: .leading, spacing: 3) { + Text(event.date.formatted(date: .abbreviated, time: .omitted)) + .font(appDensity.font(.caption2)) + .foregroundStyle(Color(.appTextSecondary)) + Text(event.summary) + .font(appDensity.font(.caption)) + if !event.aRecords.isEmpty { + Text("A: \(event.aRecords.joined(separator: ", "))") + .font(appDensity.font(.caption2)) + .foregroundStyle(Color(.appTextSecondary)) + } + if !event.nameservers.isEmpty { + Text("NS: \(event.nameservers.joined(separator: ", "))") + .font(appDensity.font(.caption2)) + .foregroundStyle(Color(.appTextSecondary)) + } + } + } + } else if let historyError { + MessageRowView(text: historyError, isError: false) + } else if showsHistoryPlaceholder { + MessageRowView(text: "DNS history available in Pro+", isError: false) + } + } + } + } + } +} + +struct WebSectionView: View { + @Environment(\.appDensity) private var appDensity + @Binding var isCollapsed: Bool + let certificateRows: [InfoRowViewData] + let sslInfo: SSLCertificateInfo? + let tlsSummary: WebResultSummary? + let sslLoading: Bool + let sslError: String? + let tlsProvenance: SectionProvenance? + let responseRows: [InfoRowViewData] + let headers: [HTTPHeader] + let headersLoading: Bool + let headersError: String? + let httpProvenance: SectionProvenance? + let redirects: [RedirectHopViewData] + let redirectLoading: Bool + let redirectError: String? + let redirectProvenance: SectionProvenance? + let finalURL: String? + + var body: some View { + CollapsibleSectionView(title: "Web", isCollapsed: $isCollapsed) { + CardView { + HStack { + Text("TLS") + .font(appDensity.font(.subheadline, weight: .semibold)) + .foregroundStyle(Color(.statusInfo)) + Spacer() + if !sslLoading { + AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError)) + } + } + SectionTrustMetadataView(provenance: tlsProvenance, confidence: nil) + if !sslLoading, let tlsSummary { + LabeledValueRow(row: InfoRowViewData(label: "TLS Grade", value: tlsSummary.tlsGrade.rawValue, tone: tlsSummary.tlsGrade.tone)) + ForEach(Array(tlsSummary.tlsHighlights.enumerated()), id: \.offset) { _, highlight in + MessageRowView(text: highlight, isError: isTLSHighlightError(highlight)) + } + } + if sslLoading { + ProgressView("Checking certificate…") + .appLoadingStyle() + } else if let sslError { + MessageRowView(text: sslError, isError: true) + } else { + ForEach(certificateRows) { row in + LabeledValueRow(row: row) + } + if let sslInfo, !sslInfo.subjectAltNames.isEmpty { + Text("SANs") + .font(appDensity.font(.caption2)) + .foregroundStyle(Color(.appTextSecondary)) + ForEach(sslInfo.subjectAltNames, id: \.self) { san in + HStack(alignment: .top, spacing: 8) { + Text(san) + .font(appDensity.font(.caption)) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + Spacer() + AppCopyButton(value: san, label: "Copy certificate SAN") + } + } + } + } + } + + CardView { + Text("Headers") + .font(appDensity.font(.subheadline, weight: .semibold)) + .foregroundStyle(Color(.statusInfo)) + SectionTrustMetadataView(provenance: httpProvenance, confidence: nil) + if headersLoading { + ProgressView("Fetching headers…") + .appLoadingStyle() + } else if let headersError { + MessageRowView(text: headersError, isError: true) + } else { + ForEach(responseRows) { row in + LabeledValueRow(row: row) + } + if headers.isEmpty { + MessageRowView(text: "No HTTP headers returned", isError: false) + } else { + ForEach(headers) { header in + HStack(alignment: .top, spacing: 4) { + Text(header.name + ":") + .font(appDensity.font(.caption)) + .foregroundStyle(header.isSecurityHeader ? Color(.statusWarning) : Color(.statusInfo)) + Text(header.value) + .font(appDensity.font(.caption)) + .foregroundStyle(.primary) + .textSelection(.enabled) + } + } + } + } + } + + CardView { + HStack { + Text("Redirects") + .font(appDensity.font(.subheadline, weight: .semibold)) + .foregroundStyle(Color(.statusInfo)) + Spacer() + if let finalURL { + AppCopyButton(value: finalURL, label: "Copy redirect URL") + } + } + SectionTrustMetadataView(provenance: redirectProvenance, confidence: nil) + if redirectLoading { + ProgressView("Tracing redirects…") + .appLoadingStyle() + } else if let redirectError { + MessageRowView(text: redirectError, isError: true) + } else if redirects.isEmpty { + MessageRowView(text: "No redirect data available", isError: false) + } else { + if let finalURL { + LabeledValueRow(row: InfoRowViewData(label: "Final URL", value: finalURL, tone: .secondary)) + } + ForEach(redirects) { redirect in + HStack(alignment: .top, spacing: 6) { + Text(redirect.stepLabel) + .font(appDensity.font(.caption)) + .foregroundStyle(Color(.appTextSecondary)) + .frame(width: 16, alignment: .trailing) + Text(redirect.statusCode) + .font(appDensity.font(.caption)) + .foregroundStyle(Color(.statusInfo)) + .frame(width: 36, alignment: .leading) + Text(redirect.url) + .font(appDensity.font(.caption)) + .textSelection(.enabled) + AppCopyButton(value: redirect.url, label: "Copy redirect URL") + if redirect.isFinal { + Text("(final)") + .font(appDensity.font(.caption2)) + .foregroundStyle(Color(.appTextSecondary)) + } + } + } + } + } + } + } + + private func isTLSHighlightError(_ highlight: String) -> Bool { + let normalized = highlight.lowercased() + if normalized.contains("no weak tls indicators were detected") { + return false + } + return normalized.contains("expires") + || normalized.contains("weak") + || normalized.contains("tls 1.0") + || normalized.contains("tls 1.1") + } +} + +struct EmailSectionView: View { + @Environment(\.appDensity) private var appDensity + @Binding var isCollapsed: Bool + let rows: [EmailRowViewData] + let assessment: EmailSecuritySummary? + let loading: Bool + let provenance: SectionProvenance? + let confidence: ConfidenceLevel? + let error: String? + + var body: some View { + CollapsibleSectionView(title: "Email", isCollapsed: $isCollapsed) { + CardView { + SectionTrustMetadataView(provenance: provenance, confidence: confidence) + HStack { + Spacer() + AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error)) + .opacity(loading ? 0 : 1) + } + if let assessment, let grade = assessment.grade { + LabeledValueRow(row: InfoRowViewData(label: "Grade", value: grade.rawValue, tone: grade.tone)) + if !assessment.reasons.isEmpty { + Text(assessment.reasons.joined(separator: " | ")) + .font(appDensity.font(.caption2)) + .foregroundStyle(Color(.appTextSecondary)) + } + } + if loading { + ProgressView("Checking email records…") + .appLoadingStyle() + } else if let error { + MessageRowView(text: error, isError: true) + } else if rows.isEmpty { + MessageRowView(text: "No email security records found", isError: false) + } else { + ForEach(rows) { row in + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(row.label) + .font(appDensity.font(.caption)) + .foregroundStyle(Color(.statusInfo)) + .frame(width: 76, alignment: .leading) + AppStatusBadgeView(model: emailRowBadge(row)) + } + Text(row.detail) + .font(appDensity.font(.caption2)) + .foregroundStyle(.primary) + .textSelection(.enabled) + if let auxiliaryDetail = row.auxiliaryDetail { + Text(auxiliaryDetail) + .font(appDensity.font(.caption2)) + .foregroundStyle(Color(.appTextSecondary)) + } + } + } + } + } + } + } + + private func emailRowBadge(_ row: EmailRowViewData) -> AppStatusBadgeModel { + switch row.statusTone { + case .success: + return .init(title: row.status, systemImage: "checkmark.shield.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface)) + case .warning: + return .init(title: row.status, systemImage: "shield.lefthalf.filled", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface)) + case .failure: + return .init(title: row.status, systemImage: "minus.circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated)) + case .primary, .secondary: + return .init(title: row.status, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated)) + } + } +} + +struct NetworkSectionView: View { + @Environment(\.appDensity) private var appDensity + @Binding var isCollapsed: Bool + let reachabilityRows: [ReachabilityRowViewData] + let reachabilityLoading: Bool + let reachabilityError: String? + let reachabilityProvenance: SectionProvenance? + let locationRows: [InfoRowViewData] + let geolocation: IPGeolocation? + let geolocationLoading: Bool + let geolocationError: String? + let geolocationProvenance: SectionProvenance? + let geolocationConfidence: ConfidenceLevel? + let standardPortRows: [PortScanRowViewData] + let customPortRows: [PortScanRowViewData] + let portScanLoading: Bool + let portScanError: String? + let portScanProvenance: SectionProvenance? + let customPortScanLoading: Bool + let customPortScanError: String? + let isCloudflareProxied: Bool + @Binding var customPortsExpanded: Bool + @Binding var customPortInput: String + let onScanCustomPorts: () -> Void + + var body: some View { + CollapsibleSectionView(title: "Network", isCollapsed: $isCollapsed) { + CardView { + Text("Reachability") + .font(appDensity.font(.subheadline, weight: .semibold)) + .foregroundStyle(Color(.statusInfo)) + SectionTrustMetadataView(provenance: reachabilityProvenance, confidence: nil) + if reachabilityLoading { + ProgressView("Checking ports…") + .appLoadingStyle() + } else if let reachabilityError { + MessageRowView(text: reachabilityError, isError: true) + } else { + ForEach(reachabilityRows) { row in + HStack { + Text(row.portLabel) + .font(appDensity.font(.caption)) + Spacer() + Text(row.latencyLabel) + .font(appDensity.font(.caption2)) + .foregroundStyle(Color(.appTextSecondary)) + AppStatusBadgeView(model: reachabilityBadge(row)) + } + } + } + } + + CardView(allowsHorizontalScroll: false) { + Text("Location") + .font(appDensity.font(.subheadline, weight: .semibold)) + .foregroundStyle(Color(.statusInfo)) + SectionTrustMetadataView(provenance: geolocationProvenance, confidence: geolocationConfidence) + if geolocationLoading { + ProgressView("Looking up location…") + .appLoadingStyle() + } else if let geolocationError, geolocation == nil { + MessageRowView(text: geolocationError, isError: geolocationError != "No A record available") + } else if let geolocation { + ForEach(locationRows) { row in + LabeledValueRow(row: row) + } + if let latitude = geolocation.latitude, let longitude = geolocation.longitude { + let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + Map(initialPosition: .region(MKCoordinateRegion( + center: coordinate, + span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1) + ))) { + Marker(geolocation.ip, coordinate: coordinate) + } + .mapStyle(.standard) + .frame(maxWidth: .infinity) + .frame(height: 180) + .cornerRadius(8) + } + } else { + MessageRowView(text: "No location data available", isError: false) + } + } + + CardView(allowsHorizontalScroll: false) { + Text("Port Scan") + .font(appDensity.font(.subheadline, weight: .semibold)) + .foregroundStyle(Color(.statusInfo)) + SectionTrustMetadataView(provenance: portScanProvenance, confidence: nil) + + if isCloudflareProxied { + Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.") + .font(appDensity.font(.caption2)) + .foregroundStyle(Color(.statusWarning)) + .fixedSize(horizontal: false, vertical: true) + } + + if portScanLoading { + ProgressView("Scanning ports…") + .appLoadingStyle() + } else if let portScanError, standardPortRows.isEmpty { + MessageRowView(text: portScanError, isError: true) + } else { + Text("Standard Ports") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(Color(.appTextSecondary)) + PortRowsView(rows: standardPortRows) + } + + DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) { + VStack(alignment: .leading, spacing: 10) { + TextField("8888, 9000, 27017", text: $customPortInput) + .font(appDensity.font(.caption)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.numberPad) + .padding(10) + .background(Color(.appSurface)) + .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) + + Button("Scan") { + AppHaptics.refresh() + onScanCustomPorts() + } + .buttonStyle(.borderedProminent) + .tint(Color(.accentFill)) + .disabled(customPortScanLoading) + + if customPortScanLoading { + ProgressView("Scanning custom ports…") + .appLoadingStyle() + } else if let customPortScanError { + MessageRowView(text: customPortScanError, isError: true) + } else { + PortRowsView(rows: customPortRows) + } + } + .padding(.top, 8) + } + .font(.system(.caption, design: .monospaced)) + .tint(.secondary) + } + } + } + + private func reachabilityBadge(_ row: ReachabilityRowViewData) -> AppStatusBadgeModel { + switch row.statusTone { + case .success: + return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface)) + case .warning: + return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface)) + case .failure: + return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface)) + case .primary, .secondary: + return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated)) + } + } +} + +struct PortRowsView: View { + @Environment(\.appDensity) private var appDensity + let rows: [PortScanRowViewData] + + var body: some View { + if rows.isEmpty { + MessageRowView(text: "No results", isError: false) + } else { + ForEach(rows) { row in + VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) { + HStack { + Text(row.portLabel) + .font(appDensity.font(.caption)) + .frame(width: 52, alignment: .leading) + Text(row.service) + .font(appDensity.font(.caption)) + .foregroundStyle(.primary) + Spacer() + if let durationLabel = row.durationLabel { + Text(durationLabel) + .font(appDensity.font(.caption2)) + .foregroundStyle(Color(.appTextSecondary)) + } + AppStatusBadgeView(model: portBadge(row)) + } + if let banner = row.banner { + Text(banner) + .font(appDensity.font(.caption2)) + .foregroundStyle(Color(.appTextSecondary)) + .padding(.leading, 8) + } + } + .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading) + } + } + } + + private func portBadge(_ row: PortScanRowViewData) -> AppStatusBadgeModel { + switch row.statusTone { + case .success: + return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface)) + case .warning: + return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface)) + case .failure: + return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface)) + case .primary, .secondary: + return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated)) + } + } +} |
