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}