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