krz/hutch

an ios client for sourcehut

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

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