Repository: woocommerce/woocommerce-ios
Pull request #15504: [Woo POS] Expose item controllers for POS list views to use directly
Author: login: joshheald
Labels: name: type: task
description: An internally driven task.
name: feature: POS
Created: 2025-04-11T11:04:12Z | Merged: 2025-04-14T12:00:31Z

<!-- Remember about a good descriptive title. -->

Closes: #
<!-- Id number of the GitHub issue this PR addresses. -->
<!-- Part of: # -> use this if your PR is one of many related to one issue -->

<!-- 
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🛠 Need an AI review?  
Add a comment: `@coderabbitai review`
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-->

## Description
<!-- Take the time to write a good summary. Why is it needed? What does it do? When fixing bugs try to avoid just writing “See original issue” – clarify what the problem was and how you’ve fixed it. -->

This PR exposes the items controllers, and then switches between them in the `ItemListView`.

`ItemList` and `ChildItemList` (mostly) don't know what controller they're using, they have it passed in by the `ItemListView` as we swap between them.

Initially, in 452247f I did the switching in the dashboard view and then had one `ItemListView` per controller... but I actually think this is better. When we have searching for multiple item types, we might want to change the responsibilities of the `ItemListView` or add another intermediate view, to simplify knowing which controller we're searching... but equally, it might work fairly neatly.

## Steps to reproduce
<!-- Step-by-step testing instructions. For new user flows, consider instead stating the goal of the workflow and see if your PR reviewer can accomplish the workflow without specific steps! -->

Enable the coupons and search feature flags
1. Launch the app and navigate to POS
2. Use the items list, search, and coupons to build and edit a cart
3. Add a coupon to the store
4. Refresh, and simulate errors/recovery
5. Observe that the lists work as expected

N.B. the search list is far from perfect, but it's all behind a feature flag. I'll improve it further in future PRs

## Testing information
<!-- This is your opportunity to break out individual scenarios that need testing (when necessary) and/or include a checklist for the reviewer to go through. Consider documenting the following from your own completed testing: devices used, alternate workflows, edge cases, affected areas, critical flows, areas not tested, and any remaining unknowns. Provide feedback on this new section of the PR template through Sept 30, 2024 to Apps Quality; additional context here: p91TBi-b8z-p2#comment-12036 -->

Tested on iPad Air running iOS 17.7.3, and a simulator running 18.3

## Screenshots
<!-- Include before and after images or gifs when appropriate. -->

---
- [x] I have considered if this change warrants user-facing release notes and have added them to `RELEASE-NOTES.txt` if necessary.

## Reviewer (or Author, in the case of optional code reviews):

Please make sure these conditions are met before approving the PR, or request changes if the PR needs improvement:

- [x] The PR is small and has a clear, single focus, or a valid explanation is provided in the description. If needed, please request to split it into smaller PRs.
- [x] Ensure Adequate Unit Test Coverage: The changes are reasonably covered by unit tests or an explanation is provided in the PR description.
- [x] Manual Testing: The author listed all the tests they ran, including smoke tests when needed (e.g., for refactorings). The reviewer confirmed that the PR works as expected on all devices (phone/tablet) and no regressions are added.


Diff:
diff --git a/WooCommerce/Classes/POS/Controllers/PointOfSaleItemsController.swift b/WooCommerce/Classes/POS/Controllers/PointOfSaleItemsController.swift
index 1cf36834b69..cf391c6b127 100644
--- a/WooCommerce/Classes/POS/Controllers/PointOfSaleItemsController.swift
+++ b/WooCommerce/Classes/POS/Controllers/PointOfSaleItemsController.swift
@@ -80,7 +80,7 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController
         switch base {
         case .root:
             await loadRootItems()
-        case .parent(let parent, _):
+        case .parent(let parent):
             await loadChildItems(for: parent)
         }
     }
@@ -104,7 +104,7 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController
         switch base {
         case .root:
             await loadNextRootItems()
-        case .parent(let parent, _):
+        case .parent(let parent):
             await loadNextChildItems(for: parent)
         }
     }
@@ -197,7 +197,7 @@ private extension PointOfSaleItemsController {
         switch base {
         case .root:
             setRootLoadingState()
-        case .parent(let parent, _):
+        case .parent(let parent):
             setChildLoadingState(for: parent)
         }
     }
@@ -220,7 +220,7 @@ private extension PointOfSaleItemsController {
         switch base {
         case .root:
             setRootSearchingState()
-        case .parent(let parent, _):
+        case .parent(let parent):
             setChildSearchingState(for: parent)
         }
     }
diff --git a/WooCommerce/Classes/POS/Models/ItemListBaseItem.swift b/WooCommerce/Classes/POS/Models/ItemListBaseItem.swift
index 774d7aa5b03..a6a8579483e 100644
--- a/WooCommerce/Classes/POS/Models/ItemListBaseItem.swift
+++ b/WooCommerce/Classes/POS/Models/ItemListBaseItem.swift
@@ -2,15 +2,6 @@ import Foundation
 import enum Yosemite.POSItem
 
 enum ItemListBaseItem {
-    case root(ItemType)
-    case parent(POSItem, ItemType)
-
-    var itemType: ItemType {
-        switch self {
-        case .root(let type):
-            return type
-        case .parent(_, let type):
-            return type
-        }
-    }
+    case root
+    case parent(POSItem)
 }
