summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-12 21:33:14 -0500
committerChristian Cleberg <[email protected]>2026-04-12 21:33:14 -0500
commit31cbca674957eea7ae2529012e20a9d95158e561 (patch)
treeea57e5b7faf17b3a3a0967e074960f41d3556393 /Hutch
parent7548bfb595d853c682653c0dc21bb3bf17af80d5 (diff)
downloadhutch-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')
-rw-r--r--Hutch/App/AppStorageKeys.swift2
-rw-r--r--Hutch/Views/Builds/BuildTaskLogView.swift4
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift245
-rw-r--r--Hutch/Views/Tickets/TicketListViewModel.swift178
-rw-r--r--Hutch/Views/Tickets/TicketSavedFilterStore.swift142
5 files changed, 534 insertions, 37 deletions
diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift
index 9a97657..5bb8307 100644
--- a/Hutch/App/AppStorageKeys.swift
+++ b/Hutch/App/AppStorageKeys.swift
@@ -14,6 +14,8 @@ enum AppStorageKeys {
static let homeBuildsExpanded = "homeBuildsExpanded"
static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval"
static let buildsRepoFilter = "buildsRepoFilter"
+ static let ticketFilterState = "ticketFilterState"
+ static let ticketSavedFilters = "ticketSavedFilters"
static let appTheme = "appTheme"
static let displayDensity = "displayDensity"
}
diff --git a/Hutch/Views/Builds/BuildTaskLogView.swift b/Hutch/Views/Builds/BuildTaskLogView.swift
index 69353bf..fda69f6 100644
--- a/Hutch/Views/Builds/BuildTaskLogView.swift
+++ b/Hutch/Views/Builds/BuildTaskLogView.swift
@@ -407,7 +407,7 @@ private struct LogScrollTarget: Equatable {
let range: LogTextRange
}
-func logMatchRanges(in text: String, query: String, limit: Int = 2_000) -> [LogTextRange] {
+nonisolated func logMatchRanges(in text: String, query: String, limit: Int = 2_000) -> [LogTextRange] {
let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedQuery.isEmpty else { return [] }
@@ -433,7 +433,7 @@ func logMatchRanges(in text: String, query: String, limit: Int = 2_000) -> [LogT
return matches
}
-func detectLogAnchors(in text: String, limit: Int = 24) -> [LogAnchor] {
+nonisolated func detectLogAnchors(in text: String, limit: Int = 24) -> [LogAnchor] {
let nsText = text as NSString
let strongMarkers = [
"fatal error",
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
diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift
index 9f2fe79..3b1310d 100644
--- a/Hutch/Views/Tickets/TicketListViewModel.swift
+++ b/Hutch/Views/Tickets/TicketListViewModel.swift
@@ -55,7 +55,7 @@ private struct MutationEventRef: Decodable, Sendable {
// MARK: - Filter
-enum TicketFilter: String, CaseIterable, Sendable {
+enum TicketFilter: String, CaseIterable, Codable, Sendable {
case open = "Open"
case resolved = "Resolved"
case all = "All"
@@ -77,32 +77,46 @@ final class TicketListViewModel {
private(set) var isCreatingTicket = false
private(set) var isPerformingAction = false
private(set) var trackerLabels: [TicketLabel] = []
+ private(set) var savedFilters: [SavedTicketFilter]
var error: String?
var filter: TicketFilter = .open {
didSet {
- UserDefaults.standard.set(filter.rawValue, forKey: filterDefaultsKey)
+ persistFilterState()
+ }
+ }
+ var selectedLabelIDs: Set<Int> = [] {
+ didSet {
+ persistFilterState()
}
}
var searchText = ""
+ private(set) var activeSavedFilterID: SavedTicketFilter.ID?
private var cursor: String?
private var hasMore = true
private let client: SRHTClient
-
- private var filterDefaultsKey: String {
- "ticketFilter_\(trackerRid)"
- }
-
- init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, client: SRHTClient) {
+ private let defaults: UserDefaults
+
+ init(
+ ownerUsername: String,
+ trackerName: String,
+ trackerId: Int,
+ trackerRid: String,
+ client: SRHTClient,
+ defaults: UserDefaults = .standard
+ ) {
self.ownerUsername = ownerUsername
self.trackerName = trackerName
self.trackerId = trackerId
self.trackerRid = trackerRid
self.client = client
- if let raw = UserDefaults.standard.string(forKey: filterDefaultsKey),
- let restored = TicketFilter(rawValue: raw) {
- self.filter = restored
- }
+ self.defaults = defaults
+
+ let restoredState = TicketSavedFilterStore.loadCurrentState(for: trackerRid, defaults: defaults)
+ self.filter = restoredState.status
+ self.selectedLabelIDs = Set(restoredState.labelIDs)
+ self.savedFilters = TicketSavedFilterStore.loadSavedFilters(for: trackerRid, defaults: defaults)
+ self.activeSavedFilterID = self.savedFilters.first(where: { $0.state == restoredState })?.id
}
// MARK: - Query
@@ -186,25 +200,43 @@ final class TicketListViewModel {
// MARK: - Computed
- /// Tickets filtered by the selected status filter.
- var filteredTickets: [TicketSummary] {
- let statusFiltered: [TicketSummary]
- switch filter {
- case .open:
- statusFiltered = tickets.filter { $0.status.isOpen }
- case .resolved:
- statusFiltered = tickets.filter { !$0.status.isOpen }
- case .all:
- statusFiltered = tickets
+ var currentFilterState: TicketListFilterState {
+ TicketListFilterState(status: filter, labelIDs: Array(selectedLabelIDs))
+ }
+
+ var availableLabels: [TicketLabel] {
+ let combinedLabels = trackerLabels + tickets.flatMap(\.labels)
+ let deduplicated = combinedLabels.reduce(into: [Int: TicketLabel]()) { partialResult, label in
+ partialResult[label.id] = label
}
- let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- guard !q.isEmpty else { return statusFiltered }
- return statusFiltered.filter {
- String($0.id).contains(q) ||
- $0.title.lowercased().contains(q) ||
- $0.submitter.canonicalName.lowercased().contains(q) ||
- $0.labels.contains { $0.name.lowercased().contains(q) }
+ return deduplicated.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
+ }
+
+ var selectedLabels: [TicketLabel] {
+ availableLabels.filter { selectedLabelIDs.contains($0.id) }
+ }
+
+ var suggestedSavedFilterName: String {
+ let labelNames = selectedLabels.map(\.name).sorted()
+ var components: [String] = []
+
+ if filter != .open || !labelNames.isEmpty {
+ components.append(filter.rawValue)
+ }
+ if !labelNames.isEmpty {
+ components.append(labelNames.joined(separator: ", "))
}
+
+ return components.isEmpty ? "Open Tickets" : components.joined(separator: " • ")
+ }
+
+ var hasCustomFilterSelection: Bool {
+ !currentFilterState.isDefault
+ }
+
+ /// Tickets filtered by the selected status and label filters.
+ var filteredTickets: [TicketSummary] {
+ Self.filterTickets(tickets, state: currentFilterState, query: searchText)
}
// MARK: - Public API
@@ -401,6 +433,54 @@ final class TicketListViewModel {
}
}
+ func toggleLabelSelection(_ label: TicketLabel) {
+ if selectedLabelIDs.contains(label.id) {
+ selectedLabelIDs.remove(label.id)
+ } else {
+ selectedLabelIDs.insert(label.id)
+ }
+ }
+
+ func clearLabelSelection() {
+ selectedLabelIDs = []
+ }
+
+ func resetFilters() {
+ filter = .open
+ selectedLabelIDs = []
+ }
+
+ func applySavedFilter(_ savedFilter: SavedTicketFilter) {
+ filter = savedFilter.state.status
+ selectedLabelIDs = Set(savedFilter.state.labelIDs)
+ activeSavedFilterID = savedFilter.id
+ }
+
+ func saveCurrentFilter(named name: String) {
+ guard let savedFilter = TicketSavedFilterStore.saveFilter(
+ named: name,
+ state: currentFilterState,
+ for: trackerRid,
+ defaults: defaults
+ ) else {
+ return
+ }
+
+ savedFilters.removeAll {
+ $0.name.compare(name, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
+ }
+ savedFilters.insert(savedFilter, at: 0)
+ activeSavedFilterID = savedFilter.id
+ }
+
+ func deleteSavedFilter(_ savedFilter: SavedTicketFilter) {
+ TicketSavedFilterStore.deleteFilter(id: savedFilter.id, for: trackerRid, defaults: defaults)
+ savedFilters.removeAll { $0.id == savedFilter.id }
+ if activeSavedFilterID == savedFilter.id {
+ activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id
+ }
+ }
+
func labelTicket(_ ticket: TicketSummary, label: TicketLabel) async {
guard !isPerformingAction else { return }
isPerformingAction = true
@@ -569,4 +649,44 @@ final class TicketListViewModel {
assignees: ticket.assignees
)
}
+
+ private func persistFilterState() {
+ TicketSavedFilterStore.saveCurrentState(currentFilterState, for: trackerRid, defaults: defaults)
+ activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id
+ }
+
+ static func filterTickets(
+ _ tickets: [TicketSummary],
+ state: TicketListFilterState,
+ query: String
+ ) -> [TicketSummary] {
+ let statusFiltered: [TicketSummary]
+ switch state.status {
+ case .open:
+ statusFiltered = tickets.filter { $0.status.isOpen }
+ case .resolved:
+ statusFiltered = tickets.filter { !$0.status.isOpen }
+ case .all:
+ statusFiltered = tickets
+ }
+
+ let labelFiltered: [TicketSummary]
+ if state.labelIDs.isEmpty {
+ labelFiltered = statusFiltered
+ } else {
+ let selectedLabelIDs = Set(state.labelIDs)
+ labelFiltered = statusFiltered.filter { ticket in
+ !selectedLabelIDs.isDisjoint(with: ticket.labels.map(\.id))
+ }
+ }
+
+ let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !q.isEmpty else { return labelFiltered }
+ return labelFiltered.filter {
+ String($0.id).contains(q) ||
+ $0.title.lowercased().contains(q) ||
+ $0.submitter.canonicalName.lowercased().contains(q) ||
+ $0.labels.contains { $0.name.lowercased().contains(q) }
+ }
+ }
}
diff --git a/Hutch/Views/Tickets/TicketSavedFilterStore.swift b/Hutch/Views/Tickets/TicketSavedFilterStore.swift
new file mode 100644
index 0000000..f5e5946
--- /dev/null
+++ b/Hutch/Views/Tickets/TicketSavedFilterStore.swift
@@ -0,0 +1,142 @@
+import Foundation
+
+struct TicketListFilterState: Codable, Hashable, Sendable {
+ var status: TicketFilter
+ var labelIDs: [Int]
+
+ init(status: TicketFilter = .open, labelIDs: [Int] = []) {
+ self.status = status
+ self.labelIDs = Array(Set(labelIDs)).sorted()
+ }
+
+ var isDefault: Bool {
+ status == .open && labelIDs.isEmpty
+ }
+}
+
+struct SavedTicketFilter: Codable, Hashable, Identifiable, Sendable {
+ let id: UUID
+ let name: String
+ let state: TicketListFilterState
+ let createdAt: Date
+}
+
+enum TicketSavedFilterStore {
+ static func loadCurrentState(
+ for trackerID: String,
+ defaults: UserDefaults = .standard
+ ) -> TicketListFilterState {
+ let allStates = loadStates(defaults: defaults)
+ if let savedState = allStates[trackerID] {
+ return savedState
+ }
+
+ if let legacyRawValue = defaults.string(forKey: legacyStatusKey(for: trackerID)),
+ let status = TicketFilter(rawValue: legacyRawValue) {
+ return TicketListFilterState(status: status)
+ }
+
+ return TicketListFilterState()
+ }
+
+ static func saveCurrentState(
+ _ state: TicketListFilterState,
+ for trackerID: String,
+ defaults: UserDefaults = .standard
+ ) {
+ var allStates = loadStates(defaults: defaults)
+ allStates[trackerID] = state
+ save(allStates, key: AppStorageKeys.ticketFilterState, defaults: defaults)
+ defaults.removeObject(forKey: legacyStatusKey(for: trackerID))
+ }
+
+ static func loadSavedFilters(
+ for trackerID: String,
+ defaults: UserDefaults = .standard
+ ) -> [SavedTicketFilter] {
+ let allFilters: [String: [SavedTicketFilter]] = loadDictionary(
+ key: AppStorageKeys.ticketSavedFilters,
+ defaults: defaults
+ )
+ return allFilters[trackerID] ?? []
+ }
+
+ static func saveFilter(
+ named name: String,
+ state: TicketListFilterState,
+ for trackerID: String,
+ defaults: UserDefaults = .standard,
+ now: Date = .now
+ ) -> SavedTicketFilter? {
+ let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedName.isEmpty else { return nil }
+
+ var allFilters: [String: [SavedTicketFilter]] = loadDictionary(
+ key: AppStorageKeys.ticketSavedFilters,
+ defaults: defaults
+ )
+ var trackerFilters = allFilters[trackerID] ?? []
+ trackerFilters.removeAll {
+ $0.name.compare(trimmedName, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
+ }
+
+ let savedFilter = SavedTicketFilter(
+ id: UUID(),
+ name: trimmedName,
+ state: state,
+ createdAt: now
+ )
+ trackerFilters.insert(savedFilter, at: 0)
+ allFilters[trackerID] = trackerFilters
+ save(allFilters, key: AppStorageKeys.ticketSavedFilters, defaults: defaults)
+ return savedFilter
+ }
+
+ static func deleteFilter(
+ id: SavedTicketFilter.ID,
+ for trackerID: String,
+ defaults: UserDefaults = .standard
+ ) {
+ var allFilters: [String: [SavedTicketFilter]] = loadDictionary(
+ key: AppStorageKeys.ticketSavedFilters,
+ defaults: defaults
+ )
+ var trackerFilters = allFilters[trackerID] ?? []
+ trackerFilters.removeAll { $0.id == id }
+ allFilters[trackerID] = trackerFilters
+ save(allFilters, key: AppStorageKeys.ticketSavedFilters, defaults: defaults)
+ }
+
+ private static func loadStates(defaults: UserDefaults) -> [String: TicketListFilterState] {
+ loadDictionary(key: AppStorageKeys.ticketFilterState, defaults: defaults)
+ }
+
+ private static func loadDictionary<T: Decodable>(
+ key: String,
+ defaults: UserDefaults
+ ) -> [String: T] {
+ guard let data = defaults.data(forKey: key) else {
+ return [:]
+ }
+
+ do {
+ return try JSONDecoder().decode([String: T].self, from: data)
+ } catch {
+ defaults.removeObject(forKey: key)
+ return [:]
+ }
+ }
+
+ private static func save<T: Encodable>(
+ _ value: [String: T],
+ key: String,
+ defaults: UserDefaults
+ ) {
+ guard let data = try? JSONEncoder().encode(value) else { return }
+ defaults.set(data, forKey: key)
+ }
+
+ private static func legacyStatusKey(for trackerID: String) -> String {
+ "ticketFilter_\(trackerID)"
+ }
+}