krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
main: DomainDig/ContentView.swift · raw
1import MapKit
2import SwiftUI
3import UniformTypeIdentifiers
4
5enum LookupInputMode: String, CaseIterable, Identifiable {
6 case single
7 case bulk
8
9 var id: String { rawValue }
10}
11
12enum ResultSection: String, Hashable {
13 case domain
14 case intelligence
15 case ownership
16 case dns
17 case web
18 case email
19 case network
20 case subdomains
21}
22
23private enum LookupInputField: Hashable {
24 case singleDomain
25 case bulkDomains
26}
27
28private struct WorkflowNavigationTarget: Hashable {
29 let workflowID: UUID
30}
31
32struct ContentView: View {
33 @Environment(\.appDensity) private var appDensity
34 @Bindable var viewModel: DomainViewModel
35 @State private var purchaseService = PurchaseService.shared
36 @State private var navigationPath = NavigationPath()
37 @FocusState private var focusedInputField: LookupInputField?
38 @State private var customPortInput = ""
39 @State private var customPortsExpanded = false
40 @State private var trackingNoteDraft = ""
41 @State private var editingTrackedDomain: TrackedDomain?
42 @State private var inputMode: LookupInputMode = .single
43 @State private var collapsedSections: Set<ResultSection> = [.network]
44 @State private var showingCurrentDomainWorkflowSheet = false
45 @State private var showingBatchWorkflowSheet = false
46 @State private var showingTimeline = false
47 @State private var showingAuditTimeline = false
48 @State private var auditStartInFlight = false
49
50 var body: some View {
51 let _ = purchaseService.currentTier
52
53 NavigationStack(path: $navigationPath) {
54 ScrollView(.vertical) {
55 VStack(spacing: 0) {
56 inputSection
57 if !viewModel.batchResults.isEmpty || viewModel.batchLookupRunning {
58 batchSection
59 .padding(.top, appDensity.metrics.cardSpacing)
60 }
61 if viewModel.hasRun {
62 actionButtons
63 if !viewModel.resultsLoaded {
64 LookupProgressOverviewView(steps: viewModel.activeLoadingLabels)
65 .padding(.top, appDensity.metrics.cardSpacing)
66 } else if let statusMessage = resultStatusMessage {
67 LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource)
68 .padding(.top, appDensity.metrics.cardSpacing)
69 }
70 if viewModel.resultsLoaded {
71 SummaryView(fields: viewModel.summaryFields)
72 .padding(.top, appDensity.metrics.cardSpacing)
73 if let report = viewModel.currentReport {
74 RiskSummaryCardView(report: report)
75 .padding(.top, appDensity.metrics.cardSpacing)
76 InsightsSummaryCardView(insights: report.insights)
77 .padding(.top, appDensity.metrics.cardSpacing)
78 }
79 if let changeSummary = viewModel.currentChangeSummary {
80 DomainChangeSummaryView(summary: changeSummary)
81 .padding(.top, appDensity.metrics.cardSpacing)
82 }
83 }
84 if let report = viewModel.currentReport {
85 intelligenceSection(report: report)
86 .padding(.top, appDensity.metrics.sectionSpacing)
87 }
88 domainOverviewSection
89 .padding(.top, appDensity.metrics.sectionSpacing)
90 ownershipSection
91 .padding(.top, appDensity.metrics.sectionSpacing)
92 subdomainsSection
93 .padding(.top, appDensity.metrics.sectionSpacing)
94 if !viewModel.currentDiffSections.isEmpty {
95 DomainDiffView(
96 title: "Latest Changes",
97 sections: viewModel.currentDiffSections,
98 contextNote: viewModel.currentChangeSummary?.contextNote,
99 showsUnchanged: false,
100 highlightedSectionID: nil
101 )
102 .padding(.top, appDensity.metrics.sectionSpacing)
103 }
104 dnsSection
105 .padding(.top, appDensity.metrics.sectionSpacing)
106 WebSectionView(
107 isCollapsed: sectionCollapsedBinding(.web),
108 certificateRows: viewModel.webCertificateRows,
109 sslInfo: viewModel.sslInfo,
110 tlsSummary: viewModel.currentTLSSummary,
111 sslLoading: viewModel.sslLoading || viewModel.hstsLoading,
112 sslError: viewModel.sslError,
113 tlsProvenance: viewModel.currentSnapshot.provenanceBySection[.ssl],
114 responseRows: viewModel.webResponseRows,
115 headers: viewModel.httpHeaders,
116 headersLoading: viewModel.httpHeadersLoading,
117 headersError: viewModel.httpHeadersError,
118 httpProvenance: viewModel.currentSnapshot.provenanceBySection[.httpHeaders],
119 redirects: viewModel.redirectRows,
120 redirectLoading: viewModel.redirectChainLoading,
121 redirectError: viewModel.redirectChainError,
122 redirectProvenance: viewModel.currentSnapshot.provenanceBySection[.redirectChain],
123 finalURL: viewModel.currentSnapshot.redirectChain.last?.url
124 )
125 .padding(.top, appDensity.metrics.sectionSpacing)
126 EmailSectionView(
127 isCollapsed: sectionCollapsedBinding(.email),
128 rows: viewModel.emailRows,
129 assessment: viewModel.currentEmailAssessment,
130 loading: viewModel.emailSecurityLoading,
131 provenance: viewModel.currentSnapshot.provenanceBySection[.emailSecurity],
132 confidence: viewModel.currentSnapshot.emailSecurityConfidence,
133 error: viewModel.emailSecurityError
134 )
135 .padding(.top, appDensity.metrics.sectionSpacing)
136 NetworkSectionView(
137 isCollapsed: sectionCollapsedBinding(.network),
138 reachabilityRows: viewModel.reachabilityRows,
139 reachabilityLoading: viewModel.reachabilityLoading,
140 reachabilityError: viewModel.reachabilityError,
141 reachabilityProvenance: viewModel.currentSnapshot.provenanceBySection[.reachability],
142 locationRows: viewModel.locationRows,
143 geolocation: viewModel.ipGeolocation,
144 geolocationLoading: viewModel.ipGeolocationLoading,
145 geolocationError: viewModel.ipGeolocationError,
146 geolocationProvenance: viewModel.currentSnapshot.provenanceBySection[.ipGeolocation],
147 geolocationConfidence: viewModel.currentSnapshot.geolocationConfidence,
148 standardPortRows: viewModel.standardPortRows,
149 customPortRows: viewModel.customPortRows,
150 portScanLoading: viewModel.portScanLoading,
151 portScanError: viewModel.portScanError,
152 portScanProvenance: viewModel.currentSnapshot.provenanceBySection[.portScan],
153 customPortScanLoading: viewModel.customPortScanLoading,
154 customPortScanError: viewModel.customPortScanError,
155 isCloudflareProxied: viewModel.isCloudflareProxied,
156 customPortsExpanded: $customPortsExpanded,
157 customPortInput: $customPortInput,
158 onScanCustomPorts: runCustomPortScan
159 )
160 .padding(.top, appDensity.metrics.sectionSpacing)
161 } else if !viewModel.recentSearches.isEmpty {
162 recentSearchesSection
163 }
164 }
165 .padding(.horizontal)
166 .padding(.bottom, 32)
167 }
168 .background(
169 LinearGradient(
170 colors: [Color(.appBackground), Color(.appSurface)],
171 startPoint: .top,
172 endPoint: .bottom
173 )
174 )
175 .navigationTitle("DomainDig")
176 .toolbar {
177 ToolbarItem(placement: .topBarTrailing) {
178 if viewModel.hasRun {
179 Button {
180 viewModel.reset()
181 } label: {
182 Image(systemName: "xmark.circle")
183 .foregroundStyle(Color(.appTextSecondary))
184 }
185 .accessibilityLabel("Clear results")
186 }
187 }
188 }
189 .navigationDestination(for: WorkflowNavigationTarget.self) { target in
190 WorkflowDetailView(viewModel: viewModel, workflowID: target.workflowID)
191 }
192 }
193 .task {
194 await viewModel.refreshUsageCredits()
195 }
196 .onChange(of: viewModel.searchedDomain) { _, _ in
197 collapsedSections = defaultCollapsedSections
198 }
199 .onChange(of: viewModel.resultsLoaded) { wasLoaded, isLoaded in
200 // Single-lookup completion has no single view-model moment
201 // (`resultsLoaded` is derived from many loading flags), so the
202 // announcement is posted from the view where the transition is
203 // observable. The batch path announces from the view model directly.
204 guard !wasLoaded, isLoaded, viewModel.hasRun else { return }
205 let summary = AppStatusFactory.availability(viewModel.availabilityResult?.status).title
206 AppAccessibility.announce("Lookup complete for \(viewModel.searchedDomain). \(summary).")
207 }
208 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
209 navigationPath = NavigationPath()
210 focusedInputField = nil
211 }
212 .onChange(of: inputMode) { _, newValue in
213 viewModel.clearPresentedResults()
214 focusedInputField = newValue == .single ? .singleDomain : .bulkDomains
215 }
216 .onChange(of: viewModel.domain) { _, newValue in
217 guard inputMode == .single else { return }
218 let normalized = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
219 guard normalized != viewModel.searchedDomain else { return }
220 guard viewModel.hasRun || !viewModel.batchResults.isEmpty else { return }
221 viewModel.clearPresentedResults()
222 }
223 .onChange(of: viewModel.bulkInput) { _, newValue in
224 guard inputMode == .bulk else { return }
225 let normalized = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
226 guard !normalized.isEmpty || viewModel.hasRun || !viewModel.batchResults.isEmpty else { return }
227 viewModel.clearPresentedResults()
228 }
229 .sheet(item: $editingTrackedDomain) { trackedDomain in
230 NavigationStack {
231 Form {
232 Section("Tracking Note") {
233 TextField("Optional note", text: $trackingNoteDraft, axis: .vertical)
234 .lineLimit(3...6)
235 .textInputAutocapitalization(.never)
236 .autocorrectionDisabled()
237 }
238 }
239 .navigationTitle(trackedDomain.domain)
240 .toolbar {
241 ToolbarItem(placement: .cancellationAction) {
242 Button("Cancel") {
243 editingTrackedDomain = nil
244 }
245 }
246 ToolbarItem(placement: .confirmationAction) {
247 Button("Save") {
248 viewModel.updateNote(trackingNoteDraft, for: trackedDomain)
249 editingTrackedDomain = nil
250 }
251 }
252 }
253 }
254 }
255 .sheet(isPresented: $showingCurrentDomainWorkflowSheet) {
256 WorkflowBulkAddSheet(
257 viewModel: viewModel,
258 title: "Add Domain to Workflow",
259 availableDomains: [viewModel.searchedDomain]
260 )
261 }
262 .sheet(isPresented: $showingBatchWorkflowSheet) {
263 WorkflowBulkAddSheet(
264 viewModel: viewModel,
265 title: "Add Batch Domains",
266 availableDomains: viewModel.batchResults.map(\.domain)
267 )
268 }
269 .sheet(item: manualBatchSummaryBinding) { summary in
270 BatchSweepSummaryView(viewModel: viewModel, summary: summary)
271 }
272 .sheet(isPresented: $showingTimeline) {
273 NavigationStack {
274 TimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
275 }
276 }
277 .sheet(isPresented: $showingAuditTimeline) {
278 NavigationStack {
279 AuditDomainTimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
280 }
281 }
282 }
283
284 private var manualBatchSummaryBinding: Binding<BatchSweepSummary?> {
285 Binding(
286 get: {
287 guard let summary = viewModel.latestBatchSweepSummary,
288 summary.source == .manual else {
289 return nil
290 }
291 return summary
292 },
293 set: { viewModel.latestBatchSweepSummary = $0 }
294 )
295 }
296
297 private var inputSection: some View {
298 VStack(spacing: appDensity.metrics.cardSpacing + 2) {
299 Picker("Mode", selection: $inputMode) {
300 Text("Single").tag(LookupInputMode.single)
301 Text("Bulk").tag(LookupInputMode.bulk)
302 }
303 .pickerStyle(.segmented)
304
305 if inputMode == .single {
306 TextField("e.g. cleberg.net", text: $viewModel.domain)
307 .font(appDensity.font(.title3, design: .monospaced))
308 .textInputAutocapitalization(.never)
309 .autocorrectionDisabled()
310 .keyboardType(.URL)
311 .padding(.horizontal, 12)
312 .padding(.vertical, appDensity.metrics.controlVerticalPadding)
313 .background(Color(.appSurfaceElevated))
314 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
315 .focused($focusedInputField, equals: .singleDomain)
316 .onSubmit {
317 focusedInputField = nil
318 viewModel.run()
319 }
320
321 Button {
322 focusedInputField = nil
323 viewModel.run()
324 } label: {
325 Text("Run")
326 .font(appDensity.font(.headline, design: .default, weight: .semibold))
327 .frame(maxWidth: .infinity)
328 .frame(minHeight: appDensity.metrics.controlMinHeight)
329 }
330 .buttonStyle(.borderedProminent)
331 .tint(Color(.accentFill))
332 .disabled(viewModel.trimmedDomain.isEmpty)
333 } else {
334 if FeatureAccessService.hasAccess(to: .batchOperations) {
335 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
336 Text("Paste domains separated by new lines or commas.")
337 .font(appDensity.font(.caption))
338 .foregroundStyle(Color(.appTextSecondary))
339
340 if let batchAllowanceSummary = FeatureAccessService.batchAllowanceSummary() {
341 Text(batchAllowanceSummary)
342 .font(appDensity.font(.caption2))
343 .foregroundStyle(Color(.appTextSecondary))
344 }
345
346 TextField(
347 "example.com\napple.com, openai.com",
348 text: $viewModel.bulkInput,
349 axis: .vertical
350 )
351 .font(appDensity.font(.body))
352 .textInputAutocapitalization(.never)
353 .autocorrectionDisabled()
354 .keyboardType(.URL)
355 .lineLimit(4...10)
356 .padding(.horizontal, 12)
357 .padding(.vertical, appDensity.metrics.controlVerticalPadding)
358 .background(Color(.appSurfaceElevated))
359 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
360 .focused($focusedInputField, equals: .bulkDomains)
361
362 Button {
363 focusedInputField = nil
364 viewModel.runBulkLookup()
365 } label: {
366 Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch")
367 .font(appDensity.font(.headline, design: .default, weight: .semibold))
368 .frame(maxWidth: .infinity)
369 .frame(minHeight: appDensity.metrics.controlMinHeight)
370 }
371 .buttonStyle(.borderedProminent)
372 .tint(Color(.accentFill))
373 .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning)
374 }
375 } else {
376 lockedFeatureCard(
377 title: "Batch Operations",
378 message: FeatureAccessService.upgradeMessage(for: .batchOperations)
379 )
380 }
381 }
382 }
383 .padding(.vertical, appDensity.metrics.sectionSpacing)
384 }
385
386 private var domainOverviewSection: some View {
387 let trackedDomain = viewModel.currentTrackedDomain
388 let workflows = viewModel.currentDomainWorkflows
389
390 return DomainSectionView(
391 isCollapsed: sectionCollapsedBinding(.domain),
392 rows: viewModel.domainRows,
393 suggestions: viewModel.suggestionRows,
394 showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading,
395 availabilityLoading: viewModel.availabilityLoading,
396 suggestionsLoading: viewModel.suggestionsLoading,
397 provenance: viewModel.currentSnapshot.provenanceBySection[.availability],
398 confidence: viewModel.currentSnapshot.availabilityConfidence,
399 snapshotNote: viewModel.currentSnapshot.note,
400 trackedDomain: trackedDomain,
401 workflows: workflows,
402 trackingLimitMessage: viewModel.trackingLimitMessage,
403 pricingLoading: viewModel.domainPricingLoading,
404 pricingError: viewModel.domainPricingError,
405 showsPricingPlaceholder: !DataAccessService.hasAccess(to: .domainPricing),
406 onTrack: {
407 _ = viewModel.trackCurrentDomain()
408 },
409 onTogglePinned: {
410 guard let trackedDomain else { return }
411 viewModel.togglePinned(for: trackedDomain)
412 },
413 onEditNote: {
414 guard let trackedDomain else { return }
415 trackingNoteDraft = trackedDomain.note ?? ""
416 editingTrackedDomain = trackedDomain
417 },
418 onAddToWorkflow: {
419 showingCurrentDomainWorkflowSheet = true
420 },
421 onOpenWorkflow: { workflow in
422 navigationPath.append(WorkflowNavigationTarget(workflowID: workflow.id))
423 },
424 onRunWorkflow: { workflow in
425 viewModel.rerunCurrentDomain(in: workflow)
426 }
427 )
428 }
429
430 private func intelligenceSection(report: DomainReport) -> some View {
431 IntelligenceSectionView(
432 isCollapsed: sectionCollapsedBinding(.intelligence),
433 report: report,
434 showsPlaceholder: FeatureAccessService.currentTier != .proPlus
435 )
436 }
437
438 private var ownershipSection: some View {
439 OwnershipSectionView(
440 isCollapsed: sectionCollapsedBinding(.ownership),
441 rows: viewModel.ownershipRows,
442 loading: viewModel.ownershipLoading,
443 error: viewModel.ownershipError,
444 provenance: viewModel.currentSnapshot.provenanceBySection[.ownership],
445 confidence: viewModel.currentSnapshot.ownershipConfidence,
446 showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory),
447 history: viewModel.ownershipHistory,
448 historyLoading: viewModel.ownershipHistoryLoading,
449 historyError: viewModel.ownershipHistoryError,
450 historyCreditStatus: viewModel.ownershipHistoryCreditStatus,
451 onLoadHistory: {
452 Task {
453 await viewModel.loadOwnershipHistory()
454 }
455 }
456 )
457 }
458
459 private var subdomainsSection: some View {
460 SubdomainsSectionView(
461 isCollapsed: sectionCollapsedBinding(.subdomains),
462 rows: viewModel.subdomainRows,
463 groups: viewModel.currentSubdomainGroups,
464 loading: viewModel.subdomainsLoading,
465 error: viewModel.subdomainsError,
466 provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains],
467 confidence: viewModel.currentSnapshot.subdomainConfidence,
468 showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains),
469 extendedCount: viewModel.extendedSubdomains.count,
470 extendedLoading: viewModel.extendedSubdomainsLoading,
471 extendedError: viewModel.extendedSubdomainsError,
472 extendedCreditStatus: viewModel.extendedSubdomainsCreditStatus,
473 onLoadExtended: {
474 Task {
475 await viewModel.loadExtendedSubdomains()
476 }
477 }
478 )
479 }
480
481 private var dnsSection: some View {
482 DNSSectionView(
483 isCollapsed: sectionCollapsedBinding(.dns),
484 dnssecLabel: viewModel.dnssecLabel,
485 patternSummary: viewModel.currentDNSPatterns,
486 sections: viewModel.dnsRows,
487 ptrMessage: viewModel.ptrMessage,
488 loading: viewModel.dnsLoading || viewModel.ptrLoading,
489 dnsProvenance: viewModel.currentSnapshot.provenanceBySection[.dns],
490 ptrProvenance: viewModel.currentSnapshot.provenanceBySection[.ptr],
491 sectionError: viewModel.dnsError,
492 history: viewModel.dnsHistory,
493 historyLoading: viewModel.dnsHistoryLoading,
494 historyError: viewModel.dnsHistoryError,
495 showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .dnsHistory),
496 historyCreditStatus: viewModel.dnsHistoryCreditStatus,
497 onLoadHistory: {
498 Task {
499 await viewModel.loadDNSHistory()
500 }
501 }
502 )
503 }
504
505 private var actionButtons: some View {
506 HStack {
507 Spacer()
508 if viewModel.resultsLoaded {
509 Menu {
510 if !viewModel.isCurrentDomainTracked {
511 Button("Track this domain") {
512 _ = viewModel.trackCurrentDomain()
513 }
514 }
515 Button("Add to workflow") {
516 showingCurrentDomainWorkflowSheet = true
517 }
518 Button(auditStartInFlight ? "Starting audit…" : "Start audit") {
519 Task {
520 auditStartInFlight = true
521 if await viewModel.startAudit(for: viewModel.searchedDomain) != nil {
522 showingAuditTimeline = true
523 }
524 auditStartInFlight = false
525 }
526 }
527 .disabled(auditStartInFlight)
528 if !viewModel.audits(for: viewModel.searchedDomain).isEmpty {
529 Button("View audits") {
530 showingAuditTimeline = true
531 }
532 }
533 if !viewModel.historyEntries(for: viewModel.searchedDomain).isEmpty {
534 Button("Open timeline") {
535 showingTimeline = true
536 }
537 }
538 if FeatureAccessService.hasAccess(to: .advancedExports) {
539 Button("Copy report JSON") {
540 guard let json = viewModel.exportJSONString() else { return }
541 AppClipboard.copy(json)
542 AppHaptics.copy()
543 }
544 } else {
545 Button("Copy report JSON") {
546 viewModel.upgradePrompt = FeatureAccessService.upgradePrompt(for: .advancedExports)
547 }
548 }
549 Button("Export report") {
550 shareSingleResults(format: .text)
551 }
552 } label: {
553 Image(systemName: "bolt.circle")
554 .font(appDensity.font(.body, design: .default))
555 .foregroundStyle(Color(.appTextSecondary))
556 }
557 .accessibilityLabel("Actions")
558 Button {
559 viewModel.toggleSavedDomain()
560 } label: {
561 Image(systemName: viewModel.isCurrentDomainSaved ? "bookmark.fill" : "bookmark")
562 .font(appDensity.font(.body, design: .default))
563 .foregroundStyle(viewModel.isCurrentDomainSaved ? Color(.statusWarning) : .secondary)
564 }
565 .accessibilityLabel("Save domain")
566 .accessibilityValue(viewModel.isCurrentDomainSaved ? "Saved" : "Not saved")
567 .accessibilityAddTraits(viewModel.isCurrentDomainSaved ? .isSelected : [])
568 Menu {
569 Button("Export TXT") {
570 shareSingleResults(format: .text)
571 }
572 if FeatureAccessService.hasAccess(to: .advancedExports) {
573 Button("Export CSV") {
574 shareSingleResults(format: .csv)
575 }
576 Button("Export JSON") {
577 shareSingleResults(format: .json)
578 }
579 Button("Export Markdown") {
580 shareSingleResults(format: .markdown)
581 }
582 Button("Export PDF") {
583 shareSingleResults(format: .pdf)
584 }
585 } else {
586 Button("CSV Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
587 .disabled(true)
588 Button("JSON Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
589 .disabled(true)
590 Button("Markdown Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
591 .disabled(true)
592 Button("PDF Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
593 .disabled(true)
594 }
595 } label: {
596 Image(systemName: "square.and.arrow.up")
597 .font(appDensity.font(.body, design: .default))
598 .foregroundStyle(Color(.appTextSecondary))
599 }
600 .accessibilityLabel("Export")
601 }
602 }
603 }
604
605 private var batchSection: some View {
606 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
607 HStack {
608 Spacer()
609 if viewModel.batchLookupRunning {
610 Button("Cancel") {
611 viewModel.cancelBatchLookup()
612 }
613 .buttonStyle(.bordered)
614 .font(appDensity.font(.caption))
615 }
616 if !viewModel.currentBatchResultEntries.isEmpty {
617 Menu {
618 Button("Add to Workflow") {
619 showingBatchWorkflowSheet = true
620 }
621 Divider()
622 Button("Export Batch TXT") {
623 shareBatchResults(format: .text)
624 }
625 if FeatureAccessService.hasAccess(to: .advancedExports) {
626 Button("Export Batch CSV") {
627 shareBatchResults(format: .csv)
628 }
629 Button("Export Batch JSON") {
630 shareBatchResults(format: .json)
631 }
632 Button("Export Batch Markdown") {
633 shareBatchResults(format: .markdown)
634 }
635 Button("Export Batch PDF") {
636 shareBatchResults(format: .pdf)
637 }
638 } else {
639 Button("Batch CSV • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
640 .disabled(true)
641 Button("Batch JSON • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
642 .disabled(true)
643 Button("Batch Markdown • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
644 .disabled(true)
645 Button("Batch PDF • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
646 .disabled(true)
647 }
648 } label: {
649 Label("Export", systemImage: "square.and.arrow.up")
650 .font(appDensity.font(.caption))
651 }
652 .buttonStyle(.bordered)
653 }
654 }
655
656 BatchResultsView(
657 viewModel: viewModel,
658 title: viewModel.batchLookupSource == .watchlistRefresh ? "Tracked Domain Refresh" : "Batch Results"
659 )
660 }
661 }
662
663 private var recentSearchesSection: some View {
664 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
665 HStack {
666 Text("RECENT")
667 .font(appDensity.font(.caption2))
668 .foregroundStyle(Color(.appTextSecondary))
669 Spacer()
670 Button("Clear") {
671 viewModel.clearRecentSearches()
672 }
673 .font(appDensity.font(.caption2))
674 .foregroundStyle(Color(.appTextSecondary))
675 }
676
677 ForEach(viewModel.recentSearches, id: \.self) { domain in
678 Button {
679 viewModel.domain = domain
680 focusedInputField = nil
681 viewModel.run()
682 } label: {
683 Text(domain)
684 .font(appDensity.font(.callout))
685 .foregroundStyle(.primary)
686 .frame(maxWidth: .infinity, alignment: .leading)
687 .padding(.vertical, 8)
688 .padding(.horizontal, 10)
689 .background(Color(.appSurface))
690 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
691 }
692 }
693 }
694 .padding(.top, appDensity.metrics.cardSpacing)
695 }
696
697 private func runCustomPortScan() {
698 let ports = parsedCustomPorts(from: customPortInput)
699 Task {
700 await viewModel.runCustomPortScan(ports: ports)
701 }
702 }
703
704 private func lockedFeatureCard(title: String, message: String) -> some View {
705 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
706 Text(title)
707 .font(appDensity.font(.headline, design: .default, weight: .semibold))
708 Text(message)
709 .font(appDensity.font(.callout, design: .default))
710 .foregroundStyle(Color(.appTextSecondary))
711 }
712 .frame(maxWidth: .infinity, alignment: .leading)
713 .padding(appDensity.metrics.cardPadding)
714 .background(Color(.appSurfaceElevated))
715 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
716 }
717
718 private func parsedCustomPorts(from input: String) -> [UInt16] {
719 let parts = input.split(separator: ",", omittingEmptySubsequences: true)
720 var seen = Set<UInt16>()
721 var ports: [UInt16] = []
722
723 for part in parts {
724 let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
725 guard let value = UInt16(trimmed), seen.insert(value).inserted else {
726 continue
727 }
728 ports.append(value)
729 if ports.count == 20 {
730 break
731 }
732 }
733
734 return ports
735 }
736
737 private func shareSingleResults(format: DomainExportFormat) {
738 guard let data = viewModel.exportSingleReportData(format: format) else { return }
739 ExportPresenter.share(filename: exportFilename(prefix: "domaindig_single", format: format), data: data)
740 }
741
742 private func shareBatchResults(format: DomainExportFormat) {
743 guard let data = viewModel.exportBatchReportData(format: format) else { return }
744 ExportPresenter.share(filename: exportFilename(prefix: "domaindig_batch", format: format), data: data)
745 }
746
747 private func exportFilename(prefix: String, format: DomainExportFormat) -> String {
748 let formatter = DateFormatter()
749 formatter.dateFormat = "yyyyMMdd_HHmmss"
750 let timestamp = formatter.string(from: Date())
751 return "\(timestamp)_\(prefix).\(format.fileExtension)"
752 }
753
754 private var defaultCollapsedSections: Set<ResultSection> {
755 []
756 }
757
758 private var currentPrimaryIP: String? {
759 viewModel.currentSnapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
760 }
761
762 private var resultStatusMessage: String? {
763 if let currentStatusMessage = viewModel.currentStatusMessage {
764 return currentStatusMessage
765 }
766
767 if viewModel.currentResultSource != .live {
768 return viewModel.currentResultSource.label
769 }
770
771 return nil
772 }
773
774 private func sectionCollapsedBinding(_ section: ResultSection) -> Binding<Bool> {
775 Binding(
776 get: { collapsedSections.contains(section) },
777 set: { isCollapsed in
778 if isCollapsed {
779 collapsedSections.insert(section)
780 } else {
781 collapsedSections.remove(section)
782 }
783 }
784 )
785 }
786}
787
788struct SummaryView: View {
789 @Environment(\.appDensity) private var appDensity
790 let fields: [SummaryFieldViewData]
791
792 var body: some View {
793 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
794 SectionTitleView(title: "Summary")
795 LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: appDensity.metrics.cardSpacing) {
796 ForEach(fields) { field in
797 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing) {
798 Text(field.label)
799 .font(appDensity.font(.caption2))
800 .foregroundStyle(Color(.appTextSecondary))
801 Text(field.value)
802 .font(appDensity.font(.caption))
803 .foregroundStyle(ResultColors.color(for: field.tone))
804 .lineLimit(2)
805 .textSelection(.enabled)
806 }
807 .frame(minHeight: appDensity.metrics.rowMinHeight + 12, alignment: .topLeading)
808 .frame(maxWidth: .infinity, alignment: .leading)
809 .padding(appDensity.metrics.cardPadding)
810 .background(Color(.appSurface))
811 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
812 }
813 }
814 }
815 }
816}
817
818struct RiskSummaryCardView: View {
819 @Environment(\.appDensity) private var appDensity
820 let report: DomainReport
821
822 private var topFactors: [RiskFactor] {
823 Array(report.riskAssessment.factors.prefix(3))
824 }
825
826 var body: some View {
827 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
828 SectionTitleView(title: "Risk")
829 CardView(allowsHorizontalScroll: false) {
830 HStack(alignment: .firstTextBaseline) {
831 VStack(alignment: .leading, spacing: 4) {
832 Text("\(report.riskAssessment.score)")
833 .font(appDensity.font(.largeTitle, weight: .bold))
834 .foregroundStyle(levelColor)
835 Text(report.riskAssessment.level.title)
836 .font(appDensity.font(.caption))
837 .foregroundStyle(levelColor)
838 }
839 Spacer()
840 Text("Deterministic")
841 .font(appDensity.font(.caption2))
842 .foregroundStyle(Color(.appTextSecondary))
843 }
844
845 if topFactors.isEmpty {
846 Text("No major risk factors identified")
847 .font(appDensity.font(.caption))
848 .foregroundStyle(Color(.appTextSecondary))
849 } else {
850 ForEach(Array(topFactors.enumerated()), id: \.offset) { _, factor in
851 HStack(alignment: .top, spacing: 8) {
852 Circle()
853 .fill(factorColor(factor.impact))
854 .frame(width: 8, height: 8)
855 .padding(.top, 5)
856 Text(factor.description)
857 .font(appDensity.font(.caption))
858 .foregroundStyle(.primary)
859 }
860 }
861 }
862 }
863 }
864 }
865
866 private var levelColor: Color {
867 switch report.riskAssessment.level {
868 case .low:
869 return Color(.statusPositive)
870 case .medium:
871 return Color(.statusWarning)
872 case .high:
873 return Color(.statusCritical)
874 }
875 }
876
877 private func factorColor(_ impact: RiskImpact) -> Color {
878 switch impact {
879 case .positive:
880 return Color(.statusPositive)
881 case .neutral:
882 return .secondary
883 case .negative:
884 return Color(.statusCritical)
885 }
886 }
887}
888
889struct InsightsSummaryCardView: View {
890 @Environment(\.appDensity) private var appDensity
891 let insights: [String]
892
893 var body: some View {
894 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
895 SectionTitleView(title: "Insights")
896 CardView(allowsHorizontalScroll: false) {
897 if insights.isEmpty {
898 Text("No deterministic insights triggered")
899 .font(appDensity.font(.caption))
900 .foregroundStyle(Color(.appTextSecondary))
901 } else {
902 ForEach(Array(insights.enumerated()), id: \.offset) { _, insight in
903 HStack(alignment: .top, spacing: 8) {
904 Image(systemName: "chart.line.uptrend.xyaxis")
905 .font(appDensity.font(.caption2))
906 .foregroundStyle(Color(.statusInfo))
907 .padding(.top, 2)
908 Text(insight)
909 .font(appDensity.font(.caption))
910 .foregroundStyle(.primary)
911 }
912 }
913 }
914 }
915 }
916 }
917}
918
919struct StickyLookupSummaryView: View {
920 @Environment(\.appDensity) private var appDensity
921
922 let domain: String
923 let availability: DomainAvailabilityStatus?
924 let primaryIP: String?
925 let sslInfo: SSLCertificateInfo?
926 let sslError: String?
927 let emailSecurity: EmailSecurityResult?
928 let emailError: String?
929 let changeSummary: DomainChangeSummary?
930
931 var body: some View {
932 CardView(allowsHorizontalScroll: false) {
933 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
934 HStack(alignment: .center, spacing: 10) {
935 Text(domain)
936 .font(appDensity.font(.headline, weight: .semibold))
937 .foregroundStyle(.primary)
938 .lineLimit(1)
939 Spacer(minLength: 6)
940 AppCopyButton(value: domain, label: "Copy domain")
941 }
942
943 ScrollView(.horizontal, showsIndicators: false) {
944 HStack(spacing: 8) {
945 AppStatusBadgeView(model: AppStatusFactory.availability(availability))
946 AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError))
947 AppStatusBadgeView(model: AppStatusFactory.email(emailSecurity, error: emailError))
948 AppStatusBadgeView(model: AppStatusFactory.change(changeSummary))
949 }
950 }
951
952 if let primaryIP {
953 HStack(spacing: 8) {
954 Label(primaryIP, systemImage: "network")
955 .font(appDensity.font(.caption))
956 .foregroundStyle(Color(.appTextSecondary))
957 Spacer(minLength: 6)
958 AppCopyButton(value: primaryIP, label: "Copy IP")
959 }
960 }
961 }
962 }
963 .shadow(color: .black.opacity(0.12), radius: 14, y: 6)
964 }
965}
966
967struct LookupProgressOverviewView: View {
968 @Environment(\.appDensity) private var appDensity
969 let steps: [String]
970
971 var body: some View {
972 CardView(allowsHorizontalScroll: false) {
973 HStack(spacing: 8) {
974 ProgressView()
975 .controlSize(.small)
976 VStack(alignment: .leading, spacing: 4) {
977 Text("Running lookup…")
978 .font(appDensity.font(.caption))
979 .foregroundStyle(.primary)
980 Text(steps.isEmpty ? "Preparing requests" : steps.joined(separator: " • "))
981 .font(appDensity.font(.caption2))
982 .foregroundStyle(Color(.appTextSecondary))
983 }
984 Spacer()
985 }
986 }
987 }
988}
989
990struct LookupStatusBannerView: View {
991 @Environment(\.appDensity) private var appDensity
992 let message: String
993 let resultSource: LookupResultSource
994
995 var body: some View {
996 HStack(spacing: 8) {
997 Image(systemName: iconName)
998 .font(.caption)
999 Text(message)
1000 .font(appDensity.font(.caption))
1001 Spacer()
1002 }
1003 .foregroundStyle(color)
1004 .padding(appDensity.metrics.cardPadding - 2)
1005 .frame(maxWidth: .infinity, alignment: .leading)
1006 .background(color.opacity(0.12))
1007 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
1008 }
1009
1010 private var color: Color {
1011 switch resultSource {
1012 case .live:
1013 return Color(.statusPositive)
1014 case .cached:
1015 return .secondary
1016 case .mixed:
1017 return Color(.statusWarning)
1018 case .snapshot:
1019 return Color(.statusWarning)
1020 }
1021 }
1022
1023 private var iconName: String {
1024 switch resultSource {
1025 case .live:
1026 return "bolt.horizontal"
1027 case .cached:
1028 return "clock.arrow.trianglehead.counterclockwise.rotate.90"
1029 case .mixed:
1030 return "arrow.triangle.branch"
1031 case .snapshot:
1032 return "archivebox"
1033 }
1034 }
1035}
1036
1037struct DomainChangeSummaryView: View {
1038 @Environment(\.appDensity) private var appDensity
1039 let summary: DomainChangeSummary
1040 @State private var showsDetails = false
1041
1042 var body: some View {
1043 CardView(allowsHorizontalScroll: false) {
1044 HStack {
1045 Label(summary.hasChanges ? "Changed" : "Stable", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle")
1046 .font(appDensity.font(.caption))
1047 .foregroundStyle(summary.hasChanges ? severityTone(summary.severity).foreground : Color(.statusPositive))
1048 Spacer()
1049 Text(summary.severity.title.uppercased())
1050 .font(appDensity.font(.caption2))
1051 .foregroundStyle(summary.hasChanges ? severityTone(summary.severity).foreground : Color(.appTextSecondary))
1052 .padding(.horizontal, 8)
1053 .padding(.vertical, 4)
1054 .background((summary.hasChanges ? severityTone(summary.severity) : AppStatusTone.neutral).surface)
1055 .clipShape(Capsule())
1056 Text(summary.impactClassification.title.uppercased())
1057 .font(appDensity.font(.caption2))
1058 .foregroundStyle(summary.impactClassification.tone.foreground)
1059 .padding(.horizontal, 8)
1060 .padding(.vertical, 4)
1061 .background(summary.impactClassification.tone.surface)
1062 .clipShape(Capsule())
1063 Text(summary.generatedAt, style: .time)
1064 .font(appDensity.font(.caption2))
1065 .foregroundStyle(Color(.appTextSecondary))
1066 }
1067
1068 VStack(alignment: .leading, spacing: 4) {
1069 Text("Inference")
1070 .font(appDensity.font(.caption2))
1071 .foregroundStyle(Color(.appTextSecondary))
1072 Text(summary.message)
1073 .font(appDensity.font(.caption))
1074 .foregroundStyle(.primary)
1075 .lineLimit(2)
1076 }
1077
1078 if !summary.observedFacts.isEmpty || summary.contextNote != nil {
1079 DisclosureGroup(showsDetails ? "Hide Details" : "Show Details", isExpanded: $showsDetails) {
1080 VStack(alignment: .leading, spacing: 8) {
1081 if !summary.observedFacts.isEmpty {
1082 VStack(alignment: .leading, spacing: 4) {
1083 Text("Observed")
1084 .font(appDensity.font(.caption2))
1085 .foregroundStyle(Color(.appTextSecondary))
1086 ForEach(Array(summary.observedFacts.enumerated()), id: \.offset) { _, fact in
1087 Text(fact)
1088 .font(appDensity.font(.caption))
1089 .foregroundStyle(.primary)
1090 }
1091 }
1092 }
1093
1094 if let riskScoreDelta = summary.riskScoreDelta {
1095 Text("Risk delta: \(riskScoreDelta >= 0 ? "+" : "")\(riskScoreDelta)")
1096 .font(appDensity.font(.caption2))
1097 .foregroundStyle(riskScoreDelta > 0 ? Color(.statusWarning) : .secondary)
1098 }
1099
1100 if let contextNote = summary.contextNote {
1101 Text(contextNote)
1102 .font(appDensity.font(.caption2))
1103 .foregroundStyle(Color(.statusWarning))
1104 }
1105 }
1106 .padding(.top, 4)
1107 }
1108 .font(appDensity.font(.caption))
1109 .tint(.secondary)
1110 }
1111 }
1112 }
1113
1114 private func severityTone(_ severity: ChangeSeverity) -> AppStatusTone {
1115 switch severity {
1116 case .low:
1117 return .neutral
1118 case .medium:
1119 return .warning
1120 case .high:
1121 return .critical
1122 }
1123 }
1124
1125}
1126
1127struct DomainDiffView: View {
1128 let title: String
1129 let sections: [DomainDiffSection]
1130 let contextNote: String?
1131 let showsUnchanged: Bool
1132 let highlightedSectionID: String?
1133
1134 @State private var collapsedSections = Set<String>()
1135 @State private var showsLowSeverity = false
1136
1137 private var filteredSections: [DomainDiffSection] {
1138 sections
1139 .map { section in
1140 let items = section.items.filter { item in
1141 if !showsUnchanged, !item.hasChanges {
1142 return false
1143 }
1144 if showsLowSeverity {
1145 return true
1146 }
1147 return item.severity >= .medium || (showsUnchanged && item.changeType == .unchanged)
1148 }
1149 return DomainDiffSection(id: section.id, title: section.title, items: items)
1150 }
1151 .filter { !$0.items.isEmpty }
1152 }
1153
1154 private var hasLowSeverityChanges: Bool {
1155 sections.flatMap(\.items).contains { $0.hasChanges && $0.severity == .low }
1156 }
1157
1158 var body: some View {
1159 VStack(alignment: .leading, spacing: 12) {
1160 HStack {
1161 SectionTitleView(title: title)
1162 Spacer()
1163 if hasLowSeverityChanges {
1164 Button(showsLowSeverity ? "Hide Low" : "Show Low") {
1165 showsLowSeverity.toggle()
1166 }
1167 .buttonStyle(.bordered)
1168 .font(.system(.caption, design: .monospaced))
1169 }
1170 }
1171 if let contextNote {
1172 MessageCardView(text: contextNote, isError: false)
1173 }
1174 if filteredSections.isEmpty {
1175 MessageCardView(text: "No comparison data available", isError: false)
1176 } else {
1177 ForEach(filteredSections) { section in
1178 CardView(allowsHorizontalScroll: false) {
1179 DisclosureGroup(isExpanded: binding(for: section)) {
1180 let visibleItems = showsUnchanged ? section.items : section.items.filter(\.hasChanges)
1181
1182 ForEach(visibleItems) { item in
1183 VStack(alignment: .leading, spacing: 6) {
1184 HStack {
1185 Text(item.label)
1186 .font(.system(.caption, design: .monospaced))
1187 .foregroundStyle(Color(.appTextSecondary))
1188 Spacer()
1189 Text("\(item.changeType.marker) \(item.severity.title) • \(changeLabel(for: item.changeType))")
1190 .font(.system(.caption2, design: .monospaced))
1191 .foregroundStyle(changeTone(for: item).foreground)
1192 .padding(.horizontal, 8)
1193 .padding(.vertical, 4)
1194 .background(changeTone(for: item).surface)
1195 .clipShape(Capsule())
1196 }
1197
1198 if let oldValue = item.oldValue {
1199 VStack(alignment: .leading, spacing: 2) {
1200 Text("Old")
1201 .font(.system(.caption2, design: .monospaced))
1202 .foregroundStyle(Color(.appTextSecondary))
1203 Text(oldValue)
1204 .font(.system(.caption2, design: .monospaced))
1205 .foregroundStyle(Color(.appTextSecondary))
1206 .textSelection(.enabled)
1207 }
1208 }
1209
1210 if let newValue = item.newValue {
1211 VStack(alignment: .leading, spacing: 2) {
1212 Text("New")
1213 .font(.system(.caption2, design: .monospaced))
1214 .foregroundStyle(Color(.appTextSecondary))
1215 Text(newValue)
1216 .font(.system(.caption, design: .monospaced))
1217 .foregroundStyle(item.hasChanges ? .primary : .secondary)
1218 .textSelection(.enabled)
1219 }
1220 }
1221 }
1222 .padding(10)
1223 .background(item.hasChanges ? changeTone(for: item).surface : Color(.appSurface))
1224 .cornerRadius(8)
1225 }
1226 } label: {
1227 HStack {
1228 Text(section.title)
1229 .font(.system(.subheadline, design: .monospaced))
1230 .fontWeight(.semibold)
1231 .foregroundStyle(sectionColor(section))
1232 Spacer()
1233 Text(section.severity.title)
1234 .font(.system(.caption2, design: .monospaced))
1235 .foregroundStyle(sectionColor(section))
1236 }
1237 }
1238 }
1239 .id(section.id)
1240 .overlay {
1241 if highlightedSectionID == section.id {
1242 RoundedRectangle(cornerRadius: 12)
1243 .stroke(Color(.statusInfo).opacity(0.55), lineWidth: 1)
1244 }
1245 }
1246 }
1247 }
1248 }
1249 .onAppear {
1250 collapsedSections = Set(sections.filter { !showsUnchanged && !$0.hasChanges }.map(\.id))
1251 }
1252 }
1253
1254 private func changeLabel(for changeType: DiffChangeType) -> String {
1255 switch changeType {
1256 case .added:
1257 return "Added"
1258 case .removed:
1259 return "Removed"
1260 case .changed:
1261 return "Changed"
1262 case .unchanged:
1263 return "Unchanged"
1264 }
1265 }
1266
1267 private func changeTone(for item: DomainDiffItem) -> AppStatusTone {
1268 if item.changeType == .unchanged {
1269 return .neutral
1270 }
1271
1272 switch item.severity {
1273 case .low:
1274 return .info
1275 case .medium:
1276 return .warning
1277 case .high:
1278 return .critical
1279 }
1280 }
1281
1282 private func sectionColor(_ section: DomainDiffSection) -> Color {
1283 switch section.severity {
1284 case .low:
1285 return .blue
1286 case .medium:
1287 return Color(.statusWarning)
1288 case .high:
1289 return Color(.statusCritical)
1290 }
1291 }
1292
1293 private func binding(for section: DomainDiffSection) -> Binding<Bool> {
1294 Binding(
1295 get: { !collapsedSections.contains(section.id) },
1296 set: { isExpanded in
1297 if isExpanded {
1298 collapsedSections.remove(section.id)
1299 } else {
1300 collapsedSections.insert(section.id)
1301 }
1302 }
1303 )
1304 }
1305}
1306
1307struct TrackedDomainDetailHeaderView: View {
1308 let trackedDomain: TrackedDomain
1309
1310 var body: some View {
1311 VStack(alignment: .leading, spacing: 4) {
1312 if let note = trackedDomain.note?.nilIfEmpty {
1313 LabeledValueRow(row: InfoRowViewData(label: "Tracking Note", value: note, tone: .secondary))
1314 }
1315 HStack(spacing: 8) {
1316 if trackedDomain.isPinned {
1317 Label("Pinned", systemImage: "pin.fill")
1318 }
1319 Text("Last refresh \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
1320 }
1321 .font(.system(.caption2, design: .monospaced))
1322 .foregroundStyle(Color(.appTextSecondary))
1323 }
1324 }
1325}
1326
1327struct SectionTitleView: View {
1328 @Environment(\.appDensity) private var appDensity
1329 let title: String
1330
1331 var body: some View {
1332 Text(title)
1333 .font(appDensity.font(.headline, design: .default, weight: .semibold))
1334 .foregroundStyle(.primary)
1335 .accessibilityAddTraits(.isHeader)
1336 }
1337}
1338
1339/// A card that wraps its content by default.
1340///
1341/// `allowsHorizontalScroll` used to default to `true`, so nine call sites put
1342/// their content behind a horizontal gesture instead of letting it wrap — a
1343/// WCAG 1.4.10 (Reflow) failure, and the mechanism behind clipped rows at large
1344/// text sizes. It also forced VoiceOver and Switch Control users onto a nested
1345/// scroll axis to reach data.
1346///
1347/// The default is now `false`. Where horizontal scrolling genuinely suits wide
1348/// tabular content, it is still opt-in — but it is suppressed at accessibility
1349/// text sizes, where wrapping always beats a hidden axis.
1350struct CardView<Content: View>: View {
1351 @Environment(\.appDensity) private var appDensity
1352 @Environment(\.dynamicTypeSize) private var dynamicTypeSize
1353 let allowsHorizontalScroll: Bool
1354 let content: Content
1355
1356 init(allowsHorizontalScroll: Bool = false, @ViewBuilder content: () -> Content) {
1357 self.allowsHorizontalScroll = allowsHorizontalScroll
1358 self.content = content()
1359 }
1360
1361 var body: some View {
1362 Group {
1363 if allowsHorizontalScroll, !dynamicTypeSize.isAccessibilitySize {
1364 ScrollView(.horizontal) {
1365 cardContent
1366 .scrollTargetLayout()
1367 }
1368 .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
1369 } else {
1370 cardContent
1371 .frame(maxWidth: .infinity, alignment: .leading)
1372 }
1373 }
1374 .frame(maxWidth: .infinity, alignment: .leading)
1375 .padding(appDensity.metrics.cardPadding)
1376 .background(Color(.appSurface))
1377 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
1378 }
1379
1380 private var cardContent: some View {
1381 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
1382 content
1383 }
1384 }
1385}
1386
1387struct LoadingCardView: View {
1388 let text: String
1389
1390 var body: some View {
1391 CardView {
1392 ProgressView(text)
1393 .appLoadingStyle()
1394 .frame(maxWidth: .infinity, alignment: .center)
1395 }
1396 }
1397}
1398
1399struct MessageCardView: View {
1400 let text: String
1401 let isError: Bool
1402
1403 var body: some View {
1404 CardView {
1405 MessageRowView(text: text, isError: isError)
1406 }
1407 }
1408}
1409
1410struct MessageRowView: View {
1411 @Environment(\.appDensity) private var appDensity
1412 let text: String
1413 let isError: Bool
1414
1415 var body: some View {
1416 Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle")
1417 .font(appDensity.font(.caption))
1418 .foregroundStyle(isError ? Color(.statusCritical) : .secondary)
1419 .lineLimit(nil)
1420 .fixedSize(horizontal: false, vertical: true)
1421 }
1422}
1423
1424struct SectionTrustMetadataView: View {
1425 @Environment(\.appDensity) private var appDensity
1426 let provenance: SectionProvenance?
1427 let confidence: ConfidenceLevel?
1428 let note: String?
1429
1430 init(provenance: SectionProvenance?, confidence: ConfidenceLevel?, note: String? = nil) {
1431 self.provenance = provenance
1432 self.confidence = confidence
1433 self.note = note
1434 }
1435
1436 var body: some View {
1437 if provenance != nil || confidence != nil || note != nil {
1438 VStack(alignment: .leading, spacing: 6) {
1439 HStack(spacing: 8) {
1440 if let confidence {
1441 Text("Confidence \(confidence.title)")
1442 .font(appDensity.font(.caption2))
1443 .foregroundStyle(Color(.appTextSecondary))
1444 }
1445 if let provenance {
1446 Text(provenance.provider ?? provenance.source)
1447 .font(appDensity.font(.caption2))
1448 .foregroundStyle(Color(.appTextSecondary))
1449 Text(provenance.resultSource.label)
1450 .font(appDensity.font(.caption2))
1451 .foregroundStyle(Color(.appTextSecondary))
1452 }
1453 }
1454 DisclosureGroup("Details") {
1455 VStack(alignment: .leading, spacing: 4) {
1456 if let provenance {
1457 LabeledValueRow(row: .init(label: "Method", value: provenance.source, tone: .secondary))
1458 if let provider = provenance.provider {
1459 LabeledValueRow(row: .init(label: "Provider", value: provider, tone: .secondary))
1460 }
1461 if let resolver = provenance.resolver {
1462 LabeledValueRow(row: .init(label: "Resolver", value: resolver, tone: .secondary))
1463 }
1464 LabeledValueRow(row: .init(label: "Collected", value: provenance.collectedAt.formatted(date: .abbreviated, time: .shortened), tone: .secondary))
1465 LabeledValueRow(row: .init(label: "Mode", value: provenance.resultSource.label, tone: .secondary))
1466 }
1467 if let note {
1468 LabeledValueRow(row: .init(label: "Note", value: note, tone: .secondary))
1469 }
1470 }
1471 .padding(.top, 4)
1472 }
1473 .font(appDensity.font(.caption))
1474 .tint(.secondary)
1475 }
1476 }
1477 }
1478}
1479
1480struct LabeledValueRow: View {
1481 @Environment(\.appDensity) private var appDensity
1482 @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor
1483 let row: InfoRowViewData
1484
1485 var body: some View {
1486 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
1487 HStack(alignment: .top, spacing: 8) {
1488 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
1489 Text(row.label)
1490 .font(appDensity.font(.caption2))
1491 .foregroundStyle(Color(.appTextSecondary))
1492 HStack(alignment: .firstTextBaseline, spacing: 4) {
1493 if differentiateWithoutColor, let symbol = toneSymbol {
1494 Image(systemName: symbol)
1495 .font(appDensity.font(.caption2))
1496 .foregroundStyle(ResultColors.color(for: row.tone))
1497 .accessibilityHidden(true)
1498 }
1499 valueText
1500 }
1501 }
1502 .frame(maxWidth: .infinity, alignment: .leading)
1503 .layoutPriority(1)
1504 Spacer(minLength: 6)
1505 if !row.value.isEmpty, row.value != "Unavailable" {
1506 AppCopyButton(value: row.value, label: "Copy \(row.label)")
1507 }
1508 }
1509 }
1510 .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
1511 }
1512
1513 /// A leading symbol for warning/failure tones, shown only under Differentiate
1514 /// Without Color so tone is not conveyed by text colour alone. Hidden from
1515 /// VoiceOver — the value text already carries the meaning.
1516 private var toneSymbol: String? {
1517 switch row.tone {
1518 case .warning: return "exclamationmark.triangle.fill"
1519 case .failure: return "xmark.octagon.fill"
1520 default: return nil
1521 }
1522 }
1523
1524 @ViewBuilder
1525 private var valueText: some View {
1526 let base = Text(row.value)
1527 .font(appDensity.font(.caption))
1528 .foregroundStyle(ResultColors.color(for: row.tone))
1529
1530 switch row.speechStyle {
1531 case .plain:
1532 base
1533 .lineLimit(nil)
1534 .fixedSize(horizontal: false, vertical: true)
1535 .textSelection(.enabled)
1536 case .technical:
1537 // Record values and identifiers: keep punctuation audible (SPF/DMARC
1538 // separators are semantically load-bearing) and let VoiceOver use its
1539 // code-reading heuristics.
1540 base
1541 .speechAlwaysIncludesPunctuation()
1542 .accessibilityTextContentType(.sourceCode)
1543 .lineLimit(nil)
1544 .fixedSize(horizontal: false, vertical: true)
1545 .textSelection(.enabled)
1546 }
1547 }
1548}
1549
1550/// Maps a row's semantic tone onto the app palette.
1551///
1552/// See `Docs/ACCESSIBILITY.md` for the measured contrast ratios behind these
1553/// colours. Never reach for a literal (`Color(.statusCritical)`, `Color(.statusWarning)`, …) — the system
1554/// palette fails WCAG AA badly in light mode (systemYellow is 1.28:1 on white).
1555enum ResultColors {
1556 static func color(for tone: ResultTone) -> Color {
1557 switch tone {
1558 case .primary:
1559 return .primary
1560 case .secondary:
1561 return .secondary
1562 case .success:
1563 return Color(.statusPositive)
1564 case .warning:
1565 return Color(.statusWarning)
1566 case .failure:
1567 return Color(.statusCritical)
1568 }
1569 }
1570}
1571
1572extension DateFormatter {
1573 static let certDate: DateFormatter = {
1574 let formatter = DateFormatter()
1575 formatter.dateStyle = .medium
1576 formatter.timeStyle = .short
1577 return formatter
1578 }()
1579}
1580
1581extension View {
1582 func appLoadingStyle() -> some View {
1583 font(.system(.caption, design: .monospaced))
1584 }
1585}
1586
1587private extension String {
1588 var nilIfEmpty: String? {
1589 isEmpty ? nil : self
1590 }
1591}
1592
1593extension ChangeImpactClassification {
1594 var tone: AppStatusTone {
1595 switch self {
1596 case .informational: return .neutral
1597 case .warning: return .warning
1598 case .critical: return .critical
1599 }
1600 }
1601}
1602
1603extension TLSGrade {
1604 var tone: ResultTone {
1605 switch self {
1606 case .a: return .success
1607 case .f: return .failure
1608 default: return .warning
1609 }
1610 }
1611}
1612
1613extension EmailSecurityGrade {
1614 var tone: ResultTone {
1615 switch self {
1616 case .a: return .success
1617 case .f: return .failure
1618 default: return .warning
1619 }
1620 }
1621}
1622
1623#Preview {
1624 ContentView(viewModel: DomainViewModel())
1625}