diff --git a/0225-composable-navigation-pt4/Inventory/Inventory.xcodeproj/project.pbxproj b/0225-composable-navigation-pt4/Inventory/Inventory.xcodeproj/project.pbxproj new file mode 100644 index 00000000..0b50265a --- /dev/null +++ b/0225-composable-navigation-pt4/Inventory/Inventory.xcodeproj/project.pbxproj @@ -0,0 +1,541 @@ +// !$*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 */; }; + 4B0E65B729944EFC00DFB522 /* FirstTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0E65B629944EFC00DFB522 /* FirstTab.swift */; }; + 4B0E65B929944F4800DFB522 /* Inventory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0E65B829944F4800DFB522 /* Inventory.swift */; }; + 4B0E65BB29944F6500DFB522 /* ThirdTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0E65BA29944F6500DFB522 /* ThirdTab.swift */; }; + 4B0E65BD2994626900DFB522 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0E65BC2994626900DFB522 /* Models.swift */; }; + 4B0E65BF29946B1100DFB522 /* Navigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0E65BE29946B1100DFB522 /* Navigation.swift */; }; + 4B0E65C229946FD700DFB522 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = 4B0E65C129946FD700DFB522 /* SwiftUINavigation */; }; + 4BD190FF299EA3D500A6A7E5 /* ItemForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD190FE299EA3D500A6A7E5 /* ItemForm.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 2A3BEA042994469600351060 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 2A3BE9EB2994469500351060 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 2A3BE9F22994469500351060; + remoteInfo = Inventory; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 2A3BE9F32994469500351060 /* Inventory.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Inventory.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 2A3BE9F62994469500351060 /* InventoryApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InventoryApp.swift; sourceTree = ""; }; + 2A3BE9F82994469500351060 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 2A3BE9FA2994469600351060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 2A3BE9FD2994469600351060 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 2A3BEA032994469600351060 /* InventoryTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = InventoryTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 2A3BEA072994469600351060 /* InventoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InventoryTests.swift; sourceTree = ""; }; + 2A3BEA2329945D4000351060 /* Vanilla.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Vanilla.swift; sourceTree = ""; }; + 2A3BEA2729945ECB00351060 /* VanillaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VanillaTests.swift; sourceTree = ""; }; + 4B0E65B629944EFC00DFB522 /* FirstTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstTab.swift; sourceTree = ""; }; + 4B0E65B829944F4800DFB522 /* Inventory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Inventory.swift; sourceTree = ""; }; + 4B0E65BA29944F6500DFB522 /* ThirdTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdTab.swift; sourceTree = ""; }; + 4B0E65BC2994626900DFB522 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 4B0E65BE29946B1100DFB522 /* Navigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Navigation.swift; sourceTree = ""; }; + 4BD190FE299EA3D500A6A7E5 /* ItemForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemForm.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 2A3BE9F02994469500351060 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4B0E65C229946FD700DFB522 /* SwiftUINavigation in Frameworks */, + 2A3BEA2229944BA700351060 /* ComposableArchitecture in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2A3BEA002994469600351060 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2A3BE9EA2994469500351060 = { + isa = PBXGroup; + children = ( + 2A3BE9F52994469500351060 /* Inventory */, + 2A3BEA062994469600351060 /* InventoryTests */, + 2A3BE9F42994469500351060 /* Products */, + ); + sourceTree = ""; + }; + 2A3BE9F42994469500351060 /* Products */ = { + isa = PBXGroup; + children = ( + 2A3BE9F32994469500351060 /* Inventory.app */, + 2A3BEA032994469600351060 /* InventoryTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 2A3BE9F52994469500351060 /* Inventory */ = { + isa = PBXGroup; + children = ( + 2A3BE9F82994469500351060 /* ContentView.swift */, + 4B0E65B629944EFC00DFB522 /* FirstTab.swift */, + 4B0E65B829944F4800DFB522 /* Inventory.swift */, + 2A3BE9F62994469500351060 /* InventoryApp.swift */, + 4BD190FE299EA3D500A6A7E5 /* ItemForm.swift */, + 4B0E65BC2994626900DFB522 /* Models.swift */, + 4B0E65BE29946B1100DFB522 /* Navigation.swift */, + 4B0E65BA29944F6500DFB522 /* ThirdTab.swift */, + 2A3BEA2329945D4000351060 /* Vanilla.swift */, + 2A3BE9FA2994469600351060 /* Assets.xcassets */, + 2A3BE9FC2994469600351060 /* Preview Content */, + ); + path = Inventory; + sourceTree = ""; + }; + 2A3BE9FC2994469600351060 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 2A3BE9FD2994469600351060 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 2A3BEA062994469600351060 /* InventoryTests */ = { + isa = PBXGroup; + children = ( + 2A3BEA072994469600351060 /* InventoryTests.swift */, + 2A3BEA2729945ECB00351060 /* VanillaTests.swift */, + ); + path = InventoryTests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 2A3BE9F22994469500351060 /* Inventory */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2A3BEA172994469600351060 /* Build configuration list for PBXNativeTarget "Inventory" */; + buildPhases = ( + 2A3BE9EF2994469500351060 /* Sources */, + 2A3BE9F02994469500351060 /* Frameworks */, + 2A3BE9F12994469500351060 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Inventory; + packageProductDependencies = ( + 2A3BEA2129944BA700351060 /* ComposableArchitecture */, + 4B0E65C129946FD700DFB522 /* SwiftUINavigation */, + ); + 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" */, + ); + productRefGroup = 2A3BE9F42994469500351060 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 2A3BE9F22994469500351060 /* Inventory */, + 2A3BEA022994469600351060 /* InventoryTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 2A3BE9F12994469500351060 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2A3BE9FE2994469600351060 /* Preview Assets.xcassets in Resources */, + 2A3BE9FB2994469600351060 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2A3BEA012994469600351060 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 2A3BE9EF2994469500351060 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4BD190FF299EA3D500A6A7E5 /* ItemForm.swift in Sources */, + 4B0E65BB29944F6500DFB522 /* ThirdTab.swift in Sources */, + 4B0E65B929944F4800DFB522 /* Inventory.swift in Sources */, + 4B0E65B729944EFC00DFB522 /* FirstTab.swift in Sources */, + 4B0E65BF29946B1100DFB522 /* Navigation.swift in Sources */, + 2A3BE9F92994469500351060 /* ContentView.swift in Sources */, + 2A3BE9F72994469500351060 /* InventoryApp.swift in Sources */, + 2A3BEA2429945D4000351060 /* Vanilla.swift in Sources */, + 4B0E65BD2994626900DFB522 /* Models.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2A3BE9FF2994469600351060 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2A3BEA082994469600351060 /* InventoryTests.swift in Sources */, + 2A3BEA2829945ECB00351060 /* VanillaTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 2A3BEA052994469600351060 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 2A3BE9F22994469500351060 /* Inventory */; + targetProxy = 2A3BEA042994469600351060 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 2A3BEA152994469600351060 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 2A3BEA162994469600351060 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 2A3BEA182994469600351060 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Inventory/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Inventory; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 2A3BEA192994469600351060 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Inventory/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Inventory; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 2A3BEA1B2994469600351060 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.InventoryTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Inventory.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Inventory"; + }; + name = Debug; + }; + 2A3BEA1C2994469600351060 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.InventoryTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Inventory.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Inventory"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 2A3BE9EE2994469500351060 /* Build configuration list for PBXProject "Inventory" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2A3BEA152994469600351060 /* Debug */, + 2A3BEA162994469600351060 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2A3BEA172994469600351060 /* Build configuration list for PBXNativeTarget "Inventory" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2A3BEA182994469600351060 /* Debug */, + 2A3BEA192994469600351060 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2A3BEA1A2994469600351060 /* Build configuration list for PBXNativeTarget "InventoryTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2A3BEA1B2994469600351060 /* Debug */, + 2A3BEA1C2994469600351060 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 2A3BEA2029944BA700351060 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-composable-architecture.git"; + requirement = { + kind = revision; + revision = bcf5683aecdba339d309848c50b7f33fed887709; + }; + }; + 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; + }; + 4B0E65C129946FD700DFB522 /* SwiftUINavigation */ = { + isa = XCSwiftPackageProductDependency; + package = 4B0E65C029946FD700DFB522 /* XCRemoteSwiftPackageReference "swiftui-navigation" */; + productName = SwiftUINavigation; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 2A3BE9EB2994469500351060 /* Project object */; +} diff --git a/0225-composable-navigation-pt4/Inventory/Inventory.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/0225-composable-navigation-pt4/Inventory/Inventory.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/0225-composable-navigation-pt4/Inventory/Inventory.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/0225-composable-navigation-pt4/Inventory/Inventory.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/0225-composable-navigation-pt4/Inventory/Inventory.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/0225-composable-navigation-pt4/Inventory/Inventory.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/0225-composable-navigation-pt4/Inventory/Inventory.xcodeproj/xcshareddata/xcschemes/Inventory.xcscheme b/0225-composable-navigation-pt4/Inventory/Inventory.xcodeproj/xcshareddata/xcschemes/Inventory.xcscheme new file mode 100644 index 00000000..3315c319 --- /dev/null +++ b/0225-composable-navigation-pt4/Inventory/Inventory.xcodeproj/xcshareddata/xcschemes/Inventory.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0225-composable-navigation-pt4/Inventory/Inventory/Assets.xcassets/AccentColor.colorset/Contents.json b/0225-composable-navigation-pt4/Inventory/Inventory/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/0225-composable-navigation-pt4/Inventory/Inventory/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0225-composable-navigation-pt4/Inventory/Inventory/Assets.xcassets/AppIcon.appiconset/Contents.json b/0225-composable-navigation-pt4/Inventory/Inventory/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..13613e3e --- /dev/null +++ b/0225-composable-navigation-pt4/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/0225-composable-navigation-pt4/Inventory/Inventory/Assets.xcassets/Contents.json b/0225-composable-navigation-pt4/Inventory/Inventory/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0225-composable-navigation-pt4/Inventory/Inventory/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0225-composable-navigation-pt4/Inventory/Inventory/ContentView.swift b/0225-composable-navigation-pt4/Inventory/Inventory/ContentView.swift new file mode 100644 index 00000000..37d95216 --- /dev/null +++ b/0225-composable-navigation-pt4/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/0225-composable-navigation-pt4/Inventory/Inventory/FirstTab.swift b/0225-composable-navigation-pt4/Inventory/Inventory/FirstTab.swift new file mode 100644 index 00000000..a70e68dd --- /dev/null +++ b/0225-composable-navigation-pt4/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/0225-composable-navigation-pt4/Inventory/Inventory/Inventory.swift b/0225-composable-navigation-pt4/Inventory/Inventory/Inventory.swift new file mode 100644 index 00000000..57e3ce58 --- /dev/null +++ b/0225-composable-navigation-pt4/Inventory/Inventory/Inventory.swift @@ -0,0 +1,284 @@ +import ComposableArchitecture +import SwiftUI + +struct InventoryFeature: Reducer { + struct State: Equatable { + var addItem: ItemFormFeature.State? + var alert: AlertState? + var confirmationDialog: ConfirmationDialogState? + var items: IdentifiedArrayOf = [] + } + enum Action: Equatable { + case addButtonTapped + case addItem(SheetAction) + case alert(AlertAction) + case cancelAddItemButtonTapped + case confirmAddItemButtonTapped + case confirmationDialog(ConfirmationDialogAction) + case deleteButtonTapped(id: Item.ID) + //case dismissAddItem + case duplicateButtonTapped(id: Item.ID) + + enum Alert: Equatable { + case confirmDeletion(id: Item.ID) + } + enum Dialog: Equatable { + case confirmDuplication(id: Item.ID) + } + } + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .addButtonTapped: + state.addItem = ItemFormFeature.State( + item: Item(name: "", status: .inStock(quantity: 1)) + ) + return .none + +// case .addItem(.dismiss): +// state.addItem = nil +// return .none + + case .addItem: + return .none +// case let .addItem(action): +// guard var itemFormState = state.addItem +// else { return .none } +// let itemFormEffects = ItemFormFeature().reduce(into: &itemFormState, action: action) +// state.addItem = itemFormState +// return itemFormEffects.map(Action.addItem) + + + case let .alert(.presented(.confirmDeletion(id))): + state.items.remove(id: id) + return .none + +// case .alert(.dismiss): +// state.alert = nil +// return .none + + case .alert: + return .none + + case .cancelAddItemButtonTapped: + state.addItem = nil + return .none + + case .confirmAddItemButtonTapped: + defer { state.addItem = nil } + guard let item = state.addItem?.item + else { return .none } + state.items.append(item) + return .none + + case let .confirmationDialog(.presented(.confirmDuplication(id: id))): + guard + let item = state.items[id: id], + let index = state.items.index(id: id) + else { + return .none + } + state.items.insert(item.duplicate(), at: index) + return .none + + case .confirmationDialog(.dismiss): + return .none + + case let .deleteButtonTapped(id): + guard let item = state.items[id: id] + else { return .none } + + state.alert = .delete(item: item) + return .none + + case let .duplicateButtonTapped(id): + guard let item = state.items[id: id] + else { return .none } + + // show a confirmation dialog + state.confirmationDialog = .duplicate(item: item) + return .none + } + } + .alert(state: \.alert, action: /Action.alert) + .confirmationDialog(state: \.confirmationDialog, action: /Action.confirmationDialog) + .sheet(state: \.addItem, action: /Action.addItem) { + ItemFormFeature() + } +// let _ = \Item.status.isInStock +// let _ = (\Item.status).appending(path: \Item.Status.isInStock) + } +} + +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 addItemID: Item.ID? + let items: IdentifiedArrayOf + + init(state: InventoryFeature.State) { + self.addItemID = state.addItem?.item.id + self.items = state.items + } + } + + var body: some View { + WithViewStore(self.store, observe: ViewState.init) { (viewStore: ViewStore) in + List { + ForEach(viewStore.items) { item in + HStack { + VStack(alignment: .leading) { + Text(item.name) + + switch item.status { + case let .inStock(quantity): + Text("In stock: \(quantity)") + case let .outOfStock(isOnBackOrder): + Text("Out of stock" + (isOnBackOrder ? ": on back order" : "")) + } + } + + Spacer() + + if let color = item.color { + Rectangle() + .frame(width: 30, height: 30) + .foregroundColor(color.swiftUIColor) + .border(Color.black, width: 1) + } + + Button { + viewStore.send(.duplicateButtonTapped(id: item.id)) + } label: { + Image(systemName: "doc.on.doc.fill") + } + .padding(.leading) + + Button { + viewStore.send(.deleteButtonTapped(id: item.id)) + } label: { + Image(systemName: "trash.fill") + } + .padding(.leading) + } + .buttonStyle(.plain) + .foregroundColor(item.status.isInStock ? nil : Color.gray) + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Add") { + viewStore.send(.addButtonTapped) + } + } + } + .alert( + store: self.store.scope(state: \.alert, action: InventoryFeature.Action.alert) + ) + .confirmationDialog( + store: self.store.scope(state: \.confirmationDialog, action: InventoryFeature.Action.confirmationDialog) + ) + .sheet( + store: self.store.scope(state: \.addItem, action: InventoryFeature.Action.addItem) + ) { store in + NavigationStack { + ItemFormView(store: store) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + viewStore.send(.cancelAddItemButtonTapped) + } + } + ToolbarItem(placement: .primaryAction) { + Button("Add") { + viewStore.send(.confirmAddItemButtonTapped) + } + } + } + .navigationTitle("New item") + } + } + +// .sheet( +// item: viewStore.binding( +// get: { $0.addItemID.map { Identified($0, id: \.self) } }, +// send: .addItem(.dismiss) +// ) +// ) { _ in +// IfLetStore( +// self.store.scope( +// state: \.addItem, +// action: { .addItem(.presented($0)) } +// ) +// ) { store in +// NavigationStack { +// ItemFormView(store: store) +// .toolbar { +// ToolbarItem(placement: .cancellationAction) { +// Button("Cancel") { +// viewStore.send(.cancelAddItemButtonTapped) +// } +// } +// ToolbarItem(placement: .primaryAction) { +// Button("Add") { +// viewStore.send(.confirmAddItemButtonTapped) +// } +// } +// } +// .navigationTitle("New item") +// } +// } +// } + } + } +} + +struct Inventory_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + InventoryView( + store: Store( + initialState: InventoryFeature.State( + items: [ + .headphones, + .mouse, + .keyboard, + .monitor, + ] + ), + reducer: InventoryFeature() + ) + ) + } + } +} diff --git a/0225-composable-navigation-pt4/Inventory/Inventory/InventoryApp.swift b/0225-composable-navigation-pt4/Inventory/Inventory/InventoryApp.swift new file mode 100644 index 00000000..5122fca5 --- /dev/null +++ b/0225-composable-navigation-pt4/Inventory/Inventory/InventoryApp.swift @@ -0,0 +1,28 @@ +import ComposableArchitecture +import SwiftUI + +@main +struct InventoryApp: App { + var body: some Scene { + WindowGroup { + ContentView( + store: Store( + initialState: AppFeature.State( + inventory: InventoryFeature.State( +// addItem: ItemFormFeature.State(item: Item(name: "Laptop", status: .inStock(quantity: 100))), + items: [ + .monitor, + .mouse, + .keyboard, + .headphones + ] + )//, +// selectedTab: .inventory + ), + reducer: AppFeature() + ._printChanges() + ) + ) + } + } +} diff --git a/0225-composable-navigation-pt4/Inventory/Inventory/ItemForm.swift b/0225-composable-navigation-pt4/Inventory/Inventory/ItemForm.swift new file mode 100644 index 00000000..d4c6d2cc --- /dev/null +++ b/0225-composable-navigation-pt4/Inventory/Inventory/ItemForm.swift @@ -0,0 +1,150 @@ +import ComposableArchitecture +import SwiftUI +import SwiftUINavigation + +struct ItemFormFeature: Reducer { + struct State: Equatable, Identifiable { + @BindingState var isTimerOn = false + @BindingState var item: Item + + var id: Item.ID { self.item.id } + } + enum Action: BindableAction, Equatable { + case binding(BindingAction) + case timerTick + } + @Dependency(\.continuousClock) var clock + @Dependency(\.dismiss) var dismiss + + var body: some ReducerOf { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(\.$isTimerOn): + if state.isTimerOn { + return .run { send in + var tickCount = 0 + for await _ in self.clock.timer(interval: .seconds(1)) { + await send(.timerTick) + tickCount += 1 + if tickCount == 3 { + await self.dismiss() + } + } + } + .cancellable(id: CancelID.timer) + } else { + return .cancel(id: CancelID.timer) + } + + case .binding: + return .none + + case .timerTick: + guard case let .inStock(quantity) = state.item.status + else { return .none } + state.item.status = .inStock(quantity: quantity + 1) +// if quantity == 3 { +// self.dismiss() +// } +// URLSession.shared.dataTask(with: ...) { data, _, _ in +// +// }.resume() + return .none +// return quantity == 3 +// ? .fireAndForget { await self.dismiss() } +// : .none + } + } + } + + private enum CancelID { + case timer + } +} + +struct ItemFormView: View { + @Environment(\.dismiss) var dismiss + let store: StoreOf + +// init() { +// // _ = URLSession... +// } + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Form { + TextField("Name", text: viewStore.binding(\.$item.name)) + +// _ = URLSession... +// _ = self.dismiss() + + HStack { + Picker("Color", selection: viewStore.binding(\.$item.color)) { + Text("None") + .tag(Item.Color?.none) + ForEach(Item.Color.defaults) { color in + ZStack { + RoundedRectangle(cornerRadius: 4) + .fill(color.swiftUIColor) + Label(color.name, systemImage: "paintpalette") + .padding(4) + } + .fixedSize(horizontal: false, vertical: true) + .tag(Optional(color)) + } + } + + if let color = viewStore.item.color { + Rectangle() + .frame(width: 30, height: 30) + .foregroundColor(color.swiftUIColor) + .border(Color.black, width: 1) + } + } + + Switch(viewStore.binding(\.$item.status)) { + CaseLet(/Item.Status.inStock) { $quantity in + Section(header: Text("In stock")) { + Stepper("Quantity: \(quantity)", value: $quantity) + Button("Mark as sold out") { + viewStore.send( + .set(\.$item.status, .outOfStock(isOnBackOrder: false)), + animation: .default + ) + } + } + } + CaseLet(/Item.Status.outOfStock) { $isOnBackOrder in + Section(header: Text("Out of stock")) { + Toggle("Is on back order?", isOn: $isOnBackOrder) + Button("Is back in stock!") { + viewStore.send( + .set(\.$item.status, .inStock(quantity: 1)), + animation: .default + ) + } + } + } + } + + Toggle("Timer", isOn: viewStore.binding(\.$isTimerOn)) + + Button("Dismiss") { self.dismiss() } + } + } + } +} + +struct ItemForm_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + ItemFormView( + store: Store( + initialState: ItemFormFeature.State(item: .headphones), + reducer: ItemFormFeature() + ) + ) + } + } +} diff --git a/0225-composable-navigation-pt4/Inventory/Inventory/Models.swift b/0225-composable-navigation-pt4/Inventory/Inventory/Models.swift new file mode 100644 index 00000000..4814bc56 --- /dev/null +++ b/0225-composable-navigation-pt4/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/0225-composable-navigation-pt4/Inventory/Inventory/Navigation.swift b/0225-composable-navigation-pt4/Inventory/Inventory/Navigation.swift new file mode 100644 index 00000000..782dddcd --- /dev/null +++ b/0225-composable-navigation-pt4/Inventory/Inventory/Navigation.swift @@ -0,0 +1,270 @@ +import ComposableArchitecture +import SwiftUI +import SwiftUINavigation + +enum AlertAction { + case dismiss + case presented(Action) +} +extension AlertAction: Equatable where Action: Equatable {} + +enum ConfirmationDialogAction { + case dismiss + case presented(Action) +} +extension ConfirmationDialogAction: Equatable where Action: Equatable {} + +enum SheetAction { + case dismiss + case presented(Action) +} +extension SheetAction: Equatable where Action: Equatable {} + +extension Reducer { + func sheet( + state stateKeyPath: WritableKeyPath, + action actionCasePath: CasePath>, + @ReducerBuilder child: () -> some Reducer + ) -> some ReducerOf { + let child = child() + return Reduce { state, action in + switch (state[keyPath: stateKeyPath], actionCasePath.extract(from: action)) { + + case (_, .none): + let childStateBefore = state[keyPath: stateKeyPath] + let effects = self.reduce(into: &state, action: action) + let childStateAfter = state[keyPath: stateKeyPath] + let cancelEffect: Effect + if let childStateBefore, childStateBefore.id != childStateAfter?.id { + cancelEffect = .cancel(id: childStateBefore.id) + } else { + cancelEffect = .none + } + let onFirstAppearEffect: Effect + if let childStateAfter, childStateAfter.id != childStateBefore?.id { + onFirstAppearEffect = .run { send in + do { + try await withTaskCancellation(id: DismissID(id: childStateAfter.id)) { + try await Task.never() + } + } catch is CancellationError { + await send(actionCasePath.embed(.dismiss)) + } + } + .cancellable(id: childStateAfter.id) + } else { + onFirstAppearEffect = .none + } + return .merge( + effects, + cancelEffect, + onFirstAppearEffect + ) + + case (.none, .some(.presented)), (.none, .some(.dismiss)): + XCTFail("A sheet action was sent while child state was nil.") + return self.reduce(into: &state, action: action) + + case (.some(var childState), .some(.presented(let childAction))): + let childEffects = child + .dependency(\.dismiss, DismissEffect { [id = childState.id] in + Task.cancel(id: DismissID(id: id)) + }) + .reduce(into: &childState, action: childAction) + state[keyPath: stateKeyPath] = childState + let effects = self.reduce(into: &state, action: action) + return .merge( + childEffects + .map { actionCasePath.embed(.presented($0)) } + .cancellable(id: childState.id), + effects + ) + + case let (.some(childState), .some(.dismiss)): + let effects = self.reduce(into: &state, action: action) + state[keyPath: stateKeyPath] = nil + return .merge( + effects, + .cancel(id: childState.id) + ) + } + } + } +} + +private struct DismissID: Hashable { let id: AnyHashable } + +struct DismissEffect: Sendable { + private var dismiss: @Sendable () async -> Void + func callAsFunction() async { + await self.dismiss() + } +} +extension DismissEffect { + init(_ dismiss: @escaping @Sendable () async -> Void) { + self.dismiss = dismiss + } +} +extension DismissEffect: DependencyKey { + static var liveValue = DismissEffect(dismiss: {}) + static var testValue = DismissEffect(dismiss: {}) +} +extension DependencyValues { + var dismiss: DismissEffect { + get { self[DismissEffect.self] } + set { self[DismissEffect.self] = newValue } + } +} +// self.dismiss.dismiss() +// self.dismiss() + +extension View { + func sheet( + store: Store>, + @ViewBuilder child: @escaping (Store) -> some View + ) -> some View { + WithViewStore(store, observe: { $0?.id }) { viewStore in + self.sheet( + item: Binding( + get: { viewStore.state.map { Identified($0, id: \.self) } }, + set: { newState in + if viewStore.state != nil { + viewStore.send(.dismiss) + } + } + ) + ) { _ in + IfLetStore( + store.scope( + state: returningLastNonNilValue { $0 }, + action: SheetAction.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 Reducer { + func alert( + state alertKeyPath: WritableKeyPath?>, + action alertCasePath: CasePath> + ) -> some ReducerOf { + Reduce { state, action in + let effects = self.reduce(into: &state, action: action) + if alertCasePath ~= action { + state[keyPath: alertKeyPath] = nil + } + return effects + } + } +} + +extension Reducer { + func confirmationDialog( + state alertKeyPath: WritableKeyPath?>, + action alertCasePath: CasePath> + ) -> some ReducerOf { + Reduce { state, action in + let effects = self.reduce(into: &state, action: action) + if alertCasePath ~= action { + state[keyPath: alertKeyPath] = nil + } + return effects + } + } +} + +extension View { + func alert( + store: Store?, AlertAction> + ) -> 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?, ConfirmationDialogAction> + ) -> 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)) + } + } + } + } +} + +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/0225-composable-navigation-pt4/Inventory/Inventory/Preview Content/Preview Assets.xcassets/Contents.json b/0225-composable-navigation-pt4/Inventory/Inventory/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0225-composable-navigation-pt4/Inventory/Inventory/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0225-composable-navigation-pt4/Inventory/Inventory/ThirdTab.swift b/0225-composable-navigation-pt4/Inventory/Inventory/ThirdTab.swift new file mode 100644 index 00000000..f8fde492 --- /dev/null +++ b/0225-composable-navigation-pt4/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/0225-composable-navigation-pt4/Inventory/Inventory/Vanilla.swift b/0225-composable-navigation-pt4/Inventory/Inventory/Vanilla.swift new file mode 100644 index 00000000..1e28c060 --- /dev/null +++ b/0225-composable-navigation-pt4/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/0225-composable-navigation-pt4/Inventory/InventoryTests/InventoryTests.swift b/0225-composable-navigation-pt4/Inventory/InventoryTests/InventoryTests.swift new file mode 100644 index 00000000..39b0b0a3 --- /dev/null +++ b/0225-composable-navigation-pt4/Inventory/InventoryTests/InventoryTests.swift @@ -0,0 +1,210 @@ +import ComposableArchitecture +import XCTest +@testable import Inventory + +@MainActor +final class InventoryTests: XCTestCase { + func testGoToInventory() async { + let store = TestStore( + initialState: AppFeature.State(), + reducer: AppFeature() + ) + + await store.send(.firstTab(.goToInventoryButtonTapped)) + await store.receive(.firstTab(.delegate(.switchToInventoryTab))) { + $0.selectedTab = .inventory + } + } + + func testDelete() async { + let item = Item.headphones + + let store = TestStore( + initialState: InventoryFeature.State(items: [item]), + reducer: InventoryFeature() + ) + + await store.send(.deleteButtonTapped(id: item.id)) { + $0.alert = .delete(item: item) + } + await store.send(.alert(.presented(.confirmDeletion(id: item.id)))) { + $0.alert = nil + $0.items = [] + } + } + + func testDuplicate() async { + let item = Item.headphones + + let store = TestStore( + initialState: InventoryFeature.State(items: [item]), + reducer: InventoryFeature() + ) { + $0.uuid = .incrementing + } + + await store.send(.duplicateButtonTapped(id: item.id)) { + $0.confirmationDialog = .duplicate(item: item) + } + await store.send(.confirmationDialog(.presented(.confirmDuplication(id: item.id)))) { + $0.confirmationDialog = nil + $0.items = [ + Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "Headphones", + color: .blue, + status: .inStock(quantity: 20) + ), + item + ] + } + } + + func testAddItem() async { + let store = TestStore( + initialState: InventoryFeature.State(), + reducer: InventoryFeature() + ) { + $0.uuid = .incrementing + } + + await store.send(.addButtonTapped) { + $0.addItem = ItemFormFeature.State( + item: Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "", + status: .inStock(quantity: 1) + ) + ) + } + + await store.send(.addItem(.presented(.set(\.$item.name, "Headphones")))) { + $0.addItem?.item.name = "Headphones" + } + + await store.send(.confirmAddItemButtonTapped) { + $0.addItem = nil + $0.items = [ + Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "Headphones", + status: .inStock(quantity: 1) + ) + ] + } + } + + func testAddItem_Timer() async { + let clock = TestClock() + let store = TestStore( + initialState: InventoryFeature.State(), + reducer: InventoryFeature() + ) { + $0.continuousClock = clock + $0.uuid = .incrementing + } + + await store.send(.addButtonTapped) { + $0.addItem = ItemFormFeature.State( + item: Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "", + status: .inStock(quantity: 1) + ) + ) + } + + await store.send(.addItem(.presented(.set(\.$item.name, "Headphones")))) { + $0.addItem?.item.name = "Headphones" + } + + /*let toggleTask = */await store.send(.addItem(.presented(.set(\.$isTimerOn, true)))) { + $0.addItem?.isTimerOn = true + } + + // await store.send(.addItem(.presented(.set(\.$isTimerOn, false)))) { + // $0.addItem?.isTimerOn = false + // } + + await store.send(.confirmAddItemButtonTapped) { + $0.addItem = nil + $0.items = [ + Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "Headphones", + status: .inStock(quantity: 1) + ) + ] + } + + // await toggleTask.cancel() + } + + + func testAddItem_Timer_Dismissal() async { + let clock = TestClock() + let store = TestStore( + initialState: InventoryFeature.State(), + reducer: InventoryFeature() + ) { + $0.continuousClock = clock + $0.uuid = .incrementing + } + + await store.send(.addButtonTapped) { + $0.addItem = ItemFormFeature.State( + item: Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "", + status: .inStock(quantity: 1) + ) + ) + } + + await store.send(.addItem(.presented(.set(\.$isTimerOn, true)))) { + $0.addItem?.isTimerOn = true + } + await clock.advance(by: .seconds(3)) + await store.receive(.addItem(.presented(.timerTick))) { + $0.addItem?.item.status = .inStock(quantity: 2) + } + await store.receive(.addItem(.presented(.timerTick))) { + $0.addItem?.item.status = .inStock(quantity: 3) + } + await store.receive(.addItem(.presented(.timerTick))) { + $0.addItem?.item.status = .inStock(quantity: 4) + } + await store.receive(.addItem(.dismiss)) { + $0.addItem = nil + } + } + + func testAddItem_Timer_Dismissal_NonExhaustive() async { + let store = TestStore( + initialState: InventoryFeature.State(), + reducer: InventoryFeature() + ) { + $0.continuousClock = ImmediateClock() + $0.uuid = .incrementing + } + + store.exhaustivity = .off(showSkippedAssertions: true) + + await store.send(.addButtonTapped) { + $0.addItem = ItemFormFeature.State( + item: Item( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + name: "", + status: .inStock(quantity: 1) + ) + ) + } + + await store.send(.addItem(.presented(.set(\.$isTimerOn, true)))) { + $0.addItem?.isTimerOn = true + } + await store.receive(.addItem(.dismiss)) { + $0.addItem = nil + } + } +} diff --git a/0225-composable-navigation-pt4/Inventory/InventoryTests/VanillaTests.swift b/0225-composable-navigation-pt4/Inventory/InventoryTests/VanillaTests.swift new file mode 100644 index 00000000..af07fb5f --- /dev/null +++ b/0225-composable-navigation-pt4/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/0225-composable-navigation-pt4/README.md b/0225-composable-navigation-pt4/README.md new file mode 100644 index 00000000..21c45eb9 --- /dev/null +++ b/0225-composable-navigation-pt4/README.md @@ -0,0 +1,5 @@ +## [Point-Free](https://www.pointfree.co) + +> #### This directory contains code from Point-Free Episode: [Composable Navigation: Effect Cancellation](https://www.pointfree.co/episodes/ep225-composable-navigation-behavior) +> +> We add superpowers to the navigation tools of the Composable Architecture, including automatically cancelling a child feature’s effects upon dismissal, and even letting child features dismiss themselves! Plus, we look at how “non-exhaustive” testing simplifies navigation-based tests. diff --git a/README.md b/README.md index 85f824da..ef05d04f 100644 --- a/README.md +++ b/README.md @@ -226,3 +226,4 @@ This repository is the home of code written on episodes of [Point-Free](https:// 1. [Composable Navigation: Tabs](0222-composable-navigation-pt1) 1. [Composable Navigation: Alerts & Dialogs](0223-composable-navigation-pt2) 1. [Composable Navigation: Sheets](0224-composable-navigation-pt3) +1. [Composable Navigation: Effect Cancellation](0225-composable-navigation-pt4)