krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.3.0: 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}