krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.9.0: DomainDig/BatchResultsView.swift · raw
1import SwiftUI
2
3struct BatchResultsView: View {
4 @Environment(\.appDensity) private var appDensity
5 @Bindable var viewModel: DomainViewModel
6 let title: String
7
8 var body: some View {
9 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
10 HStack(alignment: .top) {
11 SectionTitleView(title: title)
12 Spacer()
13 if viewModel.batchLookupRunning {
14 VStack(alignment: .trailing, spacing: 4) {
15 ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1)))
16 .tint(Color(.statusInfo))
17 .frame(width: 120)
18 Text(viewModel.batchProgressLabel)
19 .font(appDensity.font(.caption2))
20 .foregroundStyle(Color(.appTextSecondary))
21 }
22 } else if !viewModel.batchResults.isEmpty {
23 Text("\(viewModel.batchResults.count) domains")
24 .font(appDensity.font(.caption2))
25 .foregroundStyle(Color(.appTextSecondary))
26 }
27 }
28
29 if viewModel.batchResults.isEmpty {
30 EmptyStateCardView(
31 title: "No Batch Results Yet",
32 message: "Batch runs collect availability, IP, and change status for multiple domains in one pass.",
33 suggestion: "Switch to Bulk mode, paste a list of domains, then run a batch lookup.",
34 systemImage: "square.stack.3d.up"
35 )
36 } else {
37 CardView(allowsHorizontalScroll: false) {
38 ForEach(viewModel.batchResults) { result in
39 if let entry = viewModel.historyEntry(for: result) {
40 NavigationLink {
41 HistoryDetailView(viewModel: viewModel, entry: entry)
42 } label: {
43 BatchResultRowView(result: result)
44 }
45 .buttonStyle(.plain)
46 } else {
47 BatchResultRowView(result: result)
48 }
49 }
50 }
51 }
52 }
53 }
54}
55
56struct BatchResultRowView: View {
57 @Environment(\.appDensity) private var appDensity
58 let result: BatchLookupResult
59
60 var body: some View {
61 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
62 // Same reflow as WatchlistRowView: wide while it fits, stacked at
63 // accessibility sizes so the badge cannot letter-wrap vertically.
64 ViewThatFits(in: .horizontal) {
65 HStack(alignment: .firstTextBaseline, spacing: 8) {
66 domainTitle
67 Spacer(minLength: 8)
68 sourceLabel
69 AppStatusBadgeView(model: quickStatusBadge)
70 }
71 VStack(alignment: .leading, spacing: 6) {
72 domainTitle
73 HStack(spacing: 8) {
74 AppStatusBadgeView(model: quickStatusBadge)
75 sourceLabel
76 }
77 }
78 }
79
80 HStack(spacing: 10) {
81 AppStatusBadgeView(model: availabilityBadgeModel)
82 if let riskScore = result.riskScore, let riskLevel = result.riskLevel {
83 Text("Risk \(riskScore) \(riskLevel.title)")
84 .lineLimit(1)
85 .minimumScaleFactor(0.85)
86 }
87 }
88 .font(appDensity.font(.caption2))
89 .foregroundStyle(Color(.appTextSecondary))
90
91 HStack(spacing: 10) {
92 Text(result.primaryIP ?? "No IP")
93 .lineLimit(1)
94 .truncationMode(.middle)
95
96 Spacer(minLength: 8)
97
98 Text(result.timestamp.formatted(date: .abbreviated, time: .shortened))
99 .lineLimit(1)
100 }
101 .font(appDensity.font(.caption2))
102 .foregroundStyle(Color(.appTextSecondary))
103
104 if let summaryMessage = result.summaryMessage {
105 Text(summaryMessage)
106 .font(appDensity.font(.caption2))
107 .foregroundStyle(Color(.appTextSecondary))
108 }
109
110 if let changeClassification = result.changeClassification {
111 Text("Impact: \(changeClassification.title)")
112 .font(appDensity.font(.caption2))
113 .foregroundStyle(changeClassification.tone.foreground)
114 }
115
116 if let errorMessage = result.errorMessage {
117 Text(errorMessage)
118 .font(appDensity.font(.caption2))
119 .foregroundStyle(result.status == .failed ? Color(.statusCritical) : Color(.appTextSecondary))
120 }
121 }
122 .frame(maxWidth: .infinity, alignment: .leading)
123 .padding(.vertical, 4)
124 .frame(minHeight: appDensity.metrics.rowMinHeight + 12, alignment: .topLeading)
125 // One VoiceOver stop per row: domain as the label, status as the value,
126 // everything else on the More Content rotor. Reading all eight text
127 // elements inline would make a 200-domain sweep unnavigable. `.high`
128 // importance is spoken without the rotor; the rest waits for a swipe.
129 // Extracted to a modifier — inlined, the chain broke the type-checker.
130 .modifier(BatchRowAccessibility(
131 domain: result.domain,
132 status: "\(quickStatusBadge.title), \(availabilityText)",
133 risk: riskDescription,
134 ip: result.primaryIP ?? "none",
135 checked: result.timestamp.formatted(date: .abbreviated, time: .shortened),
136 source: result.resultSource.label,
137 changeLabel: changeContentLabel,
138 changeValue: changeContentValue
139 ))
140 }
141
142 private var domainTitle: some View {
143 Text(result.domain)
144 .font(appDensity.font(.callout))
145 .foregroundStyle(.primary)
146 .lineLimit(3)
147 .multilineTextAlignment(.leading)
148 .fixedSize(horizontal: false, vertical: true)
149 }
150
151 private var sourceLabel: some View {
152 Text(result.resultSource.label.lowercased())
153 .font(appDensity.font(.caption2))
154 .foregroundStyle(Color(.appTextSecondary))
155 }
156
157 private var changeContentLabel: String {
158 result.changeClassification != nil ? "Impact" : "Status"
159 }
160
161 private var changeContentValue: String {
162 if let change = result.changeClassification {
163 return change.title
164 }
165 return result.errorMessage ?? result.summaryMessage ?? quickStatusBadge.title
166 }
167
168 private var riskDescription: String {
169 if let score = result.riskScore, let level = result.riskLevel {
170 return "\(score), \(level.title)"
171 }
172 return "not scored"
173 }
174
175 private var availabilityText: String {
176 switch result.availability {
177 case .available:
178 return "Available"
179 case .registered:
180 return "Registered"
181 case .unknown, .none:
182 return "Unknown"
183 }
184 }
185
186 private var availabilityBadgeModel: AppStatusBadgeModel {
187 guard result.status == .failed else {
188 return AppStatusFactory.availability(result.availability)
189 }
190
191 return .init(
192 title: availabilityText,
193 systemImage: "exclamationmark.circle",
194 foregroundColor: Color(.appTextSecondary),
195 backgroundColor: Color(.appSurfaceElevated)
196 )
197 }
198
199 private var quickStatusBadge: AppStatusBadgeModel {
200 switch result.status {
201 case .pending:
202 return .init(title: "Pending", systemImage: "clock", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
203 case .running:
204 return .init(title: "Running", systemImage: "arrow.clockwise", foregroundColor: Color(.statusInfo), backgroundColor: Color(.statusInfoSurface))
205 case .completed:
206 if result.changeClassification == .critical || result.certificateWarningLevel == .critical || result.riskLevel == .high {
207 return .init(title: "Critical", systemImage: "exclamationmark.octagon.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface))
208 }
209 if result.changeClassification == .warning || result.changeSeverity == .medium || result.certificateWarningLevel == .warning {
210 return .init(title: "Warning", systemImage: "exclamationmark.triangle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
211 }
212 if result.quickStatus == "Changed" {
213 return .init(title: "Changed", systemImage: "arrow.triangle.2.circlepath", foregroundColor: Color(.statusInfo), backgroundColor: Color(.statusInfoSurface))
214 }
215 return .init(title: "Stable", systemImage: "checkmark.circle.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
216 case .failed:
217 return .init(title: "Failed", systemImage: "xmark.circle.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface))
218 }
219 }
220}
221
222/// Row-level VoiceOver treatment for a batch result: a single element whose
223/// label is the domain and whose value is the status, with the remaining fields
224/// on the More Content rotor. Extracted from the row body because inlining the
225/// full modifier chain broke Swift's type-checker.
226private struct BatchRowAccessibility: ViewModifier {
227 let domain: String
228 let status: String
229 let risk: String
230 let ip: String
231 let checked: String
232 let source: String
233 let changeLabel: String
234 let changeValue: String
235
236 func body(content: Content) -> some View {
237 content
238 .accessibilityElement(children: .ignore)
239 .accessibilityLabel(domain)
240 .accessibilityValue(status)
241 .accessibilityCustomContent("Risk", risk, importance: .high)
242 .accessibilityCustomContent("IP address", ip)
243 .accessibilityCustomContent("Checked", checked)
244 .accessibilityCustomContent("Source", source)
245 .accessibilityCustomContent(LocalizedStringResource(stringLiteral: changeLabel), changeValue)
246 }
247}