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