krz/hutch

an ios client for sourcehut

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

v3.0.0: Hutch/Views/Tickets/TicketListView.swift · raw

   1import SwiftUI
   2
   3struct TicketListView: View {
   4    let onTrackerUpdated: (TrackerSummary) -> Void
   5    let onTrackerDeleted: (TrackerSummary) -> Void
   6
   7    @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
   8    @Environment(AppState.self) private var appState
   9    @Environment(\.dismiss) private var dismiss
  10    @Environment(\.openURL) private var openURL
  11    @State private var tracker: TrackerSummary
  12    @State private var viewModel: TicketListViewModel?
  13    @State private var trackerManagementViewModel: TrackerManagementViewModel?
  14    @State private var showCreateTicketSheet = false
  15    @State private var createdTicket: TicketSummary?
  16    @State private var labelEditorTicket: LabelEditorTicket?
  17    @State private var showLabelFilterSheet = false
  18    @State private var showSaveFilterSheet = false
  19    @State private var showTrackerEditor = false
  20    @State private var showTrackerACLs = false
  21    @State private var showTrackerLabels = false
  22    @State private var showDeleteTrackerConfirmation = false
  23    @State private var showBulkCloseSheet = false
  24    @State private var showBulkAssignSheet = false
  25    @State private var bulkActionResult: TicketBulkActionResult?
  26    @State private var pinChangeCount = 0
  27
  28    private var isOwnedByCurrentUser: Bool {
  29        guard let currentUser = appState.currentUser else { return false }
  30        return normalizedUsername(currentUser.username) == normalizedUsername(tracker.owner.canonicalName)
  31    }
  32
  33    private var currentUserKey: String? {
  34        appState.currentUser?.canonicalName
  35    }
  36
  37    private var isPinnedToHome: Bool {
  38        _ = pinChangeCount
  39        guard let currentUserKey else { return false }
  40        return HomePinStore.isPinned(.tracker(tracker), for: currentUserKey, defaults: appState.accountDefaults)
  41    }
  42
  43    private var navigationTitle: String {
  44        guard let viewModel, viewModel.isSelectionMode else { return tracker.name }
  45        let count = viewModel.selectedTicketCount
  46        return count == 0 ? "Select Tickets" : "\(count) Selected"
  47    }
  48
  49    init(
  50        tracker: TrackerSummary,
  51        onTrackerUpdated: @escaping (TrackerSummary) -> Void = { _ in /* no-op: default for callers that don't handle this event */ },
  52        onTrackerDeleted: @escaping (TrackerSummary) -> Void = { _ in /* no-op: default for callers that don't handle this event */ }
  53    ) {
  54        self._tracker = State(initialValue: tracker)
  55        self.onTrackerUpdated = onTrackerUpdated
  56        self.onTrackerDeleted = onTrackerDeleted
  57    }
  58
  59    var body: some View {
  60        Group {
  61            if let viewModel {
  62                listContent(viewModel)
  63            } else {
  64                SRHTLoadingStateView(message: "Loading tickets…")
  65            }
  66        }
  67        .navigationTitle(navigationTitle)
  68        .navigationBarTitleDisplayMode(.inline)
  69        .toolbar {
  70            ToolbarItem(placement: .topBarLeading) {
  71                if let viewModel, viewModel.isSelectionMode {
  72                    Button("Cancel") {
  73                        viewModel.setSelectionMode(false)
  74                    }
  75                }
  76            }
  77
  78            ToolbarItemGroup(placement: .topBarTrailing) {
  79                if viewModel != nil {
  80                    if let viewModel, viewModel.isSelectionMode {
  81                        Button("All") {
  82                            viewModel.selectVisibleTickets(viewModel.filteredTickets)
  83                        }
  84                        .disabled(viewModel.filteredTickets.isEmpty || viewModel.isPerformingAction)
  85                    } else {
  86                        Button {
  87                            showCreateTicketSheet = true
  88                        } label: {
  89                            Image(systemName: "plus")
  90                        }
  91                        .accessibilityLabel("Create ticket")
  92
  93                        trackerActionsMenu
  94                    }
  95                }
  96            }
  97        }
  98        .sheet(isPresented: $showCreateTicketSheet) {
  99            if let viewModel {
 100                CreateTicketSheet(viewModel: viewModel) { ticket in
 101                    showCreateTicketSheet = false
 102                    createdTicket = ticket
 103                }
 104            }
 105        }
 106        .sheet(item: $labelEditorTicket) { item in
 107            if let viewModel {
 108                TicketLabelsSheet(
 109                    ticketId: item.id,
 110                    viewModel: viewModel
 111                )
 112                .presentationDetents([.medium])
 113            }
 114        }
 115        .sheet(isPresented: $showLabelFilterSheet) {
 116            if let viewModel {
 117                TicketFilterLabelsSheet(viewModel: viewModel)
 118                    .presentationDetents([.medium, .large])
 119            }
 120        }
 121        .sheet(isPresented: $showSaveFilterSheet) {
 122            if let viewModel {
 123                SaveTicketFilterSheet(viewModel: viewModel)
 124                    .presentationDetents([.height(220)])
 125            }
 126        }
 127        .sheet(isPresented: $showTrackerEditor) {
 128            if let trackerManagementViewModel {
 129                TrackerEditorSheet(
 130                    title: "Update Tracker",
 131                    confirmationTitle: "Save",
 132                    isSaving: trackerManagementViewModel.isSavingTracker,
 133                    error: trackerManagementViewModel.error,
 134                    initialName: tracker.name,
 135                    initialDescription: tracker.description ?? "",
 136                    initialVisibility: tracker.visibility
 137                ) { name, description, visibility in
 138                    if let updatedTracker = await trackerManagementViewModel.updateTracker(
 139                        name: name,
 140                        description: description,
 141                        visibility: visibility
 142                    ) {
 143                        tracker = updatedTracker
 144                        onTrackerUpdated(updatedTracker)
 145                        return true
 146                    }
 147                    return false
 148                }
 149            }
 150        }
 151        .sheet(isPresented: $showTrackerACLs) {
 152            if let trackerManagementViewModel {
 153                TrackerACLManagementSheet(viewModel: trackerManagementViewModel)
 154                    .presentationDetents([.large])
 155            }
 156        }
 157        .sheet(isPresented: $showTrackerLabels, onDismiss: syncTrackerLabelsIntoTicketList) {
 158            if let trackerManagementViewModel {
 159                TrackerLabelManagementSheet(viewModel: trackerManagementViewModel)
 160                    .presentationDetents([.large])
 161            }
 162        }
 163        .sheet(isPresented: $showBulkCloseSheet) {
 164            if let viewModel {
 165                BulkResolveSheet(
 166                    viewModel: viewModel,
 167                    isPresented: $showBulkCloseSheet,
 168                    onComplete: { result in
 169                        bulkActionResult = result
 170                    }
 171                )
 172                    .presentationDetents([.medium])
 173            }
 174        }
 175        .sheet(isPresented: $showBulkAssignSheet) {
 176            if let viewModel {
 177                BulkAssignSheet(
 178                    viewModel: viewModel,
 179                    currentUser: appState.currentUser,
 180                    isPresented: $showBulkAssignSheet,
 181                    onComplete: { result in
 182                        bulkActionResult = result
 183                    }
 184                )
 185                .presentationDetents([.medium])
 186            }
 187        }
 188        .alert("Delete Tracker?", isPresented: $showDeleteTrackerConfirmation) {
 189            Button("Cancel", role: .cancel) {
 190                // no-op: .cancel role handles alert dismissal
 191            }
 192            Button("Delete", role: .destructive) {
 193                guard let trackerManagementViewModel else { return }
 194                Task {
 195                    let didDelete = await trackerManagementViewModel.deleteTracker()
 196                    if didDelete {
 197                        onTrackerDeleted(tracker)
 198                        dismiss()
 199                    }
 200                }
 201            }
 202        } message: {
 203            Text("\(tracker.name)” will be permanently deleted.")
 204        }
 205        .alert(item: $bulkActionResult) { result in
 206            Alert(
 207                title: Text(result.title),
 208                message: Text(result.message),
 209                dismissButton: .default(Text("OK"))
 210            )
 211        }
 212        .navigationDestination(isPresented: Binding(
 213            get: { createdTicket != nil },
 214            set: { isPresented in
 215                if !isPresented {
 216                    createdTicket = nil
 217                }
 218            }
 219        )) {
 220            if let createdTicket {
 221                TicketDetailView(
 222                    ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
 223                    trackerName: tracker.name,
 224                    trackerId: tracker.id,
 225                    trackerRid: tracker.rid,
 226                    ticketId: createdTicket.id
 227                )
 228            }
 229        }
 230        .task {
 231            if viewModel == nil {
 232                let vm = TicketListViewModel(
 233                    ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
 234                    trackerName: tracker.name,
 235                    trackerId: tracker.id,
 236                    trackerRid: tracker.rid,
 237                    client: appState.client,
 238                    defaults: appState.accountDefaults
 239                )
 240                viewModel = vm
 241                trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client)
 242                await vm.loadTickets()
 243                await vm.loadTrackerLabels()
 244            }
 245        }
 246    }
 247
 248    private func togglePinnedState() {
 249        guard let currentUserKey else { return }
 250        HomePinStore.togglePin(.tracker(tracker), for: currentUserKey, defaults: appState.accountDefaults)
 251        pinChangeCount += 1
 252    }
 253
 254    @ViewBuilder
 255    private func listContent(_ viewModel: TicketListViewModel) -> some View {
 256        @Bindable var vm = viewModel
 257
 258        List {
 259            Section {
 260                TicketListFilterHeader(
 261                    selectedLabels: viewModel.selectedLabels,
 262                    savedFilters: viewModel.savedFilters,
 263                    activeSavedFilterID: viewModel.activeSavedFilterID,
 264                    canSaveCurrentFilter: viewModel.hasCustomFilterSelection,
 265                    isSelectionMode: viewModel.isSelectionMode,
 266                    selectedTicketCount: viewModel.selectedTicketCount,
 267                    filter: $vm.filter
 268                ) {
 269                    showLabelFilterSheet = true
 270                } onSaveFilter: {
 271                    showSaveFilterSheet = true
 272                } onResetFilters: {
 273                    vm.resetFilters()
 274                } onApplySavedFilter: { savedFilter in
 275                    vm.applySavedFilter(savedFilter)
 276                } onDeleteSavedFilter: { savedFilter in
 277                    vm.deleteSavedFilter(savedFilter)
 278                }
 279                .listRowInsets(EdgeInsets())
 280                .listRowBackground(Color.clear)
 281                .listRowSeparator(.hidden)
 282
 283                ForEach(viewModel.filteredTickets) { ticket in
 284                    Group {
 285                        if viewModel.isSelectionMode {
 286                            Button {
 287                                viewModel.toggleTicketSelection(ticket)
 288                            } label: {
 289                                SelectableTicketRow(
 290                                    ticket: ticket,
 291                                    isSelected: viewModel.selectedTicketIDs.contains(ticket.id)
 292                                )
 293                            }
 294                            .buttonStyle(.plain)
 295                        } else {
 296                            NavigationLink {
 297                                TicketDetailView(
 298                                    ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
 299                                    trackerName: tracker.name,
 300                                    trackerId: tracker.id,
 301                                    trackerRid: tracker.rid,
 302                                    ticketId: ticket.id
 303                                )
 304                            } label: {
 305                                TicketRowView(ticket: ticket)
 306                            }
 307                            .contextMenu {
 308                                if let url = SRHTWebURL.ticket(ownerUsername: ownerUsername(for: tracker), trackerName: tracker.name, ticketId: ticket.id) {
 309                                    Button {
 310                                        openURL(url)
 311                                    } label: {
 312                                        Label("Open in Browser", systemImage: "safari")
 313                                    }
 314
 315                                    Button {
 316                                        appState.copyToPasteboard(url.absoluteString, label: "ticket URL")
 317                                    } label: {
 318                                        Label("Copy URL", systemImage: "doc.on.doc")
 319                                    }
 320                                }
 321
 322                                Button {
 323                                    appState.copyToPasteboard(String(ticket.id), label: "ticket ID")
 324                                } label: {
 325                                    Label("Copy Ticket ID", systemImage: "number")
 326                                }
 327                            }
 328                            .swipeActions(edge: .leading, allowsFullSwipe: true) {
 329                                if swipeActionsEnabled {
 330                                    ticketAssignSwipeAction(ticket, viewModel: viewModel)
 331                                }
 332                            }
 333                            .swipeActions(edge: .trailing, allowsFullSwipe: false) {
 334                                if swipeActionsEnabled {
 335                                    ticketStatusSwipeAction(ticket, viewModel: viewModel)
 336                                    ticketLabelSwipeAction(ticket, viewModel: viewModel)
 337                                }
 338                            }
 339                        }
 340                    }
 341                    .task {
 342                        await viewModel.loadMoreIfNeeded(currentItem: ticket)
 343                    }
 344                }
 345
 346                if viewModel.isLoadingMore {
 347                    HStack {
 348                        Spacer()
 349                        ProgressView()
 350                        Spacer()
 351                    }
 352                    .listRowSeparator(.hidden)
 353                }
 354            }
 355        }
 356        .listStyle(.plain)
 357        .listSectionSpacing(.compact)
 358        .searchable(
 359            text: $vm.searchText,
 360            placement: .navigationBarDrawer(displayMode: .always),
 361            prompt: "Search tickets in \(tracker.name)"
 362        )
 363        .searchSuggestions {
 364            if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
 365                RecentSearchSuggestions(
 366                    title: "Recent Ticket Searches",
 367                    entries: viewModel.recentSearches
 368                ) { query in
 369                    vm.searchText = query
 370                } onClear: {
 371                    viewModel.clearRecentSearches()
 372                }
 373            }
 374        }
 375        .onSubmit(of: .search) {
 376            let query = viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
 377            guard !query.isEmpty else { return }
 378            viewModel.recordRecentSearch(query)
 379        }
 380        .overlay {
 381            if viewModel.isLoading, viewModel.tickets.isEmpty {
 382                SRHTLoadingStateView(message: "Loading tickets…")
 383            } else if let error = viewModel.error, viewModel.tickets.isEmpty {
 384                SRHTErrorStateView(
 385                    title: "Couldn't Load Tickets",
 386                    message: error,
 387                    retryAction: { await viewModel.loadTickets() }
 388                )
 389            } else if !viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
 390                      viewModel.filteredTickets.isEmpty {
 391                ContentUnavailableView(
 392                    "No Ticket Matches",
 393                    systemImage: "magnifyingglass",
 394                    description: Text("No tickets in \(tracker.name) matched “\(viewModel.searchText)”.")
 395                )
 396            } else if viewModel.filteredTickets.isEmpty, viewModel.error == nil {
 397                ContentUnavailableView(
 398                    "No Tickets",
 399                    systemImage: "ticket",
 400                    description: Text(emptyStateDescription(for: viewModel))
 401                )
 402            }
 403        }
 404        .connectivityOverlay(hasContent: !viewModel.filteredTickets.isEmpty) {
 405            await viewModel.loadTickets()
 406        }
 407        .srhtErrorBanner(error: $vm.error)
 408        .refreshable {
 409            await viewModel.loadTickets()
 410        }
 411        .safeAreaInset(edge: .bottom) {
 412            if viewModel.isSelectionMode {
 413                TicketBulkActionBar(
 414                    selectedCount: viewModel.selectedTicketCount,
 415                    isPerformingAction: viewModel.isPerformingAction,
 416                    onClose: {
 417                        showBulkCloseSheet = true
 418                    },
 419                    onAssign: {
 420                        showBulkAssignSheet = true
 421                    }
 422                )
 423            }
 424        }
 425    }
 426
 427    private func emptyStateDescription(for viewModel: TicketListViewModel) -> String {
 428        if !viewModel.selectedLabelIDs.isEmpty {
 429            return "No \(viewModel.filter.rawValue.lowercased()) tickets found for the selected labels."
 430        }
 431        return "No \(viewModel.filter.rawValue.lowercased()) tickets found."
 432    }
 433
 434    private func syncTrackerLabelsIntoTicketList() {
 435        guard let trackerManagementViewModel, let viewModel else { return }
 436        viewModel.syncTrackerLabels(trackerManagementViewModel.labels)
 437    }
 438
 439    private var trackerActionsMenu: some View {
 440        Menu {
 441            Button {
 442                viewModel?.setSelectionMode(true)
 443            } label: {
 444                Label("Select", systemImage: "checkmark.circle")
 445            }
 446
 447            if currentUserKey != nil {
 448                Button {
 449                    togglePinnedState()
 450                } label: {
 451                    Label(
 452                        isPinnedToHome ? "Unpin from Home" : "Pin to Home",
 453                        systemImage: isPinnedToHome ? "pin.slash" : "pin"
 454                    )
 455                }
 456            }
 457
 458            if let shareURL = SRHTWebURL.tracker(tracker) {
 459                ShareLink(item: shareURL) {
 460                    Label("Share", systemImage: "square.and.arrow.up")
 461                }
 462            }
 463
 464            Divider()
 465
 466            if let trackerURL = SRHTWebURL.tracker(tracker) {
 467                Button {
 468                    openURL(trackerURL)
 469                } label: {
 470                    Label("Open in Browser", systemImage: "safari")
 471                }
 472
 473                Button {
 474                    appState.copyToPasteboard(trackerURL.absoluteString, label: "tracker URL")
 475                } label: {
 476                    Label("Copy URL", systemImage: "doc.on.doc")
 477                }
 478            }
 479
 480            Button {
 481                appState.copyToPasteboard(String(tracker.id), label: "tracker ID")
 482            } label: {
 483                Label("Copy Tracker ID", systemImage: "number")
 484            }
 485
 486            Button {
 487                appState.copyToPasteboard(tracker.rid, label: "tracker RID")
 488            } label: {
 489                Label("Copy RID", systemImage: "number")
 490            }
 491
 492            if isOwnedByCurrentUser {
 493                Divider()
 494
 495            Button {
 496                showTrackerEditor = true
 497            } label: {
 498                Label("Update Tracker", systemImage: "pencil")
 499            }
 500
 501            Button {
 502                showTrackerACLs = true
 503            } label: {
 504                Label("Manage ACLs", systemImage: "person.2")
 505            }
 506
 507            Button {
 508                showTrackerLabels = true
 509            } label: {
 510                Label("Manage Labels", systemImage: "tag")
 511            }
 512
 513            Button(role: .destructive) {
 514                showDeleteTrackerConfirmation = true
 515            } label: {
 516                Label("Delete Tracker", systemImage: "trash")
 517            }
 518            }
 519        } label: {
 520            Image(systemName: "ellipsis.circle")
 521        }
 522        .accessibilityLabel("Tracker actions")
 523    }
 524
 525    private func ownerUsername(for tracker: TrackerSummary) -> String {
 526        tracker.owner.canonicalName.srhtUsername
 527    }
 528
 529    @ViewBuilder
 530    private func ticketAssignSwipeAction(
 531        _ ticket: TicketSummary,
 532        viewModel: TicketListViewModel
 533    ) -> some View {
 534        if let currentUser = appState.currentUser {
 535            let isAssigned = ticket.assignees.contains { assignee in
 536                matchesAssignee(assignee, user: currentUser)
 537            }
 538
 539            if isAssigned {
 540                Button {
 541                    Task {
 542                        await viewModel.unassignFromMe(ticket: ticket, user: currentUser)
 543                    }
 544                } label: {
 545                    Label("Unassign Me", systemImage: "person.badge.minus")
 546                }
 547                .tint(.orange)
 548            } else {
 549                Button {
 550                    Task {
 551                        await viewModel.assignToMe(ticket: ticket, user: currentUser)
 552                    }
 553                } label: {
 554                    Label("Assign Me", systemImage: "person.badge.plus")
 555                }
 556                .tint(.cyan)
 557            }
 558        }
 559    }
 560
 561    @ViewBuilder
 562    private func ticketStatusSwipeAction(
 563        _ ticket: TicketSummary,
 564        viewModel: TicketListViewModel
 565    ) -> some View {
 566        if ticket.status.isOpen {
 567            Button {
 568                Task { await viewModel.resolveTicket(ticket) }
 569            } label: {
 570                Label("Close", systemImage: "xmark.circle")
 571            }
 572            .tint(.red)
 573        } else {
 574            Button {
 575                Task { await viewModel.reopenTicket(ticket) }
 576            } label: {
 577                Label("Reopen", systemImage: "arrow.uturn.backward")
 578            }
 579            .tint(.blue)
 580        }
 581    }
 582
 583    @ViewBuilder
 584    private func ticketLabelSwipeAction(
 585        _ ticket: TicketSummary,
 586        viewModel: TicketListViewModel
 587    ) -> some View {
 588        Button {
 589            labelEditorTicket = LabelEditorTicket(id: ticket.id)
 590            Task { await viewModel.loadTrackerLabels() }
 591        } label: {
 592            Label("Labels", systemImage: "tag")
 593        }
 594        .tint(.purple)
 595    }
 596
 597    private func matchesAssignee(_ entity: Entity, user: User) -> Bool {
 598        let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
 599        let userCanonical = normalizedCanonicalName(user.canonicalName)
 600        if assigneeCanonical == userCanonical {
 601            return true
 602        }
 603        return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
 604    }
 605
 606    private func normalizedCanonicalName(_ value: String) -> String {
 607        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
 608        if trimmed.hasPrefix("~") {
 609            return trimmed
 610        }
 611        return "~\(trimmed)"
 612    }
 613
 614    private func normalizedUsername(_ value: String) -> String {
 615        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
 616        return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
 617    }
 618}
 619
 620private struct LabelEditorTicket: Identifiable {
 621    let id: Int
 622}
 623
 624private struct CreateTicketSheet: View {
 625    let viewModel: TicketListViewModel
 626    let onCreated: (TicketSummary) -> Void
 627
 628    @Environment(\.dismiss) private var dismiss
 629    @State private var subject = ""
 630    @State private var descriptionText = ""
 631
 632    var body: some View {
 633        NavigationStack {
 634            Form {
 635                Section("Ticket Details") {
 636                    TextField("Title", text: $subject)
 637                    TextField("Description (optional)", text: $descriptionText, axis: .vertical)
 638                        .lineLimit(6...12)
 639                }
 640            }
 641            .navigationTitle("New Ticket")
 642            .navigationBarTitleDisplayMode(.inline)
 643            .toolbar {
 644                ToolbarItem(placement: .cancellationAction) {
 645                    Button("Cancel") { dismiss() }
 646                }
 647                ToolbarItem(placement: .confirmationAction) {
 648                    Button {
 649                        Task {
 650                            if let ticket = await viewModel.createTicket(subject: subject, body: descriptionText) {
 651                                onCreated(ticket)
 652                            }
 653                        }
 654                    } label: {
 655                        if viewModel.isCreatingTicket {
 656                            ProgressView()
 657                                .controlSize(.small)
 658                        } else {
 659                            Text("Create Ticket")
 660                        }
 661                    }
 662                    .disabled(subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTicket)
 663                }
 664            }
 665        }
 666    }
 667}
 668
 669private struct TicketLabelsSheet: View {
 670    let ticketId: Int
 671    let viewModel: TicketListViewModel
 672
 673    @Environment(\.dismiss) private var dismiss
 674
 675    var body: some View {
 676        NavigationStack {
 677            Group {
 678                if let ticket = viewModel.ticket(withId: ticketId) {
 679                    if viewModel.trackerLabels.isEmpty {
 680                        if viewModel.isPerformingAction {
 681                            ProgressView()
 682                        } else {
 683                            ContentUnavailableView(
 684                                "No Labels",
 685                                systemImage: "tag",
 686                                description: Text("This tracker has no labels defined.")
 687                            )
 688                        }
 689                    } else {
 690                        List {
 691                            ForEach(viewModel.trackerLabels) { label in
 692                                TicketListLabelToggleRow(
 693                                    label: label,
 694                                    isApplied: ticket.labels.contains(where: { $0.id == label.id }),
 695                                    isLoading: viewModel.isPerformingAction
 696                                ) { shouldApply in
 697                                    Task {
 698                                        if shouldApply {
 699                                            await viewModel.labelTicket(ticket, label: label)
 700                                        } else {
 701                                            await viewModel.unlabelTicket(ticket, label: label)
 702                                        }
 703                                    }
 704                                }
 705                            }
 706                        }
 707                    }
 708                } else {
 709                    ContentUnavailableView(
 710                        "Ticket Unavailable",
 711                        systemImage: "ticket",
 712                        description: Text("This ticket is no longer in the current list.")
 713                    )
 714                }
 715            }
 716            .navigationTitle("Labels")
 717            .navigationBarTitleDisplayMode(.inline)
 718            .toolbar {
 719                ToolbarItem(placement: .cancellationAction) {
 720                    Button("Done") { dismiss() }
 721                }
 722            }
 723            .task {
 724                if viewModel.trackerLabels.isEmpty {
 725                    await viewModel.loadTrackerLabels()
 726                }
 727            }
 728        }
 729    }
 730}
 731
 732private struct TicketListFilterHeader: View {
 733    let selectedLabels: [TicketLabel]
 734    let savedFilters: [SavedTicketFilter]
 735    let activeSavedFilterID: SavedTicketFilter.ID?
 736    let canSaveCurrentFilter: Bool
 737    let isSelectionMode: Bool
 738    let selectedTicketCount: Int
 739    @Binding var filter: TicketFilter
 740    let onShowLabels: () -> Void
 741    let onSaveFilter: () -> Void
 742    let onResetFilters: () -> Void
 743    let onApplySavedFilter: (SavedTicketFilter) -> Void
 744    let onDeleteSavedFilter: (SavedTicketFilter) -> Void
 745
 746    var body: some View {
 747        VStack(alignment: .leading, spacing: 10) {
 748            if isSelectionMode {
 749                Text(selectionSummary)
 750                    .font(.subheadline.weight(.medium))
 751                    .foregroundStyle(.secondary)
 752            }
 753
 754            Picker("Filter", selection: $filter) {
 755                ForEach(TicketFilter.allCases, id: \.self) { filter in
 756                    Text(filter.rawValue).tag(filter)
 757                }
 758            }
 759            .pickerStyle(.segmented)
 760            .disabled(isSelectionMode)
 761
 762            TicketQuickFilterBar(
 763                selectedLabels: selectedLabels,
 764                savedFilters: savedFilters,
 765                activeSavedFilterID: activeSavedFilterID,
 766                canSaveCurrentFilter: canSaveCurrentFilter,
 767                onShowLabels: onShowLabels,
 768                onSaveFilter: onSaveFilter,
 769                onResetFilters: onResetFilters,
 770                onApplySavedFilter: onApplySavedFilter,
 771                onDeleteSavedFilter: onDeleteSavedFilter
 772            )
 773            .disabled(isSelectionMode)
 774        }
 775        .padding(.horizontal, 16)
 776        .padding(.top, 6)
 777        .padding(.bottom, 10)
 778    }
 779
 780    private var selectionSummary: String {
 781        selectedTicketCount == 0 ? "Select tickets for bulk actions." : "\(selectedTicketCount) selected"
 782    }
 783}
 784
 785private struct TicketQuickFilterBar: View {
 786    let selectedLabels: [TicketLabel]
 787    let savedFilters: [SavedTicketFilter]
 788    let activeSavedFilterID: SavedTicketFilter.ID?
 789    let canSaveCurrentFilter: Bool
 790    let onShowLabels: () -> Void
 791    let onSaveFilter: () -> Void
 792    let onResetFilters: () -> Void
 793    let onApplySavedFilter: (SavedTicketFilter) -> Void
 794    let onDeleteSavedFilter: (SavedTicketFilter) -> Void
 795
 796    var body: some View {
 797        VStack(alignment: .leading, spacing: 8) {
 798            actionButtons
 799
 800            if !selectedLabels.isEmpty {
 801                ScrollView(.horizontal, showsIndicators: false) {
 802                    HStack(spacing: 8) {
 803                        ForEach(selectedLabels) { label in
 804                            LabelPill(label: label)
 805                        }
 806                    }
 807                }
 808            }
 809
 810            if !savedFilters.isEmpty {
 811                ScrollView(.horizontal, showsIndicators: false) {
 812                    HStack(spacing: 8) {
 813                        ForEach(savedFilters) { savedFilter in
 814                            Button {
 815                                onApplySavedFilter(savedFilter)
 816                            } label: {
 817                                HStack(spacing: 6) {
 818                                    Image(systemName: activeSavedFilterID == savedFilter.id ? "checkmark.circle.fill" : "line.3.horizontal.decrease.circle")
 819                                        .imageScale(.small)
 820                                    Text(savedFilter.name)
 821                                        .lineLimit(1)
 822                                }
 823                                .font(.caption.weight(.medium))
 824                                .padding(.horizontal, 10)
 825                                .padding(.vertical, 7)
 826                                .foregroundStyle(activeSavedFilterID == savedFilter.id ? Color.accentColor : Color.primary)
 827                                .background(
 828                                    activeSavedFilterID == savedFilter.id ?
 829                                    Color.accentColor.opacity(0.14) :
 830                                    Color(.secondarySystemFill),
 831                                    in: Capsule()
 832                                )
 833                            }
 834                            .contextMenu {
 835                                Button(role: .destructive) {
 836                                    onDeleteSavedFilter(savedFilter)
 837                                } label: {
 838                                    Label("Delete Filter", systemImage: "trash")
 839                                }
 840                            }
 841                        }
 842                    }
 843                }
 844            }
 845        }
 846    }
 847
 848    private var labelButtonTitle: String {
 849        selectedLabels.isEmpty ? "Labels" : "Labels (\(selectedLabels.count))"
 850    }
 851
 852    @ViewBuilder
 853    private var actionButtons: some View {
 854        ViewThatFits(in: .horizontal) {
 855            HStack(spacing: 8) {
 856                labelsButton
 857                saveFilterButton
 858                if canSaveCurrentFilter {
 859                    resetButton
 860                }
 861                Spacer(minLength: 0)
 862            }
 863
 864            FlowLayout(spacing: 8) {
 865                labelsButton
 866                saveFilterButton
 867                if canSaveCurrentFilter {
 868                    resetButton
 869                }
 870            }
 871        }
 872    }
 873
 874    private var labelsButton: some View {
 875        Button(action: onShowLabels) {
 876            Label(labelButtonTitle, systemImage: "tag")
 877                .font(.caption.weight(.medium))
 878        }
 879        .buttonStyle(.bordered)
 880    }
 881
 882    private var saveFilterButton: some View {
 883        Button(action: onSaveFilter) {
 884            Label("Save Filter", systemImage: "square.and.arrow.down")
 885                .font(.caption.weight(.medium))
 886        }
 887        .buttonStyle(.bordered)
 888        .disabled(!canSaveCurrentFilter)
 889    }
 890
 891    private var resetButton: some View {
 892        Button("Reset", action: onResetFilters)
 893            .font(.caption.weight(.medium))
 894            .buttonStyle(.bordered)
 895    }
 896}
 897
 898private struct TicketFilterLabelsSheet: View {
 899    let viewModel: TicketListViewModel
 900
 901    @Environment(\.dismiss) private var dismiss
 902
 903    var body: some View {
 904        NavigationStack {
 905            Group {
 906                if viewModel.availableLabels.isEmpty {
 907                    ContentUnavailableView(
 908                        "No Labels",
 909                        systemImage: "tag",
 910                        description: Text("This tracker has no labels available for filtering yet.")
 911                    )
 912                } else {
 913                    List {
 914                        if !viewModel.selectedLabels.isEmpty {
 915                            Section("Selected") {
 916                                FlowLayout(spacing: 6) {
 917                                    ForEach(viewModel.selectedLabels) { label in
 918                                        LabelPill(label: label)
 919                                    }
 920                                }
 921                                .padding(.vertical, 4)
 922                            }
 923                        }
 924
 925                        Section("Labels") {
 926                            ForEach(viewModel.availableLabels) { label in
 927                                Button {
 928                                    viewModel.toggleLabelSelection(label)
 929                                } label: {
 930                                    HStack {
 931                                        LabelPill(label: label)
 932                                        Spacer()
 933                                        if viewModel.selectedLabelIDs.contains(label.id) {
 934                                            Image(systemName: "checkmark")
 935                                                .foregroundStyle(.blue)
 936                                        }
 937                                    }
 938                                }
 939                                .buttonStyle(.plain)
 940                            }
 941                        }
 942                    }
 943                }
 944            }
 945            .navigationTitle("Filter Labels")
 946            .navigationBarTitleDisplayMode(.inline)
 947            .toolbar {
 948                ToolbarItem(placement: .cancellationAction) {
 949                    Button("Done") { dismiss() }
 950                }
 951                if !viewModel.selectedLabelIDs.isEmpty {
 952                    ToolbarItem(placement: .confirmationAction) {
 953                        Button("Clear") { viewModel.clearLabelSelection() }
 954                    }
 955                }
 956            }
 957            .task {
 958                if viewModel.availableLabels.isEmpty {
 959                    await viewModel.loadTrackerLabels()
 960                }
 961            }
 962        }
 963    }
 964}
 965
 966private struct SaveTicketFilterSheet: View {
 967    let viewModel: TicketListViewModel
 968
 969    @Environment(\.dismiss) private var dismiss
 970    @State private var name: String
 971
 972    init(viewModel: TicketListViewModel) {
 973        self.viewModel = viewModel
 974        self._name = State(initialValue: viewModel.suggestedSavedFilterName)
 975    }
 976
 977    var body: some View {
 978        NavigationStack {
 979            Form {
 980                Section("Name") {
 981                    TextField("Filter name", text: $name)
 982                        .textInputAutocapitalization(.words)
 983                }
 984            }
 985            .navigationTitle("Save Filter")
 986            .navigationBarTitleDisplayMode(.inline)
 987            .toolbar {
 988                ToolbarItem(placement: .cancellationAction) {
 989                    Button("Cancel") { dismiss() }
 990                }
 991                ToolbarItem(placement: .confirmationAction) {
 992                    Button("Save") {
 993                        viewModel.saveCurrentFilter(named: name)
 994                        dismiss()
 995                    }
 996                    .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
 997                }
 998            }
 999        }
1000    }
1001}
1002
1003private struct TicketListLabelToggleRow: View {
1004    let label: TicketLabel
1005    let isApplied: Bool
1006    let isLoading: Bool
1007    let onToggle: (Bool) -> Void
1008
1009    var body: some View {
1010        Button {
1011            onToggle(!isApplied)
1012        } label: {
1013            HStack {
1014                LabelPill(label: label)
1015                Spacer()
1016                if isApplied {
1017                    Image(systemName: "checkmark")
1018                        .foregroundStyle(.blue)
1019                }
1020            }
1021        }
1022        .disabled(isLoading)
1023    }
1024}
1025
1026private struct TicketBulkActionBar: View {
1027    let selectedCount: Int
1028    let isPerformingAction: Bool
1029    let onClose: () -> Void
1030    let onAssign: () -> Void
1031
1032    var body: some View {
1033        VStack(spacing: 12) {
1034            HStack {
1035                Text(selectedCount == 0 ? "Select tickets to continue" : "\(selectedCount) selected")
1036                    .font(.subheadline.weight(.medium))
1037                    .foregroundStyle(.secondary)
1038                Spacer()
1039            }
1040
1041            HStack(spacing: 12) {
1042                Button("Close") {
1043                    onClose()
1044                }
1045                .buttonStyle(.borderedProminent)
1046                .tint(.red)
1047                .disabled(selectedCount == 0 || isPerformingAction)
1048
1049                Button("Assign") {
1050                    onAssign()
1051                }
1052                .buttonStyle(.bordered)
1053                .disabled(selectedCount == 0 || isPerformingAction)
1054
1055                if isPerformingAction {
1056                    ProgressView()
1057                        .controlSize(.small)
1058                }
1059            }
1060        }
1061        .padding(.horizontal, 16)
1062        .padding(.top, 12)
1063        .padding(.bottom, 16)
1064        .background(.ultraThinMaterial)
1065    }
1066}
1067
1068private struct BulkResolveSheet: View {
1069    let viewModel: TicketListViewModel
1070    @Binding var isPresented: Bool
1071    let onComplete: (TicketBulkActionResult) -> Void
1072    @State private var selectedResolution: TicketResolution = .fixed
1073
1074    private static let resolutionOptions: [TicketResolution] = [
1075        .closed, .fixed, .implemented, .wontFix,
1076        .byDesign, .invalid, .duplicate, .notOurBug
1077    ]
1078
1079    var body: some View {
1080        NavigationStack {
1081            Form {
1082                Section {
1083                    Text("\(viewModel.selectedTicketCount) ticket\(viewModel.selectedTicketCount == 1 ? "" : "s") selected")
1084                        .font(.subheadline)
1085                        .foregroundStyle(.secondary)
1086                }
1087
1088                Section("Resolution") {
1089                    Picker("Resolution", selection: $selectedResolution) {
1090                        ForEach(Self.resolutionOptions, id: \.self) { resolution in
1091                            Text(resolution.displayName).tag(resolution)
1092                        }
1093                    }
1094                    .pickerStyle(.inline)
1095                    .labelsHidden()
1096                }
1097            }
1098            .navigationTitle("Close Tickets")
1099            .navigationBarTitleDisplayMode(.inline)
1100            .toolbar {
1101                ToolbarItem(placement: .cancellationAction) {
1102                    Button("Cancel") { isPresented = false }
1103                }
1104                ToolbarItem(placement: .confirmationAction) {
1105                    Button("Close") {
1106                        Task {
1107                            if let result = await viewModel.closeSelectedTickets(resolution: selectedResolution) {
1108                                onComplete(result)
1109                                isPresented = false
1110                            }
1111                        }
1112                    }
1113                    .disabled(viewModel.selectedTicketCount == 0 || viewModel.isPerformingAction)
1114                }
1115            }
1116        }
1117    }
1118}
1119
1120private struct BulkAssignSheet: View {
1121    let viewModel: TicketListViewModel
1122    let currentUser: User?
1123    @Binding var isPresented: Bool
1124    let onComplete: (TicketBulkActionResult) -> Void
1125    @State private var username = ""
1126
1127    var body: some View {
1128        NavigationStack {
1129            Form {
1130                Section {
1131                    Text("\(viewModel.selectedTicketCount) ticket\(viewModel.selectedTicketCount == 1 ? "" : "s") selected")
1132                        .font(.subheadline)
1133                        .foregroundStyle(.secondary)
1134                }
1135
1136                if let currentUser {
1137                    Section {
1138                        Button("Assign to Me") {
1139                            Task {
1140                                if let result = await viewModel.assignSelectedTickets(username: currentUser.username) {
1141                                    onComplete(result)
1142                                    isPresented = false
1143                                }
1144                            }
1145                        }
1146                        .disabled(viewModel.selectedTicketCount == 0 || viewModel.isPerformingAction)
1147                    }
1148                }
1149
1150                Section("Assign User") {
1151                    TextField("Username or ~username", text: $username)
1152                        .textContentType(.username)
1153                        .autocorrectionDisabled()
1154                        .textInputAutocapitalization(.never)
1155                }
1156            }
1157            .navigationTitle("Assign Tickets")
1158            .navigationBarTitleDisplayMode(.inline)
1159            .toolbar {
1160                ToolbarItem(placement: .cancellationAction) {
1161                    Button("Cancel") { isPresented = false }
1162                }
1163                ToolbarItem(placement: .confirmationAction) {
1164                    Button("Assign") {
1165                        Task {
1166                            if let result = await viewModel.assignSelectedTickets(username: username) {
1167                                onComplete(result)
1168                                isPresented = false
1169                            }
1170                        }
1171                    }
1172                    .disabled(
1173                        username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
1174                        || viewModel.selectedTicketCount == 0
1175                        || viewModel.isPerformingAction
1176                    )
1177                }
1178            }
1179        }
1180    }
1181}
1182
1183private struct SelectableTicketRow: View {
1184    let ticket: TicketSummary
1185    let isSelected: Bool
1186
1187    var body: some View {
1188        HStack(alignment: .top, spacing: 12) {
1189            Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
1190                .font(.title3)
1191                .foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
1192                .padding(.top, 4)
1193
1194            TicketRowView(ticket: ticket)
1195        }
1196        .padding(.vertical, 2)
1197        .contentShape(Rectangle())
1198    }
1199}
1200
1201// MARK: - Ticket Row
1202
1203private struct TicketRowView: View {
1204    let ticket: TicketSummary
1205
1206    var body: some View {
1207        VStack(alignment: .leading, spacing: 6) {
1208            HStack(alignment: .top) {
1209                TicketStatusIcon(status: ticket.status)
1210                    .frame(width: 20)
1211
1212                VStack(alignment: .leading, spacing: 2) {
1213                    Text("#\(ticket.id)")
1214                        .font(.caption.monospaced())
1215                        .foregroundStyle(.secondary)
1216                    + Text(" ")
1217                    + Text(ticket.title)
1218                        .font(.subheadline)
1219                }
1220
1221                Spacer()
1222
1223                TicketStatusBadge(status: ticket.status)
1224            }
1225
1226            HStack(spacing: 8) {
1227                Text(ticket.submitter.canonicalName)
1228                    .font(.caption)
1229                    .foregroundStyle(.secondary)
1230
1231                Spacer()
1232
1233                Text(ticket.created.relativeDescription)
1234                    .font(.caption)
1235                    .foregroundStyle(.tertiary)
1236            }
1237
1238            if !ticket.labels.isEmpty {
1239                FlowLayout(spacing: 4) {
1240                    ForEach(ticket.labels) { label in
1241                        LabelPill(label: label)
1242                    }
1243                }
1244            }
1245
1246            if !ticket.assignees.isEmpty {
1247                HStack(spacing: 4) {
1248                    Image(systemName: "person.fill")
1249                        .font(.caption2)
1250                        .foregroundStyle(.secondary)
1251                    Text(ticket.assignees.map(\.canonicalName).joined(separator: ", "))
1252                        .font(.caption)
1253                        .foregroundStyle(.secondary)
1254                        .lineLimit(1)
1255                }
1256            }
1257        }
1258        .padding(.vertical, 2)
1259    }
1260}
1261
1262// MARK: - Ticket Status Badge
1263
1264private struct TicketStatusBadge: View {
1265    let status: TicketStatus
1266
1267    var body: some View {
1268        Text(status.displayName)
1269            .font(.caption2.weight(.semibold))
1270            .padding(.horizontal, 6)
1271            .padding(.vertical, 2)
1272            .foregroundStyle(color)
1273            .background(color.opacity(0.12))
1274            .clipShape(Capsule())
1275    }
1276
1277    private var color: Color {
1278        switch status {
1279        case .reported:   .gray
1280        case .confirmed:  .blue
1281        case .inProgress: .yellow
1282        case .pending:    .orange
1283        case .resolved:   .green
1284        }
1285    }
1286}
1287
1288// MARK: - Ticket Status Icon
1289
1290struct TicketStatusIcon: View {
1291    let status: TicketStatus
1292
1293    var body: some View {
1294        Image(systemName: iconName)
1295            .foregroundStyle(color)
1296    }
1297
1298    private var iconName: String {
1299        switch status {
1300        case .reported:   "circle"
1301        case .confirmed:  "circle.inset.filled"
1302        case .inProgress: "arrow.trianglehead.2.clockwise.rotate.90"
1303        case .pending:    "clock.fill"
1304        case .resolved:   "checkmark.circle.fill"
1305        }
1306    }
1307
1308    private var color: Color {
1309        switch status {
1310        case .reported:   .gray
1311        case .confirmed:  .blue
1312        case .inProgress: .yellow
1313        case .pending:    .orange
1314        case .resolved:   .green
1315        }
1316    }
1317}
1318
1319// MARK: - Label Pill
1320
1321struct LabelPill: View {
1322    let label: TicketLabel
1323
1324    var body: some View {
1325        Text(label.name)
1326            .font(.caption.weight(.medium))
1327            .padding(.horizontal, 8)
1328            .padding(.vertical, 3)
1329            .background(backgroundColor)
1330            .foregroundStyle(foregroundColor)
1331            .clipShape(Capsule())
1332    }
1333
1334    private var backgroundColor: Color {
1335        Color(hex: label.backgroundColor) ?? .gray.opacity(0.2)
1336    }
1337
1338    private var foregroundColor: Color {
1339        Color(hex: label.foregroundColor) ?? .primary
1340    }
1341}
1342
1343// MARK: - Color from hex string
1344
1345extension Color {
1346    init?(hex: String) {
1347        var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines)
1348        if hexString.hasPrefix("#") {
1349            hexString.removeFirst()
1350        }
1351
1352        guard hexString.count == 6,
1353              let hexNumber = UInt64(hexString, radix: 16) else {
1354            return nil
1355        }
1356
1357        let r = Double((hexNumber & 0xFF0000) >> 16) / 255
1358        let g = Double((hexNumber & 0x00FF00) >> 8) / 255
1359        let b = Double(hexNumber & 0x0000FF) / 255
1360
1361        self.init(red: r, green: g, blue: b)
1362    }
1363
1364    /// Returns a `#rrggbb` hex string for this color.
1365    var hexString: String {
1366        let resolved = resolve(in: .init())
1367        let r = Int(max(0, min(1, resolved.red)) * 255)
1368        let g = Int(max(0, min(1, resolved.green)) * 255)
1369        let b = Int(max(0, min(1, resolved.blue)) * 255)
1370        return String(format: "#%02x%02x%02x", r, g, b)
1371    }
1372}
1373
1374// MARK: - Flow Layout (for label pills)
1375
1376struct FlowLayout: Layout {
1377    var spacing: CGFloat = 4
1378
1379    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
1380        let result = layoutSubviews(proposal: proposal, subviews: subviews)
1381        return result.size
1382    }
1383
1384    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
1385        let result = layoutSubviews(proposal: proposal, subviews: subviews)
1386        for (index, position) in result.positions.enumerated() {
1387            subviews[index].place(
1388                at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y),
1389                proposal: ProposedViewSize(result.sizes[index])
1390            )
1391        }
1392    }
1393
1394    private struct LayoutResult {
1395        var positions: [CGPoint]
1396        var sizes: [CGSize]
1397        var size: CGSize
1398    }
1399
1400    private func layoutSubviews(proposal: ProposedViewSize, subviews: Subviews) -> LayoutResult {
1401        let maxWidth = proposal.width ?? .infinity
1402        var positions: [CGPoint] = []
1403        var sizes: [CGSize] = []
1404        var currentX: CGFloat = 0
1405        var currentY: CGFloat = 0
1406        var lineHeight: CGFloat = 0
1407        var totalHeight: CGFloat = 0
1408        var totalWidth: CGFloat = 0
1409
1410        for subview in subviews {
1411            let size = subview.sizeThatFits(.unspecified)
1412            sizes.append(size)
1413
1414            if currentX + size.width > maxWidth, currentX > 0 {
1415                currentX = 0
1416                currentY += lineHeight + spacing
1417                lineHeight = 0
1418            }
1419
1420            positions.append(CGPoint(x: currentX, y: currentY))
1421            lineHeight = max(lineHeight, size.height)
1422            currentX += size.width + spacing
1423            totalWidth = max(totalWidth, currentX - spacing)
1424            totalHeight = currentY + lineHeight
1425        }
1426
1427        return LayoutResult(
1428            positions: positions,
1429            sizes: sizes,
1430            size: CGSize(width: totalWidth, height: totalHeight)
1431        )
1432    }
1433}