krz/hutch

an ios client for sourcehut

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

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

  1import SwiftUI
  2import WebKit
  3
  4struct TicketDetailView: View {
  5    let ownerUsername: String
  6    let trackerName: String
  7    let trackerId: Int
  8    let trackerRid: String
  9    let ticketId: Int
 10
 11    @Environment(AppState.self) private var appState
 12    @Environment(\.colorScheme) private var colorScheme
 13    @State private var viewModel: TicketDetailViewModel?
 14
 15    // Sheet state
 16    @State private var showResolveSheet = false
 17    @State private var showAssignSheet = false
 18    @State private var showLabelsSheet = false
 19
 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            } message: {
429                Text("This status change was recorded automatically or without a named user attached to the event.")
430            }
431        }
432    }
433
434    private func description(for change: EventChange, in event: TicketEvent, ticketSubmitter: String? = nil, ticketAssignees: [String]? = nil) -> String {
435        let eventKind = kind(for: change)
436        let authorName: String
437
438        if let commentAuthor = event.changes.first(where: {
439            kind(for: $0) == .comment && $0.author != nil
440        })?.author?.canonicalName {
441            authorName = commentAuthor
442        } else {
443            switch eventKind {
444            case .created:
445                authorName = change.author?.canonicalName ?? ticketSubmitter ?? "Someone"
446            case .statusChange:
447                authorName = "System"
448            case .labelAdded, .labelRemoved, .labelUpdated:
449                authorName = change.labeler?.canonicalName ?? "Someone"
450            case .assigned, .unassigned:
451                authorName = change.assigner?.canonicalName ?? "Someone"
452            case .comment:
453                authorName = change.author?.canonicalName ?? "Someone"
454            case .ticketMention, .userMention:
455                authorName = change.author?.canonicalName
456                    ?? change.assigner?.canonicalName
457                    ?? change.labeler?.canonicalName
458                    ?? "Someone"
459            case .unknown:
460                authorName = change.author?.canonicalName
461                    ?? change.assigner?.canonicalName
462                    ?? change.labeler?.canonicalName
463                    ?? change.assignee?.canonicalName
464                    ?? change.mentioned?.canonicalName
465                    ?? ticketAssignees?.first
466                    ?? "Someone"
467            }
468        }
469
470        switch eventKind {
471        case .statusChange:
472            let oldStatus = change.oldStatus?.displayName ?? "unknown"
473            let newStatus = change.newStatus?.displayName ?? "unknown"
474            return "\(authorName) changed status from \(oldStatus) to \(newStatus)"
475        case .labelUpdated, .labelAdded:
476            let labelName = change.label?.name ?? "a label"
477            let verb = eventKind == .labelAdded ? "added" : "updated"
478            return "\(authorName) \(verb) label \"\(labelName)\""
479        case .labelRemoved:
480            let labelName = change.label?.name ?? "a label"
481            return "\(authorName) removed label \"\(labelName)\""
482        case .assigned:
483            let assigneeName = change.assignee?.canonicalName ?? "someone"
484            return "\(authorName) assigned \(assigneeName)"
485        case .unassigned:
486            let assigneeName = change.assignee?.canonicalName ?? "someone"
487            return "\(authorName) unassigned \(assigneeName)"
488        case .ticketMention:
489            if let ticketId = change.mentioned?.id {
490                return "\(authorName) mentioned ticket #\(ticketId)"
491            }
492            return "\(authorName) mentioned another ticket"
493        case .userMention:
494            let user = change.mentioned?.canonicalName ?? "someone"
495            return "\(authorName) mentioned \(user)"
496        case .created:
497            return "\(authorName) opened this ticket"
498        case .comment:
499            return "\(authorName) commented"
500        case .unknown:
501            return "\(authorName) updated this ticket"
502        }
503    }
504
505    private func icon(for change: EventChange) -> String {
506        switch kind(for: change) {
507        case .comment:
508            "text.bubble"
509        case .statusChange:
510            "arrow.triangle.2.circlepath"
511        case .labelAdded, .labelRemoved, .labelUpdated:
512            "tag"
513        case .assigned:
514            "person.badge.plus"
515        case .unassigned:
516            "person.badge.minus"
517        case .ticketMention, .userMention:
518            "at"
519        case .created:
520            "plus.circle"
521        case .unknown:
522            "circle.fill"
523        }
524    }
525
526    private func color(for change: EventChange) -> Color {
527        switch kind(for: change) {
528        case .comment:
529            .blue
530        case .statusChange:
531            change.newStatus == .resolved ? .green : .orange
532        case .labelAdded, .labelRemoved, .labelUpdated:
533            .purple
534        case .assigned, .unassigned:
535            .cyan
536        case .ticketMention, .userMention:
537            .indigo
538        case .created:
539            .green
540        case .unknown:
541            .gray
542        }
543    }
544
545    private func kind(for change: EventChange) -> EventKind {
546        switch change.eventType {
547        case "COMMENT", "Comment":
548            .comment
549        case "STATUS_CHANGE", "StatusChange":
550            .statusChange
551        case "LABEL_UPDATE", "LabelUpdate":
552            .labelUpdated
553        case "LABEL_ADDED", "LabelAdded":
554            .labelAdded
555        case "LABEL_REMOVED", "LabelRemoved":
556            .labelRemoved
557        case "ASSIGNMENT", "Assignment", "ASSIGNED_USER", "AssignedUser":
558            .assigned
559        case "UNASSIGNED_USER", "UnassignedUser":
560            .unassigned
561        case "TICKET_MENTION", "TicketMention":
562            .ticketMention
563        case "USER_MENTION", "UserMention":
564            .userMention
565        case "CREATED", "Created":
566            .created
567        default:
568            .unknown
569        }
570    }
571
572    private enum EventKind: Equatable {
573        case comment
574        case statusChange
575        case labelUpdated
576        case labelAdded
577        case labelRemoved
578        case assigned
579        case unassigned
580        case ticketMention
581        case userMention
582        case created
583        case unknown
584    }
585}
586
587// MARK: - Resolve Sheet
588
589private struct ResolveSheet: View {
590    let viewModel: TicketDetailViewModel
591    @Binding var isPresented: Bool
592    @State private var selectedResolution: TicketResolution = .fixed
593
594    private static let resolutionOptions: [TicketResolution] = [
595        .closed, .fixed, .implemented, .wontFix,
596        .byDesign, .invalid, .duplicate, .notOurBug
597    ]
598
599    var body: some View {
600        NavigationStack {
601            Form {
602                Section("Resolution") {
603                    Picker("Resolution", selection: $selectedResolution) {
604                        ForEach(Self.resolutionOptions, id: \.self) { resolution in
605                            Text(resolution.displayName).tag(resolution)
606                        }
607                    }
608                    .pickerStyle(.inline)
609                    .labelsHidden()
610                }
611            }
612            .navigationTitle("Resolve Ticket")
613            .navigationBarTitleDisplayMode(.inline)
614            .toolbar {
615                ToolbarItem(placement: .cancellationAction) {
616                    Button("Cancel") { isPresented = false }
617                }
618                ToolbarItem(placement: .confirmationAction) {
619                    Button("Mark Resolved") {
620                        Task {
621                            await viewModel.updateStatus(
622                                status: .resolved,
623                                resolution: selectedResolution
624                            )
625                            if viewModel.error == nil {
626                                isPresented = false
627                            }
628                        }
629                    }
630                    .disabled(viewModel.isPerformingAction)
631                }
632            }
633            .overlay {
634                if viewModel.isPerformingAction {
635                    ProgressView()
636                }
637            }
638        }
639    }
640}
641
642// MARK: - Assign Sheet
643
644private struct AssignSheet: View {
645    let viewModel: TicketDetailViewModel
646    @Binding var isPresented: Bool
647    @State private var username = ""
648
649    var body: some View {
650        NavigationStack {
651            Form {
652                // Current assignees with remove buttons
653                if let ticket = viewModel.ticket, !ticket.assignees.isEmpty {
654                    Section("Current Assignees") {
655                        ForEach(ticket.assignees, id: \.canonicalName) { assignee in
656                            HStack {
657                                Text(assignee.canonicalName)
658                                Spacer()
659                                Button(role: .destructive) {
660                                    Task {
661                                        await viewModel.unassignUser(
662                                            username: assignee.canonicalName
663                                        )
664                                    }
665                                } label: {
666                                    Image(systemName: "minus.circle.fill")
667                                        .foregroundStyle(.red)
668                                }
669                                .buttonStyle(.plain)
670                            }
671                        }
672                    }
673                }
674
675                Section("Add Assignee") {
676                    TextField("Username or ~username", text: $username)
677                        .textContentType(.username)
678                        .autocorrectionDisabled()
679                        .textInputAutocapitalization(.never)
680
681                    Button("Add Assignee") {
682                        let name = username.trimmingCharacters(in: .whitespacesAndNewlines)
683                        guard !name.isEmpty else { return }
684                        Task {
685                            await viewModel.assignUser(username: name)
686                            username = ""
687                        }
688                    }
689                    .disabled(
690                        username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
691                        || viewModel.isPerformingAction
692                    )
693                }
694            }
695            .navigationTitle("Assignees")
696            .navigationBarTitleDisplayMode(.inline)
697            .toolbar {
698                ToolbarItem(placement: .confirmationAction) {
699                    Button("Done") { isPresented = false }
700                }
701            }
702            .overlay {
703                if viewModel.isPerformingAction {
704                    ProgressView()
705                }
706            }
707        }
708    }
709}
710
711// MARK: - Labels Sheet
712
713private struct LabelsSheet: View {
714    let viewModel: TicketDetailViewModel
715    @Binding var isPresented: Bool
716    @State private var showCreateLabel = false
717
718    var body: some View {
719        NavigationStack {
720            Group {
721                if viewModel.trackerLabels.isEmpty {
722                    if viewModel.isPerformingAction {
723                        ProgressView()
724                    } else {
725                        ContentUnavailableView(
726                            "No Labels",
727                            systemImage: "tag",
728                            description: Text("This tracker has no labels defined.")
729                        )
730                    }
731                } else {
732                    List {
733                        ForEach(viewModel.trackerLabels) { label in
734                            LabelToggleRow(
735                                label: label,
736                                isApplied: viewModel.ticket?.labels.contains(where: { $0.id == label.id }) ?? false,
737                                isLoading: viewModel.isPerformingAction
738                            ) { shouldApply in
739                                Task {
740                                    if shouldApply {
741                                        await viewModel.labelTicket(labelId: label.id)
742                                    } else {
743                                        await viewModel.unlabelTicket(labelId: label.id)
744                                    }
745                                }
746                            }
747                        }
748                    }
749                }
750            }
751            .navigationTitle("Labels")
752            .navigationBarTitleDisplayMode(.inline)
753            .toolbar {
754                ToolbarItem(placement: .cancellationAction) {
755                    Button("Done") { isPresented = false }
756                }
757                ToolbarItem(placement: .primaryAction) {
758                    Button {
759                        showCreateLabel = true
760                    } label: {
761                        SwiftUI.Label("New Label", systemImage: "plus")
762                    }
763                }
764            }
765            .sheet(isPresented: $showCreateLabel) {
766                CreateLabelSheet(viewModel: viewModel, isPresented: $showCreateLabel)
767                    .presentationDetents([.medium])
768            }
769        }
770    }
771}
772
773// MARK: - Create Label Sheet
774
775private struct CreateLabelSheet: View {
776    let viewModel: TicketDetailViewModel
777    @Binding var isPresented: Bool
778    @State private var labelName = ""
779    @State private var backgroundColor = Color.blue
780    @State private var foregroundColor = Color.white
781
782    var body: some View {
783        NavigationStack {
784            Form {
785                Section("Label Details") {
786                    TextField("Label name", text: $labelName)
787                        .autocorrectionDisabled()
788                }
789
790                Section("Colors") {
791                    ColorPicker("Background color", selection: $backgroundColor, supportsOpacity: false)
792                    ColorPicker("Text color", selection: $foregroundColor, supportsOpacity: false)
793                }
794
795                Section("Preview") {
796                    HStack {
797                        Spacer()
798                        Text(labelName.isEmpty ? "Label" : labelName)
799                            .font(.caption2.weight(.medium))
800                            .padding(.horizontal, 6)
801                            .padding(.vertical, 2)
802                            .background(backgroundColor)
803                            .foregroundStyle(foregroundColor)
804                            .clipShape(Capsule())
805                        Spacer()
806                    }
807                }
808            }
809            .navigationTitle("New Label")
810            .navigationBarTitleDisplayMode(.inline)
811            .toolbar {
812                ToolbarItem(placement: .cancellationAction) {
813                    Button("Cancel") { isPresented = false }
814                }
815                ToolbarItem(placement: .confirmationAction) {
816                    Button("Create Label") {
817                        Task {
818                            await viewModel.createLabel(
819                                name: labelName.trimmingCharacters(in: .whitespacesAndNewlines),
820                                backgroundColor: backgroundColor.hexString,
821                                foregroundColor: foregroundColor.hexString
822                            )
823                            if viewModel.error == nil {
824                                isPresented = false
825                            }
826                        }
827                    }
828                    .disabled(
829                        labelName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
830                        || viewModel.isPerformingAction
831                    )
832                }
833            }
834            .overlay {
835                if viewModel.isPerformingAction {
836                    ProgressView()
837                }
838            }
839        }
840    }
841}
842
843private struct LabelToggleRow: View {
844    let label: TicketLabel
845    let isApplied: Bool
846    let isLoading: Bool
847    let onToggle: (Bool) -> Void
848
849    var body: some View {
850        Button {
851            onToggle(!isApplied)
852        } label: {
853            HStack {
854                LabelPill(label: label)
855                Spacer()
856                if isApplied {
857                    Image(systemName: "checkmark")
858                        .foregroundStyle(.blue)
859                }
860            }
861        }
862        .disabled(isLoading)
863    }
864}