diff --git a/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift b/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift
index 80d4ce5a416..40019c4fa68 100644
--- a/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift
+++ b/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift
@@ -24,12 +24,9 @@ protocol PointOfSaleAggregateModelProtocol {
     func cancelCardPaymentsOnboarding()
     func trackCardPaymentsOnboardingShown()
 
-    var itemsViewState: ItemsViewState { get }
-    var purchasableItemsSearchViewState: ItemsViewState { get }
-    var couponsViewState: ItemsViewState { get }
-
-    func loadItems(base: ItemListBaseItem) async
-    func loadNextItems(base: ItemListBaseItem) async
+    var purchasableItemsController: PointOfSaleItemsControllerProtocol { get }
+    var purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol { get }
+    var couponsController: PointOfSaleCouponsControllerProtocol { get }
 
     var cart: Cart { get }
     func addToCart(_ item: POSItem)
@@ -57,18 +54,14 @@ protocol PointOfSaleAggregateModelProtocol {
     var cardPresentPaymentOnboardingViewModel: CardPresentPaymentsOnboardingViewModel?
     private var onOnboardingCancellation: (() -> Void)?
 
-    var itemsViewState: ItemsViewState { itemsController.itemsViewState }
-    var purchasableItemsSearchViewState: ItemsViewState { purchasableItemsSearchController.itemsViewState }
-    var couponsViewState: ItemsViewState { couponsController.itemsViewState }
-
     private(set) var cart: Cart = .init()
 
     var orderState: PointOfSaleOrderState { orderController.orderState.externalState }
     private var internalOrderState: PointOfSaleInternalOrderState { orderController.orderState }
 
-    private let itemsController: PointOfSaleItemsControllerProtocol
-    private let purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol
-    private let couponsController: PointOfSaleCouponsControllerProtocol
+    let purchasableItemsController: PointOfSaleItemsControllerProtocol
+    let purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol
+    let couponsController: PointOfSaleCouponsControllerProtocol
 
     private let cardPresentPaymentService: CardPresentPaymentFacade
     private let orderController: PointOfSaleOrderControllerProtocol
@@ -88,7 +81,7 @@ protocol PointOfSaleAggregateModelProtocol {
          analytics: Analytics = ServiceLocator.analytics,
          collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentAnalyticsTracking,
          paymentState: PointOfSalePaymentState = .card(.idle)) {
-        self.itemsController = itemsController
+        self.purchasableItemsController = itemsController
         self.purchasableItemsSearchController = purchasableItemsSearchController
         self.couponsController = couponsController
         self.cardPresentPaymentService = cardPresentPaymentService
@@ -102,46 +95,7 @@ protocol PointOfSaleAggregateModelProtocol {
     }
 }
 
-// MARK: - ItemList
-@available(iOS 17.0, *)
-extension PointOfSaleAggregateModel {
-    @MainActor
-    func loadItems(base: ItemListBaseItem) async {
-        await itemsController(for: base.itemType).loadItems(base: base)
-    }
-
-    @MainActor
-    func refreshItems(base: ItemListBaseItem) async {
-        await itemsController(for: base.itemType).refreshItems(base: base)
-    }
-
-    @MainActor
-    func loadNextItems(base: ItemListBaseItem) async {
-        await itemsController(for: base.itemType).loadNextItems(base: base)
-    }
-
-    @MainActor
-    func searchItems(searchTerm: String, base: ItemListBaseItem) async {
-        guard case .products = base.itemType else {
-            return
-        }
-        await purchasableItemsSearchController.searchItems(searchTerm: searchTerm, baseItem: base)
-    }
-
-    private func itemsController(for itemType: ItemType) -> PointOfSaleItemsControllerProtocol {
-        switch itemType {
-        case .products(let searching):
-            if searching {
-                return purchasableItemsSearchController
-            } else {
-                return itemsController
-            }
-        case .coupons:
-            return couponsController
-        }
-    }
-}
-
+// MARK: - Cart
 @available(iOS 17.0, *)
 extension PointOfSaleAggregateModel {
     func addToCart(_ item: POSItem) {
diff --git a/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/PointOfSaleItemListEmptyView.swift b/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/PointOfSaleItemListEmptyView.swift
index 9c33bcc59c8..faa26de4737 100644
--- a/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/PointOfSaleItemListEmptyView.swift	
+++ b/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/PointOfSaleItemListEmptyView.swift	
@@ -3,7 +3,7 @@ import SwiftUI
 struct PointOfSaleItemListEmptyView: View {
     @Environment(\.dynamicTypeSize) private var dynamicTypeSize
     @Environment(\.floatingControlAreaSize) private var floatingControlAreaSize: CGSize
-    private let baseItem: ItemListBaseItem
+    private let viewModel: PointOfSaleItemListEmptyViewModel
 
     private let onAction: (() -> Void)?
 
@@ -13,8 +13,8 @@ struct PointOfSaleItemListEmptyView: View {
         ServiceLocator.featureFlagService.isFeatureFlagEnabled(.enableCouponsInPointOfSale)
     }
 
-    init(base: ItemListBaseItem, onAction: (() -> Void)? = nil) {
-        self.baseItem = base
+    init(viewModel: PointOfSaleItemListEmptyViewModel, onAction: (() -> Void)? = nil) {
+        self.viewModel = viewModel
         self.onAction = onAction
     }
 
@@ -35,19 +35,19 @@ struct PointOfSaleItemListEmptyView: View {
 
                 Spacer().frame(height: PointOfSaleCardPresentPaymentLayout.imageAndTextSpacing)
 
-                Text(title)
+                Text(viewModel.title)
                     .accessibilityAddTraits(.isHeader)
                     .foregroundStyle(Color.posOnSurface)
                     .font(.posHeadingBold)
 
                 Spacer().frame(height: PointOfSaleCardPresentPaymentLayout.textSpacing)
 
-                Text(subtitle)
+                Text(viewModel.subtitle)
                     .foregroundStyle(Color.posOnSurface)
                     .font(.posBodyLargeRegular())
                     .padding([.leading, .trailing])
 
-                if let hint {
+                if let hint = viewModel.hint {
                     Spacer().frame(height: PointOfSaleCardPresentPaymentLayout.textSpacing)
                     Text(hint)
                         .foregroundStyle(Color.posOnSurfaceVariantHighest)
@@ -57,7 +57,7 @@ struct PointOfSaleItemListEmptyView: View {
 
                 Spacer().frame(height: PointOfSaleCardPresentPaymentLayout.textAndButtonSpacing)
 
-                if let onAction, let buttonTitle {
+                if let onAction, let buttonTitle = viewModel.buttonTitle {
                     Button(action: {
                         onAction()
                     }, label: {
@@ -79,13 +79,22 @@ struct PointOfSaleItemListEmptyView: View {
 }
 
 private extension PointOfSaleItemListEmptyView {
+    enum Constants {
+        static let iconSize: CGFloat = 100
+    }
+}
+
+struct PointOfSaleItemListEmptyViewModel {
+    let itemType: ItemType
+    let baseItem: ItemListBaseItem
+
     var title: String {
-        switch baseItem {
-        case .root(.products):
+        switch (baseItem, itemType) {
+        case (.root, .products):
             return Localization.emptyProductsTitle
-        case .root(.coupons):
+        case (.root, .coupons):
             return Localization.emptyCouponsTitle
-        case .parent(.variableParentProduct, _):
+        case (.parent, .products):
             return Localization.emptyVariableParentProductTitle
         default:
             assertionFailure("No title defined for \(baseItem)")
@@ -94,12 +103,12 @@ private extension PointOfSaleItemListEmptyView {
     }
 
     var subtitle: String {
-        switch baseItem {
-        case .root(.products):
+        switch (baseItem, itemType) {
+        case (.root, .products):
             return Localization.emptyProductsSubtitle
-        case .root(.coupons):
+        case (.root, .coupons):
             return Localization.emptyCouponsSubtitle
-        case .parent(.variableParentProduct, _):
+        case (.parent, .products):
             return Localization.emptyVariableParentProductSubtitle
         default:
             assertionFailure("No subtitle defined for \(baseItem)")
@@ -108,12 +117,12 @@ private extension PointOfSaleItemListEmptyView {
     }
 
     var hint: String? {
-        switch baseItem {
-        case .root(.products):
+        switch (baseItem, itemType) {
+        case (.root, .products):
             return Localization.emptyProductsHint
-        case .root(.coupons):
+        case (.root, .coupons):
             return nil
-        case .parent(.variableParentProduct, _):
+        case (.parent, .products):
             return Localization.emptyVariableParentProductHint
         default:
             assertionFailure("No hint defined for \(baseItem)")
@@ -122,17 +131,14 @@ private extension PointOfSaleItemListEmptyView {
     }
 
     var buttonTitle: String? {
-        switch baseItem {
-        case .root(.coupons):
+        switch itemType {
+        case .coupons:
             return Localization.emptyCouponsButtonTitle
         default:
             return nil
         }
     }
 
-    enum Constants {
-        static let iconSize: CGFloat = 100
-    }
     enum Localization {
         static let emptyProductsTitle = NSLocalizedString(
             "pos.pointOfSaleItemListEmptyView.emptyProductsTitle.1",
diff --git a/WooCommerce/Classes/POS/Presentation/Item Selector/ChildItemList.swift b/WooCommerce/Classes/POS/Presentation/Item Selector/ChildItemList.swift
index 5660b2dcd8c..f68ca4fbcef 100644
--- a/WooCommerce/Classes/POS/Presentation/Item Selector/ChildItemList.swift	
+++ b/WooCommerce/Classes/POS/Presentation/Item Selector/ChildItemList.swift	
@@ -6,23 +6,22 @@ import Yosemite
 struct ChildItemList: View {
     private let parentItem: POSItem
     private let title: String
-    private let itemsStack: ItemsStackState
-    @Environment(PointOfSaleAggregateModel.self) private var posModel
+    private var itemsController: PointOfSaleItemsControllerProtocol
     @Environment(\.dismiss) private var dismiss
 
     private var node: ItemListBaseItem {
-        .parent(parentItem, .products())
+        .parent(parentItem)
     }
 
     private var state: ItemListState {
-        itemsStack.itemStates[parentItem] ??
+        itemsController.itemsViewState.itemsStack.itemStates[parentItem] ??
             .loading([])
     }
 
-    init(parentItem: POSItem, title: String, itemsStack: ItemsStackState) {
+    init(parentItem: POSItem, title: String, itemsController: PointOfSaleItemsControllerProtocol) {
         self.parentItem = parentItem
         self.title = title
-        self.itemsStack = itemsStack
+        self.itemsController = itemsController
     }
 
     var body: some View {
@@ -44,7 +43,7 @@ struct ChildItemList: View {
             guard state.items.isEmpty else {
                 return
             }
-            await posModel.loadItems(base: node)
+            await itemsController.loadItems(base: node)
         }
     }
 }
@@ -64,13 +63,12 @@ private extension ChildItemList {
         VStack(spacing: 0) {
             headerView
 
-            ItemList(state: state,
-                     itemsStack: itemsStack,
+            ItemList(itemsController: itemsController,
                      node: node)
                 .transition(.opacity)
                 .refreshable {
                     ServiceLocator.analytics.track(.pointOfSaleVariationsPullToRefresh)
-                    await posModel.refreshItems(base: node)
+                    await itemsController.refreshItems(base: node)
                 }
         }
     }
@@ -79,7 +77,10 @@ private extension ChildItemList {
     var emptyView: some View {
         VStack {
             headerView
-            PointOfSaleItemListEmptyView(base: node)
+            PointOfSaleItemListEmptyView(
+                viewModel: PointOfSaleItemListEmptyViewModel(
+                    itemType: .products(search: false),
+                    baseItem: node))
         }
     }
 
@@ -93,7 +94,7 @@ private extension ChildItemList {
 
             PointOfSaleItemListErrorView(error: error, onAction: {
                 Task {
-                    await posModel.loadItems(base: node)
+                    await itemsController.loadItems(base: node)
                 }
             })
             .zIndex(1)
@@ -152,15 +153,8 @@ private extension ChildItemList {
                         )
                     )
                 ], hasMoreItems: false)])
-    let posModel = PointOfSaleAggregateModel(
-        itemsController: itemsController,
-        purchasableItemsSearchController: PointOfSalePreviewItemsController(),
-        couponsController: PointOfSalePreviewCouponsController(),
-        cardPresentPaymentService: CardPresentPaymentPreviewService(),
-        orderController: PointOfSalePreviewOrderController(),
-        collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentAnalytics())
-    return ChildItemList(parentItem: parentItem, title: parentProduct.name, itemsStack: itemsStack)
-        .environment(posModel)
+    itemsController.itemsViewState = .init(containerState: .content, itemsStack: itemsStack)
+    return ChildItemList(parentItem: parentItem, title: parentProduct.name, itemsController: itemsController)
 }
 
 @available(iOS 17.0, *)
@@ -178,15 +172,8 @@ private extension ChildItemList {
         itemStates: [
             parentItem: .error(.errorOnLoadingVariations)
         ])
-    let posModel = PointOfSaleAggregateModel(
-        itemsController: itemsController,
-        purchasableItemsSearchController: PointOfSalePreviewItemsController(),
-        couponsController: PointOfSalePreviewCouponsController(),
-        cardPresentPaymentService: CardPresentPaymentPreviewService(),
-        orderController: PointOfSalePreviewOrderController(),
-        collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentAnalytics())
-    return ChildItemList(parentItem: parentItem, title: parentProduct.name, itemsStack: itemsStack)
-        .environment(posModel)
+    itemsController.itemsViewState = .init(containerState: .content, itemsStack: itemsStack)
+    return ChildItemList(parentItem: parentItem, title: parentProduct.name, itemsController: itemsController)
 }
 
 #endif
diff --git a/WooCommerce/Classes/POS/Presentation/Item Selector/ItemList.swift b/WooCommerce/Classes/POS/Presentation/Item Selector/ItemList.swift
index 2ac002c9220..96adddc8428 100644
--- a/WooCommerce/Classes/POS/Presentation/Item Selector/ItemList.swift	
+++ b/WooCommerce/Classes/POS/Presentation/Item Selector/ItemList.swift	
@@ -13,17 +13,23 @@ struct ItemList<HeaderView: View>: View {
     // Navigation only uses this on iOS 17
     @State private var activeNavigationItem: POSItem? = nil
 
-    let state: ItemListState
-    let itemsStack: ItemsStackState
+    var state: ItemListState? {
+        switch node {
+        case .root:
+            itemsController.itemsViewState.itemsStack.root
+        case .parent(let posItem):
+            itemsController.itemsViewState.itemsStack.itemStates[posItem]
+        }
+    }
+
+    private let itemsController: PointOfSaleItemsControllerProtocol
     private let node: ItemListBaseItem
     private let headerView: HeaderView
 
-    init(state: ItemListState,
-         itemsStack: ItemsStackState,
+    init(itemsController: PointOfSaleItemsControllerProtocol,
          node: ItemListBaseItem,
          @ViewBuilder headerView: () -> HeaderView = { EmptyView() }) {
-        self.state = state
-        self.itemsStack = itemsStack
+        self.itemsController = itemsController
         self.node = node
         self.headerView = headerView()
     }
@@ -36,14 +42,16 @@ struct ItemList<HeaderView: View>: View {
                     guard case .loaded(_, let hasMoreItems) = state,
                           hasMoreItems
                     else { return }
-                    await posModel.loadNextItems(base: node)
+                    await itemsController.loadNextItems(base: node)
                 },
                 content: {
                     LazyVStack(spacing: Constants.itemSpacing) {
                         headerView
 
-                        ForEach(state.items) { item in
-                            ItemListRow(item: item, itemsStack: itemsStack, activeNavigationItem: $activeNavigationItem)
+                        if let state {
+                            ForEach(state.items) { item in
+                                ItemListRow(item: item, activeNavigationItem: $activeNavigationItem)
+                            }
                         }
 
                         footerRows
@@ -59,10 +67,10 @@ struct ItemList<HeaderView: View>: View {
                 EmptyView()
             } else if let activeItem = activeNavigationItem,
                case let .variableParentProduct(parentProduct) = activeItem {
-                // This always uses the non-search itemsStack, otherwise it will have the search term and not work properly
+                // This always uses the non-search itemsController, otherwise it will have the search term and not work properly
                 // This is a temporary fix until we tidy up the stack selection, as it means non-products child lists won't work.
                 NavigationLink(
-                    destination: ChildItemList(parentItem: activeItem, title: parentProduct.name, itemsStack: posModel.itemsViewState.itemsStack),
+                    destination: ChildItemList(parentItem: activeItem, title: parentProduct.name, itemsController: posModel.purchasableItemsController),
                     isActive: Binding(
                         get: { activeNavigationItem != nil },
                         set: { if !$0 { activeNavigationItem = nil } }
@@ -88,10 +96,10 @@ struct ItemList<HeaderView: View>: View {
             ItemListErrorCardView(errorState: errorState,
                                   buttonAction: {
                 Task { @MainActor in
-                    await posModel.loadNextItems(base: node)
+                    await itemsController.loadNextItems(base: node)
                 }
             })
-        case .loaded, .error, .empty:
+        case .loaded, .error, .empty, .none:
             EmptyView()
         }
     }
@@ -105,7 +113,6 @@ private enum Constants {
 @available(iOS 17.0, *)
 private struct ItemListRow: View {
     let item: POSItem
-    let itemsStack: ItemsStackState
     @Binding var activeNavigationItem: POSItem?
     @Environment(PointOfSaleAggregateModel.self) private var posModel
     let analytics: Analytics = ServiceLocator.analytics
@@ -198,9 +205,8 @@ private extension ItemListRow {
         hasMoreItems: false
     )
     ItemList(
-        state: itemList,
-        itemsStack: .init(root: itemList, itemStates: [:]),
-        node: .root(.products())
+        itemsController: PointOfSalePreviewItemsController(),
+        node: .root
     )
 }
 
@@ -213,9 +219,8 @@ private extension ItemListRow {
         cardPresentPaymentService: CardPresentPaymentPreviewService(),
         orderController: PointOfSalePreviewOrderController(),
         collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentAnalytics())
-    ItemList(state: .loading([]),
-             itemsStack: .init(root: .loading([]), itemStates: [:]),
-             node: .root(.products()))
+    ItemList(itemsController: PointOfSalePreviewItemsController(),
+             node: .root)
         .environment(posModel)
 }
 
diff --git a/WooCommerce/Classes/POS/Presentation/ItemListView.swift b/WooCommerce/Classes/POS/Presentation/ItemListView.swift
index c344ca76f7b..7fb1b7d1ab9 100644
--- a/WooCommerce/Classes/POS/Presentation/ItemListView.swift
+++ b/WooCommerce/Classes/POS/Presentation/ItemListView.swift
@@ -14,23 +14,21 @@ struct ItemListView: View {
 
     @Binding var selectedItemType: ItemType
 
-    private var itemListState: ItemListState {
-        itemsStack.root
-    }
-
-    private var itemsStack: ItemsStackState {
+    var itemsController: PointOfSaleItemsControllerProtocol {
         switch selectedItemType {
-        case .products(let searching):
-            if searching {
-                return posModel.purchasableItemsSearchViewState.itemsStack
-            } else {
-                return posModel.itemsViewState.itemsStack
-            }
+        case .products(search: false):
+            posModel.purchasableItemsController
+        case .products(search: true):
+            posModel.purchasableItemsSearchController
         case .coupons:
-            return posModel.couponsViewState.itemsStack
+            posModel.couponsController
         }
     }
 
+    private var itemListState: ItemListState {
+        itemsController.itemsViewState.itemsStack.root
+    }
+
     @AppStorage(BannerState.isSimpleProductsOnlyBannerDismissedKey)
     private var isHeaderBannerDismissed: Bool = false
 
@@ -82,7 +80,7 @@ struct ItemListView: View {
         .posCouponCreationSheet(isPresented: $showCouponCreationModal, onSuccess: { couponItem in
             Task { @MainActor in
                 posModel.addToCart(couponItem)
-                await posModel.refreshItems(base: .root(.coupons))
+                await posModel.couponsController.refreshItems(base: .root)
             }
         })
     }
@@ -105,7 +103,7 @@ private extension ItemListView {
                         .onChange(of: searchTerm) { oldValue, newValue in
                             Task {
                                 selectedItemType = .products(search: newValue.isNotEmpty)
-                                await posModel.searchItems(searchTerm: newValue, base: .root(.products(search: true)))
+                                await posModel.purchasableItemsSearchController.searchItems(searchTerm: newValue, baseItem: .root)
                             }
                         }
                     }
@@ -186,14 +184,14 @@ private extension ItemListView {
 
     @ViewBuilder
     func listView(_ items: [POSItem]) -> some View {
-        ItemList(state: itemListState, itemsStack: itemsStack, node: .root(selectedItemType)) {
+        ItemList(itemsController: itemsController, node: .root) {
             if dynamicTypeSize.isAccessibilitySize, shouldShowHeaderBanner {
                 bannerCardView
             }
         }
         .refreshable {
             ServiceLocator.analytics.track(.pointOfSaleProductsPullToRefresh)
-            await posModel.refreshItems(base: .root(selectedItemType))
+            await itemsController.refreshItems(base: .root)
         }
     }
 
@@ -202,9 +200,9 @@ private extension ItemListView {
         // Note that navigation is handled by the ItemList in iOS 17, so any changes to this should be reflected in ItemListRow.
         switch parentItem {
         case let .variableParentProduct(parentProduct):
-            // This always uses the non-search itemsStack, otherwise it will have the search term and not work properly
+            // This always uses the non-search itemsController, otherwise it will have the search term and not work properly
             // This is a temporary fix until we tidy up the stack selection, as it means non-products child lists won't work.
-            ChildItemList(parentItem: parentItem, title: parentProduct.name, itemsStack: posModel.itemsViewState.itemsStack)
+            ChildItemList(parentItem: parentItem, title: parentProduct.name, itemsController: posModel.purchasableItemsController)
         default:
             EmptyView()
         }
@@ -214,9 +212,15 @@ private extension ItemListView {
     var emptyView: some View {
         switch selectedItemType {
         case .products:
-            PointOfSaleItemListEmptyView(base: .root(selectedItemType))
+            PointOfSaleItemListEmptyView(
+                viewModel: PointOfSaleItemListEmptyViewModel(
+                    itemType: .products(search: false),
+                    baseItem: .root))
         case .coupons:
-            PointOfSaleItemListEmptyView(base: .root(selectedItemType)) {
+            PointOfSaleItemListEmptyView(
+                viewModel: PointOfSaleItemListEmptyViewModel(
+                    itemType: .coupons,
+                    baseItem: .root)) {
                 showCouponCreationModal = true
             }
         }
@@ -234,7 +238,7 @@ private extension ItemListView {
         default:
             PointOfSaleItemListErrorView(error: errorState, onAction: {
                 Task {
-                    await posModel.loadItems(base: .root(selectedItemType))
+                    await itemsController.loadItems(base: .root)
                 }
             })
         }
@@ -250,7 +254,7 @@ private extension ItemListView {
     func displayItemType(_ itemType: ItemType) {
         selectedItemType = itemType
         Task { @MainActor in
-            await posModel.loadItems(base: .root(itemType))
+            await itemsController.loadItems(base: .root)
         }
     }
 }
@@ -333,7 +337,7 @@ private extension ItemListView {
 #Preview("Loaded with all product types") {
     let itemsController = PointOfSalePreviewItemsController()
     Task { @MainActor in
-        await itemsController.loadItems(base: .root(.products()))
+        await itemsController.loadItems(base: .root)
     }
     let posModel = PointOfSaleAggregateModel(
         itemsController: itemsController,
diff --git a/WooCommerce/Classes/POS/Presentation/PointOfSaleDashboardView.swift b/WooCommerce/Classes/POS/Presentation/PointOfSaleDashboardView.swift
index 3151b7bcf64..64291572efa 100644
--- a/WooCommerce/Classes/POS/Presentation/PointOfSaleDashboardView.swift
+++ b/WooCommerce/Classes/POS/Presentation/PointOfSaleDashboardView.swift
@@ -17,12 +17,12 @@ struct PointOfSaleDashboardView: View {
         switch selectedItemType {
         case .products(let searching):
             if searching {
-                return posModel.purchasableItemsSearchViewState
+                return posModel.purchasableItemsSearchController.itemsViewState
             } else {
-                return posModel.itemsViewState
+                return posModel.purchasableItemsController.itemsViewState
             }
         case .coupons:
-            return posModel.couponsViewState
+            return posModel.couponsController.itemsViewState
         }
     }
 
@@ -38,7 +38,14 @@ struct PointOfSaleDashboardView: View {
                 case .error(let error):
                     PointOfSaleItemListFullscreenErrorView(error: error, onAction: {
                         Task {
-                            await posModel.loadItems(base: .root(.products()))
+                            switch selectedItemType {
+                            case .products(search: false):
+                                await posModel.purchasableItemsController.loadItems(base: .root)
+                            case .products(search: true):
+                                await posModel.purchasableItemsSearchController.loadItems(base: .root)
+                            case .coupons:
+                                await posModel.purchasableItemsSearchController.loadItems(base: .root)
+                            }
                         }
                     })
                 case .content:
@@ -94,7 +101,7 @@ struct PointOfSaleDashboardView: View {
             documentationView
         }
         .task {
-            await posModel.loadItems(base: .root(.products()))
+            await posModel.purchasableItemsController.loadItems(base: .root)
         }
         .ignoresSafeArea(.keyboard)
     }
diff --git a/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift b/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift
index 9cc150e8440..9c496857137 100644
--- a/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift
+++ b/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift
@@ -99,7 +99,7 @@ final class PointOfSalePreviewItemsController: PointOfSaleSearchingItemsControll
         case .root:
             itemsViewState = ItemsViewState(containerState: .content, itemsStack: ItemsStackState(root: .loaded(mockItems, hasMoreItems: true),
                                                                                                   itemStates: [:]))
-        case .parent(let parent, _):
+        case .parent(let parent):
             await loadInitialChildItems(for: parent)
         }
     }
diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleCouponsControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleCouponsControllerTests.swift
index c65ea4f9c65..d7bf201c910 100644
--- a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleCouponsControllerTests.swift
+++ b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleCouponsControllerTests.swift
@@ -52,7 +52,7 @@ struct PointOfSaleCouponsControllerTests {
         let expectedViewState = ItemsViewState(containerState: .content, itemsStack: expectedItemStackState)
 
         // When
-        await sut.loadItems(base: .root(.coupons))
+        await sut.loadItems(base: .root)
 
         // Then
         #expect(sut.itemsViewState == expectedViewState)
@@ -69,7 +69,7 @@ struct PointOfSaleCouponsControllerTests {
         let expectedViewState = ItemsViewState(containerState: .content, itemsStack: expectedItemStackState)
 
         // When
-        await sut.loadItems(base: .root(.coupons))
+        await sut.loadItems(base: .root)
 
         // Then
         #expect(sut.itemsViewState == expectedViewState)
@@ -86,7 +86,7 @@ struct PointOfSaleCouponsControllerTests {
         let expectedViewState = ItemsViewState(containerState: .content, itemsStack: expectedItemStackState)
 
         // When
-        await sut.refreshItems(base: .root(.coupons))
+        await sut.refreshItems(base: .root)
 
         // Then
         #expect(sut.itemsViewState == expectedViewState)
@@ -103,7 +103,7 @@ struct PointOfSaleCouponsControllerTests {
         let expectedViewState = ItemsViewState(containerState: .content, itemsStack: expectedItemStackState)
 
         // When
-        await sut.refreshItems(base: .root(.coupons))
+        await sut.refreshItems(base: .root)
 
         // Then
         #expect(sut.itemsViewState == expectedViewState)
@@ -120,7 +120,7 @@ struct PointOfSaleCouponsControllerTests {
         let expectedViewState = ItemsViewState(containerState: .content, itemsStack: expectedItemStackState)
 
         // When
-        await sut.loadNextItems(base: .root(.coupons))
+        await sut.loadNextItems(base: .root)
 
         // Then
         #expect(sut.itemsViewState == expectedViewState)
@@ -137,7 +137,7 @@ struct PointOfSaleCouponsControllerTests {
         let expectedViewState = ItemsViewState(containerState: .content, itemsStack: expectedItemStackState)
 
         // When
-        await sut.loadNextItems(base: .root(.coupons))
+        await sut.loadNextItems(base: .root)
 
         // Then
         #expect(sut.itemsViewState == expectedViewState)
@@ -154,7 +154,7 @@ struct PointOfSaleCouponsControllerTests {
         let expectedViewState = ItemsViewState(containerState: .content, itemsStack: expectedItemStackState)
 
         // When
-        await sut.loadItems(base: .root(.coupons))
+        await sut.loadItems(base: .root)
 
         // Then
         #expect(sut.itemsViewState == expectedViewState)
diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleItemsControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleItemsControllerTests.swift
index 9ebd5acfe6c..033ef3d7bf1 100644
--- a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleItemsControllerTests.swift
+++ b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleItemsControllerTests.swift
@@ -20,13 +20,13 @@ final class PointOfSaleItemsControllerTests {
 
         try #require(sut.itemsViewState.containerState == .loading)
         itemProvider.shouldSimulateTwoPages = true
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
 
-        await sut.loadNextItems(base: .root(.products(search: false)))
+        await sut.loadNextItems(base: .root)
         try #require(itemProvider.spyLastRequestedPageNumber == 2)
 
         // When
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
 
         // Then
         #expect(itemProvider.spyLastRequestedPageNumber == 1)
@@ -46,7 +46,7 @@ final class PointOfSaleItemsControllerTests {
         try #require(sut.itemsViewState.containerState == .loading)
 
         // When
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
 
         // Then
         #expect(sut.itemsViewState == ItemsViewState(containerState: .content,
@@ -68,7 +68,7 @@ final class PointOfSaleItemsControllerTests {
         itemProvider.shouldSimulateTwoPages = true
 
         // When
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
 
         // Then
         #expect(sut.itemsViewState == ItemsViewState(containerState: .content,
@@ -90,9 +90,9 @@ final class PointOfSaleItemsControllerTests {
         itemProvider.itemPages = [expectedItems]
 
         // When
-        await sut.loadItems(base: .root(.products(search: false)))
-        await sut.loadItems(base: .root(.products(search: false)))
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
+        await sut.loadItems(base: .root)
+        await sut.loadItems(base: .root)
 
         // Then
         guard case .loaded(let items, _) = sut.itemsViewState.itemsStack.root else {
@@ -129,7 +129,7 @@ final class PointOfSaleItemsControllerTests {
         try #require(sut.itemsViewState.containerState == .loading)
 
         // When
-        await sut.loadNextItems(base: .root(.products(search: false)))
+        await sut.loadNextItems(base: .root)
 
         // Then
         #expect(sut.itemsViewState.containerState == .content)
@@ -151,7 +151,7 @@ final class PointOfSaleItemsControllerTests {
         try #require(sut.itemsViewState.containerState == .loading)
 
         // When
-        await sut.loadNextItems(base: .root(.products(search: false)))
+        await sut.loadNextItems(base: .root)
 
         // Then
         #expect(sut.itemsViewState == ItemsViewState(containerState: .content,
@@ -169,10 +169,10 @@ final class PointOfSaleItemsControllerTests {
         )
 
         itemProvider.shouldSimulateTwoPages = true
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
 
         // When
-        await sut.loadNextItems(base: .root(.products(search: false)))
+        await sut.loadNextItems(base: .root)
 
         // Then
         guard case .loaded(let items, _) = sut.itemsViewState.itemsStack.root else {
@@ -193,10 +193,10 @@ final class PointOfSaleItemsControllerTests {
 
         try #require(sut.itemsViewState.containerState == .loading)
         itemProvider.shouldSimulateTwoPages = true
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
 
         // When
-        await sut.loadNextItems(base: .root(.products(search: false)))
+        await sut.loadNextItems(base: .root)
 
         // Then
         #expect(itemProvider.spyLastRequestedPageNumber == 2)
@@ -213,10 +213,10 @@ final class PointOfSaleItemsControllerTests {
 
         itemProvider.shouldSimulateTwoPages = true
         itemProvider.shouldSimulateMorePages = true
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
 
         // When
-        await sut.loadNextItems(base: .root(.products(search: false)))
+        await sut.loadNextItems(base: .root)
 
         // Then
         guard case .loaded(let items, let hasMoreItems) = sut.itemsViewState.itemsStack.root else {
@@ -240,11 +240,11 @@ final class PointOfSaleItemsControllerTests {
                                                                                 name: "Fake Parent",
                                                                                 productImageSource: nil,
                                                                                 productID: 12345))
-        let baseItem = ItemListBaseItem.parent(parentItem, .products(search: false))
+        let baseItem = ItemListBaseItem.parent(parentItem)
         itemProvider.shouldSimulateTwoPagesOfVariations = true
         itemProvider.shouldSimulateMorePagesOfVariations = true
 
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
         await sut.loadItems(base: baseItem)
 
         // When
@@ -272,10 +272,10 @@ final class PointOfSaleItemsControllerTests {
                                                                                 name: "Fake Parent",
                                                                                 productImageSource: nil,
                                                                                 productID: 12345))
-        let baseItem = ItemListBaseItem.parent(parentItem, .products(search: false))
+        let baseItem = ItemListBaseItem.parent(parentItem)
         itemProvider.shouldSimulateTwoPagesOfVariations = true
 
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
         await sut.loadItems(base: baseItem)
 
         itemProvider.errorToThrow = MockError.requestFailed
@@ -306,7 +306,7 @@ final class PointOfSaleItemsControllerTests {
         try #require(sut.itemsViewState.containerState == .loading)
 
         // When
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
 
         // Then
         #expect(sut.itemsViewState.containerState == .content)
@@ -330,7 +330,7 @@ final class PointOfSaleItemsControllerTests {
         try #require(sut.itemsViewState.containerState == .loading)
 
         // When
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
 
         // Then
         #expect(sut.itemsViewState.containerState == .error(expectedError))
@@ -348,12 +348,12 @@ final class PointOfSaleItemsControllerTests {
         try #require(sut.itemsViewState.containerState == .loading)
 
         itemProvider.shouldSimulateTwoPages = true
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
 
         itemProvider.errorToThrow = MockError.requestFailed
 
         // When
-        await sut.loadNextItems(base: .root(.products(search: false)))
+        await sut.loadNextItems(base: .root)
 
         // Then
         #expect(sut.itemsViewState.containerState == .content)
@@ -379,7 +379,7 @@ final class PointOfSaleItemsControllerTests {
         try #require(sut.itemsViewState.containerState == .loading)
 
         // When
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
 
         // Then
         guard case .loaded(let items, let hasMoreItems) = sut.itemsViewState.itemsStack.root else {
@@ -400,7 +400,7 @@ final class PointOfSaleItemsControllerTests {
         )
 
         itemProvider.shouldSimulateTwoPages = true
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
 
         guard case .loaded = sut.itemsViewState.itemsStack.root else {
             Issue.record("Expected loaded ItemList state, but got \(sut.itemsViewState.itemsStack.root)")
@@ -410,7 +410,7 @@ final class PointOfSaleItemsControllerTests {
         itemProvider.errorToThrow = PointOfSaleItemServiceError.requestCancelled
 
         // When
-        await sut.loadNextItems(base: .root(.products(search: false)))
+        await sut.loadNextItems(base: .root)
 
         // Then
         #expect(sut.itemsViewState.containerState == .content)
@@ -433,15 +433,15 @@ final class PointOfSaleItemsControllerTests {
         )
 
         itemProvider.shouldSimulateTwoPages = true
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
 
         itemProvider.errorToThrow = MockError.requestFailed
-        await sut.loadNextItems(base: .root(.products(search: false)))
+        await sut.loadNextItems(base: .root)
         try #require(itemProvider.spyLastRequestedPageNumber == 2)
         itemProvider.spyLastRequestedPageNumber = 0
 
         // When
-        await sut.loadNextItems(base: .root(.products(search: false)))
+        await sut.loadNextItems(base: .root)
 
         // Then
         #expect(itemProvider.spyLastRequestedPageNumber == 2)
@@ -461,7 +461,7 @@ final class PointOfSaleItemsControllerTests {
         itemProvider.itemPages = [expectedItems]
 
         // When
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
 
         // Then
         #expect(sut.itemsViewState == ItemsViewState(containerState: .content,
@@ -480,12 +480,12 @@ final class PointOfSaleItemsControllerTests {
 
         let expectedItems = MockPointOfSaleItemService.makeInitialItems()
         itemProvider.itemPages = [expectedItems]
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
         try #require(itemProvider.spyLastRequestedPageNumber == 1)
 
         // When
         itemProvider.shouldReturnZeroItems = true
-        await sut.loadNextItems(base: .root(.products(search: false)))
+        await sut.loadNextItems(base: .root)
 
         // Then
         #expect(sut.itemsViewState == ItemsViewState(containerState: .content,
@@ -503,12 +503,12 @@ final class PointOfSaleItemsControllerTests {
             itemFetchStrategyFactory: PointOfSaleItemFetchStrategyFactory(siteID: 1, credentials: nil)
         )
 
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
         try #require(itemProvider.spyLastRequestedPageNumber == 1)
 
         // When
         itemProvider.shouldReturnZeroItems = true
-        await sut.loadNextItems(base: .root(.products(search: false)))
+        await sut.loadNextItems(base: .root)
 
         // Then
         try #require(itemProvider.spyLastRequestedPageNumber == 1)
@@ -525,7 +525,7 @@ final class PointOfSaleItemsControllerTests {
 
         let initialItems = MockPointOfSaleItemService.makeInitialItems()
         itemProvider.itemPages = [initialItems]
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
         try #require(sut.itemsViewState.itemsStack.root.items == initialItems)
 
         await confirmation() { confirmation in
@@ -541,7 +541,7 @@ final class PointOfSaleItemsControllerTests {
             }
 
             // When
-            await sut.loadItems(base: .root(.products(search: false)))
+            await sut.loadItems(base: .root)
         }
     }
 
@@ -569,7 +569,7 @@ final class PointOfSaleItemsControllerTests {
             }
 
             // When
-            await sut.loadItems(base: .root(.products(search: false)))
+            await sut.loadItems(base: .root)
         }
     }
 
@@ -587,13 +587,13 @@ final class PointOfSaleItemsControllerTests {
                                                                               productImageSource: nil,
                                                                               productID: 125))
         itemProvider.itemPages = [[parentItem]]
-        await sut.loadItems(base: .root(.products(search: false)))
-        await sut.loadItems(base: .parent(parentItem, .products(search: false)))
+        await sut.loadItems(base: .root)
+        await sut.loadItems(base: .parent(parentItem))
         let itemStates = sut.itemsViewState.itemsStack.itemStates
         try #require(itemStates != [:])
 
         // When
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
 
         // Then
         try #require(sut.itemsViewState.itemsStack.itemStates == itemStates)
@@ -612,11 +612,11 @@ final class PointOfSaleItemsControllerTests {
                                                                               name: "Parent product",
                                                                               productImageSource: nil,
                                                                               productID: 125))
-        let baseItem = ItemListBaseItem.parent(parentItem, .products(search: false))
+        let baseItem = ItemListBaseItem.parent(parentItem)
         itemProvider.itemPages = [[parentItem]]
 
         // Loads initial items.
-        await sut.loadItems(base: .root(.products(search: false)))
+        await sut.loadItems(base: .root)
         await sut.loadItems(base: baseItem)
 
         let initialChildItems = sut.itemsViewState.itemsStack.itemStates[parentItem]?.items ?? []
@@ -651,7 +651,7 @@ final class PointOfSaleItemsControllerTests {
                                                                               name: "Parent product",
                                                                               productImageSource: nil,
                                                                               productID: 125))
-        let baseItem = ItemListBaseItem.parent(parentItem, .products(search: false))
+        let baseItem = ItemListBaseItem.parent(parentItem)
 
         itemProvider.errorToThrow = PointOfSaleItemServiceError.requestCancelled
         try #require(sut.itemsViewState.containerState == .loading)
@@ -681,7 +681,7 @@ final class PointOfSaleItemsControllerTests {
                                                                               name: "Parent product",
                                                                               productImageSource: nil,
                                                                               productID: 125))
-        let baseItem = ItemListBaseItem.parent(parentItem, .products(search: false))
+        let baseItem = ItemListBaseItem.parent(parentItem)
 
         itemProvider.shouldSimulateTwoPagesOfVariations = true
         await sut.loadItems(base: baseItem)
@@ -712,7 +712,7 @@ final class PointOfSaleItemsControllerTests {
         )
 
         // When
-        await sut.searchItems(searchTerm: "green mug", baseItem: .root(.products(search: true)))
+        await sut.searchItems(searchTerm: "green mug", baseItem: .root)
 
         // Then
         let fetchStrategy = try #require(itemProvider.spyItemsFetchStrategy as? PointOfSaleSearchPurchasableItemFetchStrategy)
diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift
index b0357463d08..9144e45f376 100644
--- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift
+++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift
@@ -27,31 +27,25 @@ final class MockPointOfSaleAggregateModel: PointOfSaleAggregateModelProtocol {
 
     var orderState: WooCommerce.PointOfSaleOrderState
 
-    var itemsViewState: WooCommerce.ItemsViewState
+    var purchasableItemsController: any WooCommerce.PointOfSaleItemsControllerProtocol
 
-    var purchasableItemsSearchViewState: WooCommerce.ItemsViewState
+    var purchasableItemsSearchController: any WooCommerce.PointOfSaleSearchingItemsControllerProtocol
 
-    var couponsViewState: WooCommerce.ItemsViewState
+    var couponsController: any WooCommerce.PointOfSaleCouponsControllerProtocol
 
     var blockReturnToItemSelection: Bool = false
 
     init(cardReaderConnectionStatus: CardPresentPaymentReaderConnectionStatus = .disconnected,
-         itemsViewState: ItemsViewState = ItemsViewState(containerState: .loading,
-                                                         itemsStack: ItemsStackState(root: .loading([]),
-                                                                                     itemStates: [:])),
-         purchasableItemsSearchViewState: ItemsViewState = ItemsViewState(containerState: .loading,
-                                                                          itemsStack: ItemsStackState(root: .loading([]),
-                                                                                                      itemStates: [:])),
-         couponsViewState: ItemsViewState = ItemsViewState(containerState: .loading,
-                                                           itemsStack: ItemsStackState(root: .loading([]),
-                                                                                       itemStates: [:])),
+         purchasableItemsController: PointOfSaleItemsControllerProtocol = MockPointOfSaleItemsController(),
+         purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol = MockPointOfSalePurchasableItemsSearchController(),
+         couponsController: PointOfSaleCouponsControllerProtocol = MockPointOfSaleCouponsController(),
          orderStage: PointOfSaleOrderStage = .building,
          orderState: PointOfSaleOrderState = .idle,
          paymentState: PointOfSalePaymentState = .card(.idle)) {
         self.cardReaderConnectionStatus = cardReaderConnectionStatus
-        self.itemsViewState = itemsViewState
-        self.purchasableItemsSearchViewState = purchasableItemsSearchViewState
-        self.couponsViewState = couponsViewState
+        self.purchasableItemsController = purchasableItemsController
+        self.purchasableItemsSearchController = purchasableItemsSearchController
+        self.couponsController = couponsController
         self.orderStage = orderStage
         self.orderState = orderState
         self.paymentState = paymentState


Reviews:
user: login: staskus
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T11:26:11Z
user: login: joshheald
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T14:37:59Z
user: login: staskus
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T07:06:43Z
user: login: staskus
body: Thank you for this refactoring. Looks good. Thanks for additional improvements of extracting VM out of the view. 

Tested on iOS 18 iPad simulator, tested products and variations with features flags disabled, and additionally coupons with feature flags enabled.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T11:46:26Z

Review comments:
diff_hunk: @@ -14,21 +14,23 @@ struct ItemListView: View {
 
     @Binding var selectedItemType: ItemType
 
+    var itemListController: PointOfSaleItemsControllerProtocol {
path: WooCommerce/Classes/POS/Presentation/ItemListView.swift
user: login: staskus
body: Let's call selected ones `selected` or `current`, or it's easy to conflict with actual `itemsController`

```suggestion
    var selectedItemListController: PointOfSaleItemsControllerProtocol {
```
created_at: 2025-04-11T11:26:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,21 +14,23 @@ struct ItemListView: View {
 
     @Binding var selectedItemType: ItemType
 
+    var itemListController: PointOfSaleItemsControllerProtocol {
path: WooCommerce/Classes/POS/Presentation/ItemListView.swift
user: login: joshheald
body: It's probably clear enough now that the posModel only has specifically named controllers, no general `itemsController` – WDYT?
created_at: 2025-04-11T14:37:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,21 +14,23 @@ struct ItemListView: View {
 
     @Binding var selectedItemType: ItemType
 
+    var itemListController: PointOfSaleItemsControllerProtocol {
path: WooCommerce/Classes/POS/Presentation/ItemListView.swift
user: login: staskus
body: Yes 👍 then it's clear. 
created_at: 2025-04-14T07:06:43Z
author_association: CONTRIBUTOR

PR comments:
user: login: dangermattic
created_at: 2025-04-11T11:04:42Z
body: <!--
  0 Errors
  1 Warning: View files have been modified,...
  0 Messages
  0 Markdowns
-->
<table>
  <thead>
    <tr>
      <th width="50"></th>
      <th width="100%" data-danger-table="true" data-kind="Warning">
          1 Warning
      </th>
     </tr>
  </thead>
  <tbody>
    <tr>
      <td>:warning:</td>
      <td data-sticky="false">View files have been modified, but no screenshot or video is included in the pull request. Consider adding some for clarity.</td>
    </tr>
  </tbody>
</table>

<p align="right" data-meta="generated_by_pr-check">
  Generated by :no_entry_sign: <a href="https://danger.systems/">Danger</a>
</p>

author_association: COLLABORATOR
user: login: wpmobilebot
created_at: 2025-04-11T11:14:16Z
body: <!-- REUSE_ID: prototype-build-link -->

<p><img align='top' src='https://raw.githubusercontent.com/buildkite/emojis/main/img-buildkite-64/firebase.png' width='20px' alt='App Icon' />📲 You can test the changes from this Pull Request in <b>WooCommerce iOS Prototype</b> by scanning the QR code below to install the corresponding build.</p>
<table>
<tr>
  <td rowspan='6' width='260px'><img src='https://api.qrserver.com/v1/create-qr-code/?size=500x500&qzone=4&data=https%3A%2F%2Fappdistribution.firebase.google.com%2Ftesterapps%2F1%3A124902176124%3Aios%3A02259de1e7c42b291620f9%2Freleases%2F4k6fgi6s79jb0%3Futm_source%3Dfastlane' width='250' height='250' /></td>
  <td><b>App Name</b></td><td>WooCommerce iOS Prototype</td>
</tr>
<tr><td><b>Build Number</b></td><td><code>29297</code></td></tr>
<tr><td><b>Version</b></td><td><code>PR #15504</code></td></tr>
<tr><td><b>Bundle ID</b></td><td><code>com.automattic.alpha.woocommerce</code></td></tr>
<tr><td><b>Commit</b></td><td>6e27621580ee0d3221063882bce277d369d517e5</td></tr>
<tr><td><b>Installation URL</b></td><td><a href='https://appdistribution.firebase.google.com/testerapps/1:124902176124:ios:02259de1e7c42b291620f9/releases/4k6fgi6s79jb0?utm_source=fastlane'>4k6fgi6s79jb0</a></td></tr>
</table>
<em>Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.</em>

author_association: COLLABORATOR