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