krz/hutch

an ios client for sourcehut

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

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