diff options
| author | Christian Cleberg <[email protected]> | 2026-04-12 22:06:16 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-12 22:06:16 -0500 |
| commit | 86bcd9452d3107f68aca085e03e69b02667c0dbb (patch) | |
| tree | 8b0adece5cb0f8ffb178eaab2cd12d025c9ebf0a /Hutch/Views | |
| parent | 31cbca674957eea7ae2529012e20a9d95158e561 (diff) | |
| download | hutch-86bcd9452d3107f68aca085e03e69b02667c0dbb.tar.gz hutch-86bcd9452d3107f68aca085e03e69b02667c0dbb.tar.bz2 hutch-86bcd9452d3107f68aca085e03e69b02667c0dbb.zip | |
feat: polish read-only project views and home integration
Refs: https://todo.sr.ht/~ccleberg/hutch/36
Refs: https://todo.sr.ht/~ccleberg/hutch/37
Refs: https://todo.sr.ht/~ccleberg/hutch/38
Diffstat (limited to 'Hutch/Views')
| -rw-r--r-- | Hutch/Views/Home/HomeView.swift | 68 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 11 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectDetailView.swift | 152 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectPinStore.swift | 32 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectsListView.swift | 70 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListView.swift | 209 |
6 files changed, 363 insertions, 179 deletions
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift index 42a6737..426d351 100644 --- a/Hutch/Views/Home/HomeView.swift +++ b/Hutch/Views/Home/HomeView.swift @@ -179,7 +179,7 @@ struct HomeView: View { @ViewBuilder private func projectsSection(_ viewModel: HomeViewModel) -> some View { - if !viewModel.pinnedProjects.isEmpty { + if viewModel.hasPinnedProjects { HomeSectionView("Pinned Projects", isExpanded: $projectsExpanded) { NavigationLink { ProjectsListView() @@ -189,11 +189,27 @@ struct HomeView: View { } .buttonStyle(.plain) } content: { - ForEach(viewModel.pinnedProjects.prefix(projectPreviewLimit)) { project in - NavigationLink { - ProjectDetailView(project: project) - } label: { - HomeProjectRow(project: project) + if viewModel.isLoadingProjects && viewModel.pinnedProjects.isEmpty { + HomeSectionLoadingRow(label: "Loading pinned projects") + } else if let error = viewModel.projectsError, viewModel.pinnedProjects.isEmpty { + HomeSectionMessageRow( + text: "Couldn’t load pinned projects.", + systemImage: "exclamationmark.triangle", + emphasized: true, + accessibilityHint: error + ) + } else if viewModel.pinnedProjects.isEmpty { + HomeSectionMessageRow( + text: "Pinned projects will appear here when they’re available.", + systemImage: "pin" + ) + } else { + ForEach(viewModel.pinnedProjects.prefix(projectPreviewLimit)) { project in + NavigationLink { + ProjectDetailView(project: project) + } label: { + HomeProjectRow(project: project) + } } } } @@ -388,26 +404,34 @@ private struct HomeProjectRow: View { let project: Project var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(project.name) - .font(.subheadline.weight(.medium)) - .lineLimit(1) + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text(project.displayName) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(1) - if let description = project.description, !description.isEmpty { - Text(description) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } + if let description = project.displayDescription { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } - if let summary = project.resourceSummary { - Text(summary) - .font(.caption) - .foregroundStyle(.tertiary) - .lineLimit(1) + Spacer(minLength: 8) + + VisibilityBadge(visibility: project.visibility) } + + Text(project.metadataLine) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) } - .padding(.vertical, 2) + .contentShape(Rectangle()) + .padding(.vertical, 4) } } diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift index e9f0f50..682c94d 100644 --- a/Hutch/Views/Home/HomeViewModel.swift +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -185,6 +185,7 @@ final class HomeViewModel { private(set) var isLoadingProjects = false private(set) var isLoadingAssignedTickets = false private(set) var isLoadingRecentBuilds = false + private(set) var projectsError: String? private(set) var assignedTicketsError: String? private(set) var recentBuildsError: String? @@ -332,6 +333,7 @@ final class HomeViewModel { isLoadingAssignedTickets = true isLoadingRecentBuilds = true isLoadingSystemStatus = true + projectsError = nil assignedTicketsError = nil recentBuildsError = nil isShowingStaleSystemStatus = false @@ -347,8 +349,9 @@ final class HomeViewModel { switch projectsResult { case .success(let projects): self.projects = projects - case .failure: - self.projects = [] + self.projectsError = nil + case .failure(let error): + self.projectsError = error.userFacingMessage } isLoadingProjects = false @@ -407,6 +410,10 @@ final class HomeViewModel { return pinnedIDs.compactMap { projectsByID[$0] } } + var hasPinnedProjects: Bool { + !ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey).isEmpty + } + var failedBuildCount: Int { recentBuilds.filter { switch $0.job.status { diff --git a/Hutch/Views/Projects/ProjectDetailView.swift b/Hutch/Views/Projects/ProjectDetailView.swift index a04c526..11758ae 100644 --- a/Hutch/Views/Projects/ProjectDetailView.swift +++ b/Hutch/Views/Projects/ProjectDetailView.swift @@ -5,6 +5,7 @@ struct ProjectDetailView: View { @Environment(AppState.self) private var appState @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL @State private var detailProject: Project? @State private var isLoading = false @State private var error: String? @@ -27,13 +28,14 @@ struct ProjectDetailView: View { var body: some View { List { headerSection - linksSection repositoriesSection trackersSection mailingListsSection + linksSection + emptyResourcesSection } .themedList() - .navigationTitle(displayedProject.name) + .navigationTitle(displayedProject.displayName) .navigationBarTitleDisplayMode(.inline) .overlay { if isLoading, detailProject == nil, !project.isFullyLoaded { @@ -71,19 +73,19 @@ struct ProjectDetailView: View { private var headerSection: some View { Section { VStack(alignment: .leading, spacing: 10) { - Text(displayedProject.name) + Text(displayedProject.displayName) .font(.headline) - if let description = displayedProject.description, !description.isEmpty { + if let description = displayedProject.displayDescription { Text(description) .font(.subheadline) .foregroundStyle(.secondary) } - if !displayedProject.tags.isEmpty { + if !displayedProject.displayTags.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { - ForEach(displayedProject.tags, id: \.self) { tag in + ForEach(displayedProject.displayTags, id: \.self) { tag in Text(tag) .font(.caption.weight(.medium)) .padding(.horizontal, 10) @@ -94,7 +96,11 @@ struct ProjectDetailView: View { } } + LabeledContent("Project", value: displayedProject.visibility.displayName) LabeledContent("Updated", value: displayedProject.updated.relativeDescription) + if let summary = displayedProject.resourceSummary { + LabeledContent("Linked", value: summary) + } } .padding(.vertical, 4) } @@ -106,10 +112,20 @@ struct ProjectDetailView: View { if !links.isEmpty { Section("Links") { ForEach(links) { link in - Link(destination: link.url) { - Label(link.title, systemImage: link.systemImage) - .font(.subheadline) + Button { + openURL(link.url) + } label: { + HStack(spacing: 12) { + Label(link.title, systemImage: link.systemImage) + .font(.subheadline) + .foregroundStyle(.primary) + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } } + .buttonStyle(.plain) } } } @@ -122,14 +138,19 @@ struct ProjectDetailView: View { ForEach(displayedProject.sources) { source in Button { Task { - try? await appState.openProjectSource(source) - dismiss() + do { + try await appState.openProjectSource(source) + dismiss() + } catch { + self.error = "Couldn’t open repository. \(error.userFacingMessage)" + } } } label: { ProjectResourceRow( - title: source.name, - subtitle: source.owner.canonicalName, - detail: source.description + title: source.displayName, + subtitle: source.ownerDisplayName, + detail: source.displayDescription, + systemImage: "book.closed" ) } .buttonStyle(.plain) @@ -145,14 +166,19 @@ struct ProjectDetailView: View { ForEach(displayedProject.trackers) { tracker in Button { Task { - try? await appState.openProjectTracker(tracker) - dismiss() + do { + try await appState.openProjectTracker(tracker) + dismiss() + } catch { + self.error = "Couldn’t open tracker. \(error.userFacingMessage)" + } } } label: { ProjectResourceRow( - title: tracker.name, - subtitle: tracker.owner.canonicalName, - detail: tracker.description + title: tracker.displayName, + subtitle: tracker.ownerDisplayName, + detail: tracker.displayDescription, + systemImage: "checklist" ) } .buttonStyle(.plain) @@ -171,9 +197,10 @@ struct ProjectDetailView: View { dismiss() } label: { ProjectResourceRow( - title: mailingList.name, - subtitle: mailingList.owner.canonicalName, - detail: mailingList.description + title: mailingList.displayName, + subtitle: mailingList.ownerDisplayName, + detail: mailingList.displayDescription, + systemImage: "list.bullet" ) } .buttonStyle(.plain) @@ -182,6 +209,19 @@ struct ProjectDetailView: View { } } + @ViewBuilder + private var emptyResourcesSection: some View { + if !displayedProject.hasLinkedResources, displayedProject.websiteURL == nil { + Section { + ContentUnavailableView( + "No Linked Resources", + systemImage: "square.stack.3d.up.slash", + description: Text("This project doesn’t currently expose repositories, trackers, mailing lists, or external links.") + ) + } + } + } + private func loadProjectIfNeeded(forceRefresh: Bool = false) async { guard forceRefresh || !project.isFullyLoaded else { detailProject = project @@ -197,7 +237,7 @@ struct ProjectDetailView: View { let service = ProjectService(client: appState.client) detailProject = try await service.fetchProjectDetail(rid: project.id) } catch { - self.error = "Failed to load project" + self.error = "Couldn’t load project. \(error.userFacingMessage)" } } @@ -210,31 +250,26 @@ struct ProjectDetailView: View { private func projectLinks(for project: Project) -> [ProjectLink] { var links: [ProjectLink] = [] - if let website = project.website?.trimmingCharacters(in: .whitespacesAndNewlines), - let url = URL(string: website), - !website.isEmpty { - links.append(ProjectLink(id: "website", title: website, systemImage: "globe", url: url)) + if let url = project.websiteURL { + links.append(ProjectLink(id: "website", title: project.website ?? url.absoluteString, systemImage: "globe", url: url)) } if let source = project.sources.first, - let url = sourceURL(for: source) { - links.append(ProjectLink(id: "primary-repo", title: "\(source.ownerUsername)/\(source.name)", systemImage: "book.closed", url: url)) + let url = source.webURL { + links.append(ProjectLink(id: "primary-repo", title: "\(source.ownerUsername)/\(source.displayName)", systemImage: "book.closed", url: url)) } if let tracker = project.trackers.first, - let url = SRHTWebURL.tracker(ownerUsername: tracker.ownerUsername, trackerName: tracker.name) { - links.append(ProjectLink(id: "primary-tracker", title: "\(tracker.ownerUsername)/\(tracker.name)", systemImage: "checklist", url: url)) + let url = tracker.webURL { + links.append(ProjectLink(id: "primary-tracker", title: "\(tracker.ownerUsername)/\(tracker.displayName)", systemImage: "checklist", url: url)) } - return links - } + if let mailingList = project.mailingLists.first, + let url = SRHTWebURL.mailingList(ownerUsername: mailingList.ownerUsername, listName: mailingList.name) { + links.append(ProjectLink(id: "primary-list", title: "\(mailingList.ownerUsername)/\(mailingList.displayName)", systemImage: "list.bullet", url: url)) + } - private func sourceURL(for source: Project.SourceRepo) -> URL? { - var components = URLComponents() - components.scheme = "https" - components.host = "\(source.repoType.service.rawValue).sr.ht" - components.percentEncodedPath = "/~\(source.ownerUsername)/\(source.name)" - return components.url + return links } } @@ -249,24 +284,39 @@ private struct ProjectResourceRow: View { let title: String let subtitle: String let detail: String? + let systemImage: String var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.subheadline.weight(.medium)) - - Text(subtitle) - .font(.caption) + HStack(alignment: .top, spacing: 12) { + Image(systemName: systemImage) + .frame(width: 18, alignment: .leading) .foregroundStyle(.secondary) - .lineLimit(1) - if let detail, !detail.isEmpty { - Text(detail) + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.primary) + + Text(subtitle) .font(.caption) - .foregroundStyle(.tertiary) - .lineLimit(2) + .foregroundStyle(.secondary) + .lineLimit(1) + + if let detail, !detail.isEmpty { + Text(detail) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(2) + } } + + Spacer(minLength: 8) + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) } - .padding(.vertical, 2) + .contentShape(Rectangle()) + .padding(.vertical, 4) } } diff --git a/Hutch/Views/Projects/ProjectPinStore.swift b/Hutch/Views/Projects/ProjectPinStore.swift index cd3d961..77a4172 100644 --- a/Hutch/Views/Projects/ProjectPinStore.swift +++ b/Hutch/Views/Projects/ProjectPinStore.swift @@ -6,7 +6,7 @@ enum ProjectPinStore { defaults: UserDefaults = .standard ) -> [String] { let pinnedProjects = loadAll(defaults: defaults) - return pinnedProjects[userKey] ?? [] + return normalizedProjectIDs(pinnedProjects[userKey] ?? []) } static func isPinned( @@ -23,12 +23,14 @@ enum ProjectPinStore { defaults: UserDefaults = .standard ) { var pinnedProjects = loadAll(defaults: defaults) - var projectIDs = pinnedProjects[userKey] ?? [] + var projectIDs = normalizedProjectIDs(pinnedProjects[userKey] ?? []) if let index = projectIDs.firstIndex(of: projectID) { projectIDs.remove(at: index) } else { - projectIDs.append(projectID) + if let normalizedProjectID = normalizedProjectID(projectID) { + projectIDs.append(normalizedProjectID) + } } pinnedProjects[userKey] = projectIDs @@ -40,11 +42,33 @@ enum ProjectPinStore { return [:] } - return (try? JSONDecoder().decode([String: [String]].self, from: data)) ?? [:] + let decoded = (try? JSONDecoder().decode([String: [String]].self, from: data)) ?? [:] + return decoded.reduce(into: [String: [String]]()) { result, entry in + let normalizedUserKey = entry.key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedUserKey.isEmpty else { return } + let normalizedProjectIDs = normalizedProjectIDs(entry.value) + if !normalizedProjectIDs.isEmpty { + result[normalizedUserKey] = normalizedProjectIDs + } + } } private static func save(_ pinnedProjects: [String: [String]], defaults: UserDefaults) { guard let data = try? JSONEncoder().encode(pinnedProjects) else { return } defaults.set(data, forKey: AppStorageKeys.pinnedHomeProjects) } + + private static func normalizedProjectIDs(_ projectIDs: [String]) -> [String] { + var seen = Set<String>() + return projectIDs.compactMap { projectID in + guard let normalizedProjectID = normalizedProjectID(projectID) else { return nil } + guard seen.insert(normalizedProjectID).inserted else { return nil } + return normalizedProjectID + } + } + + private static func normalizedProjectID(_ projectID: String) -> String? { + let trimmed = projectID.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } } diff --git a/Hutch/Views/Projects/ProjectsListView.swift b/Hutch/Views/Projects/ProjectsListView.swift index 0ffe183..5786462 100644 --- a/Hutch/Views/Projects/ProjectsListView.swift +++ b/Hutch/Views/Projects/ProjectsListView.swift @@ -34,7 +34,11 @@ final class ProjectsListViewModel { do { projects = try await service.fetchProjects() } catch { - self.error = "Failed to load projects" + if projects.isEmpty { + self.error = error.userFacingMessage + } else { + self.error = "Couldn’t refresh projects. \(error.userFacingMessage)" + } } } } @@ -72,6 +76,8 @@ struct ProjectsListView: View { } label: { ProjectListRow(project: project) } + .buttonStyle(.plain) + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } } } .themedList() @@ -96,7 +102,7 @@ struct ProjectsListView: View { ContentUnavailableView( "No Projects", systemImage: "square.stack.3d.up", - description: Text("Your SourceHut projects will appear here.") + description: Text("Projects from your SourceHut account will appear here when available.") ) } } @@ -114,33 +120,49 @@ private struct ProjectListRow: View { let project: Project var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(project.name) - .font(.subheadline.weight(.medium)) - .lineLimit(1) - - Spacer() + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text(project.displayName) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(1) + + if let description = project.displayDescription { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } - Text(project.updated.relativeDescription) - .font(.caption) - .foregroundStyle(.tertiary) - } + Spacer(minLength: 8) - if let description = project.description, !description.isEmpty { - Text(description) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) + VisibilityBadge(visibility: project.visibility) } - if let summary = project.resourceSummary { - Text(summary) - .font(.caption) - .foregroundStyle(.tertiary) - .lineLimit(1) + Text(project.metadataLine) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + if !project.displayTags.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(project.displayTags.prefix(4), id: \.self) { tag in + Text(tag) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(.quaternary, in: Capsule()) + } + } + } + .scrollDisabled(true) } } - .padding(.vertical, 2) + .contentShape(Rectangle()) + .padding(.vertical, 4) } } diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift index fffaba4..3e0c656 100644 --- a/Hutch/Views/Tickets/TicketListView.swift +++ b/Hutch/Views/Tickets/TicketListView.swift @@ -194,75 +194,67 @@ struct TicketListView: View { List { Section { - 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) - } + TicketListFilterHeader( + selectedLabels: viewModel.selectedLabels, + savedFilters: viewModel.savedFilters, + activeSavedFilterID: viewModel.activeSavedFilterID, + canSaveCurrentFilter: viewModel.hasCustomFilterSelection, + filter: $vm.filter + ) { + showLabelFilterSheet = true + } onSaveFilter: { + showSaveFilterSheet = true + } onResetFilters: { + vm.resetFilters() + } onApplySavedFilter: { savedFilter in + vm.applySavedFilter(savedFilter) + } onDeleteSavedFilter: { savedFilter in + vm.deleteSavedFilter(savedFilter) } - .padding(.vertical, 4) - .listRowBackground(Color.clear) .listRowInsets(EdgeInsets()) - } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) - // Tickets - ForEach(viewModel.filteredTickets) { ticket in - NavigationLink { - TicketDetailView( - ownerUsername: String(tracker.owner.canonicalName.dropFirst()), - trackerName: tracker.name, - trackerId: tracker.id, - trackerRid: tracker.rid, - ticketId: ticket.id - ) - } label: { - TicketRowView(ticket: ticket) - } - .swipeActions(edge: .leading, allowsFullSwipe: true) { - if swipeActionsEnabled { - ticketAssignSwipeAction(ticket, viewModel: viewModel) + ForEach(viewModel.filteredTickets) { ticket in + NavigationLink { + TicketDetailView( + ownerUsername: String(tracker.owner.canonicalName.dropFirst()), + trackerName: tracker.name, + trackerId: tracker.id, + trackerRid: tracker.rid, + ticketId: ticket.id + ) + } label: { + TicketRowView(ticket: ticket) } - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if swipeActionsEnabled { - ticketStatusSwipeAction(ticket, viewModel: viewModel) - ticketLabelSwipeAction(ticket, viewModel: viewModel) + .swipeActions(edge: .leading, allowsFullSwipe: true) { + if swipeActionsEnabled { + ticketAssignSwipeAction(ticket, viewModel: viewModel) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if swipeActionsEnabled { + ticketStatusSwipeAction(ticket, viewModel: viewModel) + ticketLabelSwipeAction(ticket, viewModel: viewModel) + } + } + .task { + await viewModel.loadMoreIfNeeded(currentItem: ticket) } } - .task { - await viewModel.loadMoreIfNeeded(currentItem: ticket) - } - } - if viewModel.isLoadingMore { - HStack { - Spacer() - ProgressView() - Spacer() + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowSeparator(.hidden) } - .listRowSeparator(.hidden) } } .listStyle(.plain) + .listSectionSpacing(.compact) .searchable( text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always), @@ -538,11 +530,12 @@ private struct TicketLabelsSheet: View { } } -private struct TicketQuickFilterBar: View { +private struct TicketListFilterHeader: View { let selectedLabels: [TicketLabel] let savedFilters: [SavedTicketFilter] let activeSavedFilterID: SavedTicketFilter.ID? let canSaveCurrentFilter: Bool + @Binding var filter: TicketFilter let onShowLabels: () -> Void let onSaveFilter: () -> Void let onResetFilters: () -> Void @@ -551,26 +544,45 @@ private struct TicketQuickFilterBar: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Button(action: onShowLabels) { - Label(labelButtonTitle, systemImage: "tag") - .font(.caption.weight(.medium)) + Picker("Filter", selection: $filter) { + ForEach(TicketFilter.allCases, id: \.self) { filter in + Text(filter.rawValue).tag(filter) } - .buttonStyle(.bordered) + } + .pickerStyle(.segmented) + + TicketQuickFilterBar( + selectedLabels: selectedLabels, + savedFilters: savedFilters, + activeSavedFilterID: activeSavedFilterID, + canSaveCurrentFilter: canSaveCurrentFilter, + onShowLabels: onShowLabels, + onSaveFilter: onSaveFilter, + onResetFilters: onResetFilters, + onApplySavedFilter: onApplySavedFilter, + onDeleteSavedFilter: onDeleteSavedFilter + ) + } + .padding(.horizontal, 16) + .padding(.top, 6) + .padding(.bottom, 10) + } +} - Button(action: onSaveFilter) { - Label("Save Filter", systemImage: "square.and.arrow.down") - .font(.caption.weight(.medium)) - } - .buttonStyle(.bordered) - .disabled(!canSaveCurrentFilter) +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 - if canSaveCurrentFilter { - Button("Reset", action: onResetFilters) - .font(.caption.weight(.medium)) - .buttonStyle(.bordered) - } - } + var body: some View { + VStack(alignment: .leading, spacing: 8) { + actionButtons if !selectedLabels.isEmpty { ScrollView(.horizontal, showsIndicators: false) { @@ -623,6 +635,51 @@ private struct TicketQuickFilterBar: View { private var labelButtonTitle: String { selectedLabels.isEmpty ? "Labels" : "Labels (\(selectedLabels.count))" } + + @ViewBuilder + private var actionButtons: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { + labelsButton + saveFilterButton + if canSaveCurrentFilter { + resetButton + } + Spacer(minLength: 0) + } + + FlowLayout(spacing: 8) { + labelsButton + saveFilterButton + if canSaveCurrentFilter { + resetButton + } + } + } + } + + private var labelsButton: some View { + Button(action: onShowLabels) { + Label(labelButtonTitle, systemImage: "tag") + .font(.caption.weight(.medium)) + } + .buttonStyle(.bordered) + } + + private var saveFilterButton: some View { + Button(action: onSaveFilter) { + Label("Save Filter", systemImage: "square.and.arrow.down") + .font(.caption.weight(.medium)) + } + .buttonStyle(.bordered) + .disabled(!canSaveCurrentFilter) + } + + private var resetButton: some View { + Button("Reset", action: onResetFilters) + .font(.caption.weight(.medium)) + .buttonStyle(.bordered) + } } private struct TicketFilterLabelsSheet: View { |
