diff options
| author | Christian Cleberg <[email protected]> | 2026-04-22 01:20:47 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-22 01:20:47 -0500 |
| commit | 36ea668d7fc9070770973b0bc329f4319bf43cab (patch) | |
| tree | 04525d5420c0df84c918b27f8ac059cf14b92fa9 /DomainDig/DomainDigUI.swift | |
| parent | 3846dc2f5dee69fc4ffbc052009a60e17d60e36b (diff) | |
| download | domain-dig-36ea668d7fc9070770973b0bc329f4319bf43cab.tar.gz domain-dig-36ea668d7fc9070770973b0bc329f4319bf43cab.tar.bz2 domain-dig-36ea668d7fc9070770973b0bc329f4319bf43cab.zip | |
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
Diffstat (limited to 'DomainDig/DomainDigUI.swift')
| -rw-r--r-- | DomainDig/DomainDigUI.swift | 319 |
1 files changed, 319 insertions, 0 deletions
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))) + } + } + } +} |
