krz/hutch

an ios client for sourcehut

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

main: 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                            .accessibilityAddTraits(
 861                                activeSavedFilterID == savedFilter.id ? [.isSelected] : []
 862                            )
 863                            .contextMenu {
 864                                Button(role: .destructive) {
 865                                    onDeleteSavedFilter(savedFilter)
 866                                } label: {
 867                                    Label("Delete Filter", systemImage: "trash")
 868                                }
 869                            }
 870                        }
 871                    }
 872                }
 873            }
 874        }
 875    }
 876
 877    private var labelButtonTitle: String {
 878        selectedLabels.isEmpty ? "Labels" : "Labels (\(selectedLabels.count))"
 879    }
 880
 881    @ViewBuilder
 882    private var actionButtons: some View {
 883        ViewThatFits(in: .horizontal) {
 884            HStack(spacing: 8) {
 885                labelsButton
 886                saveFilterButton
 887                if canSaveCurrentFilter {
 888                    resetButton
 889                }
 890                Spacer(minLength: 0)
 891            }
 892
 893            FlowLayout(spacing: 8) {
 894                labelsButton
 895                saveFilterButton
 896                if canSaveCurrentFilter {
 897                    resetButton
 898                }
 899            }
 900        }
 901    }
 902
 903    private var labelsButton: some View {
 904        Button(action: onShowLabels) {
 905            Label(labelButtonTitle, systemImage: "tag")
 906                .font(.caption.weight(.medium))
 907        }
 908        .buttonStyle(.bordered)
 909    }
 910
 911    private var saveFilterButton: some View {
 912        Button(action: onSaveFilter) {
 913            Label("Save Filter", systemImage: "square.and.arrow.down")
 914                .font(.caption.weight(.medium))
 915        }
 916        .buttonStyle(.bordered)
 917        .disabled(!canSaveCurrentFilter)
 918    }
 919
 920    private var resetButton: some View {
 921        Button("Reset", action: onResetFilters)
 922            .font(.caption.weight(.medium))
 923            .buttonStyle(.bordered)
 924    }
 925}
 926
 927private struct TicketFilterLabelsSheet: View {
 928    let viewModel: TicketListViewModel
 929
 930    @Environment(\.dismiss) private var dismiss
 931
 932    var body: some View {
 933        NavigationStack {
 934            Group {
 935                if viewModel.availableLabels.isEmpty {
 936                    ContentUnavailableView(
 937                        "No Labels",
 938                        systemImage: "tag",
 939                        description: Text("This tracker has no labels available for filtering yet.")
 940                    )
 941                } else {
 942                    List {
 943                        if !viewModel.selectedLabels.isEmpty {
 944                            Section("Selected") {
 945                                FlowLayout(spacing: 6) {
 946                                    ForEach(viewModel.selectedLabels) { label in
 947                                        LabelPill(label: label)
 948                                    }
 949                                }
 950                                .padding(.vertical, 4)
 951                                .themedRow()
 952                            }
 953                        }
 954
 955                        Section("Labels") {
 956                            ForEach(viewModel.availableLabels) { label in
 957                                Button {
 958                                    viewModel.toggleLabelSelection(label)
 959                                } label: {
 960                                    HStack {
 961                                        LabelPill(label: label)
 962                                        Spacer()
 963                                        if viewModel.selectedLabelIDs.contains(label.id) {
 964                                            Image(systemName: "checkmark")
 965                                                .foregroundStyle(.blue)
 966                                        }
 967                                    }
 968                                }
 969                                .buttonStyle(.plain)
 970                                .accessibilityAddTraits(
 971                                    viewModel.selectedLabelIDs.contains(label.id) ? [.isSelected] : []
 972                                )
 973                            }
 974                            .themedRow()
 975                        }
 976                    }
 977                    .themedList()
 978                }
 979            }
 980            .navigationTitle("Filter Labels")
 981            .navigationBarTitleDisplayMode(.inline)
 982            .toolbar {
 983                ToolbarItem(placement: .cancellationAction) {
 984                    Button("Done") { dismiss() }
 985                }
 986                if !viewModel.selectedLabelIDs.isEmpty {
 987                    ToolbarItem(placement: .confirmationAction) {
 988                        Button("Clear") { viewModel.clearLabelSelection() }
 989                    }
 990                }
 991            }
 992            .task {
 993                if viewModel.availableLabels.isEmpty {
 994                    await viewModel.loadTrackerLabels()
 995                }
 996            }
 997        }
 998    }
 999}
