summaryrefslogtreecommitdiff
path: root/DomainDig/ContentView.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-20 16:24:20 -0500
committerChristian Cleberg <[email protected]>2026-04-20 16:24:20 -0500
commit9999f876feb9a2d419a79ad601f78053c44d44f8 (patch)
treeb920aacd1192646dcca71ddae17879355ed4f040 /DomainDig/ContentView.swift
parent397c7c420c22fc1c235893bd85b2029da9145730 (diff)
downloaddomain-dig-2.0.0.tar.gz
domain-dig-2.0.0.tar.bz2
domain-dig-2.0.0.zip
feat(v2.0.0): introduce tracked domains, diffing, and monitoring foundationsv2.0.0
- replace basic watchlist with structured TrackedDomain model - add manual refresh flow for tracked domains - implement snapshot diffing (DNS, IP, TLS, HTTP, redirect, email, availability) - add change summaries (changed/unchanged + affected sections) - link tracked domains to history snapshots - enhance HistoryEntry with normalized summary fields - persist availability + suggestions in history and export - add WatchlistView with pinning, notes, and status display - introduce premium capability scaffolding (no StoreKit) - enforce free-tier tracked domain limit (local only) - refactor view model to support tracking, diffing, and refresh flows notes: - no background monitoring, notifications, or paid APIs yet - all features remain local-first and manual
Diffstat (limited to 'DomainDig/ContentView.swift')
-rw-r--r--DomainDig/ContentView.swift287
1 files changed, 262 insertions, 25 deletions
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index f70bc6a..69f3af9 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -3,12 +3,16 @@ import SwiftUI
struct ContentView: View {
@State private var viewModel = DomainViewModel()
+ @State private var navigationPath = NavigationPath()
@FocusState private var domainFieldFocused: Bool
@State private var customPortInput = ""
@State private var customPortsExpanded = false
+ @State private var trackingNoteDraft = ""
+ @State private var editingTrackedDomain: TrackedDomain?
+ @State private var showTrackLimitAlert = false
var body: some View {
- NavigationStack {
+ NavigationStack(path: $navigationPath) {
ScrollView(.vertical) {
VStack(spacing: 0) {
inputSection
@@ -16,16 +20,42 @@ struct ContentView: View {
actionButtons
SummaryView(fields: viewModel.summaryFields)
.padding(.top, 8)
+ if let changeSummary = viewModel.currentChangeSummary {
+ DomainChangeSummaryView(summary: changeSummary)
+ .padding(.top, 12)
+ }
DomainSectionView(
rows: viewModel.domainRows,
suggestions: viewModel.suggestionRows,
showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading,
availabilityLoading: viewModel.availabilityLoading,
suggestionsLoading: viewModel.suggestionsLoading,
- isWatched: viewModel.isCurrentDomainWatched,
- onToggleWatch: { viewModel.toggleWatchedDomain() }
+ trackedDomain: viewModel.currentTrackedDomain,
+ trackingLimitMessage: viewModel.trackingLimitMessage,
+ onTrack: {
+ if !viewModel.trackCurrentDomain() {
+ showTrackLimitAlert = true
+ }
+ },
+ onTogglePinned: {
+ guard let trackedDomain = viewModel.currentTrackedDomain else { return }
+ viewModel.togglePinned(for: trackedDomain)
+ },
+ onEditNote: {
+ guard let trackedDomain = viewModel.currentTrackedDomain else { return }
+ trackingNoteDraft = trackedDomain.note ?? ""
+ editingTrackedDomain = trackedDomain
+ }
)
.padding(.top, 16)
+ if !viewModel.currentDiffSections.isEmpty {
+ DomainDiffView(
+ title: "Latest Changes",
+ sections: viewModel.currentDiffSections,
+ showsUnchanged: false
+ )
+ .padding(.top, 16)
+ }
DNSSectionView(
dnssecLabel: viewModel.dnssecLabel,
sections: viewModel.dnsRows,
@@ -97,27 +127,31 @@ struct ContentView: View {
}
}
NavigationLink {
- SavedDomainsView(viewModel: viewModel)
- } label: {
- Image(systemName: "bookmark")
- .foregroundStyle(.secondary)
- }
- NavigationLink {
- HistoryView(viewModel: viewModel)
- } label: {
- Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
- .foregroundStyle(.secondary)
- }
- NavigationLink {
WatchlistView(viewModel: viewModel)
} label: {
Image(systemName: "eye")
.foregroundStyle(.secondary)
}
- NavigationLink {
- SettingsView()
+ Menu {
+ NavigationLink {
+ HistoryView(viewModel: viewModel)
+ } label: {
+ Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
+ }
+
+ NavigationLink {
+ SavedDomainsView(viewModel: viewModel)
+ } label: {
+ Label("Saved Domains", systemImage: "bookmark")
+ }
+
+ NavigationLink {
+ SettingsView()
+ } label: {
+ Label("Settings", systemImage: "gearshape")
+ }
} label: {
- Image(systemName: "gearshape")
+ Image(systemName: "ellipsis.circle")
.foregroundStyle(.secondary)
}
}
@@ -126,6 +160,41 @@ struct ContentView: View {
.onAppear {
domainFieldFocused = true
}
+ .onChange(of: viewModel.rerunNavigationToken) { _, _ in
+ navigationPath = NavigationPath()
+ domainFieldFocused = false
+ }
+ .alert("Tracking limit reached", isPresented: $showTrackLimitAlert) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text("Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade.")
+ }
+ .sheet(item: $editingTrackedDomain) { trackedDomain in
+ NavigationStack {
+ Form {
+ Section("Tracking Note") {
+ TextField("Optional note", text: $trackingNoteDraft, axis: .vertical)
+ .lineLimit(3...6)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ }
+ }
+ .navigationTitle(trackedDomain.domain)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ editingTrackedDomain = nil
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ viewModel.updateNote(trackingNoteDraft, for: trackedDomain)
+ editingTrackedDomain = nil
+ }
+ }
+ }
+ }
+ }
}
private var inputSection: some View {
@@ -292,30 +361,192 @@ struct SummaryView: View {
}
}
+struct DomainChangeSummaryView: View {
+ let summary: DomainChangeSummary
+
+ var body: some View {
+ CardView(allowsHorizontalScroll: false) {
+ HStack {
+ Label(summary.hasChanges ? "Changed" : "Unchanged", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle")
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(summary.hasChanges ? .yellow : .green)
+ Spacer()
+ Text(summary.generatedAt, style: .time)
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
+ }
+
+ Text(summary.changedSections.isEmpty ? "No meaningful changes detected." : summary.changedSections.joined(separator: " • "))
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.primary)
+ }
+ }
+}
+
+struct DomainDiffView: View {
+ let title: String
+ let sections: [DomainDiffSection]
+ let showsUnchanged: Bool
+
+ private var filteredSections: [DomainDiffSection] {
+ guard !showsUnchanged else { return sections }
+ return sections
+ .map { section in
+ DomainDiffSection(
+ title: section.title,
+ items: section.items.filter { $0.changeType != .unchanged }
+ )
+ }
+ .filter { !$0.items.isEmpty }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ SectionTitleView(title: title)
+ if filteredSections.isEmpty {
+ MessageCardView(text: "No comparison data available", isError: false)
+ } else {
+ ForEach(filteredSections) { section in
+ CardView(allowsHorizontalScroll: false) {
+ Text(section.title)
+ .font(.system(.subheadline, design: .monospaced))
+ .fontWeight(.semibold)
+ .foregroundStyle(.cyan)
+
+ ForEach(section.items) { item in
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(item.label)
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text(changeLabel(for: item.changeType))
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(changeColor(for: item.changeType))
+ }
+ if let oldValue = item.oldValue {
+ Text("Old: \(oldValue)")
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ }
+ if let newValue = item.newValue {
+ Text("New: \(newValue)")
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.primary)
+ .textSelection(.enabled)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private func changeLabel(for changeType: DiffChangeType) -> String {
+ switch changeType {
+ case .added:
+ return "Added"
+ case .removed:
+ return "Removed"
+ case .changed:
+ return "Changed"
+ case .unchanged:
+ return "Unchanged"
+ }
+ }
+
+ private func changeColor(for changeType: DiffChangeType) -> Color {
+ switch changeType {
+ case .added:
+ return .green
+ case .removed:
+ return .red
+ case .changed:
+ return .yellow
+ case .unchanged:
+ return .secondary
+ }
+ }
+}
+
+struct TrackedDomainDetailHeaderView: View {
+ let trackedDomain: TrackedDomain
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ if let note = trackedDomain.note?.nilIfEmpty {
+ LabeledValueRow(row: InfoRowViewData(label: "Tracking Note", value: note, tone: .secondary))
+ }
+ HStack(spacing: 8) {
+ if trackedDomain.isPinned {
+ Label("Pinned", systemImage: "pin.fill")
+ }
+ Text("Last refresh \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
+ }
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
+ }
+ }
+}
+
struct DomainSectionView: View {
let rows: [InfoRowViewData]
let suggestions: [DomainSuggestionViewData]
let showSuggestions: Bool
let availabilityLoading: Bool
let suggestionsLoading: Bool
- let isWatched: Bool
- let onToggleWatch: () -> Void
+ let trackedDomain: TrackedDomain?
+ let trackingLimitMessage: String?
+ let onTrack: () -> Void
+ let onTogglePinned: () -> Void
+ let onEditNote: (() -> Void)?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
SectionTitleView(title: "Domain")
Spacer()
- Button(isWatched ? "Watching" : "Watch") {
- onToggleWatch()
+ 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")
+ }
+ .buttonStyle(.bordered)
+ .font(.system(.caption, design: .monospaced))
+ if let onEditNote {
+ Button("Note") {
+ onEditNote()
+ }
+ .buttonStyle(.bordered)
+ .font(.system(.caption, design: .monospaced))
+ }
+ }
+ } else {
+ Button("Track") {
+ onTrack()
+ }
+ .buttonStyle(.bordered)
+ .font(.system(.caption, design: .monospaced))
}
- .buttonStyle(.bordered)
- .font(.system(.caption, design: .monospaced))
}
- CardView {
+ CardView(allowsHorizontalScroll: false) {
ForEach(rows) { row in
LabeledValueRow(row: row)
}
+ if let trackedDomain {
+ TrackedDomainDetailHeaderView(trackedDomain: trackedDomain)
+ .padding(.top, 4)
+ } else if let trackingLimitMessage {
+ MessageRowView(text: trackingLimitMessage, isError: false)
+ .padding(.top, 4)
+ }
if availabilityLoading {
ProgressView("Checking availability…")
.appLoadingStyle()
@@ -888,6 +1119,12 @@ private extension View {
}
}
+private extension String {
+ var nilIfEmpty: String? {
+ isEmpty ? nil : self
+ }
+}
+
private struct SettingsView: View {
@AppStorage(DNSResolverOption.userDefaultsKey)
private var storedResolverURL = DNSResolverOption.defaultURLString