krz/hutch

an ios client for sourcehut

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

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