krz/hutch

an ios client for sourcehut

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

main: Hutch/Views/Tickets/TicketDetailView.swift · raw

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