krz/hutch

an ios client for sourcehut

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

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