krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

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