Commit 36ea668d7f

36ea668d7fc9070770973b0bc329f4319bf43cab

parent: 3846dc2f5d

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-22 06:20 UTC

feat(v2​.6​.0): refine ​UI, density, and usability

• add compact/comfortable density modes
• implement collapsible sections and sticky summary header
• improve visual status indicators and copy actions
• refine watchlist and history UX with swipe actions
• reorganize settings and improve empty states
• add subtle animations and accessibility improvements

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +4 −4
@@ -354,7 +354,7 @@
354 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 354 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
355 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 355 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
356 CODE_SIGN_STYLE = Automatic; 356 CODE_SIGN_STYLE = Automatic;
357 CURRENT_PROJECT_VERSION = 18; 357 CURRENT_PROJECT_VERSION = 19;
358 DEVELOPMENT_TEAM = ZCNAX3VL9D; 358 DEVELOPMENT_TEAM = ZCNAX3VL9D;
359 ENABLE_PREVIEWS = YES; 359 ENABLE_PREVIEWS = YES;
360 GENERATE_INFOPLIST_FILE = YES; 360 GENERATE_INFOPLIST_FILE = YES;
@@ -371,7 +371,7 @@
371 "$(inherited)", 371 "$(inherited)",
372 "@executable_path/Frameworks", 372 "@executable_path/Frameworks",
373 ); 373 );
374 MARKETING_VERSION = 2.5.0; 374 MARKETING_VERSION = 2.6.0;
375 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 375 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
376 PRODUCT_NAME = "$(TARGET_NAME)"; 376 PRODUCT_NAME = "$(TARGET_NAME)";
377 STRING_CATALOG_GENERATE_SYMBOLS = YES; 377 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -390,7 +390,7 @@
390 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 390 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
391 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 391 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
392 CODE_SIGN_STYLE = Automatic; 392 CODE_SIGN_STYLE = Automatic;
393 CURRENT_PROJECT_VERSION = 18; 393 CURRENT_PROJECT_VERSION = 19;
394 DEVELOPMENT_TEAM = ZCNAX3VL9D; 394 DEVELOPMENT_TEAM = ZCNAX3VL9D;
395 ENABLE_PREVIEWS = YES; 395 ENABLE_PREVIEWS = YES;
396 GENERATE_INFOPLIST_FILE = YES; 396 GENERATE_INFOPLIST_FILE = YES;
@@ -407,7 +407,7 @@
407 "$(inherited)", 407 "$(inherited)",
408 "@executable_path/Frameworks", 408 "@executable_path/Frameworks",
409 ); 409 );
410 MARKETING_VERSION = 2.5.0; 410 MARKETING_VERSION = 2.6.0;
411 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 411 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
412 PRODUCT_NAME = "$(TARGET_NAME)"; 412 PRODUCT_NAME = "$(TARGET_NAME)";
413 STRING_CATALOG_GENERATE_SYMBOLS = YES; 413 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/BatchResultsView.swift +28 −26
@@ -1,11 +1,12 @@
1import SwiftUI 1import SwiftUI
2 2
3struct BatchResultsView: View { 3struct BatchResultsView: View {
4 @Environment(\.appDensity) private var appDensity
4 @Bindable var viewModel: DomainViewModel 5 @Bindable var viewModel: DomainViewModel
5 let title: String 6 let title: String
6 7
7 var body: some View { 8 var body: some View {
8 VStack(alignment: .leading, spacing: 12) { 9 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
9 HStack(alignment: .top) { 10 HStack(alignment: .top) {
10 SectionTitleView(title: title) 11 SectionTitleView(title: title)
11 Spacer() 12 Spacer()
@@ -15,18 +16,23 @@ struct BatchResultsView: View {
15 .tint(.cyan) 16 .tint(.cyan)
16 .frame(width: 120) 17 .frame(width: 120)
17 Text(viewModel.batchProgressLabel) 18 Text(viewModel.batchProgressLabel)
18 .font(.system(.caption2, design: .monospaced)) 19 .font(appDensity.font(.caption2))
19 .foregroundStyle(.secondary) 20 .foregroundStyle(.secondary)
20 } 21 }
21 } else if !viewModel.batchResults.isEmpty { 22 } else if !viewModel.batchResults.isEmpty {
22 Text("\(viewModel.batchResults.count) domains") 23 Text("\(viewModel.batchResults.count) domains")
23 .font(.system(.caption2, design: .monospaced)) 24 .font(appDensity.font(.caption2))
24 .foregroundStyle(.secondary) 25 .foregroundStyle(.secondary)
25 } 26 }
26 } 27 }
27 28
28 if viewModel.batchResults.isEmpty { 29 if viewModel.batchResults.isEmpty {
29 MessageCardView(text: "No batch results yet", isError: false) 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 )
30 } else { 36 } else {
31 CardView(allowsHorizontalScroll: false) { 37 CardView(allowsHorizontalScroll: false) {
32 ForEach(viewModel.batchResults) { result in 38 ForEach(viewModel.batchResults) { result in
@@ -48,50 +54,46 @@ struct BatchResultsView: View {
48} 54}
49 55
50struct BatchResultRowView: View { 56struct BatchResultRowView: View {
57 @Environment(\.appDensity) private var appDensity
51 let result: BatchLookupResult 58 let result: BatchLookupResult
52 59
53 var body: some View { 60 var body: some View {
54 VStack(alignment: .leading, spacing: 6) { 61 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
55 HStack(alignment: .firstTextBaseline, spacing: 8) { 62 HStack(alignment: .firstTextBaseline, spacing: 8) {
56 Text(result.domain) 63 Text(result.domain)
57 .font(.system(.callout, design: .monospaced)) 64 .font(appDensity.font(.callout))
58 .foregroundStyle(.primary) 65 .foregroundStyle(.primary)
59 .lineLimit(1) 66 .lineLimit(1)
60 Spacer(minLength: 8) 67 Spacer(minLength: 8)
61 Text(result.resultSource.label.lowercased()) 68 Text(result.resultSource.label.lowercased())
62 .font(.system(.caption2, design: .monospaced)) 69 .font(appDensity.font(.caption2))
63 .foregroundStyle(.secondary) 70 .foregroundStyle(.secondary)
64 Text(result.quickStatus) 71 AppStatusBadgeView(model: quickStatusBadge)
65 .font(.system(.caption2, design: .monospaced))
66 .foregroundStyle(quickStatusColor)
67 .padding(.horizontal, 8)
68 .padding(.vertical, 4)
69 .background(quickStatusColor.opacity(0.16))
70 .clipShape(Capsule())
71 } 72 }
72 73
73 HStack(spacing: 10) { 74 HStack(spacing: 10) {
74 Text(availabilityText) 75 AppStatusBadgeView(model: AppStatusFactory.availability(result.availability))
75 Text(result.primaryIP ?? "No IP") 76 Text(result.primaryIP ?? "No IP")
76 Text(result.timestamp.formatted(date: .abbreviated, time: .shortened)) 77 Text(result.timestamp.formatted(date: .abbreviated, time: .shortened))
77 } 78 }
78 .font(.system(.caption2, design: .monospaced)) 79 .font(appDensity.font(.caption2))
79 .foregroundStyle(.secondary) 80 .foregroundStyle(.secondary)
80 81
81 if let summaryMessage = result.summaryMessage { 82 if let summaryMessage = result.summaryMessage {
82 Text(summaryMessage) 83 Text(summaryMessage)
83 .font(.system(.caption2, design: .monospaced)) 84 .font(appDensity.font(.caption2))
84 .foregroundStyle(.secondary) 85 .foregroundStyle(.secondary)
85 } 86 }
86 87
87 if let errorMessage = result.errorMessage { 88 if let errorMessage = result.errorMessage {
88 Text(errorMessage) 89 Text(errorMessage)
89 .font(.system(.caption2, design: .monospaced)) 90 .font(appDensity.font(.caption2))
90 .foregroundStyle(result.status == .failed ? .red : .secondary) 91 .foregroundStyle(result.status == .failed ? .red : .secondary)
91 } 92 }
92 } 93 }
93 .frame(maxWidth: .infinity, alignment: .leading) 94 .frame(maxWidth: .infinity, alignment: .leading)
94 .padding(.vertical, 4) 95 .padding(.vertical, 4)
96 .frame(minHeight: appDensity.metrics.rowMinHeight + 12, alignment: .topLeading)
95 } 97 }
96 98
97 private var availabilityText: String { 99 private var availabilityText: String {
@@ -105,25 +107,25 @@ struct BatchResultRowView: View {
105 } 107 }
106 } 108 }
107 109
108 private var quickStatusColor: Color { 110 private var quickStatusBadge: AppStatusBadgeModel {
109 switch result.status { 111 switch result.status {
110 case .pending: 112 case .pending:
111 return .secondary 113 return .init(title: "Pending", systemImage: "clock", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
112 case .running: 114 case .running:
113 return .cyan 115 return .init(title: "Running", systemImage: "arrow.clockwise", foregroundColor: .cyan, backgroundColor: .cyan.opacity(0.16))
114 case .completed: 116 case .completed:
115 if result.changeSeverity == .high || result.certificateWarningLevel == .critical { 117 if result.changeSeverity == .high || result.certificateWarningLevel == .critical {
116 return .red 118 return .init(title: "High", systemImage: "exclamationmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
117 } 119 }
118 if result.changeSeverity == .medium || result.certificateWarningLevel == .warning { 120 if result.changeSeverity == .medium || result.certificateWarningLevel == .warning {
119 return .yellow 121 return .init(title: "Warning", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
120 } 122 }
121 if result.quickStatus == "Changed" { 123 if result.quickStatus == "Changed" {
122 return .blue 124 return .init(title: "Changed", systemImage: "arrow.triangle.2.circlepath", foregroundColor: .cyan, backgroundColor: .cyan.opacity(0.16))
123 } 125 }
124 return .green 126 return .init(title: "Stable", systemImage: "checkmark.circle.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
125 case .failed: 127 case .failed:
126 return .red 128 return .init(title: "Failed", systemImage: "xmark.circle.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
127 } 129 }
128 } 130 }
129} 131}
DomainDig/ContentView.swift +416 −182
@@ -8,7 +8,18 @@ enum LookupInputMode: String, CaseIterable, Identifiable {
8 var id: String { rawValue } 8 var id: String { rawValue }
9} 9}
10 10
11enum ResultSection: String, Hashable {
12 case domain
13 case ownership
14 case dns
15 case web
16 case email
17 case network
18 case subdomains
19}
20
11struct ContentView: View { 21struct ContentView: View {
22 @Environment(\.appDensity) private var appDensity
12 @State private var viewModel = DomainViewModel() 23 @State private var viewModel = DomainViewModel()
13 @State private var navigationPath = NavigationPath() 24 @State private var navigationPath = NavigationPath()
14 @FocusState private var domainFieldFocused: Bool 25 @FocusState private var domainFieldFocused: Bool
@@ -18,6 +29,7 @@ struct ContentView: View {
18 @State private var editingTrackedDomain: TrackedDomain? 29 @State private var editingTrackedDomain: TrackedDomain?
19 @State private var showTrackLimitAlert = false 30 @State private var showTrackLimitAlert = false
20 @State private var inputMode: LookupInputMode = .single 31 @State private var inputMode: LookupInputMode = .single
32 @State private var collapsedSections: Set<ResultSection> = [.network]
21 33
22 var body: some View { 34 var body: some View {
23 NavigationStack(path: $navigationPath) { 35 NavigationStack(path: $navigationPath) {
@@ -26,21 +38,22 @@ struct ContentView: View {
26 inputSection 38 inputSection
27 if !viewModel.batchResults.isEmpty || viewModel.batchLookupRunning { 39 if !viewModel.batchResults.isEmpty || viewModel.batchLookupRunning {
28 batchSection 40 batchSection
29 .padding(.top, 8) 41 .padding(.top, appDensity.metrics.cardSpacing)
30 } 42 }
31 if viewModel.hasRun { 43 if viewModel.hasRun {
32 actionButtons 44 actionButtons
33 if let statusMessage = viewModel.currentStatusMessage ?? (viewModel.currentResultSource != .live ? viewModel.currentResultSource.label : nil) { 45 if let statusMessage = resultStatusMessage {
34 LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource) 46 LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource)
35 .padding(.top, 8) 47 .padding(.top, appDensity.metrics.cardSpacing)
36 } 48 }
37 SummaryView(fields: viewModel.summaryFields) 49 SummaryView(fields: viewModel.summaryFields)
38 .padding(.top, 8) 50 .padding(.top, appDensity.metrics.cardSpacing)
39 if let changeSummary = viewModel.currentChangeSummary { 51 if let changeSummary = viewModel.currentChangeSummary {
40 DomainChangeSummaryView(summary: changeSummary) 52 DomainChangeSummaryView(summary: changeSummary)
41 .padding(.top, 12) 53 .padding(.top, appDensity.metrics.cardSpacing)
42 } 54 }
43 DomainSectionView( 55 DomainSectionView(
56 isCollapsed: sectionCollapsedBinding(.domain),
44 rows: viewModel.domainRows, 57 rows: viewModel.domainRows,
45 suggestions: viewModel.suggestionRows, 58 suggestions: viewModel.suggestionRows,
46 showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading, 59 showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading,
@@ -63,38 +76,42 @@ struct ContentView: View {
63 editingTrackedDomain = trackedDomain 76 editingTrackedDomain = trackedDomain
64 } 77 }
65 ) 78 )
66 .padding(.top, 16) 79 .padding(.top, appDensity.metrics.sectionSpacing)
67 OwnershipSectionView( 80 OwnershipSectionView(
81 isCollapsed: sectionCollapsedBinding(.ownership),
68 rows: viewModel.ownershipRows, 82 rows: viewModel.ownershipRows,
69 loading: viewModel.ownershipLoading, 83 loading: viewModel.ownershipLoading,
70 error: viewModel.ownershipError, 84 error: viewModel.ownershipError,
71 showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory) 85 showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory)
72 ) 86 )
73 .padding(.top, 16) 87 .padding(.top, appDensity.metrics.sectionSpacing)
74 SubdomainsSectionView( 88 SubdomainsSectionView(
89 isCollapsed: sectionCollapsedBinding(.subdomains),
75 rows: viewModel.subdomainRows, 90 rows: viewModel.subdomainRows,
76 loading: viewModel.subdomainsLoading, 91 loading: viewModel.subdomainsLoading,
77 error: viewModel.subdomainsError, 92 error: viewModel.subdomainsError,
78 showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains) 93 showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains)
79 ) 94 )
80 .padding(.top, 16) 95 .padding(.top, appDensity.metrics.sectionSpacing)
81 if !viewModel.currentDiffSections.isEmpty { 96 if !viewModel.currentDiffSections.isEmpty {
82 DomainDiffView( 97 DomainDiffView(
83 title: "Latest Changes", 98 title: "Latest Changes",
84 sections: viewModel.currentDiffSections, 99 sections: viewModel.currentDiffSections,
85 showsUnchanged: false 100 showsUnchanged: false
86 ) 101 )
87 .padding(.top, 16) 102 .padding(.top, appDensity.metrics.sectionSpacing)
88 } 103 }
89 DNSSectionView( 104 DNSSectionView(
105 isCollapsed: sectionCollapsedBinding(.dns),
90 dnssecLabel: viewModel.dnssecLabel, 106 dnssecLabel: viewModel.dnssecLabel,
91 sections: viewModel.dnsRows, 107 sections: viewModel.dnsRows,
92 ptrMessage: viewModel.ptrMessage, 108 ptrMessage: viewModel.ptrMessage,
93 loading: viewModel.dnsLoading || viewModel.ptrLoading, 109 loading: viewModel.dnsLoading || viewModel.ptrLoading,
94 sectionError: viewModel.dnsError 110 sectionError: viewModel.dnsError
95 ) 111 )
96 .padding(.top, 16) 112 .padding(.top, appDensity.metrics.sectionSpacing)
97 WebSectionView( 113 WebSectionView(
114 isCollapsed: sectionCollapsedBinding(.web),
98 certificateRows: viewModel.webCertificateRows, 115 certificateRows: viewModel.webCertificateRows,
99 sslInfo: viewModel.sslInfo, 116 sslInfo: viewModel.sslInfo,
100 sslLoading: viewModel.sslLoading || viewModel.hstsLoading, 117 sslLoading: viewModel.sslLoading || viewModel.hstsLoading,
@@ -108,14 +125,16 @@ struct ContentView: View {
108 redirectError: viewModel.redirectChainError, 125 redirectError: viewModel.redirectChainError,
109 finalURL: viewModel.currentSnapshot.redirectChain.last?.url 126 finalURL: viewModel.currentSnapshot.redirectChain.last?.url
110 ) 127 )
111 .padding(.top, 16) 128 .padding(.top, appDensity.metrics.sectionSpacing)
112 EmailSectionView( 129 EmailSectionView(
130 isCollapsed: sectionCollapsedBinding(.email),
113 rows: viewModel.emailRows, 131 rows: viewModel.emailRows,
114 loading: viewModel.emailSecurityLoading, 132 loading: viewModel.emailSecurityLoading,
115 error: viewModel.emailSecurityError 133 error: viewModel.emailSecurityError
116 ) 134 )
117 .padding(.top, 16) 135 .padding(.top, appDensity.metrics.sectionSpacing)
118 NetworkSectionView( 136 NetworkSectionView(
137 isCollapsed: sectionCollapsedBinding(.network),
119 reachabilityRows: viewModel.reachabilityRows, 138 reachabilityRows: viewModel.reachabilityRows,
120 reachabilityLoading: viewModel.reachabilityLoading, 139 reachabilityLoading: viewModel.reachabilityLoading,
121 reachabilityError: viewModel.reachabilityError, 140 reachabilityError: viewModel.reachabilityError,
@@ -134,7 +153,7 @@ struct ContentView: View {
134 customPortInput: $customPortInput, 153 customPortInput: $customPortInput,
135 onScanCustomPorts: runCustomPortScan 154 onScanCustomPorts: runCustomPortScan
136 ) 155 )
137 .padding(.top, 16) 156 .padding(.top, appDensity.metrics.sectionSpacing)
138 } else if !viewModel.recentSearches.isEmpty { 157 } else if !viewModel.recentSearches.isEmpty {
139 recentSearchesSection 158 recentSearchesSection
140 } 159 }
@@ -142,7 +161,34 @@ struct ContentView: View {
142 .padding(.horizontal) 161 .padding(.horizontal)
143 .padding(.bottom, 32) 162 .padding(.bottom, 32)
144 } 163 }
145 .background(Color.black) 164 .safeAreaInset(edge: .top) {
165 if viewModel.hasRun {
166 StickyLookupSummaryView(
167 domain: viewModel.searchedDomain,
168 availability: viewModel.availabilityResult?.status,
169 primaryIP: currentPrimaryIP,
170 sslInfo: viewModel.sslInfo,
171 sslError: viewModel.sslError,
172 emailSecurity: viewModel.emailSecurity,
173 emailError: viewModel.emailSecurityError,
174 changeSummary: viewModel.currentChangeSummary
175 )
176 .padding(.horizontal)
177 .padding(.top, 6)
178 .background {
179 Rectangle()
180 .fill(.ultraThinMaterial)
181 .opacity(0.96)
182 }
183 }
184 }
185 .background(
186 LinearGradient(
187 colors: [Color.black, Color(.systemGray6).opacity(0.12)],
188 startPoint: .top,
189 endPoint: .bottom
190 )
191 )
146 .navigationTitle("DomainDig") 192 .navigationTitle("DomainDig")
147 .toolbarColorScheme(.dark, for: .navigationBar) 193 .toolbarColorScheme(.dark, for: .navigationBar)
148 .preferredColorScheme(.dark) 194 .preferredColorScheme(.dark)
@@ -176,7 +222,7 @@ struct ContentView: View {
176 } 222 }
177 223
178 NavigationLink { 224 NavigationLink {
179 SettingsView() 225 SettingsView(viewModel: viewModel)
180 } label: { 226 } label: {
181 Label("Settings", systemImage: "gearshape") 227 Label("Settings", systemImage: "gearshape")
182 } 228 }
@@ -190,6 +236,9 @@ struct ContentView: View {
190 .onAppear { 236 .onAppear {
191 domainFieldFocused = true 237 domainFieldFocused = true
192 } 238 }
239 .onChange(of: viewModel.searchedDomain) { _, _ in
240 collapsedSections = defaultCollapsedSections
241 }
193 .onChange(of: viewModel.rerunNavigationToken) { _, _ in 242 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
194 navigationPath = NavigationPath() 243 navigationPath = NavigationPath()
195 domainFieldFocused = false 244 domainFieldFocused = false
@@ -228,7 +277,7 @@ struct ContentView: View {
228 } 277 }
229 278
230 private var inputSection: some View { 279 private var inputSection: some View {
231 VStack(spacing: 12) { 280 VStack(spacing: appDensity.metrics.cardSpacing + 2) {
232 Picker("Mode", selection: $inputMode) { 281 Picker("Mode", selection: $inputMode) {
233 Text("Single").tag(LookupInputMode.single) 282 Text("Single").tag(LookupInputMode.single)
234 Text("Bulk").tag(LookupInputMode.bulk) 283 Text("Bulk").tag(LookupInputMode.bulk)
@@ -237,13 +286,14 @@ struct ContentView: View {
237 286
238 if inputMode == .single { 287 if inputMode == .single {
239 TextField("e.g. cleberg.net", text: $viewModel.domain) 288 TextField("e.g. cleberg.net", text: $viewModel.domain)
240 .font(.system(.title3, design: .monospaced)) 289 .font(appDensity.font(.title3, design: .monospaced))
241 .textInputAutocapitalization(.never) 290 .textInputAutocapitalization(.never)
242 .autocorrectionDisabled() 291 .autocorrectionDisabled()
243 .keyboardType(.URL) 292 .keyboardType(.URL)
244 .padding(12) 293 .padding(.horizontal, 12)
294 .padding(.vertical, appDensity.metrics.controlVerticalPadding)
245 .background(Color(.systemGray6)) 295 .background(Color(.systemGray6))
246 .cornerRadius(8) 296 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
247 .focused($domainFieldFocused) 297 .focused($domainFieldFocused)
248 .onSubmit { viewModel.run() } 298 .onSubmit { viewModel.run() }
249 299
@@ -252,16 +302,16 @@ struct ContentView: View {
252 viewModel.run() 302 viewModel.run()
253 } label: { 303 } label: {
254 Text("Run") 304 Text("Run")
255 .font(.headline) 305 .font(appDensity.font(.headline, design: .default, weight: .semibold))
256 .frame(maxWidth: .infinity) 306 .frame(maxWidth: .infinity)
257 .padding(.vertical, 12) 307 .frame(minHeight: appDensity.metrics.controlMinHeight)
258 } 308 }
259 .buttonStyle(.borderedProminent) 309 .buttonStyle(.borderedProminent)
260 .disabled(viewModel.trimmedDomain.isEmpty) 310 .disabled(viewModel.trimmedDomain.isEmpty)
261 } else { 311 } else {
262 VStack(alignment: .leading, spacing: 8) { 312 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
263 Text("Paste domains separated by new lines or commas.") 313 Text("Paste domains separated by new lines or commas.")
264 .font(.system(.caption, design: .monospaced)) 314 .font(appDensity.font(.caption))
265 .foregroundStyle(.secondary) 315 .foregroundStyle(.secondary)
266 316
267 TextField( 317 TextField(
@@ -269,30 +319,31 @@ struct ContentView: View {
269 text: $viewModel.bulkInput, 319 text: $viewModel.bulkInput,
270 axis: .vertical 320 axis: .vertical
271 ) 321 )
272 .font(.system(.body, design: .monospaced)) 322 .font(appDensity.font(.body))
273 .textInputAutocapitalization(.never) 323 .textInputAutocapitalization(.never)
274 .autocorrectionDisabled() 324 .autocorrectionDisabled()
275 .keyboardType(.URL) 325 .keyboardType(.URL)
276 .lineLimit(4...10) 326 .lineLimit(4...10)
277 .padding(12) 327 .padding(.horizontal, 12)
328 .padding(.vertical, appDensity.metrics.controlVerticalPadding)
278 .background(Color(.systemGray6)) 329 .background(Color(.systemGray6))
279 .cornerRadius(8) 330 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
280 331
281 Button { 332 Button {
282 domainFieldFocused = false 333 domainFieldFocused = false
283 viewModel.runBulkLookup() 334 viewModel.runBulkLookup()
284 } label: { 335 } label: {
285 Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch") 336 Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch")
286 .font(.headline) 337 .font(appDensity.font(.headline, design: .default, weight: .semibold))
287 .frame(maxWidth: .infinity) 338 .frame(maxWidth: .infinity)
288 .padding(.vertical, 12) 339 .frame(minHeight: appDensity.metrics.controlMinHeight)
289 } 340 }
290 .buttonStyle(.borderedProminent) 341 .buttonStyle(.borderedProminent)
291 .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning) 342 .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning)
292 } 343 }
293 } 344 }
294 } 345 }
295 .padding(.vertical, 16) 346 .padding(.vertical, appDensity.metrics.sectionSpacing)
296 } 347 }
297 348
298 private var actionButtons: some View { 349 private var actionButtons: some View {
@@ -303,7 +354,7 @@ struct ContentView: View {
303 viewModel.toggleSavedDomain() 354 viewModel.toggleSavedDomain()
304 } label: { 355 } label: {
305 Image(systemName: viewModel.isCurrentDomainSaved ? "bookmark.fill" : "bookmark") 356 Image(systemName: viewModel.isCurrentDomainSaved ? "bookmark.fill" : "bookmark")
306 .font(.system(.body)) 357 .font(appDensity.font(.body, design: .default))
307 .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary) 358 .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary)
308 } 359 }
309 Menu { 360 Menu {
@@ -318,7 +369,7 @@ struct ContentView: View {
318 } 369 }
319 } label: { 370 } label: {
320 Image(systemName: "square.and.arrow.up") 371 Image(systemName: "square.and.arrow.up")
321 .font(.system(.body)) 372 .font(appDensity.font(.body, design: .default))
322 .foregroundStyle(.secondary) 373 .foregroundStyle(.secondary)
323 } 374 }
324 } 375 }
@@ -326,7 +377,7 @@ struct ContentView: View {
326 } 377 }
327 378
328 private var batchSection: some View { 379 private var batchSection: some View {
329 VStack(alignment: .leading, spacing: 12) { 380 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
330 HStack { 381 HStack {
331 Spacer() 382 Spacer()
332 if viewModel.batchLookupRunning { 383 if viewModel.batchLookupRunning {
@@ -334,7 +385,7 @@ struct ContentView: View {
334 viewModel.cancelBatchLookup() 385 viewModel.cancelBatchLookup()
335 } 386 }
336 .buttonStyle(.bordered) 387 .buttonStyle(.bordered)
337 .font(.system(.caption, design: .monospaced)) 388 .font(appDensity.font(.caption))
338 } 389 }
339 if !viewModel.currentBatchResultEntries.isEmpty { 390 if !viewModel.currentBatchResultEntries.isEmpty {
340 Menu { 391 Menu {
@@ -349,7 +400,7 @@ struct ContentView: View {
349 } 400 }
350 } label: { 401 } label: {
351 Label("Export", systemImage: "square.and.arrow.up") 402 Label("Export", systemImage: "square.and.arrow.up")
352 .font(.system(.caption, design: .monospaced)) 403 .font(appDensity.font(.caption))
353 } 404 }
354 .buttonStyle(.bordered) 405 .buttonStyle(.bordered)
355 } 406 }
@@ -363,16 +414,16 @@ struct ContentView: View {
363 } 414 }
364 415
365 private var recentSearchesSection: some View { 416 private var recentSearchesSection: some View {
366 VStack(alignment: .leading, spacing: 8) { 417 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
367 HStack { 418 HStack {
368 Text("RECENT") 419 Text("RECENT")
369 .font(.system(.caption2, design: .monospaced)) 420 .font(appDensity.font(.caption2))
370 .foregroundStyle(.secondary) 421 .foregroundStyle(.secondary)
371 Spacer() 422 Spacer()
372 Button("Clear") { 423 Button("Clear") {
373 viewModel.clearRecentSearches() 424 viewModel.clearRecentSearches()
374 } 425 }
375 .font(.system(.caption2, design: .monospaced)) 426 .font(appDensity.font(.caption2))
376 .foregroundStyle(.secondary) 427 .foregroundStyle(.secondary)
377 } 428 }
378 429
@@ -383,17 +434,17 @@ struct ContentView: View {
383 viewModel.run() 434 viewModel.run()
384 } label: { 435 } label: {
385 Text(domain) 436 Text(domain)
386 .font(.system(.callout, design: .monospaced)) 437 .font(appDensity.font(.callout))
387 .foregroundStyle(.primary) 438 .foregroundStyle(.primary)
388 .frame(maxWidth: .infinity, alignment: .leading) 439 .frame(maxWidth: .infinity, alignment: .leading)
389 .padding(.vertical, 6) 440 .padding(.vertical, 8)
390 .padding(.horizontal, 10) 441 .padding(.horizontal, 10)
391 .background(Color(.systemGray6).opacity(0.5)) 442 .background(Color(.systemGray6).opacity(0.5))
392 .cornerRadius(6) 443 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
393 } 444 }
394 } 445 }
395 } 446 }
396 .padding(.top, 8) 447 .padding(.top, appDensity.metrics.cardSpacing)
397 } 448 }
398 449
399 private func runCustomPortScan() { 450 private func runCustomPortScan() {
@@ -468,37 +519,125 @@ struct ContentView: View {
468 519
469 return (filename, data) 520 return (filename, data)
470 } 521 }
522
523 private var defaultCollapsedSections: Set<ResultSection> {
524 var sections: Set<ResultSection> = []
525 if viewModel.standardPortRows.count + viewModel.customPortRows.count > 6 || currentPrimaryIP == nil {
526 sections.insert(.network)
527 }
528 return sections
529 }
530
531 private var currentPrimaryIP: String? {
532 viewModel.currentSnapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
533 }
534
535 private var resultStatusMessage: String? {
536 if let currentStatusMessage = viewModel.currentStatusMessage {
537 return currentStatusMessage
538 }
539
540 if viewModel.currentResultSource != .live {
541 return viewModel.currentResultSource.label
542 }
543
544 return nil
545 }
546
547 private func sectionCollapsedBinding(_ section: ResultSection) -> Binding<Bool> {
548 Binding(
549 get: { collapsedSections.contains(section) },
550 set: { isCollapsed in
551 if isCollapsed {
552 collapsedSections.insert(section)
553 } else {
554 collapsedSections.remove(section)
555 }
556 }
557 )
558 }
471} 559}
472 560
473struct SummaryView: View { 561struct SummaryView: View {
562 @Environment(\.appDensity) private var appDensity
474 let fields: [SummaryFieldViewData] 563 let fields: [SummaryFieldViewData]
475 564
476 var body: some View { 565 var body: some View {
477 VStack(alignment: .leading, spacing: 12) { 566 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
478 SectionTitleView(title: "Summary") 567 SectionTitleView(title: "Summary")
479 LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { 568 LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: appDensity.metrics.cardSpacing) {
480 ForEach(fields) { field in 569 ForEach(fields) { field in
481 VStack(alignment: .leading, spacing: 4) { 570 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing) {
482 Text(field.label) 571 Text(field.label)
483 .font(.system(.caption2, design: .monospaced)) 572 .font(appDensity.font(.caption2))
484 .foregroundStyle(.secondary) 573 .foregroundStyle(.secondary)
485 Text(field.value) 574 Text(field.value)
486 .font(.system(.caption, design: .monospaced)) 575 .font(appDensity.font(.caption))
487 .foregroundStyle(ResultColors.color(for: field.tone)) 576 .foregroundStyle(ResultColors.color(for: field.tone))
488 .lineLimit(2) 577 .lineLimit(2)
489 .textSelection(.enabled) 578 .textSelection(.enabled)
490 } 579 }
580 .frame(minHeight: appDensity.metrics.rowMinHeight + 12, alignment: .topLeading)
491 .frame(maxWidth: .infinity, alignment: .leading) 581 .frame(maxWidth: .infinity, alignment: .leading)
492 .padding(10) 582 .padding(appDensity.metrics.cardPadding)
493 .background(Color(.systemGray6).opacity(0.5)) 583 .background(Color(.systemGray6).opacity(0.5))
494 .cornerRadius(6) 584 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
495 } 585 }
496 } 586 }
497 } 587 }
498 } 588 }
499} 589}
500 590
591struct StickyLookupSummaryView: View {
592 @Environment(\.appDensity) private var appDensity
593
594 let domain: String
595 let availability: DomainAvailabilityStatus?
596 let primaryIP: String?
597 let sslInfo: SSLCertificateInfo?
598 let sslError: String?
599 let emailSecurity: EmailSecurityResult?
600 let emailError: String?
601 let changeSummary: DomainChangeSummary?
602
603 var body: some View {
604 CardView(allowsHorizontalScroll: false) {
605 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
606 HStack(alignment: .center, spacing: 10) {
607 Text(domain)
608 .font(appDensity.font(.headline, weight: .semibold))
609 .foregroundStyle(.primary)
610 .lineLimit(1)
611 Spacer(minLength: 6)
612 AppCopyButton(value: domain, label: "Copy domain")
613 }
614
615 ScrollView(.horizontal, showsIndicators: false) {
616 HStack(spacing: 8) {
617 AppStatusBadgeView(model: AppStatusFactory.availability(availability))
618 AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError))
619 AppStatusBadgeView(model: AppStatusFactory.email(emailSecurity, error: emailError))
620 AppStatusBadgeView(model: AppStatusFactory.change(changeSummary))
621 }
622 }
623
624 if let primaryIP {
625 HStack(spacing: 8) {
626 Label(primaryIP, systemImage: "network")
627 .font(appDensity.font(.caption))
628 .foregroundStyle(.secondary)
629 Spacer(minLength: 6)
630 AppCopyButton(value: primaryIP, label: "Copy IP")
631 }
632 }
633 }
634 }
635 .shadow(color: .black.opacity(0.12), radius: 14, y: 6)
636 }
637}
638
501struct LookupStatusBannerView: View { 639struct LookupStatusBannerView: View {
640 @Environment(\.appDensity) private var appDensity
502 let message: String 641 let message: String
503 let resultSource: LookupResultSource 642 let resultSource: LookupResultSource
504 643
@@ -507,14 +646,14 @@ struct LookupStatusBannerView: View {
507 Image(systemName: iconName) 646 Image(systemName: iconName)
508 .font(.caption) 647 .font(.caption)
509 Text(message) 648 Text(message)
510 .font(.system(.caption, design: .monospaced)) 649 .font(appDensity.font(.caption))
511 Spacer() 650 Spacer()
512 } 651 }
513 .foregroundStyle(color) 652 .foregroundStyle(color)
514 .padding(8) 653 .padding(appDensity.metrics.cardPadding - 2)
515 .frame(maxWidth: .infinity, alignment: .leading) 654 .frame(maxWidth: .infinity, alignment: .leading)
516 .background(color.opacity(0.12)) 655 .background(color.opacity(0.12))
517 .clipShape(RoundedRectangle(cornerRadius: 8)) 656 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
518 } 657 }
519 658
520 private var color: Color { 659 private var color: Color {
@@ -545,29 +684,30 @@ struct LookupStatusBannerView: View {
545} 684}
546 685
547struct DomainChangeSummaryView: View { 686struct DomainChangeSummaryView: View {
687 @Environment(\.appDensity) private var appDensity
548 let summary: DomainChangeSummary 688 let summary: DomainChangeSummary
549 689
550 var body: some View { 690 var body: some View {
551 CardView(allowsHorizontalScroll: false) { 691 CardView(allowsHorizontalScroll: false) {
552 HStack { 692 HStack {
553 Label(summary.hasChanges ? "Changed" : "Stable", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle") 693 Label(summary.hasChanges ? "Changed" : "Stable", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle")
554 .font(.system(.caption, design: .monospaced)) 694 .font(appDensity.font(.caption))
555 .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .green) 695 .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .green)
556 Spacer() 696 Spacer()
557 Text(summary.severity.title.uppercased()) 697 Text(summary.severity.title.uppercased())
558 .font(.system(.caption2, design: .monospaced)) 698 .font(appDensity.font(.caption2))
559 .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .secondary) 699 .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .secondary)
560 .padding(.horizontal, 8) 700 .padding(.horizontal, 8)
561 .padding(.vertical, 4) 701 .padding(.vertical, 4)
562 .background((summary.hasChanges ? severityColor(summary.severity) : .secondary).opacity(0.16)) 702 .background((summary.hasChanges ? severityColor(summary.severity) : .secondary).opacity(0.16))
563 .clipShape(Capsule()) 703 .clipShape(Capsule())
564 Text(summary.generatedAt, style: .time) 704 Text(summary.generatedAt, style: .time)
565 .font(.system(.caption2, design: .monospaced)) 705 .font(appDensity.font(.caption2))
566 .foregroundStyle(.secondary) 706 .foregroundStyle(.secondary)
567 } 707 }
568 708
569 Text(summary.message) 709 Text(summary.message)
570 .font(.system(.caption, design: .monospaced)) 710 .font(appDensity.font(.caption))
571 .foregroundStyle(.primary) 711 .foregroundStyle(.primary)
572 } 712 }
573 } 713 }
@@ -770,6 +910,8 @@ struct TrackedDomainDetailHeaderView: View {
770} 910}
771 911
772struct DomainSectionView: View { 912struct DomainSectionView: View {
913 @Environment(\.appDensity) private var appDensity
914 @Binding var isCollapsed: Bool
773 let rows: [InfoRowViewData] 915 let rows: [InfoRowViewData]
774 let suggestions: [DomainSuggestionViewData] 916 let suggestions: [DomainSuggestionViewData]
775 let showSuggestions: Bool 917 let showSuggestions: Bool
@@ -782,38 +924,34 @@ struct DomainSectionView: View {
782 let onEditNote: (() -> Void)? 924 let onEditNote: (() -> Void)?
783 925
784 var body: some View { 926 var body: some View {
785 VStack(alignment: .leading, spacing: 12) { 927 CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) {
786 HStack { 928 if let trackedDomain {
787 SectionTitleView(title: "Domain") 929 HStack(spacing: 8) {
788 Spacer() 930 AppStatusBadgeView(model: .init(title: "Tracked", systemImage: "eye.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16)))
789 if let trackedDomain { 931 Button {
790 HStack(spacing: 8) { 932 onTogglePinned()
791 Text("Tracked") 933 } label: {
792 .font(.system(.caption, design: .monospaced)) 934 Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin")
793 .foregroundStyle(.green) 935 }
794 Button { 936 .buttonStyle(.bordered)
795 onTogglePinned() 937 .font(appDensity.font(.caption))
796 } label: { 938 if let onEditNote {
797 Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin") 939 Button("Note") {
940 onEditNote()
798 } 941 }
799 .buttonStyle(.bordered) 942 .buttonStyle(.bordered)
800 .font(.system(.caption, design: .monospaced)) 943 .font(appDensity.font(.caption))
801 if let onEditNote {
802 Button("Note") {
803 onEditNote()
804 }
805 .buttonStyle(.bordered)
806 .font(.system(.caption, design: .monospaced))
807 }
808 }
809 } else {
810 Button("Track") {
811 onTrack()
812 } 944 }
813 .buttonStyle(.bordered)
814 .font(.system(.caption, design: .monospaced))
815 } 945 }
946 } else {
947 Button("Track") {
948 AppHaptics.track()
949 onTrack()
950 }
951 .buttonStyle(.bordered)
952 .font(appDensity.font(.caption))
816 } 953 }
954 } content: {
817 CardView(allowsHorizontalScroll: false) { 955 CardView(allowsHorizontalScroll: false) {
818 ForEach(rows) { row in 956 ForEach(rows) { row in
819 LabeledValueRow(row: row) 957 LabeledValueRow(row: row)
@@ -832,7 +970,7 @@ struct DomainSectionView: View {
832 } 970 }
833 if showSuggestions { 971 if showSuggestions {
834 Text("Suggestions") 972 Text("Suggestions")
835 .font(.system(.caption, design: .monospaced)) 973 .font(appDensity.font(.caption))
836 .foregroundStyle(.secondary) 974 .foregroundStyle(.secondary)
837 .padding(.top, 4) 975 .padding(.top, 4)
838 if suggestionsLoading { 976 if suggestionsLoading {
@@ -844,13 +982,11 @@ struct DomainSectionView: View {
844 ForEach(suggestions) { suggestion in 982 ForEach(suggestions) { suggestion in
845 HStack { 983 HStack {
846 Text(suggestion.domain) 984 Text(suggestion.domain)
847 .font(.system(.caption, design: .monospaced)) 985 .font(appDensity.font(.caption))
848 .foregroundStyle(.primary) 986 .foregroundStyle(.primary)
849 .textSelection(.enabled) 987 .textSelection(.enabled)
850 Spacer() 988 Spacer()
851 Text(suggestion.status) 989 AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.status == "Available" ? .available : .registered))
852 .font(.system(.caption2, design: .monospaced))
853 .foregroundStyle(ResultColors.color(for: suggestion.tone))
854 } 990 }
855 } 991 }
856 } 992 }
@@ -861,14 +997,14 @@ struct DomainSectionView: View {
861} 997}
862 998
863struct OwnershipSectionView: View { 999struct OwnershipSectionView: View {
1000 @Binding var isCollapsed: Bool
864 let rows: [InfoRowViewData] 1001 let rows: [InfoRowViewData]
865 let loading: Bool 1002 let loading: Bool
866 let error: String? 1003 let error: String?
867 let showsHistoryPlaceholder: Bool 1004 let showsHistoryPlaceholder: Bool
868 1005
869 var body: some View { 1006 var body: some View {
870 VStack(alignment: .leading, spacing: 12) { 1007 CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) {
871 SectionTitleView(title: "Ownership")
872 CardView(allowsHorizontalScroll: false) { 1008 CardView(allowsHorizontalScroll: false) {
873 if loading { 1009 if loading {
874 ProgressView("Fetching RDAP ownership…") 1010 ProgressView("Fetching RDAP ownership…")
@@ -892,21 +1028,15 @@ struct OwnershipSectionView: View {
892} 1028}
893 1029
894struct SubdomainsSectionView: View { 1030struct SubdomainsSectionView: View {
1031 @Environment(\.appDensity) private var appDensity
1032 @Binding var isCollapsed: Bool
895 let rows: [SubdomainRowViewData] 1033 let rows: [SubdomainRowViewData]
896 let loading: Bool 1034 let loading: Bool
897 let error: String? 1035 let error: String?
898 let showsExtendedPlaceholder: Bool 1036 let showsExtendedPlaceholder: Bool
899 1037
900 var body: some View { 1038 var body: some View {
901 VStack(alignment: .leading, spacing: 12) { 1039 CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") {
902 HStack {
903 SectionTitleView(title: "Subdomains")
904 Spacer()
905 Text("\(rows.count)")
906 .font(.system(.caption2, design: .monospaced))
907 .foregroundStyle(.secondary)
908 }
909
910 CardView(allowsHorizontalScroll: false) { 1040 CardView(allowsHorizontalScroll: false) {
911 if loading { 1041 if loading {
912 ProgressView("Checking certificate transparency…") 1042 ProgressView("Checking certificate transparency…")
@@ -921,7 +1051,7 @@ struct SubdomainsSectionView: View {
921 ForEach(rows) { row in 1051 ForEach(rows) { row in
922 HStack(spacing: 8) { 1052 HStack(spacing: 8) {
923 Text(row.hostname) 1053 Text(row.hostname)
924 .font(.system(.caption, design: .monospaced)) 1054 .font(appDensity.font(.caption))
925 .foregroundStyle(.primary) 1055 .foregroundStyle(.primary)
926 .textSelection(.enabled) 1056 .textSelection(.enabled)
927 Spacer() 1057 Spacer()
@@ -947,6 +1077,7 @@ struct SubdomainsSectionView: View {
947} 1077}
948 1078
949struct DNSSectionView: View { 1079struct DNSSectionView: View {
1080 @Binding var isCollapsed: Bool
950 let dnssecLabel: String? 1081 let dnssecLabel: String?
951 let sections: [DNSRecordSectionViewData] 1082 let sections: [DNSRecordSectionViewData]
952 let ptrMessage: SectionMessageViewData? 1083 let ptrMessage: SectionMessageViewData?
@@ -954,18 +1085,7 @@ struct DNSSectionView: View {
954 let sectionError: String? 1085 let sectionError: String?
955 1086
956 var body: some View { 1087 var body: some View {
957 VStack(alignment: .leading, spacing: 12) { 1088 CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) {
958 HStack(alignment: .top, spacing: 8) {
959 SectionTitleView(title: "DNS")
960 Spacer()
961 if let dnssecLabel {
962 Text(dnssecLabel)
963 .font(.system(.caption2, design: .monospaced))
964 .foregroundStyle(.secondary)
965 .multilineTextAlignment(.trailing)
966 }
967 }
968
969 if loading { 1089 if loading {
970 LoadingCardView(text: "Querying DNS…") 1090 LoadingCardView(text: "Querying DNS…")
971 } else if let sectionError, sections.isEmpty { 1091 } else if let sectionError, sections.isEmpty {
@@ -1013,6 +1133,8 @@ struct DNSSectionView: View {
1013} 1133}
1014 1134
1015struct WebSectionView: View { 1135struct WebSectionView: View {
1136 @Environment(\.appDensity) private var appDensity
1137 @Binding var isCollapsed: Bool
1016 let certificateRows: [InfoRowViewData] 1138 let certificateRows: [InfoRowViewData]
1017 let sslInfo: SSLCertificateInfo? 1139 let sslInfo: SSLCertificateInfo?
1018 let sslLoading: Bool 1140 let sslLoading: Bool
@@ -1027,14 +1149,15 @@ struct WebSectionView: View {
1027 let finalURL: String? 1149 let finalURL: String?
1028 1150
1029 var body: some View { 1151 var body: some View {
1030 VStack(alignment: .leading, spacing: 12) { 1152 CollapsibleSectionView(title: "Web", isCollapsed: $isCollapsed) {
1031 SectionTitleView(title: "Web")
1032
1033 CardView { 1153 CardView {
1034 Text("TLS") 1154 HStack {
1035 .font(.system(.subheadline, design: .monospaced)) 1155 Text("TLS")
1036 .fontWeight(.semibold) 1156 .font(appDensity.font(.subheadline, weight: .semibold))
1037 .foregroundStyle(.cyan) 1157 .foregroundStyle(.cyan)
1158 Spacer()
1159 AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError))
1160 }
1038 if sslLoading { 1161 if sslLoading {
1039 ProgressView("Checking certificate…") 1162 ProgressView("Checking certificate…")
1040 .appLoadingStyle() 1163 .appLoadingStyle()
@@ -1046,12 +1169,16 @@ struct WebSectionView: View {
1046 } 1169 }
1047 if let sslInfo, !sslInfo.subjectAltNames.isEmpty { 1170 if let sslInfo, !sslInfo.subjectAltNames.isEmpty {
1048 Text("SANs") 1171 Text("SANs")
1049 .font(.system(.caption2, design: .monospaced)) 1172 .font(appDensity.font(.caption2))
1050 .foregroundStyle(.secondary) 1173 .foregroundStyle(.secondary)
1051 ForEach(sslInfo.subjectAltNames, id: \.self) { san in 1174 ForEach(sslInfo.subjectAltNames, id: \.self) { san in
1052 Text(san) 1175 HStack(spacing: 8) {
1053 .font(.system(.caption, design: .monospaced)) 1176 Text(san)
1054 .textSelection(.enabled) 1177 .font(appDensity.font(.caption))
1178 .textSelection(.enabled)
1179 Spacer()
1180 AppCopyButton(value: san, label: "Copy certificate SAN")
1181 }
1055 } 1182 }
1056 } 1183 }
1057 } 1184 }
@@ -1059,8 +1186,7 @@ struct WebSectionView: View {
1059 1186
1060 CardView { 1187 CardView {
1061 Text("Headers") 1188 Text("Headers")
1062 .font(.system(.subheadline, design: .monospaced)) 1189 .font(appDensity.font(.subheadline, weight: .semibold))
1063 .fontWeight(.semibold)
1064 .foregroundStyle(.cyan) 1190 .foregroundStyle(.cyan)
1065 if headersLoading { 1191 if headersLoading {
1066 ProgressView("Fetching headers…") 1192 ProgressView("Fetching headers…")
@@ -1077,10 +1203,10 @@ struct WebSectionView: View {
1077 ForEach(headers) { header in 1203 ForEach(headers) { header in
1078 HStack(alignment: .top, spacing: 4) { 1204 HStack(alignment: .top, spacing: 4) {
1079 Text(header.name + ":") 1205 Text(header.name + ":")
1080 .font(.system(.caption, design: .monospaced)) 1206 .font(appDensity.font(.caption))
1081 .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan) 1207 .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
1082 Text(header.value) 1208 Text(header.value)
1083 .font(.system(.caption, design: .monospaced)) 1209 .font(appDensity.font(.caption))
1084 .foregroundStyle(.primary) 1210 .foregroundStyle(.primary)
1085 .textSelection(.enabled) 1211 .textSelection(.enabled)
1086 } 1212 }
@@ -1090,10 +1216,15 @@ struct WebSectionView: View {
1090 } 1216 }
1091 1217
1092 CardView { 1218 CardView {
1093 Text("Redirects") 1219 HStack {
1094 .font(.system(.subheadline, design: .monospaced)) 1220 Text("Redirects")
1095 .fontWeight(.semibold) 1221 .font(appDensity.font(.subheadline, weight: .semibold))
1096 .foregroundStyle(.cyan) 1222 .foregroundStyle(.cyan)
1223 Spacer()
1224 if let finalURL {
1225 AppCopyButton(value: finalURL, label: "Copy redirect URL")
1226 }
1227 }
1097 if redirectLoading { 1228 if redirectLoading {
1098 ProgressView("Tracing redirects…") 1229 ProgressView("Tracing redirects…")
1099 .appLoadingStyle() 1230 .appLoadingStyle()
@@ -1108,19 +1239,20 @@ struct WebSectionView: View {
1108 ForEach(redirects) { redirect in 1239 ForEach(redirects) { redirect in
1109 HStack(alignment: .top, spacing: 6) { 1240 HStack(alignment: .top, spacing: 6) {
1110 Text(redirect.stepLabel) 1241 Text(redirect.stepLabel)
1111 .font(.system(.caption, design: .monospaced)) 1242 .font(appDensity.font(.caption))
1112 .foregroundStyle(.secondary) 1243 .foregroundStyle(.secondary)
1113 .frame(width: 16, alignment: .trailing) 1244 .frame(width: 16, alignment: .trailing)
1114 Text(redirect.statusCode) 1245 Text(redirect.statusCode)
1115 .font(.system(.caption, design: .monospaced)) 1246 .font(appDensity.font(.caption))
1116 .foregroundStyle(.cyan) 1247 .foregroundStyle(.cyan)
1117 .frame(width: 36, alignment: .leading) 1248 .frame(width: 36, alignment: .leading)
1118 Text(redirect.url) 1249 Text(redirect.url)
1119 .font(.system(.caption, design: .monospaced)) 1250 .font(appDensity.font(.caption))
1120 .textSelection(.enabled) 1251 .textSelection(.enabled)
1252 AppCopyButton(value: redirect.url, label: "Copy redirect URL")
1121 if redirect.isFinal { 1253 if redirect.isFinal {
1122 Text("(final)") 1254 Text("(final)")
1123 .font(.system(.caption2, design: .monospaced)) 1255 .font(appDensity.font(.caption2))
1124 .foregroundStyle(.secondary) 1256 .foregroundStyle(.secondary)
1125 } 1257 }
1126 } 1258 }
@@ -1132,14 +1264,20 @@ struct WebSectionView: View {
1132} 1264}
1133 1265
1134struct EmailSectionView: View { 1266struct EmailSectionView: View {
1267 @Environment(\.appDensity) private var appDensity
1268 @Binding var isCollapsed: Bool
1135 let rows: [EmailRowViewData] 1269 let rows: [EmailRowViewData]
1136 let loading: Bool 1270 let loading: Bool
1137 let error: String? 1271 let error: String?
1138 1272
1139 var body: some View { 1273 var body: some View {
1140 VStack(alignment: .leading, spacing: 12) { 1274 CollapsibleSectionView(title: "Email", isCollapsed: $isCollapsed) {
1141 SectionTitleView(title: "Email")
1142 CardView { 1275 CardView {
1276 HStack {
1277 Spacer()
1278 AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error))
1279 .opacity(loading ? 0 : 1)
1280 }
1143 if loading { 1281 if loading {
1144 ProgressView("Checking email records…") 1282 ProgressView("Checking email records…")
1145 .appLoadingStyle() 1283 .appLoadingStyle()
@@ -1152,20 +1290,18 @@ struct EmailSectionView: View {
1152 VStack(alignment: .leading, spacing: 4) { 1290 VStack(alignment: .leading, spacing: 4) {
1153 HStack(spacing: 8) { 1291 HStack(spacing: 8) {
1154 Text(row.label) 1292 Text(row.label)
1155 .font(.system(.caption, design: .monospaced)) 1293 .font(appDensity.font(.caption))
1156 .foregroundStyle(.cyan) 1294 .foregroundStyle(.cyan)
1157 .frame(width: 76, alignment: .leading) 1295 .frame(width: 76, alignment: .leading)
1158 Text(row.status) 1296 AppStatusBadgeView(model: emailRowBadge(row))
1159 .font(.system(.caption, design: .monospaced))
1160 .foregroundStyle(ResultColors.color(for: row.statusTone))
1161 } 1297 }
1162 Text(row.detail) 1298 Text(row.detail)
1163 .font(.system(.caption2, design: .monospaced)) 1299 .font(appDensity.font(.caption2))
1164 .foregroundStyle(.primary) 1300 .foregroundStyle(.primary)
1165 .textSelection(.enabled) 1301 .textSelection(.enabled)
1166 if let auxiliaryDetail = row.auxiliaryDetail { 1302 if let auxiliaryDetail = row.auxiliaryDetail {
1167 Text(auxiliaryDetail) 1303 Text(auxiliaryDetail)
1168 .font(.system(.caption2, design: .monospaced)) 1304 .font(appDensity.font(.caption2))
1169 .foregroundStyle(.secondary) 1305 .foregroundStyle(.secondary)
1170 } 1306 }
1171 } 1307 }
@@ -1174,9 +1310,24 @@ struct EmailSectionView: View {
1174 } 1310 }
1175 } 1311 }
1176 } 1312 }
1313
1314 private func emailRowBadge(_ row: EmailRowViewData) -> AppStatusBadgeModel {
1315 switch row.statusTone {
1316 case .success:
1317 return .init(title: row.status, systemImage: "checkmark.shield.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
1318 case .warning:
1319 return .init(title: row.status, systemImage: "shield.lefthalf.filled", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
1320 case .failure:
1321 return .init(title: row.status, systemImage: "minus.circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
1322 case .primary, .secondary:
1323 return .init(title: row.status, systemImage: "circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
1324 }
1325 }
1177} 1326}
1178 1327
1179struct NetworkSectionView: View { 1328struct NetworkSectionView: View {
1329 @Environment(\.appDensity) private var appDensity
1330 @Binding var isCollapsed: Bool
1180 let reachabilityRows: [ReachabilityRowViewData] 1331 let reachabilityRows: [ReachabilityRowViewData]
1181 let reachabilityLoading: Bool 1332 let reachabilityLoading: Bool
1182 let reachabilityError: String? 1333 let reachabilityError: String?
@@ -1196,13 +1347,10 @@ struct NetworkSectionView: View {
1196 let onScanCustomPorts: () -> Void 1347 let onScanCustomPorts: () -> Void
1197 1348
1198 var body: some View { 1349 var body: some View {
1199 VStack(alignment: .leading, spacing: 12) { 1350 CollapsibleSectionView(title: "Network", isCollapsed: $isCollapsed) {
1200 SectionTitleView(title: "Network")
1201
1202 CardView { 1351 CardView {
1203 Text("Reachability") 1352 Text("Reachability")
1204 .font(.system(.subheadline, design: .monospaced)) 1353 .font(appDensity.font(.subheadline, weight: .semibold))
1205 .fontWeight(.semibold)
1206 .foregroundStyle(.cyan) 1354 .foregroundStyle(.cyan)
1207 if reachabilityLoading { 1355 if reachabilityLoading {
1208 ProgressView("Checking ports…") 1356 ProgressView("Checking ports…")
@@ -1213,14 +1361,12 @@ struct NetworkSectionView: View {
1213 ForEach(reachabilityRows) { row in 1361 ForEach(reachabilityRows) { row in
1214 HStack { 1362 HStack {
1215 Text(row.portLabel) 1363 Text(row.portLabel)
1216 .font(.system(.caption, design: .monospaced)) 1364 .font(appDensity.font(.caption))
1217 Spacer() 1365 Spacer()
1218 Text(row.latencyLabel) 1366 Text(row.latencyLabel)
1219 .font(.system(.caption2, design: .monospaced)) 1367 .font(appDensity.font(.caption2))
1220 .foregroundStyle(.secondary) 1368 .foregroundStyle(.secondary)
1221 Text(row.statusLabel) 1369 AppStatusBadgeView(model: reachabilityBadge(row))
1222 .font(.system(.caption, design: .monospaced))
1223 .foregroundStyle(ResultColors.color(for: row.statusTone))
1224 } 1370 }
1225 } 1371 }
1226 } 1372 }
@@ -1228,8 +1374,7 @@ struct NetworkSectionView: View {
1228 1374
1229 CardView(allowsHorizontalScroll: false) { 1375 CardView(allowsHorizontalScroll: false) {
1230 Text("Location") 1376 Text("Location")
1231 .font(.system(.subheadline, design: .monospaced)) 1377 .font(appDensity.font(.subheadline, weight: .semibold))
1232 .fontWeight(.semibold)
1233 .foregroundStyle(.cyan) 1378 .foregroundStyle(.cyan)
1234 if geolocationLoading { 1379 if geolocationLoading {
1235 ProgressView("Looking up location…") 1380 ProgressView("Looking up location…")
@@ -1260,13 +1405,12 @@ struct NetworkSectionView: View {
1260 1405
1261 CardView(allowsHorizontalScroll: false) { 1406 CardView(allowsHorizontalScroll: false) {
1262 Text("Port Scan") 1407 Text("Port Scan")
1263 .font(.system(.subheadline, design: .monospaced)) 1408 .font(appDensity.font(.subheadline, weight: .semibold))
1264 .fontWeight(.semibold)
1265 .foregroundStyle(.cyan) 1409 .foregroundStyle(.cyan)
1266 1410
1267 if isCloudflareProxied { 1411 if isCloudflareProxied {
1268 Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.") 1412 Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.")
1269 .font(.system(.caption2, design: .monospaced)) 1413 .font(appDensity.font(.caption2))
1270 .foregroundStyle(.orange) 1414 .foregroundStyle(.orange)
1271 .fixedSize(horizontal: false, vertical: true) 1415 .fixedSize(horizontal: false, vertical: true)
1272 } 1416 }
@@ -1286,15 +1430,16 @@ struct NetworkSectionView: View {
1286 DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) { 1430 DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) {
1287 VStack(alignment: .leading, spacing: 10) { 1431 VStack(alignment: .leading, spacing: 10) {
1288 TextField("8888, 9000, 27017", text: $customPortInput) 1432 TextField("8888, 9000, 27017", text: $customPortInput)
1289 .font(.system(.caption, design: .monospaced)) 1433 .font(appDensity.font(.caption))
1290 .textInputAutocapitalization(.never) 1434 .textInputAutocapitalization(.never)
1291 .autocorrectionDisabled() 1435 .autocorrectionDisabled()
1292 .keyboardType(.numberPad) 1436 .keyboardType(.numberPad)
1293 .padding(10) 1437 .padding(10)
1294 .background(Color(.systemGray6).opacity(0.5)) 1438 .background(Color(.systemGray6).opacity(0.5))
1295 .cornerRadius(6) 1439 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
1296 1440
1297 Button("Scan") { 1441 Button("Scan") {
1442 AppHaptics.refresh()
1298 onScanCustomPorts() 1443 onScanCustomPorts()
1299 } 1444 }
1300 .buttonStyle(.borderedProminent) 1445 .buttonStyle(.borderedProminent)
@@ -1316,9 +1461,23 @@ struct NetworkSectionView: View {
1316 } 1461 }
1317 } 1462 }
1318 } 1463 }
1464
1465 private func reachabilityBadge(_ row: ReachabilityRowViewData) -> AppStatusBadgeModel {
1466 switch row.statusTone {
1467 case .success:
1468 return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
1469 case .warning:
1470 return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
1471 case .failure:
1472 return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
1473 case .primary, .secondary:
1474 return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
1475 }
1476 }
1319} 1477}
1320 1478
1321struct PortRowsView: View { 1479struct PortRowsView: View {
1480 @Environment(\.appDensity) private var appDensity
1322 let rows: [PortScanRowViewData] 1481 let rows: [PortScanRowViewData]
1323 1482
1324 var body: some View { 1483 var body: some View {
@@ -1326,47 +1485,61 @@ struct PortRowsView: View {
1326 MessageRowView(text: "No results", isError: false) 1485 MessageRowView(text: "No results", isError: false)
1327 } else { 1486 } else {
1328 ForEach(rows) { row in 1487 ForEach(rows) { row in
1329 VStack(alignment: .leading, spacing: 2) { 1488 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
1330 HStack { 1489 HStack {
1331 Text(row.portLabel) 1490 Text(row.portLabel)
1332 .font(.system(.caption, design: .monospaced)) 1491 .font(appDensity.font(.caption))
1333 .frame(width: 52, alignment: .leading) 1492 .frame(width: 52, alignment: .leading)
1334 Text(row.service) 1493 Text(row.service)
1335 .font(.system(.caption, design: .monospaced)) 1494 .font(appDensity.font(.caption))
1336 .foregroundStyle(.primary) 1495 .foregroundStyle(.primary)
1337 Spacer() 1496 Spacer()
1338 if let durationLabel = row.durationLabel { 1497 if let durationLabel = row.durationLabel {
1339 Text(durationLabel) 1498 Text(durationLabel)
1340 .font(.system(.caption2, design: .monospaced)) 1499 .font(appDensity.font(.caption2))
1341 .foregroundStyle(.secondary) 1500 .foregroundStyle(.secondary)
1342 } 1501 }
1343 Text(row.statusLabel) 1502 AppStatusBadgeView(model: portBadge(row))
1344 .font(.system(.caption2, design: .monospaced))
1345 .foregroundStyle(ResultColors.color(for: row.statusTone))
1346 } 1503 }
1347 if let banner = row.banner { 1504 if let banner = row.banner {
1348 Text(banner) 1505 Text(banner)
1349 .font(.system(.caption2, design: .monospaced)) 1506 .font(appDensity.font(.caption2))
1350 .foregroundStyle(.secondary) 1507 .foregroundStyle(.secondary)
1351 .padding(.leading, 8) 1508 .padding(.leading, 8)
1352 } 1509 }
1353 } 1510 }
1511 .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
1354 } 1512 }
1355 } 1513 }
1356 } 1514 }
1515
1516 private func portBadge(_ row: PortScanRowViewData) -> AppStatusBadgeModel {
1517 switch row.statusTone {
1518 case .success:
1519 return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
1520 case .warning:
1521 return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
1522 case .failure:
1523 return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
1524 case .primary, .secondary:
1525 return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
1526 }
1527 }
1357} 1528}
1358 1529
1359struct SectionTitleView: View { 1530struct SectionTitleView: View {
1531 @Environment(\.appDensity) private var appDensity
1360 let title: String 1532 let title: String
1361 1533
1362 var body: some View { 1534 var body: some View {
1363 Text(title) 1535 Text(title)
1364 .font(.system(.headline)) 1536 .font(appDensity.font(.headline, design: .default, weight: .semibold))
1365 .foregroundStyle(.white) 1537 .foregroundStyle(.white)
1366 } 1538 }
1367} 1539}
1368 1540
1369struct CardView<Content: View>: View { 1541struct CardView<Content: View>: View {
1542 @Environment(\.appDensity) private var appDensity
1370 let allowsHorizontalScroll: Bool 1543 let allowsHorizontalScroll: Bool
1371 let content: Content 1544 let content: Content
1372 1545
@@ -1389,13 +1562,13 @@ struct CardView<Content: View>: View {
1389 } 1562 }
1390 } 1563 }
1391 .frame(maxWidth: .infinity, alignment: .leading) 1564 .frame(maxWidth: .infinity, alignment: .leading)
1392 .padding(10) 1565 .padding(appDensity.metrics.cardPadding)
1393 .background(Color(.systemGray6).opacity(0.5)) 1566 .background(Color(.systemGray6).opacity(0.5))
1394 .cornerRadius(6) 1567 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
1395 } 1568 }
1396 1569
1397 private var cardContent: some View { 1570 private var cardContent: some View {
1398 VStack(alignment: .leading, spacing: 6) { 1571 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
1399 content 1572 content
1400 } 1573 }
1401 } 1574 }
@@ -1425,29 +1598,40 @@ struct MessageCardView: View {
1425} 1598}
1426 1599
1427struct MessageRowView: View { 1600struct MessageRowView: View {
1601 @Environment(\.appDensity) private var appDensity
1428 let text: String 1602 let text: String
1429 let isError: Bool 1603 let isError: Bool
1430 1604
1431 var body: some View { 1605 var body: some View {
1432 Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle") 1606 Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle")
1433 .font(.system(.caption, design: .monospaced)) 1607 .font(appDensity.font(.caption))
1434 .foregroundStyle(isError ? .red : .secondary) 1608 .foregroundStyle(isError ? .red : .secondary)
1435 } 1609 }
1436} 1610}
1437 1611
1438struct LabeledValueRow: View { 1612struct LabeledValueRow: View {
1613 @Environment(\.appDensity) private var appDensity
1439 let row: InfoRowViewData 1614 let row: InfoRowViewData
1440 1615
1441 var body: some View { 1616 var body: some View {
1442 VStack(alignment: .leading, spacing: 2) { 1617 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
1443 Text(row.label) 1618 HStack(alignment: .top, spacing: 8) {
1444 .font(.system(.caption2, design: .monospaced)) 1619 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
1445 .foregroundStyle(.secondary) 1620 Text(row.label)
1446 Text(row.value) 1621 .font(appDensity.font(.caption2))
1447 .font(.system(.caption, design: .monospaced)) 1622 .foregroundStyle(.secondary)
1448 .foregroundStyle(ResultColors.color(for: row.tone)) 1623 Text(row.value)
1449 .textSelection(.enabled) 1624 .font(appDensity.font(.caption))
1625 .foregroundStyle(ResultColors.color(for: row.tone))
1626 .textSelection(.enabled)
1627 }
1628 Spacer(minLength: 6)
1629 if !row.value.isEmpty, row.value != "Unavailable" {
1630 AppCopyButton(value: row.value, label: "Copy \(row.label)")
1631 }
1632 }
1450 } 1633 }
1634 .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
1451 } 1635 }
1452} 1636}
1453 1637
@@ -1490,11 +1674,17 @@ private extension String {
1490} 1674}
1491 1675
1492private struct SettingsView: View { 1676private struct SettingsView: View {
1677 @Environment(\.appDensity) private var appDensity
1678 @Bindable var viewModel: DomainViewModel
1493 @AppStorage(DNSResolverOption.userDefaultsKey) 1679 @AppStorage(DNSResolverOption.userDefaultsKey)
1494 private var storedResolverURL = DNSResolverOption.defaultURLString 1680 private var storedResolverURL = DNSResolverOption.defaultURLString
1681 @AppStorage(AppDensity.userDefaultsKey)
1682 private var storedDensity = AppDensity.compact.rawValue
1495 1683
1496 @State private var resolverOption: DNSResolverOption = .cloudflare 1684 @State private var resolverOption: DNSResolverOption = .cloudflare
1497 @State private var customResolverURL = DNSResolverOption.defaultURLString 1685 @State private var customResolverURL = DNSResolverOption.defaultURLString
1686 @State private var showClearHistoryConfirmation = false
1687 @State private var showClearCacheConfirmation = false
1498 1688
1499 private var customResolverError: String? { 1689 private var customResolverError: String? {
1500 guard resolverOption == .custom else { 1690 guard resolverOption == .custom else {
@@ -1505,7 +1695,15 @@ private struct SettingsView: View {
1505 1695
1506 var body: some View { 1696 var body: some View {
1507 Form { 1697 Form {
1508 Section { 1698 Section("Display") {
1699 Picker("Density", selection: $storedDensity) {
1700 ForEach(AppDensity.allCases) { density in
1701 Text(density.title).tag(density.rawValue)
1702 }
1703 }
1704 }
1705
1706 Section("Network") {
1509 Picker("Resolver", selection: $resolverOption) { 1707 Picker("Resolver", selection: $resolverOption) {
1510 ForEach(DNSResolverOption.allCases) { option in 1708 ForEach(DNSResolverOption.allCases) { option in
1511 Text(option.title).tag(option) 1709 Text(option.title).tag(option)
@@ -1520,13 +1718,45 @@ private struct SettingsView: View {
1520 1718
1521 if let customResolverError { 1719 if let customResolverError {
1522 Text(customResolverError) 1720 Text(customResolverError)
1523 .font(.caption) 1721 .font(appDensity.font(.caption, design: .default))
1524 .foregroundStyle(.red) 1722 .foregroundStyle(.red)
1525 } 1723 }
1526 } 1724 }
1527 } 1725 }
1726
1727 Section("Data") {
1728 Button("Clear History", role: .destructive) {
1729 showClearHistoryConfirmation = true
1730 }
1731
1732 Button("Clear Cache", role: .destructive) {
1733 showClearCacheConfirmation = true
1734 }
1735 }
1736
1737 Section("About") {
1738 LabeledContent("Version", value: appVersion)
1739 LabeledContent("Storage", value: "Local-only")
1740 LabeledContent("Focus", value: "Readable domain inspection")
1741 }
1528 } 1742 }
1529 .navigationTitle("Settings") 1743 .navigationTitle("Settings")
1744 .alert("Clear history?", isPresented: $showClearHistoryConfirmation) {
1745 Button("Clear", role: .destructive) {
1746 viewModel.clearHistory()
1747 }
1748 Button("Cancel", role: .cancel) {}
1749 } message: {
1750 Text("This removes saved lookup snapshots from this device.")
1751 }
1752 .alert("Clear cache?", isPresented: $showClearCacheConfirmation) {
1753 Button("Clear", role: .destructive) {
1754 viewModel.clearLookupCache()
1755 }
1756 Button("Cancel", role: .cancel) {}
1757 } message: {
1758 Text("This clears the in-memory lookup cache and cancels any cached in-flight work.")
1759 }
1530 .onAppear { 1760 .onAppear {
1531 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) 1761 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
1532 resolverOption = DNSResolverOption.option(for: currentResolverURL) 1762 resolverOption = DNSResolverOption.option(for: currentResolverURL)
@@ -1544,6 +1774,10 @@ private struct SettingsView: View {
1544 storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines) 1774 storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
1545 } 1775 }
1546 } 1776 }
1777
1778 private var appVersion: String {
1779 Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "2.6.0"
1780 }
1547} 1781}
1548 1782
1549#Preview { 1783#Preview {
DomainDig/DomainDigApp.swift +3
@@ -9,6 +9,8 @@ import SwiftUI
9 9
10@main 10@main
11struct DomainDigApp: App { 11struct DomainDigApp: App {
12 @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue
13
12 init() { 14 init() {
13 LocalNotificationService.shared.configureForegroundPresentation() 15 LocalNotificationService.shared.configureForegroundPresentation()
14 } 16 }
@@ -16,6 +18,7 @@ struct DomainDigApp: App {
16 var body: some Scene { 18 var body: some Scene {
17 WindowGroup { 19 WindowGroup {
18 ContentView() 20 ContentView()
21 .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact)
19 } 22 }
20 } 23 }
21} 24}
DomainDig/DomainDigUI.swift added +319
@@ -0,0 +1,319 @@
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
239 var body: some View {
240 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
241 Label(title, systemImage: systemImage)
242 .font(appDensity.font(.headline, weight: .semibold))
243 .foregroundStyle(.primary)
244
245 Text(message)
246 .font(appDensity.font(.body))
247 .foregroundStyle(.secondary)
248 .fixedSize(horizontal: false, vertical: true)
249
250 Text(suggestion)
251 .font(appDensity.font(.caption))
252 .foregroundStyle(.cyan)
253 }
254 .frame(maxWidth: .infinity, alignment: .leading)
255 .padding(appDensity.metrics.cardPadding)
256 .background(Color(.systemGray6).opacity(0.45))
257 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
258 }
259}
260
261struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View {
262 @Environment(\.appDensity) private var appDensity
263
264 let title: String
265 @Binding var isCollapsed: Bool
266 let subtitle: String?
267 @ViewBuilder let trailing: () -> HeaderTrailing
268 @ViewBuilder let content: () -> Content
269
270 init(
271 title: String,
272 isCollapsed: Binding<Bool>,
273 subtitle: String? = nil,
274 @ViewBuilder trailing: @escaping () -> HeaderTrailing = { EmptyView() },
275 @ViewBuilder content: @escaping () -> Content
276 ) {
277 self.title = title
278 self._isCollapsed = isCollapsed
279 self.subtitle = subtitle
280 self.trailing = trailing
281 self.content = content
282 }
283
284 var body: some View {
285 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
286 Button {
287 withAnimation(.easeInOut(duration: 0.2)) {
288 isCollapsed.toggle()
289 }
290 } label: {
291 HStack(alignment: .center, spacing: 10) {
292 VStack(alignment: .leading, spacing: 3) {
293 Text(title)
294 .font(appDensity.font(.headline, design: .default, weight: .semibold))
295 .foregroundStyle(.white)
296 if let subtitle {
297 Text(subtitle)
298 .font(appDensity.font(.caption))
299 .foregroundStyle(.secondary)
300 }
301 }
302 Spacer(minLength: 8)
303 trailing()
304 Image(systemName: isCollapsed ? "chevron.down" : "chevron.up")
305 .font(.caption.weight(.semibold))
306 .foregroundStyle(.secondary)
307 }
308 .contentShape(Rectangle())
309 .frame(minHeight: appDensity.metrics.controlMinHeight, alignment: .center)
310 }
311 .buttonStyle(.plain)
312
313 if !isCollapsed {
314 content()
315 .transition(.opacity.combined(with: .move(edge: .top)))
316 }
317 }
318 }
319}
DomainDig/DomainViewModel.swift +6
@@ -598,6 +598,12 @@ final class DomainViewModel {
598 persistHistory() 598 persistHistory()
599 } 599 }
600 600
601 func clearLookupCache() {
602 Task {
603 await LookupRuntime.shared.clearCache()
604 }
605 }
606
601 func clearRecentSearches() { 607 func clearRecentSearches() {
602 recentSearches.removeAll() 608 recentSearches.removeAll()
603 UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) 609 UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey)
DomainDig/HistoryView.swift +95 −43
@@ -1,53 +1,69 @@
1import SwiftUI 1import SwiftUI
2 2
3struct HistoryView: View { 3struct HistoryView: View {
4 @Environment(\.appDensity) private var appDensity
4 @Bindable var viewModel: DomainViewModel 5 @Bindable var viewModel: DomainViewModel
5 @Environment(\.dismiss) private var dismiss 6 @Environment(\.dismiss) private var dismiss
6 @State private var showClearAllConfirmation = false 7 @State private var showClearAllConfirmation = false
7 8
8 private let dateFormatter: DateFormatter = { 9 private var groupedHistory: [HistoryGroup] {
9 let formatter = DateFormatter() 10 HistoryGroup.groups(for: viewModel.filteredHistory)
10 formatter.dateStyle = .medium 11 }
11 formatter.timeStyle = .short
12 return formatter
13 }()
14 12
15 var body: some View { 13 var body: some View {
16 List { 14 List {
17 if viewModel.filteredHistory.isEmpty { 15 if viewModel.filteredHistory.isEmpty {
18 Text("No lookup history") 16 EmptyStateCardView(
19 .font(.system(.callout, design: .monospaced)) 17 title: "No History Yet",
20 .foregroundStyle(.secondary) 18 message: "History stores local snapshots of previous inspections so you can revisit and compare them later.",
19 suggestion: "Run a lookup to create your first saved snapshot.",
20 systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90"
21 )
21 .listRowBackground(Color(.systemGray6).opacity(0.5)) 22 .listRowBackground(Color(.systemGray6).opacity(0.5))
22 } else { 23 } else {
23 ForEach(viewModel.filteredHistory) { entry in 24 ForEach(groupedHistory) { group in
24 NavigationLink { 25 Section(group.title) {
25 HistoryDetailView(viewModel: viewModel, entry: entry) 26 ForEach(group.entries) { entry in
26 } label: { 27 NavigationLink {
27 VStack(alignment: .leading, spacing: 4) { 28 HistoryDetailView(viewModel: viewModel, entry: entry)
28 Text(entry.domain) 29 } label: {
29 .font(.system(.callout, design: .monospaced)) 30 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
30 .foregroundStyle(.primary) 31 HStack(alignment: .center, spacing: 8) {
31 if let summary = entry.changeSummary { 32 Text(entry.domain)
32 Text(summary.hasChanges ? "Changed" : "Unchanged") 33 .font(appDensity.font(.callout))
33 .font(.system(.caption2, design: .monospaced)) 34 .foregroundStyle(.primary)
34 .foregroundStyle(summary.hasChanges ? .yellow : .green) 35 Spacer()
36 AppStatusBadgeView(model: AppStatusFactory.change(entry.changeSummary))
37 }
38
39 HStack(spacing: 8) {
40 AppStatusBadgeView(model: AppStatusFactory.availability(entry.availabilityResult?.status))
41 AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: entry.sslInfo, error: entry.sslError))
42 }
43
44 HStack(spacing: 8) {
45 Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened))
46 Text(entry.timestamp.formatted(.relative(presentation: .named)))
47 Text(entry.resolverDisplayName)
48 if let totalLookupDurationMs = entry.totalLookupDurationMs {
49 Text("\(totalLookupDurationMs) ms")
50 }
51 }
52 .font(appDensity.font(.caption2))
53 .foregroundStyle(.secondary)
54 }
35 } 55 }
36 HStack(spacing: 8) { 56 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
37 Text(dateFormatter.string(from: entry.timestamp)) 57 Button(role: .destructive) {
38 Text("Snapshot") 58 viewModel.removeHistoryEntries(withIDs: [entry.id])
39 Text(entry.resolverDisplayName) 59 } label: {
40 if let totalLookupDurationMs = entry.totalLookupDurationMs { 60 Label("Delete", systemImage: "trash")
41 Text("\(totalLookupDurationMs) ms")
42 } 61 }
43 } 62 }
44 .font(.system(.caption2, design: .monospaced))
45 .foregroundStyle(.secondary)
46 } 63 }
64 .listRowBackground(Color(.systemGray6).opacity(0.5))
47 } 65 }
48 .listRowBackground(Color(.systemGray6).opacity(0.5))
49 } 66 }
50 .onDelete(perform: deleteFilteredHistoryEntries)
51 } 67 }
52 } 68 }
53 .scrollContentBackground(.hidden) 69 .scrollContentBackground(.hidden)
@@ -108,6 +124,7 @@ struct HistoryView: View {
108} 124}
109 125
110struct HistoryDetailView: View { 126struct HistoryDetailView: View {
127 @Environment(\.appDensity) private var appDensity
111 @Bindable var viewModel: DomainViewModel 128 @Bindable var viewModel: DomainViewModel
112 let entry: HistoryEntry 129 let entry: HistoryEntry
113 @Environment(\.dismiss) private var dismiss 130 @Environment(\.dismiss) private var dismiss
@@ -130,6 +147,7 @@ struct HistoryDetailView: View {
130 SummaryView(fields: DomainViewModel.summaryFields(from: snapshot)) 147 SummaryView(fields: DomainViewModel.summaryFields(from: snapshot))
131 .padding(.top, 8) 148 .padding(.top, 8)
132 DomainSectionView( 149 DomainSectionView(
150 isCollapsed: .constant(false),
133 rows: DomainViewModel.domainRows(from: snapshot), 151 rows: DomainViewModel.domainRows(from: snapshot),
134 suggestions: DomainViewModel.suggestionRows(from: snapshot), 152 suggestions: DomainViewModel.suggestionRows(from: snapshot),
135 showSuggestions: entry.availabilityResult?.status == .registered && !entry.suggestions.isEmpty, 153 showSuggestions: entry.availabilityResult?.status == .registered && !entry.suggestions.isEmpty,
@@ -146,42 +164,46 @@ struct HistoryDetailView: View {
146 }, 164 },
147 onEditNote: nil 165 onEditNote: nil
148 ) 166 )
149 .padding(.top, 16) 167 .padding(.top, appDensity.metrics.sectionSpacing)
150 OwnershipSectionView( 168 OwnershipSectionView(
169 isCollapsed: .constant(false),
151 rows: DomainViewModel.ownershipRows(from: snapshot), 170 rows: DomainViewModel.ownershipRows(from: snapshot),
152 loading: false, 171 loading: false,
153 error: snapshot.ownershipError, 172 error: snapshot.ownershipError,
154 showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory) 173 showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory)
155 ) 174 )
156 .padding(.top, 16) 175 .padding(.top, appDensity.metrics.sectionSpacing)
157 SubdomainsSectionView( 176 SubdomainsSectionView(
177 isCollapsed: .constant(false),
158 rows: DomainViewModel.subdomainRows(from: snapshot), 178 rows: DomainViewModel.subdomainRows(from: snapshot),
159 loading: false, 179 loading: false,
160 error: snapshot.subdomainsError, 180 error: snapshot.subdomainsError,
161 showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains) 181 showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains)
162 ) 182 )
163 .padding(.top, 16) 183 .padding(.top, appDensity.metrics.sectionSpacing)
164 if let comparisonSnapshot = viewModel.comparisonSnapshot(for: entry) { 184 if let comparisonSnapshot = viewModel.comparisonSnapshot(for: entry) {
165 if let changeSummary = entry.changeSummary { 185 if let changeSummary = entry.changeSummary {
166 DomainChangeSummaryView(summary: changeSummary) 186 DomainChangeSummaryView(summary: changeSummary)
167 .padding(.top, 16) 187 .padding(.top, appDensity.metrics.sectionSpacing)
168 } 188 }
169 DomainDiffView( 189 DomainDiffView(
170 title: "Compared With Previous Snapshot", 190 title: "Compared With Previous Snapshot",
171 sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot), 191 sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot),
172 showsUnchanged: false 192 showsUnchanged: false
173 ) 193 )
174 .padding(.top, 16) 194 .padding(.top, appDensity.metrics.sectionSpacing)
175 } 195 }
176 DNSSectionView( 196 DNSSectionView(
197 isCollapsed: .constant(false),
177 dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot), 198 dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot),
178 sections: DomainViewModel.dnsRows(from: snapshot), 199 sections: DomainViewModel.dnsRows(from: snapshot),
179 ptrMessage: DomainViewModel.ptrMessage(from: snapshot), 200 ptrMessage: DomainViewModel.ptrMessage(from: snapshot),
180 loading: false, 201 loading: false,
181 sectionError: snapshot.dnsError 202 sectionError: snapshot.dnsError
182 ) 203 )
183 .padding(.top, 16) 204 .padding(.top, appDensity.metrics.sectionSpacing)
184 WebSectionView( 205 WebSectionView(
206 isCollapsed: .constant(false),
185 certificateRows: DomainViewModel.webCertificateRows(from: snapshot), 207 certificateRows: DomainViewModel.webCertificateRows(from: snapshot),
186 sslInfo: snapshot.sslInfo, 208 sslInfo: snapshot.sslInfo,
187 sslLoading: false, 209 sslLoading: false,
@@ -195,14 +217,16 @@ struct HistoryDetailView: View {
195 redirectError: snapshot.redirectChainError, 217 redirectError: snapshot.redirectChainError,
196 finalURL: snapshot.redirectChain.last?.url 218 finalURL: snapshot.redirectChain.last?.url
197 ) 219 )
198 .padding(.top, 16) 220 .padding(.top, appDensity.metrics.sectionSpacing)
199 EmailSectionView( 221 EmailSectionView(
222 isCollapsed: .constant(false),
200 rows: DomainViewModel.emailRows(from: snapshot), 223 rows: DomainViewModel.emailRows(from: snapshot),
201 loading: false, 224 loading: false,
202 error: snapshot.emailSecurityError 225 error: snapshot.emailSecurityError
203 ) 226 )
204 .padding(.top, 16) 227 .padding(.top, appDensity.metrics.sectionSpacing)
205 NetworkSectionView( 228 NetworkSectionView(
229 isCollapsed: .constant(false),
206 reachabilityRows: DomainViewModel.reachabilityRows(from: snapshot), 230 reachabilityRows: DomainViewModel.reachabilityRows(from: snapshot),
207 reachabilityLoading: false, 231 reachabilityLoading: false,
208 reachabilityError: snapshot.reachabilityError, 232 reachabilityError: snapshot.reachabilityError,
@@ -221,7 +245,7 @@ struct HistoryDetailView: View {
221 customPortInput: .constant(""), 245 customPortInput: .constant(""),
222 onScanCustomPorts: {} 246 onScanCustomPorts: {}
223 ) 247 )
224 .padding(.top, 16) 248 .padding(.top, appDensity.metrics.sectionSpacing)
225 } 249 }
226 .padding(.horizontal) 250 .padding(.horizontal)
227 .padding(.bottom, 32) 251 .padding(.bottom, 32)
@@ -244,17 +268,45 @@ struct HistoryDetailView: View {
244 Image(systemName: "archivebox") 268 Image(systemName: "archivebox")
245 .font(.caption) 269 .font(.caption)
246 Text("Snapshot from \(dateFormatter.string(from: entry.timestamp))") 270 Text("Snapshot from \(dateFormatter.string(from: entry.timestamp))")
247 .font(.system(.caption, design: .monospaced)) 271 .font(appDensity.font(.caption))
248 Spacer() 272 Spacer()
249 Text("Live re-run available") 273 Text("Live re-run available")
250 .font(.system(.caption2, design: .monospaced)) 274 .font(appDensity.font(.caption2))
251 .foregroundStyle(.secondary) 275 .foregroundStyle(.secondary)
252 } 276 }
253 .foregroundStyle(.secondary) 277 .foregroundStyle(.secondary)
254 .padding(8) 278 .padding(8)
255 .frame(maxWidth: .infinity, alignment: .leading) 279 .frame(maxWidth: .infinity, alignment: .leading)
256 .background(Color(.systemGray6).opacity(0.3)) 280 .background(Color(.systemGray6).opacity(0.3))
257 .cornerRadius(6) 281 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
258 .padding(.vertical, 12) 282 .padding(.vertical, 12)
259 } 283 }
260} 284}
285
286private struct HistoryGroup: Identifiable {
287 let title: String
288 let entries: [HistoryEntry]
289
290 var id: String { title }
291
292 static func groups(for entries: [HistoryEntry]) -> [HistoryGroup] {
293 let calendar = Calendar.current
294 let today = Date()
295 let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
296
297 let grouped = Dictionary(grouping: entries) { entry -> String in
298 if calendar.isDate(entry.timestamp, inSameDayAs: today) {
299 return "Today"
300 }
301 if calendar.isDate(entry.timestamp, inSameDayAs: yesterday) {
302 return "Yesterday"
303 }
304 return "Older"
305 }
306
307 return ["Today", "Yesterday", "Older"].compactMap { title in
308 guard let entries = grouped[title], !entries.isEmpty else { return nil }
309 return HistoryGroup(title: title, entries: entries)
310 }
311 }
312}
DomainDig/LookupRuntime.swift +7
@@ -58,6 +58,13 @@ actor LookupRuntime {
58 private var inFlight: [RequestKey: Task<CachedPayload, Never>] = [:] 58 private var inFlight: [RequestKey: Task<CachedPayload, Never>] = [:]
59 private var nextAllowedAt: [RateLimitBucket: Date] = [:] 59 private var nextAllowedAt: [RateLimitBucket: Date] = [:]
60 60
61 func clearCache() {
62 cache.removeAll()
63 inFlight.values.forEach { $0.cancel() }
64 inFlight.removeAll()
65 nextAllowedAt.removeAll()
66 }
67
61 func dns(domain: String) async -> CachedLookupResult<ServiceResult<[DNSSection]>> { 68 func dns(domain: String) async -> CachedLookupResult<ServiceResult<[DNSSection]>> {
62 await execute( 69 await execute(
63 key: .domain(domain, .dns), 70 key: .domain(domain, .dns),
DomainDig/WatchlistView.swift +119 −133
@@ -1,9 +1,18 @@
1import SwiftUI 1import SwiftUI
2 2
3struct WatchlistView: View { 3struct WatchlistView: View {
4 @Environment(\.appDensity) private var appDensity
4 @Bindable var viewModel: DomainViewModel 5 @Bindable var viewModel: DomainViewModel
5 @Environment(\.dismiss) private var dismiss 6 @Environment(\.dismiss) private var dismiss
6 7
8 private var pinnedDomains: [TrackedDomain] {
9 viewModel.filteredTrackedDomains.filter(\.isPinned)
10 }
11
12 private var otherDomains: [TrackedDomain] {
13 viewModel.filteredTrackedDomains.filter { !$0.isPinned }
14 }
15
7 var body: some View { 16 var body: some View {
8 List { 17 List {
9 if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) { 18 if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
@@ -13,7 +22,7 @@ struct WatchlistView: View {
13 .tint(.cyan) 22 .tint(.cyan)
14 HStack { 23 HStack {
15 Text(viewModel.batchProgressLabel) 24 Text(viewModel.batchProgressLabel)
16 .font(.system(.caption, design: .monospaced)) 25 .font(appDensity.font(.caption))
17 .foregroundStyle(.secondary) 26 .foregroundStyle(.secondary)
18 Spacer() 27 Spacer()
19 if viewModel.batchLookupRunning { 28 if viewModel.batchLookupRunning {
@@ -21,7 +30,7 @@ struct WatchlistView: View {
21 viewModel.cancelBatchLookup() 30 viewModel.cancelBatchLookup()
22 } 31 }
23 .buttonStyle(.bordered) 32 .buttonStyle(.bordered)
24 .font(.system(.caption2, design: .monospaced)) 33 .font(appDensity.font(.caption2))
25 } 34 }
26 } 35 }
27 36
@@ -36,87 +45,34 @@ struct WatchlistView: View {
36 45
37 if viewModel.filteredTrackedDomains.isEmpty { 46 if viewModel.filteredTrackedDomains.isEmpty {
38 Section { 47 Section {
39 VStack(alignment: .leading, spacing: 8) { 48 EmptyStateCardView(
40 Text("No tracked domains yet") 49 title: "No Tracked Domains",
41 .font(.system(.callout, design: .monospaced)) 50 message: "Track important domains locally so you can refresh them quickly and see status changes at a glance.",
42 .foregroundStyle(.primary) 51 suggestion: "Run an inspection and use the Track action on a domain you care about.",
43 Text("Tracked domains appear here. Tracking is local and manual for now.") 52 systemImage: "eye"
44 .font(.system(.caption, design: .monospaced)) 53 )
45 .foregroundStyle(.secondary)
46 }
47 .padding(.vertical, 8)
48 } 54 }
49 .listRowBackground(Color(.systemGray6).opacity(0.5)) 55 .listRowBackground(Color(.systemGray6).opacity(0.5))
50 } else { 56 } else {
51 if let limitMessage = PremiumAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) { 57 if let limitMessage = PremiumAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) {
52 Section { 58 Section {
53 Text(limitMessage) 59 Text(limitMessage)
54 .font(.system(.caption, design: .monospaced)) 60 .font(appDensity.font(.caption))
55 .foregroundStyle(.secondary) 61 .foregroundStyle(.secondary)
56 } 62 }
57 .listRowBackground(Color(.systemGray6).opacity(0.5)) 63 .listRowBackground(Color(.systemGray6).opacity(0.5))
58 } 64 }
59 65
60 Section { 66 if !pinnedDomains.isEmpty {
61 ForEach(viewModel.filteredTrackedDomains) { trackedDomain in 67 trackedSection(title: "Pinned", domains: pinnedDomains)
62 NavigationLink { 68 }
63 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
64 } label: {
65 WatchlistRowView(
66 trackedDomain: trackedDomain,
67 isRefreshing: viewModel.refreshingTrackedDomainID == trackedDomain.id
68 )
69 }
70 .buttonStyle(.plain)
71 .swipeActions(edge: .leading, allowsFullSwipe: false) {
72 Button {
73 viewModel.togglePinned(for: trackedDomain)
74 } label: {
75 Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
76 }
77 .tint(.yellow)
78 }
79 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
80 Button(role: .destructive) {
81 viewModel.deleteTrackedDomain(trackedDomain)
82 } label: {
83 Label("Delete", systemImage: "trash")
84 }
85 }
86 .contextMenu {
87 Button {
88 viewModel.refreshTrackedDomain(trackedDomain)
89 } label: {
90 Label("Refresh", systemImage: "arrow.clockwise")
91 }
92
93 Button {
94 dismiss()
95 viewModel.rerunInspection(for: trackedDomain)
96 } label: {
97 Label("Open Inspection", systemImage: "magnifyingglass")
98 }
99
100 Button {
101 viewModel.togglePinned(for: trackedDomain)
102 } label: {
103 Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
104 }
105 69
106 Button(role: .destructive) { 70 if !otherDomains.isEmpty {
107 viewModel.deleteTrackedDomain(trackedDomain) 71 trackedSection(title: pinnedDomains.isEmpty ? "Tracked Domains" : "Others", domains: otherDomains)
108 } label: {
109 Label("Delete", systemImage: "trash")
110 }
111 }
112 .listRowBackground(Color(.systemGray6).opacity(0.5))
113 }
114 .onDelete(perform: deleteFilteredTrackedDomains)
115 } header: {
116 Text("Tracked Domains")
117 } 72 }
118 } 73 }
119 } 74 }
75 .animation(.easeInOut(duration: 0.2), value: viewModel.filteredTrackedDomains.map(\.id))
120 .scrollContentBackground(.hidden) 76 .scrollContentBackground(.hidden)
121 .background(Color.black) 77 .background(Color.black)
122 .navigationTitle("Watchlist") 78 .navigationTitle("Watchlist")
@@ -138,6 +94,7 @@ struct WatchlistView: View {
138 } 94 }
139 95
140 Button(viewModel.batchLookupRunning ? "Check All Running" : "Check All") { 96 Button(viewModel.batchLookupRunning ? "Check All Running" : "Check All") {
97 AppHaptics.refresh()
141 viewModel.refreshAllTrackedDomains() 98 viewModel.refreshAllTrackedDomains()
142 } 99 }
143 .disabled(viewModel.batchLookupRunning) 100 .disabled(viewModel.batchLookupRunning)
@@ -170,6 +127,86 @@ struct WatchlistView: View {
170 .preferredColorScheme(.dark) 127 .preferredColorScheme(.dark)
171 } 128 }
172 129
130 @ViewBuilder
131 private func trackedSection(title: String, domains: [TrackedDomain]) -> some View {
132 Section(title) {
133 ForEach(domains) { trackedDomain in
134 trackedDomainRow(trackedDomain)
135 }
136 }
137 }
138
139 private func trackedDomainRow(_ trackedDomain: TrackedDomain) -> some View {
140 NavigationLink {
141 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
142 } label: {
143 WatchlistRowView(
144 trackedDomain: trackedDomain,
145 isRefreshing: viewModel.refreshingTrackedDomainID == trackedDomain.id
146 )
147 }
148 .buttonStyle(.plain)
149 .swipeActions(edge: .leading, allowsFullSwipe: false) {
150 Button {
151 AppHaptics.refresh()
152 viewModel.refreshTrackedDomain(trackedDomain)
153 } label: {
154 Label("Refresh", systemImage: "arrow.clockwise")
155 }
156 .tint(.cyan)
157
158 Button {
159 viewModel.togglePinned(for: trackedDomain)
160 } label: {
161 Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
162 }
163 .tint(.yellow)
164 }
165 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
166 Button {
167 AppHaptics.refresh()
168 viewModel.refreshTrackedDomain(trackedDomain)
169 } label: {
170 Label("Refresh", systemImage: "arrow.clockwise")
171 }
172 .tint(.cyan)
173
174 Button(role: .destructive) {
175 viewModel.deleteTrackedDomain(trackedDomain)
176 } label: {
177 Label("Delete", systemImage: "trash")
178 }
179 }
180 .contextMenu {
181 Button {
182 AppHaptics.refresh()
183 viewModel.refreshTrackedDomain(trackedDomain)
184 } label: {
185 Label("Refresh", systemImage: "arrow.clockwise")
186 }
187
188 Button {
189 dismiss()
190 viewModel.rerunInspection(for: trackedDomain)
191 } label: {
192 Label("Open Inspection", systemImage: "magnifyingglass")
193 }
194
195 Button {
196 viewModel.togglePinned(for: trackedDomain)
197 } label: {
198 Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
199 }
200
201 Button(role: .destructive) {
202 viewModel.deleteTrackedDomain(trackedDomain)
203 } label: {
204 Label("Delete", systemImage: "trash")
205 }
206 }
207 .listRowBackground(Color(.systemGray6).opacity(0.5))
208 }
209
173 private var batchSummaryBinding: Binding<BatchSweepSummary?> { 210 private var batchSummaryBinding: Binding<BatchSweepSummary?> {
174 Binding( 211 Binding(
175 get: { viewModel.latestBatchSweepSummary }, 212 get: { viewModel.latestBatchSweepSummary },
@@ -203,11 +240,12 @@ struct WatchlistView: View {
203} 240}
204 241
205struct WatchlistRowView: View { 242struct WatchlistRowView: View {
243 @Environment(\.appDensity) private var appDensity
206 let trackedDomain: TrackedDomain 244 let trackedDomain: TrackedDomain
207 let isRefreshing: Bool 245 let isRefreshing: Bool
208 246
209 var body: some View { 247 var body: some View {
210 VStack(alignment: .leading, spacing: 6) { 248 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
211 HStack(alignment: .firstTextBaseline, spacing: 8) { 249 HStack(alignment: .firstTextBaseline, spacing: 8) {
212 if trackedDomain.isPinned { 250 if trackedDomain.isPinned {
213 Image(systemName: "pin.fill") 251 Image(systemName: "pin.fill")
@@ -215,7 +253,7 @@ struct WatchlistRowView: View {
215 .foregroundStyle(.yellow) 253 .foregroundStyle(.yellow)
216 } 254 }
217 Text(trackedDomain.domain) 255 Text(trackedDomain.domain)
218 .font(.system(.callout, design: .monospaced)) 256 .font(appDensity.font(.callout))
219 .foregroundStyle(.primary) 257 .foregroundStyle(.primary)
220 .lineLimit(2) 258 .lineLimit(2)
221 .multilineTextAlignment(.leading) 259 .multilineTextAlignment(.leading)
@@ -224,19 +262,19 @@ struct WatchlistRowView: View {
224 } 262 }
225 263
226 Text("Updated \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))") 264 Text("Updated \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
227 .font(.system(.caption2, design: .monospaced)) 265 .font(appDensity.font(.caption2))
228 .foregroundStyle(.secondary) 266 .foregroundStyle(.secondary)
229 267
230 indicatorRow 268 indicatorRow
231 269
232 if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { 270 if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
233 Text(note) 271 Text(note)
234 .font(.system(.caption, design: .monospaced)) 272 .font(appDensity.font(.caption))
235 .foregroundStyle(.secondary) 273 .foregroundStyle(.secondary)
236 .lineLimit(2) 274 .lineLimit(2)
237 } else if let summary = trackedDomain.lastChangeSummary { 275 } else if let summary = trackedDomain.lastChangeSummary {
238 Text(summary.message) 276 Text(summary.message)
239 .font(.system(.caption, design: .monospaced)) 277 .font(appDensity.font(.caption))
240 .foregroundStyle(.secondary) 278 .foregroundStyle(.secondary)
241 .lineLimit(2) 279 .lineLimit(2)
242 } 280 }
@@ -259,84 +297,32 @@ struct WatchlistRowView: View {
259 @ViewBuilder 297 @ViewBuilder
260 private var statusBadge: some View { 298 private var statusBadge: some View {
261 if isRefreshing { 299 if isRefreshing {
262 HStack(spacing: 6) { 300 AppStatusBadgeView(model: .init(title: "Refreshing", systemImage: "arrow.clockwise", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.6)))
263 ProgressView()
264 .controlSize(.small)
265 Text("Refreshing")
266 .font(.system(.caption2, design: .monospaced))
267 .foregroundStyle(.secondary)
268 }
269 .padding(.horizontal, 8)
270 .padding(.vertical, 4)
271 .background(Color(.systemGray5).opacity(0.6))
272 .clipShape(Capsule())
273 } else { 301 } else {
274 Text(availabilityLabel(trackedDomain.lastKnownAvailability)) 302 AppStatusBadgeView(model: AppStatusFactory.availability(trackedDomain.lastKnownAvailability))
275 .font(.system(.caption2, design: .monospaced))
276 .foregroundStyle(badgeColor)
277 .padding(.horizontal, 8)
278 .padding(.vertical, 4)
279 .background(badgeBackground)
280 .clipShape(Capsule())
281 }
282 }
283
284 private var badgeColor: Color {
285 switch trackedDomain.lastKnownAvailability {
286 case .available:
287 return .green
288 case .registered:
289 return .yellow
290 case .unknown, .none:
291 return .secondary
292 }
293 }
294
295 private var badgeBackground: Color {
296 switch trackedDomain.lastKnownAvailability {
297 case .available:
298 return .green.opacity(0.16)
299 case .registered:
300 return .yellow.opacity(0.16)
301 case .unknown, .none:
302 return Color(.systemGray5).opacity(0.6)
303 } 303 }
304 } 304 }
305 305
306 @ViewBuilder 306 @ViewBuilder
307 private var indicatorRow: some View { 307 private var indicatorRow: some View {
308 HStack(spacing: 8) { 308 HStack(spacing: 8) {
309 if let severity = trackedDomain.lastChangeSeverity, severity >= .medium { 309 AppStatusBadgeView(model: AppStatusFactory.change(trackedDomain.lastChangeSummary))
310 Label(severity.title, systemImage: severity == .high ? "exclamationmark.circle.fill" : "circle.fill")
311 .font(.system(.caption2, design: .monospaced))
312 .foregroundStyle(severity == .high ? .red : .yellow)
313 } else {
314 Label("Stable", systemImage: "circle.fill")
315 .font(.system(.caption2, design: .monospaced))
316 .foregroundStyle(.secondary)
317 }
318 310
319 if trackedDomain.certificateWarningLevel != .none { 311 if trackedDomain.certificateWarningLevel != .none {
320 Text(certificateLabel) 312 AppStatusBadgeView(model: certificateBadge)
321 .font(.system(.caption2, design: .monospaced))
322 .foregroundStyle(trackedDomain.certificateWarningLevel == .critical ? .red : .yellow)
323 .padding(.horizontal, 8)
324 .padding(.vertical, 4)
325 .background((trackedDomain.certificateWarningLevel == .critical ? Color.red : Color.yellow).opacity(0.16))
326 .clipShape(Capsule())
327 } 313 }
328 } 314 }
329 } 315 }
330 316
331 private var certificateLabel: String { 317 private var certificateBadge: AppStatusBadgeModel {
332 let days = trackedDomain.certificateDaysRemaining.map { "\($0)d" } ?? "Soon" 318 let days = trackedDomain.certificateDaysRemaining.map { "\($0)d" } ?? "Soon"
333 switch trackedDomain.certificateWarningLevel { 319 switch trackedDomain.certificateWarningLevel {
334 case .critical: 320 case .critical:
335 return "Cert \(days)" 321 return .init(title: "Invalid \(days)", systemImage: "xmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
336 case .warning: 322 case .warning:
337 return "Warn \(days)" 323 return .init(title: "Expiring \(days)", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
338 case .none: 324 case .none:
339 return "" 325 return .init(title: "Valid", systemImage: "lock.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
340 } 326 }
341 } 327 }
342} 328}