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