krz/hutch

an ios client for sourcehut

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

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