From caa1b95bf0aa046db75e46f9e0433c9e1aa0ee75 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 20 Mar 2023 13:06:03 -0700 Subject: [PATCH] 227 --- .../Inventory.xcodeproj/project.pbxproj | 558 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/Inventory.xcscheme | 88 +++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + .../Inventory/Assets.xcassets/Contents.json | 6 + .../Inventory/Inventory/ContentView.swift | 101 ++++ .../Inventory/Inventory/FirstTab.swift | 38 ++ .../Inventory/Inventory/Inventory.swift | 396 +++++++++++++ .../Inventory/Inventory/InventoryApp.swift | 28 + .../Inventory/Inventory/ItemForm.swift | 150 +++++ .../Inventory/Inventory/Models.swift | 88 +++ .../Inventory/Inventory/Navigation.swift | 351 +++++++++++ .../Preview Assets.xcassets/Contents.json | 6 + .../Inventory/Inventory/ThirdTab.swift | 18 + .../Inventory/Inventory/Vanilla.swift | 32 + .../InventoryTests/InventoryTests.swift | 220 +++++++ .../InventoryTests/VanillaTests.swift | 26 + 0227-composable-navigation-pt6/README.md | 5 + README.md | 1 + 21 files changed, 2151 insertions(+) create mode 100644 0227-composable-navigation-pt6/Inventory/Inventory.xcodeproj/project.pbxproj create mode 100644 0227-composable-navigation-pt6/Inventory/Inventory.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 0227-composable-navigation-pt6/Inventory/Inventory.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 0227-composable-navigation-pt6/Inventory/Inventory.xcodeproj/xcshareddata/xcschemes/Inventory.xcscheme create mode 100644 0227-composable-navigation-pt6/Inventory/Inventory/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 0227-composable-navigation-pt6/Inventory/Inventory/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 0227-composable-navigation-pt6/Inventory/Inventory/Assets.xcassets/Contents.json create mode 100644 0227-composable-navigation-pt6/Inventory/Inventory/ContentView.swift create mode 100644 0227-composable-navigation-pt6/Inventory/Inventory/FirstTab.swift create mode 100644 0227-composable-navigation-pt6/Inventory/Inventory/Inventory.swift create mode 100644 0227-composable-navigation-pt6/Inventory/Inventory/InventoryApp.swift create mode 100644 0227-composable-navigation-pt6/Inventory/Inventory/ItemForm.swift create mode 100644 0227-composable-navigation-pt6/Inventory/Inventory/Models.swift create mode 100644 0227-composable-navigation-pt6/Inventory/Inventory/Navigation.swift create mode 100644 0227-composable-navigation-pt6/Inventory/Inventory/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 0227-composable-navigation-pt6/Inventory/Inventory/ThirdTab.swift create mode 100644 0227-composable-navigation-pt6/Inventory/Inventory/Vanilla.swift create mode 100644 0227-composable-navigation-pt6/Inventory/InventoryTests/InventoryTests.swift create mode 100644 0227-composable-navigation-pt6/Inventory/InventoryTests/VanillaTests.swift create mode 100644 0227-composable-navigation-pt6/README.md diff --git a/0227-composable-navigation-pt6/Inventory/Inventory.xcodeproj/project.pbxproj b/0227-composable-navigation-pt6/Inventory/Inventory.xcodeproj/project.pbxproj new file mode 100644 index 00000000..276aa5b4 --- /dev/null +++ b/0227-composable-navigation-pt6/Inventory/Inventory.xcodeproj/project.pbxproj @@ -0,0 +1,558 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 2A3BE9F72994469500351060 /* InventoryApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3BE9F62994469500351060 /* InventoryApp.swift */; }; + 2A3BE9F92994469500351060 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3BE9F82994469500351060 /* ContentView.swift */; }; + 2A3BE9FB2994469600351060 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2A3BE9FA2994469600351060 /* Assets.xcassets */; }; + 2A3BE9FE2994469600351060 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2A3BE9FD2994469600351060 /* Preview Assets.xcassets */; }; + 2A3BEA082994469600351060 /* InventoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3BEA072994469600351060 /* InventoryTests.swift */; }; + 2A3BEA2229944BA700351060 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = 2A3BEA2129944BA700351060 /* ComposableArchitecture */; }; + 2A3BEA2429945D4000351060 /* Vanilla.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3BEA2329945D4000351060 /* Vanilla.swift */; }; + 2A3BEA2829945ECB00351060 /* VanillaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3BEA2729945ECB00351060 /* VanillaTests.swift */; }; + 2AD4AD5329B9512C00F691CF /* CustomDump in Frameworks */ = {isa = PBXBuildFile; productRef = 2AD4AD5229B9512C00F691CF /* CustomDump */; }; + 4B0E65B729944EFC00DFB522 /* FirstTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0E65B629944EFC00DFB522 /* FirstTab.swift */; }; + 4B0E65B929944F4800DFB522 /* Inventory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0E65B829944F4800DFB522 /* Inventory.swift */; }; + 4B0E65BB29944F6500DFB522 /* ThirdTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0E65BA29944F6500DFB522 /* ThirdTab.swift */; }; + 4B0E65BD2994626900DFB522 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0E65BC2994626900DFB522 /* Models.swift */; }; + 4B0E65BF29946B1100DFB522 /* Navigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0E65BE29946B1100DFB522 /* Navigation.swift */; }; + 4B0E65C229946FD700DFB522 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = 4B0E65C129946FD700DFB522 /* SwiftUINavigation */; }; + 4BD190FF299EA3D500A6A7E5 /* ItemForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD190FE299EA3D500A6A7E5 /* ItemForm.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 2A3BEA042994469600351060 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 2A3BE9EB2994469500351060 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 2A3BE9F22994469500351060; + remoteInfo = Inventory; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 2A3BE9F32994469500351060 /* Inventory.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Inventory.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 2A3BE9F62994469500351060 /* InventoryApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InventoryApp.swift; sourceTree = ""; }; + 2A3BE9F82994469500351060 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 2A3BE9FA2994469600351060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 2A3BE9FD2994469600351060 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 2A3BEA032994469600351060 /* InventoryTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = InventoryTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 2A3BEA072994469600351060 /* InventoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InventoryTests.swift; sourceTree = ""; }; + 2A3BEA2329945D4000351060 /* Vanilla.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Vanilla.swift; sourceTree = ""; }; + 2A3BEA2729945ECB00351060 /* VanillaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VanillaTests.swift; sourceTree = ""; }; + 4B0E65B629944EFC00DFB522 /* FirstTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstTab.swift; sourceTree = ""; }; + 4B0E65B829944F4800DFB522 /* Inventory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Inventory.swift; sourceTree = ""; }; + 4B0E65BA29944F6500DFB522 /* ThirdTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdTab.swift; sourceTree = ""; }; + 4B0E65BC2994626900DFB522 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 4B0E65BE29946B1100DFB522 /* Navigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Navigation.swift; sourceTree = ""; }; + 4BD190FE299EA3D500A6A7E5 /* ItemForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemForm.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 2A3BE9F02994469500351060 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4B0E65C229946FD700DFB522 /* SwiftUINavigation in Frameworks */, + 2AD4AD5329B9512C00F691CF /* CustomDump in Frameworks */, + 2A3BEA2229944BA700351060 /* ComposableArchitecture in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2A3BEA002994469600351060 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2A3BE9EA2994469500351060 = { + isa = PBXGroup; + children = ( + 2A3BE9F52994469500351060 /* Inventory */, + 2A3BEA062994469600351060 /* InventoryTests */, + 2A3BE9F42994469500351060 /* Products */, + ); + sourceTree = ""; + }; + 2A3BE9F42994469500351060 /* Products */ = { + isa = PBXGroup; + children = ( + 2A3BE9F32994469500351060 /* Inventory.app */, + 2A3BEA032994469600351060 /* InventoryTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 2A3BE9F52994469500351060 /* Inventory */ = { + isa = PBXGroup; + children = ( + 2A3BE9F82994469500351060 /* ContentView.swift */, + 4B0E65B629944EFC00DFB522 /* FirstTab.swift */, + 4B0E65B829944F4800DFB522 /* Inventory.swift */, + 2A3BE9F62994469500351060 /* InventoryApp.swift */, + 4BD190FE299EA3D500A6A7E5 /* ItemForm.swift */, + 4B0E65BC2994626900DFB522 /* Models.swift */, + 4B0E65BE29946B1100DFB522 /* Navigation.swift */, + 4B0E65BA29944F6500DFB522 /* ThirdTab.swift */, + 2A3BEA2329945D4000351060 /* Vanilla.swift */, + 2A3BE9FA2994469600351060 /* Assets.xcassets */, + 2A3BE9FC2994469600351060 /* Preview Content */, + ); + path = Inventory; + sourceTree = ""; + }; + 2A3BE9FC2994469600351060 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 2A3BE9FD2994469600351060 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 2A3BEA062994469600351060 /* InventoryTests */ = { + isa = PBXGroup; + children = ( + 2A3BEA072994469600351060 /* InventoryTests.swift */, + 2A3BEA2729945ECB00351060 /* VanillaTests.swift */, + ); + path = InventoryTests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 2A3BE9F22994469500351060 /* Inventory */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2A3BEA172994469600351060 /* Build configuration list for PBXNativeTarget "Inventory" */; + buildPhases = ( + 2A3BE9EF2994469500351060 /* Sources */, + 2A3BE9F02994469500351060 /* Frameworks */, + 2A3BE9F12994469500351060 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Inventory; + packageProductDependencies = ( + 2A3BEA2129944BA700351060 /* ComposableArchitecture */, + 4B0E65C129946FD700DFB522 /* SwiftUINavigation */, + 2AD4AD5229B9512C00F691CF /* CustomDump */, + ); + productName = Inventory; + productReference = 2A3BE9F32994469500351060 /* Inventory.app */; + productType = "com.apple.product-type.application"; + }; + 2A3BEA022994469600351060 /* InventoryTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2A3BEA1A2994469600351060 /* Build configuration list for PBXNativeTarget "InventoryTests" */; + buildPhases = ( + 2A3BE9FF2994469600351060 /* Sources */, + 2A3BEA002994469600351060 /* Frameworks */, + 2A3BEA012994469600351060 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 2A3BEA052994469600351060 /* PBXTargetDependency */, + ); + name = InventoryTests; + productName = InventoryTests; + productReference = 2A3BEA032994469600351060 /* InventoryTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 2A3BE9EB2994469500351060 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1420; + LastUpgradeCheck = 1420; + TargetAttributes = { + 2A3BE9F22994469500351060 = { + CreatedOnToolsVersion = 14.2; + }; + 2A3BEA022994469600351060 = { + CreatedOnToolsVersion = 14.2; + TestTargetID = 2A3BE9F22994469500351060; + }; + }; + }; + buildConfigurationList = 2A3BE9EE2994469500351060 /* Build configuration list for PBXProject "Inventory" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 2A3BE9EA2994469500351060; + packageReferences = ( + 2A3BEA2029944BA700351060 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */, + 4B0E65C029946FD700DFB522 /* XCRemoteSwiftPackageReference "swiftui-navigation" */, + 2AD4AD5129B9512C00F691CF /* XCRemoteSwiftPackageReference "swift-custom-dump" */, + ); + productRefGroup = 2A3BE9F42994469500351060 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 2A3BE9F22994469500351060 /* Inventory */, + 2A3BEA022994469600351060 /* InventoryTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 2A3BE9F12994469500351060 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2A3BE9FE2994469600351060 /* Preview Assets.xcassets in Resources */, + 2A3BE9FB2994469600351060 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2A3BEA012994469600351060 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 2A3BE9EF2994469500351060 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4BD190FF299EA3D500A6A7E5 /* ItemForm.swift in Sources */, + 4B0E65BB29944F6500DFB522 /* ThirdTab.swift in Sources */, + 4B0E65B929944F4800DFB522 /* Inventory.swift in Sources */, + 4B0E65B729944EFC00DFB522 /* FirstTab.swift in Sources */, + 4B0E65BF29946B1100DFB522 /* Navigation.swift in Sources */, + 2A3BE9F92994469500351060 /* ContentView.swift in Sources */, + 2A3BE9F72994469500351060 /* InventoryApp.swift in Sources */, + 2A3BEA2429945D4000351060 /* Vanilla.swift in Sources */, + 4B0E65BD2994626900DFB522 /* Models.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2A3BE9FF2994469600351060 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2A3BEA082994469600351060 /* InventoryTests.swift in Sources */, + 2A3BEA2829945ECB00351060 /* VanillaTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 2A3BEA052994469600351060 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 2A3BE9F22994469500351060 /* Inventory */; + targetProxy = 2A3BEA042994469600351060 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 2A3BEA152994469600351060 /* 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++20"; + 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 = 16.2; + 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; + }; + 2A3BEA162994469600351060 /* 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++20"; + 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 = 16.2; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 2A3BEA182994469600351060 /* 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 = "\"Inventory/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + 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.Inventory; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 2A3BEA192994469600351060 /* 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 = "\"Inventory/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + 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.Inventory; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 2A3BEA1B2994469600351060 /* 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; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.InventoryTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Inventory.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Inventory"; + }; + name = Debug; + }; + 2A3BEA1C2994469600351060 /* 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; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.InventoryTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Inventory.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Inventory"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 2A3BE9EE2994469500351060 /* Build configuration list for PBXProject "Inventory" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2A3BEA152994469600351060 /* Debug */, + 2A3BEA162994469600351060 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2A3BEA172994469600351060 /* Build configuration list for PBXNativeTarget "Inventory" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2A3BEA182994469600351060 /* Debug */, + 2A3BEA192994469600351060 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2A3BEA1A2994469600351060 /* Build configuration list for PBXNativeTarget "InventoryTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2A3BEA1B2994469600351060 /* Debug */, + 2A3BEA1C2994469600351060 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 2A3BEA2029944BA700351060 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-composable-architecture.git"; + requirement = { + kind = revision; + revision = bcf5683aecdba339d309848c50b7f33fed887709; + }; + }; + 2AD4AD5129B9512C00F691CF /* XCRemoteSwiftPackageReference "swift-custom-dump" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-custom-dump.git"; + requirement = { + kind = exactVersion; + version = 0.8.0; + }; + }; + 4B0E65C029946FD700DFB522 /* XCRemoteSwiftPackageReference "swiftui-navigation" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swiftui-navigation.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.6.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 2A3BEA2129944BA700351060 /* ComposableArchitecture */ = { + isa = XCSwiftPackageProductDependency; + package = 2A3BEA2029944BA700351060 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */; + productName = ComposableArchitecture; + }; + 2AD4AD5229B9512C00F691CF /* CustomDump */ = { + isa = XCSwiftPackageProductDependency; + package = 2AD4AD5129B9512C00F691CF /* XCRemoteSwiftPackageReference "swift-custom-dump" */; + productName = CustomDump; + }; + 4B0E65C129946FD700DFB522 /* SwiftUINavigation */ = { + isa = XCSwiftPackageProductDependency; + package = 4B0E65C029946FD700DFB522 /* XCRemoteSwiftPackageReference "swiftui-navigation" */; + productName = SwiftUINavigation; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 2A3BE9EB2994469500351060 /* Project object */; +} diff --git a/0227-composable-navigation-pt6/Inventory/Inventory.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/0227-composable-navigation-pt6/Inventory/Inventory.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/0227-composable-navigation-pt6/Inventory/Inventory.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/0227-composable-navigation-pt6/Inventory/Inventory.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/0227-composable-navigation-pt6/Inventory/Inventory.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/0227-composable-navigation-pt6/Inventory/Inventory.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/0227-composable-navigation-pt6/Inventory/Inventory.xcodeproj/xcshareddata/xcschemes/Inventory.xcscheme b/0227-composable-navigation-pt6/Inventory/Inventory.xcodeproj/xcshareddata/xcschemes/Inventory.xcscheme new file mode 100644 index 00000000..3315c319 --- /dev/null +++ b/0227-composable-navigation-pt6/Inventory/Inventory.xcodeproj/xcshareddata/xcschemes/Inventory.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0227-composable-navigation-pt6/Inventory/Inventory/Assets.xcassets/AccentColor.colorset/Contents.json b/0227-composable-navigation-pt6/Inventory/Inventory/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/0227-composable-navigation-pt6/Inventory/Inventory/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0227-composable-navigation-pt6/Inventory/Inventory/Assets.xcassets/AppIcon.appiconset/Contents.json b/0227-composable-navigation-pt6/Inventory/Inventory/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..13613e3e --- /dev/null +++ b/0227-composable-navigation-pt6/Inventory/Inventory/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0227-composable-navigation-pt6/Inventory/Inventory/Assets.xcassets/Contents.json b/0227-composable-navigation-pt6/Inventory/Inventory/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0227-composable-navigation-pt6/Inventory/Inventory/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0227-composable-navigation-pt6/Inventory/Inventory/ContentView.swift b/0227-composable-navigation-pt6/Inventory/Inventory/ContentView.swift new file mode 100644 index 00000000..2822b61c --- /dev/null +++ b/0227-composable-navigation-pt6/Inventory/Inventory/ContentView.swift @@ -0,0 +1,101 @@ +import ComposableArchitecture +import SwiftUI + +struct AppFeature: Reducer { + struct State: Equatable { + var firstTab = FirstTabFeature.State() + var inventory = InventoryFeature.State() + var selectedTab: Tab = .one + var thirdTab = ThirdTabFeature.State() + } + enum Action: Equatable { + case firstTab(FirstTabFeature.Action) + case inventory(InventoryFeature.Action) + case selectedTabChanged(Tab) + case thirdTab(ThirdTabFeature.Action) + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .firstTab(.delegate(action)): + switch action { + case .switchToInventoryTab: + state.selectedTab = .inventory + return .none + } + + case let .selectedTabChanged(tab): + state.selectedTab = tab + return .none + + case .firstTab, .inventory, .thirdTab: + return .none + } + } + Scope(state: \.firstTab, action: /Action.firstTab) { + FirstTabFeature() + } + Scope(state: \.inventory, action: /Action.inventory) { + InventoryFeature() + } + Scope(state: \.thirdTab, action: /Action.thirdTab) { + ThirdTabFeature() + } + } +} + +enum Tab { + case one, inventory, three +} + +struct ContentView: View { + //@State var selectedTab: Tab = .one + let store: StoreOf + // Store + + var body: some View { + WithViewStore(self.store, observe: \.selectedTab) { viewStore in + TabView(selection: viewStore.binding(send: AppFeature.Action.selectedTabChanged)) { + FirstTabView( + store: self.store.scope( + state: \.firstTab, + action: AppFeature.Action.firstTab + ) + ) + .tabItem { Text("One") } + .tag(Tab.one) + + NavigationView { + InventoryView( + store: self.store.scope( + state: \.inventory, + action: AppFeature.Action.inventory + ) + ) + } + .tabItem { Text("Inventory") } + .tag(Tab.inventory) + + ThirdTabView( + store: self.store.scope( + state: \.thirdTab, + action: AppFeature.Action.thirdTab + ) + ) + .tabItem { Text("Three") } + .tag(Tab.three) + } + } + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView( + store: Store( + initialState: AppFeature.State(), + reducer: AppFeature() + ) + ) + } +} diff --git a/0227-composable-navigation-pt6/Inventory/Inventory/FirstTab.swift b/0227-composable-navigation-pt6/Inventory/Inventory/FirstTab.swift new file mode 100644 index 00000000..a70e68dd --- /dev/null +++ b/0227-composable-navigation-pt6/Inventory/Inventory/FirstTab.swift @@ -0,0 +1,38 @@ +import ComposableArchitecture +import SwiftUI + +struct FirstTabFeature: Reducer { + struct State: Equatable {} + enum Action: Equatable { + case goToInventoryButtonTapped + case delegate(Delegate) + + enum Delegate: Equatable { + case switchToInventoryTab + } + } + + func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .delegate: + return .none + + case .goToInventoryButtonTapped: + return .send(.delegate(.switchToInventoryTab)) + } + } +} + +struct FirstTabView: View { + let store: StoreOf + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Button { + viewStore.send(.goToInventoryButtonTapped) + } label: { + Text("Go to inventory") + } + } + } +} diff --git a/0227-composable-navigation-pt6/Inventory/Inventory/Inventory.swift b/0227-composable-navigation-pt6/Inventory/Inventory/Inventory.swift new file mode 100644 index 00000000..2d2c3d41 --- /dev/null +++ b/0227-composable-navigation-pt6/Inventory/Inventory/Inventory.swift @@ -0,0 +1,396 @@ +import ComposableArchitecture +import SwiftUI + +struct InventoryFeature: Reducer { + struct State: Equatable { + var addItem: ItemFormFeature.State? + var alert: AlertState? + var duplicateItem: ItemFormFeature.State? + var editItem: ItemFormFeature.State? + var items: IdentifiedArrayOf = [] + } + enum Action: Equatable { + case addButtonTapped + case addItem(PresentationAction) + case alert(PresentationAction) + case cancelAddItemButtonTapped + case cancelDuplicateItemButtonTapped + case confirmAddItemButtonTapped + case confirmDuplicateItemButtonTapped + case duplicateItem(PresentationAction) + case deleteButtonTapped(id: Item.ID) + case duplicateButtonTapped(id: Item.ID) + case editItem(PresentationAction) + case itemButtonTapped(id: Item.ID) + + enum Alert: Equatable { + case confirmDeletion(id: Item.ID) + } + enum Dialog: Equatable { + case confirmDuplication(id: Item.ID) + } + } + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .addButtonTapped: + state.addItem = ItemFormFeature.State( + item: Item(name: "", status: .inStock(quantity: 1)) + ) + return .none + + case .addItem: + return .none + + case let .alert(.presented(.confirmDeletion(id))): + state.items.remove(id: id) + return .none + + case .alert: + return .none + + case .cancelAddItemButtonTapped: + state.addItem = nil + return .none + + case .cancelDuplicateItemButtonTapped: + state.duplicateItem = nil + return .none + + case .confirmAddItemButtonTapped: + defer { state.addItem = nil } + guard let item = state.addItem?.item + else { + XCTFail("Can't confirm add when item is nil") + return .none + } + state.items.append(item) + return .none + + case .confirmDuplicateItemButtonTapped: + defer { state.duplicateItem = nil } + guard let item = state.duplicateItem?.item + else { + XCTFail("Can't confirm duplicate when item is nil") + return .none + } + state.items.append(item) + return .none + + case let .deleteButtonTapped(id): + guard let item = state.items[id: id] + else { return .none } + + state.alert = .delete(item: item) + return .none + + case let .duplicateButtonTapped(id): + guard let item = state.items[id: id] + else { return .none } + + state.duplicateItem = ItemFormFeature.State(item: item.duplicate()) + return .none + + case .duplicateItem: + return .none + + case .editItem(.dismiss): + guard let item = state.editItem?.item + else { return .none } + state.items[id: item.id] = item + return .none + case .editItem: + return .none + + case let .itemButtonTapped(id: itemID): + guard let item = state.items[id: itemID] + else { + XCTFail("Can't edit the item when it's not found in the list.") + return .none + } + state.editItem = ItemFormFeature.State(item: item) + return .none + } + } + .ifLet(\.alert, action: /Action.alert) + .ifLet(\.addItem, action: /Action.addItem) { + ItemFormFeature() + } + .ifLet(\.duplicateItem, action: /Action.duplicateItem) { + ItemFormFeature() + } + .ifLet(\.editItem, action: /Action.editItem) { + ItemFormFeature() + } + } +} + +extension AlertState where Action == InventoryFeature.Action.Alert { + static func delete(item: Item) -> Self { + AlertState { + TextState(#"Delete "\#(item.name)""#) + } actions: { + ButtonState(role: .destructive, action: .send(.confirmDeletion(id: item.id), animation: .default)) { + TextState("Delete") + } + } message: { + TextState("Are you sure you want to delete this item?") + } + } +} + +extension ConfirmationDialogState where Action == InventoryFeature.Action.Dialog { + static func duplicate(item: Item) -> Self { + ConfirmationDialogState { + TextState(#"Duplicate "\#(item.name)""#) + } actions: { + ButtonState(action: .send(.confirmDuplication(id: item.id), animation: .default)) { + TextState("Duplicate") + } + } message: { + TextState("Are you sure you want to duplicate this item?") + } + } +} + +struct InventoryView: View { + let store: StoreOf + + struct ViewState: Equatable { + let editItemID: Item.ID? + let items: IdentifiedArrayOf + + init(state: InventoryFeature.State) { + self.editItemID = state.editItem?.item.id + self.items = state.items + } + } + + var body: some View { + WithViewStore(self.store, observe: ViewState.init) { (viewStore: ViewStore) in + List { + ForEach(viewStore.items) { item in + NavigationLinkStore( + store: self.store.scope( + state: \.editItem, + action: InventoryFeature.Action.editItem + ), + id: item.id + ) { + viewStore.send(.itemButtonTapped(id: item.id)) + } destination: { store in + ItemFormView(store: store) + .navigationTitle("Edit item") + } label: { + HStack { + VStack(alignment: .leading) { + Text(item.name) + + switch 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 = item.color { + Rectangle() + .frame(width: 30, height: 30) + .foregroundColor(color.swiftUIColor) + .border(Color.black, width: 1) + } + + Button { + viewStore.send(.duplicateButtonTapped(id: item.id)) + } label: { + Image(systemName: "doc.on.doc.fill") + } + .padding(.leading) + + Button { + viewStore.send(.deleteButtonTapped(id: item.id)) + } label: { + Image(systemName: "trash.fill") + } + .padding(.leading) + } } +// NavigationLink( +// isActive: Binding( +// get: { viewStore.editItemID == item.id }, +// set: { isActive in +// if isActive { +// viewStore.send(.itemButtonTapped(id: item.id)) +// } else { +// viewStore.send(.editItem(.dismiss)) +// } +// } +// ), +// destination: { +// IfLetStore( +// self.store.scope( +// state: \.editItem, +// action: { .editItem(.presented($0)) } +// ), +// then: { store in +// ItemFormView(store: store) +// } +// ) +// }, +// label: { +// HStack { +// VStack(alignment: .leading) { +// Text(item.name) +// +// switch 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 = item.color { +// Rectangle() +// .frame(width: 30, height: 30) +// .foregroundColor(color.swiftUIColor) +// .border(Color.black, width: 1) +// } +// +// Button { +// viewStore.send(.duplicateButtonTapped(id: item.id)) +// } label: { +// Image(systemName: "doc.on.doc.fill") +// } +// .padding(.leading) +// +// Button { +// viewStore.send(.deleteButtonTapped(id: item.id)) +// } label: { +// Image(systemName: "trash.fill") +// } +// .padding(.leading) +// } +// } +// ) + .buttonStyle(.plain) + .foregroundColor(item.status.isInStock ? nil : Color.gray) + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Add") { + viewStore.send(.addButtonTapped) + } + } + } + .alert( + store: self.store.scope(state: \.alert, action: InventoryFeature.Action.alert) + ) + .popover( + store: self.store.scope(state: \.duplicateItem, action: InventoryFeature.Action.duplicateItem) + ) { store in + NavigationStack { + ItemFormView(store: store) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + viewStore.send(.cancelDuplicateItemButtonTapped) + } + } + ToolbarItem(placement: .primaryAction) { + Button("Add") { + viewStore.send(.confirmDuplicateItemButtonTapped) + } + } + } + .navigationTitle("Duplicate item") + } + } + .sheet( + store: self.store.scope(state: \.addItem, action: InventoryFeature.Action.addItem) + ) { store in + NavigationStack { + ItemFormView(store: store) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + viewStore.send(.cancelAddItemButtonTapped) + } + } + ToolbarItem(placement: .primaryAction) { + Button("Add") { + viewStore.send(.confirmAddItemButtonTapped) + } + } + } + .navigationTitle("New item") + } + } + +// .sheet( +// item: viewStore.binding( +// get: { $0.addItemID.map { Identified($0, id: \.self) } }, +// send: .addItem(.dismiss) +// ) +// ) { _ in +// IfLetStore( +// self.store.scope( +// state: \.addItem, +// action: { .addItem(.presented($0)) } +// ) +// ) { store in +// NavigationStack { +// ItemFormView(store: store) +// .toolbar { +// ToolbarItem(placement: .cancellationAction) { +// Button("Cancel") { +// viewStore.send(.cancelAddItemButtonTapped) +// } +// } +// ToolbarItem(placement: .primaryAction) { +// Button("Add") { +// viewStore.send(.confirmAddItemButtonTapped) +// } +// } +// } +// .navigationTitle("New item") +// } +// } +// } + } + } +} + +struct Inventory_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + InventoryView( + store: Store( + initialState: InventoryFeature.State( + editItem: ItemFormFeature.State( + item: Item( + id: Item.keyboard.id, + name: "Bluetooth Keyboard", + color: .red, + status: .outOfStock(isOnBackOrder: true) + ) + ), + items: [ + .headphones, + .mouse, + .keyboard, + .monitor, + ] + ), + reducer: InventoryFeature() + ) + ) + } + } +} diff --git a/0227-composable-navigation-pt6/Inventory/Inventory/InventoryApp.swift b/0227-composable-navigation-pt6/Inventory/Inventory/InventoryApp.swift new file mode 100644 index 00000000..bdb5af20 --- /dev/null +++ b/0227-composable-navigation-pt6/Inventory/Inventory/InventoryApp.swift @@ -0,0 +1,28 @@ +import ComposableArchitecture +import SwiftUI + +@main +struct InventoryApp: App { + var body: some Scene { + WindowGroup { + ContentView( + store: Store( + initialState: AppFeature.State( + inventory: InventoryFeature.State( +// addItem: ItemFormFeature.State(item: Item(name: "Laptop", status: .inStock(quantity: 100))), + items: [ + .monitor, + .mouse, + .keyboard, + .headphones + ] + )//, +// selectedTab: .inventory + ), + reducer: AppFeature() + //._printChanges() + ) + ) + } + } +} diff --git a/0227-composable-navigation-pt6/Inventory/Inventory/ItemForm.swift b/0227-composable-navigation-pt6/Inventory/Inventory/ItemForm.swift new file mode 100644 index 00000000..d4c6d2cc --- /dev/null +++ b/0227-composable-navigation-pt6/Inventory/Inventory/ItemForm.swift @@ -0,0 +1,150 @@ +import ComposableArchitecture +import SwiftUI +import SwiftUINavigation + +struct ItemFormFeature: Reducer { + struct State: Equatable, Identifiable { + @BindingState var isTimerOn = false + @BindingState var item: Item + + var id: Item.ID { self.item.id } + } + enum Action: BindableAction, Equatable { + case binding(BindingAction) + case timerTick + } + @Dependency(\.continuousClock) var clock + @Dependency(\.dismiss) var dismiss + + var body: some ReducerOf { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(\.$isTimerOn): + if state.isTimerOn { + return .run { send in + var tickCount = 0 + for await _ in self.clock.timer(interval: .seconds(1)) { + await send(.timerTick) + tickCount += 1 + if tickCount == 3 { + await self.dismiss() + } + } + } + .cancellable(id: CancelID.timer) + } else { + return .cancel(id: CancelID.timer) + } + + case .binding: + return .none + + case .timerTick: + guard case let .inStock(quantity) = state.item.status + else { return .none } + state.item.status = .inStock(quantity: quantity + 1) +// if quantity == 3 { +// self.dismiss() +// } +// URLSession.shared.dataTask(with: ...) { data, _, _ in +// +// }.resume() + return .none +// return quantity == 3 +// ? .fireAndForget { await self.dismiss() } +// : .none + } + } + } + + private enum CancelID { + case timer + } +} + +struct ItemFormView: View { + @Environment(\.dismiss) var dismiss + let store: StoreOf + +// init() { +// // _ = URLSession... +// } + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Form { + TextField("Name", text: viewStore.binding(\.$item.name)) + +// _ = URLSession... +// _ = self.dismiss() + + HStack { + Picker("Color", selection: viewStore.binding(\.$item.color)) { + Text("None") + .tag(Item.Color?.none) + ForEach(Item.Color.defaults) { color in + ZStack { + RoundedRectangle(cornerRadius: 4) + .fill(color.swiftUIColor) + Label(color.name, systemImage: "paintpalette") + .padding(4) + } + .fixedSize(horizontal: false, vertical: true) + .tag(Optional(color)) + } + } + + if let color = viewStore.item.color { + Rectangle() + .frame(width: 30, height: 30) + .foregroundColor(color.swiftUIColor) + .border(Color.black, width: 1) + } + } + + Switch(viewStore.binding(\.$item.status)) { + CaseLet(/Item.Status.inStock) { $quantity in + Section(header: Text("In stock")) { + Stepper("Quantity: \(quantity)", value: $quantity) + Button("Mark as sold out") { + viewStore.send( + .set(\.$item.status, .outOfStock(isOnBackOrder: false)), + animation: .default + ) + } + } + } + CaseLet(/Item.Status.outOfStock) { $isOnBackOrder in + Section(header: Text("Out of stock")) { + Toggle("Is on back order?", isOn: $isOnBackOrder) + Button("Is back in stock!") { + viewStore.send( + .set(\.$item.status, .inStock(quantity: 1)), + animation: .default + ) + } + } + } + } + + Toggle("Timer", isOn: viewStore.binding(\.$isTimerOn)) + + Button("Dismiss") { self.dismiss() } + } + } + } +} + +struct ItemForm_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + ItemFormView( + store: Store( + initialState: ItemFormFeature.State(item: .headphones), + reducer: ItemFormFeature() + ) + ) + } + } +} diff --git a/0227-composable-navigation-pt6/Inventory/Inventory/Models.swift b/0227-composable-navigation-pt6/Inventory/Inventory/Models.swift new file mode 100644 index 00000000..4814bc56 --- /dev/null +++ b/0227-composable-navigation-pt6/Inventory/Inventory/Models.swift @@ -0,0 +1,88 @@ +import Dependencies +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( + id: UUID? = nil, + name: String, + color: Color? = nil, + status: Status + ) { + @Dependency(\.uuid) var uuid + self.id = id ?? uuid() + self.name = name + self.color = color + self.status = status + } + +// var quantity: Int? +// var isOnBackOrder: Bool? + + 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 func duplicate() -> Self { + Self(name: self.name, color: self.color, status: self.status) + } + + public struct Color: Equatable, Hashable, Identifiable { + 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 var id: String { self.name } + + 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 { + SwiftUI.Color(red: self.red, green: self.green, blue: self.blue) + } + } +} + +extension Item { + static let headphones = Self(name: "Headphones", color: .blue, status: .inStock(quantity: 20)) + static let mouse = Self(name: "Mouse", color: .green, status: .inStock(quantity: 10)) + static let keyboard = Self(name: "Keyboard", color: .yellow, status: .outOfStock(isOnBackOrder: false)) + static let monitor = Self(name: "Monitor", color: .red, status: .outOfStock(isOnBackOrder: true)) +} diff --git a/0227-composable-navigation-pt6/Inventory/Inventory/Navigation.swift b/0227-composable-navigation-pt6/Inventory/Inventory/Navigation.swift new file mode 100644 index 00000000..c674beff --- /dev/null +++ b/0227-composable-navigation-pt6/Inventory/Inventory/Navigation.swift @@ -0,0 +1,351 @@ +import ComposableArchitecture +import SwiftUI +import SwiftUINavigation + +enum PresentationAction { + case dismiss + case presented(Action) +} +extension PresentationAction: Equatable where Action: Equatable {} + +extension Reducer { + func ifLet( + _ stateKeyPath: WritableKeyPath, + action actionCasePath: CasePath> + ) -> some ReducerOf + where ChildState: _EphemeralState + { + self.ifLet(stateKeyPath, action: actionCasePath) { + EmptyReducer() + } + } + + func ifLet( + _ stateKeyPath: WritableKeyPath, + action actionCasePath: CasePath>, + @ReducerBuilder child: () -> some Reducer + ) -> some ReducerOf { + let child = child() + return Reduce { state, action in + switch (state[keyPath: stateKeyPath], actionCasePath.extract(from: action)) { + + case (_, .none): + let childStateBefore = state[keyPath: stateKeyPath] + let effects = self.reduce(into: &state, action: action) + let childStateAfter = state[keyPath: stateKeyPath] + let cancelEffect: Effect + if + !(ChildState.self is _EphemeralState.Type), + let childStateBefore, + childStateBefore.id != childStateAfter?.id + { + cancelEffect = .cancel(id: childStateBefore.id) + } else { + cancelEffect = .none + } + let onFirstAppearEffect: Effect + if + !(ChildState.self is _EphemeralState.Type), + let childStateAfter, + childStateAfter.id != childStateBefore?.id + { + onFirstAppearEffect = .run { send in + do { + try await withTaskCancellation(id: DismissID(id: childStateAfter.id)) { + try await Task.never() + } + } catch is CancellationError { + await send(actionCasePath.embed(.dismiss)) + } + } + .cancellable(id: childStateAfter.id) + } else { + onFirstAppearEffect = .none + } + return .merge( + effects, + cancelEffect, + onFirstAppearEffect + ) + + case (.none, .some(.presented)), (.none, .some(.dismiss)): + XCTFail("A sheet action was sent while child state was nil.") + return self.reduce(into: &state, action: action) + + case (.some(var childState), .some(.presented(let childAction))): + defer { + if ChildState.self is _EphemeralState.Type { + state[keyPath: stateKeyPath] = nil + } + } + let childEffects = child + .dependency(\.dismiss, DismissEffect { [id = childState.id] in + Task.cancel(id: DismissID(id: id)) + }) + .reduce(into: &childState, action: childAction) + state[keyPath: stateKeyPath] = childState + let effects = self.reduce(into: &state, action: action) + return .merge( + childEffects + .map { actionCasePath.embed(.presented($0)) } + .cancellable(id: childState.id), + effects + ) + + case let (.some(childState), .some(.dismiss)): + let effects = self.reduce(into: &state, action: action) + state[keyPath: stateKeyPath] = nil + return .merge( + effects, + .cancel(id: childState.id) + ) + } + } + } +} + +protocol _EphemeralState {} +extension AlertState: _EphemeralState {} +extension ConfirmationDialogState: _EphemeralState {} + +private struct DismissID: Hashable { let id: AnyHashable } + +struct DismissEffect: Sendable { + private var dismiss: @Sendable () async -> Void + func callAsFunction() async { + await self.dismiss() + } +} +extension DismissEffect { + init(_ dismiss: @escaping @Sendable () async -> Void) { + self.dismiss = dismiss + } +} +extension DismissEffect: DependencyKey { + static var liveValue = DismissEffect(dismiss: {}) + static var testValue = DismissEffect(dismiss: {}) +} +extension DependencyValues { + var dismiss: DismissEffect { + get { self[DismissEffect.self] } + set { self[DismissEffect.self] = newValue } + } +} +// self.dismiss.dismiss() +// self.dismiss() + +extension View { + func sheet( + store: Store>, + @ViewBuilder child: @escaping (Store) -> some View + ) -> some View { + WithViewStore(store, observe: { $0?.id }) { viewStore in + self.sheet( + item: Binding( + get: { viewStore.state.map { Identified($0, id: \.self) } }, + set: { newState in + if viewStore.state != nil { + viewStore.send(.dismiss) + } + } + ) + ) { _ in + IfLetStore( + store.scope( + state: returningLastNonNilValue { $0 }, + action: PresentationAction.presented + ) + ) { store in + child(store) + } + } + } + } + + func popover( + store: Store>, + @ViewBuilder child: @escaping (Store) -> some View + ) -> some View { + WithViewStore(store, observe: { $0?.id }) { viewStore in + self.popover( + item: Binding( + get: { viewStore.state.map { Identified($0, id: \.self) } }, + set: { newState in + if viewStore.state != nil { + viewStore.send(.dismiss) + } + } + ) + ) { _ in + IfLetStore( + store.scope( + state: returningLastNonNilValue { $0 }, + action: PresentationAction.presented + ) + ) { store in + child(store) + } + } + } + } + + func fullScreenCover( + store: Store>, + @ViewBuilder child: @escaping (Store) -> some View + ) -> some View { + WithViewStore(store, observe: { $0?.id }) { viewStore in + self.fullScreenCover( + item: Binding( + get: { viewStore.state.map { Identified($0, id: \.self) } }, + set: { newState in + if viewStore.state != nil { + viewStore.send(.dismiss) + } + } + ) + ) { _ in + IfLetStore( + store.scope( + state: returningLastNonNilValue { $0 }, + action: PresentationAction.presented + ) + ) { store in + child(store) + } + } + } + } +} + +func returningLastNonNilValue( + _ f: @escaping (A) -> B? +) -> (A) -> B? { + var lastValue: B? + return { a in + lastValue = f(a) ?? lastValue + return lastValue + } +} + +extension View { + func alert( + store: Store?, PresentationAction> + ) -> some View { + WithViewStore( + store, + observe: { $0 }, + removeDuplicates: { ($0 != nil) == ($1 != nil) } + ) { viewStore in + self.alert( + unwrapping: Binding( + get: { viewStore.state }, + set: { newState in + if viewStore.state != nil { + viewStore.send(.dismiss) + } + } + ) + ) { action in + if let action { + viewStore.send(.presented(action)) + } + } + } + } +} + +extension View { + func confirmationDialog( + store: Store?, PresentationAction> + ) -> some View { + WithViewStore( + store, + observe: { $0 }, + removeDuplicates: { ($0 != nil) == ($1 != nil) } + ) { viewStore in + self.confirmationDialog( + unwrapping: Binding( + get: { viewStore.state }, + set: { newState in + if viewStore.state != nil { + viewStore.send(.dismiss) + } + } + ) + ) { action in + if let action { + viewStore.send(.presented(action)) + } + } + } + } +} + +@available(*, deprecated) +struct NavigationLinkStore: View { + let store: Store> + let id: ChildState.ID? + let action: () -> Void + @ViewBuilder let destination: (Store) -> Destination + @ViewBuilder let label: Label + + var body: some View { +// NavigationLink( +// tag: <#T##V#>, +// selection: <#T##SwiftUI.Binding#>, +// destination: <#T##() -> Destination#>, +// label: <#T##() -> Label#> +// ) + + WithViewStore(self.store, observe: { $0?.id == self.id }) { viewStore in + NavigationLink( + isActive: Binding( + get: { viewStore.state }, + set: { isActive in + if isActive { + self.action() + } else if viewStore.state { + viewStore.send(.dismiss) + } + } + ), + destination: { + IfLetStore( + self.store.scope(state: { $0 }, action: { .presented($0) }) + ) { store in + self.destination(store) + } + }, + label: { self.label } + ) + } + } +} + +struct Test: View, PreviewProvider { + static var previews: some View { + Self() + } + + @State var background = Color.white + @State var message = "" + @State var isPresented = false + + var body: some View { + ZStack { + self.background.edgesIgnoringSafeArea(.all) + Button { + self.isPresented = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.message = "\(Int.random(in: 0...1_000_000))" + self.background = .red + } + } label: { + Text("Press") + } + .alert("Hello: \(self.message)", isPresented: self.$isPresented) { + Text("Ok") + } + } + } +} diff --git a/0227-composable-navigation-pt6/Inventory/Inventory/Preview Content/Preview Assets.xcassets/Contents.json b/0227-composable-navigation-pt6/Inventory/Inventory/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0227-composable-navigation-pt6/Inventory/Inventory/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0227-composable-navigation-pt6/Inventory/Inventory/ThirdTab.swift b/0227-composable-navigation-pt6/Inventory/Inventory/ThirdTab.swift new file mode 100644 index 00000000..f8fde492 --- /dev/null +++ b/0227-composable-navigation-pt6/Inventory/Inventory/ThirdTab.swift @@ -0,0 +1,18 @@ +import ComposableArchitecture +import SwiftUI + +struct ThirdTabFeature: Reducer { + struct State: Equatable {} + enum Action: Equatable {} + + func reduce(into state: inout State, action: Action) -> Effect { + } +} + +struct ThirdTabView: View { + let store: StoreOf + + var body: some View { + Text("Three") + } +} diff --git a/0227-composable-navigation-pt6/Inventory/Inventory/Vanilla.swift b/0227-composable-navigation-pt6/Inventory/Inventory/Vanilla.swift new file mode 100644 index 00000000..1e28c060 --- /dev/null +++ b/0227-composable-navigation-pt6/Inventory/Inventory/Vanilla.swift @@ -0,0 +1,32 @@ +import SwiftUI +import XCTestDynamicOverlay + +class AppModel: ObservableObject { + @Published var firstTab: FirstTabModel { + didSet { self.bind() } + } + @Published var selectedTab: Tab + + init( + firstTab: FirstTabModel, + selectedTab: Tab = .one + ) { + self.firstTab = firstTab + self.selectedTab = selectedTab + self.bind() + } + + private func bind() { + self.firstTab.switchToInventoryTab = { [weak self] in + self?.selectedTab = .inventory + } + } +} + +class FirstTabModel: ObservableObject { + var switchToInventoryTab: () -> Void = unimplemented("FirstTabModel.switchToInventoryTab") + + func goToInventoryTab() { + self.switchToInventoryTab() + } +} diff --git a/0227-composable-navigation-pt6/Inventory/InventoryTests/InventoryTests.swift b/0227-composable-navigation-pt6/Inventory/InventoryTests/InventoryTests.swift new file mode 100644 index 00000000..1b82ce1c --- /dev/null +++ b/0227-composable-navigation-pt6/Inventory/InventoryTests/InventoryTests.swift @@ -0,0 +1,220 @@ +import ComposableArchitecture +import XCTest +@testable import Inventory + +@MainActor +final class InventoryTests: XCTestCase { + func testGoToInventory() async { + let store = TestStore( + initialState: AppFeature.State(), + reducer: AppFeature() + ) + + await store.send(.firstTab(.goToInventoryButtonTapped)) + await store.receive(.firstTab(.delegate(.switchToInventoryTab))) { + $0.selectedTab = .inventory + } + } + + func testDelete() async { + let item = Item.headphones + + let store = TestStore( + initialState: InventoryFeature.State(items: [item]), + reducer: InventoryFeature() + ) + + await store.send(.deleteButtonTapped(id: item.id)) { + $0.alert = .delete(item: item) + } + await store.send(.alert(.presented(.confirmDeletion(id: item.id)))) { + $0.alert = nil + $0.items = [] + } + } + + func testDuplicate() async { + let item = Item.headphones + + let store = TestStore( + initialState: InventoryFeature.State(items: [item]), + reducer: InventoryFeature() + ) { + $0.uuid = .incrementing + } + + await store.send(.duplicateButtonTapped(id: item.id)) { + $0.duplicateItem = ItemFormFeature.State( + item: Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "Headphones", + color: .blue, + status: .inStock(quantity: 20) + ) + ) + } + await store.send(.duplicateItem(.presented(.set(\.$item.name, "Bluetooth Headphones")))) { + $0.duplicateItem?.item.name = "Bluetooth Headphones" + } + await store.send(.confirmDuplicateItemButtonTapped) { + $0.duplicateItem = nil + $0.items = [ + item, + Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "Bluetooth Headphones", + color: .blue, + status: .inStock(quantity: 20) + ) + ] + } + } + + func testAddItem() async { + let store = TestStore( + initialState: InventoryFeature.State(), + reducer: InventoryFeature() + ) { + $0.uuid = .incrementing + } + + await store.send(.addButtonTapped) { + $0.addItem = ItemFormFeature.State( + item: Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "", + status: .inStock(quantity: 1) + ) + ) + } + + await store.send(.addItem(.presented(.set(\.$item.name, "Headphones")))) { + $0.addItem?.item.name = "Headphones" + } + + await store.send(.confirmAddItemButtonTapped) { + $0.addItem = nil + $0.items = [ + Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "Headphones", + status: .inStock(quantity: 1) + ) + ] + } + } + + func testAddItem_Timer() async { + let clock = TestClock() + let store = TestStore( + initialState: InventoryFeature.State(), + reducer: InventoryFeature() + ) { + $0.continuousClock = clock + $0.uuid = .incrementing + } + + await store.send(.addButtonTapped) { + $0.addItem = ItemFormFeature.State( + item: Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "", + status: .inStock(quantity: 1) + ) + ) + } + + await store.send(.addItem(.presented(.set(\.$item.name, "Headphones")))) { + $0.addItem?.item.name = "Headphones" + } + + /*let toggleTask = */await store.send(.addItem(.presented(.set(\.$isTimerOn, true)))) { + $0.addItem?.isTimerOn = true + } + + // await store.send(.addItem(.presented(.set(\.$isTimerOn, false)))) { + // $0.addItem?.isTimerOn = false + // } + + await store.send(.confirmAddItemButtonTapped) { + $0.addItem = nil + $0.items = [ + Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "Headphones", + status: .inStock(quantity: 1) + ) + ] + } + + // await toggleTask.cancel() + } + + + func testAddItem_Timer_Dismissal() async { + let clock = TestClock() + let store = TestStore( + initialState: InventoryFeature.State(), + reducer: InventoryFeature() + ) { + $0.continuousClock = clock + $0.uuid = .incrementing + } + + await store.send(.addButtonTapped) { + $0.addItem = ItemFormFeature.State( + item: Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "", + status: .inStock(quantity: 1) + ) + ) + } + + await store.send(.addItem(.presented(.set(\.$isTimerOn, true)))) { + $0.addItem?.isTimerOn = true + } + await clock.advance(by: .seconds(3)) + await store.receive(.addItem(.presented(.timerTick))) { + $0.addItem?.item.status = .inStock(quantity: 2) + } + await store.receive(.addItem(.presented(.timerTick))) { + $0.addItem?.item.status = .inStock(quantity: 3) + } + await store.receive(.addItem(.presented(.timerTick))) { + $0.addItem?.item.status = .inStock(quantity: 4) + } + await store.receive(.addItem(.dismiss)) { + $0.addItem = nil + } + } + + func testAddItem_Timer_Dismissal_NonExhaustive() async { + let store = TestStore( + initialState: InventoryFeature.State(), + reducer: InventoryFeature() + ) { + $0.continuousClock = ImmediateClock() + $0.uuid = .incrementing + } + + store.exhaustivity = .off(showSkippedAssertions: true) + + await store.send(.addButtonTapped) { + $0.addItem = ItemFormFeature.State( + item: Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "", + status: .inStock(quantity: 1) + ) + ) + } + + await store.send(.addItem(.presented(.set(\.$isTimerOn, true)))) { + $0.addItem?.isTimerOn = true + } + await store.receive(.addItem(.dismiss)) { + $0.addItem = nil + } + } +} diff --git a/0227-composable-navigation-pt6/Inventory/InventoryTests/VanillaTests.swift b/0227-composable-navigation-pt6/Inventory/InventoryTests/VanillaTests.swift new file mode 100644 index 00000000..af07fb5f --- /dev/null +++ b/0227-composable-navigation-pt6/Inventory/InventoryTests/VanillaTests.swift @@ -0,0 +1,26 @@ +import XCTest + +@testable import Inventory + +class VanillaTests: XCTestCase { + func testFirstTabModel() { + let model = FirstTabModel() + +// let expectation = self.expectation(description: "switchToInventoryTab") + model.switchToInventoryTab = { +// expectation.fulfill() + } + + model.goToInventoryTab() +// self.wait(for: [expectation], timeout: 0) + } + + func testAppModel() { + let model = AppModel( + firstTab: FirstTabModel() + ) + + model.firstTab.goToInventoryTab() +// XCTAssertEqual(model.selectedTab, .inventory) + } +} diff --git a/0227-composable-navigation-pt6/README.md b/0227-composable-navigation-pt6/README.md new file mode 100644 index 00000000..b3a1078d --- /dev/null +++ b/0227-composable-navigation-pt6/README.md @@ -0,0 +1,5 @@ +## [Point-Free](https://www.pointfree.co) + +> #### This directory contains code from Point-Free Episode: [Composable Navigation: Links](https://www.pointfree.co/episodes/ep227-composable-navigation-links) +> +> We have a single navigation API powering alerts, dialogs, sheets, popovers, and full screen covers, but what about the prototypical form of navigation, the one that everyone thinks of when they hear “navigation”? It’s time to tackle links. diff --git a/README.md b/README.md index 0063f590..9d3a8bb7 100644 --- a/README.md +++ b/README.md @@ -228,3 +228,4 @@ This repository is the home of code written on episodes of [Point-Free](https:// 1. [Composable Navigation: Sheets](0224-composable-navigation-pt3) 1. [Composable Navigation: Effect Cancellation](0225-composable-navigation-pt4) 1. [Composable Navigation: Unification](0226-composable-navigation-pt5) +1. [Composable Navigation: Links](0227-composable-navigation-pt6)