krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.2.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 @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 .themedRow()
743 }
744 }
745 .themedList()
746 .navigationTitle("Resolve Ticket")
747 .navigationBarTitleDisplayMode(.inline)
748 .toolbar {
749 ToolbarItem(placement: .cancellationAction) {
750 Button("Cancel") { isPresented = false }
751 }
752 ToolbarItem(placement: .confirmationAction) {
753 Button("Mark Resolved") {
754 Task {
755 await viewModel.updateStatus(
756 status: .resolved,
757 resolution: selectedResolution
758 )
759 if viewModel.error == nil {
760 isPresented = false
761 }
762 }
763 }
764 .disabled(viewModel.isPerformingAction)
765 }
766 }
767 .overlay {
768 if viewModel.isPerformingAction {
769 ProgressView()
770 }
771 }
772 }
773 }
774}
775
776// MARK: - Assign Sheet
777
778private struct AssignSheet: View {
779 let viewModel: TicketDetailViewModel
780 @Binding var isPresented: Bool
781 @State private var username = ""
782
783 var body: some View {
784 NavigationStack {
785 Form {
786 // Current assignees with remove buttons
787 if let ticket = viewModel.ticket, !ticket.assignees.isEmpty {
788 Section("Current Assignees") {
789 ForEach(ticket.assignees, id: \.canonicalName) { assignee in
790 HStack {
791 Text(assignee.canonicalName)
792 Spacer()
793 Button(role: .destructive) {
794 Task {
795 await viewModel.unassignUser(
796 username: assignee.canonicalName
797 )
798 }
799 } label: {
800 Image(systemName: "minus.circle.fill")
801 .foregroundStyle(.red)
802 }
803 .buttonStyle(.plain)
804 }
805 }
806 .themedRow()
807 }
808 }
809
810 Section("Add Assignee") {
811 TextField("Username or ~username", text: $username)
812 .textContentType(.username)
813 .autocorrectionDisabled()
814 .textInputAutocapitalization(.never)
815 .themedRow()
816
817 Button("Add Assignee") {
818 let name = username.trimmingCharacters(in: .whitespacesAndNewlines)
819 guard !name.isEmpty else { return }
820 Task {
821 await viewModel.assignUser(username: name)
822 username = ""
823 }
824 }
825 .disabled(
826 username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
827 || viewModel.isPerformingAction
828 )
829 .themedRow()
830 }
831 }
832 .themedList()
833 .navigationTitle("Assignees")
834 .navigationBarTitleDisplayMode(.inline)
835 .toolbar {
836 ToolbarItem(placement: .confirmationAction) {
837 Button("Done") { isPresented = false }
838 }
839 }
840 .overlay {
841 if viewModel.isPerformingAction {
842 ProgressView()
843 }
844 }
845 }
846 }
847}
848
849// MARK: - Labels Sheet
850
851private struct LabelsSheet: View {
852 let viewModel: TicketDetailViewModel
853 @Binding var isPresented: Bool
854 @State private var showCreateLabel = false
855
856 var body: some View {
857 NavigationStack {
858 Group {
859 if viewModel.trackerLabels.isEmpty {
860 if viewModel.isPerformingAction {
861 ProgressView()
862 } else {
863 ContentUnavailableView(
864 "No Labels",
865 systemImage: "tag",
866 description: Text("This tracker has no labels defined.")
867 )
868 }
869 } else {
870 List {
871 ForEach(viewModel.trackerLabels) { label in
872 LabelToggleRow(
873 label: label,
874 isApplied: viewModel.ticket?.labels.contains(where: { $0.id == label.id }) ?? false,
875 isLoading: viewModel.isPerformingAction
876 ) { shouldApply in
877 Task {
878 if shouldApply {
879 await viewModel.labelTicket(labelId: label.id)
880 } else {
881 await viewModel.unlabelTicket(labelId: label.id)
882 }
883 }
884 }
885 }
886 .themedRow()
887 }
888 .themedList()
889 }
890 }
891 .navigationTitle("Labels")
892 .navigationBarTitleDisplayMode(.inline)
893 .toolbar {
894 ToolbarItem(placement: .cancellationAction) {
895 Button("Done") { isPresented = false }
896 }
897 ToolbarItem(placement: .primaryAction) {
898 Button {
899 showCreateLabel = true
900 } label: {
901 SwiftUI.Label("New Label", systemImage: "plus")
902 }
903 }
904 }
905 .sheet(isPresented: $showCreateLabel) {
906 CreateLabelSheet(viewModel: viewModel, isPresented: $showCreateLabel)
907 .presentationDetents([.medium])
908 }
909 }
910 }
911}
912
913// MARK: - Create Label Sheet
914
915private struct CreateLabelSheet: View {
916 let viewModel: TicketDetailViewModel
917 @Binding var isPresented: Bool
918 @State private var labelName = ""
919 @State private var backgroundColor = Color.blue
920 @State private var foregroundColor = Color.white
921
922 var body: some View {
923 NavigationStack {
924 Form {
925 Section("Label Details") {
926 TextField("Label name", text: $labelName)
927 .autocorrectionDisabled()
928 .themedRow()
929 }
930
931 Section("Colors") {
932 ColorPicker("Background color", selection: $backgroundColor, supportsOpacity: false)
933 .themedRow()
934 ColorPicker("Text color", selection: $foregroundColor, supportsOpacity: false)
935 .themedRow()
936 }
937
938 Section("Preview") {
939 HStack {
940 Spacer()
941 Text(labelName.isEmpty ? "Label" : labelName)
942 .font(.caption2.weight(.medium))
943 .padding(.horizontal, 6)
944 .padding(.vertical, 2)
945 .background(backgroundColor)
946 .foregroundStyle(foregroundColor)
947 .clipShape(Capsule())
948 Spacer()
949 }
950 .themedRow()
951 }
952 }
953 .themedList()
954 .navigationTitle("New Label")
955 .navigationBarTitleDisplayMode(.inline)
956 .toolbar {
957 ToolbarItem(placement: .cancellationAction) {
958 Button("Cancel") { isPresented = false }
959 }
960 ToolbarItem(placement: .confirmationAction) {
961 Button("Create Label") {
962 Task {
963 await viewModel.createLabel(
964 name: labelName.trimmingCharacters(in: .whitespacesAndNewlines),
965 backgroundColor: backgroundColor.hexString,
966 foregroundColor: foregroundColor.hexString
967 )
968 if viewModel.error == nil {
969 isPresented = false
970 }
971 }
972 }
973 .disabled(
974 labelName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
975 || viewModel.isPerformingAction
976 )
977 }
978 }
979 .overlay {
980 if viewModel.isPerformingAction {
981 ProgressView()
982 }
983 }
984 }
985 }
986}
987
988private struct LabelToggleRow: View {
989 let label: TicketLabel
990 let isApplied: Bool
991 let isLoading: Bool
992 let onToggle: (Bool) -> Void
993
994 var body: some View {
995 Button {
996 onToggle(!isApplied)
997 } label: {
998 HStack {
999 LabelPill(label: label)
1000 Spacer()
1001 if isApplied {
1002 Image(systemName: "checkmark")
1003 .foregroundStyle(.blue)
1004 }
1005 }
1006 }
1007 .disabled(isLoading)
1008 }
1009}