krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.4.1: 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}