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