krz/hutch

an ios client for sourcehut

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

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