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