krz/hutch

an ios client for sourcehut

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

v3.2.0: Hutch/Views/Tickets/TicketDetailView.swift · raw

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