Commit fbf2d9bf2c
Unsigned
Layout: unified · split
DomainDig/DomainDigUI.swift +54
| @@ -332,3 +332,57 @@ struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View { | |||
| 332 | } | 332 | } |
| 333 | } | 333 | } |
| 334 | } | 334 | } |
| 335 | |||
| 336 | /// A horizontally scrolling row of read-only tag chips, e.g. for a tracked | ||
| 337 | /// domain's detail view. | ||
| 338 | struct TagChipRowView: View { | ||
| 339 | let tags: [String] | ||
| 340 | |||
| 341 | var body: some View { | ||
| 342 | ScrollView(.horizontal, showsIndicators: false) { | ||
| 343 | HStack(spacing: 8) { | ||
| 344 | ForEach(tags, id: \.self) { tag in | ||
| 345 | Text(tag) | ||
| 346 | .font(.caption) | ||
| 347 | .padding(.horizontal, 10) | ||
| 348 | .padding(.vertical, 5) | ||
| 349 | .background(Color(.systemGray5).opacity(0.6), in: Capsule()) | ||
| 350 | } | ||
| 351 | } | ||
| 352 | } | ||
| 353 | } | ||
| 354 | } | ||
| 355 | |||
| 356 | /// A horizontally scrolling row of selectable tag chips used to filter a list, | ||
| 357 | /// with an "All" chip to clear the selection. | ||
| 358 | struct TagFilterChipRowView: View { | ||
| 359 | let tags: [String] | ||
| 360 | @Binding var selection: String? | ||
| 361 | |||
| 362 | var body: some View { | ||
| 363 | ScrollView(.horizontal, showsIndicators: false) { | ||
| 364 | HStack(spacing: 8) { | ||
| 365 | filterChip(title: "All", isSelected: selection == nil) { | ||
| 366 | selection = nil | ||
| 367 | } | ||
| 368 | ForEach(tags, id: \.self) { tag in | ||
| 369 | filterChip(title: tag, isSelected: selection == tag) { | ||
| 370 | selection = (selection == tag) ? nil : tag | ||
| 371 | } | ||
| 372 | } | ||
| 373 | } | ||
| 374 | } | ||
| 375 | } | ||
| 376 | |||
| 377 | private func filterChip(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View { | ||
| 378 | Button(action: action) { | ||
| 379 | Text(title) | ||
| 380 | .font(.caption) | ||
| 381 | .padding(.horizontal, 10) | ||
| 382 | .padding(.vertical, 5) | ||
| 383 | .background(isSelected ? Color.cyan.opacity(0.3) : Color(.systemGray5).opacity(0.6), in: Capsule()) | ||
| 384 | .foregroundStyle(isSelected ? Color.cyan : Color.primary) | ||
| 385 | } | ||
| 386 | .buttonStyle(.plain) | ||
| 387 | } | ||
| 388 | } | ||
DomainDig/DomainViewModel.swift +69
| @@ -312,6 +312,8 @@ final class DomainViewModel { | |||
| 312 | var watchlistSearchText = "" | 312 | var watchlistSearchText = "" |
| 313 | var watchlistFilter: WatchlistFilterOption = .all | 313 | var watchlistFilter: WatchlistFilterOption = .all |
| 314 | var watchlistSortOption: WatchlistSortOption = .pinned | 314 | var watchlistSortOption: WatchlistSortOption = .pinned |
| 315 | var watchlistTagFilter: String? | ||
| 316 | var watchlistSavedViews: [WatchlistSavedView] = DomainViewModel.loadWatchlistSavedViews() | ||
| 315 | var dashboardSearchText = "" | 317 | var dashboardSearchText = "" |
| 316 | var dashboardFilter: PortfolioFilterOption = .all | 318 | var dashboardFilter: PortfolioFilterOption = .all |
| 317 | var monitoringSettings: MonitoringSettings = MonitoringStorage.loadSettings() | 319 | var monitoringSettings: MonitoringSettings = MonitoringStorage.loadSettings() |
| @@ -432,6 +434,10 @@ final class DomainViewModel { | |||
| 432 | return false | 434 | return false |
| 433 | } | 435 | } |
| 434 | 436 | ||
| 437 | if let watchlistTagFilter, !trackedDomain.tags.contains(watchlistTagFilter) { | ||
| 438 | return false | ||
| 439 | } | ||
| 440 | |||
| 435 | switch watchlistFilter { | 441 | switch watchlistFilter { |
| 436 | case .all: | 442 | case .all: |
| 437 | return true | 443 | return true |
| @@ -888,6 +894,69 @@ final class DomainViewModel { | |||
| 888 | persistTrackedDomains() | 894 | persistTrackedDomains() |
| 889 | } | 895 | } |
| 890 | 896 | ||
| 897 | func updateTags(_ tags: [String], for trackedDomain: TrackedDomain) { | ||
| 898 | guard canEdit(trackedDomain) else { return } | ||
| 899 | guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return } | ||
| 900 | let normalized = Self.normalizedTags(tags) | ||
| 901 | trackedDomains[index].tags = normalized | ||
| 902 | trackedDomains[index].updatedAt = Date() | ||
| 903 | persistTrackedDomains() | ||
| 904 | } | ||
| 905 | |||
| 906 | /// All tags currently in use across the watchlist, sorted for stable display. | ||
| 907 | var allWatchlistTags: [String] { | ||
| 908 | Array(Set(trackedDomains.flatMap(\.tags))).sorted() | ||
| 909 | } | ||
| 910 | |||
| 911 | func saveCurrentWatchlistView(name: String) { | ||
| 912 | let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 913 | guard !trimmedName.isEmpty else { return } | ||
| 914 | let view = WatchlistSavedView( | ||
| 915 | name: trimmedName, | ||
| 916 | tag: watchlistTagFilter, | ||
| 917 | filter: watchlistFilter, | ||
| 918 | sort: watchlistSortOption | ||
| 919 | ) | ||
| 920 | watchlistSavedViews.append(view) | ||
| 921 | persistWatchlistSavedViews() | ||
| 922 | } | ||
| 923 | |||
| 924 | func applyWatchlistSavedView(_ view: WatchlistSavedView) { | ||
| 925 | watchlistTagFilter = view.tag | ||
| 926 | watchlistFilter = view.filter | ||
| 927 | watchlistSortOption = view.sort | ||
| 928 | } | ||
| 929 | |||
| 930 | func deleteWatchlistSavedViews(at offsets: IndexSet) { | ||
| 931 | watchlistSavedViews.remove(atOffsets: offsets) | ||
| 932 | persistWatchlistSavedViews() | ||
| 933 | } | ||
| 934 | |||
| 935 | private static let watchlistSavedViewsKey = "watchlistSavedViews" | ||
| 936 | |||
| 937 | private static func loadWatchlistSavedViews() -> [WatchlistSavedView] { | ||
| 938 | guard let data = UserDefaults.standard.data(forKey: watchlistSavedViewsKey), | ||
| 939 | let views = try? JSONDecoder().decode([WatchlistSavedView].self, from: data) | ||
| 940 | else { return [] } | ||
| 941 | return views | ||
| 942 | } | ||
| 943 | |||
| 944 | private func persistWatchlistSavedViews() { | ||
| 945 | guard let data = try? JSONEncoder().encode(watchlistSavedViews) else { return } | ||
| 946 | UserDefaults.standard.set(data, forKey: Self.watchlistSavedViewsKey) | ||
| 947 | } | ||
| 948 | |||
| 949 | private static func normalizedTags(_ tags: [String]) -> [String] { | ||
| 950 | var seen = Set<String>() | ||
| 951 | var normalized: [String] = [] | ||
| 952 | for tag in tags { | ||
| 953 | let trimmed = tag.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 954 | guard !trimmed.isEmpty, seen.insert(trimmed.lowercased()).inserted else { continue } | ||
| 955 | normalized.append(trimmed) | ||
| 956 | } | ||
| 957 | return normalized.sorted() | ||
| 958 | } | ||
| 959 | |||
| 891 | func removeHistoryEntries(at offsets: IndexSet) { | 960 | func removeHistoryEntries(at offsets: IndexSet) { |
| 892 | history.remove(atOffsets: offsets) | 961 | history.remove(atOffsets: offsets) |
| 893 | persistHistory() | 962 | persistHistory() |
DomainDig/Models.swift +31 −3
| @@ -882,7 +882,7 @@ struct TimelineSection: Identifiable, Equatable { | |||
| 882 | let entries: [SnapshotSummary] | 882 | let entries: [SnapshotSummary] |
| 883 | } | 883 | } |
| 884 | 884 | ||
| 885 | enum WatchlistFilterOption: String, CaseIterable, Identifiable { | 885 | enum WatchlistFilterOption: String, CaseIterable, Identifiable, Codable { |
| 886 | case all | 886 | case all |
| 887 | case pinnedOnly | 887 | case pinnedOnly |
| 888 | case changedOnly | 888 | case changedOnly |
| @@ -901,7 +901,7 @@ enum WatchlistFilterOption: String, CaseIterable, Identifiable { | |||
| 901 | } | 901 | } |
| 902 | } | 902 | } |
| 903 | 903 | ||
| 904 | enum WatchlistSortOption: String, CaseIterable, Identifiable { | 904 | enum WatchlistSortOption: String, CaseIterable, Identifiable, Codable { |
| 905 | case pinned | 905 | case pinned |
| 906 | case recentlyUpdated | 906 | case recentlyUpdated |
| 907 | case alphabetical | 907 | case alphabetical |
| @@ -920,6 +920,30 @@ enum WatchlistSortOption: String, CaseIterable, Identifiable { | |||
| 920 | } | 920 | } |
| 921 | } | 921 | } |
| 922 | 922 | ||
| 923 | /// A named watchlist filter preset (tag, filter, sort) the user can save and | ||
| 924 | /// reapply. Stored locally in UserDefaults; not included in backup/restore. | ||
| 925 | struct WatchlistSavedView: Codable, Identifiable, Equatable { | ||
| 926 | let id: UUID | ||
| 927 | var name: String | ||
| 928 | var tag: String? | ||
| 929 | var filter: WatchlistFilterOption | ||
| 930 | var sort: WatchlistSortOption | ||
| 931 | |||
| 932 | init( | ||
| 933 | id: UUID = UUID(), | ||
| 934 | name: String, | ||
| 935 | tag: String?, | ||
| 936 | filter: WatchlistFilterOption, | ||
| 937 | sort: WatchlistSortOption | ||
| 938 | ) { | ||
| 939 | self.id = id | ||
| 940 | self.name = name | ||
| 941 | self.tag = tag | ||
| 942 | self.filter = filter | ||
| 943 | self.sort = sort | ||
| 944 | } | ||
| 945 | } | ||
| 946 | |||
| 923 | enum PortfolioFilterOption: String, CaseIterable, Identifiable { | 947 | enum PortfolioFilterOption: String, CaseIterable, Identifiable { |
| 924 | case all | 948 | case all |
| 925 | case healthy | 949 | case healthy |
| @@ -1258,6 +1282,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable { | |||
| 1258 | var monitoringState: MonitoringState | 1282 | var monitoringState: MonitoringState |
| 1259 | var pendingMonitoringAlerts: [MonitoringPendingAlert] | 1283 | var pendingMonitoringAlerts: [MonitoringPendingAlert] |
| 1260 | var collaboration: CollaborationMetadata? | 1284 | var collaboration: CollaborationMetadata? |
| 1285 | var tags: [String] | ||
| 1261 | 1286 | ||
| 1262 | init( | 1287 | init( |
| 1263 | id: UUID = UUID(), | 1288 | id: UUID = UUID(), |
| @@ -1277,7 +1302,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable { | |||
| 1277 | lastAlertAt: Date? = nil, | 1302 | lastAlertAt: Date? = nil, |
| 1278 | monitoringState: MonitoringState = MonitoringState(), | 1303 | monitoringState: MonitoringState = MonitoringState(), |
| 1279 | pendingMonitoringAlerts: [MonitoringPendingAlert] = [], | 1304 | pendingMonitoringAlerts: [MonitoringPendingAlert] = [], |
| 1280 | collaboration: CollaborationMetadata? = nil | 1305 | collaboration: CollaborationMetadata? = nil, |
| 1306 | tags: [String] = [] | ||
| 1281 | ) { | 1307 | ) { |
| 1282 | self.id = id | 1308 | self.id = id |
| 1283 | self.domain = domain | 1309 | self.domain = domain |
| @@ -1297,6 +1323,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable { | |||
| 1297 | self.monitoringState = monitoringState | 1323 | self.monitoringState = monitoringState |
| 1298 | self.pendingMonitoringAlerts = pendingMonitoringAlerts | 1324 | self.pendingMonitoringAlerts = pendingMonitoringAlerts |
| 1299 | self.collaboration = collaboration | 1325 | self.collaboration = collaboration |
| 1326 | self.tags = tags | ||
| 1300 | } | 1327 | } |
| 1301 | 1328 | ||
| 1302 | init(from decoder: Decoder) throws { | 1329 | init(from decoder: Decoder) throws { |
| @@ -1319,6 +1346,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable { | |||
| 1319 | monitoringState = try container.decodeIfPresent(MonitoringState.self, forKey: .monitoringState) ?? MonitoringState() | 1346 | monitoringState = try container.decodeIfPresent(MonitoringState.self, forKey: .monitoringState) ?? MonitoringState() |
| 1320 | pendingMonitoringAlerts = try container.decodeIfPresent([MonitoringPendingAlert].self, forKey: .pendingMonitoringAlerts) ?? [] | 1347 | pendingMonitoringAlerts = try container.decodeIfPresent([MonitoringPendingAlert].self, forKey: .pendingMonitoringAlerts) ?? [] |
| 1321 | collaboration = try container.decodeIfPresent(CollaborationMetadata.self, forKey: .collaboration) | 1348 | collaboration = try container.decodeIfPresent(CollaborationMetadata.self, forKey: .collaboration) |
| 1349 | tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] | ||
| 1322 | } | 1350 | } |
| 1323 | } | 1351 | } |
| 1324 | 1352 | ||
DomainDig/WatchlistView.swift +107
| @@ -10,6 +10,9 @@ struct WatchlistView: View { | |||
| 10 | @State private var newTrackedDomain = "" | 10 | @State private var newTrackedDomain = "" |
| 11 | @State private var addDomainError: String? | 11 | @State private var addDomainError: String? |
| 12 | @FocusState private var isAddDomainFieldFocused: Bool | 12 | @FocusState private var isAddDomainFieldFocused: Bool |
| 13 | @State private var showSavedViewsSheet = false | ||
| 14 | @State private var showSaveViewPrompt = false | ||
| 15 | @State private var newSavedViewName = "" | ||
| 13 | 16 | ||
| 14 | private var pinnedDomains: [TrackedDomain] { | 17 | private var pinnedDomains: [TrackedDomain] { |
| 15 | viewModel.filteredTrackedDomains.filter(\.isPinned) | 18 | viewModel.filteredTrackedDomains.filter(\.isPinned) |
| @@ -23,6 +26,14 @@ struct WatchlistView: View { | |||
| 23 | let _ = purchaseService.currentTier | 26 | let _ = purchaseService.currentTier |
| 24 | 27 | ||
| 25 | List { | 28 | List { |
| 29 | if !viewModel.allWatchlistTags.isEmpty { | ||
| 30 | Section { | ||
| 31 | TagFilterChipRowView(tags: viewModel.allWatchlistTags, selection: $viewModel.watchlistTagFilter) | ||
| 32 | } | ||
| 33 | .listRowBackground(Color.clear) | ||
| 34 | .listRowInsets(EdgeInsets()) | ||
| 35 | } | ||
| 36 | |||
| 26 | if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) { | 37 | if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) { |
| 27 | Section("Refresh Progress") { | 38 | Section("Refresh Progress") { |
| 28 | VStack(alignment: .leading, spacing: 8) { | 39 | VStack(alignment: .leading, spacing: 8) { |
| @@ -110,6 +121,17 @@ struct WatchlistView: View { | |||
| 110 | } | 121 | } |
| 111 | } | 122 | } |
| 112 | 123 | ||
| 124 | Button("Save Current View…") { | ||
| 125 | newSavedViewName = "" | ||
| 126 | showSaveViewPrompt = true | ||
| 127 | } | ||
| 128 | |||
| 129 | if !viewModel.watchlistSavedViews.isEmpty { | ||
| 130 | Button("Saved Views") { | ||
| 131 | showSavedViewsSheet = true | ||
| 132 | } | ||
| 133 | } | ||
| 134 | |||
| 113 | Button(viewModel.batchLookupRunning ? "Check All Running" : "Check All") { | 135 | Button(viewModel.batchLookupRunning ? "Check All Running" : "Check All") { |
| 114 | AppHaptics.refresh() | 136 | AppHaptics.refresh() |
| 115 | viewModel.refreshAllTrackedDomains() | 137 | viewModel.refreshAllTrackedDomains() |
| @@ -211,6 +233,49 @@ struct WatchlistView: View { | |||
| 211 | availableDomains: viewModel.filteredTrackedDomains.map(\.domain) | 233 | availableDomains: viewModel.filteredTrackedDomains.map(\.domain) |
| 212 | ) | 234 | ) |
| 213 | } | 235 | } |
| 236 | .alert("Save Current View", isPresented: $showSaveViewPrompt) { | ||
| 237 | TextField("View name", text: $newSavedViewName) | ||
| 238 | Button("Save") { | ||
| 239 | viewModel.saveCurrentWatchlistView(name: newSavedViewName) | ||
| 240 | } | ||
| 241 | Button("Cancel", role: .cancel) {} | ||
| 242 | } message: { | ||
| 243 | Text("Saves the current tag, filter, and sort as a reusable preset.") | ||
| 244 | } | ||
| 245 | .sheet(isPresented: $showSavedViewsSheet) { | ||
| 246 | NavigationStack { | ||
| 247 | List { | ||
| 248 | ForEach(viewModel.watchlistSavedViews) { view in | ||
| 249 | Button { | ||
| 250 | viewModel.applyWatchlistSavedView(view) | ||
| 251 | showSavedViewsSheet = false | ||
| 252 | } label: { | ||
| 253 | VStack(alignment: .leading, spacing: 2) { | ||
| 254 | Text(view.name) | ||
| 255 | .foregroundStyle(.primary) | ||
| 256 | Text([view.tag, view.filter.title, view.sort.title].compactMap { $0 }.joined(separator: " • ")) | ||
| 257 | .font(.caption) | ||
| 258 | .foregroundStyle(.secondary) | ||
| 259 | } | ||
| 260 | } | ||
| 261 | } | ||
| 262 | .onDelete { offsets in | ||
| 263 | viewModel.deleteWatchlistSavedViews(at: offsets) | ||
| 264 | } | ||
| 265 | } | ||
| 266 | .navigationTitle("Saved Views") | ||
| 267 | .toolbar { | ||
| 268 | ToolbarItem(placement: .cancellationAction) { | ||
| 269 | Button("Done") { | ||
| 270 | showSavedViewsSheet = false | ||
| 271 | } | ||
| 272 | } | ||
| 273 | ToolbarItem(placement: .topBarTrailing) { | ||
| 274 | EditButton() | ||
| 275 | } | ||
| 276 | } | ||
| 277 | } | ||
| 278 | } | ||
| 214 | .preferredColorScheme(.dark) | 279 | .preferredColorScheme(.dark) |
| 215 | } | 280 | } |
| 216 | 281 | ||
| @@ -480,6 +545,8 @@ struct TrackedDomainDetailView: View { | |||
| 480 | 545 | ||
| 481 | @State private var noteDraft = "" | 546 | @State private var noteDraft = "" |
| 482 | @State private var isEditingNote = false | 547 | @State private var isEditingNote = false |
| 548 | @State private var tagsDraft = "" | ||
| 549 | @State private var isEditingTags = false | ||
| 483 | @State private var showRerunOptions = false | 550 | @State private var showRerunOptions = false |
| 484 | @State private var shareEntity: ShareableEntity? | 551 | @State private var shareEntity: ShareableEntity? |
| 485 | @State private var showingAuditTimeline = false | 552 | @State private var showingAuditTimeline = false |
| @@ -566,6 +633,14 @@ struct TrackedDomainDetailView: View { | |||
| 566 | } | 633 | } |
| 567 | .disabled(!viewModel.canEdit(liveTrackedDomain)) | 634 | .disabled(!viewModel.canEdit(liveTrackedDomain)) |
| 568 | 635 | ||
| 636 | Button { | ||
| 637 | tagsDraft = liveTrackedDomain.tags.joined(separator: ", ") | ||
| 638 | isEditingTags = true | ||
| 639 | } label: { | ||
| 640 | Label(liveTrackedDomain.tags.isEmpty ? "Add Tags" : "Edit Tags", systemImage: "tag") | ||
| 641 | } | ||
| 642 | .disabled(!viewModel.canEdit(liveTrackedDomain)) | ||
| 643 | |||
| 569 | Button { | 644 | Button { |
| 570 | shareEntity = .trackedDomain(liveTrackedDomain.domain) | 645 | shareEntity = .trackedDomain(liveTrackedDomain.domain) |
| 571 | } label: { | 646 | } label: { |
| @@ -574,6 +649,13 @@ struct TrackedDomainDetailView: View { | |||
| 574 | } | 649 | } |
| 575 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | 650 | .listRowBackground(Color(.systemGray6).opacity(0.5)) |
| 576 | 651 | ||
| 652 | if !liveTrackedDomain.tags.isEmpty { | ||
| 653 | Section("Tags") { | ||
| 654 | TagChipRowView(tags: liveTrackedDomain.tags) | ||
| 655 | } | ||
| 656 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | ||
| 657 | } | ||
| 658 | |||
| 577 | Section("Monitoring Status") { | 659 | Section("Monitoring Status") { |
| 578 | LabeledContent("State", value: viewModel.monitoringStatusLabel(for: liveTrackedDomain)) | 660 | LabeledContent("State", value: viewModel.monitoringStatusLabel(for: liveTrackedDomain)) |
| 579 | LabeledContent("Current Interval", value: viewModel.monitoringIntervalLabel(for: liveTrackedDomain)) | 661 | LabeledContent("Current Interval", value: viewModel.monitoringIntervalLabel(for: liveTrackedDomain)) |
| @@ -677,6 +759,31 @@ struct TrackedDomainDetailView: View { | |||
| 677 | } | 759 | } |
| 678 | } | 760 | } |
| 679 | } | 761 | } |
| 762 | .sheet(isPresented: $isEditingTags) { | ||
| 763 | NavigationStack { | ||
| 764 | Form { | ||
| 765 | Section("Tags") { | ||
| 766 | TextField("comma, separated, tags", text: $tagsDraft) | ||
| 767 | .textInputAutocapitalization(.never) | ||
| 768 | } | ||
| 769 | } | ||
| 770 | .navigationTitle("Edit Tags") | ||
| 771 | .toolbar { | ||
| 772 | ToolbarItem(placement: .cancellationAction) { | ||
| 773 | Button("Cancel") { | ||
| 774 | isEditingTags = false | ||
| 775 | } | ||
| 776 | } | ||
| 777 | ToolbarItem(placement: .confirmationAction) { | ||
| 778 | Button("Save") { | ||
| 779 | let tags = tagsDraft.components(separatedBy: ",") | ||
| 780 | viewModel.updateTags(tags, for: liveTrackedDomain) | ||
| 781 | isEditingTags = false | ||
| 782 | } | ||
| 783 | } | ||
| 784 | } | ||
| 785 | } | ||
| 786 | } | ||
| 680 | .sheet(item: $shareEntity) { entity in | 787 | .sheet(item: $shareEntity) { entity in |
| 681 | CloudSharingSheet(entity: entity, title: liveTrackedDomain.domain) | 788 | CloudSharingSheet(entity: entity, title: liveTrackedDomain.domain) |
| 682 | } | 789 | } |