krz/hutch

an ios client for sourcehut

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

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