krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.8.2: DomainDig/DomainDigUI.swift · raw

  1import SwiftUI
  2
  3#if canImport(UIKit)
  4import UIKit
  5#elseif canImport(AppKit)
  6import AppKit
  7#endif
  8
  9enum AppDensity: String, CaseIterable, Identifiable {
 10    case compact
 11    case comfortable
 12
 13    static let userDefaultsKey = "appDensity"
 14
 15    var id: String { rawValue }
 16
 17    var title: String {
 18        switch self {
 19        case .compact:
 20            return "Compact"
 21        case .comfortable:
 22            return "Comfortable"
 23        }
 24    }
 25
 26    var metrics: AppDensityMetrics {
 27        switch self {
 28        case .compact:
 29            return AppDensityMetrics(
 30                sectionSpacing: 14,
 31                cardSpacing: 6,
 32                cardPadding: 10,
 33                rowSpacing: 4,
 34                rowMinHeight: 30,
 35                controlVerticalPadding: 10,
 36                controlMinHeight: 42,
 37                cardCornerRadius: 10
 38            )
 39        case .comfortable:
 40            return AppDensityMetrics(
 41                sectionSpacing: 18,
 42                cardSpacing: 10,
 43                cardPadding: 14,
 44                rowSpacing: 7,
 45                rowMinHeight: 38,
 46                controlVerticalPadding: 14,
 47                controlMinHeight: 48,
 48                cardCornerRadius: 14
 49            )
 50        }
 51    }
 52
 53    func font(_ textStyle: Font.TextStyle, design: Font.Design = .monospaced, weight: Font.Weight? = nil) -> Font {
 54        var font = Font.system(textStyle, design: design)
 55        if let weight {
 56            font = font.weight(weight)
 57        }
 58        return font
 59    }
 60}
 61
 62struct AppDensityMetrics: Equatable {
 63    let sectionSpacing: CGFloat
 64    let cardSpacing: CGFloat
 65    let cardPadding: CGFloat
 66    let rowSpacing: CGFloat
 67    let rowMinHeight: CGFloat
 68    let controlVerticalPadding: CGFloat
 69    let controlMinHeight: CGFloat
 70    let cardCornerRadius: CGFloat
 71}
 72
 73private struct AppDensityKey: EnvironmentKey {
 74    static let defaultValue: AppDensity = .compact
 75}
 76
 77extension EnvironmentValues {
 78    var appDensity: AppDensity {
 79        get { self[AppDensityKey.self] }
 80        set { self[AppDensityKey.self] = newValue }
 81    }
 82}
 83
 84struct AppStatusBadgeModel: Equatable {
 85    let title: String
 86    let systemImage: String?
 87    let foregroundColor: Color
 88    let backgroundColor: Color
 89}
 90
 91enum AppStatusFactory {
 92    static func availability(_ status: DomainAvailabilityStatus?) -> AppStatusBadgeModel {
 93        switch status {
 94        case .available:
 95            return .init(title: "Available", systemImage: "checkmark.circle.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
 96        case .registered:
 97            return .init(title: "Registered", systemImage: "circle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
 98        case .unknown, .none:
 99            return .init(title: "Unknown", systemImage: "questionmark.circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
100        }
101    }
102
103    static func tls(sslInfo: SSLCertificateInfo?, error: String?) -> AppStatusBadgeModel {
104        if error != nil || sslInfo == nil {
105            return .init(title: "Invalid", systemImage: "xmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
106        }
107        if let sslInfo, sslInfo.daysUntilExpiry <= 14 {
108            return .init(title: "Expiring", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
109        }
110        return .init(title: "Valid", systemImage: "lock.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
111    }
112
113    static func email(_ result: EmailSecurityResult?, error: String?) -> AppStatusBadgeModel {
114        guard error == nil, let result else {
115            return .init(title: "Missing", systemImage: "minus.circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
116        }
117
118        let foundCount = [result.spf.found, result.dmarc.found, result.dkim.found].filter { $0 }.count
119        switch foundCount {
120        case 3:
121            return .init(title: "Secure", systemImage: "checkmark.shield.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
122        case 1, 2:
123            return .init(title: "Partial", systemImage: "shield.lefthalf.filled", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
124        default:
125            return .init(title: "Missing", systemImage: "minus.circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
126        }
127    }
128
129    static func change(_ summary: DomainChangeSummary?) -> AppStatusBadgeModel {
130        guard let summary else {
131            return .init(title: "Unchanged", systemImage: "circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
132        }
133        if summary.hasChanges {
134            return .init(title: "Changed", systemImage: "arrow.triangle.2.circlepath", foregroundColor: .cyan, backgroundColor: .cyan.opacity(0.16))
135        }
136        return .init(title: "Unchanged", systemImage: "checkmark.circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
137    }
138}
139
140struct AppStatusBadgeView: View {
141    @Environment(\.appDensity) private var appDensity
142
143    let model: AppStatusBadgeModel
144
145    var body: some View {
146        HStack(spacing: 6) {
147            if let systemImage = model.systemImage {
148                Image(systemName: systemImage)
149                    .font(.caption2)
150            }
151            Text(model.title)
152        }
153        .font(appDensity.font(.caption, weight: .semibold))
154        .foregroundStyle(model.foregroundColor)
155        .padding(.horizontal, 9)
156        .padding(.vertical, 5)
157        .background(model.backgroundColor)
158        .clipShape(Capsule())
159    }
160}
161
162struct AppCopyButton: View {
163    @Environment(\.appDensity) private var appDensity
164    @State private var didCopy = false
165
166    let value: String
167    let label: String
168
169    var body: some View {
170        Button {
171            AppClipboard.copy(value)
172            AppHaptics.copy()
173            withAnimation(.easeInOut(duration: 0.18)) {
174                didCopy = true
175            }
176            Task {
177                try? await Task.sleep(nanoseconds: 900_000_000)
178                await MainActor.run {
179                    withAnimation(.easeInOut(duration: 0.18)) {
180                        didCopy = false
181                    }
182                }
183            }
184        } label: {
185            Image(systemName: didCopy ? "checkmark" : "doc.on.doc")
186                .font(appDensity.font(.caption))
187                .foregroundStyle(didCopy ? Color.green : .secondary)
188                .frame(width: 30, height: 30)
189                .background(Color(.systemGray5).opacity(0.35))
190                .clipShape(RoundedRectangle(cornerRadius: 8))
191        }
192        .buttonStyle(.plain)
193        .accessibilityLabel(didCopy ? "\(label) copied" : label)
194    }
195}
196
197enum AppClipboard {
198    static func copy(_ value: String) {
199        #if canImport(UIKit)
200        UIPasteboard.general.string = value
201        #elseif canImport(AppKit)
202        NSPasteboard.general.clearContents()
203        NSPasteboard.general.setString(value, forType: .string)
204        #endif
205    }
206}
207
208enum AppHaptics {
209    static func copy() {
210        #if canImport(UIKit)
211        let generator = UINotificationFeedbackGenerator()
212        generator.notificationOccurred(.success)
213        #endif
214    }
215
216    static func refresh() {
217        #if canImport(UIKit)
218        let generator = UIImpactFeedbackGenerator(style: .light)
219        generator.impactOccurred()
220        #endif
221    }
222
223    static func track() {
224        #if canImport(UIKit)
225        let generator = UIImpactFeedbackGenerator(style: .soft)
226        generator.impactOccurred()
227        #endif
228    }
229}
230
231struct EmptyStateCardView: View {
232    @Environment(\.appDensity) private var appDensity
233
234    let title: String
235    let message: String
236    let suggestion: String
237    let systemImage: String
238    let showsCardBackground: Bool
239
240    init(
241        title: String,
242        message: String,
243        suggestion: String,
244        systemImage: String,
245        showsCardBackground: Bool = true
246    ) {
247        self.title = title
248        self.message = message
249        self.suggestion = suggestion
250        self.systemImage = systemImage
251        self.showsCardBackground = showsCardBackground
252    }
253
254    var body: some View {
255        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
256            Label(title, systemImage: systemImage)
257                .font(appDensity.font(.headline, weight: .semibold))
258                .foregroundStyle(.primary)
259
260            Text(message)
261                .font(appDensity.font(.body))
262                .foregroundStyle(.secondary)
263                .fixedSize(horizontal: false, vertical: true)
264
265            Text(suggestion)
266                .font(appDensity.font(.caption))
267                .foregroundStyle(.cyan)
268        }
269        .frame(maxWidth: .infinity, alignment: .leading)
270        .padding(appDensity.metrics.cardPadding)
271        .background(showsCardBackground ? Color(.systemGray6).opacity(0.45) : Color.clear)
272        .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
273    }
274}
275
276struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View {
277    @Environment(\.appDensity) private var appDensity
278
279    let title: String
280    @Binding var isCollapsed: Bool
281    let subtitle: String?
282    @ViewBuilder let trailing: () -> HeaderTrailing
283    @ViewBuilder let content: () -> Content
284
285    init(
286        title: String,
287        isCollapsed: Binding<Bool>,
288        subtitle: String? = nil,
289        @ViewBuilder trailing: @escaping () -> HeaderTrailing = { EmptyView() },
290        @ViewBuilder content: @escaping () -> Content
291    ) {
292        self.title = title
293        self._isCollapsed = isCollapsed
294        self.subtitle = subtitle
295        self.trailing = trailing
296        self.content = content
297    }
298
299    var body: some View {
300        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
301            Button {
302                withAnimation(.easeInOut(duration: 0.2)) {
303                    isCollapsed.toggle()
304                }
305            } label: {
306                HStack(alignment: .center, spacing: 10) {
307                    VStack(alignment: .leading, spacing: 3) {
308                        Text(title)
309                            .font(appDensity.font(.headline, design: .default, weight: .semibold))
310                            .foregroundStyle(.white)
311                        if let subtitle {
312                            Text(subtitle)
313                                .font(appDensity.font(.caption))
314                                .foregroundStyle(.secondary)
315                        }
316                    }
317                    Spacer(minLength: 8)
318                    trailing()
319                    Image(systemName: isCollapsed ? "chevron.down" : "chevron.up")
320                        .font(.caption.weight(.semibold))
321                        .foregroundStyle(.secondary)
322                }
323                .contentShape(Rectangle())
324                .frame(minHeight: appDensity.metrics.controlMinHeight, alignment: .center)
325            }
326            .buttonStyle(.plain)
327
328            if !isCollapsed {
329                content()
330                    .transition(.opacity.combined(with: .move(edge: .top)))
331            }
332        }
333    }
334}
335
336/// A horizontally scrolling row of read-only tag chips, e.g. for a tracked
337/// domain's detail view.
338struct TagChipRowView: View {
339    let tags: [String]
340
341    var body: some View {
342        ScrollView(.horizontal, showsIndicators: false) {
343            HStack(spacing: 8) {
344                ForEach(tags, id: \.self) { tag in
345                    Text(tag)
346                        .font(.caption)
347                        .padding(.horizontal, 10)
348                        .padding(.vertical, 5)
349                        .background(Color(.systemGray5).opacity(0.6), in: Capsule())
350                }
351            }
352        }
353    }
354}
355
356/// A horizontally scrolling row of selectable tag chips used to filter a list,
357/// with an "All" chip to clear the selection.
358struct TagFilterChipRowView: View {
359    let tags: [String]
360    @Binding var selection: String?
361
362    var body: some View {
363        ScrollView(.horizontal, showsIndicators: false) {
364            HStack(spacing: 8) {
365                filterChip(title: "All", isSelected: selection == nil) {
366                    selection = nil
367                }
368                ForEach(tags, id: \.self) { tag in
369                    filterChip(title: tag, isSelected: selection == tag) {
370                        selection = (selection == tag) ? nil : tag
371                    }
372                }
373            }
374        }
375    }
376
377    private func filterChip(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
378        Button(action: action) {
379            Text(title)
380                .font(.caption)
381                .padding(.horizontal, 10)
382                .padding(.vertical, 5)
383                .background(isSelected ? Color.cyan.opacity(0.3) : Color(.systemGray5).opacity(0.6), in: Capsule())
384                .foregroundStyle(isSelected ? Color.cyan : Color.primary)
385        }
386        .buttonStyle(.plain)
387    }
388}