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