From fbf2d9bf2cf50eb681afe18deac06a96c04004e6 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 17 Jul 2026 10:45:42 -0500 Subject: 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. --- DomainDig/WatchlistView.swift | 107 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) (limited to 'DomainDig/WatchlistView.swift') 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 @@ -566,6 +633,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: { @@ -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) } -- cgit v1.2.3