diff options
| -rw-r--r-- | DomainDig/DomainDigUI.swift | 54 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 69 | ||||
| -rw-r--r-- | DomainDig/Models.swift | 34 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 107 |
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) } |
