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