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}