krz/hutch

an ios client for sourcehut

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

v2.15.1: Hutch/Views/Tickets/TicketDetailView.swift · raw

  1import SwiftUI
  2import WebKit
  3
  4struct TicketDetailView: View {
  5    let ownerUsername: String
  6    let trackerName: String
  7    let trackerId: Int
  8    let trackerRid: String
  9    let ticketId: Int
 10
 11    @Environment(AppState.self) private var appState
 12    @Environment(\.colorScheme) private var colorScheme
 13    @State private var viewModel: TicketDetailViewModel?
 14
 15    // Sheet state
 16    @State private var showResolveSheet = false
 17    @State private var showAssignSheet = false
 18    @State private var showLabelsSheet = false
 19    @State private var isOpeningTracker = false
 20
 21    // Comment composer mode
 22    @State private var commentMode: CommentMode = .write
 23
 24    private var isOwnedByCurrentUser: Bool {
 25        guard let currentUser = appState.currentUser else { return false }
 26        return normalizedUsername(currentUser.username) == normalizedUsername(ownerUsername)
 27    }
 28
 29    private enum CommentMode: String, CaseIterable {
 30        case write = "Write"
 31        case preview = "Preview"
 32    }
 33
 34    var body: some View {
 35        Group {
 36            if let viewModel {
 37                detailContent(viewModel)
 38            } else {
 39                SRHTLoadingStateView(message: "Loading ticket…")
 40            }
 41        }
 42        .navigationTitle("#\(ticketId)")
 43        .navigationBarTitleDisplayMode(.inline)
 44        .toolbar {
 45            ToolbarItemGroup(placement: .topBarTrailing) {
 46                SRHTShareButton(url: SRHTWebURL.ticket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId), target: .ticket) {
 47                    Image(systemName: "square.and.arrow.up")
 48                }
 49
 50                if let viewModel, viewModel.ticket != nil, isOwnedByCurrentUser {
 51                    actionsMenu(viewModel)
 52                }
 53            }
 54        }
 55        .task {
 56            if viewModel == nil {
 57                let vm = TicketDetailViewModel(
 58                    ownerUsername: ownerUsername,
 59                    trackerName: trackerName,
 60                    trackerId: trackerId,
 61                    trackerRid: trackerRid,
 62                    ticketId: ticketId,
 63                    client: appState.client
 64                )
 65                viewModel = vm
 66                await vm.loadTicket()
 67            }
 68        }
 69    }
 70
 71    // MARK: - Actions Menu
 72
 73    @ViewBuilder
 74    private func actionsMenu(_ viewModel: TicketDetailViewModel) -> some View {
 75        Menu {
 76            if let ticket = viewModel.ticket {
 77                if ticket.status == .resolved {
 78                    Button {
 79                        Task {
 80                            await viewModel.updateStatus(status: .reported)
 81                        }
 82                    } label: {
 83                        SwiftUI.Label("Reopen", systemImage: "arrow.uturn.backward")
 84                    }
 85                } else {
 86                    Button {
 87                        showResolveSheet = true
 88                    } label: {
 89                        SwiftUI.Label("Resolve", systemImage: "checkmark.circle")
 90                    }
 91                }
 92            }
 93
 94            Button {
 95                showAssignSheet = true
 96            } label: {
 97                SwiftUI.Label("Manage Assignees", systemImage: "person.badge.plus")
 98            }
 99
100            Button {
101                showLabelsSheet = true
102                Task { await viewModel.loadTrackerLabels() }
103            } label: {
104                SwiftUI.Label("Manage Labels", systemImage: "tag")
105            }
106        } label: {
107            Image(systemName: "ellipsis.circle")
108        }
109        .accessibilityLabel("Ticket actions")
110        .sheet(isPresented: $showResolveSheet) {
111            ResolveSheet(viewModel: viewModel, isPresented: $showResolveSheet)
112                .presentationDetents([.medium])
113        }
114        .sheet(isPresented: $showAssignSheet) {
115            AssignSheet(viewModel: viewModel, isPresented: $showAssignSheet)
116                .presentationDetents([.medium])
117        }
118        .sheet(isPresented: $showLabelsSheet) {
119            LabelsSheet(viewModel: viewModel, isPresented: $showLabelsSheet)
120                .presentationDetents([.medium])
121        }
122    }
123
124    // MARK: - Detail Content
125
126    @ViewBuilder
127    private func detailContent(_ viewModel: TicketDetailViewModel) -> some View {
128        @Bindable var vm = viewModel
129
130        if viewModel.isLoading, viewModel.ticket == nil {
131            SRHTLoadingStateView(message: "Loading ticket…")
132        } else if let error = viewModel.error, viewModel.ticket == nil {
133            SRHTErrorStateView(
134                title: "Couldn't Load Ticket",
135                message: error,
136                retryAction: { await viewModel.loadTicket() }
137            )
138        } else if let ticket = viewModel.ticket {
139            ScrollView {
140                VStack(alignment: .leading, spacing: 0) {
141                    // Header
142                    ticketHeader(ticket, viewModel: viewModel)
143
144                    Divider()
145                        .padding(.vertical, 12)
146
147                    // Description
148                    if let description = ticket.description, !description.isEmpty {
149                        MarkdownContentView(markdown: description)
150                            .padding(.horizontal)
151                            .padding(.bottom, 16)
152
153                        Divider()
154                            .padding(.bottom, 12)
155                    }
156
157                    // Event timeline
158                    if !viewModel.events.isEmpty {
159                        Text("Activity")
160                            .font(.headline)
161                            .padding(.horizontal)
162                            .padding(.bottom, 8)
163
164                        LazyVStack(alignment: .leading, spacing: 0) {
165                            ForEach(viewModel.events) { event in
166                                EventRow(
167                                    event: event,
168                                    ticketSubmitter: viewModel.ticket?.submitter.canonicalName,
169                                    ticketAssignees: viewModel.ticket?.assignees.map { $0.canonicalName }
170                                )
171                                if event.id != viewModel.events.last?.id {
172                                    Divider()
173                                        .padding(.leading, 40)
174                                }
175                            }
176                        }
177
178                        Divider()
179                            .padding(.vertical, 12)
180                    }
181
182                    // Comment input
183                    commentInput(viewModel)
184                }
185            }
186            .srhtErrorBanner(error: $vm.error)
187            .refreshable {
188                await viewModel.loadTicket()
189            }
190        }
191    }
192
193    // MARK: - Header
194
195    @ViewBuilder
196    private func ticketHeader(_ ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View {
197        VStack(alignment: .leading, spacing: 8) {
198            HStack(alignment: .top, spacing: 12) {
199                Text(ticket.title)
200                    .font(.title3.weight(.semibold))
201
202                Spacer(minLength: 12)
203
204                if isOwnedByCurrentUser {
205                    assignToMeButton(ticket: ticket, viewModel: viewModel)
206                }
207            }
208
209            HStack(spacing: 8) {
210                TicketStatusIcon(status: ticket.status)
211                Text(ticket.status.displayName)
212                    .font(.subheadline.weight(.medium))
213
214                if ticket.status == .resolved, let resolution = ticket.resolution {
215                    Text("(\(resolution.displayName))")
216                        .font(.subheadline)
217                        .foregroundStyle(.secondary)
218                }
219            }
220
221            HStack(spacing: 4) {
222                Text("Opened by")
223                    .foregroundStyle(.secondary)
224                Text(ticket.submitter.canonicalName)
225                    .fontWeight(.medium)
226                Text(ticket.created.relativeDescription)
227                    .foregroundStyle(.tertiary)
228            }
229            .font(.caption)
230
231            Button {
232                openTracker()
233            } label: {
234                HStack(spacing: 6) {
235                    Label("\(ownerUsername)/\(trackerName)", systemImage: "checklist")
236                        .font(.caption.weight(.medium))
237                    if isOpeningTracker {
238                        ProgressView()
239                            .controlSize(.small)
240                    } else {
241                        Image(systemName: "arrow.up.right")
242                            .font(.caption2)
243                            .foregroundStyle(.tertiary)
244                    }
245                }
246                .foregroundStyle(.secondary)
247            }
248            .buttonStyle(.plain)
249            .disabled(isOpeningTracker)
250
251            if !ticket.assignees.isEmpty {
252                HStack(spacing: 4) {
253                    Image(systemName: "person.fill")
254                        .font(.caption2)
255                        .foregroundStyle(.secondary)
256                    Text(ticket.assignees.map(\.canonicalName).joined(separator: ", "))
257                        .font(.caption)
258                        .foregroundStyle(.secondary)
259                }
260            }
261
262            if !ticket.labels.isEmpty {
263                FlowLayout(spacing: 4) {
264                    ForEach(ticket.labels) { label in
265                        LabelPill(label: label)
266                    }
267                }
268            }
269        }
270        .padding()
271    }
272
273    @ViewBuilder
274    private func assignToMeButton(ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View {
275        if let currentUser = appState.currentUser {
276            let isAssignedToCurrentUser = ticket.assignees.contains {
277                TicketDetailViewModel.matchesAssignee($0, user: currentUser)
278            }
279
280            if isAssignedToCurrentUser {
281                Label("Assigned to you", systemImage: "checkmark.circle.fill")
282                    .font(.caption.weight(.medium))
283                    .foregroundStyle(.secondary)
284                    .padding(.horizontal, 10)
285                    .padding(.vertical, 6)
286                    .background(Color(.secondarySystemFill), in: Capsule())
287            } else {
288                Button {
289                    Task {
290                        await viewModel.assignToCurrentUser(currentUser)
291                    }
292                } label: {
293                    if viewModel.isPerformingAction {
294                        ProgressView()
295                            .controlSize(.small)
296                            .frame(minWidth: 88)
297                    } else {
298                        Text("Assign to Me")
299                            .font(.caption.weight(.semibold))
300                            .frame(minWidth: 88)
301                    }
302                }
303                .buttonStyle(.borderedProminent)
304                .controlSize(.small)
305                .disabled(viewModel.isPerformingAction)
306            }
307        }
308    }
309
310    // MARK: - Comment Input
311
312    @ViewBuilder
313    private func commentInput(_ viewModel: TicketDetailViewModel) -> some View {
314        @Bindable var vm = viewModel
315
316        VStack(alignment: .leading, spacing: 8) {
317            Text("New Comment")
318                .font(.headline)
319
320            Picker("Mode", selection: $commentMode) {
321                ForEach(CommentMode.allCases, id: \.self) { mode in
322                    Text(mode.rawValue).tag(mode)
323                }
324            }
325            .pickerStyle(.segmented)
326
327            if commentMode == .write {
328                TextField("Write your comment…", text: $vm.commentText, axis: .vertical)
329                    .textFieldStyle(.roundedBorder)
330                    .lineLimit(3...8)
331            } else {
332                // Markdown preview
333                if viewModel.commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
334                    Text("Nothing to preview")
335                        .font(.subheadline)
336                        .foregroundStyle(.secondary)
337                        .frame(maxWidth: .infinity, minHeight: 80, alignment: .center)
338                        .background(Color(.secondarySystemBackground))
339                        .clipShape(RoundedRectangle(cornerRadius: 8))
340                } else {
341                    MarkdownContentView(markdown: viewModel.commentText)
342                        .frame(minHeight: 80, maxHeight: 200)
343                        .clipShape(RoundedRectangle(cornerRadius: 8))
344                }
345            }
346
347            HStack {
348                Spacer()
349                Button {
350                    Task { await viewModel.submitComment() }
351                } label: {
352                    if viewModel.isSubmitting {
353                        ProgressView()
354                            .controlSize(.small)
355                    } else {
356                        Text("Post Comment")
357                    }
358                }
359                .buttonStyle(.borderedProminent)
360                .disabled(viewModel.commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting)
361            }
362        }
363        .padding()
364    }
365
366    private func normalizedUsername(_ value: String) -> String {
367        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
368        return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
369    }
370
371    private func openTracker() {
372        guard !isOpeningTracker else { return }
373        isOpeningTracker = true
374        Task {
375            defer { isOpeningTracker = false }
376            do {
377                let tracker = try await appState.resolveTracker(owner: ownerUsername, name: trackerName)
378                appState.navigateToTracker(tracker)
379            } catch {
380                appState.presentTicketDeepLinkError()
381            }
382        }
383    }
384}
385
386// MARK: - Self-Sizing Markdown Web View
387
388/// Renders markdown as HTML in a WKWebView that auto-sizes its height to
389/// fit the rendered content. Reuses the same `markdownToHTML` converter and
390/// styling as the README renderer.
391private struct MarkdownContentView: View {
392    let markdown: String
393    @Environment(\.colorScheme) private var colorScheme
394    @State private var renderedHTML: String?
395
396    var body: some View {
397        Group {
398            if let renderedHTML {
399                HTMLWebView(
400                    html: renderedHTML,
401                    colorScheme: colorScheme,
402                    style: .commentPreview
403                )
404            } else {
405                SRHTLoadingStateView(message: "Preparing content…")
406                    .frame(minHeight: 80)
407            }
408        }
409        .task(id: markdown) {
410            let html = await Task.detached(priority: .userInitiated) {
411                markdownToHTML(markdown)
412            }.value
413            guard !Task.isCancelled else { return }
414            renderedHTML = html
415        }
416    }
417}
418
419// MARK: - Event Row
420
421private struct EventRow: View {
422    let event: TicketEvent
423    let ticketSubmitter: String?
424    let ticketAssignees: [String]?
425    @State private var isShowingSystemStatusInfo = false
426
427    var body: some View {
428        ForEach(event.changes) { change in
429            HStack(alignment: .top, spacing: 12) {
430                Image(systemName: icon(for: change))
431                    .foregroundStyle(color(for: change))
432                    .frame(width: 24)
433                    .padding(.top, 2)
434
435                VStack(alignment: .leading, spacing: 4) {
436                    HStack {
437                        if kind(for: change) == .comment {
438                            Text(change.author?.canonicalName ?? "")
439                                .font(.subheadline.weight(.medium))
440                        } else {
441                            let descriptionText = description(for: change, in: event, ticketSubmitter: ticketSubmitter, ticketAssignees: ticketAssignees)
442                            if descriptionText.hasPrefix("System") {
443                                HStack(spacing: 4) {
444                                    Text(descriptionText)
445                                        .font(.subheadline)
446                                        .foregroundStyle(.secondary)
447                                    Button {
448                                        isShowingSystemStatusInfo = true
449                                    } label: {
450                                        Image(systemName: "info.circle")
451                                            .foregroundStyle(.gray)
452                                    }
453                                    .buttonStyle(.plain)
454                                }
455                            } else {
456                                Text(descriptionText)
457                                    .font(.subheadline)
458                                    .foregroundStyle(.secondary)
459                            }
460                        }
461                        Spacer()
462                        Text(event.created.relativeDescription)
463                            .font(.caption)
464                            .foregroundStyle(.tertiary)
465                    }
466                    if kind(for: change) == .comment, let text = change.text {
467                        MarkdownContentView(markdown: text)
468                    }
469                }
470            }
471            .padding(.horizontal)
472            .padding(.vertical, 8)
473            .alert("System Status Change", isPresented: $isShowingSystemStatusInfo) {
474                Button("OK", role: .cancel) {
475                    // Alert dismissal is implicit; no additional action required.
476                }
477            } message: {
478                Text("This status change was recorded automatically or without a named user attached to the event.")
479            }
480        }
481    }
482
483    private func description(for change: EventChange, in event: TicketEvent, ticketSubmitter: String? = nil, ticketAssignees: [String]? = nil) -> String {
484        let eventKind = kind(for: change)
485        let authorName: String
486
487        if let commentAuthor = event.changes.first(where: {
488            kind(for: $0) == .comment && $0.author != nil
489        })?.author?.canonicalName {
490            authorName = commentAuthor
491        } else {
492            switch eventKind {
493            case .created:
494                authorName = change.author?.canonicalName ?? ticketSubmitter ?? "Someone"
495            case .statusChange:
496                authorName = "System"
497            case .labelAdded, .labelRemoved, .labelUpdated:
498                authorName = change.labeler?.canonicalName ?? "Someone"
499            case .assigned, .unassigned:
500                authorName = change.assigner?.canonicalName ?? "Someone"
501            case .comment:
502                authorName = change.author?.canonicalName ?? "Someone"
503            case .ticketMention, .userMention:
504                authorName = change.author?.canonicalName
505                    ?? change.assigner?.canonicalName
506                    ?? change.labeler?.canonicalName
507                    ?? "Someone"
508            case .unknown:
509                authorName = change.author?.canonicalName
510                    ?? change.assigner?.canonicalName
511                    ?? change.labeler?.canonicalName
512                    ?? change.assignee?.canonicalName
513                    ?? change.mentioned?.canonicalName
514                    ?? ticketAssignees?.first
515                    ?? "Someone"
516            }
517        }
518
519        switch eventKind {
520        case .statusChange:
521            let oldStatus = change.oldStatus?.displayName ?? "unknown"
522            let newStatus = change.newStatus?.displayName ?? "unknown"
523            return "\(authorName) changed status from \(oldStatus) to \(newStatus)"
524        case .labelUpdated, .labelAdded:
525            let labelName = change.label?.name ?? "a label"
526            let verb = eventKind == .labelAdded ? "added" : "updated"
527            return "\(authorName) \(verb) label \"\(labelName)\""
528        case .labelRemoved:
529            let labelName = change.label?.name ?? "a label"
530            return "\(authorName) removed label \"\(labelName)\""
531        case .assigned:
532            let assigneeName = change.assignee?.canonicalName ?? "someone"
533            return "\(authorName) assigned \(assigneeName)"
534        case .unassigned:
535            let assigneeName = change.assignee?.canonicalName ?? "someone"
536            return "\(authorName) unassigned \(assigneeName)"
537        case .ticketMention:
538            if let ticketId = change.mentioned?.id {
539                return "\(authorName) mentioned ticket #\(ticketId)"
540            }
541            return "\(authorName) mentioned another ticket"
542        case .userMention:
543            let user = change.mentioned?.canonicalName ?? "someone"
544            return "\(authorName) mentioned \(user)"
545        case .created:
546            return "\(authorName) opened this ticket"
547        case .comment:
548            return "\(authorName) commented"
549        case .unknown:
550            return "\(authorName) updated this ticket"
551        }
552    }
553
554    private func icon(for change: EventChange) -> String {
555        switch kind(for: change) {
556        case .comment:
557            "text.bubble"
558        case .statusChange:
559            "arrow.triangle.2.circlepath"
560        case .labelAdded, .labelRemoved, .labelUpdated:
561            "tag"
562        case .assigned:
563            "person.badge.plus"
564        case .unassigned:
565            "person.badge.minus"
566        case .ticketMention, .userMention:
567            "at"
568        case .created:
569            "plus.circle"
570        case .unknown:
571            "circle.fill"
572        }
573    }
574
575    private func color(for change: EventChange) -> Color {
576        switch kind(for: change) {
577        case .comment:
578            .blue
579        case .statusChange:
580            change.newStatus == .resolved ? .green : .orange
581        case .labelAdded, .labelRemoved, .labelUpdated:
582            .purple
583        case .assigned, .unassigned:
584            .cyan
585        case .ticketMention, .userMention:
586            .indigo
587        case .created:
588            .green
589        case .unknown:
590            .gray
591        }
592    }
593
594    private func kind(for change: EventChange) -> EventKind {
595        switch change.eventType {
596        case "COMMENT", "Comment":
597            .comment
598        case "STATUS_CHANGE", "StatusChange":
599            .statusChange
600        case "LABEL_UPDATE", "LabelUpdate":
601            .labelUpdated
602        case "LABEL_ADDED", "LabelAdded":
603            .labelAdded
604        case "LABEL_REMOVED", "LabelRemoved":
605            .labelRemoved
606        case "ASSIGNMENT", "Assignment", "ASSIGNED_USER", "AssignedUser":
607            .assigned
608        case "UNASSIGNED_USER", "UnassignedUser":
609            .unassigned
610        case "TICKET_MENTION", "TicketMention":
611            .ticketMention
612        case "USER_MENTION", "UserMention":
613            .userMention
614        case "CREATED", "Created":
615            .created
616        default:
617            .unknown
618        }
619    }
620
621    private enum EventKind: Equatable {
622        case comment
623        case statusChange
624        case labelUpdated
625        case labelAdded
626        case labelRemoved
627        case assigned
628        case unassigned
629        case ticketMention
630        case userMention
631        case created
632        case unknown
633    }
634}
635
636// MARK: - Resolve Sheet
637
638private struct ResolveSheet: View {
639    let viewModel: TicketDetailViewModel
640    @Binding var isPresented: Bool
641    @State private var selectedResolution: TicketResolution = .fixed
642
643    private static let resolutionOptions: [TicketResolution] = [
644        .closed, .fixed, .implemented, .wontFix,
645        .byDesign, .invalid, .duplicate, .notOurBug
646    ]
647
648    var body: some View {
649        NavigationStack {
650            Form {
651                Section("Resolution") {
652                    Picker("Resolution", selection: $selectedResolution) {
653                        ForEach(Self.resolutionOptions, id: \.self) { resolution in
654                            Text(resolution.displayName).tag(resolution)
655                        }
656                    }
657                    .pickerStyle(.inline)
658                    .labelsHidden()
659                }
660            }
661            .navigationTitle("Resolve Ticket")
662            .navigationBarTitleDisplayMode(.inline)
663            .toolbar {
664                ToolbarItem(placement: .cancellationAction) {
665                    Button("Cancel") { isPresented = false }
666                }
667                ToolbarItem(placement: .confirmationAction) {
668                    Button("Mark Resolved") {
669                        Task {
670                            await viewModel.updateStatus(
671                                status: .resolved,
672                                resolution: selectedResolution
673                            )
674                            if viewModel.error == nil {
675                                isPresented = false
676                            }
677                        }
678                    }
679                    .disabled(viewModel.isPerformingAction)
680                }
681            }
682            .overlay {
683                if viewModel.isPerformingAction {
684                    ProgressView()
685                }
686            }
687        }
688    }
689}
690
691// MARK: - Assign Sheet
692
693private struct AssignSheet: View {
694    let viewModel: TicketDetailViewModel
695    @Binding var isPresented: Bool
696    @State private var username = ""
697
698    var body: some View {
699        NavigationStack {
700            Form {
701                // Current assignees with remove buttons
702                if let ticket = viewModel.ticket, !ticket.assignees.isEmpty {
703                    Section("Current Assignees") {
704                        ForEach(ticket.assignees, id: \.canonicalName) { assignee in
705                            HStack {
706                                Text(assignee.canonicalName)
707                                Spacer()
708                                Button(role: .destructive) {
709                                    Task {
710                                        await viewModel.unassignUser(
711                                            username: assignee.canonicalName
712                                        )
713                                    }
714                                } label: {
715                                    Image(systemName: "minus.circle.fill")
716                                        .foregroundStyle(.red)
717                                }
718                                .buttonStyle(.plain)
719                            }
720                        }
721                    }
722                }
723
724                Section("Add Assignee") {
725                    TextField("Username or ~username", text: $username)
726                        .textContentType(.username)
727                        .autocorrectionDisabled()
728                        .textInputAutocapitalization(.never)
729
730                    Button("Add Assignee") {
731                        let name = username.trimmingCharacters(in: .whitespacesAndNewlines)
732                        guard !name.isEmpty else { return }
733                        Task {
734                            await viewModel.assignUser(username: name)
735                            username = ""
736                        }
737                    }
738                    .disabled(
739                        username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
740                        || viewModel.isPerformingAction
741                    )
742                }
743            }
744            .navigationTitle("Assignees")
745            .navigationBarTitleDisplayMode(.inline)
746            .toolbar {
747                ToolbarItem(placement: .confirmationAction) {
748                    Button("Done") { isPresented = false }
749                }
750            }
751            .overlay {
752                if viewModel.isPerformingAction {
753                    ProgressView()
754                }
755            }
756        }
757    }
758}
759
760// MARK: - Labels Sheet
761
762private struct LabelsSheet: View {
763    let viewModel: TicketDetailViewModel
764    @Binding var isPresented: Bool
765    @State private var showCreateLabel = false
766
767    var body: some View {
768        NavigationStack {
769            Group {
770                if viewModel.trackerLabels.isEmpty {
771                    if viewModel.isPerformingAction {
772                        ProgressView()
773                    } else {
774                        ContentUnavailableView(
775                            "No Labels",
776                            systemImage: "tag",
777                            description: Text("This tracker has no labels defined.")
778                        )
779                    }
780                } else {
781                    List {
782                        ForEach(viewModel.trackerLabels) { label in
783                            LabelToggleRow(
784                                label: label,
785                                isApplied: viewModel.ticket?.labels.contains(where: { $0.id == label.id }) ?? false,
786                                isLoading: viewModel.isPerformingAction
787                            ) { shouldApply in
788                                Task {
789                                    if shouldApply {
790                                        await viewModel.labelTicket(labelId: label.id)
791                                    } else {
792                                        await viewModel.unlabelTicket(labelId: label.id)
793                                    }
794                                }
795                            }
796                        }
797                    }
798                }
799            }
800            .navigationTitle("Labels")
801            .navigationBarTitleDisplayMode(.inline)
802            .toolbar {
803                ToolbarItem(placement: .cancellationAction) {
804                    Button("Done") { isPresented = false }
805                }
806                ToolbarItem(placement: .primaryAction) {
807                    Button {
808                        showCreateLabel = true
809                    } label: {
810                        SwiftUI.Label("New Label", systemImage: "plus")
811                    }
812                }
813            }
814            .sheet(isPresented: $showCreateLabel) {
815                CreateLabelSheet(viewModel: viewModel, isPresented: $showCreateLabel)
816                    .presentationDetents([.medium])
817            }
818        }
819    }
820}
821
822// MARK: - Create Label Sheet
823
824private struct CreateLabelSheet: View {
825    let viewModel: TicketDetailViewModel
826    @Binding var isPresented: Bool
827    @State private var labelName = ""
828    @State private var backgroundColor = Color.blue
829    @State private var foregroundColor = Color.white
830
831    var body: some View {
832        NavigationStack {
833            Form {
834                Section("Label Details") {
835                    TextField("Label name", text: $labelName)
836                        .autocorrectionDisabled()
837                }
838
839                Section("Colors") {
840                    ColorPicker("Background color", selection: $backgroundColor, supportsOpacity: false)
841                    ColorPicker("Text color", selection: $foregroundColor, supportsOpacity: false)
842                }
843
844                Section("Preview") {
845                    HStack {
846                        Spacer()
847                        Text(labelName.isEmpty ? "Label" : labelName)
848                            .font(.caption2.weight(.medium))
849                            .padding(.horizontal, 6)
850                            .padding(.vertical, 2)
851                            .background(backgroundColor)
852                            .foregroundStyle(foregroundColor)
853                            .clipShape(Capsule())
854                        Spacer()
855                    }
856                }
857            }
858            .navigationTitle("New Label")
859            .navigationBarTitleDisplayMode(.inline)
860            .toolbar {
861                ToolbarItem(placement: .cancellationAction) {
862                    Button("Cancel") { isPresented = false }
863                }
864                ToolbarItem(placement: .confirmationAction) {
865                    Button("Create Label") {
866                        Task {
867                            await viewModel.createLabel(
868                                name: labelName.trimmingCharacters(in: .whitespacesAndNewlines),
869                                backgroundColor: backgroundColor.hexString,
870                                foregroundColor: foregroundColor.hexString
871                            )
872                            if viewModel.error == nil {
873                                isPresented = false
874                            }
875                        }
876                    }
877                    .disabled(
878                        labelName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
879                        || viewModel.isPerformingAction
880                    )
881                }
882            }
883            .overlay {
884                if viewModel.isPerformingAction {
885                    ProgressView()
886                }
887            }
888        }
889    }
890}
891
892private struct LabelToggleRow: View {
893    let label: TicketLabel
894    let isApplied: Bool
895    let isLoading: Bool
896    let onToggle: (Bool) -> Void
897
898    var body: some View {
899        Button {
900            onToggle(!isApplied)
901        } label: {
902            HStack {
903                LabelPill(label: label)
904                Spacer()
905                if isApplied {
906                    Image(systemName: "checkmark")
907                        .foregroundStyle(.blue)
908                }
909            }
910        }
911        .disabled(isLoading)
912    }
913}