1000
1001private struct SaveTicketFilterSheet: View {
1002    let viewModel: TicketListViewModel
1003
1004    @Environment(\.dismiss) private var dismiss
1005    @State private var name: String
1006
1007    init(viewModel: TicketListViewModel) {
1008        self.viewModel = viewModel
1009        self._name = State(initialValue: viewModel.suggestedSavedFilterName)
1010    }
1011
1012    var body: some View {
1013        NavigationStack {
1014            Form {
1015                Section("Name") {
1016                    TextField("Filter name", text: $name)
1017                        .textInputAutocapitalization(.words)
1018                        .themedRow()
1019                }
1020            }
1021            .themedList()
1022            .navigationTitle("Save Filter")
1023            .navigationBarTitleDisplayMode(.inline)
1024            .toolbar {
1025                ToolbarItem(placement: .cancellationAction) {
1026                    Button("Cancel") { dismiss() }
1027                }
1028                ToolbarItem(placement: .confirmationAction) {
1029                    Button("Save") {
1030                        viewModel.saveCurrentFilter(named: name)
1031                        dismiss()
1032                    }
1033                    .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
1034                }
1035            }
1036        }
1037    }
1038}
1039
1040private struct TicketListLabelToggleRow: View {
1041    let label: TicketLabel
1042    let isApplied: Bool
1043    let isLoading: Bool
1044    let onToggle: (Bool) -> Void
1045
1046    var body: some View {
1047        Button {
1048            onToggle(!isApplied)
1049        } label: {
1050            HStack {
1051                LabelPill(label: label)
1052                Spacer()
1053                if isApplied {
1054                    Image(systemName: "checkmark")
1055                        .foregroundStyle(.blue)
1056                }
1057            }
1058        }
1059        .disabled(isLoading)
1060        .accessibilityAddTraits(isApplied ? [.isSelected] : [])
1061    }
1062}
1063
1064private struct TicketBulkActionBar: View {
1065    let selectedCount: Int
1066    let isPerformingAction: Bool
1067    let onClose: () -> Void
1068    let onAssign: () -> Void
1069
1070    var body: some View {
1071        VStack(spacing: 12) {
1072            HStack {
1073                Text(selectedCount == 0 ? "Select tickets to continue" : "\(selectedCount) selected")
1074                    .font(.subheadline.weight(.medium))
1075                    .foregroundStyle(.secondary)
1076                Spacer()
1077            }
1078
1079            HStack(spacing: 12) {
1080                Button("Close") {
1081                    onClose()
1082                }
1083                .buttonStyle(.borderedProminent)
1084                .tint(.red)
1085                .disabled(selectedCount == 0 || isPerformingAction)
1086
1087                Button("Assign") {
1088                    onAssign()
1089                }
1090                .buttonStyle(.bordered)
1091                .disabled(selectedCount == 0 || isPerformingAction)
1092
1093                if isPerformingAction {
1094                    ProgressView()
1095                        .controlSize(.small)
1096                }
1097            }
1098        }
1099        .padding(.horizontal, 16)
1100        .padding(.top, 12)
1101        .padding(.bottom, 16)
1102        .background(.ultraThinMaterial)
1103    }
1104}
1105
1106private struct BulkResolveSheet: View {
1107    let viewModel: TicketListViewModel
1108    @Binding var isPresented: Bool
1109    let onComplete: (TicketBulkActionResult) -> Void
1110    @State private var selectedResolution: TicketResolution = .fixed
1111
1112    private static let resolutionOptions: [TicketResolution] = [
1113        .closed, .fixed, .implemented, .wontFix,
1114        .byDesign, .invalid, .duplicate, .notOurBug
1115    ]
1116
1117    var body: some View {
1118        NavigationStack {
1119            Form {
1120                Section {
1121                    Text("\(viewModel.selectedTicketCount) ticket\(viewModel.selectedTicketCount == 1 ? "" : "s") selected")
1122                        .font(.subheadline)
1123                        .foregroundStyle(.secondary)
1124                        .themedRow()
1125                }
1126
1127                Section("Resolution") {
1128                    Picker("Resolution", selection: $selectedResolution) {
1129                        ForEach(Self.resolutionOptions, id: \.self) { resolution in
1130                            Text(resolution.displayName).tag(resolution)
1131                        }
1132                    }
1133                    .pickerStyle(.inline)
1134                    .labelsHidden()
1135                    .themedRow()
1136                }
1137            }
1138            .themedList()
1139            .navigationTitle("Close Tickets")
1140            .navigationBarTitleDisplayMode(.inline)
1141            .toolbar {
1142                ToolbarItem(placement: .cancellationAction) {
1143                    Button("Cancel") { isPresented = false }
1144                }
1145                ToolbarItem(placement: .confirmationAction) {
1146                    Button("Close") {
1147                        Task {
1148                            if let result = await viewModel.closeSelectedTickets(resolution: selectedResolution) {
1149                                onComplete(result)
1150                                isPresented = false
1151                            }
1152                        }
1153                    }
1154                    .disabled(viewModel.selectedTicketCount == 0 || viewModel.isPerformingAction)
1155                }
1156            }
1157        }
1158    }
1159}
1160
1161private struct BulkAssignSheet: View {
1162    let viewModel: TicketListViewModel
1163    let currentUser: User?
1164    @Binding var isPresented: Bool
1165    let onComplete: (TicketBulkActionResult) -> Void
1166    @State private var username = ""
1167
1168    var body: some View {
1169        NavigationStack {
1170            Form {
1171                Section {
1172                    Text("\(viewModel.selectedTicketCount) ticket\(viewModel.selectedTicketCount == 1 ? "" : "s") selected")
1173                        .font(.subheadline)
1174                        .foregroundStyle(.secondary)
1175                        .themedRow()
1176                }
1177
1178                if let currentUser {
1179                    Section {
1180                        Button("Assign to Me") {
1181                            Task {
1182                                if let result = await viewModel.assignSelectedTickets(username: currentUser.username) {
1183                                    onComplete(result)
1184                                    isPresented = false
1185                                }
1186                            }
1187                        }
1188                        .disabled(viewModel.selectedTicketCount == 0 || viewModel.isPerformingAction)
1189                        .themedRow()
1190                    }
1191                }
1192
1193                Section("Assign User") {
1194                    TextField("Username or ~username", text: $username)
1195                        .textContentType(.username)
1196                        .autocorrectionDisabled()
1197                        .textInputAutocapitalization(.never)
1198                        .themedRow()
1199                }
1200            }
1201            .themedList()
1202            .navigationTitle("Assign Tickets")
1203            .navigationBarTitleDisplayMode(.inline)
1204            .toolbar {
1205                ToolbarItem(placement: .cancellationAction) {
1206                    Button("Cancel") { isPresented = false }
1207                }
1208                ToolbarItem(placement: .confirmationAction) {
1209                    Button("Assign") {
1210                        Task {
1211                            if let result = await viewModel.assignSelectedTickets(username: username) {
1212                                onComplete(result)
1213                                isPresented = false
1214                            }
1215                        }
1216                    }
1217                    .disabled(
1218                        username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
1219                        || viewModel.selectedTicketCount == 0
1220                        || viewModel.isPerformingAction
1221                    )
1222                }
1223            }
1224        }
1225    }
1226}
1227
1228private struct SelectableTicketRow: View, Equatable {
1229    let ticket: TicketSummary
1230    let isSelected: Bool
1231
1232    var body: some View {
1233        HStack(alignment: .top, spacing: 12) {
1234            Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
1235                .font(.title3)
1236                .foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
1237                .padding(.top, 4)
1238
1239            TicketRowView(ticket: ticket)
1240        }
1241        .padding(.vertical, 2)
1242        .contentShape(Rectangle())
1243    }
1244}
1245
1246// MARK: - Ticket Row
1247
1248private struct TicketRowView: View, Equatable {
1249    let ticket: TicketSummary
1250
1251    var body: some View {
1252        VStack(alignment: .leading, spacing: 6) {
1253            HStack(alignment: .top) {
1254                TicketStatusIcon(status: ticket.status)
1255                    .frame(width: 20)
1256
1257                VStack(alignment: .leading, spacing: 2) {
1258                    Text("#\(ticket.id)")
1259                        .font(.caption.monospaced())
1260                        .foregroundStyle(.secondary)
1261                    + Text(" ")
1262                    + Text(ticket.title)
1263                        .font(.subheadline)
1264                }
1265
1266                Spacer()
1267
1268                TicketStatusBadge(status: ticket.status)
1269            }
1270
1271            HStack(spacing: 8) {
1272                Text(ticket.submitter.canonicalName)
1273                    .font(.caption)
1274                    .foregroundStyle(.secondary)
1275
1276                Spacer()
1277
1278                Text(ticket.created.relativeDescription)
1279                    .font(.caption)
1280                    .foregroundStyle(.tertiary)
1281            }
1282
1283            if !ticket.labels.isEmpty {
1284                FlowLayout(spacing: 4) {
1285                    ForEach(ticket.labels) { label in
1286                        LabelPill(label: label)
1287                    }
1288                }
1289            }
1290
1291            if !ticket.assignees.isEmpty {
1292                HStack(spacing: 4) {
1293                    Image(systemName: "person.fill")
1294                        .font(.caption2)
1295                        .foregroundStyle(.secondary)
1296                    Text(ticket.assignees.map(\.canonicalName).joined(separator: ", "))
1297                        .font(.caption)
1298                        .foregroundStyle(.secondary)
1299                        .lineLimit(1)
1300                }
1301            }
1302        }
1303        .padding(.vertical, 2)
1304    }
1305}
1306
1307// MARK: - Ticket Status Badge
1308
1309private struct TicketStatusBadge: View {
1310    let status: TicketStatus
1311
1312    var body: some View {
1313        Text(status.displayName)
1314            .font(.caption2.weight(.semibold))
1315            .padding(.horizontal, 6)
1316            .padding(.vertical, 2)
1317            .foregroundStyle(color)
1318            .background(color.opacity(0.12))
1319            .clipShape(Capsule())
1320    }
1321
1322    private var color: Color {
1323        switch status {
1324        case .reported:   .gray
1325        case .confirmed:  .blue
1326        case .inProgress: .yellow
1327        case .pending:    .orange
1328        case .resolved:   .green
1329        }
1330    }
1331}
1332
1333// MARK: - Ticket Status Icon
1334
1335struct TicketStatusIcon: View {
1336    let status: TicketStatus
1337
1338    var body: some View {
1339        Image(systemName: iconName)
1340            .foregroundStyle(color)
1341    }
1342
1343    private var iconName: String {
1344        switch status {
1345        case .reported:   "circle"
1346        case .confirmed:  "circle.inset.filled"
1347        case .inProgress: "arrow.trianglehead.2.clockwise.rotate.90"
1348        case .pending:    "clock.fill"
1349        case .resolved:   "checkmark.circle.fill"
1350        }
1351    }
1352
1353    private var color: Color {
1354        switch status {
1355        case .reported:   .gray
1356        case .confirmed:  .blue
1357        case .inProgress: .yellow
1358        case .pending:    .orange
1359        case .resolved:   .green
1360        }
1361    }
1362}
1363
1364// MARK: - Label Pill
1365
1366struct LabelPill: View {
1367    let label: TicketLabel
1368
1369    var body: some View {
1370        Text(label.name)
1371            .font(.caption.weight(.medium))
1372            .padding(.horizontal, 8)
1373            .padding(.vertical, 3)
1374            .background(backgroundColor)
1375            .foregroundStyle(foregroundColor)
1376            .clipShape(Capsule())
1377    }
1378
1379    private var backgroundColor: Color {
1380        Color(hex: label.backgroundColor) ?? .gray.opacity(0.2)
1381    }
1382
1383    private var foregroundColor: Color {
1384        Color(hex: label.foregroundColor) ?? .primary
1385    }
1386}
1387
1388// MARK: - Color from hex string
1389
1390extension Color {
1391    init?(hex: String) {
1392        var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines)
1393        if hexString.hasPrefix("#") {
1394            hexString.removeFirst()
1395        }
1396
1397        guard hexString.count == 6,
1398              let hexNumber = UInt64(hexString, radix: 16) else {
1399            return nil
1400        }
1401
1402        let r = Double((hexNumber & 0xFF0000) >> 16) / 255
1403        let g = Double((hexNumber & 0x00FF00) >> 8) / 255
1404        let b = Double(hexNumber & 0x0000FF) / 255
1405
1406        self.init(red: r, green: g, blue: b)
1407    }
1408
1409    /// Returns a `#rrggbb` hex string for this color.
1410    var hexString: String {
1411        let resolved = resolve(in: .init())
1412        let r = Int(max(0, min(1, resolved.red)) * 255)
1413        let g = Int(max(0, min(1, resolved.green)) * 255)
1414        let b = Int(max(0, min(1, resolved.blue)) * 255)
1415        return String(format: "#%02x%02x%02x", r, g, b)
1416    }
1417}
1418
1419// MARK: - Flow Layout (for label pills)
1420
1421struct FlowLayout: Layout {
1422    var spacing: CGFloat = 4
1423
1424    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
1425        _ = cache
1426        let result = layoutSubviews(proposal: proposal, subviews: subviews)
1427        return result.size
1428    }
1429
1430    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
1431        _ = cache
1432        let result = layoutSubviews(proposal: proposal, subviews: subviews)
1433        for (index, position) in result.positions.enumerated() {
1434            subviews[index].place(
1435                at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y),
1436                proposal: ProposedViewSize(result.sizes[index])
1437            )
1438        }
1439    }
1440
1441    private struct LayoutResult {
1442        var positions: [CGPoint]
1443        var sizes: [CGSize]
1444        var size: CGSize
1445    }
1446
1447    private func layoutSubviews(proposal: ProposedViewSize, subviews: Subviews) -> LayoutResult {
1448        let maxWidth = proposal.width ?? .infinity
1449        var positions: [CGPoint] = []
1450        var sizes: [CGSize] = []
1451        var currentX: CGFloat = 0
1452        var currentY: CGFloat = 0
1453        var lineHeight: CGFloat = 0
1454        var totalHeight: CGFloat = 0
1455        var totalWidth: CGFloat = 0
1456
1457        for subview in subviews {
1458            let size = subview.sizeThatFits(.unspecified)
1459            sizes.append(size)
1460
1461            if currentX + size.width > maxWidth, currentX > 0 {
1462                currentX = 0
1463                currentY += lineHeight + spacing
1464                lineHeight = 0
1465            }
1466
1467            positions.append(CGPoint(x: currentX, y: currentY))
1468            lineHeight = max(lineHeight, size.height)
1469            currentX += size.width + spacing
1470            totalWidth = max(totalWidth, currentX - spacing)
1471            totalHeight = currentY + lineHeight
1472        }
1473
1474        return LayoutResult(
1475            positions: positions,
1476            sizes: sizes,
1477            size: CGSize(width: totalWidth, height: totalHeight)
1478        )
1479    }
1480}