diff --git a/0235-composable-navigation-pt14/Inventory/COW.playground/Contents.swift b/0235-composable-navigation-pt14/Inventory/COW.playground/Contents.swift new file mode 100644 index 00000000..11bfb3b2 --- /dev/null +++ b/0235-composable-navigation-pt14/Inventory/COW.playground/Contents.swift @@ -0,0 +1,62 @@ + +struct Wrapper { + var value: Value { + get { self.storage.value } + set { + if Swift.isKnownUniquelyReferenced(&self.storage) { + self.storage.value = newValue + } else { + self.storage = Storage(value: newValue) + } + } + } + + private var storage: Storage + + init(value: Value) { + self.storage = Storage(value: value) + } + + mutating func isKnownUniquelyReferenced() -> Bool { + Swift.isKnownUniquelyReferenced(&self.storage) + } + + private class Storage { + var value: Value + init(value: Value) { + self.value = value + } + } +} + +import Foundation + +extension Wrapper: Equatable where Value: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + if lhs.storage === rhs.storage { + return true + } + Thread.sleep(forTimeInterval: 3) + return lhs.value == rhs.value + } +} + +var x = Wrapper(value: 1) +x.isKnownUniquelyReferenced() +var y = x +x.isKnownUniquelyReferenced() +y.isKnownUniquelyReferenced() +x == y +x.value = 2 +y.value = 2 +x == y +y.value +x.value +x.isKnownUniquelyReferenced() +y.isKnownUniquelyReferenced() + +var z = 1 +var w = z +z = 2 +w +z diff --git a/0235-composable-navigation-pt14/Inventory/COW.playground/contents.xcplayground b/0235-composable-navigation-pt14/Inventory/COW.playground/contents.xcplayground new file mode 100644 index 00000000..63b6dd8d --- /dev/null +++ b/0235-composable-navigation-pt14/Inventory/COW.playground/contents.xcplayground @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/0235-composable-navigation-pt14/Inventory/Inventory.xcodeproj/project.pbxproj b/0235-composable-navigation-pt14/Inventory/Inventory.xcodeproj/project.pbxproj new file mode 100644 index 00000000..088a7075 --- /dev/null +++ b/0235-composable-navigation-pt14/Inventory/Inventory.xcodeproj/project.pbxproj @@ -0,0 +1,574 @@ +// !$*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 */; }; + 2A65573629E5D02E00A52383 /* StackExplorations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A65573529E5D02E00A52383 /* StackExplorations.swift */; }; + 2AD4AD5329B9512C00F691CF /* CustomDump in Frameworks */ = {isa = PBXBuildFile; productRef = 2AD4AD5229B9512C00F691CF /* CustomDump */; }; + 2ADF330929F70ED100DFCE90 /* WolframAlpha.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ADF330829F70ED100DFCE90 /* WolframAlpha.swift */; }; + 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 */; }; + 4BFB468D29D60F9B004C6032 /* StackOverflowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BFB468C29D60F9B004C6032 /* StackOverflowTests.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 = ""; }; + 2A65573529E5D02E00A52383 /* StackExplorations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackExplorations.swift; sourceTree = ""; }; + 2ADF330829F70ED100DFCE90 /* WolframAlpha.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WolframAlpha.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 = ""; }; + 4B94848A29E5E6E3000009E6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 4BD190FE299EA3D500A6A7E5 /* ItemForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemForm.swift; sourceTree = ""; }; + 4BFB468C29D60F9B004C6032 /* StackOverflowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackOverflowTests.swift; sourceTree = ""; }; + 4BFB468E29D61689004C6032 /* COW.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = COW.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; +/* 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 = ( + 4BFB468E29D61689004C6032 /* COW.playground */, + 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 = ( + 4B94848A29E5E6E3000009E6 /* Info.plist */, + 2A3BE9F82994469500351060 /* ContentView.swift */, + 4B0E65B629944EFC00DFB522 /* FirstTab.swift */, + 4B0E65B829944F4800DFB522 /* Inventory.swift */, + 2A3BE9F62994469500351060 /* InventoryApp.swift */, + 4BD190FE299EA3D500A6A7E5 /* ItemForm.swift */, + 4B0E65BC2994626900DFB522 /* Models.swift */, + 4B0E65BE29946B1100DFB522 /* Navigation.swift */, + 2A65573529E5D02E00A52383 /* StackExplorations.swift */, + 4B0E65BA29944F6500DFB522 /* ThirdTab.swift */, + 2A3BEA2329945D4000351060 /* Vanilla.swift */, + 2ADF330829F70ED100DFCE90 /* WolframAlpha.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 */, + 4BFB468C29D60F9B004C6032 /* StackOverflowTests.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 */, + 2A65573629E5D02E00A52383 /* StackExplorations.swift in Sources */, + 4B0E65BF29946B1100DFB522 /* Navigation.swift in Sources */, + 2ADF330929F70ED100DFCE90 /* WolframAlpha.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 = ( + 4BFB468D29D60F9B004C6032 /* StackOverflowTests.swift in Sources */, + 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.4; + 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.4; + 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_FILE = Inventory/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.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_FILE = Inventory/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.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; + 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; + 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 = { + branch = "for-tca-nav-eps"; + kind = branch; + }; + }; + 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/0235-composable-navigation-pt14/Inventory/Inventory.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/0235-composable-navigation-pt14/Inventory/Inventory.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/0235-composable-navigation-pt14/Inventory/Inventory.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/0235-composable-navigation-pt14/Inventory/Inventory.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/0235-composable-navigation-pt14/Inventory/Inventory.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/0235-composable-navigation-pt14/Inventory/Inventory.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/0235-composable-navigation-pt14/Inventory/Inventory.xcodeproj/xcshareddata/xcschemes/Inventory.xcscheme b/0235-composable-navigation-pt14/Inventory/Inventory.xcodeproj/xcshareddata/xcschemes/Inventory.xcscheme new file mode 100644 index 00000000..3315c319 --- /dev/null +++ b/0235-composable-navigation-pt14/Inventory/Inventory.xcodeproj/xcshareddata/xcschemes/Inventory.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0235-composable-navigation-pt14/Inventory/Inventory/Assets.xcassets/AccentColor.colorset/Contents.json b/0235-composable-navigation-pt14/Inventory/Inventory/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/0235-composable-navigation-pt14/Inventory/Inventory/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0235-composable-navigation-pt14/Inventory/Inventory/Assets.xcassets/AppIcon.appiconset/Contents.json b/0235-composable-navigation-pt14/Inventory/Inventory/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..13613e3e --- /dev/null +++ b/0235-composable-navigation-pt14/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/0235-composable-navigation-pt14/Inventory/Inventory/Assets.xcassets/Contents.json b/0235-composable-navigation-pt14/Inventory/Inventory/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0235-composable-navigation-pt14/Inventory/Inventory/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0235-composable-navigation-pt14/Inventory/Inventory/ContentView.swift b/0235-composable-navigation-pt14/Inventory/Inventory/ContentView.swift new file mode 100644 index 00000000..37d95216 --- /dev/null +++ b/0235-composable-navigation-pt14/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) + + NavigationStack { + 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/0235-composable-navigation-pt14/Inventory/Inventory/FirstTab.swift b/0235-composable-navigation-pt14/Inventory/Inventory/FirstTab.swift new file mode 100644 index 00000000..a70e68dd --- /dev/null +++ b/0235-composable-navigation-pt14/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/0235-composable-navigation-pt14/Inventory/Inventory/Info.plist b/0235-composable-navigation-pt14/Inventory/Inventory/Info.plist new file mode 100644 index 00000000..6a6654d9 --- /dev/null +++ b/0235-composable-navigation-pt14/Inventory/Inventory/Info.plist @@ -0,0 +1,11 @@ + + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/0235-composable-navigation-pt14/Inventory/Inventory/Inventory.swift b/0235-composable-navigation-pt14/Inventory/Inventory/Inventory.swift new file mode 100644 index 00000000..fcd58619 --- /dev/null +++ b/0235-composable-navigation-pt14/Inventory/Inventory/Inventory.swift @@ -0,0 +1,377 @@ +import ComposableArchitecture +import SwiftUI + +struct InventoryFeature: Reducer { + struct State: Equatable { + @PresentationState var destination: Destination.State? + var items: IdentifiedArrayOf = [] + } + enum Action: Equatable { + case destination(PresentationAction) + + case addButtonTapped + case cancelAddItemButtonTapped + case cancelDuplicateItemButtonTapped + case confirmAddItemButtonTapped + case confirmDuplicateItemButtonTapped + case deleteButtonTapped(id: Item.ID) + case duplicateButtonTapped(id: Item.ID) + case itemButtonTapped(id: Item.ID) + + enum Alert: Equatable { + case confirmDeletion(id: Item.ID) + } + enum Dialog: Equatable { + case confirmDuplication(id: Item.ID) + } + } + + struct Destination: Reducer { +// @DerivingIdentifable + enum State: Equatable, Identifiable /*, DerivingIdentifiable */ { + case addItem(ItemFormFeature.State) + case alert(AlertState) + case duplicateItem(ItemFormFeature.State) + case editItem(ItemFormFeature.State) + var id: AnyHashable { + switch self { + case let .addItem(state): + return state.id + case let .alert(state): + return state.id + case let .editItem(state): + return state.id + case let .duplicateItem(state): + return state.id + } + } + } + enum Action: Equatable { + case addItem(ItemFormFeature.Action) + case alert(InventoryFeature.Action.Alert) + case duplicateItem(ItemFormFeature.Action) + case editItem(ItemFormFeature.Action) + } + var body: some ReducerOf { + Scope(state: /State.addItem, action: /Action.addItem) { + ItemFormFeature() + } + Scope(state: /State.duplicateItem, action: /Action.duplicateItem) { + ItemFormFeature() + } + Scope(state: /State.editItem, action: /Action.editItem) { + ItemFormFeature() + } + } + } + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .addButtonTapped: + state.destination = .addItem( + ItemFormFeature.State( + item: Item(name: "", status: .inStock(quantity: 1)) + ) + ) + return .none + +// case .addItem: +// return .none + + case let .destination(.presented(.alert(.confirmDeletion(id: id)))): + state.items.remove(id: id) + return .none + +// case .alert: +// return .none + + case .cancelAddItemButtonTapped: + state.destination = nil + return .none + + case .cancelDuplicateItemButtonTapped: + state.destination = nil + return .none + + case .confirmAddItemButtonTapped: + defer { state.destination = nil } + guard case let .addItem(itemFormState) = state.destination + else { + XCTFail("Can't confirm add when destination is not 'addItem'") + return .none + } + state.items.append(itemFormState.item) + return .none + + case .confirmDuplicateItemButtonTapped: + defer { state.destination = nil } + guard case let .duplicateItem(itemFormState) = state.destination + else { + XCTFail("Can't confirm duplicate when destination is not 'duplicateItem'") + return .none + } + state.items.append(itemFormState.item) + return .none + + case let .deleteButtonTapped(id): + guard let item = state.items[id: id] + else { return .none } + + state.destination = .alert(.delete(item: item)) + return .none + + case let .duplicateButtonTapped(id): + guard let item = state.items[id: id] + else { return .none } + + state.destination = .duplicateItem(ItemFormFeature.State(item: item.duplicate())) + return .none + +// case .duplicateItem: +// return .none + + case .destination(.dismiss): + guard case let .editItem(itemFormState) = state.destination + else { return .none } + state.items[id: itemFormState.id] = itemFormState.item + 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.destination = .editItem(ItemFormFeature.State(item: item)) + return .none + + case .destination: + return .none + } + } + .ifLet(\.$destination, action: /Action.destination) { + Destination() + } +// .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 items: IdentifiedArrayOf + + init(state: InventoryFeature.State) { + 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") + Button { + viewStore.send(.itemButtonTapped(id: item.id)) + } 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: \.destination, action: InventoryFeature.Action.destination + ), + state: /InventoryFeature.Destination.State.alert, + action: InventoryFeature.Destination.Action.alert + ) + .popover( + store: self.store.scope( + state: \.destination, action: InventoryFeature.Action.destination + ), + state: /InventoryFeature.Destination.State.duplicateItem, + action: InventoryFeature.Destination.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: \.destination, action: InventoryFeature.Action.destination + ), + state: /InventoryFeature.Destination.State.addItem, + action: InventoryFeature.Destination.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") + } + } + .navigationDestination( + store: self.store.scope( + state: \.destination, action: InventoryFeature.Action.destination + ), + state: /InventoryFeature.Destination.State.editItem, + action: InventoryFeature.Destination.Action.editItem + ) { store in + ItemFormView(store: store) + .navigationTitle("Edit item") + } + } + } +} + +struct Inventory_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + InventoryView( + store: Store( + initialState: InventoryFeature.State( + destination: .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/0235-composable-navigation-pt14/Inventory/Inventory/InventoryApp.swift b/0235-composable-navigation-pt14/Inventory/Inventory/InventoryApp.swift new file mode 100644 index 00000000..2a8f3f86 --- /dev/null +++ b/0235-composable-navigation-pt14/Inventory/Inventory/InventoryApp.swift @@ -0,0 +1,48 @@ +import ComposableArchitecture +import SwiftUI + +@main +struct InventoryApp: App { + var body: some Scene { + WindowGroup { + RootView( + store: Store( + initialState: RootFeature.State( + path: StackState([ + .counter(CounterFeature.State(count: 9999)), +// .counter(CounterFeature.State(count: 1729)), +// .counter(CounterFeature.State(count: -999)), + ]) + /* + state.path = [ + state.path[0], + .counter(…), + state.path[1], + ] + */ + ), + reducer: RootFeature()._printChanges() + ) + ) +// ContentView( +// store: Store( +// initialState: AppFeature.State( +// inventory: InventoryFeature.State( +// destination: .addItem(ItemFormFeature.State(item: .headphones)), +//// addItem: ItemFormFeature.State(item: .headphones), +//// duplicateItem: ItemFormFeature.State(item: .headphones.duplicate()), +// items: [ +// .monitor, +// .mouse, +// .keyboard, +// .headphones +// ] +// ), +// selectedTab: .inventory +// ), +// reducer: AppFeature()._printChanges() +// ) +// ) + } + } +} diff --git a/0235-composable-navigation-pt14/Inventory/Inventory/ItemForm.swift b/0235-composable-navigation-pt14/Inventory/Inventory/ItemForm.swift new file mode 100644 index 00000000..a50dc8d0 --- /dev/null +++ b/0235-composable-navigation-pt14/Inventory/Inventory/ItemForm.swift @@ -0,0 +1,154 @@ +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 dismissButtonTapped + 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 .dismissButtonTapped: + return .fireAndForget { await self.dismiss() } + + 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") { viewStore.send(.dismissButtonTapped) } + } + } + } +} + +struct ItemForm_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + ItemFormView( + store: Store( + initialState: ItemFormFeature.State(item: .headphones), + reducer: ItemFormFeature() + ) + ) + } + } +} diff --git a/0235-composable-navigation-pt14/Inventory/Inventory/Models.swift b/0235-composable-navigation-pt14/Inventory/Inventory/Models.swift new file mode 100644 index 00000000..4814bc56 --- /dev/null +++ b/0235-composable-navigation-pt14/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/0235-composable-navigation-pt14/Inventory/Inventory/Navigation.swift b/0235-composable-navigation-pt14/Inventory/Inventory/Navigation.swift new file mode 100644 index 00000000..fb0fb8ce --- /dev/null +++ b/0235-composable-navigation-pt14/Inventory/Inventory/Navigation.swift @@ -0,0 +1,731 @@ +import ComposableArchitecture +import SwiftUI +import SwiftUINavigation + +extension NavigationLink where Destination == Never { + init( + state element: Element, + @ViewBuilder label: () -> Label + ) { + self.init( + value: StackState.Component( + id: UUID(), + element: element + ), + label: label + ) + } +} + +extension Reducer { + func forEach( + _ toElementsState: WritableKeyPath>, + action toStackAction: CasePath>, + @ReducerBuilder element: () -> Element, + file: StaticString = #file, + fileID: StaticString = #fileID, + line: UInt = #line + ) -> some ReducerOf + where ElementState == Element.State, ElementAction == Element.Action { + let element = element() + + return Reduce { state, action in + switch toStackAction.extract(from: action) { + case let .element(id: id, action: childAction): + if state[keyPath: toElementsState].elements[id: id] == nil { + XCTFail("Action was sent for an element that does not exist") + return self.reduce(into: &state, action: action) + } + + return .merge( + element + .reduce(into: &state[keyPath: toElementsState].elements[id: id]!.element, action: childAction) + .map { toStackAction.embed(.element(id: id, action: $0)) }, + self.reduce(into: &state, action: action) + ) + + case let .setPath(path): + state[keyPath: toElementsState] = path + return self.reduce(into: &state, action: action) + + case .none: + return self.reduce(into: &state, action: action) + } + } + } +} + +struct StackState { + fileprivate var elements: IdentifiedArrayOf = [] + + fileprivate init(elements: IdentifiedArrayOf = []) { + self.elements = elements + } + + init() { + } + init(_ elements: S) where S.Element == Element { + self.elements = IdentifiedArray( + uncheckedUniqueElements: elements.map { Component(id: UUID(), element: $0) } + ) + } + + fileprivate struct Component: Identifiable { + let id: UUID + var element: Element + } + + mutating func append(_ element: Element) { + self.elements.append(Component(id: UUID(), element: element)) + } +} +extension StackState: Collection { + var startIndex: Int { self.elements.startIndex } + var endIndex: Int { self.elements.endIndex } + func index(after i: Int) -> Int { self.elements.index(after: i) } + subscript(position: Int) -> Element { self.elements[position].element } +} +extension StackState: Equatable where Element: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + guard lhs.elements.count == rhs.elements.count + else { return false } + return zip(lhs.elements, rhs.elements).allSatisfy { + $0.id == $1.id && $0.element == $1.element + } + } +} + +extension StackState.Component: Hashable { + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } +} + +enum StackAction { + case element(id: UUID, action: Action) + case setPath(StackState) +} + +struct NavigationStackStore< + Root: View, + PathState, + PathAction, + Destination: View +>: View { + let store: Store, StackAction> + let root: Root + let destination: (PathState) -> Destination + + init( + _ store: Store, StackAction>, + @ViewBuilder root: () -> Root, + @ViewBuilder destination: @escaping (PathState) -> Destination + ) { + self.store = store + self.root = root() + self.destination = destination + } + + var body: some View { + WithViewStore( + self.store, + observe: { $0 }, + removeDuplicates: { $0.elements.ids == $1.elements.ids } + ) { viewStore in + NavigationStack( + path: viewStore.binding( + get: { _ in + ViewStore(self.store, observe: { $0 }, removeDuplicates: { _, _ in true }).state.elements + }, + send: { .setPath(StackState(elements: $0)) } + ) + ) { + self.root + .navigationDestination(for: StackState.Component.self) { component in + SwitchStore( + self.store.scope( + state: { $0.elements[id: component.id]?.element ?? component.element }, + action: { + .element(id: component.id, action: $0) + } + ) + ) { state in + self.destination(state) + } + } + } + } + } +} + +@propertyWrapper +struct PresentationState { + private var value: [State] + fileprivate var isPresented = false + + init(wrappedValue: State?) { + if let wrappedValue { + self.value = [wrappedValue] + } else { + self.value = [] + } + } + + var wrappedValue: State? { + get { + self.value.first + } + set { + guard let newValue = newValue + else { + self.value = [] + return + } + self.value = [newValue] + } + } + + var projectedValue: Self { + get { self } + set { self = newValue } + } +} +extension PresentationState: Equatable where State: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.value == rhs.value + } +} +extension PresentationState: Hashable where State: Hashable { + func hash(into hasher: inout Hasher) { + hasher.combine(self.value) + } +} +extension PresentationState: CustomDumpReflectable { + var customDumpMirror: Mirror { + Mirror(reflecting: self.wrappedValue as Any) + } +} + +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].wrappedValue, actionCasePath.extract(from: action)) { + + case (_, .none): + let childStateBefore = state[keyPath: stateKeyPath].wrappedValue + let effects = self.reduce(into: &state, action: action) + let childStateAfter = state[keyPath: stateKeyPath].wrappedValue + let cancelEffect: Effect + if + let childStateBefore, + !isEphemeral(childStateBefore), + childStateBefore.id != childStateAfter?.id + { + cancelEffect = .cancel(id: childStateBefore.id) + } else { + cancelEffect = .none + } + let onFirstAppearEffect: Effect + if + let childStateAfter, + !isEphemeral(childStateAfter), + childStateAfter.id != childStateBefore?.id || !state[keyPath: stateKeyPath].isPresented + { + state[keyPath: stateKeyPath].isPresented = true + 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 presentation 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 isEphemeral(childState) { + state[keyPath: stateKeyPath].wrappedValue = 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].wrappedValue = childState + let effects = self.reduce(into: &state, action: action) + + let onFirstAppearEffect: Effect + if + let childStateAfter = state[keyPath: stateKeyPath].wrappedValue, + !isEphemeral(childStateAfter), + childStateAfter.id != childState.id || !state[keyPath: stateKeyPath].isPresented + { + state[keyPath: stateKeyPath].isPresented = true + 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( + childEffects + .map { actionCasePath.embed(.presented($0)) } + .cancellable(id: childState.id), + effects, + onFirstAppearEffect + ) + + case let (.some(childState), .some(.dismiss)): + let effects = self.reduce(into: &state, action: action) + state[keyPath: stateKeyPath].wrappedValue = nil + return .merge( + effects, + .cancel(id: childState.id) + ) + } + } + } +} + +@_spi(Reflection) import CasePaths +private func isEphemeral(_ state: State) -> Bool { + if State.self is _EphemeralState.Type { + return true + } else if let metadata = EnumMetadata(State.self) { + return metadata.associatedValueType(forTag: metadata.tag(of: state)) is _EphemeralState.Type + } + return false +} + +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>, + state toChildState: @escaping (DestinationState) -> ChildState?, + action fromChildAction: @escaping (ChildAction) -> DestinationAction, + @ViewBuilder child: @escaping (Store) -> some View + ) -> some View { + self.sheet( + store: store.scope( + state: { $0.flatMap(toChildState) }, + action: { + switch $0 { + case .dismiss: + return .dismiss + case let .presented(action): + return .presented(fromChildAction(action)) + } + } + ), + child: child + ) + } + + 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>, + state toChildState: @escaping (DestinationState) -> ChildState?, + action fromChildAction: @escaping (ChildAction) -> DestinationAction, + @ViewBuilder child: @escaping (Store) -> some View + ) -> some View { + self.popover( + store: store.scope( + state: { $0.flatMap(toChildState) }, + action: { + switch $0 { + case .dismiss: + return .dismiss + case let .presented(action): + return .presented(fromChildAction(action)) + } + } + ), + child: child + ) + } + + 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>, + state toAlertState: @escaping (DestinationState) -> AlertState?, + action fromAlertAction: @escaping (Action) -> DestinationAction + ) -> some View { + self.alert( + store: store.scope( + state: { $0.flatMap(toAlertState) }, + action: { + switch $0 { + case .dismiss: + return .dismiss + case let .presented(action): + return .presented(fromAlertAction(action)) + } + } + ) + ) + } + + 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>, + state toAlertState: @escaping (DestinationState) -> ConfirmationDialogState?, + action fromAlertAction: @escaping (Action) -> DestinationAction + ) -> some View { + self.confirmationDialog( + store: store.scope( + state: { $0.flatMap(toAlertState) }, + action: { + switch $0 { + case .dismiss: + return .dismiss + case let .presented(action): + return .presented(fromAlertAction(action)) + } + } + ) + ) + } + + 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: returningLastNonNilValue { $0 }, action: { .presented($0) }) + ) { store in + self.destination(store) + } + }, + label: { self.label } + ) + } + } +} + +extension View { + func navigationDestination( + store: Store>, + state toChildState: @escaping (DestinationState) -> ChildState?, + action fromChildAction: @escaping (ChildAction) -> DestinationAction, + @ViewBuilder child: @escaping (Store) -> some View + ) -> some View { + self.navigationDestination( + store: store.scope( + state: { $0.flatMap(toChildState) }, + action: { + switch $0 { + case .dismiss: + return .dismiss + case let .presented(action): + return .presented(fromChildAction(action)) + } + } + ), + destination: child + ) + } + + func navigationDestination( + store: Store>, + @ViewBuilder destination: @escaping (Store) -> some View + ) -> some View { + WithViewStore( + store, + observe: { $0 }, + removeDuplicates: { ($0 != nil) == ($1 != nil) } + ) { viewStore in + self.navigationDestination( + isPresented: Binding( + get: { viewStore.state != nil }, + set: { isActive in + if !isActive, viewStore.state != nil { + viewStore.send(.dismiss) + } + } + ) + ) { + IfLetStore( + store.scope( + state: returningLastNonNilValue { $0 }, + action: { .presented($0) } + ) + ) { store in + destination(store) + } + } + } + } +} + +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/0235-composable-navigation-pt14/Inventory/Inventory/Preview Content/Preview Assets.xcassets/Contents.json b/0235-composable-navigation-pt14/Inventory/Inventory/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0235-composable-navigation-pt14/Inventory/Inventory/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0235-composable-navigation-pt14/Inventory/Inventory/StackExplorations.swift b/0235-composable-navigation-pt14/Inventory/Inventory/StackExplorations.swift new file mode 100644 index 00000000..e92adff2 --- /dev/null +++ b/0235-composable-navigation-pt14/Inventory/Inventory/StackExplorations.swift @@ -0,0 +1,444 @@ +import ComposableArchitecture +import SwiftUI + +struct CounterFeature: Reducer { + struct State: Equatable { + var count = 0 + var isLoading = false + var isTimerOn = false + } + enum Action { + case decrementButtonTapped + case delegate(Delegate) + case incrementButtonTapped + case loadAndGoToCounterButtonTapped + case loadResponse + case timerTick + case toggleTimerButtonTapped + + enum Delegate { + case goToCounter(Int) + } + } + private enum CancelID { case timer } + func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .decrementButtonTapped: + state.count -= 1 + return .none + + case .delegate: + return .none + + case .incrementButtonTapped: + state.count += 1 + return .none + + case .loadAndGoToCounterButtonTapped: + state.isLoading = true + return .run { send in + try await Task.sleep(for: .seconds(2)) + await send(.loadResponse) + } + + case .loadResponse: + state.isLoading = false + if Bool.random() { + return .send(.delegate(.goToCounter(state.count))) + } else { + return .none + } + + case .timerTick: + state.count += 1 + return .none + + case .toggleTimerButtonTapped: + state.isTimerOn.toggle() + if state.isTimerOn { + // Start up a timer + return .run { send in + while true { + try await Task.sleep(for: .seconds(1)) + await send(.timerTick) + } + } + .cancellable(id: CancelID.timer) + } else { + return .cancel(id: CancelID.timer) + } + } + } +} + +struct CounterView: View { + let store: StoreOf + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + VStack { + HStack { + Button("-") { + viewStore.send(.decrementButtonTapped) + } + Text("\(viewStore.count)") + Button("+") { + viewStore.send(.incrementButtonTapped) + } + } + + Button(viewStore.isTimerOn ? "Stop timer" : "Start timer") { + viewStore.send(.toggleTimerButtonTapped) + } + + NavigationLink( + state: RootFeature.Path.State.counter( + CounterFeature.State(count: viewStore.count) + ) + ) { + Text("Push counter: \(viewStore.count)") + } + + Button { + viewStore.send(.loadAndGoToCounterButtonTapped) + } label: { + if viewStore.isLoading { + ProgressView() + } + Text("Load and go to counter: \(viewStore.count)") + } + + NavigationLink( + state: RootFeature.Path.State.numberFact( + NumberFactFeature.State(number: viewStore.count) + ) + ) { + Text("Go to fact for \(viewStore.count)") + } + + NavigationLink( + state: RootFeature.Path.State.primeNumber( + PrimeNumberFeature.State(number: viewStore.count) + ) + ) { + Text("Go to prime number for \(viewStore.count)") + } + } + .navigationTitle("Counter: \(viewStore.count)") + } + } +} + +struct NumberFactFeature: Reducer { + struct State: Equatable { + @PresentationState var alert: AlertState? + let number: Int + } + enum Action { + case alert(PresentationAction) + case factButtonTapped + case factResponse(TaskResult) + } + enum AlertAction: Hashable { + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .alert: + return .none + + case .factButtonTapped: + return .task { [number = state.number] in + await .factResponse( + TaskResult { + try await String( + decoding: URLSession.shared.data(from: URL(string: "http://numbersapi.com/\(number)/trivia")!).0, + as: UTF8.self + ) + } + ) + } + + case let .factResponse(.success(fact)): + state.alert = AlertState { + TextState(fact) + } + return .none + + case .factResponse(.failure): + state.alert = AlertState { + TextState("Could not load a number fact :(") + } + return .none + } + } + .ifLet(\.$alert, action: /Action.alert) + } +} + +struct NumberFactView: View { + let store: StoreOf + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + VStack { + Text("Number: \(viewStore.number)") + Button("Get fact") { + viewStore.send(.factButtonTapped) + } + } + .alert(store: self.store.scope(state: \.alert, action: NumberFactFeature.Action.alert)) + } + } +} + +struct PrimeNumberFeature: Reducer { + struct State: Equatable { + @PresentationState var alert: AlertState? + var isLoading = false + let number: Int + } + enum Action: Equatable { + case alert(PresentationAction) + case nthPrimeButtonTapped + case response(TaskResult) + + enum Alert: Equatable {} + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .alert: + return .none + + case .nthPrimeButtonTapped: + state.isLoading = true + return .task { [number = state.number] in + await .response( + TaskResult { + let (data, _) = try await URLSession.shared + .data(from: wolframRequest(query: "prime \(number)")) + let result = try JSONDecoder().decode(WolframAlphaResult.self, from: data) + + if let prime = ( + result.queryresult + .pods + .first(where: { $0.primary == .some(true) })? + .subpods + .first? + .plaintext + ).flatMap(Int.init) { + return prime + } else { + struct PrimeError: Error {} + throw PrimeError() + } + } + ) + } + + case let .response(.success(prime)): + state.alert = AlertState { + TextState("The \(state.number)th prime is \(prime).") + } + state.isLoading = false + return .none + + case .response(.failure): + state.alert = AlertState { + TextState("Something went wrong :(") + } + state.isLoading = false + return .none + } + } + .ifLet(\.$alert, action: /Action.alert) + } +} +struct PrimeNumberView: View { + let store: StoreOf + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Form { + Button { + viewStore.send(.nthPrimeButtonTapped) + } label: { + HStack { + if viewStore.isLoading { + ProgressView() + } + Text("What is the \(viewStore.number)th prime?") + } + } + } + .alert(store: self.store.scope(state: \.alert, action: { .alert($0) })) + } + } +} + +struct RootFeature: Reducer { + struct State: Equatable { + var path: StackState = StackState() + var sum: Int { + self.path.reduce(into: 0) { sum, element in + switch element { + case let .counter(counterState): + sum += counterState.count + case .numberFact, .primeNumber: + break + } + } + } + } + enum Action { + case path(StackAction) + } + struct Path: Reducer { + enum State: Equatable { + case counter(CounterFeature.State = CounterFeature.State()) + case numberFact(NumberFactFeature.State) + case primeNumber(PrimeNumberFeature.State) + } + enum Action { + case counter(CounterFeature.Action) + case numberFact(NumberFactFeature.Action) + case primeNumber(PrimeNumberFeature.Action) + } + var body: some ReducerOf { + Scope(state: /State.counter, action: /Action.counter) { + CounterFeature() + } + Scope(state: /State.numberFact, action: /Action.numberFact) { + NumberFactFeature() + } + Scope(state: /State.primeNumber, action: /Action.primeNumber) { + PrimeNumberFeature() + } + } + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .path(.element(id: _, action: .counter(.delegate(action)))): + switch action { + case let .goToCounter(count): + state.path.append(.counter(CounterFeature.State(count: count))) + return .none + } + + case .path: + return .none + } + } + .forEach(\.path, action: /Action.path) { + Path() + } + } +} + +struct RootView: View { + struct ViewState: Equatable { + let isSummaryVisible: Bool + let sum: Int + + init(state: RootFeature.State) { + self.isSummaryVisible = !state.path.isEmpty && !state.path.reduce(into: false) { isAlertShown, element in + switch element { + case .counter: + break + case let .numberFact(numberFactState): + isAlertShown = isAlertShown || numberFactState.alert != nil + case let .primeNumber(primeNumberState): + isAlertShown = isAlertShown || primeNumberState.alert != nil + } + } + self.sum = state.sum + } + } + + let store: StoreOf + + var body: some View { + NavigationStackStore(self.store.scope(state: \.path, action: { .path($0) })) { + NavigationLink(state: RootFeature.Path.State.counter()) { + Text("Go to counter") + } + } destination: { state in + switch state { + case .counter: + CaseLet( + state: /RootFeature.Path.State.counter, + action: RootFeature.Path.Action.counter, + then: CounterView.init(store:) + ) + case .numberFact: + CaseLet( + state: /RootFeature.Path.State.numberFact, + action: RootFeature.Path.Action.numberFact, + then: NumberFactView.init(store:) + ) + case .primeNumber: + CaseLet( + state: /RootFeature.Path.State.primeNumber, + action: RootFeature.Path.Action.primeNumber, + then: PrimeNumberView.init(store:) + ) + } + } + .overlay(alignment: .bottom) { + WithViewStore(self.store, observe: ViewState.init) { viewStore in + if viewStore.isSummaryVisible { + Text("Sum: \(viewStore.sum)") + .padding() + .background(Color.white) + .transition(.opacity.animation(.default)) + .clipped() + .shadow(color: .black.opacity(0.2), radius: 5, y: 5) + } + } + } + } +} + +struct Previews: PreviewProvider { + static var previews: some View { + RootView( + store: Store( + initialState: RootFeature.State( + path: StackState([ + .counter(CounterFeature.State(count: 100)) + ]) + ), + reducer: RootFeature() + ._printChanges() + ) + ) + .previewDisplayName("RootView") + + NumberFactView( + store: Store( + initialState: NumberFactFeature.State(number: 42), + reducer: NumberFactFeature() + ) + ) + .previewDisplayName("Fact") + + NavigationStack { + CounterView( + store: Store( + initialState: CounterFeature.State(), + reducer: CounterFeature() + ) + ) + } + .previewDisplayName("Counter") + + PrimeNumberView( + store: Store( + initialState: PrimeNumberFeature.State(number: 999), + reducer: PrimeNumberFeature() + ) + ) + .previewDisplayName("Prime number") + } +} diff --git a/0235-composable-navigation-pt14/Inventory/Inventory/ThirdTab.swift b/0235-composable-navigation-pt14/Inventory/Inventory/ThirdTab.swift new file mode 100644 index 00000000..f8fde492 --- /dev/null +++ b/0235-composable-navigation-pt14/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/0235-composable-navigation-pt14/Inventory/Inventory/Vanilla.swift b/0235-composable-navigation-pt14/Inventory/Inventory/Vanilla.swift new file mode 100644 index 00000000..1e28c060 --- /dev/null +++ b/0235-composable-navigation-pt14/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/0235-composable-navigation-pt14/Inventory/Inventory/WolframAlpha.swift b/0235-composable-navigation-pt14/Inventory/Inventory/WolframAlpha.swift new file mode 100644 index 00000000..f0fdd7ed --- /dev/null +++ b/0235-composable-navigation-pt14/Inventory/Inventory/WolframAlpha.swift @@ -0,0 +1,27 @@ +struct WolframAlphaResult: Decodable { + let queryresult: QueryResult + + struct QueryResult: Decodable { + let pods: [Pod] + + struct Pod: Decodable { + let primary: Bool? + let subpods: [SubPod] + + struct SubPod: Decodable { + let plaintext: String + } + } + } +} +import Foundation +func wolframRequest(query: String) -> URL { + var components = URLComponents(string: "https://api.wolframalpha.com/v2/query")! + components.queryItems = [ + URLQueryItem(name: "input", value: query), + URLQueryItem(name: "format", value: "plaintext"), + URLQueryItem(name: "output", value: "JSON"), + URLQueryItem(name: "appid", value: "6H69Q3-828TKQJ4EP"), + ] + return components.url(relativeTo: nil)! +} diff --git a/0235-composable-navigation-pt14/Inventory/InventoryTests/InventoryTests.swift b/0235-composable-navigation-pt14/Inventory/InventoryTests/InventoryTests.swift new file mode 100644 index 00000000..96f561fe --- /dev/null +++ b/0235-composable-navigation-pt14/Inventory/InventoryTests/InventoryTests.swift @@ -0,0 +1,296 @@ +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.destination = .alert(.delete(item: item)) + } + await store.send(.destination(.presented(.alert(.confirmDeletion(id: item.id))))) { + $0.destination = 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.destination = .duplicateItem( + ItemFormFeature.State( + item: Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "Headphones", + color: .blue, + status: .inStock(quantity: 20) + ) + ) + ) + } + await store.send(.destination(.presented(.duplicateItem(.set(\.$item.name, "Bluetooth Headphones"))))) { +// guard case let .duplicateItem(&state) = $0.destination +// else { XCTFail(); return } +// state.item.name = "Bluetooth Headphones" + + XCTModify(&$0.destination, case: /InventoryFeature.Destination.State.duplicateItem) { + $0.item.name = "Bluetooth Headphones" + } + } + await store.send(.confirmDuplicateItemButtonTapped) { + $0.destination = 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.destination = .addItem( + ItemFormFeature.State( + item: Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "", + status: .inStock(quantity: 1) + ) + ) + ) + } + + await store.send(.destination(.presented(.addItem(.set(\.$item.name, "Headphones"))))) { + XCTModify(&$0.destination, case: /InventoryFeature.Destination.State.addItem) { + $0.item.name = "Headphones" + } + } + + await store.send(.confirmAddItemButtonTapped) { + $0.destination = 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.destination = .addItem( + ItemFormFeature.State( + item: Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "", + status: .inStock(quantity: 1) + ) + ) + ) + } + + await store.send(.destination(.presented(.addItem(.set(\.$item.name, "Headphones"))))) { + XCTModify(&$0.destination, case: /InventoryFeature.Destination.State.addItem) { + $0.item.name = "Headphones" + } + } + + await store.send(.destination(.presented(.addItem(.set(\.$isTimerOn, true))))) { + XCTModify(&$0.destination, case: /InventoryFeature.Destination.State.addItem) { + $0.isTimerOn = true + } + } + + await store.send(.confirmAddItemButtonTapped) { + $0.destination = nil + $0.items = [ + Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "Headphones", + status: .inStock(quantity: 1) + ) + ] + } + } + + 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.destination = .addItem( + ItemFormFeature.State( + item: Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "", + status: .inStock(quantity: 1) + ) + ) + ) + } + + await store.send(.destination(.presented(.addItem(.set(\.$isTimerOn, true))))) { + XCTModify(&$0.destination, case: /InventoryFeature.Destination.State.addItem) { + $0.isTimerOn = true + } + } + await clock.advance(by: .seconds(3)) + await store.receive(.destination(.presented(.addItem(.timerTick)))) { + XCTModify(&$0.destination, case: /InventoryFeature.Destination.State.addItem) { + $0.item.status = .inStock(quantity: 2) + } + } + await store.receive(.destination(.presented(.addItem(.timerTick)))) { + XCTModify(&$0.destination, case: /InventoryFeature.Destination.State.addItem) { + $0.item.status = .inStock(quantity: 3) + } + } + await store.receive(.destination(.presented(.addItem(.timerTick)))) { + XCTModify(&$0.destination, case: /InventoryFeature.Destination.State.addItem) { + $0.item.status = .inStock(quantity: 4) + } + } + await store.receive(.destination(.dismiss)) { + $0.destination = 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.destination = .addItem( + ItemFormFeature.State( + item: Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "", + status: .inStock(quantity: 1) + ) + ) + ) + } + + await store.send(.destination(.presented(.addItem(.set(\.$isTimerOn, true))))) + await store.receive(.destination(.dismiss)) { + $0.destination = nil + } + } +// + func testEditItem() async { + let item = Item.headphones + let store = TestStore( + initialState: InventoryFeature.State(items: [item]), + reducer: InventoryFeature() + ) + + await store.send(.itemButtonTapped(id: item.id)) { + $0.destination = .editItem(ItemFormFeature.State(item: item)) + } + await store.send(.destination(.presented(.editItem(.set(\.$item.name, "Bluetooth Headphones"))))) { + XCTModify(&$0.destination, case: /InventoryFeature.Destination.State.editItem) { + $0.item.name = "Bluetooth Headphones" + } + } + await store.send(.destination(.dismiss)) { + $0.destination = nil + $0.items[0].name = "Bluetooth Headphones" + } + } +// + func testEditItem_Timer() async { + let item = Item.headphones + let store = TestStore( + initialState: InventoryFeature.State(items: [item]), + reducer: InventoryFeature() + ) { + $0.continuousClock = ImmediateClock() + } + store.exhaustivity = .off(showSkippedAssertions: true) + + await store.send(.itemButtonTapped(id: item.id)) + await store.send(.destination(.presented(.editItem(.set(\.$isTimerOn, true))))) + await store.receive(.destination(.dismiss)) { + $0.destination = nil + $0.items[0].status = .inStock(quantity: 23) + } + } + + func testDismiss() async { + let store = TestStore( + initialState: InventoryFeature.State( + destination: .addItem(ItemFormFeature.State(item: .headphones)) + ), + reducer: InventoryFeature() + ) + + await store.send(.destination(.presented(.addItem(.dismissButtonTapped)))) { + $0.destination = nil + } +// await store.receive(.destination(.dismiss)) { +// $0.destination = nil +// } + } +} diff --git a/0235-composable-navigation-pt14/Inventory/InventoryTests/StackOverflowTests.swift b/0235-composable-navigation-pt14/Inventory/InventoryTests/StackOverflowTests.swift new file mode 100644 index 00000000..7f907a4a --- /dev/null +++ b/0235-composable-navigation-pt14/Inventory/InventoryTests/StackOverflowTests.swift @@ -0,0 +1,191 @@ +//struct Ten: Equatable { +// var a, b, c, d, e, f, g, h, i, j: A +//} +//struct BoxedTen: Equatable { +// private var box: [A] +// init(a: A, b: A, c: A, d: A, e: A, f: A, g: A, h: A, i: A, j: A) { +// self.box = [a, b, c, d, e, f, g, h, i, j] +// } +// var a: A { +// get { self.box[0] } +// set { self.box[0] = newValue } +// } +// var b: A { +// get { self.box[1] } +// set { self.box[1] = newValue } +// } +// var c: A { +// get { self.box[2] } +// set { self.box[2] = newValue } +// } +// var d: A { +// get { self.box[3] } +// set { self.box[3] = newValue } +// } +// var e: A { +// get { self.box[4] } +// set { self.box[4] = newValue } +// } +// var f: A { +// get { self.box[5] } +// set { self.box[5] = newValue } +// } +// var g: A { +// get { self.box[6] } +// set { self.box[6] = newValue } +// } +// var h: A { +// get { self.box[7] } +// set { self.box[7] = newValue } +// } +// var i: A { +// get { self.box[8] } +// set { self.box[8] = newValue } +// } +// var j: A { +// get { self.box[9] } +// set { self.box[9] = newValue } +// } +//} +// +//import XCTest +// +//class StackOverflowTests: XCTestCase { +// func testTen() { +// let value1 = Ten( +// a: "qndfjkasdf njkasdfnjsakd", +// b: "qndfjkasdf njkasdfnjsakd", +// c: "qndfjkasdf njkasdfnjsakd", +// d: "qndfjkasdf njkasdfnjsakd", +// e: "qndfjkasdf njkasdfnjsakd", +// f: "qndfjkasdf njkasdfnjsakd", +// g: "qndfjkasdf njkasdfnjsakd", +// h: "qndfjkasdf njkasdfnjsakd", +// i: "qndfjkasdf njkasdfnjsakd", +// j: "qndfjkasdf njkasdfnjsakd" +// ) +// let value2 = Ten( +// a: value1, +// b: value1, +// c: value1, +// d: value1, +// e: value1, +// f: value1, +// g: value1, +// h: value1, +// i: value1, +// j: value1 +// ) +// let value3 = Ten( +// a: value2, +// b: value2, +// c: value2, +// d: value2, +// e: value2, +// f: value2, +// g: value2, +// h: value2, +// i: value2, +// j: value2 +// ) +//// let value4 = Ten( +//// a: value3, +//// b: value3, +//// c: value3, +//// d: value3, +//// e: value3, +//// f: value3, +//// g: value3, +//// h: value3, +//// i: value3, +//// j: value3 +//// ) +//// let value5 = Ten( +//// a: value4, +//// b: value4, +//// c: value4, +//// d: value4, +//// e: value4, +//// f: value4, +//// g: value4, +//// h: value4, +//// i: value4, +//// j: value4 +//// ) +//// var copy = value4 +// let start = Date() +// for _ in 1...10_000 { +// precondition(value3 == value3) +// } +// print("Ten equality", Date().timeIntervalSince(start)) +// } +// +// func testBoxedTen() { +// let value1 = BoxedTen( +// a: "qndfjkasdf njkasdfnjsakd", +// b: "qndfjkasdf njkasdfnjsakd", +// c: "qndfjkasdf njkasdfnjsakd", +// d: "qndfjkasdf njkasdfnjsakd", +// e: "qndfjkasdf njkasdfnjsakd", +// f: "qndfjkasdf njkasdfnjsakd", +// g: "qndfjkasdf njkasdfnjsakd", +// h: "qndfjkasdf njkasdfnjsakd", +// i: "qndfjkasdf njkasdfnjsakd", +// j: "qndfjkasdf njkasdfnjsakd" +// ) +// let value2 = BoxedTen( +// a: value1, +// b: value1, +// c: value1, +// d: value1, +// e: value1, +// f: value1, +// g: value1, +// h: value1, +// i: value1, +// j: value1 +// ) +// let value3 = BoxedTen( +// a: value2, +// b: value2, +// c: value2, +// d: value2, +// e: value2, +// f: value2, +// g: value2, +// h: value2, +// i: value2, +// j: value2 +// ) +// let value4 = BoxedTen( +// a: value3, +// b: value3, +// c: value3, +// d: value3, +// e: value3, +// f: value3, +// g: value3, +// h: value3, +// i: value3, +// j: value3 +// ) +// let value5 = BoxedTen( +// a: value4, +// b: value4, +// c: value4, +// d: value4, +// e: value4, +// f: value4, +// g: value4, +// h: value4, +// i: value4, +// j: value4 +// ) +// var copy = value5 +// let start = Date() +// for _ in 1...10_000 { +// precondition(value5 == value5) +// } +// print("BoxedTen equality", Date().timeIntervalSince(start)) +// } +//} diff --git a/0235-composable-navigation-pt14/Inventory/InventoryTests/VanillaTests.swift b/0235-composable-navigation-pt14/Inventory/InventoryTests/VanillaTests.swift new file mode 100644 index 00000000..af07fb5f --- /dev/null +++ b/0235-composable-navigation-pt14/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/0235-composable-navigation-pt14/README.md b/0235-composable-navigation-pt14/README.md new file mode 100644 index 00000000..10bacb7a --- /dev/null +++ b/0235-composable-navigation-pt14/README.md @@ -0,0 +1,5 @@ +## [Point-Free](https://www.pointfree.co) + +> #### This directory contains code from Point-Free Episode: [Composable Stacks: State Ergonomics](https://www.pointfree.co/episodes/ep235-composable-stacks-state-ergonomics) +> +> We introduce a complementary `StackState` wrapper to our navigation tools to solve a few issues that came from repurposing the identified array type. Once implemented and integrated, we will put these tools’ superpowers to work by implementing two new features. diff --git a/README.md b/README.md index 6faf298a..bb725356 100644 --- a/README.md +++ b/README.md @@ -236,3 +236,4 @@ This repository is the home of code written on episodes of [Point-Free](https:// 1. [Composable Stacks: Multiple Layers](0232-composable-navigation-pt11) 1. [Composable Stacks: Multiple Destinations](0233-composable-navigation-pt12) 1. [Composable Stacks: Action Ergonomics](0234-composable-navigation-pt13) +1. [Composable Stacks: State Ergonomics](0235-composable-navigation-pt14)