krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.3.1: 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}