From 0d018dab95ecb15ab491cea15eecec4b39843e18 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 21 Dec 2021 10:30:23 -0600 Subject: [PATCH] 0172 --- 0172-modularization-pt2/Inventory/.gitignore | 7 + .../Inventory/Package.swift | 77 +++ 0172-modularization-pt2/Inventory/README.md | 3 + .../Sources/AppFeature/ContentView.swift | 98 +++ .../AppFeature/ContentViewController.swift | 77 +++ .../Sources/InventoryFeature/Inventory.swift | 162 +++++ .../InventoryViewController.swift | 187 +++++ .../Sources/InventoryFeature/Routing.swift | 61 ++ .../Sources/ItemFeature/ItemView.swift | 168 +++++ .../ItemFeature/ItemViewController.swift | 219 ++++++ .../Sources/ItemRowFeature/ItemRow.swift | 213 ++++++ .../ItemRowFeature/ItemRowCellView.swift | 95 +++ .../Sources/ItemRowFeature/Routing.swift | 38 + .../Inventory/Sources/Models/Models.swift | 68 ++ .../ParsingHelpers/ParsingHelpers.swift | 82 +++ .../SwiftUIHelpers/SwiftUIHelpers.swift | 262 +++++++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 98 +++ .../Assets.xcassets/Contents.json | 6 + .../ItemRowPreviewApp/Info.plist | 17 + .../ItemRowPreviewAppApp.swift | 26 + .../Preview Assets.xcassets/Contents.json | 6 + .../Inventory/SwiftUINavigation/Package.swift | 8 + .../project.pbxproj | 651 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 98 +++ .../Assets.xcassets/Contents.json | 6 + .../SwiftUINavigation/Info.plist | 17 + .../Preview Assets.xcassets/Contents.json | 6 + .../SwiftUINavigationApp.swift | 112 +++ .../SwiftUINavigationTests.swift | 93 +++ 0172-modularization-pt2/README.md | 5 + README.md | 1 + 35 files changed, 3004 insertions(+) create mode 100644 0172-modularization-pt2/Inventory/.gitignore create mode 100644 0172-modularization-pt2/Inventory/Package.swift create mode 100644 0172-modularization-pt2/Inventory/README.md create mode 100644 0172-modularization-pt2/Inventory/Sources/AppFeature/ContentView.swift create mode 100644 0172-modularization-pt2/Inventory/Sources/AppFeature/ContentViewController.swift create mode 100644 0172-modularization-pt2/Inventory/Sources/InventoryFeature/Inventory.swift create mode 100644 0172-modularization-pt2/Inventory/Sources/InventoryFeature/InventoryViewController.swift create mode 100644 0172-modularization-pt2/Inventory/Sources/InventoryFeature/Routing.swift create mode 100644 0172-modularization-pt2/Inventory/Sources/ItemFeature/ItemView.swift create mode 100644 0172-modularization-pt2/Inventory/Sources/ItemFeature/ItemViewController.swift create mode 100644 0172-modularization-pt2/Inventory/Sources/ItemRowFeature/ItemRow.swift create mode 100644 0172-modularization-pt2/Inventory/Sources/ItemRowFeature/ItemRowCellView.swift create mode 100644 0172-modularization-pt2/Inventory/Sources/ItemRowFeature/Routing.swift create mode 100644 0172-modularization-pt2/Inventory/Sources/Models/Models.swift create mode 100644 0172-modularization-pt2/Inventory/Sources/ParsingHelpers/ParsingHelpers.swift create mode 100644 0172-modularization-pt2/Inventory/Sources/SwiftUIHelpers/SwiftUIHelpers.swift create mode 100644 0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/Assets.xcassets/Contents.json create mode 100644 0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/Info.plist create mode 100644 0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/ItemRowPreviewAppApp.swift create mode 100644 0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 0172-modularization-pt2/Inventory/SwiftUINavigation/Package.swift create mode 100644 0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.pbxproj create mode 100644 0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/Contents.json create mode 100644 0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/Info.plist create mode 100644 0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/SwiftUINavigationApp.swift create mode 100644 0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigationTests/SwiftUINavigationTests.swift create mode 100644 0172-modularization-pt2/README.md diff --git a/0172-modularization-pt2/Inventory/.gitignore b/0172-modularization-pt2/Inventory/.gitignore new file mode 100644 index 00000000..bb460e7b --- /dev/null +++ b/0172-modularization-pt2/Inventory/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata diff --git a/0172-modularization-pt2/Inventory/Package.swift b/0172-modularization-pt2/Inventory/Package.swift new file mode 100644 index 00000000..f67210e3 --- /dev/null +++ b/0172-modularization-pt2/Inventory/Package.swift @@ -0,0 +1,77 @@ +// swift-tools-version:5.5 + +import PackageDescription + +let package = Package( + name: "Inventory", + platforms: [.iOS(.v15)], + products: [ + .library(name: "AppFeature", targets: ["AppFeature"]), + .library(name: "InventoryFeature", targets: ["InventoryFeature"]), + .library(name: "ItemFeature", targets: ["ItemFeature"]), + .library(name: "ItemRowFeature", targets: ["ItemRowFeature"]), + .library(name: "Models", targets: ["Models"]), + .library(name: "ParsingHelpers", targets: ["ParsingHelpers"]), + .library(name: "SwiftUIHelpers", targets: ["SwiftUIHelpers"]) + ], + dependencies: [ + .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "0.7.0"), + .package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "0.3.2"), + .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.3.1"), + ], + targets: [ + .target( + name: "AppFeature", + dependencies: [ + "InventoryFeature", + "Models", + "ParsingHelpers", + .product(name: "Parsing", package: "swift-parsing"), + ] + ), + .target( + name: "InventoryFeature", + dependencies: [ + "ItemRowFeature", + "Models", + "ParsingHelpers", + "SwiftUIHelpers", + .product(name: "CasePaths", package: "swift-case-paths"), + .product(name: "Parsing", package: "swift-parsing"), + .product(name: "IdentifiedCollections", package: "swift-identified-collections"), + ] + ), + .target( + name: "ItemFeature", + dependencies: [ + "Models", + "SwiftUIHelpers", + .product(name: "CasePaths", package: "swift-case-paths"), + ] + ), + .target( + name: "ItemRowFeature", + dependencies: [ + "ItemFeature", + "Models", + "ParsingHelpers", + "SwiftUIHelpers", + .product(name: "CasePaths", package: "swift-case-paths"), + .product(name: "Parsing", package: "swift-parsing"), + ] + ), + .target(name: "Models"), + .target( + name: "ParsingHelpers", + dependencies: [ + .product(name: "Parsing", package: "swift-parsing") + ] + ), + .target( + name: "SwiftUIHelpers", + dependencies: [ + .product(name: "CasePaths", package: "swift-case-paths") + ] + ), + ] +) diff --git a/0172-modularization-pt2/Inventory/README.md b/0172-modularization-pt2/Inventory/README.md new file mode 100644 index 00000000..d8ff3505 --- /dev/null +++ b/0172-modularization-pt2/Inventory/README.md @@ -0,0 +1,3 @@ +# Inventory + +A description of this package. diff --git a/0172-modularization-pt2/Inventory/Sources/AppFeature/ContentView.swift b/0172-modularization-pt2/Inventory/Sources/AppFeature/ContentView.swift new file mode 100644 index 00000000..6d6ff2de --- /dev/null +++ b/0172-modularization-pt2/Inventory/Sources/AppFeature/ContentView.swift @@ -0,0 +1,98 @@ +import InventoryFeature +import ItemFeature +import ItemRowFeature +import Models +import Parsing +import ParsingHelpers +import SwiftUI + +enum AppRoute { + case one + case inventory(InventoryRoute?) + case three +} + +let deepLinker = PathComponent("one") + .skip(PathEnd()) + .map { AppRoute.one } + .orElse( + PathComponent("inventory") + .take(inventoryDeepLinker) + .map(AppRoute.inventory) + ) + .orElse( + PathComponent("three") + .skip(PathEnd()) + .map { .three } + ) + +public enum Tab { + case one, inventory, three +} + +public class AppViewModel: ObservableObject { + @Published var inventoryViewModel: InventoryViewModel + @Published var selectedTab: Tab + + public init( + inventoryViewModel: InventoryViewModel = .init(), + selectedTab: Tab = .one + ) { + self.inventoryViewModel = inventoryViewModel + self.selectedTab = selectedTab + } + + public func open(url: URL) { + var request = DeepLinkRequest(url: url) + if let route = deepLinker.parse(&request) { + switch route { + case .one: + self.selectedTab = .one + + case let .inventory(inventoryRoute): + self.selectedTab = .inventory + self.inventoryViewModel.navigate(to: inventoryRoute) + + case .three: + self.selectedTab = .three + } + } + } +} + +public struct ContentView: View { + @ObservedObject var viewModel: AppViewModel + + public init(viewModel: AppViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + TabView(selection: self.$viewModel.selectedTab) { + Button("Go to 2nd tab") { + self.viewModel.selectedTab = .inventory + } + .tabItem { Text("One") } + .tag(Tab.one) + + NavigationView { + InventoryView(viewModel: self.viewModel.inventoryViewModel) + } + .tabItem { Text("Inventory") } + .tag(Tab.inventory) + + Text("Three") + .tabItem { Text("Three") } + .tag(Tab.three) + } + .onOpenURL { url in + self.viewModel.open(url: url) + } + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView(viewModel: .init(selectedTab: .inventory)) + } +} diff --git a/0172-modularization-pt2/Inventory/Sources/AppFeature/ContentViewController.swift b/0172-modularization-pt2/Inventory/Sources/AppFeature/ContentViewController.swift new file mode 100644 index 00000000..5f72c42e --- /dev/null +++ b/0172-modularization-pt2/Inventory/Sources/AppFeature/ContentViewController.swift @@ -0,0 +1,77 @@ +import Combine +import InventoryFeature +import UIKit + +public class ContentViewController: UITabBarController { + let viewModel: AppViewModel + private var cancellables: Set = [] + + public init(viewModel: AppViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func viewDidLoad() { + super.viewDidLoad() + + let oneLabel = UILabel() + oneLabel.text = "One" + oneLabel.sizeToFit() + + let one = UIViewController() + one.tabBarItem.title = "One" + one.view.addSubview(oneLabel) + oneLabel.center = one.view.center + + let inventory = UINavigationController( + rootViewController: InventoryViewController( + viewModel: self.viewModel.inventoryViewModel + ) + ) + inventory.tabBarItem.title = "Inventory" + + let threeLabel = UILabel() + threeLabel.text = "Three" + threeLabel.sizeToFit() + + let three = UIViewController() + three.tabBarItem.title = "Three" + three.view.addSubview(threeLabel) + threeLabel.center = three.view.center + + self.setViewControllers([one, inventory, three], animated: false) + + self.viewModel.$selectedTab + .sink { [unowned self] tab in + switch tab { + case .one: + self.selectedIndex = 0 + case .inventory: + self.selectedIndex = 1 + case .three: + self.selectedIndex = 2 + } + } + .store(in: &self.cancellables) + } + + override public func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { + guard let index = tabBar.items?.firstIndex(of: item) + else { return } + + switch index { + case 0: + self.viewModel.selectedTab = .one + case 1: + self.viewModel.selectedTab = .inventory + case 2: + self.viewModel.selectedTab = .three + default: + break + } + } +} diff --git a/0172-modularization-pt2/Inventory/Sources/InventoryFeature/Inventory.swift b/0172-modularization-pt2/Inventory/Sources/InventoryFeature/Inventory.swift new file mode 100644 index 00000000..e91cd71f --- /dev/null +++ b/0172-modularization-pt2/Inventory/Sources/InventoryFeature/Inventory.swift @@ -0,0 +1,162 @@ +import CasePaths +import IdentifiedCollections +import ItemFeature +import ItemRowFeature +import Models +import SwiftUI +import SwiftUIHelpers + +public class InventoryViewModel: ObservableObject { + @Published public var inventory: IdentifiedArrayOf + @Published public var route: Route? + + public enum Route: Equatable { + case add(ItemViewModel) + case row(id: ItemRowViewModel.ID, route: ItemRowViewModel.Route) + + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.add(lhs), .add(rhs)): + return lhs === rhs + case let (.row(lhsId, lhsRoute), .row(rhsId, rhsRoute)): + return lhsId == rhsId && lhsRoute == rhsRoute + case (.add, .row), (.row, .add): + return false + } + } + } + + public init( + inventory: IdentifiedArrayOf = [], + route: Route? = nil + ) { + self.inventory = [] + self.route = route + + for itemRowViewModel in inventory { + self.bind(itemRowViewModel: itemRowViewModel) + } + } + + private func bind(itemRowViewModel: ItemRowViewModel) { + print("bind id", itemRowViewModel.id) + + itemRowViewModel.onDelete = { [weak self, item = itemRowViewModel.item] in + withAnimation { + self?.delete(item: item) + } + } + itemRowViewModel.onDuplicate = { [weak self] item in + withAnimation { + self?.add(item: item) + } + } + itemRowViewModel.$route + .map { [id = itemRowViewModel.id] route in + route.map { Route.row(id: id, route: $0) } + } + .removeDuplicates() + .dropFirst() + .assign(to: &self.$route) + self.$route + .map { [id = itemRowViewModel.id] route in + guard + case let .row(id: routeRowId, route: route) = route, + routeRowId == id + else { return nil } + return route + } + .removeDuplicates() + .assign(to: &itemRowViewModel.$route) + self.inventory.append(itemRowViewModel) + } + + func delete(item: Item) { + withAnimation { + _ = self.inventory.remove(id: item.id) + } + } + + func add(item: Item) { + withAnimation { + self.bind(itemRowViewModel: .init(item: item)) + self.route = nil + } + } + + func addButtonTapped() { + self.route = .add( + .init( + item: .init(name: "", color: nil, status: .inStock(quantity: 1)) + ) + ) + + Task { @MainActor in + try await Task.sleep(nanoseconds: 500 * NSEC_PER_MSEC) + try (/Route.add).modify(&self.route) { + $0.item.name = "Bluetooth Keyboard" + } + } + } + + func cancelButtonTapped() { + self.route = nil + } +} + +public struct InventoryView: View { + @ObservedObject var viewModel: InventoryViewModel + + public init(viewModel: InventoryViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + List { + ForEach( + self.viewModel.inventory, + content: ItemRowView.init(viewModel:) + ) + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Add") { self.viewModel.addButtonTapped() } + } + } + .navigationTitle("Inventory") + .sheet(item: self.$viewModel.route.case(/InventoryViewModel.Route.add)) { itemToAdd in + NavigationView { + ItemView(viewModel: itemToAdd) + .navigationTitle("Add") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { self.viewModel.cancelButtonTapped() } + } + ToolbarItem(placement: .primaryAction) { + Button("Save") { self.viewModel.add(item: itemToAdd.item) } + } + } + } + } + } +} + +struct InventoryView_Previews: PreviewProvider { + static var previews: some View { + let keyboard = Item(name: "Keyboard", color: .blue, status: .inStock(quantity: 100)) + + NavigationView { + InventoryView( + viewModel: .init( + inventory: [ + .init(item: keyboard), + .init(item: Item(name: "Charger", color: .yellow, status: .inStock(quantity: 20))), + .init(item: Item(name: "Phone", color: .green, status: .outOfStock(isOnBackOrder: true))), + .init(item: Item(name: "Headphones", color: .green, status: .outOfStock(isOnBackOrder: false))), + ], + route: nil + ) + ) + } + } +} diff --git a/0172-modularization-pt2/Inventory/Sources/InventoryFeature/InventoryViewController.swift b/0172-modularization-pt2/Inventory/Sources/InventoryFeature/InventoryViewController.swift new file mode 100644 index 00000000..b33d28c8 --- /dev/null +++ b/0172-modularization-pt2/Inventory/Sources/InventoryFeature/InventoryViewController.swift @@ -0,0 +1,187 @@ +import Combine +import ItemFeature +import ItemRowFeature +import Models +import SwiftUIHelpers +import UIKit + +public class InventoryViewController: UIViewController, UICollectionViewDelegate { + let viewModel: InventoryViewModel + private var cancellables: Set = [] + + public init(viewModel: InventoryViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func viewDidLoad() { + super.viewDidLoad() + + // MARK: view creation + + self.title = "Inventory" + self.navigationItem.rightBarButtonItem = .init( + title: "Add", + primaryAction: .init { [unowned self] _ in + self.viewModel.addButtonTapped() + } + ) + + enum Section { case inventory } + + let cellRegistration = UICollectionView.CellRegistration< + ItemRowCellView, + ItemRowViewModel + > { [unowned self] cell, indexPath, itemRowViewModel in + cell.bind(viewModel: itemRowViewModel, context: self) + } + + var dataSource: UICollectionViewDiffableDataSource< + Section, + ItemRowViewModel + >! + + var layoutConfig = UICollectionLayoutListConfiguration(appearance: .insetGrouped) + layoutConfig.trailingSwipeActionsConfigurationProvider = { indexPath in + guard let viewModel = dataSource.itemIdentifier(for: indexPath) + else { return nil } + let duplicate = UIContextualAction( + style: .normal, + title: "Duplicate" + ) { _, _, completion in + viewModel.duplicateButtonTapped() + completion(true) + } + let delete = UIContextualAction( + style: .destructive, + title: "Delete" + ) { _, _, completion in + viewModel.deleteButtonTapped() + completion(true) + } + return UISwipeActionsConfiguration(actions: [duplicate, delete]) + } + + let collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewCompositionalLayout.list(using: layoutConfig) + ) + collectionView.delegate = self + collectionView.translatesAutoresizingMaskIntoConstraints = false + + dataSource = UICollectionViewDiffableDataSource< + Section, + ItemRowViewModel + >( + collectionView: collectionView + ) { collectionView, indexPath, itemRowViewModel in + + let cell = collectionView.dequeueConfiguredReusableCell( + using: cellRegistration, + for: indexPath, + item: itemRowViewModel + ) + cell.accessories = [.disclosureIndicator()] + return cell + } + + collectionView.dataSource = dataSource + + self.view.addSubview(collectionView) + + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor), + ]) + + // MARK: view model bindings + + var presentedViewController: UIViewController? + + self.viewModel.$route + .removeDuplicates() + .sink { [unowned self] route in + switch route { + case .none: + presentedViewController?.dismiss(animated: true) + break + + case let .add(itemViewModel): + let vc = ItemViewController(viewModel: itemViewModel) + vc.title = "Add" + vc.navigationItem.leftBarButtonItem = .init( + title: "Cancel", + primaryAction: .init { [unowned self] _ in + self.viewModel.cancelButtonTapped() + } + ) + vc.navigationItem.rightBarButtonItem = .init( + title: "Add", + primaryAction: .init { [unowned self] _ in + self.viewModel.add(item: itemViewModel.item) + } + ) + let nav = UINavigationController(rootViewController: vc) + self.present(nav, animated: true) + presentedViewController = nav + + case .row: + break + } + } + .store(in: &self.cancellables) + + self.viewModel.$inventory + .sink { inventory in + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.inventory]) + snapshot.appendItems(inventory.elements, toSection: .inventory) + dataSource.apply(snapshot, animatingDifferences: true) + } + .store(in: &self.cancellables) + + // MARK: UI actions + } + + public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + self.viewModel.inventory[indexPath.row].setEditNavigation(isActive: true) + } +} + +import SwiftUI + +struct InventoryViewController_Previews: PreviewProvider { + static var previews: some View { + ToSwiftUI { + UINavigationController( + rootViewController: InventoryViewController( + viewModel: .init( + inventory: [ + .init( + item: .init( + name: "Keyboard", + color: .red, + status: .inStock(quantity: 100) + ) + ), + .init( + item: .init( + name: "Mouse", + color: .red, + status: .inStock(quantity: 10) + ) + ) + ], + route: nil + ) + ) + ) + } + } +} diff --git a/0172-modularization-pt2/Inventory/Sources/InventoryFeature/Routing.swift b/0172-modularization-pt2/Inventory/Sources/InventoryFeature/Routing.swift new file mode 100644 index 00000000..f46068b1 --- /dev/null +++ b/0172-modularization-pt2/Inventory/Sources/InventoryFeature/Routing.swift @@ -0,0 +1,61 @@ +import Foundation +import ItemRowFeature +import Models +import Parsing +import ParsingHelpers + +public enum InventoryRoute { + case add(Item, ItemRoute? = nil) + case row(Item.ID, ItemRowRoute) +} + +public enum ItemRoute { + case colorPicker +} + +let item = QueryItem("name").orElse(Always("")) + .take(QueryItem("quantity", Int.parser()).orElse(Always(1))) + .map { name, quantity in + Item(name: String(name), status: .inStock(quantity: quantity)) + } + +public let inventoryDeepLinker = PathEnd() // / + .map { InventoryRoute?.none } + .orElse( + PathComponent("add") + .skip(PathEnd()) + .take(item) + .map { .add($0) } + ) + .orElse( + PathComponent("add") + .skip(PathComponent("colorPicker")) + .skip(PathEnd()) + .take(item) + .map { .add($0, .colorPicker) } + ) + .orElse( + PathComponent(UUID.parser()) + .take(itemRowDeepLinker) + .map(InventoryRoute.row) + ) + +extension InventoryViewModel { + public func navigate(to route: InventoryRoute?) { + switch route { + case let .add(item, .none): + self.route = .add(.init(item: item)) + + case let .add(item, .colorPicker): + self.route = .add(.init(item: item, route: .colorPicker)) + + case let .row(id, rowRoute): + guard let viewModel = self.inventory[id: id] + else { break } + viewModel.navigate(to: rowRoute) + + case .none: + self.route = nil + } + } +} diff --git a/0172-modularization-pt2/Inventory/Sources/ItemFeature/ItemView.swift b/0172-modularization-pt2/Inventory/Sources/ItemFeature/ItemView.swift new file mode 100644 index 00000000..0d7c69db --- /dev/null +++ b/0172-modularization-pt2/Inventory/Sources/ItemFeature/ItemView.swift @@ -0,0 +1,168 @@ +import CasePaths +import Models +import SwiftUI +import SwiftUIHelpers + +struct ColorPickerView: View { + @ObservedObject var viewModel: ItemViewModel + @Environment(\.dismiss) var dismiss + + var body: some View { + Form { + Button(action: { + self.viewModel.item.color = nil + self.dismiss() + }) { + HStack { + Text("None") + Spacer() + if self.viewModel.item.color == nil { + Image(systemName: "checkmark") + } + } + } + + Section(header: Text("Default colors")) { + ForEach(Item.Color.defaults, id: \.name) { color in + Button(action: { + self.viewModel.item.color = color + self.dismiss() + }) { + HStack { + Text(color.name) + Spacer() + if self.viewModel.item.color == color { + Image(systemName: "checkmark") + } + } + } + } + } + + if !self.viewModel.newColors.isEmpty { + Section(header: Text("New colors")) { + ForEach(self.viewModel.newColors, id: \.name) { color in + Button(action: { + self.viewModel.item.color = color + self.dismiss() + }) { + HStack { + Text(color.name) + Spacer() + if self.viewModel.item.color == color { + Image(systemName: "checkmark") + } + } + } + } + } + } + } + .task { + await self.viewModel.loadColors() + } + } +} + +public class ItemViewModel: Identifiable, ObservableObject { + @Published public var item: Item + @Published public var nameIsDuplicate = false + @Published public var newColors: [Item.Color] = [] + @Published public var route: Route? + + public var id: Item.ID { self.item.id } + + public enum Route { + case colorPicker + } + + public init(item: Item, route: Route? = nil) { + self.item = item + self.route = route + + Task { @MainActor in + for await item in self.$item.values { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 300) + self.nameIsDuplicate = item.name == "Keyboard" + } + } + } + + @MainActor + func loadColors() async { + try? await Task.sleep(nanoseconds: NSEC_PER_MSEC * 500) + self.newColors = [ + .init(name: "Pink", red: 1, green: 0.7, blue: 0.7), + ] + } + + func setColorPickerNavigation(isActive: Bool) { + self.route = isActive ? .colorPicker : nil + } +} + +public struct ItemView: View { + @ObservedObject var viewModel: ItemViewModel + + public init( + viewModel: ItemViewModel + ) { + self.viewModel = viewModel + } + + public var body: some View { + Form { + TextField("Name", text: self.$viewModel.item.name) + .background(self.viewModel.nameIsDuplicate ? Color.red.opacity(0.1) : Color.clear) + + NavigationLink( + unwrap: self.$viewModel.route, + case: /ItemViewModel.Route.colorPicker, + onNavigate: self.viewModel.setColorPickerNavigation(isActive:), + destination: { _ in ColorPickerView(viewModel: self.viewModel) } + ) { + HStack { + Text("Color") + Spacer() + if let color = self.viewModel.item.color { + Rectangle() + .frame(width: 30, height: 30) + .foregroundColor(color.swiftUIColor) + .border(Color.black, width: 1) + } + Text(self.viewModel.item.color?.name ?? "None") + .foregroundColor(.gray) + } + } + + IfCaseLet(self.$viewModel.item.status, pattern: /Item.Status.inStock) { $quantity in + Section(header: Text("In stock")) { + Stepper("Quantity: \(quantity)", value: $quantity) + Button("Mark as sold out") { + self.viewModel.item.status = .outOfStock(isOnBackOrder: false) + } + } + } + IfCaseLet(self.$viewModel.item.status, pattern: /Item.Status.outOfStock) { $isOnBackOrder in + Section(header: Text("Out of stock")) { + Toggle("Is on back order?", isOn: $isOnBackOrder) + Button("Is back in stock!") { + self.viewModel.item.status = .inStock(quantity: 1) + } + } + } + } + } +} + +struct ItemView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + ItemView( + viewModel: .init( + item: Item(name: "", color: nil, status: .inStock(quantity: 1)) + ) + ) + } + } +} diff --git a/0172-modularization-pt2/Inventory/Sources/ItemFeature/ItemViewController.swift b/0172-modularization-pt2/Inventory/Sources/ItemFeature/ItemViewController.swift new file mode 100644 index 00000000..e83cbe9d --- /dev/null +++ b/0172-modularization-pt2/Inventory/Sources/ItemFeature/ItemViewController.swift @@ -0,0 +1,219 @@ +import CasePaths +import Combine +import Models +import SwiftUI +import SwiftUIHelpers +import UIKit + +public class ItemViewController: UIViewController, UIPickerViewDelegate, UIPickerViewDataSource { + let viewModel: ItemViewModel + private var cancellables: Set = [] + + public init(viewModel: ItemViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func viewDidLoad() { + super.viewDidLoad() + + // MARK: View creation + + self.view.backgroundColor = .white + + let nameTextField = UITextField() + nameTextField.placeholder = "Name" + nameTextField.borderStyle = .roundedRect + + let colorPicker = UIPickerView() + colorPicker.dataSource = self + colorPicker.delegate = self + + let quantityLabel = UILabel() + + let quantityStepper = UIStepper() + quantityStepper.maximumValue = .infinity + + let quantityStackView = UIStackView(arrangedSubviews: [ + quantityLabel, + quantityStepper + ]) + + let markAsSoldOutButton = UIButton(type: .system) + markAsSoldOutButton.setTitle("Mark as sold out", for: .normal) + + let inStockStackView = UIStackView(arrangedSubviews: [ + quantityStackView, + markAsSoldOutButton, + ]) + inStockStackView.axis = .vertical + + let isOnBackOrderLabel = UILabel() + isOnBackOrderLabel.text = "Is on back order?" + + let isOnBackOrderSwitch = UISwitch() + + let isOnBackOrderStackView = UIStackView(arrangedSubviews: [ + isOnBackOrderLabel, + isOnBackOrderSwitch, + ]) + + let isBackInStockButton = UIButton(type: .system) + isBackInStockButton.setTitle("Is back in stock!", for: .normal) + + let outOfStockStackView = UIStackView(arrangedSubviews: [ + isOnBackOrderStackView, + isBackInStockButton, + ]) + outOfStockStackView.axis = .vertical + + let stackView = UIStackView(arrangedSubviews: [ + nameTextField, + colorPicker, + inStockStackView, + outOfStockStackView, + ]) + stackView.axis = .vertical + stackView.spacing = UIStackView.spacingUseSystem + stackView.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(stackView) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: self.view.readableContentGuide.topAnchor), + stackView.leadingAnchor.constraint(equalTo: self.view.readableContentGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: self.view.readableContentGuide.trailingAnchor), + ]) + + // MARK: View model bindings + + self.viewModel.$item + .map(\.name) + .removeDuplicates() + .sink { nameTextField.text = $0 } + .store(in: &self.cancellables) + + self.viewModel.$item + .map(\.color) + .removeDuplicates() + .sink { color in + guard let row = Item.Color.all.firstIndex(of: color) + else { return } + colorPicker.selectRow(row, inComponent: 0, animated: false) + } + .store(in: &self.cancellables) + + self.viewModel.$item + .map(\.status) + .compactMap(/Item.Status.inStock) + .removeDuplicates() + .sink { quantity in + quantityLabel.text = "Quantity: \(quantity)" + quantityStepper.value = Double(quantity) + } + .store(in: &self.cancellables) + + self.viewModel.$item + .map { /Item.Status.inStock ~= $0.status } + .removeDuplicates() + .sink { isInStock in + inStockStackView.isHidden = !isInStock + } + .store(in: &self.cancellables) + + self.viewModel.$item + .map { /Item.Status.outOfStock ~= $0.status } + .removeDuplicates() + .sink { isOutOfStock in + outOfStockStackView.isHidden = !isOutOfStock + } + .store(in: &self.cancellables) + + self.viewModel.$item + .map(\.status) + .compactMap(/Item.Status.outOfStock) + .removeDuplicates() + .sink { isOnBackOrder in + isOnBackOrderSwitch.isOn = isOnBackOrder + } + .store(in: &self.cancellables) + + // MARK: UI actions + + quantityStepper.addAction(.init { [unowned self, unowned quantityStepper] _ in + self.viewModel.item.status = .inStock(quantity: Int(quantityStepper.value)) + }, for: .valueChanged) + + markAsSoldOutButton.addAction( + .init { [unowned self] _ in + self.viewModel.item.status = .outOfStock(isOnBackOrder: false) + }, + for: .touchUpInside + ) + + isBackInStockButton.addAction( + .init { [unowned self] _ in + self.viewModel.item.status = .inStock(quantity: 1) + }, + for: .touchUpInside + ) + + nameTextField.addAction( + .init { [unowned self, unowned nameTextField] _ in + self.viewModel.item.name = nameTextField.text ?? "" + }, + for: .editingChanged + ) + + isOnBackOrderSwitch.addAction( + .init { [unowned self, unowned isOnBackOrderSwitch] _ in + self.viewModel.item.status = .outOfStock(isOnBackOrder: isOnBackOrderSwitch.isOn) + }, + for: .valueChanged + ) + } + + public func numberOfComponents(in pickerView: UIPickerView) -> Int { + 1 + } + + public func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + Item.Color.all.count + } + + public func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { + Item.Color.all[row]?.name ?? "None" + } + + public func pickerView( + _ pickerView: UIPickerView, + didSelectRow row: Int, + inComponent component: Int + ) { + self.viewModel.item.color = Item.Color.all[row] + } +} + +extension Item.Color { + static let all: [Self?] = [nil] + Self.defaults +} + +struct ItemViewController_Previews: PreviewProvider { + static var previews: some View { + ToSwiftUI { + ItemViewController( + viewModel: ItemViewModel( + item: .init( + name: "Keyboard", + color: .blue, + status: .outOfStock(isOnBackOrder: true) + ), + route: nil + ) + ) + } + } +} diff --git a/0172-modularization-pt2/Inventory/Sources/ItemRowFeature/ItemRow.swift b/0172-modularization-pt2/Inventory/Sources/ItemRowFeature/ItemRow.swift new file mode 100644 index 00000000..640a6ecf --- /dev/null +++ b/0172-modularization-pt2/Inventory/Sources/ItemRowFeature/ItemRow.swift @@ -0,0 +1,213 @@ +import CasePaths +import ItemFeature +import Models +import SwiftUI +import SwiftUIHelpers + +public class ItemRowViewModel: Hashable, Identifiable, ObservableObject { + @Published public var item: Item + @Published public var route: Route? + @Published var isSaving = false + + public func hash(into hasher: inout Hasher) { + hasher.combine(self.item.id) + } + + public static func == (lhs: ItemRowViewModel, rhs: ItemRowViewModel) -> Bool { + lhs.item.id == rhs.item.id + } + + public enum Route: Equatable { + case deleteAlert + case duplicate(ItemViewModel) + case edit(ItemViewModel) + + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.deleteAlert, .deleteAlert): + return true + case let (.duplicate(lhs), .duplicate(rhs)): + return lhs === rhs + case let (.edit(lhs), .edit(rhs)): + return lhs === rhs + case (.deleteAlert, _), (.duplicate, _), (.edit, _): + return false + } + } + } + + public var onDelete: () -> Void = {} + public var onDuplicate: (Item) -> Void = { _ in } + + public var id: Item.ID { self.item.id } + + public init(item: Item) { + self.item = item + } + + public func deleteButtonTapped() { + self.route = .deleteAlert + } + + func deleteConfirmationButtonTapped() { + self.onDelete() + self.route = nil + } + + public func setEditNavigation(isActive: Bool) { + self.route = isActive ? .edit(.init(item: self.item)) : nil + } + + func edit(item: Item) { + self.isSaving = true + + Task { @MainActor in + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + + self.isSaving = false + self.item = item + self.route = nil + } + } + + public func cancelButtonTapped() { + self.route = nil + } + + public func duplicateButtonTapped() { + self.route = .duplicate(.init(item: self.item.duplicate())) + } + + func duplicate(item: Item) { + self.onDuplicate(item) + self.route = nil + } +} + +extension Item { + public func duplicate() -> Self { + .init(name: self.name, color: self.color, status: self.status) + } +} + +public struct ItemRowView: View { + @ObservedObject var viewModel: ItemRowViewModel + + public init( + viewModel: ItemRowViewModel + ) { + self.viewModel = viewModel + } + + public var body: some View { + NavigationLink( + unwrap: self.$viewModel.route, + case: /ItemRowViewModel.Route.edit, + onNavigate: self.viewModel.setEditNavigation(isActive:), + destination: { $itemViewModel in + ItemView(viewModel: itemViewModel) + .navigationBarTitle("Edit") + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.viewModel.cancelButtonTapped() + } + } + ToolbarItem(placement: .primaryAction) { + HStack { + if self.viewModel.isSaving { + ProgressView() + } + Button("Save") { + self.viewModel.edit(item: itemViewModel.item) + } + } + .disabled(self.viewModel.isSaving) + } + } + } + ) { + HStack { + VStack(alignment: .leading) { + Text(self.viewModel.item.name) + + switch self.viewModel.item.status { + case let .inStock(quantity): + Text("In stock: \(quantity)") + case let .outOfStock(isOnBackOrder): + Text("Out of stock" + (isOnBackOrder ? ": on back order" : "")) + } + } + + Spacer() + + if let color = self.viewModel.item.color { + Rectangle() + .frame(width: 30, height: 30) + .foregroundColor(color.swiftUIColor) + .border(Color.black, width: 1) + } + + Button(action: { self.viewModel.duplicateButtonTapped() }) { + Image(systemName: "square.fill.on.square.fill") + } + .padding(.leading) + + Button(action: { self.viewModel.deleteButtonTapped() }) { + Image(systemName: "trash.fill") + } + .padding(.leading) + } + .buttonStyle(.plain) + .foregroundColor(self.viewModel.item.status.isInStock ? nil : Color.gray) + .alert( + self.viewModel.item.name, + isPresented: self.$viewModel.route.isPresent(/ItemRowViewModel.Route.deleteAlert), + actions: { + Button("Delete", role: .destructive) { + self.viewModel.deleteConfirmationButtonTapped() + } + }, + message: { + Text("Are you sure you want to delete this item?") + } + ) + .popover( + item: self.$viewModel.route.case(/ItemRowViewModel.Route.duplicate) + ) { itemViewModel in + NavigationView { + ItemView(viewModel: itemViewModel) + .navigationBarTitle("Duplicate") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.viewModel.cancelButtonTapped() + } + } + ToolbarItem(placement: .primaryAction) { + Button("Add") { + self.viewModel.duplicate(item: itemViewModel.item) + } + } + } + } + .frame(minWidth: 300, minHeight: 500) + } + } + } +} + +struct ItemRowPreviews: PreviewProvider { + static var previews: some View { + NavigationView { + List { + ItemRowView( + viewModel: .init( + item: .init(name: "Keyboard", status: .inStock(quantity: 1)) + ) + ) + } + } + } +} diff --git a/0172-modularization-pt2/Inventory/Sources/ItemRowFeature/ItemRowCellView.swift b/0172-modularization-pt2/Inventory/Sources/ItemRowFeature/ItemRowCellView.swift new file mode 100644 index 00000000..0fb82bd9 --- /dev/null +++ b/0172-modularization-pt2/Inventory/Sources/ItemRowFeature/ItemRowCellView.swift @@ -0,0 +1,95 @@ +import Combine +import ItemFeature +import UIKit + +public class ItemRowCellView: UICollectionViewListCell { + var cancellables: Set = [] + + override public func prepareForReuse() { + super.prepareForReuse() + self.cancellables = [] + } + + public func bind(viewModel: ItemRowViewModel, context: UIViewController) { + viewModel.$item + .map(\.name) + .removeDuplicates() + .sink { [unowned self] name in + var content = self.defaultContentConfiguration() + content.text = name + self.contentConfiguration = content + } + .store(in: &self.cancellables) + + var presentedViewController: UIViewController? + + viewModel.$route + .removeDuplicates() + .sink { [unowned self] route in + switch route { + case .none: + guard let vc = presentedViewController + else { return } + vc.dismiss(animated: true) + context.navigationController?.popToViewController(vc, animated: true) + context.navigationController?.popViewController(animated: true) + presentedViewController = nil + + case .deleteAlert: + let alert = UIAlertController( + title: viewModel.item.name, + message: "Are you sure you want to delete this item?", + preferredStyle: .alert + ) + alert.addAction(.init(title: "Cancel", style: .cancel, handler: { _ in + viewModel.cancelButtonTapped() + })) + alert.addAction(.init(title: "Delete", style: .destructive, handler: { _ in + viewModel.deleteConfirmationButtonTapped() + })) + context.present(alert, animated: true) + presentedViewController = alert + + case let .duplicate(itemViewModel): + let vc = ItemViewController(viewModel: itemViewModel) + vc.title = "Duplicate" + vc.navigationItem.leftBarButtonItem = .init( + title: "Cancel", + primaryAction: .init { _ in + viewModel.cancelButtonTapped() + } + ) + vc.navigationItem.rightBarButtonItem = .init( + title: "Add", + primaryAction: .init { _ in + viewModel.duplicate(item: itemViewModel.item) + } + ) + let nav = UINavigationController(rootViewController: vc) + nav.modalPresentationStyle = .popover + nav.popoverPresentationController?.sourceView = self + context.present(nav, animated: true) + presentedViewController = nav + + case let .edit(itemViewModel): + let vc = ItemViewController(viewModel: itemViewModel) + vc.title = "Edit" + vc.navigationItem.leftBarButtonItem = .init( + title: "Cancel", + primaryAction: .init { _ in + viewModel.cancelButtonTapped() + } + ) + vc.navigationItem.rightBarButtonItem = .init( + title: "Save", + primaryAction: .init { _ in + viewModel.edit(item: itemViewModel.item) + } + ) + context.show(vc, sender: nil) + presentedViewController = vc + } + } + .store(in: &self.cancellables) + } +} diff --git a/0172-modularization-pt2/Inventory/Sources/ItemRowFeature/Routing.swift b/0172-modularization-pt2/Inventory/Sources/ItemRowFeature/Routing.swift new file mode 100644 index 00000000..6bf7cecb --- /dev/null +++ b/0172-modularization-pt2/Inventory/Sources/ItemRowFeature/Routing.swift @@ -0,0 +1,38 @@ +import Foundation +import Parsing +import ParsingHelpers + +public enum ItemRowRoute { + case delete + case duplicate + case edit +} + +// nav:///inventory/:uuid/edit/colorPicker? + +public let itemRowDeepLinker = PathComponent("edit") + .skip(PathEnd()) + .map { ItemRowRoute.edit } + .orElse( + PathComponent("delete") + .skip(PathEnd()) + .map { .delete } + ) + .orElse( + PathComponent("duplicate") + .skip(PathEnd()) + .map { .duplicate } + ) + +extension ItemRowViewModel { + public func navigate(to route: ItemRowRoute) { + switch route { + case .delete: + self.route = .deleteAlert + case .duplicate: + self.route = .duplicate(.init(item: self.item)) + case .edit: + self.route = .edit(.init(item: self.item)) + } + } +} diff --git a/0172-modularization-pt2/Inventory/Sources/Models/Models.swift b/0172-modularization-pt2/Inventory/Sources/Models/Models.swift new file mode 100644 index 00000000..043294a0 --- /dev/null +++ b/0172-modularization-pt2/Inventory/Sources/Models/Models.swift @@ -0,0 +1,68 @@ +import Foundation +import SwiftUI + +public struct Item: Equatable, Identifiable { + public let id = UUID() + public var name: String + public var color: Color? + public var status: Status + + public init( + name: String, + color: Color? = nil, + status: Status + ) { + self.name = name + self.color = color + self.status = status + } + + public enum Status: Equatable { + case inStock(quantity: Int) + case outOfStock(isOnBackOrder: Bool) + + public var isInStock: Bool { + guard case .inStock = self else { return false } + return true + } + } + + public struct Color: Equatable, Hashable { + public var name: String + public var red: CGFloat = 0 + public var green: CGFloat = 0 + public var blue: CGFloat = 0 + + public init( + name: String, + red: CGFloat = 0, + green: CGFloat = 0, + blue: CGFloat = 0 + ) { + self.name = name + self.red = red + self.green = green + self.blue = blue + } + + public static var defaults: [Self] = [ + .red, + .green, + .blue, + .black, + .yellow, + .white, + ] + + public static let red = Self(name: "Red", red: 1) + public static let green = Self(name: "Green", green: 1) + public static let blue = Self(name: "Blue", blue: 1) + public static let black = Self(name: "Black") + public static let yellow = Self(name: "Yellow", red: 1, green: 1) + public static let white = Self(name: "White", red: 1, green: 1, blue: 1) + + public var swiftUIColor: SwiftUI.Color { + .init(red: self.red, green: self.green, blue: self.blue) + } + } +} diff --git a/0172-modularization-pt2/Inventory/Sources/ParsingHelpers/ParsingHelpers.swift b/0172-modularization-pt2/Inventory/Sources/ParsingHelpers/ParsingHelpers.swift new file mode 100644 index 00000000..1e918f10 --- /dev/null +++ b/0172-modularization-pt2/Inventory/Sources/ParsingHelpers/ParsingHelpers.swift @@ -0,0 +1,82 @@ +import Foundation +import Parsing + +public struct DeepLinkRequest { + var pathComponents: ArraySlice + var queryItems: [String: ArraySlice] +} + +extension DeepLinkRequest { + public init(url: URL) { + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + + self.init( + pathComponents: url.path.split(separator: "/")[...], + queryItems: queryItems.reduce(into: [:]) { dictionary, item in + dictionary[item.name, default: []].append(item.value?[...]) + } + ) + } +} + +public struct PathComponent: Parser +where + ComponentParser: Parser, + ComponentParser.Input == Substring +{ + let component: ComponentParser + public init(_ component: ComponentParser) { + self.component = component + } + + public func parse(_ input: inout DeepLinkRequest) -> ComponentParser.Output? { + guard + var firstComponent = input.pathComponents.first, + let output = self.component.parse(&firstComponent), + firstComponent.isEmpty + else { return nil } + + input.pathComponents.removeFirst() + return output + } +} + +public struct PathEnd: Parser { + public init() {} + + public func parse(_ input: inout DeepLinkRequest) -> Void? { + guard input.pathComponents.isEmpty + else { return nil } + return () + } +} + +public struct QueryItem: Parser +where + ValueParser: Parser, + ValueParser.Input == Substring +{ + let name: String + let valueParser: ValueParser + + public init(_ name: String, _ valueParser: ValueParser) { + self.name = name + self.valueParser = valueParser + } + + public init(_ name: String) where ValueParser == Rest { + self.init(name, Rest()) + } + + public func parse(_ input: inout DeepLinkRequest) -> ValueParser.Output? { + guard + let wrapped = input.queryItems[self.name]?.first, + var value = wrapped, + let output = self.valueParser.parse(&value), + value.isEmpty + else { return nil } + + input.queryItems[self.name]?.removeFirst() + return output + } +} diff --git a/0172-modularization-pt2/Inventory/Sources/SwiftUIHelpers/SwiftUIHelpers.swift b/0172-modularization-pt2/Inventory/Sources/SwiftUIHelpers/SwiftUIHelpers.swift new file mode 100644 index 00000000..2efdce84 --- /dev/null +++ b/0172-modularization-pt2/Inventory/Sources/SwiftUIHelpers/SwiftUIHelpers.swift @@ -0,0 +1,262 @@ +import CasePaths +import SwiftUI + +extension Binding { + public init?(unwrap binding: Binding) { + guard let wrappedValue = binding.wrappedValue + else { return nil } + + self.init( + get: { wrappedValue }, + set: { binding.wrappedValue = $0 } + ) + } + + public func isPresent() -> Binding + where Value == Wrapped? { + .init( + get: { self.wrappedValue != nil }, + set: { isPresented in + if !isPresented { + self.wrappedValue = nil + } + } + ) + } + + public func isPresent(_ casePath: CasePath) -> Binding + where Value == Enum? { + Binding( + get: { + if let wrappedValue = self.wrappedValue, casePath.extract(from: wrappedValue) != nil { + return true + } else { + return false + } + }, + set: { isPresented in + if !isPresented { + self.wrappedValue = nil + } + } + ) + } + + public func `case`(_ casePath: CasePath) -> Binding + where Value == Enum? { + Binding( + get: { + guard + let wrappedValue = self.wrappedValue, + let `case` = casePath.extract(from: wrappedValue) + else { return nil } + return `case` + }, + set: { `case` in + if let `case` = `case` { + self.wrappedValue = casePath.embed(`case`) + } else { + self.wrappedValue = nil + } + } + ) + } + + public func didSet(_ callback: @escaping (Value) -> Void) -> Self { + .init( + get: { self.wrappedValue }, + set: { + self.wrappedValue = $0 + callback($0) + } + ) + } +} + +extension View { + public func alert( + title: (T) -> Text, + presenting data: Binding, + @ViewBuilder actions: @escaping (T) -> A, + @ViewBuilder message: @escaping (T) -> M + ) -> some View { + self.alert( + data.wrappedValue.map(title) ?? Text(""), + isPresented: data.isPresent(), + presenting: data.wrappedValue, + actions: actions, + message: message + ) + } + + public func alert( + title: (Case) -> Text, + unwrap data: Binding, + case casePath: CasePath, + @ViewBuilder actions: @escaping (Case) -> A, + @ViewBuilder message: @escaping (Case) -> M + ) -> some View { + self.alert( + title: title, + presenting: data.case(casePath), + actions: actions, + message: message + ) + } + + public func confirmationDialog( + title: (T) -> Text, + titleVisibility: Visibility = .automatic, + presenting data: Binding, + @ViewBuilder actions: @escaping (T) -> A, + @ViewBuilder message: @escaping (T) -> M + ) -> some View { + self.confirmationDialog( + data.wrappedValue.map(title) ?? Text(""), + isPresented: data.isPresent(), + titleVisibility: titleVisibility, + presenting: data.wrappedValue, + actions: actions, + message: message + ) + } + + public func confirmationDialog( + title: (Case) -> Text, + unwrap data: Binding, + case casePath: CasePath, + @ViewBuilder actions: @escaping (Case) -> A, + @ViewBuilder message: @escaping (Case) -> M + ) -> some View { + self.confirmationDialog( + title: title, + presenting: data.case(casePath), + actions: actions, + message: message + ) + } + + public func sheet( + unwrap optionalValue: Binding, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View where Value: Identifiable, Content: View { + self.sheet( + item: optionalValue + ) { _ in + if let value = Binding(unwrap: optionalValue) { + content(value) + } + } + } + + public func sheet( + unwrap optionalValue: Binding, + case casePath: CasePath, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View where Case: Identifiable, Content: View { + self.sheet(unwrap: optionalValue.case(casePath), content: content) + } + + public func popover( + unwrap optionalValue: Binding, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View where Value: Identifiable, Content: View { + self.popover( + item: optionalValue + ) { _ in + if let value = Binding(unwrap: optionalValue) { + content(value) + } + } + } + + public func popover( + unwrap optionalValue: Binding, + case casePath: CasePath, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View where Case: Identifiable, Content: View { + self.popover(unwrap: optionalValue.case(casePath), content: content) + } +} + +extension NavigationLink { + public init( + unwrap optionalValue: Binding, + onNavigate: @escaping (Bool) -> Void, + @ViewBuilder destination: @escaping (Binding) -> WrappedDestination, + @ViewBuilder label: @escaping () -> Label + ) + where Destination == WrappedDestination? + { + self.init( + isActive: optionalValue.isPresent().didSet(onNavigate), + destination: { + if let value = Binding(unwrap: optionalValue) { + destination(value) + } + }, + label: label + ) + } + + public init( + unwrap optionalValue: Binding, + case casePath: CasePath, + onNavigate: @escaping (Bool) -> Void, + @ViewBuilder destination: @escaping (Binding) -> WrappedDestination, + @ViewBuilder label: @escaping () -> Label + ) + where Destination == WrappedDestination? + { + self.init( + unwrap: optionalValue.case(casePath), + onNavigate: onNavigate, + destination: destination, + label: label + ) + } +} + +public struct IfCaseLet: View where Content: View { + let binding: Binding + let casePath: CasePath + let content: (Binding) -> Content + + public init( + _ binding: Binding, + pattern casePath: CasePath, + @ViewBuilder content: @escaping (Binding) -> Content + ) { + self.binding = binding + self.casePath = casePath + self.content = content + } + + public var body: some View { + if let `case` = self.casePath.extract(from: self.binding.wrappedValue) { + self.content( + Binding( + get: { `case` }, + set: { binding.wrappedValue = self.casePath.embed($0) } + ) + ) + } + } +} + +public struct ToSwiftUI: UIViewControllerRepresentable { + let viewController: () -> UIViewController + + public init( + viewController: @escaping () -> UIViewController + ) { + self.viewController = viewController + } + + public func makeUIViewController(context: Context) -> UIViewController { + self.viewController() + } + + public func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + } +} diff --git a/0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/Assets.xcassets/AccentColor.colorset/Contents.json b/0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..9221b9bb --- /dev/null +++ b/0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/Assets.xcassets/Contents.json b/0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/Info.plist b/0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/Info.plist new file mode 100644 index 00000000..28b13be5 --- /dev/null +++ b/0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/Info.plist @@ -0,0 +1,17 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + itemRow + + + + + diff --git a/0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/ItemRowPreviewAppApp.swift b/0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/ItemRowPreviewAppApp.swift new file mode 100644 index 00000000..664ec593 --- /dev/null +++ b/0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/ItemRowPreviewAppApp.swift @@ -0,0 +1,26 @@ +import ItemRowFeature +import ParsingHelpers +import SwiftUI + +@main +struct ItemRowPreviewAppApp: App { + let viewModel = ItemRowViewModel(item: .init(name: "Keyboard", color: .blue, status: .inStock(quantity: 1))) + + var body: some Scene { + WindowGroup { + NavigationView { + List { + ItemRowView( + viewModel: self.viewModel + ) + } + } + .onOpenURL { url in + var request = DeepLinkRequest(url: url) + if let route = itemRowDeepLinker.parse(&request) { + self.viewModel.navigate(to: route) + } + } + } + } +} diff --git a/0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/Preview Content/Preview Assets.xcassets/Contents.json b/0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0172-modularization-pt2/Inventory/SwiftUINavigation/ItemRowPreviewApp/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0172-modularization-pt2/Inventory/SwiftUINavigation/Package.swift b/0172-modularization-pt2/Inventory/SwiftUINavigation/Package.swift new file mode 100644 index 00000000..c7564eea --- /dev/null +++ b/0172-modularization-pt2/Inventory/SwiftUINavigation/Package.swift @@ -0,0 +1,8 @@ +import PackageDescription + +let package = Package( + name: "", + products: [], + dependencies: [], + targets: [] +) diff --git a/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.pbxproj b/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.pbxproj new file mode 100644 index 00000000..659ce467 --- /dev/null +++ b/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.pbxproj @@ -0,0 +1,651 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 55; + objects = { + +/* Begin PBXBuildFile section */ + 2A3A7B8B26EF940C00A37A4D /* SwiftUINavigationApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3A7B8A26EF940C00A37A4D /* SwiftUINavigationApp.swift */; }; + 2A3A7B8F26EF940D00A37A4D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2A3A7B8E26EF940D00A37A4D /* Assets.xcassets */; }; + 2A3A7B9226EF940D00A37A4D /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2A3A7B9126EF940D00A37A4D /* Preview Assets.xcassets */; }; + 2A3A7B9C26EF940D00A37A4D /* SwiftUINavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3A7B9B26EF940D00A37A4D /* SwiftUINavigationTests.swift */; }; + 4B07D500276120FB00C89C56 /* AppFeature in Frameworks */ = {isa = PBXBuildFile; productRef = 4B07D4FF276120FB00C89C56 /* AppFeature */; }; + 4B43DCB427612A990019AB45 /* ItemRowPreviewAppApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B43DCB327612A990019AB45 /* ItemRowPreviewAppApp.swift */; }; + 4B43DCB827612A9B0019AB45 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4B43DCB727612A9B0019AB45 /* Assets.xcassets */; }; + 4B43DCBB27612A9B0019AB45 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4B43DCBA27612A9B0019AB45 /* Preview Assets.xcassets */; }; + 4B43DCC027612AAC0019AB45 /* ItemRowFeature in Frameworks */ = {isa = PBXBuildFile; productRef = 4B43DCBF27612AAC0019AB45 /* ItemRowFeature */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 2A3A7B9826EF940D00A37A4D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 2A3A7B7F26EF940C00A37A4D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 2A3A7B8626EF940C00A37A4D; + remoteInfo = SwiftUINavigation; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 2A3A7B8726EF940C00A37A4D /* SwiftUINavigation.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftUINavigation.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 2A3A7B8A26EF940C00A37A4D /* SwiftUINavigationApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUINavigationApp.swift; sourceTree = ""; }; + 2A3A7B8E26EF940D00A37A4D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 2A3A7B9126EF940D00A37A4D /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 2A3A7B9726EF940D00A37A4D /* SwiftUINavigationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SwiftUINavigationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 2A3A7B9B26EF940D00A37A4D /* SwiftUINavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUINavigationTests.swift; sourceTree = ""; }; + 2AF90B8727611158002BEEF8 /* Inventory */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Inventory; path = ..; sourceTree = ""; }; + 4B43DCB127612A990019AB45 /* ItemRowPreviewApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ItemRowPreviewApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 4B43DCB327612A990019AB45 /* ItemRowPreviewAppApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemRowPreviewAppApp.swift; sourceTree = ""; }; + 4B43DCB727612A9B0019AB45 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 4B43DCBA27612A9B0019AB45 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 4B43DCC127612BA70019AB45 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 4BCC55A9272C53D80032BF7A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 2A3A7B8426EF940C00A37A4D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4B07D500276120FB00C89C56 /* AppFeature in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2A3A7B9426EF940D00A37A4D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 4B43DCAE27612A990019AB45 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4B43DCC027612AAC0019AB45 /* ItemRowFeature in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2A3A7B7E26EF940C00A37A4D = { + isa = PBXGroup; + children = ( + 2AF90B8727611158002BEEF8 /* Inventory */, + 2A3A7B8926EF940C00A37A4D /* SwiftUINavigation */, + 2A3A7B9A26EF940D00A37A4D /* SwiftUINavigationTests */, + 4B43DCB227612A990019AB45 /* ItemRowPreviewApp */, + 2A3A7B8826EF940C00A37A4D /* Products */, + 2AF90B8827611308002BEEF8 /* Frameworks */, + ); + sourceTree = ""; + }; + 2A3A7B8826EF940C00A37A4D /* Products */ = { + isa = PBXGroup; + children = ( + 2A3A7B8726EF940C00A37A4D /* SwiftUINavigation.app */, + 2A3A7B9726EF940D00A37A4D /* SwiftUINavigationTests.xctest */, + 4B43DCB127612A990019AB45 /* ItemRowPreviewApp.app */, + ); + name = Products; + sourceTree = ""; + }; + 2A3A7B8926EF940C00A37A4D /* SwiftUINavigation */ = { + isa = PBXGroup; + children = ( + 4BCC55A9272C53D80032BF7A /* Info.plist */, + 2A3A7B8A26EF940C00A37A4D /* SwiftUINavigationApp.swift */, + 2A3A7B8E26EF940D00A37A4D /* Assets.xcassets */, + 2A3A7B9026EF940D00A37A4D /* Preview Content */, + ); + path = SwiftUINavigation; + sourceTree = ""; + }; + 2A3A7B9026EF940D00A37A4D /* Preview Content */ = { + isa = PBXGroup; + children = ( + 2A3A7B9126EF940D00A37A4D /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 2A3A7B9A26EF940D00A37A4D /* SwiftUINavigationTests */ = { + isa = PBXGroup; + children = ( + 2A3A7B9B26EF940D00A37A4D /* SwiftUINavigationTests.swift */, + ); + path = SwiftUINavigationTests; + sourceTree = ""; + }; + 2AF90B8827611308002BEEF8 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + 4B43DCB227612A990019AB45 /* ItemRowPreviewApp */ = { + isa = PBXGroup; + children = ( + 4B43DCC127612BA70019AB45 /* Info.plist */, + 4B43DCB327612A990019AB45 /* ItemRowPreviewAppApp.swift */, + 4B43DCB727612A9B0019AB45 /* Assets.xcassets */, + 4B43DCB927612A9B0019AB45 /* Preview Content */, + ); + path = ItemRowPreviewApp; + sourceTree = ""; + }; + 4B43DCB927612A9B0019AB45 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 4B43DCBA27612A9B0019AB45 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 2A3A7B8626EF940C00A37A4D /* SwiftUINavigation */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2A3A7BAB26EF940E00A37A4D /* Build configuration list for PBXNativeTarget "SwiftUINavigation" */; + buildPhases = ( + 2A3A7B8326EF940C00A37A4D /* Sources */, + 2A3A7B8426EF940C00A37A4D /* Frameworks */, + 2A3A7B8526EF940C00A37A4D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SwiftUINavigation; + packageProductDependencies = ( + 4B07D4FF276120FB00C89C56 /* AppFeature */, + ); + productName = SwiftUINavigation; + productReference = 2A3A7B8726EF940C00A37A4D /* SwiftUINavigation.app */; + productType = "com.apple.product-type.application"; + }; + 2A3A7B9626EF940D00A37A4D /* SwiftUINavigationTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2A3A7BAE26EF940E00A37A4D /* Build configuration list for PBXNativeTarget "SwiftUINavigationTests" */; + buildPhases = ( + 2A3A7B9326EF940D00A37A4D /* Sources */, + 2A3A7B9426EF940D00A37A4D /* Frameworks */, + 2A3A7B9526EF940D00A37A4D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 2A3A7B9926EF940D00A37A4D /* PBXTargetDependency */, + ); + name = SwiftUINavigationTests; + productName = SwiftUINavigationTests; + productReference = 2A3A7B9726EF940D00A37A4D /* SwiftUINavigationTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 4B43DCB027612A990019AB45 /* ItemRowPreviewApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4B43DCBE27612A9B0019AB45 /* Build configuration list for PBXNativeTarget "ItemRowPreviewApp" */; + buildPhases = ( + 4B43DCAD27612A990019AB45 /* Sources */, + 4B43DCAE27612A990019AB45 /* Frameworks */, + 4B43DCAF27612A990019AB45 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ItemRowPreviewApp; + packageProductDependencies = ( + 4B43DCBF27612AAC0019AB45 /* ItemRowFeature */, + ); + productName = ItemRowPreviewApp; + productReference = 4B43DCB127612A990019AB45 /* ItemRowPreviewApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 2A3A7B7F26EF940C00A37A4D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1320; + LastUpgradeCheck = 1300; + TargetAttributes = { + 2A3A7B8626EF940C00A37A4D = { + CreatedOnToolsVersion = 13.0; + }; + 2A3A7B9626EF940D00A37A4D = { + CreatedOnToolsVersion = 13.0; + TestTargetID = 2A3A7B8626EF940C00A37A4D; + }; + 4B43DCB027612A990019AB45 = { + CreatedOnToolsVersion = 13.2; + }; + }; + }; + buildConfigurationList = 2A3A7B8226EF940C00A37A4D /* Build configuration list for PBXProject "SwiftUINavigation" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 2A3A7B7E26EF940C00A37A4D; + packageReferences = ( + ); + productRefGroup = 2A3A7B8826EF940C00A37A4D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 2A3A7B8626EF940C00A37A4D /* SwiftUINavigation */, + 2A3A7B9626EF940D00A37A4D /* SwiftUINavigationTests */, + 4B43DCB027612A990019AB45 /* ItemRowPreviewApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 2A3A7B8526EF940C00A37A4D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2A3A7B9226EF940D00A37A4D /* Preview Assets.xcassets in Resources */, + 2A3A7B8F26EF940D00A37A4D /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2A3A7B9526EF940D00A37A4D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 4B43DCAF27612A990019AB45 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4B43DCBB27612A9B0019AB45 /* Preview Assets.xcassets in Resources */, + 4B43DCB827612A9B0019AB45 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 2A3A7B8326EF940C00A37A4D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2A3A7B8B26EF940C00A37A4D /* SwiftUINavigationApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2A3A7B9326EF940D00A37A4D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2A3A7B9C26EF940D00A37A4D /* SwiftUINavigationTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 4B43DCAD27612A990019AB45 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4B43DCB427612A990019AB45 /* ItemRowPreviewAppApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 2A3A7B9926EF940D00A37A4D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 2A3A7B8626EF940C00A37A4D /* SwiftUINavigation */; + targetProxy = 2A3A7B9826EF940D00A37A4D /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 2A3A7BA926EF940E00A37A4D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 2A3A7BAA26EF940E00A37A4D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 2A3A7BAC26EF940E00A37A4D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"SwiftUINavigation/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SwiftUINavigation/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SwiftUINavigation; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 2A3A7BAD26EF940E00A37A4D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"SwiftUINavigation/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SwiftUINavigation/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SwiftUINavigation; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 2A3A7BAF26EF940E00A37A4D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SwiftUINavigationTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftUINavigation.app/SwiftUINavigation"; + }; + name = Debug; + }; + 2A3A7BB026EF940E00A37A4D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SwiftUINavigationTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftUINavigation.app/SwiftUINavigation"; + }; + name = Release; + }; + 4B43DCBC27612A9B0019AB45 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ItemRowPreviewApp/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ItemRowPreviewApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.ItemRowPreviewApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 4B43DCBD27612A9B0019AB45 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ItemRowPreviewApp/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ItemRowPreviewApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.ItemRowPreviewApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 2A3A7B8226EF940C00A37A4D /* Build configuration list for PBXProject "SwiftUINavigation" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2A3A7BA926EF940E00A37A4D /* Debug */, + 2A3A7BAA26EF940E00A37A4D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2A3A7BAB26EF940E00A37A4D /* Build configuration list for PBXNativeTarget "SwiftUINavigation" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2A3A7BAC26EF940E00A37A4D /* Debug */, + 2A3A7BAD26EF940E00A37A4D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2A3A7BAE26EF940E00A37A4D /* Build configuration list for PBXNativeTarget "SwiftUINavigationTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2A3A7BAF26EF940E00A37A4D /* Debug */, + 2A3A7BB026EF940E00A37A4D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 4B43DCBE27612A9B0019AB45 /* Build configuration list for PBXNativeTarget "ItemRowPreviewApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4B43DCBC27612A9B0019AB45 /* Debug */, + 4B43DCBD27612A9B0019AB45 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4B07D4FF276120FB00C89C56 /* AppFeature */ = { + isa = XCSwiftPackageProductDependency; + productName = AppFeature; + }; + 4B43DCBF27612AAC0019AB45 /* ItemRowFeature */ = { + isa = XCSwiftPackageProductDependency; + productName = ItemRowFeature; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 2A3A7B7F26EF940C00A37A4D /* Project object */; +} diff --git a/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/AccentColor.colorset/Contents.json b/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/AppIcon.appiconset/Contents.json b/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..9221b9bb --- /dev/null +++ b/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/Contents.json b/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/Info.plist b/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/Info.plist new file mode 100644 index 00000000..084492d5 --- /dev/null +++ b/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/Info.plist @@ -0,0 +1,17 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + nav + + + + + diff --git a/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/Preview Content/Preview Assets.xcassets/Contents.json b/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/SwiftUINavigationApp.swift b/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/SwiftUINavigationApp.swift new file mode 100644 index 00000000..63f25d6c --- /dev/null +++ b/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigation/SwiftUINavigationApp.swift @@ -0,0 +1,112 @@ +import AppFeature +import InventoryFeature +import ItemRowFeature +import Models +import SwiftUI +import SwiftUIHelpers + +@main +struct SwiftUINavigationApp: App { + var body: some Scene { + let keyboard = Item(name: "Keyboard", color: .blue, status: .inStock(quantity: 100)) + + var editedKeyboard = keyboard + editedKeyboard.name = "Bluetooth Keyboard" + editedKeyboard.status = .inStock(quantity: 1000) + + let viewModel = AppViewModel( + inventoryViewModel: .init( + inventory: [ + .init(item: keyboard), + .init(item: Item(name: "Charger", color: .yellow, status: .inStock(quantity: 20))), + .init(item: Item(name: "Phone", color: .green, status: .outOfStock(isOnBackOrder: true))), + .init(item: Item(name: "Headphones", color: .green, status: .outOfStock(isOnBackOrder: false))), + ], + route: nil + ), + selectedTab: .one + ) + + return WindowGroup { + RootView(viewModel: viewModel) + } + } +} + +struct RootView: View { + @State var isSwiftUI = true + let viewModel: AppViewModel + + var body: some View { + ZStack(alignment: .top) { + Group { + if self.isSwiftUI { + ContentView(viewModel: self.viewModel) + } else { + ToSwiftUI { + ContentViewController(viewModel: self.viewModel) + } + .onOpenURL { url in + self.viewModel.open(url: url) + } + } + } + .padding(.top, 44) + + Button(self.isSwiftUI ? "Use UIKit" : "Use SwiftUI") { + self.isSwiftUI.toggle() + } + } + } +} + + + + + + + + + + + + + + + + /*------| + | U | + |-------| | s | + | I | | e |-------| + |-------| n | | r | S | + | I | v |-------| P | e | + | t | e | S | r | t | + |-------| e | n | e | o | t | + | I | m | t | a | f | i | ... + | t | R | o | r | i | n | + | e | o | r | c | l | g | + | m | w | y | h | e | s | + |-------|-------|-------|-------|-------|------*/ +/*----------------------------------------------------------------------------------| +| | +| MODEL/HELPER/DEPENDENCY MODULES | +| |---------------|----------------|----------------|-----------|---------------| | +| | Models | SwiftUIHelpers | ParsingHelpers | ApiClient | ApiClientLive | | +| |---------------|----------------|----------------|-----------|---------------| | +| | +|----------------------------------------------------------------------------------*/ + + + + + + + + + + + + + + + diff --git a/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigationTests/SwiftUINavigationTests.swift b/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigationTests/SwiftUINavigationTests.swift new file mode 100644 index 00000000..13dbbbb9 --- /dev/null +++ b/0172-modularization-pt2/Inventory/SwiftUINavigation/SwiftUINavigationTests/SwiftUINavigationTests.swift @@ -0,0 +1,93 @@ +//import CasePaths +//import XCTest +//@testable import SwiftUINavigation +// +//class SwiftUINavigationTests: XCTestCase { +// func testAddItem() throws { +// let viewModel = InventoryViewModel() +// viewModel.addButtonTapped() +// +// let itemToAdd = try XCTUnwrap((/InventoryViewModel.Route.add).extract(from: XCTUnwrap(viewModel.route))) +// +// viewModel.add(item: itemToAdd) +// +// XCTAssertNil(viewModel.route) +// XCTAssertEqual(viewModel.inventory.count, 1) +// XCTAssertEqual(viewModel.inventory[0].item, itemToAdd) +// } +// +// func testDeleteItem() { +// let viewModel = InventoryViewModel( +// inventory: [ +// .init(item: .init(name: "Keyboard", color: .red, status: .inStock(quantity: 1))) +// ] +// ) +// +// viewModel.inventory[0].deleteButtonTapped() +// +// XCTAssertEqual(viewModel.inventory[0].route, .deleteAlert) +// XCTAssertEqual(viewModel.route, .row(id: viewModel.inventory[0].item.id, route: .deleteAlert)) +// +// viewModel.inventory[0].deleteConfirmationButtonTapped() +// +// XCTAssertEqual(viewModel.inventory.count, 0) +// XCTAssertEqual(viewModel.route, nil) +// } +// +// func testDuplicateItem() throws { +// let item = Item(name: "Keyboard", color: .red, status: .inStock(quantity: 1)) +// let viewModel = InventoryViewModel( +// inventory: [ +// .init(item: item) +// ] +// ) +// +// viewModel.inventory[0].duplicateButtonTapped() +// +//// XCTAssertEqual(viewModel.inventory[0].route, .duplicate(item)) +// XCTAssertNotNil( +// (/ItemRowViewModel.Route.duplicate) +// .extract(from: try XCTUnwrap(viewModel.inventory[0].route)) +// ) +// +// let dupe = item.duplicate() +// viewModel.inventory[0].duplicate(item: dupe) +// +// XCTAssertEqual(viewModel.inventory.count, 2) +// XCTAssertEqual(viewModel.inventory[0].item, item) +// XCTAssertEqual(viewModel.inventory[1].item, dupe) +// XCTAssertNil(viewModel.inventory[0].route) +// } +// +// func testEdit() async throws { +// let item = Item(name: "Keyboard", color: .red, status: .inStock(quantity: 1)) +// let viewModel = InventoryViewModel( +// inventory: [ +// .init(item: item) +// ] +// ) +// +// viewModel.inventory[0].setEditNavigation(isActive: true) +// +// XCTAssertNotNil( +// (/ItemRowViewModel.Route.edit) +// .extract(from: try XCTUnwrap(viewModel.inventory[0].route)) +// ) +// +// var editedItem = item +// editedItem.color = .blue +// viewModel.inventory[0].route = .edit(editedItem) +// +// viewModel.inventory[0].edit(item: editedItem) +// +// XCTAssertEqual(viewModel.inventory[0].isSaving, true) +// +// try await Task.sleep(nanoseconds: NSEC_PER_SEC + 100 * NSEC_PER_MSEC) +// +// XCTAssertNil(viewModel.inventory[0].route) +// XCTAssertNil(viewModel.route) +// XCTAssertEqual(viewModel.inventory[0].item, editedItem) +// +// XCTAssertEqual(viewModel.inventory[0].isSaving, false) +// } +//} diff --git a/0172-modularization-pt2/README.md b/0172-modularization-pt2/README.md new file mode 100644 index 00000000..3caabd6e --- /dev/null +++ b/0172-modularization-pt2/README.md @@ -0,0 +1,5 @@ +## [Point-Free](https://www.pointfree.co) + +> #### This directory contains code from Point-Free Episode: [Modularization: Part 2](https://www.pointfree.co/episodes/ep172-modularization-part-2) +> +> We finish modularizing our application by extracting its deep linking logic across feature modules. We will then show the full power of modularization by building a “preview” application that can accomplish much more than an Xcode preview can. diff --git a/README.md b/README.md index e159ee6a..f9338109 100644 --- a/README.md +++ b/README.md @@ -173,3 +173,4 @@ This repository is the home of code written on episodes of [Point-Free](https:// 1. [UIKit Navigation: Part 1](0169-uikit-navigation-pt1) 1. [UIKit Navigation: Part 2](0170-uikit-navigation-pt2) 1. [Modularization: Part 1](0171-modularization-pt1) +1. [Modularization: Part 2](0172-modularization-pt2)