krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.13.0: Hutch/Views/Tickets/TicketListView.swift · raw
1import SwiftUI
2
3struct TicketListView: View {
4 let onTrackerUpdated: (TrackerSummary) -> Void
5 let onTrackerDeleted: (TrackerSummary) -> Void
6
7 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
8 @Environment(AppState.self) private var appState
9 @Environment(\.dismiss) private var dismiss
10 @State private var tracker: TrackerSummary
11 @State private var viewModel: TicketListViewModel?
12 @State private var trackerManagementViewModel: TrackerManagementViewModel?
13 @State private var showCreateTicketSheet = false
14 @State private var createdTicket: TicketSummary?
15 @State private var labelEditorTicket: LabelEditorTicket?
16 @State private var showTrackerEditor = false
17 @State private var showTrackerACLs = false
18 @State private var showTrackerLabels = false
19 @State private var showDeleteTrackerConfirmation = false
20
21 private var isOwnedByCurrentUser: Bool {
22 guard let currentUser = appState.currentUser else { return false }
23 return normalizedUsername(currentUser.username) == normalizedUsername(tracker.owner.canonicalName)
24 }
25
26 init(
27 tracker: TrackerSummary,
28 onTrackerUpdated: @escaping (TrackerSummary) -> Void = { _ in /* no-op: default for callers that don't handle this event */ },
29 onTrackerDeleted: @escaping (TrackerSummary) -> Void = { _ in /* no-op: default for callers that don't handle this event */ }
30 ) {
31 self._tracker = State(initialValue: tracker)
32 self.onTrackerUpdated = onTrackerUpdated
33 self.onTrackerDeleted = onTrackerDeleted
34 }
35
36 var body: some View {
37 Group {
38 if let viewModel {
39 listContent(viewModel)
40 } else {
41 SRHTLoadingStateView(message: "Loading tickets…")
42 }
43 }
44 .navigationTitle(tracker.name)
45 .navigationBarTitleDisplayMode(.inline)
46 .toolbar {
47 ToolbarItemGroup(placement: .topBarTrailing) {
48 SRHTShareButton(
49 url: SRHTWebURL.tracker(
50 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
51 trackerName: tracker.name
52 ),
53 target: .tracker
54 ) {
55 Image(systemName: "square.and.arrow.up")
56 }
57
58 if viewModel != nil {
59 Button {
60 showCreateTicketSheet = true
61 } label: {
62 Image(systemName: "plus")
63 }
64 .accessibilityLabel("Create ticket")
65
66 if isOwnedByCurrentUser {
67 trackerActionsMenu
68 }
69 }
70 }
71 }
72 .sheet(isPresented: $showCreateTicketSheet) {
73 if let viewModel {
74 CreateTicketSheet(viewModel: viewModel) { ticket in
75 showCreateTicketSheet = false
76 createdTicket = ticket
77 }
78 }
79 }
80 .sheet(item: $labelEditorTicket) { item in
81 if let viewModel {
82 TicketLabelsSheet(
83 ticketId: item.id,
84 viewModel: viewModel
85 )
86 .presentationDetents([.medium])
87 }
88 }
89 .sheet(isPresented: $showTrackerEditor) {
90 if let trackerManagementViewModel {
91 TrackerEditorSheet(
92 title: "Update Tracker",
93 confirmationTitle: "Save",
94 isSaving: trackerManagementViewModel.isSavingTracker,
95 error: trackerManagementViewModel.error,
96 initialName: tracker.name,
97 initialDescription: tracker.description ?? "",
98 initialVisibility: tracker.visibility
99 ) { name, description, visibility in
100 if let updatedTracker = await trackerManagementViewModel.updateTracker(
101 name: name,
102 description: description,
103 visibility: visibility
104 ) {
105 tracker = updatedTracker
106 onTrackerUpdated(updatedTracker)
107 return true
108 }
109 return false
110 }
111 }
112 }
113 .sheet(isPresented: $showTrackerACLs) {
114 if let trackerManagementViewModel {
115 TrackerACLManagementSheet(viewModel: trackerManagementViewModel)
116 .presentationDetents([.large])
117 }
118 }
119 .sheet(isPresented: $showTrackerLabels) {
120 if let trackerManagementViewModel {
121 TrackerLabelManagementSheet(viewModel: trackerManagementViewModel)
122 .presentationDetents([.large])
123 }
124 }
125 .alert("Delete Tracker?", isPresented: $showDeleteTrackerConfirmation) {
126 Button("Cancel", role: .cancel) {
127 // no-op: .cancel role handles alert dismissal
128 }
129 Button("Delete", role: .destructive) {
130 guard let trackerManagementViewModel else { return }
131 Task {
132 let didDelete = await trackerManagementViewModel.deleteTracker()
133 if didDelete {
134 onTrackerDeleted(tracker)
135 dismiss()
136 }
137 }
138 }
139 } message: {
140 Text("“\(tracker.name)” will be permanently deleted.")
141 }
142 .navigationDestination(isPresented: Binding(
143 get: { createdTicket != nil },
144 set: { isPresented in
145 if !isPresented {
146 createdTicket = nil
147 }
148 }
149 )) {
150 if let createdTicket {
151 TicketDetailView(
152 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
153 trackerName: tracker.name,
154 trackerId: tracker.id,
155 trackerRid: tracker.rid,
156 ticketId: createdTicket.id
157 )
158 }
159 }
160 .task {
161 if viewModel == nil {
162 let vm = TicketListViewModel(
163 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
164 trackerName: tracker.name,
165 trackerId: tracker.id,
166 trackerRid: tracker.rid,
167 client: appState.client
168 )
169 viewModel = vm
170 trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client)
171 await vm.loadTickets()
172 }
173 }
174 }
175
176 @ViewBuilder
177 private func listContent(_ viewModel: TicketListViewModel) -> some View {
178 @Bindable var vm = viewModel
179
180 List {
181 // Filter picker
182 Section {
183 Picker("Filter", selection: $vm.filter) {
184 ForEach(TicketFilter.allCases, id: \.self) { filter in
185 Text(filter.rawValue).tag(filter)
186 }
187 }
188 .pickerStyle(.segmented)
189 .listRowBackground(Color.clear)
190 .listRowInsets(EdgeInsets())
191 }
192
193 // Tickets
194 ForEach(viewModel.filteredTickets) { ticket in
195 NavigationLink {
196 TicketDetailView(
197 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
198 trackerName: tracker.name,
199 trackerId: tracker.id,
200 trackerRid: tracker.rid,
201 ticketId: ticket.id
202 )
203 } label: {
204 TicketRowView(ticket: ticket)
205 }
206 .swipeActions(edge: .leading, allowsFullSwipe: true) {
207 if swipeActionsEnabled {
208 ticketLeadingSwipeAction(ticket, viewModel: viewModel)
209 }
210 }
211 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
212 if swipeActionsEnabled {
213 ticketTrailingSwipeActions(ticket, viewModel: viewModel)
214 }
215 }
216 .task {
217 await viewModel.loadMoreIfNeeded(currentItem: ticket)
218 }
219 }
220
221 if viewModel.isLoadingMore {
222 HStack {
223 Spacer()
224 ProgressView()
225 Spacer()
226 }
227 .listRowSeparator(.hidden)
228 }
229 }
230 .listStyle(.plain)
231 .searchable(
232 text: $vm.searchText,
233 placement: .navigationBarDrawer(displayMode: .always),
234 prompt: "Search tickets"
235 )
236 .overlay {
237 if viewModel.isLoading, viewModel.tickets.isEmpty {
238 SRHTLoadingStateView(message: "Loading tickets…")
239 } else if let error = viewModel.error, viewModel.tickets.isEmpty {
240 SRHTErrorStateView(
241 title: "Couldn't Load Tickets",
242 message: error,
243 retryAction: { await viewModel.loadTickets() }
244 )
245 } else if !viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
246 viewModel.filteredTickets.isEmpty {
247 ContentUnavailableView.search(text: viewModel.searchText)
248 } else if viewModel.filteredTickets.isEmpty, viewModel.error == nil {
249 ContentUnavailableView(
250 "No Tickets",
251 systemImage: "ticket",
252 description: Text("No \(viewModel.filter.rawValue.lowercased()) tickets found.")
253 )
254 }
255 }
256 .connectivityOverlay(hasContent: !viewModel.filteredTickets.isEmpty) {
257 await viewModel.loadTickets()
258 }
259 .srhtErrorBanner(error: $vm.error)
260 .refreshable {
261 await viewModel.loadTickets()
262 }
263 }
264
265 private var trackerActionsMenu: some View {
266 Menu {
267 Button {
268 showTrackerEditor = true
269 } label: {
270 Label("Update Tracker", systemImage: "pencil")
271 }
272
273 Button {
274 showTrackerACLs = true
275 } label: {
276 Label("Manage ACLs", systemImage: "person.2")
277 }
278
279 Button {
280 showTrackerLabels = true
281 } label: {
282 Label("Manage Labels", systemImage: "tag")
283 }
284
285 Button(role: .destructive) {
286 showDeleteTrackerConfirmation = true
287 } label: {
288 Label("Delete Tracker", systemImage: "trash")
289 }
290 } label: {
291 Image(systemName: "ellipsis.circle")
292 }
293 .accessibilityLabel("Tracker actions")
294 }
295
296 @ViewBuilder
297 private func ticketLeadingSwipeAction(
298 _ ticket: TicketSummary,
299 viewModel: TicketListViewModel
300 ) -> 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 {
344 let isAssigned = ticket.assignees.contains { assignee in
345 matchesAssignee(assignee, user: currentUser)
346 }
347
348 if isAssigned {
349 Button {
350 Task {
351 await viewModel.unassignFromMe(ticket: ticket, user: currentUser)
352 }
353 } label: {
354 Label("Unassign Me", systemImage: "person.badge.minus")
355 }
356 .tint(.orange)
357 } else {
358 Button {
359 Task {
360 await viewModel.assignToMe(ticket: ticket, user: currentUser)
361 }
362 } label: {
363 Label("Assign Me", systemImage: "person.badge.plus")
364 }
365 .tint(.cyan)
366 }
367 }
368 }
369
370 private func matchesAssignee(_ entity: Entity, user: User) -> Bool {
371 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
372 let userCanonical = normalizedCanonicalName(user.canonicalName)
373 if assigneeCanonical == userCanonical {
374 return true
375 }
376 return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
377 }
378
379 private func normalizedCanonicalName(_ value: String) -> String {
380 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
381 if trimmed.hasPrefix("~") {
382 return trimmed
383 }
384 return "~\(trimmed)"
385 }
386
387 private func normalizedUsername(_ value: String) -> String {
388 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
389 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
390 }
391}
392
393private struct LabelEditorTicket: Identifiable {
394 let id: Int
395}
396
397private struct CreateTicketSheet: View {
398 let viewModel: TicketListViewModel
399 let onCreated: (TicketSummary) -> Void
400
401 @Environment(\.dismiss) private var dismiss
402 @State private var subject = ""
403 @State private var descriptionText = ""
404
405 var body: some View {
406 NavigationStack {
407 Form {
408 Section("Ticket Details") {
409 TextField("Title", text: $subject)
410 TextField("Description (optional)", text: $descriptionText, axis: .vertical)
411 .lineLimit(6...12)
412 }
413 }
414 .navigationTitle("New Ticket")
415 .navigationBarTitleDisplayMode(.inline)
416 .toolbar {
417 ToolbarItem(placement: .cancellationAction) {
418 Button("Cancel") { dismiss() }
419 }
420 ToolbarItem(placement: .confirmationAction) {
421 Button {
422 Task {
423 if let ticket = await viewModel.createTicket(subject: subject, body: descriptionText) {
424 onCreated(ticket)
425 }
426 }
427 } label: {
428 if viewModel.isCreatingTicket {
429 ProgressView()
430 .controlSize(.small)
431 } else {
432 Text("Create Ticket")
433 }
434 }
435 .disabled(subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTicket)
436 }
437 }
438 }
439 }
440}
441
442private struct TicketLabelsSheet: View {
443 let ticketId: Int
444 let viewModel: TicketListViewModel
445
446 @Environment(\.dismiss) private var dismiss
447
448 var body: some View {
449 NavigationStack {
450 Group {
451 if let ticket = viewModel.ticket(withId: ticketId) {
452 if viewModel.trackerLabels.isEmpty {
453 if viewModel.isPerformingAction {
454 ProgressView()
455 } else {
456 ContentUnavailableView(
457 "No Labels",
458 systemImage: "tag",
459 description: Text("This tracker has no labels defined.")
460 )
461 }
462 } else {
463 List {
464 ForEach(viewModel.trackerLabels) { label in
465 TicketListLabelToggleRow(
466 label: label,
467 isApplied: ticket.labels.contains(where: { $0.id == label.id }),
468 isLoading: viewModel.isPerformingAction
469 ) { shouldApply in
470 Task {
471 if shouldApply {
472 await viewModel.labelTicket(ticket, label: label)
473 } else {
474 await viewModel.unlabelTicket(ticket, label: label)
475 }
476 }
477 }
478 }
479 }
480 }
481 } else {
482 ContentUnavailableView(
483 "Ticket Unavailable",
484 systemImage: "ticket",
485 description: Text("This ticket is no longer in the current list.")
486 )
487 }
488 }
489 .navigationTitle("Labels")
490 .navigationBarTitleDisplayMode(.inline)
491 .toolbar {
492 ToolbarItem(placement: .cancellationAction) {
493 Button("Done") { dismiss() }
494 }
495 }
496 .task {
497 if viewModel.trackerLabels.isEmpty {
498 await viewModel.loadTrackerLabels()
499 }
500 }
501 }
502 }
503}
504
505private struct TicketListLabelToggleRow: View {
506 let label: TicketLabel
507 let isApplied: Bool
508 let isLoading: Bool
509 let onToggle: (Bool) -> Void
510
511 var body: some View {
512 Button {
513 onToggle(!isApplied)
514 } label: {
515 HStack {
516 LabelPill(label: label)
517 Spacer()
518 if isApplied {
519 Image(systemName: "checkmark")
520 .foregroundStyle(.blue)
521 }
522 }
523 }
524 .disabled(isLoading)
525 }
526}
527
528// MARK: - Ticket Row
529
530private struct TicketRowView: View {
531 let ticket: TicketSummary
532
533 var body: some View {
534 VStack(alignment: .leading, spacing: 6) {
535 HStack(alignment: .top) {
536 TicketStatusIcon(status: ticket.status)
537 .frame(width: 20)
538
539 VStack(alignment: .leading, spacing: 2) {
540 Text("#\(ticket.id)")
541 .font(.caption.monospaced())
542 .foregroundStyle(.secondary)
543 + Text(" ")
544 + Text(ticket.title)
545 .font(.subheadline)
546 }
547
548 Spacer()
549 }
550
551 HStack(spacing: 8) {
552 Text(ticket.submitter.canonicalName)
553 .font(.caption)
554 .foregroundStyle(.secondary)
555
556 Spacer()
557
558 Text(ticket.created.relativeDescription)
559 .font(.caption)
560 .foregroundStyle(.tertiary)
561 }
562
563 if !ticket.labels.isEmpty {
564 FlowLayout(spacing: 4) {
565 ForEach(ticket.labels) { label in
566 LabelPill(label: label)
567 }
568 }
569 }
570
571 if !ticket.assignees.isEmpty {
572 HStack(spacing: 4) {
573 Image(systemName: "person.fill")
574 .font(.caption2)
575 .foregroundStyle(.secondary)
576 Text(ticket.assignees.map(\.canonicalName).joined(separator: ", "))
577 .font(.caption)
578 .foregroundStyle(.secondary)
579 .lineLimit(1)
580 }
581 }
582 }
583 .padding(.vertical, 2)
584 }
585}
586
587// MARK: - Ticket Status Icon
588
589struct TicketStatusIcon: View {
590 let status: TicketStatus
591
592 var body: some View {
593 Image(systemName: iconName)
594 .foregroundStyle(color)
595 }
596
597 private var iconName: String {
598 switch status {
599 case .reported: "circle"
600 case .confirmed: "circle.inset.filled"
601 case .inProgress: "arrow.trianglehead.2.clockwise.rotate.90"
602 case .pending: "clock.fill"
603 case .resolved: "checkmark.circle.fill"
604 }
605 }
606
607 private var color: Color {
608 switch status {
609 case .reported: .gray
610 case .confirmed: .blue
611 case .inProgress: .yellow
612 case .pending: .orange
613 case .resolved: .green
614 }
615 }
616}
617
618// MARK: - Label Pill
619
620struct LabelPill: View {
621 let label: TicketLabel
622
623 var body: some View {
624 Text(label.name)
625 .font(.caption2.weight(.medium))
626 .padding(.horizontal, 6)
627 .padding(.vertical, 2)
628 .background(backgroundColor)
629 .foregroundStyle(foregroundColor)
630 .clipShape(Capsule())
631 }
632
633 private var backgroundColor: Color {
634 Color(hex: label.backgroundColor) ?? .gray.opacity(0.2)
635 }
636
637 private var foregroundColor: Color {
638 Color(hex: label.foregroundColor) ?? .primary
639 }
640}
641
642// MARK: - Color from hex string
643
644extension Color {
645 init?(hex: String) {
646 var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines)
647 if hexString.hasPrefix("#") {
648 hexString.removeFirst()
649 }
650
651 guard hexString.count == 6,
652 let hexNumber = UInt64(hexString, radix: 16) else {
653 return nil
654 }
655
656 let r = Double((hexNumber & 0xFF0000) >> 16) / 255
657 let g = Double((hexNumber & 0x00FF00) >> 8) / 255
658 let b = Double(hexNumber & 0x0000FF) / 255
659
660 self.init(red: r, green: g, blue: b)
661 }
662
663 /// Returns a `#rrggbb` hex string for this color.
664 var hexString: String {
665 let resolved = resolve(in: .init())
666 let r = Int(max(0, min(1, resolved.red)) * 255)
667 let g = Int(max(0, min(1, resolved.green)) * 255)
668 let b = Int(max(0, min(1, resolved.blue)) * 255)
669 return String(format: "#%02x%02x%02x", r, g, b)
670 }
671}
672
673// MARK: - Flow Layout (for label pills)
674
675struct FlowLayout: Layout {
676 var spacing: CGFloat = 4
677
678 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
679 let result = layoutSubviews(proposal: proposal, subviews: subviews)
680 return result.size
681 }
682
683 func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
684 let result = layoutSubviews(proposal: proposal, subviews: subviews)
685 for (index, position) in result.positions.enumerated() {
686 subviews[index].place(
687 at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y),
688 proposal: ProposedViewSize(result.sizes[index])
689 )
690 }
691 }
692
693 private struct LayoutResult {
694 var positions: [CGPoint]
695 var sizes: [CGSize]
696 var size: CGSize
697 }
698
699 private func layoutSubviews(proposal: ProposedViewSize, subviews: Subviews) -> LayoutResult {
700 let maxWidth = proposal.width ?? .infinity
701 var positions: [CGPoint] = []
702 var sizes: [CGSize] = []
703 var currentX: CGFloat = 0
704 var currentY: CGFloat = 0
705 var lineHeight: CGFloat = 0
706 var totalHeight: CGFloat = 0
707 var totalWidth: CGFloat = 0
708
709 for subview in subviews {
710 let size = subview.sizeThatFits(.unspecified)
711 sizes.append(size)
712
713 if currentX + size.width > maxWidth, currentX > 0 {
714 currentX = 0
715 currentY += lineHeight + spacing
716 lineHeight = 0
717 }
718
719 positions.append(CGPoint(x: currentX, y: currentY))
720 lineHeight = max(lineHeight, size.height)
721 currentX += size.width + spacing
722 totalWidth = max(totalWidth, currentX - spacing)
723 totalHeight = currentY + lineHeight
724 }
725
726 return LayoutResult(
727 positions: positions,
728 sizes: sizes,
729 size: CGSize(width: totalWidth, height: totalHeight)
730 )
731 }
732}