Commit 2790ec0678

2790ec06788e63b96e0bb6b1db504a026ffd22e8

parent: 2a56dd7f00

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 00:16 UTC

feat: improve ticket list actions and metadata visibility

Implements: https://todo.sr.ht/~ccleberg/hutch/20
Implements: https://todo.sr.ht/~ccleberg/hutch/21

Layout: unified · split

Hutch/Views/Tickets/TicketListView.swift +71 −48
@@ -205,12 +205,13 @@ struct TicketListView: View {
205 } 205 }
206 .swipeActions(edge: .leading, allowsFullSwipe: true) { 206 .swipeActions(edge: .leading, allowsFullSwipe: true) {
207 if swipeActionsEnabled { 207 if swipeActionsEnabled {
208 ticketLeadingSwipeAction(ticket, viewModel: viewModel) 208 ticketAssignSwipeAction(ticket, viewModel: viewModel)
209 } 209 }
210 } 210 }
211 .swipeActions(edge: .trailing, allowsFullSwipe: false) { 211 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
212 if swipeActionsEnabled { 212 if swipeActionsEnabled {
213 ticketTrailingSwipeActions(ticket, viewModel: viewModel) 213 ticketStatusSwipeAction(ticket, viewModel: viewModel)
214 ticketLabelSwipeAction(ticket, viewModel: viewModel)
214 } 215 }
215 } 216 }
216 .task { 217 .task {
@@ -294,52 +295,10 @@ struct TicketListView: View {
294 } 295 }
295 296
296 @ViewBuilder 297 @ViewBuilder
297 private func ticketLeadingSwipeAction( 298 private func ticketAssignSwipeAction(
298 _ ticket: TicketSummary, 299 _ ticket: TicketSummary,
299 viewModel: TicketListViewModel 300 viewModel: TicketListViewModel
300 ) -> some View { 301 ) -> some View {
301 if ticket.status.isOpen {
302 Button {
303 Task {
304 await resolveTicket(ticket, viewModel: viewModel)
305 }
306 } label: {
307 Label("Resolve", systemImage: "checkmark.circle")
308 }
309 .tint(.green)
310 } else {
311 Button {
312 Task {
313 await reopenTicket(ticket, viewModel: viewModel)
314 }
315 } label: {
316 Label("Reopen", systemImage: "arrow.uturn.backward")
317 }
318 .tint(.blue)
319 }
320 }
321
322 private func resolveTicket(_ ticket: TicketSummary, viewModel: TicketListViewModel) async {
323 await viewModel.resolveTicket(ticket)
324 }
325
326 private func reopenTicket(_ ticket: TicketSummary, viewModel: TicketListViewModel) async {
327 await viewModel.reopenTicket(ticket)
328 }
329
330 @ViewBuilder
331 private func ticketTrailingSwipeActions(
332 _ ticket: TicketSummary,
333 viewModel: TicketListViewModel
334 ) -> some View {
335 Button {
336 labelEditorTicket = LabelEditorTicket(id: ticket.id)
337 Task { await viewModel.loadTrackerLabels() }
338 } label: {
339 Label("Edit Labels", systemImage: "tag")
340 }
341 .tint(.purple)
342
343 if let currentUser = appState.currentUser { 302 if let currentUser = appState.currentUser {
344 let isAssigned = ticket.assignees.contains { assignee in 303 let isAssigned = ticket.assignees.contains { assignee in
345 matchesAssignee(assignee, user: currentUser) 304 matchesAssignee(assignee, user: currentUser)
@@ -367,6 +326,42 @@ struct TicketListView: View {
367 } 326 }
368 } 327 }
369 328
329 @ViewBuilder
330 private func ticketStatusSwipeAction(
331 _ ticket: TicketSummary,
332 viewModel: TicketListViewModel
333 ) -> some View {
334 if ticket.status.isOpen {
335 Button {
336 Task { await viewModel.resolveTicket(ticket) }
337 } label: {
338 Label("Close", systemImage: "xmark.circle")
339 }
340 .tint(.red)
341 } else {
342 Button {
343 Task { await viewModel.reopenTicket(ticket) }
344 } label: {
345 Label("Reopen", systemImage: "arrow.uturn.backward")
346 }
347 .tint(.blue)
348 }
349 }
350
351 @ViewBuilder
352 private func ticketLabelSwipeAction(
353 _ ticket: TicketSummary,
354 viewModel: TicketListViewModel
355 ) -> some View {
356 Button {
357 labelEditorTicket = LabelEditorTicket(id: ticket.id)
358 Task { await viewModel.loadTrackerLabels() }
359 } label: {
360 Label("Labels", systemImage: "tag")
361 }
362 .tint(.purple)
363 }
364
370 private func matchesAssignee(_ entity: Entity, user: User) -> Bool { 365 private func matchesAssignee(_ entity: Entity, user: User) -> Bool {
371 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) 366 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
372 let userCanonical = normalizedCanonicalName(user.canonicalName) 367 let userCanonical = normalizedCanonicalName(user.canonicalName)
@@ -546,6 +541,8 @@ private struct TicketRowView: View {
546 } 541 }
547 542
548 Spacer() 543 Spacer()
544
545 TicketStatusBadge(status: ticket.status)
549 } 546 }
550 547
551 HStack(spacing: 8) { 548 HStack(spacing: 8) {
@@ -584,6 +581,32 @@ private struct TicketRowView: View {
584 } 581 }
585} 582}
586 583
584// MARK: - Ticket Status Badge
585
586private struct TicketStatusBadge: View {
587 let status: TicketStatus
588
589 var body: some View {
590 Text(status.displayName)
591 .font(.caption2.weight(.semibold))
592 .padding(.horizontal, 6)
593 .padding(.vertical, 2)
594 .foregroundStyle(color)
595 .background(color.opacity(0.12))
596 .clipShape(Capsule())
597 }
598
599 private var color: Color {
600 switch status {
601 case .reported: .gray
602 case .confirmed: .blue
603 case .inProgress: .yellow
604 case .pending: .orange
605 case .resolved: .green
606 }
607 }
608}
609
587// MARK: - Ticket Status Icon 610// MARK: - Ticket Status Icon
588 611
589struct TicketStatusIcon: View { 612struct TicketStatusIcon: View {
@@ -622,9 +645,9 @@ struct LabelPill: View {
622 645
623 var body: some View { 646 var body: some View {
624 Text(label.name) 647 Text(label.name)
625 .font(.caption2.weight(.medium)) 648 .font(.caption.weight(.medium))
626 .padding(.horizontal, 6) 649 .padding(.horizontal, 8)
627 .padding(.vertical, 2) 650 .padding(.vertical, 3)
628 .background(backgroundColor) 651 .background(backgroundColor)
629 .foregroundStyle(foregroundColor) 652 .foregroundStyle(foregroundColor)
630 .clipShape(Capsule()) 653 .clipShape(Capsule())