krz/domain-dig

an ios app for DNS & SSL analysis

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

v5.0.3: 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}