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 | 312 | var watchlistSearchText = "" |
| 313 | 313 | var watchlistFilter: WatchlistFilterOption = .all |
| 314 | 314 | var watchlistSortOption: WatchlistSortOption = .pinned |
| 315 | var watchlistTagFilter: String? | |
| 316 | var watchlistSavedViews: [WatchlistSavedView] = DomainViewModel.loadWatchlistSavedViews() | |
| 315 | 317 | var dashboardSearchText = "" |
| 316 | 318 | var dashboardFilter: PortfolioFilterOption = .all |
| 317 | 319 | var monitoringSettings: MonitoringSettings = MonitoringStorage.loadSettings() |
| @@ -432,6 +434,10 @@ final class DomainViewModel { | ||
| 432 | 434 | return false |
| 433 | 435 | } |
| 434 | 436 | |
| 437 | if let watchlistTagFilter, !trackedDomain.tags.contains(watchlistTagFilter) { | |
| 438 | return false | |
| 439 | } | |
| 440 | ||
| 435 | 441 | switch watchlistFilter { |
| 436 | 442 | case .all: |
| 437 | 443 | return true |
| @@ -888,6 +894,69 @@ final class DomainViewModel { | ||
| 888 | 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 | 960 | func removeHistoryEntries(at offsets: IndexSet) { |
| 892 | 961 | history.remove(atOffsets: offsets) |
| 893 | 962 | persistHistory() |
DomainDig/Models.swift +31 −3
| @@ -882,7 +882,7 @@ struct TimelineSection: Identifiable, Equatable { | ||
| 882 | 882 | let entries: [SnapshotSummary] |
| 883 | 883 | } |
| 884 | 884 | |
| 885 | enum WatchlistFilterOption: String, CaseIterable, Identifiable { | |
| 885 | enum WatchlistFilterOption: String, CaseIterable, Identifiable, Codable { | |
| 886 | 886 | case all |
| 887 | 887 | case pinnedOnly |
| 888 | 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 | 905 | case pinned |
| 906 | 906 | case recentlyUpdated |
| 907 | 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 | 947 | enum PortfolioFilterOption: String, CaseIterable, Identifiable { |
| 924 | 948 | case all |
| 925 | 949 | case healthy |
| @@ -1258,6 +1282,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable { | ||
| 1258 | 1282 | var monitoringState: MonitoringState |
| 1259 | 1283 | var pendingMonitoringAlerts: [MonitoringPendingAlert] |
| 1260 | 1284 | var collaboration: CollaborationMetadata? |
| 1285 | var tags: [String] | |
| 1261 | 1286 | |
| 1262 | 1287 | init( |
| 1263 | 1288 | id: UUID = UUID(), |
| @@ -1277,7 +1302,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable { | ||
| 1277 | 1302 | lastAlertAt: Date? = nil, |
| 1278 | 1303 | monitoringState: MonitoringState = MonitoringState(), |
| 1279 | 1304 | pendingMonitoringAlerts: [MonitoringPendingAlert] = [], |
| 1280 | collaboration: CollaborationMetadata? = nil | |
| 1305 | collaboration: CollaborationMetadata? = nil, | |
| 1306 | tags: [String] = [] | |
| 1281 | 1307 | ) { |
| 1282 | 1308 | self.id = id |
| 1283 | 1309 | self.domain = domain |
| @@ -1297,6 +1323,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable { | ||
| 1297 | 1323 | self.monitoringState = monitoringState |
| 1298 | 1324 | self.pendingMonitoringAlerts = pendingMonitoringAlerts |
| 1299 | 1325 | self.collaboration = collaboration |
| 1326 | self.tags = tags | |
| 1300 | 1327 | } |
| 1301 | 1328 | |
| 1302 | 1329 | init(from decoder: Decoder) throws { |
| @@ -1319,6 +1346,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable { | ||
| 1319 | 1346 | monitoringState = try container.decodeIfPresent(MonitoringState.self, forKey: .monitoringState) ?? MonitoringState() |
| 1320 | 1347 | pendingMonitoringAlerts = try container.decodeIfPresent([MonitoringPendingAlert].self, forKey: .pendingMonitoringAlerts) ?? [] |
| 1321 | 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 | 10 | @State private var newTrackedDomain = "" |
| 11 | 11 | @State private var addDomainError: String? |
| 12 | 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 | 17 | private var pinnedDomains: [TrackedDomain] { |
| 15 | 18 | viewModel.filteredTrackedDomains.filter(\.isPinned) |
| @@ -23,6 +26,14 @@ struct WatchlistView: View { | ||
| 23 | 26 | let _ = purchaseService.currentTier |
| 24 | 27 | |
| 25 | 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 | 37 | if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) { |
| 27 | 38 | Section("Refresh Progress") { |
| 28 | 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 | 135 | Button(viewModel.batchLookupRunning ? "Check All Running" : "Check All") { |
| 114 | 136 | AppHaptics.refresh() |
| 115 | 137 | viewModel.refreshAllTrackedDomains() |
| @@ -211,6 +233,49 @@ struct WatchlistView: View { | ||
| 211 | 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 | 279 | .preferredColorScheme(.dark) |
| 215 | 280 | } |
| 216 | 281 | |
| @@ -480,6 +545,8 @@ struct TrackedDomainDetailView: View { | ||
| 480 | 545 | |
| 481 | 546 | @State private var noteDraft = "" |
| 482 | 547 | @State private var isEditingNote = false |
| 548 | @State private var tagsDraft = "" | |
| 549 | @State private var isEditingTags = false | |
| 483 | 550 | @State private var showRerunOptions = false |
| 484 | 551 | @State private var shareEntity: ShareableEntity? |
| 485 | 552 | @State private var showingAuditTimeline = false |
| @@ -566,6 +633,14 @@ struct TrackedDomainDetailView: View { | ||
| 566 | 633 | } |
| 567 | 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 | 644 | Button { |
| 570 | 645 | shareEntity = .trackedDomain(liveTrackedDomain.domain) |
| 571 | 646 | } label: { |
| @@ -574,6 +649,13 @@ struct TrackedDomainDetailView: View { | ||
| 574 | 649 | } |
| 575 | 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 | 659 | Section("Monitoring Status") { |
| 578 | 660 | LabeledContent("State", value: viewModel.monitoringStatusLabel(for: liveTrackedDomain)) |
| 579 | 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 | 787 | .sheet(item: $shareEntity) { entity in |
| 681 | 788 | CloudSharingSheet(entity: entity, title: liveTrackedDomain.domain) |
| 682 | 789 | } |