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