aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-17 10:45:42 -0500
committerChristian Cleberg <[email protected]>2026-07-17 10:57:30 -0500
commitfbf2d9bf2cf50eb681afe18deac06a96c04004e6 (patch)
treef337f6826c283d4246cdde824786bd18b6c770b3
parent6e273c2676ce29cef057d117e4427e031886e743 (diff)
downloaddomain-dig-fbf2d9bf2cf50eb681afe18deac06a96c04004e6.tar.gz
domain-dig-fbf2d9bf2cf50eb681afe18deac06a96c04004e6.tar.bz2
domain-dig-fbf2d9bf2cf50eb681afe18deac06a96c04004e6.zip
v4.7.0: Add watchlist tags and saved filter views
- TrackedDomain.tags: [String] (backward-compatible custom decode), with updateTags(_:for:) and a normalized/deduplicated write path. - Tag editing in TrackedDomainDetailView (comma-separated field, chip display). - Tag filter chips in WatchlistView (TagFilterChipRowView), integrated into filteredTrackedDomains alongside the existing filter/search. - Saved views: name + snapshot the current tag/filter/sort as a WatchlistSavedView preset (UserDefaults-backed, not part of backup/restore), with a management sheet to apply or delete presets.
-rw-r--r--DomainDig/DomainDigUI.swift54
-rw-r--r--DomainDig/DomainViewModel.swift69
-rw-r--r--DomainDig/Models.swift34
-rw-r--r--DomainDig/WatchlistView.swift107
4 files changed, 261 insertions, 3 deletions
diff --git a/DomainDig/DomainDigUI.swift b/DomainDig/DomainDigUI.swift
index 592d7a7..272c678 100644
--- a/DomainDig/DomainDigUI.swift
+++ b/DomainDig/DomainDigUI.swift
@@ -332,3 +332,57 @@ struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View {
}
}
}
+
+/// A horizontally scrolling row of read-only tag chips, e.g. for a tracked
+/// domain's detail view.
+struct TagChipRowView: View {
+ let tags: [String]
+
+ var body: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ ForEach(tags, id: \.self) { tag in
+ Text(tag)
+ .font(.caption)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 5)
+ .background(Color(.systemGray5).opacity(0.6), in: Capsule())
+ }
+ }
+ }
+ }
+}
+
+/// A horizontally scrolling row of selectable tag chips used to filter a list,
+/// with an "All" chip to clear the selection.
+struct TagFilterChipRowView: View {
+ let tags: [String]
+ @Binding var selection: String?
+
+ var body: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ filterChip(title: "All", isSelected: selection == nil) {
+ selection = nil
+ }
+ ForEach(tags, id: \.self) { tag in
+ filterChip(title: tag, isSelected: selection == tag) {
+ selection = (selection == tag) ? nil : tag
+ }
+ }
+ }
+ }
+ }
+
+ private func filterChip(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ Text(title)
+ .font(.caption)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 5)
+ .background(isSelected ? Color.cyan.opacity(0.3) : Color(.systemGray5).opacity(0.6), in: Capsule())
+ .foregroundStyle(isSelected ? Color.cyan : Color.primary)
+ }
+ .buttonStyle(.plain)
+ }
+}
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 3c92ff6..33756df 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -312,6 +312,8 @@ final class DomainViewModel {
var watchlistSearchText = ""
var watchlistFilter: WatchlistFilterOption = .all
var watchlistSortOption: WatchlistSortOption = .pinned
+ var watchlistTagFilter: String?
+ var watchlistSavedViews: [WatchlistSavedView] = DomainViewModel.loadWatchlistSavedViews()
var dashboardSearchText = ""
var dashboardFilter: PortfolioFilterOption = .all
var monitoringSettings: MonitoringSettings = MonitoringStorage.loadSettings()
@@ -432,6 +434,10 @@ final class DomainViewModel {
return false
}
+ if let watchlistTagFilter, !trackedDomain.tags.contains(watchlistTagFilter) {
+ return false
+ }
+
switch watchlistFilter {
case .all:
return true
@@ -888,6 +894,69 @@ final class DomainViewModel {
persistTrackedDomains()
}
+ func updateTags(_ tags: [String], for trackedDomain: TrackedDomain) {
+ guard canEdit(trackedDomain) else { return }
+ guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
+ let normalized = Self.normalizedTags(tags)
+ trackedDomains[index].tags = normalized
+ trackedDomains[index].updatedAt = Date()
+ persistTrackedDomains()
+ }
+
+ /// All tags currently in use across the watchlist, sorted for stable display.
+ var allWatchlistTags: [String] {
+ Array(Set(trackedDomains.flatMap(\.tags))).sorted()
+ }
+
+ func saveCurrentWatchlistView(name: String) {
+ let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedName.isEmpty else { return }
+ let view = WatchlistSavedView(
+ name: trimmedName,
+ tag: watchlistTagFilter,
+ filter: watchlistFilter,
+ sort: watchlistSortOption
+ )
+ watchlistSavedViews.append(view)
+ persistWatchlistSavedViews()
+ }
+
+ func applyWatchlistSavedView(_ view: WatchlistSavedView) {
+ watchlistTagFilter = view.tag
+ watchlistFilter = view.filter
+ watchlistSortOption = view.sort
+ }
+
+ func deleteWatchlistSavedViews(at offsets: IndexSet) {
+ watchlistSavedViews.remove(atOffsets: offsets)
+ persistWatchlistSavedViews()
+ }
+
+ private static let watchlistSavedViewsKey = "watchlistSavedViews"
+
+ private static func loadWatchlistSavedViews() -> [WatchlistSavedView] {
+ guard let data = UserDefaults.standard.data(forKey: watchlistSavedViewsKey),
+ let views = try? JSONDecoder().decode([WatchlistSavedView].self, from: data)
+ else { return [] }
+ return views
+ }
+
+ private func persistWatchlistSavedViews() {
+ guard let data = try? JSONEncoder().encode(watchlistSavedViews) else { return }
+ UserDefaults.standard.set(data, forKey: Self.watchlistSavedViewsKey)
+ }
+
+ private static func normalizedTags(_ tags: [String]) -> [String] {
+ var seen = Set<String>()
+ var normalized: [String] = []
+ for tag in tags {
+ let trimmed = tag.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty, seen.insert(trimmed.lowercased()).inserted else { continue }
+ normalized.append(trimmed)
+ }
+ return normalized.sorted()
+ }
+
func removeHistoryEntries(at offsets: IndexSet) {
history.remove(atOffsets: offsets)
persistHistory()
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index d14ff4a..b86fc07 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -882,7 +882,7 @@ struct TimelineSection: Identifiable, Equatable {
let entries: [SnapshotSummary]
}
-enum WatchlistFilterOption: String, CaseIterable, Identifiable {
+enum WatchlistFilterOption: String, CaseIterable, Identifiable, Codable {
case all
case pinnedOnly
case changedOnly
@@ -901,7 +901,7 @@ enum WatchlistFilterOption: String, CaseIterable, Identifiable {
}
}
-enum WatchlistSortOption: String, CaseIterable, Identifiable {
+enum WatchlistSortOption: String, CaseIterable, Identifiable, Codable {
case pinned
case recentlyUpdated
case alphabetical
@@ -920,6 +920,30 @@ enum WatchlistSortOption: String, CaseIterable, Identifiable {
}
}
+/// A named watchlist filter preset (tag, filter, sort) the user can save and
+/// reapply. Stored locally in UserDefaults; not included in backup/restore.
+struct WatchlistSavedView: Codable, Identifiable, Equatable {
+ let id: UUID
+ var name: String
+ var tag: String?
+ var filter: WatchlistFilterOption
+ var sort: WatchlistSortOption
+
+ init(
+ id: UUID = UUID(),
+ name: String,
+ tag: String?,
+ filter: WatchlistFilterOption,
+ sort: WatchlistSortOption
+ ) {
+ self.id = id
+ self.name = name
+ self.tag = tag
+ self.filter = filter
+ self.sort = sort
+ }
+}
+
enum PortfolioFilterOption: String, CaseIterable, Identifiable {
case all
case healthy
@@ -1258,6 +1282,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
var monitoringState: MonitoringState
var pendingMonitoringAlerts: [MonitoringPendingAlert]
var collaboration: CollaborationMetadata?
+ var tags: [String]
init(
id: UUID = UUID(),
@@ -1277,7 +1302,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
lastAlertAt: Date? = nil,
monitoringState: MonitoringState = MonitoringState(),
pendingMonitoringAlerts: [MonitoringPendingAlert] = [],
- collaboration: CollaborationMetadata? = nil
+ collaboration: CollaborationMetadata? = nil,
+ tags: [String] = []
) {
self.id = id
self.domain = domain
@@ -1297,6 +1323,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
self.monitoringState = monitoringState
self.pendingMonitoringAlerts = pendingMonitoringAlerts
self.collaboration = collaboration
+ self.tags = tags
}
init(from decoder: Decoder) throws {
@@ -1319,6 +1346,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
monitoringState = try container.decodeIfPresent(MonitoringState.self, forKey: .monitoringState) ?? MonitoringState()
pendingMonitoringAlerts = try container.decodeIfPresent([MonitoringPendingAlert].self, forKey: .pendingMonitoringAlerts) ?? []
collaboration = try container.decodeIfPresent(CollaborationMetadata.self, forKey: .collaboration)
+ tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
}
}
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index 43dc66f..e017502 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -10,6 +10,9 @@ struct WatchlistView: View {
@State private var newTrackedDomain = ""
@State private var addDomainError: String?
@FocusState private var isAddDomainFieldFocused: Bool
+ @State private var showSavedViewsSheet = false
+ @State private var showSaveViewPrompt = false
+ @State private var newSavedViewName = ""
private var pinnedDomains: [TrackedDomain] {
viewModel.filteredTrackedDomains.filter(\.isPinned)
@@ -23,6 +26,14 @@ struct WatchlistView: View {
let _ = purchaseService.currentTier
List {
+ if !viewModel.allWatchlistTags.isEmpty {
+ Section {
+ TagFilterChipRowView(tags: viewModel.allWatchlistTags, selection: $viewModel.watchlistTagFilter)
+ }
+ .listRowBackground(Color.clear)
+ .listRowInsets(EdgeInsets())
+ }
+
if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
Section("Refresh Progress") {
VStack(alignment: .leading, spacing: 8) {
@@ -110,6 +121,17 @@ struct WatchlistView: View {
}
}
+ Button("Save Current View…") {
+ newSavedViewName = ""
+ showSaveViewPrompt = true
+ }
+
+ if !viewModel.watchlistSavedViews.isEmpty {
+ Button("Saved Views") {
+ showSavedViewsSheet = true
+ }
+ }
+
Button(viewModel.batchLookupRunning ? "Check All Running" : "Check All") {
AppHaptics.refresh()
viewModel.refreshAllTrackedDomains()
@@ -211,6 +233,49 @@ struct WatchlistView: View {
availableDomains: viewModel.filteredTrackedDomains.map(\.domain)
)
}
+ .alert("Save Current View", isPresented: $showSaveViewPrompt) {
+ TextField("View name", text: $newSavedViewName)
+ Button("Save") {
+ viewModel.saveCurrentWatchlistView(name: newSavedViewName)
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text("Saves the current tag, filter, and sort as a reusable preset.")
+ }
+ .sheet(isPresented: $showSavedViewsSheet) {
+ NavigationStack {
+ List {
+ ForEach(viewModel.watchlistSavedViews) { view in
+ Button {
+ viewModel.applyWatchlistSavedView(view)
+ showSavedViewsSheet = false
+ } label: {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(view.name)
+ .foregroundStyle(.primary)
+ Text([view.tag, view.filter.title, view.sort.title].compactMap { $0 }.joined(separator: " • "))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .onDelete { offsets in
+ viewModel.deleteWatchlistSavedViews(at: offsets)
+ }
+ }
+ .navigationTitle("Saved Views")
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Done") {
+ showSavedViewsSheet = false
+ }
+ }
+ ToolbarItem(placement: .topBarTrailing) {
+ EditButton()
+ }
+ }
+ }
+ }
.preferredColorScheme(.dark)
}
@@ -480,6 +545,8 @@ struct TrackedDomainDetailView: View {
@State private var noteDraft = ""
@State private var isEditingNote = false
+ @State private var tagsDraft = ""
+ @State private var isEditingTags = false
@State private var showRerunOptions = false
@State private var shareEntity: ShareableEntity?
@State private var showingAuditTimeline = false
@@ -567,6 +634,14 @@ struct TrackedDomainDetailView: View {
.disabled(!viewModel.canEdit(liveTrackedDomain))
Button {
+ tagsDraft = liveTrackedDomain.tags.joined(separator: ", ")
+ isEditingTags = true
+ } label: {
+ Label(liveTrackedDomain.tags.isEmpty ? "Add Tags" : "Edit Tags", systemImage: "tag")
+ }
+ .disabled(!viewModel.canEdit(liveTrackedDomain))
+
+ Button {
shareEntity = .trackedDomain(liveTrackedDomain.domain)
} label: {
Label(liveTrackedDomain.collaboration?.isShared == true ? "Manage Share" : "Share Domain", systemImage: "person.2")
@@ -574,6 +649,13 @@ struct TrackedDomainDetailView: View {
}
.listRowBackground(Color(.systemGray6).opacity(0.5))
+ if !liveTrackedDomain.tags.isEmpty {
+ Section("Tags") {
+ TagChipRowView(tags: liveTrackedDomain.tags)
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+ }
+
Section("Monitoring Status") {
LabeledContent("State", value: viewModel.monitoringStatusLabel(for: liveTrackedDomain))
LabeledContent("Current Interval", value: viewModel.monitoringIntervalLabel(for: liveTrackedDomain))
@@ -677,6 +759,31 @@ struct TrackedDomainDetailView: View {
}
}
}
+ .sheet(isPresented: $isEditingTags) {
+ NavigationStack {
+ Form {
+ Section("Tags") {
+ TextField("comma, separated, tags", text: $tagsDraft)
+ .textInputAutocapitalization(.never)
+ }
+ }
+ .navigationTitle("Edit Tags")
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ isEditingTags = false
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ let tags = tagsDraft.components(separatedBy: ",")
+ viewModel.updateTags(tags, for: liveTrackedDomain)
+ isEditingTags = false
+ }
+ }
+ }
+ }
+ }
.sheet(item: $shareEntity) { entity in
CloudSharingSheet(entity: entity, title: liveTrackedDomain.domain)
}