summaryrefslogtreecommitdiff
path: root/Hutch/Views/Tickets/TicketListView.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-22 20:42:53 -0500
committerChristian Cleberg <[email protected]>2026-03-22 20:42:53 -0500
commit82e54ad018ae5fa5cdf90c0d763e09e99bc0e933 (patch)
treecafdbc8f4116af5cc6e0ca706e05ced599e26570 /Hutch/Views/Tickets/TicketListView.swift
parent1f8b7a23529462c3c332753d33810b5c9f692bbc (diff)
downloadhutch-82e54ad018ae5fa5cdf90c0d763e09e99bc0e933.tar.gz
hutch-82e54ad018ae5fa5cdf90c0d763e09e99bc0e933.tar.bz2
hutch-82e54ad018ae5fa5cdf90c0d763e09e99bc0e933.zip
feat: swipe actions on tickets, pastes, builds, and home dashboard
Diffstat (limited to 'Hutch/Views/Tickets/TicketListView.swift')
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift208
1 files changed, 208 insertions, 0 deletions
diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift
index 93a7032..69ab419 100644
--- a/Hutch/Views/Tickets/TicketListView.swift
+++ b/Hutch/Views/Tickets/TicketListView.swift
@@ -6,10 +6,12 @@ struct TicketListView: View {
let trackerId: Int
let trackerRid: String
+ @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
@Environment(AppState.self) private var appState
@State private var viewModel: TicketListViewModel?
@State private var showCreateTicketSheet = false
@State private var createdTicket: TicketSummary?
+ @State private var labelEditorTicket: LabelEditorTicket?
var body: some View {
Group {
@@ -45,6 +47,15 @@ struct TicketListView: View {
}
}
}
+ .sheet(item: $labelEditorTicket) { item in
+ if let viewModel {
+ TicketLabelsSheet(
+ ticketId: item.id,
+ viewModel: viewModel
+ )
+ .presentationDetents([.medium])
+ }
+ }
.navigationDestination(isPresented: Binding(
get: { createdTicket != nil },
set: { isPresented in
@@ -63,6 +74,7 @@ struct TicketListView: View {
ownerUsername: ownerUsername,
trackerName: trackerName,
trackerId: trackerId,
+ trackerRid: trackerRid,
client: appState.client
)
viewModel = vm
@@ -93,6 +105,16 @@ struct TicketListView: View {
NavigationLink(value: ticket) {
TicketRowView(ticket: ticket)
}
+ .swipeActions(edge: .leading, allowsFullSwipe: true) {
+ if swipeActionsEnabled {
+ ticketLeadingSwipeAction(ticket, viewModel: viewModel)
+ }
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ if swipeActionsEnabled {
+ ticketTrailingSwipeActions(ticket, viewModel: viewModel)
+ }
+ }
.task {
await viewModel.loadMoreIfNeeded(currentItem: ticket)
}
@@ -144,6 +166,106 @@ struct TicketListView: View {
TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: ticket.id)
}
}
+
+ @ViewBuilder
+ private func ticketLeadingSwipeAction(
+ _ ticket: TicketSummary,
+ viewModel: TicketListViewModel
+ ) -> some View {
+ if ticket.status.isOpen {
+ Button {
+ Task {
+ await resolveTicket(ticket, viewModel: viewModel)
+ }
+ } label: {
+ Label("Resolve", systemImage: "checkmark.circle")
+ }
+ .tint(.green)
+ } else {
+ Button {
+ Task {
+ await reopenTicket(ticket, viewModel: viewModel)
+ }
+ } label: {
+ Label("Reopen", systemImage: "arrow.uturn.backward")
+ }
+ .tint(.blue)
+ }
+ }
+
+ private func resolveTicket(_ ticket: TicketSummary, viewModel: TicketListViewModel) async {
+ await viewModel.resolveTicket(ticket)
+ }
+
+ private func reopenTicket(_ ticket: TicketSummary, viewModel: TicketListViewModel) async {
+ await viewModel.reopenTicket(ticket)
+ }
+
+ @ViewBuilder
+ private func ticketTrailingSwipeActions(
+ _ ticket: TicketSummary,
+ viewModel: TicketListViewModel
+ ) -> some View {
+ Button {
+ labelEditorTicket = LabelEditorTicket(id: ticket.id)
+ Task { await viewModel.loadTrackerLabels() }
+ } label: {
+ Label("Edit Labels", systemImage: "tag")
+ }
+ .tint(.purple)
+
+ if let currentUser = appState.currentUser {
+ let isAssigned = ticket.assignees.contains { assignee in
+ matchesAssignee(assignee, user: currentUser)
+ }
+
+ if isAssigned {
+ Button {
+ Task {
+ await viewModel.unassignFromMe(ticket: ticket, user: currentUser)
+ }
+ } label: {
+ Label("Unassign Me", systemImage: "person.badge.minus")
+ }
+ .tint(.orange)
+ } else {
+ Button {
+ Task {
+ await viewModel.assignToMe(ticket: ticket, user: currentUser)
+ }
+ } label: {
+ Label("Assign Me", systemImage: "person.badge.plus")
+ }
+ .tint(.cyan)
+ }
+ }
+ }
+
+ private func matchesAssignee(_ entity: Entity, user: User) -> Bool {
+ let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
+ let userCanonical = normalizedCanonicalName(user.canonicalName)
+ if assigneeCanonical == userCanonical {
+ return true
+ }
+ return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
+ }
+
+ private func normalizedCanonicalName(_ value: String) -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.hasPrefix("~") {
+ return trimmed
+ }
+ return "~\(trimmed)"
+ }
+
+ private func normalizedUsername(_ value: String) -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
+ }
+}
+
+private struct LabelEditorTicket: Identifiable {
+ let id: Int
}
private struct CreateTicketSheet: View {
@@ -191,6 +313,92 @@ private struct CreateTicketSheet: View {
}
}
+private struct TicketLabelsSheet: View {
+ let ticketId: Int
+ let viewModel: TicketListViewModel
+
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if let ticket = viewModel.ticket(withId: ticketId) {
+ if viewModel.trackerLabels.isEmpty {
+ if viewModel.isPerformingAction {
+ ProgressView()
+ } else {
+ ContentUnavailableView(
+ "No Labels",
+ systemImage: "tag",
+ description: Text("This tracker has no labels defined.")
+ )
+ }
+ } else {
+ List {
+ ForEach(viewModel.trackerLabels) { label in
+ TicketListLabelToggleRow(
+ label: label,
+ isApplied: ticket.labels.contains(where: { $0.id == label.id }),
+ isLoading: viewModel.isPerformingAction
+ ) { shouldApply in
+ Task {
+ if shouldApply {
+ await viewModel.labelTicket(ticket, label: label)
+ } else {
+ await viewModel.unlabelTicket(ticket, label: label)
+ }
+ }
+ }
+ }
+ }
+ }
+ } else {
+ ContentUnavailableView(
+ "Ticket Unavailable",
+ systemImage: "ticket",
+ description: Text("This ticket is no longer in the current list.")
+ )
+ }
+ }
+ .navigationTitle("Labels")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Done") { dismiss() }
+ }
+ }
+ .task {
+ if viewModel.trackerLabels.isEmpty {
+ await viewModel.loadTrackerLabels()
+ }
+ }
+ }
+ }
+}
+
+private struct TicketListLabelToggleRow: View {
+ let label: TicketLabel
+ let isApplied: Bool
+ let isLoading: Bool
+ let onToggle: (Bool) -> Void
+
+ var body: some View {
+ Button {
+ onToggle(!isApplied)
+ } label: {
+ HStack {
+ LabelPill(label: label)
+ Spacer()
+ if isApplied {
+ Image(systemName: "checkmark")
+ .foregroundStyle(.blue)
+ }
+ }
+ }
+ .disabled(isLoading)
+ }
+}
+
// MARK: - Ticket Row
private struct TicketRowView: View {