krz/hutch

an ios client for sourcehut

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

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