krz/hutch

an ios client for sourcehut

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

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