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}