diff options
Diffstat (limited to 'DomainDig')
| -rw-r--r-- | DomainDig/BatchResultsView.swift | 54 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 598 | ||||
| -rw-r--r-- | DomainDig/DomainDigApp.swift | 3 | ||||
| -rw-r--r-- | DomainDig/DomainDigUI.swift | 319 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 6 | ||||
| -rw-r--r-- | DomainDig/HistoryView.swift | 138 | ||||
| -rw-r--r-- | DomainDig/LookupRuntime.swift | 7 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 252 |
8 files changed, 993 insertions, 384 deletions
diff --git a/DomainDig/BatchResultsView.swift b/DomainDig/BatchResultsView.swift index 1e0333d..51f6415 100644 --- a/DomainDig/BatchResultsView.swift +++ b/DomainDig/BatchResultsView.swift @@ -1,11 +1,12 @@ import SwiftUI struct BatchResultsView: View { + @Environment(\.appDensity) private var appDensity @Bindable var viewModel: DomainViewModel let title: String var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { HStack(alignment: .top) { SectionTitleView(title: title) Spacer() @@ -15,18 +16,23 @@ struct BatchResultsView: View { .tint(.cyan) .frame(width: 120) Text(viewModel.batchProgressLabel) - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) } } else if !viewModel.batchResults.isEmpty { Text("\(viewModel.batchResults.count) domains") - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) } } if viewModel.batchResults.isEmpty { - MessageCardView(text: "No batch results yet", isError: false) + EmptyStateCardView( + title: "No Batch Results Yet", + message: "Batch runs collect availability, IP, and change status for multiple domains in one pass.", + suggestion: "Switch to Bulk mode, paste a list of domains, then run a batch lookup.", + systemImage: "square.stack.3d.up" + ) } else { CardView(allowsHorizontalScroll: false) { ForEach(viewModel.batchResults) { result in @@ -48,50 +54,46 @@ struct BatchResultsView: View { } struct BatchResultRowView: View { + @Environment(\.appDensity) private var appDensity let result: BatchLookupResult var body: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(result.domain) - .font(.system(.callout, design: .monospaced)) + .font(appDensity.font(.callout)) .foregroundStyle(.primary) .lineLimit(1) Spacer(minLength: 8) Text(result.resultSource.label.lowercased()) - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) - Text(result.quickStatus) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(quickStatusColor) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(quickStatusColor.opacity(0.16)) - .clipShape(Capsule()) + AppStatusBadgeView(model: quickStatusBadge) } HStack(spacing: 10) { - Text(availabilityText) + AppStatusBadgeView(model: AppStatusFactory.availability(result.availability)) Text(result.primaryIP ?? "No IP") Text(result.timestamp.formatted(date: .abbreviated, time: .shortened)) } - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) if let summaryMessage = result.summaryMessage { Text(summaryMessage) - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) } if let errorMessage = result.errorMessage { Text(errorMessage) - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(result.status == .failed ? .red : .secondary) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 4) + .frame(minHeight: appDensity.metrics.rowMinHeight + 12, alignment: .topLeading) } private var availabilityText: String { @@ -105,25 +107,25 @@ struct BatchResultRowView: View { } } - private var quickStatusColor: Color { + private var quickStatusBadge: AppStatusBadgeModel { switch result.status { case .pending: - return .secondary + return .init(title: "Pending", systemImage: "clock", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55)) case .running: - return .cyan + return .init(title: "Running", systemImage: "arrow.clockwise", foregroundColor: .cyan, backgroundColor: .cyan.opacity(0.16)) case .completed: if result.changeSeverity == .high || result.certificateWarningLevel == .critical { - return .red + return .init(title: "High", systemImage: "exclamationmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16)) } if result.changeSeverity == .medium || result.certificateWarningLevel == .warning { - return .yellow + return .init(title: "Warning", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16)) } if result.quickStatus == "Changed" { - return .blue + return .init(title: "Changed", systemImage: "arrow.triangle.2.circlepath", foregroundColor: .cyan, backgroundColor: .cyan.opacity(0.16)) } - return .green + return .init(title: "Stable", systemImage: "checkmark.circle.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16)) case .failed: - return .red + return .init(title: "Failed", systemImage: "xmark.circle.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16)) } } } diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index 7a3a42d..21c37d2 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -8,7 +8,18 @@ enum LookupInputMode: String, CaseIterable, Identifiable { var id: String { rawValue } } +enum ResultSection: String, Hashable { + case domain + case ownership + case dns + case web + case email + case network + case subdomains +} + struct ContentView: View { + @Environment(\.appDensity) private var appDensity @State private var viewModel = DomainViewModel() @State private var navigationPath = NavigationPath() @FocusState private var domainFieldFocused: Bool @@ -18,6 +29,7 @@ struct ContentView: View { @State private var editingTrackedDomain: TrackedDomain? @State private var showTrackLimitAlert = false @State private var inputMode: LookupInputMode = .single + @State private var collapsedSections: Set<ResultSection> = [.network] var body: some View { NavigationStack(path: $navigationPath) { @@ -26,21 +38,22 @@ struct ContentView: View { inputSection if !viewModel.batchResults.isEmpty || viewModel.batchLookupRunning { batchSection - .padding(.top, 8) + .padding(.top, appDensity.metrics.cardSpacing) } if viewModel.hasRun { actionButtons - if let statusMessage = viewModel.currentStatusMessage ?? (viewModel.currentResultSource != .live ? viewModel.currentResultSource.label : nil) { + if let statusMessage = resultStatusMessage { LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource) - .padding(.top, 8) + .padding(.top, appDensity.metrics.cardSpacing) } SummaryView(fields: viewModel.summaryFields) - .padding(.top, 8) + .padding(.top, appDensity.metrics.cardSpacing) if let changeSummary = viewModel.currentChangeSummary { DomainChangeSummaryView(summary: changeSummary) - .padding(.top, 12) + .padding(.top, appDensity.metrics.cardSpacing) } DomainSectionView( + isCollapsed: sectionCollapsedBinding(.domain), rows: viewModel.domainRows, suggestions: viewModel.suggestionRows, showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading, @@ -63,38 +76,42 @@ struct ContentView: View { editingTrackedDomain = trackedDomain } ) - .padding(.top, 16) + .padding(.top, appDensity.metrics.sectionSpacing) OwnershipSectionView( + isCollapsed: sectionCollapsedBinding(.ownership), rows: viewModel.ownershipRows, loading: viewModel.ownershipLoading, error: viewModel.ownershipError, showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory) ) - .padding(.top, 16) + .padding(.top, appDensity.metrics.sectionSpacing) SubdomainsSectionView( + isCollapsed: sectionCollapsedBinding(.subdomains), rows: viewModel.subdomainRows, loading: viewModel.subdomainsLoading, error: viewModel.subdomainsError, showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains) ) - .padding(.top, 16) + .padding(.top, appDensity.metrics.sectionSpacing) if !viewModel.currentDiffSections.isEmpty { DomainDiffView( title: "Latest Changes", sections: viewModel.currentDiffSections, showsUnchanged: false ) - .padding(.top, 16) + .padding(.top, appDensity.metrics.sectionSpacing) } DNSSectionView( + isCollapsed: sectionCollapsedBinding(.dns), dnssecLabel: viewModel.dnssecLabel, sections: viewModel.dnsRows, ptrMessage: viewModel.ptrMessage, loading: viewModel.dnsLoading || viewModel.ptrLoading, sectionError: viewModel.dnsError ) - .padding(.top, 16) + .padding(.top, appDensity.metrics.sectionSpacing) WebSectionView( + isCollapsed: sectionCollapsedBinding(.web), certificateRows: viewModel.webCertificateRows, sslInfo: viewModel.sslInfo, sslLoading: viewModel.sslLoading || viewModel.hstsLoading, @@ -108,14 +125,16 @@ struct ContentView: View { redirectError: viewModel.redirectChainError, finalURL: viewModel.currentSnapshot.redirectChain.last?.url ) - .padding(.top, 16) + .padding(.top, appDensity.metrics.sectionSpacing) EmailSectionView( + isCollapsed: sectionCollapsedBinding(.email), rows: viewModel.emailRows, loading: viewModel.emailSecurityLoading, error: viewModel.emailSecurityError ) - .padding(.top, 16) + .padding(.top, appDensity.metrics.sectionSpacing) NetworkSectionView( + isCollapsed: sectionCollapsedBinding(.network), reachabilityRows: viewModel.reachabilityRows, reachabilityLoading: viewModel.reachabilityLoading, reachabilityError: viewModel.reachabilityError, @@ -134,7 +153,7 @@ struct ContentView: View { customPortInput: $customPortInput, onScanCustomPorts: runCustomPortScan ) - .padding(.top, 16) + .padding(.top, appDensity.metrics.sectionSpacing) } else if !viewModel.recentSearches.isEmpty { recentSearchesSection } @@ -142,7 +161,34 @@ struct ContentView: View { .padding(.horizontal) .padding(.bottom, 32) } - .background(Color.black) + .safeAreaInset(edge: .top) { + if viewModel.hasRun { + StickyLookupSummaryView( + domain: viewModel.searchedDomain, + availability: viewModel.availabilityResult?.status, + primaryIP: currentPrimaryIP, + sslInfo: viewModel.sslInfo, + sslError: viewModel.sslError, + emailSecurity: viewModel.emailSecurity, + emailError: viewModel.emailSecurityError, + changeSummary: viewModel.currentChangeSummary + ) + .padding(.horizontal) + .padding(.top, 6) + .background { + Rectangle() + .fill(.ultraThinMaterial) + .opacity(0.96) + } + } + } + .background( + LinearGradient( + colors: [Color.black, Color(.systemGray6).opacity(0.12)], + startPoint: .top, + endPoint: .bottom + ) + ) .navigationTitle("DomainDig") .toolbarColorScheme(.dark, for: .navigationBar) .preferredColorScheme(.dark) @@ -176,7 +222,7 @@ struct ContentView: View { } NavigationLink { - SettingsView() + SettingsView(viewModel: viewModel) } label: { Label("Settings", systemImage: "gearshape") } @@ -190,6 +236,9 @@ struct ContentView: View { .onAppear { domainFieldFocused = true } + .onChange(of: viewModel.searchedDomain) { _, _ in + collapsedSections = defaultCollapsedSections + } .onChange(of: viewModel.rerunNavigationToken) { _, _ in navigationPath = NavigationPath() domainFieldFocused = false @@ -228,7 +277,7 @@ struct ContentView: View { } private var inputSection: some View { - VStack(spacing: 12) { + VStack(spacing: appDensity.metrics.cardSpacing + 2) { Picker("Mode", selection: $inputMode) { Text("Single").tag(LookupInputMode.single) Text("Bulk").tag(LookupInputMode.bulk) @@ -237,13 +286,14 @@ struct ContentView: View { if inputMode == .single { TextField("e.g. cleberg.net", text: $viewModel.domain) - .font(.system(.title3, design: .monospaced)) + .font(appDensity.font(.title3, design: .monospaced)) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) - .padding(12) + .padding(.horizontal, 12) + .padding(.vertical, appDensity.metrics.controlVerticalPadding) .background(Color(.systemGray6)) - .cornerRadius(8) + .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) .focused($domainFieldFocused) .onSubmit { viewModel.run() } @@ -252,16 +302,16 @@ struct ContentView: View { viewModel.run() } label: { Text("Run") - .font(.headline) + .font(appDensity.font(.headline, design: .default, weight: .semibold)) .frame(maxWidth: .infinity) - .padding(.vertical, 12) + .frame(minHeight: appDensity.metrics.controlMinHeight) } .buttonStyle(.borderedProminent) .disabled(viewModel.trimmedDomain.isEmpty) } else { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { Text("Paste domains separated by new lines or commas.") - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .foregroundStyle(.secondary) TextField( @@ -269,30 +319,31 @@ struct ContentView: View { text: $viewModel.bulkInput, axis: .vertical ) - .font(.system(.body, design: .monospaced)) + .font(appDensity.font(.body)) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) .lineLimit(4...10) - .padding(12) + .padding(.horizontal, 12) + .padding(.vertical, appDensity.metrics.controlVerticalPadding) .background(Color(.systemGray6)) - .cornerRadius(8) + .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) Button { domainFieldFocused = false viewModel.runBulkLookup() } label: { Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch") - .font(.headline) + .font(appDensity.font(.headline, design: .default, weight: .semibold)) .frame(maxWidth: .infinity) - .padding(.vertical, 12) + .frame(minHeight: appDensity.metrics.controlMinHeight) } .buttonStyle(.borderedProminent) .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning) } } } - .padding(.vertical, 16) + .padding(.vertical, appDensity.metrics.sectionSpacing) } private var actionButtons: some View { @@ -303,7 +354,7 @@ struct ContentView: View { viewModel.toggleSavedDomain() } label: { Image(systemName: viewModel.isCurrentDomainSaved ? "bookmark.fill" : "bookmark") - .font(.system(.body)) + .font(appDensity.font(.body, design: .default)) .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary) } Menu { @@ -318,7 +369,7 @@ struct ContentView: View { } } label: { Image(systemName: "square.and.arrow.up") - .font(.system(.body)) + .font(appDensity.font(.body, design: .default)) .foregroundStyle(.secondary) } } @@ -326,7 +377,7 @@ struct ContentView: View { } private var batchSection: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { HStack { Spacer() if viewModel.batchLookupRunning { @@ -334,7 +385,7 @@ struct ContentView: View { viewModel.cancelBatchLookup() } .buttonStyle(.bordered) - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) } if !viewModel.currentBatchResultEntries.isEmpty { Menu { @@ -349,7 +400,7 @@ struct ContentView: View { } } label: { Label("Export", systemImage: "square.and.arrow.up") - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) } .buttonStyle(.bordered) } @@ -363,16 +414,16 @@ struct ContentView: View { } private var recentSearchesSection: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { HStack { Text("RECENT") - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) Spacer() Button("Clear") { viewModel.clearRecentSearches() } - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) } @@ -383,17 +434,17 @@ struct ContentView: View { viewModel.run() } label: { Text(domain) - .font(.system(.callout, design: .monospaced)) + .font(appDensity.font(.callout)) .foregroundStyle(.primary) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 6) + .padding(.vertical, 8) .padding(.horizontal, 10) .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) + .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) } } } - .padding(.top, 8) + .padding(.top, appDensity.metrics.cardSpacing) } private func runCustomPortScan() { @@ -468,37 +519,125 @@ struct ContentView: View { return (filename, data) } + + private var defaultCollapsedSections: Set<ResultSection> { + var sections: Set<ResultSection> = [] + if viewModel.standardPortRows.count + viewModel.customPortRows.count > 6 || currentPrimaryIP == nil { + sections.insert(.network) + } + return sections + } + + private var currentPrimaryIP: String? { + viewModel.currentSnapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value + } + + private var resultStatusMessage: String? { + if let currentStatusMessage = viewModel.currentStatusMessage { + return currentStatusMessage + } + + if viewModel.currentResultSource != .live { + return viewModel.currentResultSource.label + } + + return nil + } + + private func sectionCollapsedBinding(_ section: ResultSection) -> Binding<Bool> { + Binding( + get: { collapsedSections.contains(section) }, + set: { isCollapsed in + if isCollapsed { + collapsedSections.insert(section) + } else { + collapsedSections.remove(section) + } + } + ) + } } struct SummaryView: View { + @Environment(\.appDensity) private var appDensity let fields: [SummaryFieldViewData] var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { SectionTitleView(title: "Summary") - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: appDensity.metrics.cardSpacing) { ForEach(fields) { field in - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing) { Text(field.label) - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) Text(field.value) - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .foregroundStyle(ResultColors.color(for: field.tone)) .lineLimit(2) .textSelection(.enabled) } + .frame(minHeight: appDensity.metrics.rowMinHeight + 12, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .leading) - .padding(10) + .padding(appDensity.metrics.cardPadding) .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) + .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) } } } } } +struct StickyLookupSummaryView: View { + @Environment(\.appDensity) private var appDensity + + let domain: String + let availability: DomainAvailabilityStatus? + let primaryIP: String? + let sslInfo: SSLCertificateInfo? + let sslError: String? + let emailSecurity: EmailSecurityResult? + let emailError: String? + let changeSummary: DomainChangeSummary? + + var body: some View { + CardView(allowsHorizontalScroll: false) { + VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { + HStack(alignment: .center, spacing: 10) { + Text(domain) + .font(appDensity.font(.headline, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + Spacer(minLength: 6) + AppCopyButton(value: domain, label: "Copy domain") + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + AppStatusBadgeView(model: AppStatusFactory.availability(availability)) + AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError)) + AppStatusBadgeView(model: AppStatusFactory.email(emailSecurity, error: emailError)) + AppStatusBadgeView(model: AppStatusFactory.change(changeSummary)) + } + } + + if let primaryIP { + HStack(spacing: 8) { + Label(primaryIP, systemImage: "network") + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + Spacer(minLength: 6) + AppCopyButton(value: primaryIP, label: "Copy IP") + } + } + } + } + .shadow(color: .black.opacity(0.12), radius: 14, y: 6) + } +} + struct LookupStatusBannerView: View { + @Environment(\.appDensity) private var appDensity let message: String let resultSource: LookupResultSource @@ -507,14 +646,14 @@ struct LookupStatusBannerView: View { Image(systemName: iconName) .font(.caption) Text(message) - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) Spacer() } .foregroundStyle(color) - .padding(8) + .padding(appDensity.metrics.cardPadding - 2) .frame(maxWidth: .infinity, alignment: .leading) .background(color.opacity(0.12)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) } private var color: Color { @@ -545,29 +684,30 @@ struct LookupStatusBannerView: View { } struct DomainChangeSummaryView: View { + @Environment(\.appDensity) private var appDensity let summary: DomainChangeSummary var body: some View { CardView(allowsHorizontalScroll: false) { HStack { Label(summary.hasChanges ? "Changed" : "Stable", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle") - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .green) Spacer() Text(summary.severity.title.uppercased()) - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .secondary) .padding(.horizontal, 8) .padding(.vertical, 4) .background((summary.hasChanges ? severityColor(summary.severity) : .secondary).opacity(0.16)) .clipShape(Capsule()) Text(summary.generatedAt, style: .time) - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) } Text(summary.message) - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .foregroundStyle(.primary) } } @@ -770,6 +910,8 @@ struct TrackedDomainDetailHeaderView: View { } struct DomainSectionView: View { + @Environment(\.appDensity) private var appDensity + @Binding var isCollapsed: Bool let rows: [InfoRowViewData] let suggestions: [DomainSuggestionViewData] let showSuggestions: Bool @@ -782,38 +924,34 @@ struct DomainSectionView: View { let onEditNote: (() -> Void)? var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - SectionTitleView(title: "Domain") - Spacer() - if let trackedDomain { - HStack(spacing: 8) { - Text("Tracked") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.green) - Button { - onTogglePinned() - } label: { - Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin") + CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) { + if let trackedDomain { + HStack(spacing: 8) { + AppStatusBadgeView(model: .init(title: "Tracked", systemImage: "eye.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))) + Button { + onTogglePinned() + } label: { + Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin") + } + .buttonStyle(.bordered) + .font(appDensity.font(.caption)) + if let onEditNote { + Button("Note") { + onEditNote() } .buttonStyle(.bordered) - .font(.system(.caption, design: .monospaced)) - if let onEditNote { - Button("Note") { - onEditNote() - } - .buttonStyle(.bordered) - .font(.system(.caption, design: .monospaced)) - } - } - } else { - Button("Track") { - onTrack() + .font(appDensity.font(.caption)) } - .buttonStyle(.bordered) - .font(.system(.caption, design: .monospaced)) } + } else { + Button("Track") { + AppHaptics.track() + onTrack() + } + .buttonStyle(.bordered) + .font(appDensity.font(.caption)) } + } content: { CardView(allowsHorizontalScroll: false) { ForEach(rows) { row in LabeledValueRow(row: row) @@ -832,7 +970,7 @@ struct DomainSectionView: View { } if showSuggestions { Text("Suggestions") - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .foregroundStyle(.secondary) .padding(.top, 4) if suggestionsLoading { @@ -844,13 +982,11 @@ struct DomainSectionView: View { ForEach(suggestions) { suggestion in HStack { Text(suggestion.domain) - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .foregroundStyle(.primary) .textSelection(.enabled) Spacer() - Text(suggestion.status) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(ResultColors.color(for: suggestion.tone)) + AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.status == "Available" ? .available : .registered)) } } } @@ -861,14 +997,14 @@ struct DomainSectionView: View { } struct OwnershipSectionView: View { + @Binding var isCollapsed: Bool let rows: [InfoRowViewData] let loading: Bool let error: String? let showsHistoryPlaceholder: Bool var body: some View { - VStack(alignment: .leading, spacing: 12) { - SectionTitleView(title: "Ownership") + CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) { CardView(allowsHorizontalScroll: false) { if loading { ProgressView("Fetching RDAP ownership…") @@ -892,21 +1028,15 @@ struct OwnershipSectionView: View { } struct SubdomainsSectionView: View { + @Environment(\.appDensity) private var appDensity + @Binding var isCollapsed: Bool let rows: [SubdomainRowViewData] let loading: Bool let error: String? let showsExtendedPlaceholder: Bool var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - SectionTitleView(title: "Subdomains") - Spacer() - Text("\(rows.count)") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - } - + CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") { CardView(allowsHorizontalScroll: false) { if loading { ProgressView("Checking certificate transparency…") @@ -921,7 +1051,7 @@ struct SubdomainsSectionView: View { ForEach(rows) { row in HStack(spacing: 8) { Text(row.hostname) - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .foregroundStyle(.primary) .textSelection(.enabled) Spacer() @@ -947,6 +1077,7 @@ struct SubdomainsSectionView: View { } struct DNSSectionView: View { + @Binding var isCollapsed: Bool let dnssecLabel: String? let sections: [DNSRecordSectionViewData] let ptrMessage: SectionMessageViewData? @@ -954,18 +1085,7 @@ struct DNSSectionView: View { let sectionError: String? var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 8) { - SectionTitleView(title: "DNS") - Spacer() - if let dnssecLabel { - Text(dnssecLabel) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - .multilineTextAlignment(.trailing) - } - } - + CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) { if loading { LoadingCardView(text: "Querying DNS…") } else if let sectionError, sections.isEmpty { @@ -1013,6 +1133,8 @@ struct DNSSectionView: View { } struct WebSectionView: View { + @Environment(\.appDensity) private var appDensity + @Binding var isCollapsed: Bool let certificateRows: [InfoRowViewData] let sslInfo: SSLCertificateInfo? let sslLoading: Bool @@ -1027,14 +1149,15 @@ struct WebSectionView: View { let finalURL: String? var body: some View { - VStack(alignment: .leading, spacing: 12) { - SectionTitleView(title: "Web") - + CollapsibleSectionView(title: "Web", isCollapsed: $isCollapsed) { CardView { - Text("TLS") - .font(.system(.subheadline, design: .monospaced)) - .fontWeight(.semibold) - .foregroundStyle(.cyan) + HStack { + Text("TLS") + .font(appDensity.font(.subheadline, weight: .semibold)) + .foregroundStyle(.cyan) + Spacer() + AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError)) + } if sslLoading { ProgressView("Checking certificate…") .appLoadingStyle() @@ -1046,12 +1169,16 @@ struct WebSectionView: View { } if let sslInfo, !sslInfo.subjectAltNames.isEmpty { Text("SANs") - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) ForEach(sslInfo.subjectAltNames, id: \.self) { san in - Text(san) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) + HStack(spacing: 8) { + Text(san) + .font(appDensity.font(.caption)) + .textSelection(.enabled) + Spacer() + AppCopyButton(value: san, label: "Copy certificate SAN") + } } } } @@ -1059,8 +1186,7 @@ struct WebSectionView: View { CardView { Text("Headers") - .font(.system(.subheadline, design: .monospaced)) - .fontWeight(.semibold) + .font(appDensity.font(.subheadline, weight: .semibold)) .foregroundStyle(.cyan) if headersLoading { ProgressView("Fetching headers…") @@ -1077,10 +1203,10 @@ struct WebSectionView: View { ForEach(headers) { header in HStack(alignment: .top, spacing: 4) { Text(header.name + ":") - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan) Text(header.value) - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .foregroundStyle(.primary) .textSelection(.enabled) } @@ -1090,10 +1216,15 @@ struct WebSectionView: View { } CardView { - Text("Redirects") - .font(.system(.subheadline, design: .monospaced)) - .fontWeight(.semibold) - .foregroundStyle(.cyan) + HStack { + Text("Redirects") + .font(appDensity.font(.subheadline, weight: .semibold)) + .foregroundStyle(.cyan) + Spacer() + if let finalURL { + AppCopyButton(value: finalURL, label: "Copy redirect URL") + } + } if redirectLoading { ProgressView("Tracing redirects…") .appLoadingStyle() @@ -1108,19 +1239,20 @@ struct WebSectionView: View { ForEach(redirects) { redirect in HStack(alignment: .top, spacing: 6) { Text(redirect.stepLabel) - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .foregroundStyle(.secondary) .frame(width: 16, alignment: .trailing) Text(redirect.statusCode) - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .foregroundStyle(.cyan) .frame(width: 36, alignment: .leading) Text(redirect.url) - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .textSelection(.enabled) + AppCopyButton(value: redirect.url, label: "Copy redirect URL") if redirect.isFinal { Text("(final)") - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) } } @@ -1132,14 +1264,20 @@ struct WebSectionView: View { } struct EmailSectionView: View { + @Environment(\.appDensity) private var appDensity + @Binding var isCollapsed: Bool let rows: [EmailRowViewData] let loading: Bool let error: String? var body: some View { - VStack(alignment: .leading, spacing: 12) { - SectionTitleView(title: "Email") + CollapsibleSectionView(title: "Email", isCollapsed: $isCollapsed) { CardView { + HStack { + Spacer() + AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error)) + .opacity(loading ? 0 : 1) + } if loading { ProgressView("Checking email records…") .appLoadingStyle() @@ -1152,20 +1290,18 @@ struct EmailSectionView: View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { Text(row.label) - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .foregroundStyle(.cyan) .frame(width: 76, alignment: .leading) - Text(row.status) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ResultColors.color(for: row.statusTone)) + AppStatusBadgeView(model: emailRowBadge(row)) } Text(row.detail) - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(.primary) .textSelection(.enabled) if let auxiliaryDetail = row.auxiliaryDetail { Text(auxiliaryDetail) - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) } } @@ -1174,9 +1310,24 @@ struct EmailSectionView: View { } } } + + private func emailRowBadge(_ row: EmailRowViewData) -> AppStatusBadgeModel { + switch row.statusTone { + case .success: + return .init(title: row.status, systemImage: "checkmark.shield.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16)) + case .warning: + return .init(title: row.status, systemImage: "shield.lefthalf.filled", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16)) + case .failure: + return .init(title: row.status, systemImage: "minus.circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55)) + case .primary, .secondary: + return .init(title: row.status, systemImage: "circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55)) + } + } } struct NetworkSectionView: View { + @Environment(\.appDensity) private var appDensity + @Binding var isCollapsed: Bool let reachabilityRows: [ReachabilityRowViewData] let reachabilityLoading: Bool let reachabilityError: String? @@ -1196,13 +1347,10 @@ struct NetworkSectionView: View { let onScanCustomPorts: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 12) { - SectionTitleView(title: "Network") - + CollapsibleSectionView(title: "Network", isCollapsed: $isCollapsed) { CardView { Text("Reachability") - .font(.system(.subheadline, design: .monospaced)) - .fontWeight(.semibold) + .font(appDensity.font(.subheadline, weight: .semibold)) .foregroundStyle(.cyan) if reachabilityLoading { ProgressView("Checking ports…") @@ -1213,14 +1361,12 @@ struct NetworkSectionView: View { ForEach(reachabilityRows) { row in HStack { Text(row.portLabel) - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) Spacer() Text(row.latencyLabel) - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) - Text(row.statusLabel) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ResultColors.color(for: row.statusTone)) + AppStatusBadgeView(model: reachabilityBadge(row)) } } } @@ -1228,8 +1374,7 @@ struct NetworkSectionView: View { CardView(allowsHorizontalScroll: false) { Text("Location") - .font(.system(.subheadline, design: .monospaced)) - .fontWeight(.semibold) + .font(appDensity.font(.subheadline, weight: .semibold)) .foregroundStyle(.cyan) if geolocationLoading { ProgressView("Looking up location…") @@ -1260,13 +1405,12 @@ struct NetworkSectionView: View { CardView(allowsHorizontalScroll: false) { Text("Port Scan") - .font(.system(.subheadline, design: .monospaced)) - .fontWeight(.semibold) + .font(appDensity.font(.subheadline, weight: .semibold)) .foregroundStyle(.cyan) if isCloudflareProxied { Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.") - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(.orange) .fixedSize(horizontal: false, vertical: true) } @@ -1286,15 +1430,16 @@ struct NetworkSectionView: View { DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) { VStack(alignment: .leading, spacing: 10) { TextField("8888, 9000, 27017", text: $customPortInput) - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.numberPad) .padding(10) .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) + .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) Button("Scan") { + AppHaptics.refresh() onScanCustomPorts() } .buttonStyle(.borderedProminent) @@ -1316,9 +1461,23 @@ struct NetworkSectionView: View { } } } + + private func reachabilityBadge(_ row: ReachabilityRowViewData) -> AppStatusBadgeModel { + switch row.statusTone { + case .success: + return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16)) + case .warning: + return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16)) + case .failure: + return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16)) + case .primary, .secondary: + return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55)) + } + } } struct PortRowsView: View { + @Environment(\.appDensity) private var appDensity let rows: [PortScanRowViewData] var body: some View { @@ -1326,47 +1485,61 @@ struct PortRowsView: View { MessageRowView(text: "No results", isError: false) } else { ForEach(rows) { row in - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) { HStack { Text(row.portLabel) - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .frame(width: 52, alignment: .leading) Text(row.service) - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .foregroundStyle(.primary) Spacer() if let durationLabel = row.durationLabel { Text(durationLabel) - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) } - Text(row.statusLabel) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(ResultColors.color(for: row.statusTone)) + AppStatusBadgeView(model: portBadge(row)) } if let banner = row.banner { Text(banner) - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) .padding(.leading, 8) } } + .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading) } } } + + private func portBadge(_ row: PortScanRowViewData) -> AppStatusBadgeModel { + switch row.statusTone { + case .success: + return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16)) + case .warning: + return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16)) + case .failure: + return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16)) + case .primary, .secondary: + return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55)) + } + } } struct SectionTitleView: View { + @Environment(\.appDensity) private var appDensity let title: String var body: some View { Text(title) - .font(.system(.headline)) + .font(appDensity.font(.headline, design: .default, weight: .semibold)) .foregroundStyle(.white) } } struct CardView<Content: View>: View { + @Environment(\.appDensity) private var appDensity let allowsHorizontalScroll: Bool let content: Content @@ -1389,13 +1562,13 @@ struct CardView<Content: View>: View { } } .frame(maxWidth: .infinity, alignment: .leading) - .padding(10) + .padding(appDensity.metrics.cardPadding) .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) + .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) } private var cardContent: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { content } } @@ -1425,29 +1598,40 @@ struct MessageCardView: View { } struct MessageRowView: View { + @Environment(\.appDensity) private var appDensity let text: String let isError: Bool var body: some View { Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle") - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .foregroundStyle(isError ? .red : .secondary) } } struct LabeledValueRow: View { + @Environment(\.appDensity) private var appDensity let row: InfoRowViewData var body: some View { - VStack(alignment: .leading, spacing: 2) { - Text(row.label) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - Text(row.value) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ResultColors.color(for: row.tone)) - .textSelection(.enabled) + VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) { + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) { + Text(row.label) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + Text(row.value) + .font(appDensity.font(.caption)) + .foregroundStyle(ResultColors.color(for: row.tone)) + .textSelection(.enabled) + } + Spacer(minLength: 6) + if !row.value.isEmpty, row.value != "Unavailable" { + AppCopyButton(value: row.value, label: "Copy \(row.label)") + } + } } + .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading) } } @@ -1490,11 +1674,17 @@ private extension String { } private struct SettingsView: View { + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel @AppStorage(DNSResolverOption.userDefaultsKey) private var storedResolverURL = DNSResolverOption.defaultURLString + @AppStorage(AppDensity.userDefaultsKey) + private var storedDensity = AppDensity.compact.rawValue @State private var resolverOption: DNSResolverOption = .cloudflare @State private var customResolverURL = DNSResolverOption.defaultURLString + @State private var showClearHistoryConfirmation = false + @State private var showClearCacheConfirmation = false private var customResolverError: String? { guard resolverOption == .custom else { @@ -1505,7 +1695,15 @@ private struct SettingsView: View { var body: some View { Form { - Section { + Section("Display") { + Picker("Density", selection: $storedDensity) { + ForEach(AppDensity.allCases) { density in + Text(density.title).tag(density.rawValue) + } + } + } + + Section("Network") { Picker("Resolver", selection: $resolverOption) { ForEach(DNSResolverOption.allCases) { option in Text(option.title).tag(option) @@ -1520,13 +1718,45 @@ private struct SettingsView: View { if let customResolverError { Text(customResolverError) - .font(.caption) + .font(appDensity.font(.caption, design: .default)) .foregroundStyle(.red) } } } + + Section("Data") { + Button("Clear History", role: .destructive) { + showClearHistoryConfirmation = true + } + + Button("Clear Cache", role: .destructive) { + showClearCacheConfirmation = true + } + } + + Section("About") { + LabeledContent("Version", value: appVersion) + LabeledContent("Storage", value: "Local-only") + LabeledContent("Focus", value: "Readable domain inspection") + } } .navigationTitle("Settings") + .alert("Clear history?", isPresented: $showClearHistoryConfirmation) { + Button("Clear", role: .destructive) { + viewModel.clearHistory() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes saved lookup snapshots from this device.") + } + .alert("Clear cache?", isPresented: $showClearCacheConfirmation) { + Button("Clear", role: .destructive) { + viewModel.clearLookupCache() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This clears the in-memory lookup cache and cancels any cached in-flight work.") + } .onAppear { let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) resolverOption = DNSResolverOption.option(for: currentResolverURL) @@ -1544,6 +1774,10 @@ private struct SettingsView: View { storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines) } } + + private var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "2.6.0" + } } #Preview { diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift index a34eb12..29e0117 100644 --- a/DomainDig/DomainDigApp.swift +++ b/DomainDig/DomainDigApp.swift @@ -9,6 +9,8 @@ import SwiftUI @main struct DomainDigApp: App { + @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue + init() { LocalNotificationService.shared.configureForegroundPresentation() } @@ -16,6 +18,7 @@ struct DomainDigApp: App { var body: some Scene { WindowGroup { ContentView() + .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact) } } } diff --git a/DomainDig/DomainDigUI.swift b/DomainDig/DomainDigUI.swift new file mode 100644 index 0000000..c3caf59 --- /dev/null +++ b/DomainDig/DomainDigUI.swift @@ -0,0 +1,319 @@ +import SwiftUI + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +enum AppDensity: String, CaseIterable, Identifiable { + case compact + case comfortable + + static let userDefaultsKey = "appDensity" + + var id: String { rawValue } + + var title: String { + switch self { + case .compact: + return "Compact" + case .comfortable: + return "Comfortable" + } + } + + var metrics: AppDensityMetrics { + switch self { + case .compact: + return AppDensityMetrics( + sectionSpacing: 14, + cardSpacing: 6, + cardPadding: 10, + rowSpacing: 4, + rowMinHeight: 30, + controlVerticalPadding: 10, + controlMinHeight: 42, + cardCornerRadius: 10 + ) + case .comfortable: + return AppDensityMetrics( + sectionSpacing: 18, + cardSpacing: 10, + cardPadding: 14, + rowSpacing: 7, + rowMinHeight: 38, + controlVerticalPadding: 14, + controlMinHeight: 48, + cardCornerRadius: 14 + ) + } + } + + func font(_ textStyle: Font.TextStyle, design: Font.Design = .monospaced, weight: Font.Weight? = nil) -> Font { + var font = Font.system(textStyle, design: design) + if let weight { + font = font.weight(weight) + } + return font + } +} + +struct AppDensityMetrics: Equatable { + let sectionSpacing: CGFloat + let cardSpacing: CGFloat + let cardPadding: CGFloat + let rowSpacing: CGFloat + let rowMinHeight: CGFloat + let controlVerticalPadding: CGFloat + let controlMinHeight: CGFloat + let cardCornerRadius: CGFloat +} + +private struct AppDensityKey: EnvironmentKey { + static let defaultValue: AppDensity = .compact +} + +extension EnvironmentValues { + var appDensity: AppDensity { + get { self[AppDensityKey.self] } + set { self[AppDensityKey.self] = newValue } + } +} + +struct AppStatusBadgeModel: Equatable { + let title: String + let systemImage: String? + let foregroundColor: Color + let backgroundColor: Color +} + +enum AppStatusFactory { + static func availability(_ status: DomainAvailabilityStatus?) -> AppStatusBadgeModel { + switch status { + case .available: + return .init(title: "Available", systemImage: "checkmark.circle.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16)) + case .registered: + return .init(title: "Registered", systemImage: "circle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16)) + case .unknown, .none: + return .init(title: "Unknown", systemImage: "questionmark.circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55)) + } + } + + static func tls(sslInfo: SSLCertificateInfo?, error: String?) -> AppStatusBadgeModel { + if error != nil || sslInfo == nil { + return .init(title: "Invalid", systemImage: "xmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16)) + } + if let sslInfo, sslInfo.daysUntilExpiry <= 14 { + return .init(title: "Expiring", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16)) + } + return .init(title: "Valid", systemImage: "lock.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16)) + } + + static func email(_ result: EmailSecurityResult?, error: String?) -> AppStatusBadgeModel { + guard error == nil, let result else { + return .init(title: "Missing", systemImage: "minus.circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55)) + } + + let foundCount = [result.spf.found, result.dmarc.found, result.dkim.found].filter { $0 }.count + switch foundCount { + case 3: + return .init(title: "Secure", systemImage: "checkmark.shield.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16)) + case 1, 2: + return .init(title: "Partial", systemImage: "shield.lefthalf.filled", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16)) + default: + return .init(title: "Missing", systemImage: "minus.circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55)) + } + } + + static func change(_ summary: DomainChangeSummary?) -> AppStatusBadgeModel { + guard let summary else { + return .init(title: "Unchanged", systemImage: "circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55)) + } + if summary.hasChanges { + return .init(title: "Changed", systemImage: "arrow.triangle.2.circlepath", foregroundColor: .cyan, backgroundColor: .cyan.opacity(0.16)) + } + return .init(title: "Unchanged", systemImage: "checkmark.circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55)) + } +} + +struct AppStatusBadgeView: View { + @Environment(\.appDensity) private var appDensity + + let model: AppStatusBadgeModel + + var body: some View { + HStack(spacing: 6) { + if let systemImage = model.systemImage { + Image(systemName: systemImage) + .font(.caption2) + } + Text(model.title) + } + .font(appDensity.font(.caption, weight: .semibold)) + .foregroundStyle(model.foregroundColor) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(model.backgroundColor) + .clipShape(Capsule()) + } +} + +struct AppCopyButton: View { + @Environment(\.appDensity) private var appDensity + @State private var didCopy = false + + let value: String + let label: String + + var body: some View { + Button { + AppClipboard.copy(value) + AppHaptics.copy() + withAnimation(.easeInOut(duration: 0.18)) { + didCopy = true + } + Task { + try? await Task.sleep(nanoseconds: 900_000_000) + await MainActor.run { + withAnimation(.easeInOut(duration: 0.18)) { + didCopy = false + } + } + } + } label: { + Image(systemName: didCopy ? "checkmark" : "doc.on.doc") + .font(appDensity.font(.caption)) + .foregroundStyle(didCopy ? Color.green : .secondary) + .frame(width: 30, height: 30) + .background(Color(.systemGray5).opacity(0.35)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .accessibilityLabel(didCopy ? "\(label) copied" : label) + } +} + +enum AppClipboard { + static func copy(_ value: String) { + #if canImport(UIKit) + UIPasteboard.general.string = value + #elseif canImport(AppKit) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + #endif + } +} + +enum AppHaptics { + static func copy() { + #if canImport(UIKit) + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.success) + #endif + } + + static func refresh() { + #if canImport(UIKit) + let generator = UIImpactFeedbackGenerator(style: .light) + generator.impactOccurred() + #endif + } + + static func track() { + #if canImport(UIKit) + let generator = UIImpactFeedbackGenerator(style: .soft) + generator.impactOccurred() + #endif + } +} + +struct EmptyStateCardView: View { + @Environment(\.appDensity) private var appDensity + + let title: String + let message: String + let suggestion: String + let systemImage: String + + var body: some View { + VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { + Label(title, systemImage: systemImage) + .font(appDensity.font(.headline, weight: .semibold)) + .foregroundStyle(.primary) + + Text(message) + .font(appDensity.font(.body)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Text(suggestion) + .font(appDensity.font(.caption)) + .foregroundStyle(.cyan) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(appDensity.metrics.cardPadding) + .background(Color(.systemGray6).opacity(0.45)) + .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) + } +} + +struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View { + @Environment(\.appDensity) private var appDensity + + let title: String + @Binding var isCollapsed: Bool + let subtitle: String? + @ViewBuilder let trailing: () -> HeaderTrailing + @ViewBuilder let content: () -> Content + + init( + title: String, + isCollapsed: Binding<Bool>, + subtitle: String? = nil, + @ViewBuilder trailing: @escaping () -> HeaderTrailing = { EmptyView() }, + @ViewBuilder content: @escaping () -> Content + ) { + self.title = title + self._isCollapsed = isCollapsed + self.subtitle = subtitle + self.trailing = trailing + self.content = content + } + + var body: some View { + VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isCollapsed.toggle() + } + } label: { + HStack(alignment: .center, spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(appDensity.font(.headline, design: .default, weight: .semibold)) + .foregroundStyle(.white) + if let subtitle { + Text(subtitle) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 8) + trailing() + Image(systemName: isCollapsed ? "chevron.down" : "chevron.up") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + .frame(minHeight: appDensity.metrics.controlMinHeight, alignment: .center) + } + .buttonStyle(.plain) + + if !isCollapsed { + content() + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + } +} diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 9fdf1fc..326db3a 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -598,6 +598,12 @@ final class DomainViewModel { persistHistory() } + func clearLookupCache() { + Task { + await LookupRuntime.shared.clearCache() + } + } + func clearRecentSearches() { recentSearches.removeAll() UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift index 6f3d1a9..1a5c0d0 100644 --- a/DomainDig/HistoryView.swift +++ b/DomainDig/HistoryView.swift @@ -1,53 +1,69 @@ import SwiftUI struct HistoryView: View { + @Environment(\.appDensity) private var appDensity @Bindable var viewModel: DomainViewModel @Environment(\.dismiss) private var dismiss @State private var showClearAllConfirmation = false - private let dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .short - return formatter - }() + private var groupedHistory: [HistoryGroup] { + HistoryGroup.groups(for: viewModel.filteredHistory) + } var body: some View { List { if viewModel.filteredHistory.isEmpty { - Text("No lookup history") - .font(.system(.callout, design: .monospaced)) - .foregroundStyle(.secondary) + EmptyStateCardView( + title: "No History Yet", + message: "History stores local snapshots of previous inspections so you can revisit and compare them later.", + suggestion: "Run a lookup to create your first saved snapshot.", + systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90" + ) .listRowBackground(Color(.systemGray6).opacity(0.5)) } else { - ForEach(viewModel.filteredHistory) { entry in - NavigationLink { - HistoryDetailView(viewModel: viewModel, entry: entry) - } label: { - VStack(alignment: .leading, spacing: 4) { - Text(entry.domain) - .font(.system(.callout, design: .monospaced)) - .foregroundStyle(.primary) - if let summary = entry.changeSummary { - Text(summary.hasChanges ? "Changed" : "Unchanged") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(summary.hasChanges ? .yellow : .green) + ForEach(groupedHistory) { group in + Section(group.title) { + ForEach(group.entries) { entry in + NavigationLink { + HistoryDetailView(viewModel: viewModel, entry: entry) + } label: { + VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) { + HStack(alignment: .center, spacing: 8) { + Text(entry.domain) + .font(appDensity.font(.callout)) + .foregroundStyle(.primary) + Spacer() + AppStatusBadgeView(model: AppStatusFactory.change(entry.changeSummary)) + } + + HStack(spacing: 8) { + AppStatusBadgeView(model: AppStatusFactory.availability(entry.availabilityResult?.status)) + AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: entry.sslInfo, error: entry.sslError)) + } + + HStack(spacing: 8) { + Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened)) + Text(entry.timestamp.formatted(.relative(presentation: .named))) + Text(entry.resolverDisplayName) + if let totalLookupDurationMs = entry.totalLookupDurationMs { + Text("\(totalLookupDurationMs) ms") + } + } + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } } - HStack(spacing: 8) { - Text(dateFormatter.string(from: entry.timestamp)) - Text("Snapshot") - Text(entry.resolverDisplayName) - if let totalLookupDurationMs = entry.totalLookupDurationMs { - Text("\(totalLookupDurationMs) ms") + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + viewModel.removeHistoryEntries(withIDs: [entry.id]) + } label: { + Label("Delete", systemImage: "trash") } } - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) } + .listRowBackground(Color(.systemGray6).opacity(0.5)) } - .listRowBackground(Color(.systemGray6).opacity(0.5)) } - .onDelete(perform: deleteFilteredHistoryEntries) } } .scrollContentBackground(.hidden) @@ -108,6 +124,7 @@ struct HistoryView: View { } struct HistoryDetailView: View { + @Environment(\.appDensity) private var appDensity @Bindable var viewModel: DomainViewModel let entry: HistoryEntry @Environment(\.dismiss) private var dismiss @@ -130,6 +147,7 @@ struct HistoryDetailView: View { SummaryView(fields: DomainViewModel.summaryFields(from: snapshot)) .padding(.top, 8) DomainSectionView( + isCollapsed: .constant(false), rows: DomainViewModel.domainRows(from: snapshot), suggestions: DomainViewModel.suggestionRows(from: snapshot), showSuggestions: entry.availabilityResult?.status == .registered && !entry.suggestions.isEmpty, @@ -146,42 +164,46 @@ struct HistoryDetailView: View { }, onEditNote: nil ) - .padding(.top, 16) + .padding(.top, appDensity.metrics.sectionSpacing) OwnershipSectionView( + isCollapsed: .constant(false), rows: DomainViewModel.ownershipRows(from: snapshot), loading: false, error: snapshot.ownershipError, showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory) ) - .padding(.top, 16) + .padding(.top, appDensity.metrics.sectionSpacing) SubdomainsSectionView( + isCollapsed: .constant(false), rows: DomainViewModel.subdomainRows(from: snapshot), loading: false, error: snapshot.subdomainsError, showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains) ) - .padding(.top, 16) + .padding(.top, appDensity.metrics.sectionSpacing) if let comparisonSnapshot = viewModel.comparisonSnapshot(for: entry) { if let changeSummary = entry.changeSummary { DomainChangeSummaryView(summary: changeSummary) - .padding(.top, 16) + .padding(.top, appDensity.metrics.sectionSpacing) } DomainDiffView( title: "Compared With Previous Snapshot", sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot), showsUnchanged: false ) - .padding(.top, 16) + .padding(.top, appDensity.metrics.sectionSpacing) } DNSSectionView( + isCollapsed: .constant(false), dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot), sections: DomainViewModel.dnsRows(from: snapshot), ptrMessage: DomainViewModel.ptrMessage(from: snapshot), loading: false, sectionError: snapshot.dnsError ) - .padding(.top, 16) + .padding(.top, appDensity.metrics.sectionSpacing) WebSectionView( + isCollapsed: .constant(false), certificateRows: DomainViewModel.webCertificateRows(from: snapshot), sslInfo: snapshot.sslInfo, sslLoading: false, @@ -195,14 +217,16 @@ struct HistoryDetailView: View { redirectError: snapshot.redirectChainError, finalURL: snapshot.redirectChain.last?.url ) - .padding(.top, 16) + .padding(.top, appDensity.metrics.sectionSpacing) EmailSectionView( + isCollapsed: .constant(false), rows: DomainViewModel.emailRows(from: snapshot), loading: false, error: snapshot.emailSecurityError ) - .padding(.top, 16) + .padding(.top, appDensity.metrics.sectionSpacing) NetworkSectionView( + isCollapsed: .constant(false), reachabilityRows: DomainViewModel.reachabilityRows(from: snapshot), reachabilityLoading: false, reachabilityError: snapshot.reachabilityError, @@ -221,7 +245,7 @@ struct HistoryDetailView: View { customPortInput: .constant(""), onScanCustomPorts: {} ) - .padding(.top, 16) + .padding(.top, appDensity.metrics.sectionSpacing) } .padding(.horizontal) .padding(.bottom, 32) @@ -244,17 +268,45 @@ struct HistoryDetailView: View { Image(systemName: "archivebox") .font(.caption) Text("Snapshot from \(dateFormatter.string(from: entry.timestamp))") - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) Spacer() Text("Live re-run available") - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) } .foregroundStyle(.secondary) .padding(8) .frame(maxWidth: .infinity, alignment: .leading) .background(Color(.systemGray6).opacity(0.3)) - .cornerRadius(6) + .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) .padding(.vertical, 12) } } + +private struct HistoryGroup: Identifiable { + let title: String + let entries: [HistoryEntry] + + var id: String { title } + + static func groups(for entries: [HistoryEntry]) -> [HistoryGroup] { + let calendar = Calendar.current + let today = Date() + let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today + + let grouped = Dictionary(grouping: entries) { entry -> String in + if calendar.isDate(entry.timestamp, inSameDayAs: today) { + return "Today" + } + if calendar.isDate(entry.timestamp, inSameDayAs: yesterday) { + return "Yesterday" + } + return "Older" + } + + return ["Today", "Yesterday", "Older"].compactMap { title in + guard let entries = grouped[title], !entries.isEmpty else { return nil } + return HistoryGroup(title: title, entries: entries) + } + } +} diff --git a/DomainDig/LookupRuntime.swift b/DomainDig/LookupRuntime.swift index b70a134..08ea122 100644 --- a/DomainDig/LookupRuntime.swift +++ b/DomainDig/LookupRuntime.swift @@ -58,6 +58,13 @@ actor LookupRuntime { private var inFlight: [RequestKey: Task<CachedPayload, Never>] = [:] private var nextAllowedAt: [RateLimitBucket: Date] = [:] + func clearCache() { + cache.removeAll() + inFlight.values.forEach { $0.cancel() } + inFlight.removeAll() + nextAllowedAt.removeAll() + } + func dns(domain: String) async -> CachedLookupResult<ServiceResult<[DNSSection]>> { await execute( key: .domain(domain, .dns), diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift index 7e42b53..0874956 100644 --- a/DomainDig/WatchlistView.swift +++ b/DomainDig/WatchlistView.swift @@ -1,9 +1,18 @@ import SwiftUI struct WatchlistView: View { + @Environment(\.appDensity) private var appDensity @Bindable var viewModel: DomainViewModel @Environment(\.dismiss) private var dismiss + private var pinnedDomains: [TrackedDomain] { + viewModel.filteredTrackedDomains.filter(\.isPinned) + } + + private var otherDomains: [TrackedDomain] { + viewModel.filteredTrackedDomains.filter { !$0.isPinned } + } + var body: some View { List { if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) { @@ -13,7 +22,7 @@ struct WatchlistView: View { .tint(.cyan) HStack { Text(viewModel.batchProgressLabel) - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .foregroundStyle(.secondary) Spacer() if viewModel.batchLookupRunning { @@ -21,7 +30,7 @@ struct WatchlistView: View { viewModel.cancelBatchLookup() } .buttonStyle(.bordered) - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) } } @@ -36,87 +45,34 @@ struct WatchlistView: View { if viewModel.filteredTrackedDomains.isEmpty { Section { - VStack(alignment: .leading, spacing: 8) { - Text("No tracked domains yet") - .font(.system(.callout, design: .monospaced)) - .foregroundStyle(.primary) - Text("Tracked domains appear here. Tracking is local and manual for now.") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - } - .padding(.vertical, 8) + EmptyStateCardView( + title: "No Tracked Domains", + message: "Track important domains locally so you can refresh them quickly and see status changes at a glance.", + suggestion: "Run an inspection and use the Track action on a domain you care about.", + systemImage: "eye" + ) } .listRowBackground(Color(.systemGray6).opacity(0.5)) } else { if let limitMessage = PremiumAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) { Section { Text(limitMessage) - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .foregroundStyle(.secondary) } .listRowBackground(Color(.systemGray6).opacity(0.5)) } - Section { - ForEach(viewModel.filteredTrackedDomains) { trackedDomain in - NavigationLink { - TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain) - } label: { - WatchlistRowView( - trackedDomain: trackedDomain, - isRefreshing: viewModel.refreshingTrackedDomainID == trackedDomain.id - ) - } - .buttonStyle(.plain) - .swipeActions(edge: .leading, allowsFullSwipe: false) { - Button { - viewModel.togglePinned(for: trackedDomain) - } label: { - Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin") - } - .tint(.yellow) - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - viewModel.deleteTrackedDomain(trackedDomain) - } label: { - Label("Delete", systemImage: "trash") - } - } - .contextMenu { - Button { - viewModel.refreshTrackedDomain(trackedDomain) - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - - Button { - dismiss() - viewModel.rerunInspection(for: trackedDomain) - } label: { - Label("Open Inspection", systemImage: "magnifyingglass") - } - - Button { - viewModel.togglePinned(for: trackedDomain) - } label: { - Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin") - } + if !pinnedDomains.isEmpty { + trackedSection(title: "Pinned", domains: pinnedDomains) + } - Button(role: .destructive) { - viewModel.deleteTrackedDomain(trackedDomain) - } label: { - Label("Delete", systemImage: "trash") - } - } - .listRowBackground(Color(.systemGray6).opacity(0.5)) - } - .onDelete(perform: deleteFilteredTrackedDomains) - } header: { - Text("Tracked Domains") + if !otherDomains.isEmpty { + trackedSection(title: pinnedDomains.isEmpty ? "Tracked Domains" : "Others", domains: otherDomains) } } } + .animation(.easeInOut(duration: 0.2), value: viewModel.filteredTrackedDomains.map(\.id)) .scrollContentBackground(.hidden) .background(Color.black) .navigationTitle("Watchlist") @@ -138,6 +94,7 @@ struct WatchlistView: View { } Button(viewModel.batchLookupRunning ? "Check All Running" : "Check All") { + AppHaptics.refresh() viewModel.refreshAllTrackedDomains() } .disabled(viewModel.batchLookupRunning) @@ -170,6 +127,86 @@ struct WatchlistView: View { .preferredColorScheme(.dark) } + @ViewBuilder + private func trackedSection(title: String, domains: [TrackedDomain]) -> some View { + Section(title) { + ForEach(domains) { trackedDomain in + trackedDomainRow(trackedDomain) + } + } + } + + private func trackedDomainRow(_ trackedDomain: TrackedDomain) -> some View { + NavigationLink { + TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain) + } label: { + WatchlistRowView( + trackedDomain: trackedDomain, + isRefreshing: viewModel.refreshingTrackedDomainID == trackedDomain.id + ) + } + .buttonStyle(.plain) + .swipeActions(edge: .leading, allowsFullSwipe: false) { + Button { + AppHaptics.refresh() + viewModel.refreshTrackedDomain(trackedDomain) + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .tint(.cyan) + + Button { + viewModel.togglePinned(for: trackedDomain) + } label: { + Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin") + } + .tint(.yellow) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button { + AppHaptics.refresh() + viewModel.refreshTrackedDomain(trackedDomain) + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .tint(.cyan) + + Button(role: .destructive) { + viewModel.deleteTrackedDomain(trackedDomain) + } label: { + Label("Delete", systemImage: "trash") + } + } + .contextMenu { + Button { + AppHaptics.refresh() + viewModel.refreshTrackedDomain(trackedDomain) + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + + Button { + dismiss() + viewModel.rerunInspection(for: trackedDomain) + } label: { + Label("Open Inspection", systemImage: "magnifyingglass") + } + + Button { + viewModel.togglePinned(for: trackedDomain) + } label: { + Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin") + } + + Button(role: .destructive) { + viewModel.deleteTrackedDomain(trackedDomain) + } label: { + Label("Delete", systemImage: "trash") + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + private var batchSummaryBinding: Binding<BatchSweepSummary?> { Binding( get: { viewModel.latestBatchSweepSummary }, @@ -203,11 +240,12 @@ struct WatchlistView: View { } struct WatchlistRowView: View { + @Environment(\.appDensity) private var appDensity let trackedDomain: TrackedDomain let isRefreshing: Bool var body: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) { HStack(alignment: .firstTextBaseline, spacing: 8) { if trackedDomain.isPinned { Image(systemName: "pin.fill") @@ -215,7 +253,7 @@ struct WatchlistRowView: View { .foregroundStyle(.yellow) } Text(trackedDomain.domain) - .font(.system(.callout, design: .monospaced)) + .font(appDensity.font(.callout)) .foregroundStyle(.primary) .lineLimit(2) .multilineTextAlignment(.leading) @@ -224,19 +262,19 @@ struct WatchlistRowView: View { } Text("Updated \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))") - .font(.system(.caption2, design: .monospaced)) + .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) indicatorRow if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { Text(note) - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .foregroundStyle(.secondary) .lineLimit(2) } else if let summary = trackedDomain.lastChangeSummary { Text(summary.message) - .font(.system(.caption, design: .monospaced)) + .font(appDensity.font(.caption)) .foregroundStyle(.secondary) .lineLimit(2) } @@ -259,84 +297,32 @@ struct WatchlistRowView: View { @ViewBuilder private var statusBadge: some View { if isRefreshing { - HStack(spacing: 6) { - ProgressView() - .controlSize(.small) - Text("Refreshing") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color(.systemGray5).opacity(0.6)) - .clipShape(Capsule()) + AppStatusBadgeView(model: .init(title: "Refreshing", systemImage: "arrow.clockwise", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.6))) } else { - Text(availabilityLabel(trackedDomain.lastKnownAvailability)) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(badgeColor) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(badgeBackground) - .clipShape(Capsule()) - } - } - - private var badgeColor: Color { - switch trackedDomain.lastKnownAvailability { - case .available: - return .green - case .registered: - return .yellow - case .unknown, .none: - return .secondary - } - } - - private var badgeBackground: Color { - switch trackedDomain.lastKnownAvailability { - case .available: - return .green.opacity(0.16) - case .registered: - return .yellow.opacity(0.16) - case .unknown, .none: - return Color(.systemGray5).opacity(0.6) + AppStatusBadgeView(model: AppStatusFactory.availability(trackedDomain.lastKnownAvailability)) } } @ViewBuilder private var indicatorRow: some View { HStack(spacing: 8) { - if let severity = trackedDomain.lastChangeSeverity, severity >= .medium { - Label(severity.title, systemImage: severity == .high ? "exclamationmark.circle.fill" : "circle.fill") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(severity == .high ? .red : .yellow) - } else { - Label("Stable", systemImage: "circle.fill") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - } + AppStatusBadgeView(model: AppStatusFactory.change(trackedDomain.lastChangeSummary)) if trackedDomain.certificateWarningLevel != .none { - Text(certificateLabel) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(trackedDomain.certificateWarningLevel == .critical ? .red : .yellow) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background((trackedDomain.certificateWarningLevel == .critical ? Color.red : Color.yellow).opacity(0.16)) - .clipShape(Capsule()) + AppStatusBadgeView(model: certificateBadge) } } } - private var certificateLabel: String { + private var certificateBadge: AppStatusBadgeModel { let days = trackedDomain.certificateDaysRemaining.map { "\($0)d" } ?? "Soon" switch trackedDomain.certificateWarningLevel { case .critical: - return "Cert \(days)" + return .init(title: "Invalid \(days)", systemImage: "xmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16)) case .warning: - return "Warn \(days)" + return .init(title: "Expiring \(days)", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16)) case .none: - return "" + return .init(title: "Valid", systemImage: "lock.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16)) } } } |
