krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.9.0: DomainDig/DomainDigUI.swift · raw
1import SwiftUI
2
3#if canImport(UIKit)
4import UIKit
5#elseif canImport(AppKit)
6import AppKit
7#endif
8
9/// User-selected appearance, applied once at the `WindowGroup`.
10///
11/// Deliberately applied in exactly one place. The app previously carried 16
12/// separate `.preferredColorScheme(.dark)` calls scattered across view bodies,
13/// which is how it became impossible to reach light mode at all — re-applying
14/// per view is what let the lock spread unnoticed.
15enum AppAppearance: String, CaseIterable, Identifiable {
16 case system
17 case light
18 case dark
19
20 static let userDefaultsKey = "appAppearance"
21
22 var id: String { rawValue }
23
24 var title: String {
25 switch self {
26 case .system:
27 return "System"
28 case .light:
29 return "Light"
30 case .dark:
31 return "Dark"
32 }
33 }
34
35 /// `nil` hands control back to the system setting.
36 var colorScheme: ColorScheme? {
37 switch self {
38 case .system:
39 return nil
40 case .light:
41 return .light
42 case .dark:
43 return .dark
44 }
45 }
46}
47
48enum AppDensity: String, CaseIterable, Identifiable {
49 case compact
50 case comfortable
51
52 static let userDefaultsKey = "appDensity"
53
54 var id: String { rawValue }
55
56 var title: String {
57 switch self {
58 case .compact:
59 return "Compact"
60 case .comfortable:
61 return "Comfortable"
62 }
63 }
64
65 var metrics: AppDensityMetrics {
66 switch self {
67 case .compact:
68 return AppDensityMetrics(
69 sectionSpacing: 14,
70 cardSpacing: 6,
71 cardPadding: 10,
72 rowSpacing: 4,
73 rowMinHeight: 30,
74 controlVerticalPadding: 10,
75 // Was 42, which put every control using it under the 44pt
76 // minimum in compact density — section headers, Run, Run Batch.
77 controlMinHeight: AppLayout.minimumTapTarget,
78 cardCornerRadius: 10
79 )
80 case .comfortable:
81 return AppDensityMetrics(
82 sectionSpacing: 18,
83 cardSpacing: 10,
84 cardPadding: 14,
85 rowSpacing: 7,
86 rowMinHeight: 38,
87 controlVerticalPadding: 14,
88 controlMinHeight: 48,
89 cardCornerRadius: 14
90 )
91 }
92 }
93
94 func font(_ textStyle: Font.TextStyle, design: Font.Design = .monospaced, weight: Font.Weight? = nil) -> Font {
95 var font = Font.system(textStyle, design: design)
96 if let weight {
97 font = font.weight(weight)
98 }
99 return font
100 }
101}
102
103/// Layout constants that are not density-dependent.
104enum AppLayout {
105 /// The HIG minimum for an interactive control, and WCAG 2.5.8's floor.
106 /// Controls scale up from here with Dynamic Type; none may sit below it.
107 static let minimumTapTarget: CGFloat = 44
108}
109
110struct AppDensityMetrics: Equatable {
111 let sectionSpacing: CGFloat
112 let cardSpacing: CGFloat
113 let cardPadding: CGFloat
114 let rowSpacing: CGFloat
115 let rowMinHeight: CGFloat
116 let controlVerticalPadding: CGFloat
117 let controlMinHeight: CGFloat
118 let cardCornerRadius: CGFloat
119}
120
121private struct AppDensityKey: EnvironmentKey {
122 static let defaultValue: AppDensity = .compact
123}
124
125extension EnvironmentValues {
126 var appDensity: AppDensity {
127 get { self[AppDensityKey.self] }
128 set { self[AppDensityKey.self] = newValue }
129 }
130}
131
132/// A status colour pairing: the foreground and the surface it sits on.
133///
134/// These travel together because they cannot be derived from one another. The
135/// badge fill used to be `foreground.opacity(0.16)`, which forced every
136/// foreground dark enough to stay legible against its own wash — that is how the
137/// light palette ended up olive-and-mud rather than amber-and-green. Decoupling
138/// them lets the foregrounds stay fully saturated.
139///
140/// See `Docs/ACCESSIBILITY.md` for the measured ratios.
141enum AppStatusTone {
142 case positive
143 case warning
144 case critical
145 case info
146 case neutral
147
148 var foreground: Color {
149 switch self {
150 case .positive:
151 return Color(.statusPositive)
152 case .warning:
153 return Color(.statusWarning)
154 case .critical:
155 return Color(.statusCritical)
156 case .info:
157 return Color(.statusInfo)
158 case .neutral:
159 return Color(.statusNeutral)
160 }
161 }
162
163 var surface: Color {
164 switch self {
165 case .positive:
166 return Color(.statusPositiveSurface)
167 case .warning:
168 return Color(.statusWarningSurface)
169 case .critical:
170 return Color(.statusCriticalSurface)
171 case .info:
172 return Color(.statusInfoSurface)
173 case .neutral:
174 return Color(.statusNeutralSurface)
175 }
176 }
177}
178
179struct AppStatusBadgeModel: Equatable {
180 let title: String
181 let systemImage: String?
182 let foregroundColor: Color
183 let backgroundColor: Color
184}
185
186enum AppStatusFactory {
187 static func availability(_ status: DomainAvailabilityStatus?) -> AppStatusBadgeModel {
188 switch status {
189 case .available:
190 return .init(title: "Available", systemImage: "checkmark.circle.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
191 case .registered:
192 return .init(title: "Registered", systemImage: "circle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
193 case .unknown, .none:
194 return .init(title: "Unknown", systemImage: "questionmark.circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
195 }
196 }
197
198 static func tls(sslInfo: SSLCertificateInfo?, error: String?) -> AppStatusBadgeModel {
199 if error != nil || sslInfo == nil {
200 return .init(title: "Invalid", systemImage: "xmark.octagon.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface))
201 }
202 if let sslInfo, sslInfo.daysUntilExpiry <= 14 {
203 return .init(title: "Expiring", systemImage: "exclamationmark.triangle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
204 }
205 return .init(title: "Valid", systemImage: "lock.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
206 }
207
208 static func email(_ result: EmailSecurityResult?, error: String?) -> AppStatusBadgeModel {
209 guard error == nil, let result else {
210 return .init(title: "Missing", systemImage: "minus.circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
211 }
212
213 let foundCount = [result.spf.found, result.dmarc.found, result.dkim.found].filter { $0 }.count
214 switch foundCount {
215 case 3:
216 return .init(title: "Secure", systemImage: "checkmark.shield.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
217 case 1, 2:
218 return .init(title: "Partial", systemImage: "shield.lefthalf.filled", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
219 default:
220 return .init(title: "Missing", systemImage: "minus.circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
221 }
222 }
223
224 static func change(_ summary: DomainChangeSummary?) -> AppStatusBadgeModel {
225 guard let summary else {
226 return .init(title: "Unchanged", systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
227 }
228 if summary.hasChanges {
229 return .init(title: "Changed", systemImage: "arrow.triangle.2.circlepath", foregroundColor: Color(.statusInfo), backgroundColor: Color(.statusInfoSurface))
230 }
231 return .init(title: "Unchanged", systemImage: "checkmark.circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
232 }
233}
234
235struct AppStatusBadgeView: View {
236 @Environment(\.appDensity) private var appDensity
237
238 let model: AppStatusBadgeModel
239
240 var body: some View {
241 HStack(spacing: 6) {
242 if let systemImage = model.systemImage {
243 Image(systemName: systemImage)
244 .font(.caption2)
245 }
246 Text(model.title)
247 }
248 // Never compress. Squeezed beside a long domain at accessibility sizes,
249 // the capsule otherwise wraps one character per line into a
250 // screen-height pill. Taking natural width instead forces the row's
251 // ViewThatFits onto its stacked layout, which is the intended fallback.
252 .fixedSize()
253 .font(appDensity.font(.caption, weight: .semibold))
254 .foregroundStyle(model.foregroundColor)
255 .padding(.horizontal, 9)
256 .padding(.vertical, 5)
257 .background(model.backgroundColor)
258 .clipShape(Capsule())
259 // Read as one word ("Critical"), not "icon, Critical". The symbol
260 // duplicates the title for VoiceOver.
261 .accessibilityElement(children: .ignore)
262 .accessibilityLabel(model.title)
263 }
264}
265
266struct AppCopyButton: View {
267 @Environment(\.appDensity) private var appDensity
268 @Environment(\.accessibilityReduceMotion) private var reduceMotion
269 @State private var didCopy = false
270
271 /// Grows with Dynamic Type. The `max(_, minimumTapTarget)` floor matters
272 /// because `@ScaledMetric` also scales *down* below the default text size,
273 /// which would push this back under the 44pt minimum.
274 @ScaledMetric(relativeTo: .caption) private var size: CGFloat = AppLayout.minimumTapTarget
275
276 let value: String
277 let label: String
278
279 var body: some View {
280 Button {
281 AppClipboard.copy(value)
282 AppHaptics.copy()
283 withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.18)) {
284 didCopy = true
285 }
286 Task {
287 try? await Task.sleep(nanoseconds: 900_000_000)
288 await MainActor.run {
289 withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.18)) {
290 didCopy = false
291 }
292 }
293 }
294 } label: {
295 Image(systemName: didCopy ? "checkmark" : "doc.on.doc")
296 .font(appDensity.font(.caption))
297 .foregroundStyle(didCopy ? Color(.statusPositive) : Color(.appTextSecondary))
298 .frame(width: max(size, AppLayout.minimumTapTarget), height: max(size, AppLayout.minimumTapTarget))
299 .background(Color(.appSurfaceElevated))
300 .clipShape(RoundedRectangle(cornerRadius: 8))
301 }
302 .buttonStyle(.plain)
303 .accessibilityLabel(didCopy ? "\(label) copied" : label)
304 }
305}
306
307enum AppClipboard {
308 static func copy(_ value: String) {
309 #if canImport(UIKit)
310 UIPasteboard.general.string = value
311 #elseif canImport(AppKit)
312 NSPasteboard.general.clearContents()
313 NSPasteboard.general.setString(value, forType: .string)
314 #endif
315 }
316}
317
318enum AppAccessibility {
319 /// Speaks a status update through VoiceOver without moving focus. Used at
320 /// lookup and sweep completion so a blind user hears the result land instead
321 /// of having to hunt for whether anything changed.
322 static func announce(_ message: String) {
323 #if canImport(UIKit)
324 var announcement = AttributedString(message)
325 announcement.accessibilitySpeechAnnouncementPriority = .high
326 AccessibilityNotification.Announcement(announcement).post()
327 #endif
328 }
329}
330
331enum AppHaptics {
332 static func copy() {
333 #if canImport(UIKit)
334 let generator = UINotificationFeedbackGenerator()
335 generator.notificationOccurred(.success)
336 #endif
337 }
338
339 static func refresh() {
340 #if canImport(UIKit)
341 let generator = UIImpactFeedbackGenerator(style: .light)
342 generator.impactOccurred()
343 #endif
344 }
345
346 static func track() {
347 #if canImport(UIKit)
348 let generator = UIImpactFeedbackGenerator(style: .soft)
349 generator.impactOccurred()
350 #endif
351 }
352}
353
354struct EmptyStateCardView: View {
355 @Environment(\.appDensity) private var appDensity
356
357 let title: String
358 let message: String
359 let suggestion: String
360 let systemImage: String
361 let showsCardBackground: Bool
362
363 init(
364 title: String,
365 message: String,
366 suggestion: String,
367 systemImage: String,
368 showsCardBackground: Bool = true
369 ) {
370 self.title = title
371 self.message = message
372 self.suggestion = suggestion
373 self.systemImage = systemImage
374 self.showsCardBackground = showsCardBackground
375 }
376
377 var body: some View {
378 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
379 // `Text(message)` already carried `fixedSize`; the title and
380 // suggestion did not, which is why the audit reported the *title*
381 // clipped on every empty state while the body beneath it wrapped.
382 // Deliberately an HStack rather than `Label`. `Label` constrains its
383 // own title text and `.fixedSize` applied to the Label does not
384 // reach the Text inside, so every empty-state heading reported as
385 // clipped. Splitting it lets the modifier land on the Text itself.
386 // Verified: doing this alone cleared the finding on all four empty
387 // states; changing the font design did not.
388 HStack(alignment: .firstTextBaseline, spacing: 8) {
389 // Decorative. `Label` used to fold the icon into the title's
390 // element; splitting them exposed it as its own, announcing the
391 // raw SF Symbol name ("checklist.unchecked") to VoiceOver.
392 Image(systemName: systemImage)
393 .accessibilityHidden(true)
394 Text(title)
395 .fixedSize(horizontal: false, vertical: true)
396 .multilineTextAlignment(.leading)
397 }
398 .font(appDensity.font(.headline, weight: .semibold))
399 .foregroundStyle(.primary)
400
401 Text(message)
402 .font(appDensity.font(.body))
403 .foregroundStyle(Color(.appTextSecondary))
404 .fixedSize(horizontal: false, vertical: true)
405
406 Text(suggestion)
407 .font(appDensity.font(.caption))
408 .foregroundStyle(Color(.statusInfo))
409 .fixedSize(horizontal: false, vertical: true)
410 }
411 .frame(maxWidth: .infinity, alignment: .leading)
412 .padding(appDensity.metrics.cardPadding)
413 .background(showsCardBackground ? Color(.appSurface) : Color.clear)
414 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
415 }
416}
417
418struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View {
419 @Environment(\.appDensity) private var appDensity
420 @Environment(\.accessibilityReduceMotion) private var reduceMotion
421
422 let title: String
423 @Binding var isCollapsed: Bool
424 let subtitle: String?
425 @ViewBuilder let trailing: () -> HeaderTrailing
426 @ViewBuilder let content: () -> Content
427
428 init(
429 title: String,
430 isCollapsed: Binding<Bool>,
431 subtitle: String? = nil,
432 @ViewBuilder trailing: @escaping () -> HeaderTrailing = { EmptyView() },
433 @ViewBuilder content: @escaping () -> Content
434 ) {
435 self.title = title
436 self._isCollapsed = isCollapsed
437 self.subtitle = subtitle
438 self.trailing = trailing
439 self.content = content
440 }
441
442 var body: some View {
443 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
444 Button {
445 withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.2)) {
446 isCollapsed.toggle()
447 }
448 } label: {
449 // One line while the title, trailing controls, and chevron
450 // genuinely fit; otherwise the trailing controls drop below the
451 // title. Without this, a squeezed trailing button letter-wraps
452 // vertically ("N o t e" as a screen-tall capsule) at larger
453 // Dynamic Type sizes — same pathology as the row badges.
454 ViewThatFits(in: .horizontal) {
455 HStack(alignment: .center, spacing: 10) {
456 titleBlock
457 Spacer(minLength: 8)
458 trailing()
459 chevron
460 }
461 VStack(alignment: .leading, spacing: 8) {
462 HStack(alignment: .center, spacing: 10) {
463 titleBlock
464 Spacer(minLength: 8)
465 chevron
466 }
467 trailing()
468 }
469 }
470 .contentShape(Rectangle())
471 .frame(minHeight: appDensity.metrics.controlMinHeight, alignment: .center)
472 }
473 .buttonStyle(.plain)
474 // A header that is also the expand/collapse control. The chevron is
475 // decorative; state and hint carry it to VoiceOver instead. No
476 // `children: .combine` here — `trailing()` may hold its own controls
477 // (Track, Pin), and combining would swallow them into the header.
478 .accessibilityAddTraits(.isHeader)
479 .accessibilityValue(isCollapsed ? "Collapsed" : "Expanded")
480 .accessibilityHint(isCollapsed ? "Expands the section" : "Collapses the section")
481
482 if !isCollapsed {
483 content()
484 .transition(.opacity.combined(with: .move(edge: .top)))
485 }
486 }
487 }
488
489 private var titleBlock: some View {
490 VStack(alignment: .leading, spacing: 3) {
491 Text(title)
492 .font(appDensity.font(.headline, weight: .semibold))
493 .foregroundStyle(.primary)
494 .fixedSize(horizontal: false, vertical: true)
495 .multilineTextAlignment(.leading)
496 if let subtitle {
497 Text(subtitle)
498 .font(appDensity.font(.caption))
499 .foregroundStyle(Color(.appTextSecondary))
500 .fixedSize(horizontal: false, vertical: true)
501 }
502 }
503 }
504
505 private var chevron: some View {
506 Image(systemName: isCollapsed ? "chevron.down" : "chevron.up")
507 .font(.caption.weight(.semibold))
508 .foregroundStyle(Color(.appTextSecondary))
509 .accessibilityHidden(true)
510 }
511}
512
513/// A horizontally scrolling row of read-only tag chips, e.g. for a tracked
514/// domain's detail view.
515struct TagChipRowView: View {
516 let tags: [String]
517
518 var body: some View {
519 ScrollView(.horizontal, showsIndicators: false) {
520 HStack(spacing: 8) {
521 ForEach(tags, id: \.self) { tag in
522 Text(tag)
523 .font(.caption)
524 .padding(.horizontal, 10)
525 .padding(.vertical, 5)
526 .background(Color(.appSurfaceElevated), in: Capsule())
527 }
528 }
529 }
530 }
531}
532
533/// A horizontally scrolling row of selectable tag chips used to filter a list,
534/// with an "All" chip to clear the selection.
535struct TagFilterChipRowView: View {
536 let tags: [String]
537 @Binding var selection: String?
538
539 var body: some View {
540 ScrollView(.horizontal, showsIndicators: false) {
541 HStack(spacing: 8) {
542 filterChip(title: "All", isSelected: selection == nil) {
543 selection = nil
544 }
545 ForEach(tags, id: \.self) { tag in
546 filterChip(title: tag, isSelected: selection == tag) {
547 selection = (selection == tag) ? nil : tag
548 }
549 }
550 }
551 }
552 }
553
554 private func filterChip(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
555 Button(action: action) {
556 Text(title)
557 .font(.caption)
558 .padding(.horizontal, 10)
559 .padding(.vertical, 5)
560 .background(isSelected ? Color(.statusInfoSurface) : Color(.appSurfaceElevated), in: Capsule())
561 .foregroundStyle(isSelected ? Color(.statusInfo) : Color.primary)
562 }
563 .buttonStyle(.plain)
564 }
565}