krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.6.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 @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) {}
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: markdown) {
557 let html = await Task.detached(priority: .userInitiated) {
558 markdownToHTML(markdown)
559 }.value
560 guard !Task.isCancelled else { return }
561 renderedHTML = html
562 }
563 }
564}
565
566// MARK: - Event Row
567
568private struct EventRow: View {
569 let event: TicketEvent
570 let ticketSubmitter: String?
571 let ticketAssignees: [String]?
572 @State private var isShowingSystemStatusInfo = false
573
574 var body: some View {
575 ForEach(event.changes) { change in
576 HStack(alignment: .top, spacing: 12) {
577 Image(systemName: icon(for: change))
578 .foregroundStyle(color(for: change))
579 .frame(width: 24)
580 .padding(.top, 2)
581
582 VStack(alignment: .leading, spacing: 4) {
583 HStack {
584 if kind(for: change) == .comment {
585 Text(change.author?.canonicalName ?? "")
586 .font(.subheadline.weight(.medium))
587 } else {
588 let descriptionText = description(for: change, in: event, ticketSubmitter: ticketSubmitter, ticketAssignees: ticketAssignees)
589 if descriptionText.hasPrefix("System") {
590 HStack(spacing: 4) {
591 Text(descriptionText)
592 .font(.subheadline)
593 .foregroundStyle(.secondary)
594 Button {
595 isShowingSystemStatusInfo = true
596 } label: {
597 Image(systemName: "info.circle")
598 .foregroundStyle(.gray)
599 }
600 .buttonStyle(.plain)
601 }
602 } else {
603 Text(descriptionText)
604 .font(.subheadline)
605 .foregroundStyle(.secondary)
606 }
607 }
608 Spacer()
609 Text(event.created.relativeDescription)
610 .font(.caption)
611 .foregroundStyle(.tertiary)
612 }
613 if kind(for: change) == .comment, let text = change.text {
614 MarkdownContentView(markdown: text)
615 }
616 }
617 }
618 .padding(.horizontal)
619 .padding(.vertical, 8)
620 .alert("System Status Change", isPresented: $isShowingSystemStatusInfo) {
621 Button("OK", role: .cancel) {
622 // Alert dismissal is implicit; no additional action required.
623 }
624 } message: {
625 Text("This status change was recorded automatically or without a named user attached to the event.")
626 }
627 }
628 }
629
630 private func description(for change: EventChange, in event: TicketEvent, ticketSubmitter: String? = nil, ticketAssignees: [String]? = nil) -> String {
631 let eventKind = kind(for: change)
632 let authorName: String
633
634 if let commentAuthor = event.changes.first(where: {
635 kind(for: $0) == .comment && $0.author != nil
636 })?.author?.canonicalName {
637 authorName = commentAuthor
638 } else {
639 switch eventKind {
640 case .created:
641 authorName = change.author?.canonicalName ?? ticketSubmitter ?? "Someone"
642 case .statusChange:
643 authorName = "System"
644 case .labelAdded, .labelRemoved, .labelUpdated:
645 authorName = change.labeler?.canonicalName ?? "Someone"
646 case .assigned, .unassigned:
647 authorName = change.assigner?.canonicalName ?? "Someone"
648 case .comment:
649 authorName = change.author?.canonicalName ?? "Someone"
650 case .ticketMention, .userMention:
651 authorName = change.author?.canonicalName
652 ?? change.assigner?.canonicalName
653 ?? change.labeler?.canonicalName
654 ?? "Someone"
655 case .unknown:
656 authorName = change.author?.canonicalName
657 ?? change.assigner?.canonicalName
658 ?? change.labeler?.canonicalName
659 ?? change.assignee?.canonicalName
660 ?? change.mentioned?.canonicalName
661 ?? ticketAssignees?.first
662 ?? "Someone"
663 }
664 }
665
666 switch eventKind {
667 case .statusChange:
668 let oldStatus = change.oldStatus?.displayName ?? "unknown"
669 let newStatus = change.newStatus?.displayName ?? "unknown"
670 return "\(authorName) changed status from \(oldStatus) to \(newStatus)"
671 case .labelUpdated, .labelAdded:
672 let labelName = change.label?.name ?? "a label"
673 let verb = eventKind == .labelAdded ? "added" : "updated"
674 return "\(authorName) \(verb) label \"\(labelName)\""
675 case .labelRemoved:
676 let labelName = change.label?.name ?? "a label"
677 return "\(authorName) removed label \"\(labelName)\""
678 case .assigned:
679 let assigneeName = change.assignee?.canonicalName ?? "someone"
680 return "\(authorName) assigned \(assigneeName)"
681 case .unassigned:
682 let assigneeName = change.assignee?.canonicalName ?? "someone"
683 return "\(authorName) unassigned \(assigneeName)"
684 case .ticketMention:
685 if let ticketId = change.mentioned?.id {
686 return "\(authorName) mentioned ticket #\(ticketId)"
687 }
688 return "\(authorName) mentioned another ticket"
689 case .userMention:
690 let user = change.mentioned?.canonicalName ?? "someone"
691 return "\(authorName) mentioned \(user)"
692 case .created:
693 return "\(authorName) opened this ticket"
694 case .comment:
695 return "\(authorName) commented"
696 case .unknown:
697 return "\(authorName) updated this ticket"
698 }
699 }
700
701 private func icon(for change: EventChange) -> String {
702 switch kind(for: change) {
703 case .comment:
704 "text.bubble"
705 case .statusChange:
706 "arrow.triangle.2.circlepath"
707 case .labelAdded, .labelRemoved, .labelUpdated:
708 "tag"
709 case .assigned:
710 "person.badge.plus"
711 case .unassigned:
712 "person.badge.minus"
713 case .ticketMention, .userMention:
714 "at"
715 case .created:
716 "plus.circle"
717 case .unknown:
718 "circle.fill"
719 }
720 }
721
722 private func color(for change: EventChange) -> Color {
723 switch kind(for: change) {
724 case .comment:
725 .blue
726 case .statusChange:
727 change.newStatus == .resolved ? .green : .orange
728 case .labelAdded, .labelRemoved, .labelUpdated:
729 .purple
730 case .assigned, .unassigned:
731 .cyan
732 case .ticketMention, .userMention:
733 .indigo
734 case .created:
735 .green
736 case .unknown:
737 .gray
738 }
739 }
740
741 private func kind(for change: EventChange) -> EventKind {
742 switch change.eventType {
743 case "COMMENT", "Comment":
744 .comment
745 case "STATUS_CHANGE", "StatusChange":
746 .statusChange
747 case "LABEL_UPDATE", "LabelUpdate":
748 .labelUpdated
749 case "LABEL_ADDED", "LabelAdded":
750 .labelAdded
751 case "LABEL_REMOVED", "LabelRemoved":
752 .labelRemoved
753 case "ASSIGNMENT", "Assignment", "ASSIGNED_USER", "AssignedUser":
754 .assigned
755 case "UNASSIGNED_USER", "UnassignedUser":
756 .unassigned
757 case "TICKET_MENTION", "TicketMention":
758 .ticketMention
759 case "USER_MENTION", "UserMention":
760 .userMention
761 case "CREATED", "Created":
762 .created
763 default:
764 .unknown
765 }
766 }
767
768 private enum EventKind: Equatable {
769 case comment
770 case statusChange
771 case labelUpdated
772 case labelAdded
773 case labelRemoved
774 case assigned
775 case unassigned
776 case ticketMention
777 case userMention
778 case created
779 case unknown
780 }
781}
782
783// MARK: - Resolve Sheet
784
785private struct EditTicketSheet: View {
786 let viewModel: TicketDetailViewModel
787 @Binding var isPresented: Bool
788 let initialSubject: String
789 let initialBody: String
790
791 @State private var subject: String
792 @State private var ticketBody: String
793
794 init(
795 viewModel: TicketDetailViewModel,
796 isPresented: Binding<Bool>,
797 initialSubject: String,
798 initialBody: String
799 ) {
800 self.viewModel = viewModel
801 _isPresented = isPresented
802 self.initialSubject = initialSubject
803 self.initialBody = initialBody
804 _subject = State(initialValue: initialSubject)
805 _ticketBody = State(initialValue: initialBody)
806 }
807
808 private var trimmedSubject: String {
809 subject.trimmingCharacters(in: .whitespacesAndNewlines)
810 }
811
812 private var hasChanges: Bool {
813 trimmedSubject != initialSubject
814 || ticketBody.trimmingCharacters(in: .whitespacesAndNewlines) != initialBody
815 }
816
817 var body: some View {
818 NavigationStack {
819 Form {
820 Section("Subject") {
821 TextField("Subject", text: $subject, axis: .vertical)
822 .themedRow()
823 }
824
825 Section("Description") {
826 TextField("Description", text: $ticketBody, axis: .vertical)
827 .lineLimit(5...15)
828 .themedRow()
829 }
830 }
831 .themedList()
832 .navigationTitle("Edit Ticket")
833 .navigationBarTitleDisplayMode(.inline)
834 .toolbar {
835 ToolbarItem(placement: .cancellationAction) {
836 Button("Cancel") { isPresented = false }
837 }
838 ToolbarItem(placement: .confirmationAction) {
839 Button("Save") {
840 Task {
841 if await viewModel.updateTicket(subject: subject, body: ticketBody) {
842 isPresented = false
843 }
844 }
845 }
846 .disabled(viewModel.isPerformingAction || trimmedSubject.isEmpty || !hasChanges)
847 }
848 }
849 .overlay {
850 if viewModel.isPerformingAction {
851 ProgressView()
852 }
853 }
854 }
855 }
856}
857
858// MARK: - Resolve Sheet
859
860private struct ResolveSheet: View {
861 let viewModel: TicketDetailViewModel
862 @Binding var isPresented: Bool
863 @State private var selectedResolution: TicketResolution = .fixed
864
865 private static let resolutionOptions: [TicketResolution] = [
866 .closed, .fixed, .implemented, .wontFix,
867 .byDesign, .invalid, .duplicate, .notOurBug
868 ]
869
870 var body: some View {
871 NavigationStack {
872 Form {
873 Section("Resolution") {
874 Picker("Resolution", selection: $selectedResolution) {
875 ForEach(Self.resolutionOptions, id: \.self) { resolution in
876 Text(resolution.displayName).tag(resolution)
877 }
878 }
879 .pickerStyle(.inline)
880 .labelsHidden()
881 .themedRow()
882 }
883 }
884 .themedList()
885 .navigationTitle("Resolve Ticket")
886 .navigationBarTitleDisplayMode(.inline)
887 .toolbar {
888 ToolbarItem(placement: .cancellationAction) {
889 Button("Cancel") { isPresented = false }
890 }
891 ToolbarItem(placement: .confirmationAction) {
892 Button("Mark Resolved") {
893 Task {
894 await viewModel.updateStatus(
895 status: .resolved,
896 resolution: selectedResolution
897 )
898 if viewModel.error == nil {
899 isPresented = false
900 }
901 }
902 }
903 .disabled(viewModel.isPerformingAction)
904 }
905 }
906 .overlay {
907 if viewModel.isPerformingAction {
908 ProgressView()
909 }
910 }
911 }
912 }
913}
914
915// MARK: - Assign Sheet
916
917private struct AssignSheet: View {
918 let viewModel: TicketDetailViewModel
919 @Binding var isPresented: Bool
920 @State private var username = ""
921
922 var body: some View {
923 NavigationStack {
924 Form {
925 // Current assignees with remove buttons
926 if let ticket = viewModel.ticket, !ticket.assignees.isEmpty {
927 Section("Current Assignees") {
928 ForEach(ticket.assignees, id: \.canonicalName) { assignee in
929 HStack {
930 Text(assignee.canonicalName)
931 Spacer()
932 Button(role: .destructive) {
933 Task {
934 await viewModel.unassignUser(
935 username: assignee.canonicalName
936 )
937 }
938 } label: {
939 Image(systemName: "minus.circle.fill")
940 .foregroundStyle(.red)
941 }
942 .buttonStyle(.plain)
943 }
944 }
945 .themedRow()
946 }
947 }
948
949 Section("Add Assignee") {
950 TextField("Username or ~username", text: $username)
951 .textContentType(.username)
952 .autocorrectionDisabled()
953 .textInputAutocapitalization(.never)
954 .themedRow()
955
956 Button("Add Assignee") {
957 let name = username.trimmingCharacters(in: .whitespacesAndNewlines)
958 guard !name.isEmpty else { return }
959 Task {
960 await viewModel.assignUser(username: name)
961 username = ""
962 }
963 }
964 .disabled(
965 username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
966 || viewModel.isPerformingAction
967 )
968 .themedRow()
969 }
970 }
971 .themedList()
972 .navigationTitle("Assignees")
973 .navigationBarTitleDisplayMode(.inline)
974 .toolbar {
975 ToolbarItem(placement: .confirmationAction) {
976 Button("Done") { isPresented = false }
977 }
978 }
979 .overlay {
980 if viewModel.isPerformingAction {
981 ProgressView()
982 }
983 }
984 }
985 }
986}
987
988// MARK: - Labels Sheet
989
990private struct LabelsSheet: View {
991 let viewModel: TicketDetailViewModel
992 @Binding var isPresented: Bool
993 @State private var showCreateLabel = false
994
995 var body: some View {
996 NavigationStack {
997 Group {
998 if viewModel.trackerLabels.isEmpty {
999 if viewModel.isPerformingAction {
1000 ProgressView()
1001 } else {
1002 ContentUnavailableView(
1003 "No Labels",
1004 systemImage: "tag",
1005 description: Text("This tracker has no labels defined.")
1006 )
1007 }
1008 } else {
1009 List {
1010 ForEach(viewModel.trackerLabels) { label in
1011 LabelToggleRow(
1012 label: label,
1013 isApplied: viewModel.ticket?.labels.contains(where: { $0.id == label.id }) ?? false,
1014 isLoading: viewModel.isPerformingAction
1015 ) { shouldApply in
1016 Task {
1017 if shouldApply {
1018 await viewModel.labelTicket(labelId: label.id)
1019 } else {
1020 await viewModel.unlabelTicket(labelId: label.id)
1021 }
1022 }
1023 }
1024 }
1025 .themedRow()
1026 }
1027 .themedList()
1028 }
1029 }
1030 .navigationTitle("Labels")
1031 .navigationBarTitleDisplayMode(.inline)
1032 .toolbar {
1033 ToolbarItem(placement: .cancellationAction) {
1034 Button("Done") { isPresented = false }
1035 }
1036 ToolbarItem(placement: .primaryAction) {
1037 Button {
1038 showCreateLabel = true
1039 } label: {
1040 SwiftUI.Label("New Label", systemImage: "plus")
1041 }
1042 }
1043 }
1044 .sheet(isPresented: $showCreateLabel) {
1045 CreateLabelSheet(viewModel: viewModel, isPresented: $showCreateLabel)
1046 .presentationDetents([.medium])
1047 }
1048 }
1049 }
1050}
1051
1052// MARK: - Create Label Sheet
1053
1054private struct CreateLabelSheet: View {
1055 let viewModel: TicketDetailViewModel
1056 @Binding var isPresented: Bool
1057 @State private var labelName = ""
1058 @State private var backgroundColor = Color.blue
1059 @State private var foregroundColor = Color.white
1060
1061 var body: some View {
1062 NavigationStack {
1063 Form {
1064 Section("Label Details") {
1065 TextField("Label name", text: $labelName)
1066 .autocorrectionDisabled()
1067 .themedRow()
1068 }
1069
1070 Section("Colors") {
1071 ColorPicker("Background color", selection: $backgroundColor, supportsOpacity: false)
1072 .themedRow()
1073 ColorPicker("Text color", selection: $foregroundColor, supportsOpacity: false)
1074 .themedRow()
1075 }
1076
1077 Section("Preview") {
1078 HStack {
1079 Spacer()
1080 Text(labelName.isEmpty ? "Label" : labelName)
1081 .font(.caption2.weight(.medium))
1082 .padding(.horizontal, 6)
1083 .padding(.vertical, 2)
1084 .background(backgroundColor)
1085 .foregroundStyle(foregroundColor)
1086 .clipShape(Capsule())
1087 Spacer()
1088 }
1089 .themedRow()
1090 }
1091 }
1092 .themedList()
1093 .navigationTitle("New Label")
1094 .navigationBarTitleDisplayMode(.inline)
1095 .toolbar {
1096 ToolbarItem(placement: .cancellationAction) {
1097 Button("Cancel") { isPresented = false }
1098 }
1099 ToolbarItem(placement: .confirmationAction) {
1100 Button("Create Label") {
1101 Task {
1102 await viewModel.createLabel(
1103 name: labelName.trimmingCharacters(in: .whitespacesAndNewlines),
1104 backgroundColor: backgroundColor.hexString,
1105 foregroundColor: foregroundColor.hexString
1106 )
1107 if viewModel.error == nil {
1108 isPresented = false
1109 }
1110 }
1111 }
1112 .disabled(
1113 labelName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
1114 || viewModel.isPerformingAction
1115 )
1116 }
1117 }
1118 .overlay {
1119 if viewModel.isPerformingAction {
1120 ProgressView()
1121 }
1122 }
1123 }
1124 }
1125}
1126
1127private struct LabelToggleRow: View {
1128 let label: TicketLabel
1129 let isApplied: Bool
1130 let isLoading: Bool
1131 let onToggle: (Bool) -> Void
1132
1133 var body: some View {
1134 Button {
1135 onToggle(!isApplied)
1136 } label: {
1137 HStack {
1138 LabelPill(label: label)
1139 Spacer()
1140 if isApplied {
1141 Image(systemName: "checkmark")
1142 .foregroundStyle(.blue)
1143 }
1144 }
1145 }
1146 .disabled(isLoading)
1147 }
1148}