krz/hutch

an ios client for sourcehut

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

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

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