diff options
| author | Christian Cleberg <[email protected]> | 2026-04-12 21:33:14 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-12 21:33:14 -0500 |
| commit | 31cbca674957eea7ae2529012e20a9d95158e561 (patch) | |
| tree | ea57e5b7faf17b3a3a0967e074960f41d3556393 /Hutch/Views/Tickets/TicketListView.swift | |
| parent | 7548bfb595d853c682653c0dc21bb3bf17af80d5 (diff) | |
| download | hutch-31cbca674957eea7ae2529012e20a9d95158e561.tar.gz hutch-31cbca674957eea7ae2529012e20a9d95158e561.tar.bz2 hutch-31cbca674957eea7ae2529012e20a9d95158e561.zip | |
feat: add ticket saved filters and label filtering
Implements: https://todo.sr.ht/~ccleberg/hutch/32
Implements: https://todo.sr.ht/~ccleberg/hutch/33
Diffstat (limited to 'Hutch/Views/Tickets/TicketListView.swift')
| -rw-r--r-- | Hutch/Views/Tickets/TicketListView.swift | 245 |
1 files changed, 239 insertions, 6 deletions
diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift index 8b5fc03..fffaba4 100644 --- a/Hutch/Views/Tickets/TicketListView.swift +++ b/Hutch/Views/Tickets/TicketListView.swift @@ -13,6 +13,8 @@ struct TicketListView: View { @State private var showCreateTicketSheet = false @State private var createdTicket: TicketSummary? @State private var labelEditorTicket: LabelEditorTicket? + @State private var showLabelFilterSheet = false + @State private var showSaveFilterSheet = false @State private var showTrackerEditor = false @State private var showTrackerACLs = false @State private var showTrackerLabels = false @@ -86,6 +88,18 @@ struct TicketListView: View { .presentationDetents([.medium]) } } + .sheet(isPresented: $showLabelFilterSheet) { + if let viewModel { + TicketFilterLabelsSheet(viewModel: viewModel) + .presentationDetents([.medium, .large]) + } + } + .sheet(isPresented: $showSaveFilterSheet) { + if let viewModel { + SaveTicketFilterSheet(viewModel: viewModel) + .presentationDetents([.height(220)]) + } + } .sheet(isPresented: $showTrackerEditor) { if let trackerManagementViewModel { TrackerEditorSheet( @@ -169,6 +183,7 @@ struct TicketListView: View { viewModel = vm trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client) await vm.loadTickets() + await vm.loadTrackerLabels() } } } @@ -178,14 +193,33 @@ struct TicketListView: View { @Bindable var vm = viewModel List { - // Filter picker Section { - Picker("Filter", selection: $vm.filter) { - ForEach(TicketFilter.allCases, id: \.self) { filter in - Text(filter.rawValue).tag(filter) + VStack(alignment: .leading, spacing: 12) { + Picker("Filter", selection: $vm.filter) { + ForEach(TicketFilter.allCases, id: \.self) { filter in + Text(filter.rawValue).tag(filter) + } + } + .pickerStyle(.segmented) + + TicketQuickFilterBar( + selectedLabels: viewModel.selectedLabels, + savedFilters: viewModel.savedFilters, + activeSavedFilterID: viewModel.activeSavedFilterID, + canSaveCurrentFilter: viewModel.hasCustomFilterSelection + ) { + showLabelFilterSheet = true + } onSaveFilter: { + showSaveFilterSheet = true + } onResetFilters: { + vm.resetFilters() + } onApplySavedFilter: { savedFilter in + vm.applySavedFilter(savedFilter) + } onDeleteSavedFilter: { savedFilter in + vm.deleteSavedFilter(savedFilter) } } - .pickerStyle(.segmented) + .padding(.vertical, 4) .listRowBackground(Color.clear) .listRowInsets(EdgeInsets()) } @@ -250,7 +284,7 @@ struct TicketListView: View { ContentUnavailableView( "No Tickets", systemImage: "ticket", - description: Text("No \(viewModel.filter.rawValue.lowercased()) tickets found.") + description: Text(emptyStateDescription(for: viewModel)) ) } } @@ -263,6 +297,13 @@ struct TicketListView: View { } } + private func emptyStateDescription(for viewModel: TicketListViewModel) -> String { + if !viewModel.selectedLabelIDs.isEmpty { + return "No \(viewModel.filter.rawValue.lowercased()) tickets found for the selected labels." + } + return "No \(viewModel.filter.rawValue.lowercased()) tickets found." + } + private var trackerActionsMenu: some View { Menu { Button { @@ -497,6 +538,198 @@ private struct TicketLabelsSheet: View { } } +private struct TicketQuickFilterBar: View { + let selectedLabels: [TicketLabel] + let savedFilters: [SavedTicketFilter] + let activeSavedFilterID: SavedTicketFilter.ID? + let canSaveCurrentFilter: Bool + let onShowLabels: () -> Void + let onSaveFilter: () -> Void + let onResetFilters: () -> Void + let onApplySavedFilter: (SavedTicketFilter) -> Void + let onDeleteSavedFilter: (SavedTicketFilter) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Button(action: onShowLabels) { + Label(labelButtonTitle, systemImage: "tag") + .font(.caption.weight(.medium)) + } + .buttonStyle(.bordered) + + Button(action: onSaveFilter) { + Label("Save Filter", systemImage: "square.and.arrow.down") + .font(.caption.weight(.medium)) + } + .buttonStyle(.bordered) + .disabled(!canSaveCurrentFilter) + + if canSaveCurrentFilter { + Button("Reset", action: onResetFilters) + .font(.caption.weight(.medium)) + .buttonStyle(.bordered) + } + } + + if !selectedLabels.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(selectedLabels) { label in + LabelPill(label: label) + } + } + } + } + + if !savedFilters.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(savedFilters) { savedFilter in + Button { + onApplySavedFilter(savedFilter) + } label: { + HStack(spacing: 6) { + Image(systemName: activeSavedFilterID == savedFilter.id ? "checkmark.circle.fill" : "line.3.horizontal.decrease.circle") + .imageScale(.small) + Text(savedFilter.name) + .lineLimit(1) + } + .font(.caption.weight(.medium)) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .foregroundStyle(activeSavedFilterID == savedFilter.id ? Color.accentColor : Color.primary) + .background( + activeSavedFilterID == savedFilter.id ? + Color.accentColor.opacity(0.14) : + Color(.secondarySystemFill), + in: Capsule() + ) + } + .contextMenu { + Button(role: .destructive) { + onDeleteSavedFilter(savedFilter) + } label: { + Label("Delete Filter", systemImage: "trash") + } + } + } + } + } + } + } + } + + private var labelButtonTitle: String { + selectedLabels.isEmpty ? "Labels" : "Labels (\(selectedLabels.count))" + } +} + +private struct TicketFilterLabelsSheet: View { + let viewModel: TicketListViewModel + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Group { + if viewModel.availableLabels.isEmpty { + ContentUnavailableView( + "No Labels", + systemImage: "tag", + description: Text("This tracker has no labels available for filtering yet.") + ) + } else { + List { + if !viewModel.selectedLabels.isEmpty { + Section("Selected") { + FlowLayout(spacing: 6) { + ForEach(viewModel.selectedLabels) { label in + LabelPill(label: label) + } + } + .padding(.vertical, 4) + } + } + + Section("Labels") { + ForEach(viewModel.availableLabels) { label in + Button { + viewModel.toggleLabelSelection(label) + } label: { + HStack { + LabelPill(label: label) + Spacer() + if viewModel.selectedLabelIDs.contains(label.id) { + Image(systemName: "checkmark") + .foregroundStyle(.blue) + } + } + } + .buttonStyle(.plain) + } + } + } + } + } + .navigationTitle("Filter Labels") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + if !viewModel.selectedLabelIDs.isEmpty { + ToolbarItem(placement: .confirmationAction) { + Button("Clear") { viewModel.clearLabelSelection() } + } + } + } + .task { + if viewModel.availableLabels.isEmpty { + await viewModel.loadTrackerLabels() + } + } + } + } +} + +private struct SaveTicketFilterSheet: View { + let viewModel: TicketListViewModel + + @Environment(\.dismiss) private var dismiss + @State private var name: String + + init(viewModel: TicketListViewModel) { + self.viewModel = viewModel + self._name = State(initialValue: viewModel.suggestedSavedFilterName) + } + + var body: some View { + NavigationStack { + Form { + Section("Name") { + TextField("Filter name", text: $name) + .textInputAutocapitalization(.words) + } + } + .navigationTitle("Save Filter") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + viewModel.saveCurrentFilter(named: name) + dismiss() + } + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } +} + private struct TicketListLabelToggleRow: View { let label: TicketLabel let isApplied: Bool |
