diff --git a/WooCommerce/Classes/Bookings/BookingFilters/BookableProductListSyncable.swift b/WooCommerce/Classes/Bookings/BookingFilters/BookableProductListSyncable.swift index d64376003bc..af3c34b91b9 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/BookableProductListSyncable.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/BookableProductListSyncable.swift @@ -5,6 +5,7 @@ import Yosemite struct BookableProductListSyncable: ListSyncable { typealias StorageType = StorageProduct typealias ModelType = Product + typealias ListFilterType = BookingProductFilter let siteID: Int64 @@ -50,6 +51,10 @@ struct BookableProductListSyncable: ListSyncable { func displayName(for item: Product) -> String { item.name } + + func filterItem(for item: Product) -> BookingProductFilter { + BookingProductFilter(productID: item.productID, name: item.name) + } } private extension BookableProductListSyncable { diff --git a/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift b/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift index ebb19260d3b..a4cf9d02dcf 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift @@ -35,18 +35,18 @@ final class BookingFiltersViewModel: FilterListViewModel { } var criteria: Filters { - let teamMember = teamMemberFilterViewModel.selectedValue as? BookingResource - let product = productFilterViewModel.selectedValue as? BookingProductFilter + let teamMembers = (teamMemberFilterViewModel.selectedValue as? MultipleFilterSelection)?.items as? [BookingResource] ?? [] + let products = (productFilterViewModel.selectedValue as? MultipleFilterSelection)?.items as? [BookingProductFilter] ?? [] let customer = customerFilterViewModel.selectedValue as? CustomerFilter - let attendanceStatus = attendanceStatusFilterViewModel.selectedValue as? BookingAttendanceStatus - let paymentStatus = paymentStatusFilterViewModel.selectedValue as? BookingStatus + let attendanceStatuses = (attendanceStatusFilterViewModel.selectedValue as? MultipleFilterSelection)?.items as? [BookingAttendanceStatus] ?? [] + let paymentStatuses = (paymentStatusFilterViewModel.selectedValue as? MultipleFilterSelection)?.items as? [BookingStatus] ?? [] let dateRange = dateTimeFilterViewModel.selectedValue as? BookingDateRangeFilter let numberOfActiveFilters = filterTypeViewModels.numberOfActiveFilters - return Filters(teamMember: teamMember, - product: product, - attendanceStatus: attendanceStatus, - paymentStatus: paymentStatus, + return Filters(teamMembers: teamMembers, + products: products, + attendanceStatuses: attendanceStatuses, + paymentStatuses: paymentStatuses, customer: customer, dateRange: dateRange, numberOfActiveFilters: numberOfActiveFilters) @@ -86,55 +86,47 @@ final class BookingFiltersViewModel: FilterListViewModel { struct Filters: Equatable, HumanReadable { - let teamMember: BookingResource? - let product: BookingProductFilter? - let attendanceStatus: BookingAttendanceStatus? - let paymentStatus: BookingStatus? + let teamMembers: [BookingResource] + let products: [BookingProductFilter] + let attendanceStatuses: [BookingAttendanceStatus] + let paymentStatuses: [BookingStatus] let customer: CustomerFilter? let dateRange: BookingDateRangeFilter? let numberOfActiveFilters: Int init() { - teamMember = nil - product = nil - attendanceStatus = nil - paymentStatus = nil + teamMembers = [] + products = [] + attendanceStatuses = [] + paymentStatuses = [] customer = nil dateRange = nil numberOfActiveFilters = 0 } - init(teamMember: BookingResource?, - product: BookingProductFilter?, - attendanceStatus: BookingAttendanceStatus?, - paymentStatus: BookingStatus?, + init(teamMembers: [BookingResource], + products: [BookingProductFilter], + attendanceStatuses: [BookingAttendanceStatus], + paymentStatuses: [BookingStatus], customer: CustomerFilter?, dateRange: BookingDateRangeFilter?, numberOfActiveFilters: Int) { - self.teamMember = teamMember - self.product = product - self.attendanceStatus = attendanceStatus - self.paymentStatus = paymentStatus + self.teamMembers = teamMembers + self.products = products + self.attendanceStatuses = attendanceStatuses + self.paymentStatuses = paymentStatuses self.customer = customer self.dateRange = dateRange self.numberOfActiveFilters = numberOfActiveFilters } var readableString: String { - var readable: [String] = [] - if let teamMember { - readable.append(teamMember.name) - } - if let product { - readable.append(product.name) - } - if let attendanceStatus { - readable.append(attendanceStatus.localizedTitle) - } - if let paymentStatus { - readable.append(paymentStatus.localizedTitle) - } + var readable: [String] = teamMembers.map { $0.name } + + products.map { $0.name } + + attendanceStatuses.map { $0.localizedTitle } + + paymentStatuses.map { $0.localizedTitle } + if let customer { readable.append(customer.description) } @@ -185,25 +177,25 @@ extension BookingFiltersViewModel.BookingListFilter { case .teamMember(let siteID): return FilterTypeViewModel(title: title, listSelectorConfig: .bookingResource(siteID: siteID), - selectedValue: filters.teamMember) + selectedValue: MultipleFilterSelection(items: filters.teamMembers)) case .product(let siteID): return FilterTypeViewModel(title: title, listSelectorConfig: .bookableProduct(siteID: siteID), - selectedValue: filters.product) + selectedValue: MultipleFilterSelection(items: filters.products)) case .customer(let siteID): return FilterTypeViewModel(title: title, listSelectorConfig: .customer(siteID: siteID, source: .booking), selectedValue: filters.customer) case .attendanceStatus: - let options: [BookingAttendanceStatus?] = [nil, .booked, .checkedIn, .cancelled, .noShow] + let options: [BookingAttendanceStatus?] = [.booked, .checkedIn, .cancelled, .noShow] return FilterTypeViewModel(title: title, - listSelectorConfig: .staticOptions(options: options), - selectedValue: filters.attendanceStatus) + listSelectorConfig: .multiSelectStaticOptions(options: options), + selectedValue: MultipleFilterSelection(items: filters.attendanceStatuses)) case .paymentStatus: - let options: [BookingStatus?] = [nil, .complete, .paid, .unpaid, .cancelled, .pendingConfirmation, .confirmed] + let options: [BookingStatus?] = [.complete, .paid, .unpaid, .cancelled, .pendingConfirmation, .confirmed] return FilterTypeViewModel(title: title, - listSelectorConfig: .staticOptions(options: options), - selectedValue: filters.paymentStatus) + listSelectorConfig: .multiSelectStaticOptions(options: options), + selectedValue: MultipleFilterSelection(items: filters.paymentStatuses)) case .dateTime: return FilterTypeViewModel(title: title, listSelectorConfig: .bookingDateTime, diff --git a/WooCommerce/Classes/Bookings/BookingFilters/ListSyncable.swift b/WooCommerce/Classes/Bookings/BookingFilters/ListSyncable.swift index 3056fc0a39e..783f41a5703 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/ListSyncable.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/ListSyncable.swift @@ -6,6 +6,7 @@ import Yosemite protocol ListSyncable { associatedtype StorageType: ResultsControllerMutableType associatedtype ModelType: Equatable & Hashable where ModelType == StorageType.ReadOnlyType + associatedtype ListFilterType: FilterType & Equatable var title: String { get } var emptyStateMessage: String { get } @@ -27,4 +28,7 @@ protocol ListSyncable { /// Returns the display name for an item func displayName(for item: ModelType) -> String + + /// Returns the filter type for an item + func filterItem(for item: ModelType) -> ListFilterType } diff --git a/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorView.swift b/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorView.swift index 23c8ccaee95..67a2fb549a4 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorView.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorView.swift @@ -2,21 +2,20 @@ import SwiftUI struct SyncableListSelectorView: View { @ObservedObject private var viewModel: SyncableListSelectorViewModel - @State var selectedItem: Syncable.ModelType? + @State private var selectedItems: [Syncable.ListFilterType] private let syncable: Syncable - private let initialSelection: (Syncable.ModelType?) -> Bool - private let onSelection: (Syncable.ModelType?) -> Void + private let onSelection: ([Syncable.ListFilterType]) -> Void private let viewPadding: CGFloat = 16 init(viewModel: SyncableListSelectorViewModel, syncable: Syncable, - initialSelection: @escaping (Syncable.ModelType?) -> Bool, - onSelection: @escaping (Syncable.ModelType?) -> Void) { + initialSelections: [Syncable.ListFilterType], + onSelection: @escaping ([Syncable.ListFilterType]) -> Void) { self.viewModel = viewModel self.syncable = syncable - self.initialSelection = initialSelection + self.selectedItems = initialSelections self.onSelection = onSelection } @@ -37,9 +36,6 @@ struct SyncableListSelectorView: View { } .navigationTitle(syncable.title) .navigationBarTitleDisplayMode(.inline) - .onChange(of: selectedItem) { _, newValue in - onSelection(newValue) - } } } @@ -63,14 +59,17 @@ private extension SyncableListSelectorView { value: "Any", comment: "Option to select no filter on a list selector view" ), - isSelected: selectedItem == nil, - onSelection: { selectedItem = nil } + isSelected: selectedItems.isEmpty, + onSelection: { + selectedItems.removeAll() + onSelection([]) + } ) ForEach(items, id: \.self) { item in optionRow(text: syncable.displayName(for: item), - isSelected: isItemSelected(item), - onSelection: { selectedItem = item }) + isSelected: selectedItems.contains(where: { $0 == syncable.filterItem(for: item) }), + onSelection: { toggleSelection(for: item) }) } InfiniteScrollIndicator(showContent: viewModel.shouldShowBottomActivityIndicator) @@ -83,11 +82,14 @@ private extension SyncableListSelectorView { .background(Color(.listBackground)) } - func isItemSelected(_ item: Syncable.ModelType?) -> Bool { - if let selectedItem { - return item == selectedItem + func toggleSelection(for item: Syncable.ModelType) { + let filterItem = syncable.filterItem(for: item) + if let index = selectedItems.firstIndex(of: filterItem) { + selectedItems.remove(at: index) + } else { + selectedItems.append(filterItem) } - return initialSelection(item) + onSelection(selectedItems) } func optionRow(text: String, isSelected: Bool, onSelection: @escaping () -> Void) -> some View { diff --git a/WooCommerce/Classes/Bookings/BookingFilters/TeamMemberListSyncable.swift b/WooCommerce/Classes/Bookings/BookingFilters/TeamMemberListSyncable.swift index f6b30c48135..d115f4e3334 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/TeamMemberListSyncable.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/TeamMemberListSyncable.swift @@ -5,6 +5,7 @@ import Yosemite struct TeamMemberListSyncable: ListSyncable { typealias StorageType = StorageBookingResource typealias ModelType = BookingResource + typealias ListFilterType = BookingResource let siteID: Int64 @@ -42,6 +43,10 @@ struct TeamMemberListSyncable: ListSyncable { func displayName(for item: BookingResource) -> String { item.name } + + func filterItem(for item: BookingResource) -> BookingResource { + item + } } private extension TeamMemberListSyncable { diff --git a/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift b/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift index 96c6f85db81..591dbee0b47 100644 --- a/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift @@ -84,6 +84,8 @@ final class FilterTypeViewModel { enum FilterListValueSelectorConfig { // Standard list selector with fixed options case staticOptions(options: [FilterType]) + // Multi-select list selector with fixed options + case multiSelectStaticOptions(options: [FilterType]) // Filter list selector for categories linked to that site id, retrieved dynamically case productCategories(siteID: Int64) // Filter list selector for order statuses @@ -271,14 +273,10 @@ private extension FilterListViewController { } let selectedValueAction: (FilterType) -> Void = { [weak self] selectedOption in - guard let self = self else { - return - } - if selectedOption.description != selected.selectedValue.description { - selected.selectedValue = selectedOption - self.updateUI(numberOfActiveFilters: self.viewModel.filterTypeViewModels.numberOfActiveFilters) - self.listSelector.reloadData() - } + guard let self else { return } + selected.selectedValue = selectedOption + updateUI(numberOfActiveFilters: viewModel.filterTypeViewModels.numberOfActiveFilters) + listSelector.reloadData() } switch selected.listSelectorConfig { @@ -287,9 +285,30 @@ private extension FilterListViewController { data: options, selected: selected.selectedValue, hostViewController: self) - self.selectedFilterValueSubscription = command.onItemSelected.sink { selectedValueAction($0) } + self.selectedFilterValueSubscription = command.onItemSelected.sink { + selectedValueAction($0) + } let staticListSelector = ListSelectorViewController(command: command, tableViewStyle: .plain) { _ in } self.listSelector.navigationController?.pushViewController(staticListSelector, animated: true) + case .multiSelectStaticOptions(let options): + let selectedItems: [any FilterType] = { + if let wrapper = selected.selectedValue as? MultipleFilterSelection { + return wrapper.items + } + return [] + }() + + let multiSelectView = MultiSelectListView( + title: selected.title, + options: options, + initialSelection: selectedItems, + onSelection: { selectedOptions in + let filterType = MultipleFilterSelection(items: selectedOptions) + selectedValueAction(filterType) + } + ) + let hostingController = UIHostingController(rootView: multiSelectView) + self.listSelector.navigationController?.pushViewController(hostingController, animated: true) case let .productCategories(siteID): let selectedProductCategory = selected.selectedValue as? ProductCategory let filterProductCategoryListViewController = FilterProductCategoryListViewController(siteID: siteID, @@ -328,9 +347,8 @@ private extension FilterListViewController { onProductSelectionStateChanged: { [weak self] product, _ in guard let self else { return } - selected.selectedValue = FilterOrdersByProduct(id: product.productID, name: product.name) - self.updateUI(numberOfActiveFilters: self.viewModel.filterTypeViewModels.numberOfActiveFilters) - self.listSelector.reloadData() + let filterType = FilterOrdersByProduct(id: product.productID, name: product.name) + selectedValueAction(filterType) self.listSelector.dismiss(animated: true) }, onCloseButtonTapped: { [weak self] in @@ -359,48 +377,54 @@ private extension FilterListViewController { configuration: configuration, addressFormViewModel: nil, selectedCustomerID: selectedCustomerID, - onCustomerSelected: { [weak self] customer in - selected.selectedValue = CustomerFilter(customer: customer) - - self?.updateUI(numberOfActiveFilters: self?.viewModel.filterTypeViewModels.numberOfActiveFilters ?? 0) - self?.listSelector.reloadData() + onCustomerSelected: { customer in + let filterType = CustomerFilter(customer: customer) + selectedValueAction(filterType) } ) }() self.listSelector.navigationController?.pushViewController(controller, animated: true) case .bookingResource(let siteID): - let selectedMember = selected.selectedValue as? BookingResource + let selectedMembers: [BookingResource] = { + if let wrapper = selected.selectedValue as? MultipleFilterSelection { + return wrapper.items.compactMap { $0 as? BookingResource } + } + return [] + }() let syncable = TeamMemberListSyncable(siteID: siteID) let viewModel = SyncableListSelectorViewModel(syncable: syncable) let memberListSelectorView = SyncableListSelectorView( viewModel: viewModel, syncable: syncable, - initialSelection: { $0 == selectedMember }, - onSelection: { [weak self] resource in - selected.selectedValue = resource - self?.updateUI(numberOfActiveFilters: self?.viewModel.filterTypeViewModels.numberOfActiveFilters ?? 0) - self?.listSelector.reloadData() + initialSelections: selectedMembers, + onSelection: { resources in + let filterType = MultipleFilterSelection(items: resources) + selectedValueAction(filterType) } ) let hostingController = UIHostingController(rootView: memberListSelectorView) listSelector.navigationController?.pushViewController(hostingController, animated: true) case .bookableProduct(let siteID): - let selectedProduct = selected.selectedValue as? BookingProductFilter + let selectedProducts: [BookingProductFilter] = { + if let wrapper = selected.selectedValue as? MultipleFilterSelection { + return wrapper.items.compactMap { $0 as? BookingProductFilter } + } + return [] + }() let syncable = BookableProductListSyncable(siteID: siteID) let viewModel = SyncableListSelectorViewModel(syncable: syncable) let memberListSelectorView = SyncableListSelectorView( viewModel: viewModel, syncable: syncable, - initialSelection: { $0?.productID == selectedProduct?.productID }, - onSelection: { [weak self] product in - selected.selectedValue = { - guard let product else { return BookingProductFilter?.none } - return BookingProductFilter(productID: product.productID, name: product.name) - }() - self?.updateUI(numberOfActiveFilters: self?.viewModel.filterTypeViewModels.numberOfActiveFilters ?? 0) - self?.listSelector.reloadData() + initialSelections: selectedProducts, + onSelection: { products in + let filters = products.map { product in + BookingProductFilter(productID: product.productID, name: product.name) + } + let filterType = MultipleFilterSelection(items: filters) + selectedValueAction(filterType) } ) let hostingController = UIHostingController(rootView: memberListSelectorView) @@ -410,10 +434,9 @@ private extension FilterListViewController { let dateTimeFilterView = BookingDateTimeFilterView( startDate: selectedDateRange?.startDate, endDate: selectedDateRange?.endDate, - onSelection: { [weak self] startDate, endDate in - selected.selectedValue = BookingDateRangeFilter(startDate: startDate, endDate: endDate) - self?.updateUI(numberOfActiveFilters: self?.viewModel.filterTypeViewModels.numberOfActiveFilters ?? 0) - self?.listSelector.reloadData() + onSelection: { startDate, endDate in + let filterType = BookingDateRangeFilter(startDate: startDate, endDate: endDate) + selectedValueAction(filterType) } ) let hostingController = UIHostingController(rootView: dateTimeFilterView) @@ -596,3 +619,31 @@ private extension FilterListViewController { } } } + +/// Wrapper type for storing multiple filter selections +/// This allows arrays of FilterType items to be stored in FilterTypeViewModel.selectedValue +struct MultipleFilterSelection: FilterType { + let items: [any FilterType] + + var isActive: Bool { + return !items.isEmpty + } + + var description: String { + if items.isEmpty { + return NSLocalizedString( + "multipleFilterSelection.any", + value: "Any", + comment: "Display label for when no filter selected." + ) + } else if items.count == 1 { + return items.first?.description ?? "" + } else { + return "\(items.count)" + } + } + + init(items: [any FilterType]) { + self.items = items + } +} diff --git a/WooCommerce/Classes/ViewRelated/Filters/MultiSelectListView.swift b/WooCommerce/Classes/ViewRelated/Filters/MultiSelectListView.swift new file mode 100644 index 00000000000..f036ba4c698 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Filters/MultiSelectListView.swift @@ -0,0 +1,94 @@ +import SwiftUI + +/// A SwiftUI view for selecting multiple items from a static list +/// Works with any type conforming to FilterType +struct MultiSelectListView: View { + /// Title for the navigation bar + let title: String + + /// All available options + let options: [any FilterType] + + /// Currently selected items + @State private var selectedItems: [any FilterType] + + /// Callback when selection changes + private let onSelection: ([any FilterType]) -> Void + + init(title: String, + options: [any FilterType], + initialSelection: [any FilterType], + onSelection: @escaping ([any FilterType]) -> Void) { + self.title = title + self.options = options + self._selectedItems = State(initialValue: initialSelection) + self.onSelection = onSelection + } + + var body: some View { + List { + Button { + selectedItems.removeAll() + onSelection([]) + } label: { + HStack { + Text(Localization.any) + .foregroundColor(.primary) + Spacer() + Image(systemName: "checkmark") + .fontWeight(.medium) + .foregroundColor(.accentColor) + .renderedIf(selectedItems.isEmpty) + } + .contentShape(Rectangle()) + } + + ForEach(options, id: \.description) { option in + Button { + toggleSelection(for: option) + } label: { + HStack { + Text(option.description) + .foregroundColor(.primary) + + Spacer() + + if isSelected(option) { + Image(systemName: "checkmark") + .fontWeight(.medium) + .foregroundColor(.accentColor) + } + } + .contentShape(Rectangle()) + } + } + } + .listStyle(.plain) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .background(Color(.listBackground)) + } + + private func isSelected(_ option: any FilterType) -> Bool { + selectedItems.contains { $0.description == option.description } + } + + private func toggleSelection(for option: any FilterType) { + if let index = selectedItems.firstIndex(where: { $0.description == option.description }) { + selectedItems.remove(at: index) + } else { + selectedItems.append(option) + } + onSelection(selectedItems) + } +} + +private extension MultiSelectListView { + enum Localization { + static let any = NSLocalizedString( + "multiSelectListView.any", + value: "Any", + comment: "Option to remove selections on multi selection list view" + ) + } +} diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 1a7595915b4..973571e4525 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -2470,6 +2470,7 @@ DE8AA0B12BBE50CF0084D2CC /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8AA0B02BBE50CF0084D2CC /* DashboardView.swift */; }; DE8AA0B32BBE55E40084D2CC /* DashboardViewHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8AA0B22BBE55E40084D2CC /* DashboardViewHostingController.swift */; }; DE8AA0B52BBEBE590084D2CC /* ViewControllerContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8AA0B42BBEBE590084D2CC /* ViewControllerContainer.swift */; }; + DE8C22732EB0AE8500C69F35 /* MultiSelectListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8C22722EB0AE8500C69F35 /* MultiSelectListView.swift */; }; DE8C63AE2E1E2D2D00DA48AC /* OrderDetailsShipmentDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8C63AD2E1E2D1400DA48AC /* OrderDetailsShipmentDetailsView.swift */; }; DE8C946E264699B600C94823 /* PluginListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8C946D264699B600C94823 /* PluginListViewModel.swift */; }; DE96844B2A331AD2000FBF4E /* WooAnalyticsEvent+ProductSharingAI.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE96844A2A331AD2000FBF4E /* WooAnalyticsEvent+ProductSharingAI.swift */; }; @@ -5401,6 +5402,7 @@ DE8AA0B02BBE50CF0084D2CC /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; DE8AA0B22BBE55E40084D2CC /* DashboardViewHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardViewHostingController.swift; sourceTree = ""; }; DE8AA0B42BBEBE590084D2CC /* ViewControllerContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewControllerContainer.swift; sourceTree = ""; }; + DE8C22722EB0AE8500C69F35 /* MultiSelectListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSelectListView.swift; sourceTree = ""; }; DE8C63AD2E1E2D1400DA48AC /* OrderDetailsShipmentDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderDetailsShipmentDetailsView.swift; sourceTree = ""; }; DE8C946D264699B600C94823 /* PluginListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginListViewModel.swift; sourceTree = ""; }; DE96844A2A331AD2000FBF4E /* WooAnalyticsEvent+ProductSharingAI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooAnalyticsEvent+ProductSharingAI.swift"; sourceTree = ""; }; @@ -6659,6 +6661,7 @@ 027D67CF245ADDCC0036B8DB /* Filters */ = { isa = PBXGroup; children = ( + DE8C22722EB0AE8500C69F35 /* MultiSelectListView.swift */, 02C8876B24501FAC00E4470F /* FilterListViewController.swift */, DE87F4072D2D375E00869522 /* FilterHistoryView.swift */, 02C8876C24501FAC00E4470F /* FilterListViewController.xib */, @@ -14557,6 +14560,7 @@ B90C65CD29ACE2D6004CAB9E /* CardPresentPaymentOnboardingStateCache.swift in Sources */, 028AFFB32484ED2800693C09 /* Dictionary+Logging.swift in Sources */, 026CAF802AC2B7FF002D23BB /* ConfigurableBundleProductView.swift in Sources */, + DE8C22732EB0AE8500C69F35 /* MultiSelectListView.swift in Sources */, 45BBFBC1274FD94300213001 /* HubMenuCoordinator.swift in Sources */, D8652E582630BFF500350F37 /* OrderDetailsPaymentAlerts.swift in Sources */, B5A56BF0219F2CE90065A902 /* VerticalButton.swift in Sources */,