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