diff --git a/ComposableArchitecture.xcworkspace/contents.xcworkspacedata b/ComposableArchitecture.xcworkspace/contents.xcworkspacedata index e69aa80100e3..97dfeccdfb1e 100644 --- a/ComposableArchitecture.xcworkspace/contents.xcworkspacedata +++ b/ComposableArchitecture.xcworkspace/contents.xcworkspacedata @@ -7,6 +7,9 @@ + + diff --git a/Examples/LocationManager/LocationManager.xcodeproj/project.pbxproj b/Examples/LocationManager/LocationManager.xcodeproj/project.pbxproj new file mode 100644 index 000000000000..b6b754921757 --- /dev/null +++ b/Examples/LocationManager/LocationManager.xcodeproj/project.pbxproj @@ -0,0 +1,511 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 52; + objects = { + +/* Begin PBXBuildFile section */ + CA17CC0F24720BBA00BDDF11 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA17CC0E24720BBA00BDDF11 /* SceneDelegate.swift */; }; + CA17CC1124720BBA00BDDF11 /* LocationManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA17CC1024720BBA00BDDF11 /* LocationManagerView.swift */; }; + CA17CC1324720BBB00BDDF11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CA17CC1224720BBB00BDDF11 /* Assets.xcassets */; }; + CA17CC2424720BBB00BDDF11 /* LocationManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA17CC2324720BBB00BDDF11 /* LocationManagerTests.swift */; }; + CA17CC3924720BEB00BDDF11 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = CA17CC3824720BEB00BDDF11 /* ComposableArchitecture */; }; + CA17CC502472177400BDDF11 /* LocationManagerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA17CC462472177300BDDF11 /* LocationManagerModels.swift */; }; + CA17CC512472177400BDDF11 /* MockLocationManagerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA17CC472472177300BDDF11 /* MockLocationManagerClient.swift */; }; + CA17CC522472177400BDDF11 /* LocationManagerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA17CC482472177300BDDF11 /* LocationManagerClient.swift */; }; + CA17CC532472177400BDDF11 /* LiveLocationManagerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA17CC492472177300BDDF11 /* LiveLocationManagerClient.swift */; }; + CA17CC542472177400BDDF11 /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA17CC4A2472177300BDDF11 /* MapView.swift */; }; + CA17CC552472177400BDDF11 /* LocalSearchClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA17CC4C2472177400BDDF11 /* LocalSearchClient.swift */; }; + CA17CC562472177400BDDF11 /* LocalSearchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA17CC4D2472177400BDDF11 /* LocalSearchModels.swift */; }; + CA17CC572472177400BDDF11 /* LiveLocalSearchClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA17CC4E2472177400BDDF11 /* LiveLocalSearchClient.swift */; }; + CA17CC582472177400BDDF11 /* MockLocalSearchClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA17CC4F2472177400BDDF11 /* MockLocalSearchClient.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + CA17CC2024720BBB00BDDF11 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = CA17CC0124720BBA00BDDF11 /* Project object */; + proxyType = 1; + remoteGlobalIDString = CA17CC0824720BBA00BDDF11; + remoteInfo = LocationManager; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + CA17CC0924720BBA00BDDF11 /* LocationManager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LocationManager.app; sourceTree = BUILT_PRODUCTS_DIR; }; + CA17CC0E24720BBA00BDDF11 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + CA17CC1024720BBA00BDDF11 /* LocationManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationManagerView.swift; sourceTree = ""; }; + CA17CC1224720BBB00BDDF11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + CA17CC1A24720BBB00BDDF11 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + CA17CC1F24720BBB00BDDF11 /* LocationManagerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LocationManagerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + CA17CC2324720BBB00BDDF11 /* LocationManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationManagerTests.swift; sourceTree = ""; }; + CA17CC462472177300BDDF11 /* LocationManagerModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocationManagerModels.swift; sourceTree = ""; }; + CA17CC472472177300BDDF11 /* MockLocationManagerClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockLocationManagerClient.swift; sourceTree = ""; }; + CA17CC482472177300BDDF11 /* LocationManagerClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocationManagerClient.swift; sourceTree = ""; }; + CA17CC492472177300BDDF11 /* LiveLocationManagerClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveLocationManagerClient.swift; sourceTree = ""; }; + CA17CC4A2472177300BDDF11 /* MapView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MapView.swift; sourceTree = ""; }; + CA17CC4C2472177400BDDF11 /* LocalSearchClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalSearchClient.swift; sourceTree = ""; }; + CA17CC4D2472177400BDDF11 /* LocalSearchModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalSearchModels.swift; sourceTree = ""; }; + CA17CC4E2472177400BDDF11 /* LiveLocalSearchClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveLocalSearchClient.swift; sourceTree = ""; }; + CA17CC4F2472177400BDDF11 /* MockLocalSearchClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockLocalSearchClient.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + CA17CC0624720BBA00BDDF11 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CA17CC3924720BEB00BDDF11 /* ComposableArchitecture in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CA17CC1C24720BBB00BDDF11 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + CA17CC0024720BBA00BDDF11 = { + isa = PBXGroup; + children = ( + CA17CC0B24720BBA00BDDF11 /* LocationManager */, + CA17CC2224720BBB00BDDF11 /* LocationManagerTests */, + CA17CC0A24720BBA00BDDF11 /* Products */, + CA17CC3724720BEB00BDDF11 /* Frameworks */, + ); + sourceTree = ""; + }; + CA17CC0A24720BBA00BDDF11 /* Products */ = { + isa = PBXGroup; + children = ( + CA17CC0924720BBA00BDDF11 /* LocationManager.app */, + CA17CC1F24720BBB00BDDF11 /* LocationManagerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + CA17CC0B24720BBA00BDDF11 /* LocationManager */ = { + isa = PBXGroup; + children = ( + CA17CC1A24720BBB00BDDF11 /* Info.plist */, + CA17CC1024720BBA00BDDF11 /* LocationManagerView.swift */, + CA17CC4A2472177300BDDF11 /* MapView.swift */, + CA17CC0E24720BBA00BDDF11 /* SceneDelegate.swift */, + CA17CC1224720BBB00BDDF11 /* Assets.xcassets */, + CA17CC4B2472177400BDDF11 /* LocalSearchClient */, + CA17CC452472177300BDDF11 /* LocationManagerClient */, + ); + path = LocationManager; + sourceTree = ""; + }; + CA17CC2224720BBB00BDDF11 /* LocationManagerTests */ = { + isa = PBXGroup; + children = ( + CA17CC2324720BBB00BDDF11 /* LocationManagerTests.swift */, + ); + path = LocationManagerTests; + sourceTree = ""; + }; + CA17CC3724720BEB00BDDF11 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + CA17CC452472177300BDDF11 /* LocationManagerClient */ = { + isa = PBXGroup; + children = ( + CA17CC492472177300BDDF11 /* LiveLocationManagerClient.swift */, + CA17CC482472177300BDDF11 /* LocationManagerClient.swift */, + CA17CC462472177300BDDF11 /* LocationManagerModels.swift */, + CA17CC472472177300BDDF11 /* MockLocationManagerClient.swift */, + ); + path = LocationManagerClient; + sourceTree = ""; + }; + CA17CC4B2472177400BDDF11 /* LocalSearchClient */ = { + isa = PBXGroup; + children = ( + CA17CC4E2472177400BDDF11 /* LiveLocalSearchClient.swift */, + CA17CC4C2472177400BDDF11 /* LocalSearchClient.swift */, + CA17CC4D2472177400BDDF11 /* LocalSearchModels.swift */, + CA17CC4F2472177400BDDF11 /* MockLocalSearchClient.swift */, + ); + path = LocalSearchClient; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + CA17CC0824720BBA00BDDF11 /* LocationManager */ = { + isa = PBXNativeTarget; + buildConfigurationList = CA17CC2824720BBB00BDDF11 /* Build configuration list for PBXNativeTarget "LocationManager" */; + buildPhases = ( + CA17CC0524720BBA00BDDF11 /* Sources */, + CA17CC0624720BBA00BDDF11 /* Frameworks */, + CA17CC0724720BBA00BDDF11 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = LocationManager; + packageProductDependencies = ( + CA17CC3824720BEB00BDDF11 /* ComposableArchitecture */, + ); + productName = LocationManager; + productReference = CA17CC0924720BBA00BDDF11 /* LocationManager.app */; + productType = "com.apple.product-type.application"; + }; + CA17CC1E24720BBB00BDDF11 /* LocationManagerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = CA17CC2B24720BBB00BDDF11 /* Build configuration list for PBXNativeTarget "LocationManagerTests" */; + buildPhases = ( + CA17CC1B24720BBB00BDDF11 /* Sources */, + CA17CC1C24720BBB00BDDF11 /* Frameworks */, + CA17CC1D24720BBB00BDDF11 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + CA17CC2124720BBB00BDDF11 /* PBXTargetDependency */, + ); + name = LocationManagerTests; + productName = LocationManagerTests; + productReference = CA17CC1F24720BBB00BDDF11 /* LocationManagerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + CA17CC0124720BBA00BDDF11 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1140; + LastUpgradeCheck = 1140; + ORGANIZATIONNAME = "Brandon Williams"; + TargetAttributes = { + CA17CC0824720BBA00BDDF11 = { + CreatedOnToolsVersion = 11.4.1; + }; + CA17CC1E24720BBB00BDDF11 = { + CreatedOnToolsVersion = 11.4.1; + TestTargetID = CA17CC0824720BBA00BDDF11; + }; + }; + }; + buildConfigurationList = CA17CC0424720BBA00BDDF11 /* Build configuration list for PBXProject "LocationManager" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = CA17CC0024720BBA00BDDF11; + productRefGroup = CA17CC0A24720BBA00BDDF11 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + CA17CC0824720BBA00BDDF11 /* LocationManager */, + CA17CC1E24720BBB00BDDF11 /* LocationManagerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + CA17CC0724720BBA00BDDF11 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CA17CC1324720BBB00BDDF11 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CA17CC1D24720BBB00BDDF11 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + CA17CC0524720BBA00BDDF11 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CA17CC542472177400BDDF11 /* MapView.swift in Sources */, + CA17CC0F24720BBA00BDDF11 /* SceneDelegate.swift in Sources */, + CA17CC552472177400BDDF11 /* LocalSearchClient.swift in Sources */, + CA17CC1124720BBA00BDDF11 /* LocationManagerView.swift in Sources */, + CA17CC562472177400BDDF11 /* LocalSearchModels.swift in Sources */, + CA17CC522472177400BDDF11 /* LocationManagerClient.swift in Sources */, + CA17CC502472177400BDDF11 /* LocationManagerModels.swift in Sources */, + CA17CC512472177400BDDF11 /* MockLocationManagerClient.swift in Sources */, + CA17CC582472177400BDDF11 /* MockLocalSearchClient.swift in Sources */, + CA17CC572472177400BDDF11 /* LiveLocalSearchClient.swift in Sources */, + CA17CC532472177400BDDF11 /* LiveLocationManagerClient.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CA17CC1B24720BBB00BDDF11 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CA17CC2424720BBB00BDDF11 /* LocationManagerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + CA17CC2124720BBB00BDDF11 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = CA17CC0824720BBA00BDDF11 /* LocationManager */; + targetProxy = CA17CC2024720BBB00BDDF11 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + CA17CC2624720BBB00BDDF11 /* 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++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_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 = 13.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; + }; + CA17CC2724720BBB00BDDF11 /* 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++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_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 = 13.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; + }; + CA17CC2924720BBB00BDDF11 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + ENABLE_PREVIEWS = YES; + INFOPLIST_FILE = LocationManager/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.LocationManager; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + CA17CC2A24720BBB00BDDF11 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + ENABLE_PREVIEWS = YES; + INFOPLIST_FILE = LocationManager/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.LocationManager; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + CA17CC2C24720BBB00BDDF11 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = LocationManager/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.LocationManagerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LocationManager.app/LocationManager"; + }; + name = Debug; + }; + CA17CC2D24720BBB00BDDF11 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = LocationManager/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.LocationManagerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LocationManager.app/LocationManager"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + CA17CC0424720BBA00BDDF11 /* Build configuration list for PBXProject "LocationManager" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CA17CC2624720BBB00BDDF11 /* Debug */, + CA17CC2724720BBB00BDDF11 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + CA17CC2824720BBB00BDDF11 /* Build configuration list for PBXNativeTarget "LocationManager" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CA17CC2924720BBB00BDDF11 /* Debug */, + CA17CC2A24720BBB00BDDF11 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + CA17CC2B24720BBB00BDDF11 /* Build configuration list for PBXNativeTarget "LocationManagerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CA17CC2C24720BBB00BDDF11 /* Debug */, + CA17CC2D24720BBB00BDDF11 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + CA17CC3824720BEB00BDDF11 /* ComposableArchitecture */ = { + isa = XCSwiftPackageProductDependency; + productName = ComposableArchitecture; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = CA17CC0124720BBA00BDDF11 /* Project object */; +} diff --git a/Examples/LocationManager/LocationManager.xcodeproj/xcshareddata/xcschemes/LocationManager.xcscheme b/Examples/LocationManager/LocationManager.xcodeproj/xcshareddata/xcschemes/LocationManager.xcscheme new file mode 100644 index 000000000000..21e8243eefaf --- /dev/null +++ b/Examples/LocationManager/LocationManager.xcodeproj/xcshareddata/xcschemes/LocationManager.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/LocationManager/LocationManager/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/Examples/LocationManager/LocationManager/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 000000000000..186b90e3fe4c Binary files /dev/null and b/Examples/LocationManager/LocationManager/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/Examples/LocationManager/LocationManager/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/LocationManager/LocationManager/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000000..e4b96da01595 --- /dev/null +++ b/Examples/LocationManager/LocationManager/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,99 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "AppIcon.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/LocationManager/LocationManager/Assets.xcassets/Contents.json b/Examples/LocationManager/LocationManager/Assets.xcassets/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/Examples/LocationManager/LocationManager/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/LocationManager/LocationManager/Info.plist b/Examples/LocationManager/LocationManager/Info.plist new file mode 100644 index 000000000000..2b21583ca54b --- /dev/null +++ b/Examples/LocationManager/LocationManager/Info.plist @@ -0,0 +1,62 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UILaunchStoryboardName + LaunchScreen + NSLocationWhenInUseUsageDescription + To show points of interests near you. + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Examples/LocationManager/LocationManager/LocalSearchClient/LiveLocalSearchClient.swift b/Examples/LocationManager/LocationManager/LocalSearchClient/LiveLocalSearchClient.swift new file mode 100644 index 000000000000..a2963689b333 --- /dev/null +++ b/Examples/LocationManager/LocationManager/LocalSearchClient/LiveLocalSearchClient.swift @@ -0,0 +1,23 @@ +import Combine +import ComposableArchitecture +import MapKit + +extension LocalSearchClient { + static let live = LocalSearchClient( + search: { request in + Effect.future { callback in + MKLocalSearch(request: request).start { response, error in + switch (response, error) { + case let (.some(response), _): + callback(.success(LocalSearchResponse(response: response))) + + case (_, .some): + callback(.failure(LocalSearchClient.Error())) + + case (.none, .none): + fatalError("It should not be possible that response and error are both nil.") + } + } + } + }) +} diff --git a/Examples/LocationManager/LocationManager/LocalSearchClient/LocalSearchClient.swift b/Examples/LocationManager/LocationManager/LocalSearchClient/LocalSearchClient.swift new file mode 100644 index 000000000000..4b3575e0eb24 --- /dev/null +++ b/Examples/LocationManager/LocationManager/LocalSearchClient/LocalSearchClient.swift @@ -0,0 +1,10 @@ +import ComposableArchitecture +import MapKit + +struct LocalSearchClient { + var search: (MKLocalSearch.Request) -> Effect + + struct Error: Swift.Error, Equatable { + init() {} + } +} diff --git a/Examples/LocationManager/LocationManager/LocalSearchClient/LocalSearchModels.swift b/Examples/LocationManager/LocationManager/LocalSearchClient/LocalSearchModels.swift new file mode 100644 index 000000000000..499593ee4b05 --- /dev/null +++ b/Examples/LocationManager/LocationManager/LocalSearchClient/LocalSearchModels.swift @@ -0,0 +1,202 @@ +import MapKit + +struct LocalSearchResponse: Equatable { + var boundingRegion: MKCoordinateRegion + var mapItems: [MapItem] + + init( + response: MKLocalSearch.Response + ) { + self.boundingRegion = response.boundingRegion + self.mapItems = response.mapItems.map(MapItem.init(rawValue:)) + } + + init( + boundingRegion: MKCoordinateRegion, + mapItems: [MapItem] + ) { + self.boundingRegion = boundingRegion + self.mapItems = mapItems + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.boundingRegion.center.latitude == rhs.boundingRegion.center.latitude + && lhs.boundingRegion.center.longitude == rhs.boundingRegion.center.longitude + && lhs.boundingRegion.span.latitudeDelta == rhs.boundingRegion.span.latitudeDelta + && lhs.boundingRegion.span.longitudeDelta == rhs.boundingRegion.span.longitudeDelta + && lhs.mapItems == rhs.mapItems + } +} + +struct MapItem: Equatable { + var isCurrentLocation: Bool + var name: String? + var phoneNumber: String? + var placemark: Placemark + var pointOfInterestCategory: MKPointOfInterestCategory? + var timeZone: TimeZone? + var url: URL? + + init(rawValue: MKMapItem) { + self.isCurrentLocation = rawValue.isCurrentLocation + self.name = rawValue.name + self.placemark = Placemark(rawValue: rawValue.placemark) + self.phoneNumber = rawValue.phoneNumber + self.pointOfInterestCategory = rawValue.pointOfInterestCategory + self.timeZone = rawValue.timeZone + self.url = rawValue.url + } + + init( + isCurrentLocation: Bool = false, + name: String? = nil, + phoneNumber: String? = nil, + placemark: Placemark, + pointOfInterestCategory: MKPointOfInterestCategory? = nil, + timeZone: TimeZone? = nil, + url: URL? = nil + ) { + self.isCurrentLocation = isCurrentLocation + self.name = name + self.phoneNumber = phoneNumber + self.placemark = placemark + self.pointOfInterestCategory = pointOfInterestCategory + self.timeZone = timeZone + self.url = url + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.isCurrentLocation == rhs.isCurrentLocation + && lhs.name == rhs.name + && lhs.phoneNumber == rhs.phoneNumber + && lhs.placemark.coordinate.latitude == rhs.placemark.coordinate.latitude + && lhs.placemark.coordinate.longitude + == rhs.placemark.coordinate.longitude + && lhs.placemark.countryCode == rhs.placemark.countryCode + && lhs.placemark.region == rhs.placemark.region + && lhs.placemark.subtitle == rhs.placemark.subtitle + && lhs.placemark.title == rhs.placemark.title + && lhs.placemark.name == rhs.placemark.name + && lhs.placemark.thoroughfare == rhs.placemark.thoroughfare + && lhs.placemark.subThoroughfare == rhs.placemark.subThoroughfare + && lhs.placemark.locality == rhs.placemark.locality + && lhs.placemark.subLocality == rhs.placemark.subLocality + && lhs.placemark.administrativeArea == rhs.placemark.administrativeArea + && lhs.placemark.subAdministrativeArea + == lhs.placemark.subAdministrativeArea + && lhs.placemark.postalCode == rhs.placemark.postalCode + && lhs.placemark.isoCountryCode == rhs.placemark.isoCountryCode + && lhs.placemark.country == rhs.placemark.country + && lhs.placemark.inlandWater == rhs.placemark.inlandWater + && lhs.placemark.ocean == rhs.placemark.ocean + && lhs.placemark.areasOfInterest == rhs.placemark.areasOfInterest + && lhs.pointOfInterestCategory == rhs.pointOfInterestCategory + && lhs.timeZone == rhs.timeZone + && lhs.url == rhs.url + } +} + +struct Placemark: Equatable { + var administrativeArea: String? + var areasOfInterest: [String]? + var coordinate: CLLocationCoordinate2D + var country: String? + var countryCode: String? + var inlandWater: String? + var isoCountryCode: String? + var locality: String? + var name: String? + var ocean: String? + var postalCode: String? + var region: CLRegion? + var subAdministrativeArea: String? + var subLocality: String? + var subThoroughfare: String? + var subtitle: String? + var thoroughfare: String? + var title: String? + + init(rawValue: MKPlacemark) { + self.administrativeArea = rawValue.administrativeArea + self.areasOfInterest = rawValue.areasOfInterest + self.coordinate = rawValue.coordinate + self.country = rawValue.country + self.countryCode = rawValue.countryCode + self.inlandWater = rawValue.inlandWater + self.isoCountryCode = rawValue.isoCountryCode + self.locality = rawValue.locality + self.name = rawValue.name + self.ocean = rawValue.ocean + self.postalCode = rawValue.postalCode + self.region = rawValue.region + self.subAdministrativeArea = rawValue.subAdministrativeArea + self.subLocality = rawValue.subLocality + self.subThoroughfare = rawValue.subThoroughfare + self.subtitle = + rawValue.responds(to: #selector(getter:MKPlacemark.subtitle)) ? rawValue.subtitle : nil + self.thoroughfare = rawValue.thoroughfare + self.title = rawValue.responds(to: #selector(getter:MKPlacemark.title)) ? rawValue.title : nil + } + + init( + administrativeArea: String? = nil, + areasOfInterest: [String]? = nil, + coordinate: CLLocationCoordinate2D = .init(), + country: String? = nil, + countryCode: String? = nil, + inlandWater: String? = nil, + isoCountryCode: String? = nil, + locality: String? = nil, + name: String? = nil, + ocean: String? = nil, + postalCode: String? = nil, + region: CLRegion? = nil, + subAdministrativeArea: String? = nil, + subLocality: String? = nil, + subThoroughfare: String? = nil, + subtitle: String? = nil, + thoroughfare: String? = nil, + title: String? = nil + ) { + self.administrativeArea = administrativeArea + self.areasOfInterest = areasOfInterest + self.coordinate = coordinate + self.country = country + self.countryCode = countryCode + self.inlandWater = inlandWater + self.isoCountryCode = isoCountryCode + self.locality = locality + self.name = name + self.ocean = ocean + self.postalCode = postalCode + self.region = region + self.subAdministrativeArea = subAdministrativeArea + self.subLocality = subLocality + self.subThoroughfare = subThoroughfare + self.subtitle = subtitle + self.thoroughfare = thoroughfare + self.title = title + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.administrativeArea == lhs.administrativeArea + && lhs.areasOfInterest == lhs.areasOfInterest + && lhs.coordinate.latitude == rhs.coordinate.latitude + && lhs.coordinate.longitude == rhs.coordinate.longitude + && lhs.country == lhs.country + && lhs.countryCode == rhs.countryCode + && lhs.inlandWater == lhs.inlandWater + && lhs.isoCountryCode == lhs.isoCountryCode + && lhs.locality == lhs.locality + && lhs.name == rhs.name + && lhs.ocean == lhs.ocean + && lhs.postalCode == lhs.postalCode + && lhs.region == rhs.region + && lhs.subAdministrativeArea == lhs.subAdministrativeArea + && lhs.subLocality == lhs.subLocality + && lhs.subThoroughfare == lhs.subThoroughfare + && lhs.subtitle == rhs.subtitle + && lhs.thoroughfare == lhs.thoroughfare + && lhs.title == rhs.title + } +} diff --git a/Examples/LocationManager/LocationManager/LocalSearchClient/MockLocalSearchClient.swift b/Examples/LocationManager/LocationManager/LocalSearchClient/MockLocalSearchClient.swift new file mode 100644 index 000000000000..643a4785099c --- /dev/null +++ b/Examples/LocationManager/LocationManager/LocalSearchClient/MockLocalSearchClient.swift @@ -0,0 +1,12 @@ +import ComposableArchitecture +import MapKit + +extension LocalSearchClient { + static func mock( + search: @escaping (MKLocalSearch.Request) -> Effect< + LocalSearchResponse, LocalSearchClient.Error + > = { _ in fatalError() } + ) -> Self { + Self(search: search) + } +} diff --git a/Examples/LocationManager/LocationManager/LocationManagerClient/LiveLocationManagerClient.swift b/Examples/LocationManager/LocationManager/LocationManagerClient/LiveLocationManagerClient.swift new file mode 100644 index 000000000000..3889db078d1c --- /dev/null +++ b/Examples/LocationManager/LocationManager/LocationManagerClient/LiveLocationManagerClient.swift @@ -0,0 +1,80 @@ +import Combine +import ComposableArchitecture +import CoreLocation + +extension LocationManagerClient { + static let live = LocationManagerClient( + authorizationStatus: CLLocationManager.authorizationStatus, + create: { id in + Effect.run { callback in + let manager = CLLocationManager() + let delegate = LocationManagerDelegate( + didChangeAuthorization: { + callback.send(.didChangeAuthorization($0)) + }, + didFailWithError: { _ in + callback.send(.didFailWithError(LocationManagerClient.Error())) + }, + didUpdateLocations: { + callback.send(.didUpdateLocations($0.map(Location.init(rawValue:)))) + }) + manager.delegate = delegate + + dependencies[id] = Dependencies( + locationManager: manager, + locationManagerDelegate: delegate + ) + + return AnyCancellable { + dependencies[id] = nil + } + } + }, + destroy: { id in + .fireAndForget { dependencies[id] = nil } + }, + locationServicesEnabled: CLLocationManager.locationServicesEnabled, + requestLocation: { id in + .fireAndForget { dependencies[id]?.locationManager.requestLocation() } + }, + requestWhenInUseAuthorization: { id in + .fireAndForget { dependencies[id]?.locationManager.requestWhenInUseAuthorization() } + }) +} + +private struct Dependencies { + let locationManager: CLLocationManager + let locationManagerDelegate: LocationManagerDelegate +} + +private var dependencies: [AnyHashable: Dependencies] = [:] + +private class LocationManagerDelegate: NSObject, CLLocationManagerDelegate { + var didChangeAuthorization: (CLAuthorizationStatus) -> Void + var didFailWithError: (Error) -> Void + var didUpdateLocations: ([CLLocation]) -> Void + + init( + didChangeAuthorization: @escaping (CLAuthorizationStatus) -> Void, + didFailWithError: @escaping (Error) -> Void, + didUpdateLocations: @escaping ([CLLocation]) -> Void + ) { + self.didChangeAuthorization = didChangeAuthorization + self.didFailWithError = didFailWithError + self.didUpdateLocations = didUpdateLocations + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + self.didFailWithError(error) + } + + func locationManager( + _ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus + ) { + self.didChangeAuthorization(status) + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + self.didUpdateLocations(locations) + } +} diff --git a/Examples/LocationManager/LocationManager/LocationManagerClient/LocationManagerClient.swift b/Examples/LocationManager/LocationManager/LocationManagerClient/LocationManagerClient.swift new file mode 100644 index 000000000000..2ee6e2edf478 --- /dev/null +++ b/Examples/LocationManager/LocationManager/LocationManagerClient/LocationManagerClient.swift @@ -0,0 +1,21 @@ +import Combine +import ComposableArchitecture +import CoreLocation + +struct LocationManagerClient { + var authorizationStatus: () -> CLAuthorizationStatus + var create: (_ id: AnyHashable) -> Effect + var destroy: (AnyHashable) -> Effect + var locationServicesEnabled: () -> Bool + var requestLocation: (AnyHashable) -> Effect + var requestWhenInUseAuthorization: (AnyHashable) -> Effect + + enum Action: Equatable { + case didChangeAuthorization(CLAuthorizationStatus) + case didCreate(locationServicesEnabled: Bool, authorizationStatus: CLAuthorizationStatus) + case didFailWithError(Error) + case didUpdateLocations([Location]) + } + + struct Error: Swift.Error, Equatable {} +} diff --git a/Examples/LocationManager/LocationManager/LocationManagerClient/LocationManagerModels.swift b/Examples/LocationManager/LocationManager/LocationManagerClient/LocationManagerModels.swift new file mode 100644 index 000000000000..2794151c3252 --- /dev/null +++ b/Examples/LocationManager/LocationManager/LocationManagerClient/LocationManagerModels.swift @@ -0,0 +1,103 @@ +import CoreLocation +import MapKit + +struct Location: Equatable { + var altitude: CLLocationDistance + var coordinate: CLLocationCoordinate2D + var course: CLLocationDirection + var courseAccuracy: CLLocationDirectionAccuracy + var floor: CLFloor? + var horizontalAccuracy: CLLocationAccuracy + var speed: CLLocationSpeed + var speedAccuracy: CLLocationSpeedAccuracy + var timestamp: Date + var verticalAccuracy: CLLocationAccuracy + + init( + altitude: CLLocationDistance, + coordinate: CLLocationCoordinate2D, + course: CLLocationDirection, + courseAccuracy: CLLocationDirectionAccuracy, + floor: CLFloor?, + horizontalAccuracy: CLLocationAccuracy, + speed: CLLocationSpeed, + speedAccuracy: CLLocationSpeedAccuracy, + timestamp: Date, + verticalAccuracy: CLLocationAccuracy + ) { + self.altitude = altitude + self.coordinate = coordinate + self.course = course + self.courseAccuracy = courseAccuracy + self.floor = floor + self.horizontalAccuracy = horizontalAccuracy + self.speed = speed + self.speedAccuracy = speedAccuracy + self.timestamp = timestamp + self.verticalAccuracy = verticalAccuracy + } + + static func == (lhs: Self, rhs: Self) -> Bool { + let equals = + lhs.altitude == rhs.altitude + && lhs.coordinate.latitude == rhs.coordinate.latitude + && lhs.coordinate.longitude == rhs.coordinate.longitude + && lhs.course == rhs.course + && lhs.floor == rhs.floor + && lhs.horizontalAccuracy == rhs.horizontalAccuracy + && lhs.speed == rhs.speed + && lhs.speedAccuracy == rhs.speedAccuracy + && lhs.timestamp == rhs.timestamp + && lhs.verticalAccuracy == rhs.verticalAccuracy + + if #available(iOS 13.4, *) { + return equals && lhs.courseAccuracy == rhs.courseAccuracy + } else { + return equals + } + } +} + +extension Location { + init(rawValue: CLLocation) { + self.altitude = rawValue.altitude + self.coordinate = rawValue.coordinate + self.course = rawValue.course + self.courseAccuracy = rawValue.courseAccuracy + self.floor = rawValue.floor + self.horizontalAccuracy = rawValue.horizontalAccuracy + self.speed = rawValue.speed + self.speedAccuracy = rawValue.speedAccuracy + self.timestamp = rawValue.timestamp + self.verticalAccuracy = rawValue.verticalAccuracy + } +} + +struct CoordinateRegion: Equatable { + var center: CLLocationCoordinate2D + var span: MKCoordinateSpan + + init( + center: CLLocationCoordinate2D, + span: MKCoordinateSpan + ) { + self.center = center + self.span = span + } + + init(coordinateRegion: MKCoordinateRegion) { + self.center = coordinateRegion.center + self.span = coordinateRegion.span + } + + var asMKCoordinateRegion: MKCoordinateRegion { + .init(center: self.center, span: self.span) + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.center.latitude == rhs.center.latitude + && lhs.center.longitude == rhs.center.longitude + && lhs.span.latitudeDelta == rhs.span.latitudeDelta + && lhs.span.longitudeDelta == rhs.span.longitudeDelta + } +} diff --git a/Examples/LocationManager/LocationManager/LocationManagerClient/MockLocationManagerClient.swift b/Examples/LocationManager/LocationManager/LocationManagerClient/MockLocationManagerClient.swift new file mode 100644 index 000000000000..649abb3845e3 --- /dev/null +++ b/Examples/LocationManager/LocationManager/LocationManagerClient/MockLocationManagerClient.swift @@ -0,0 +1,32 @@ +import ComposableArchitecture +import CoreLocation + +extension LocationManagerClient { + static func mock( + authorizationStatus: @escaping () -> CLAuthorizationStatus = { + fatalError("authorizationStatus is unimplemented in the mock") + }, + create: @escaping (_ id: AnyHashable) -> Effect = { _ in + fatalError("create is unimplemented in the mock.", file: #file, line: #line) + }, + destroy: @escaping (AnyHashable) -> Effect = { _ in fatalError() }, + locationServicesEnabled: @escaping () -> Bool = { + fatalError("locationServicesEnabled is unimplemented in the mock.") + }, + requestLocation: @escaping (AnyHashable) -> Effect = { _ in + fatalError("requestLocation is unimplemented in the mock.") + }, + requestWhenInUseAuthorization: @escaping (AnyHashable) -> Effect = { _ in + fatalError("requestWhenInUseAuthorization is unimplemented in the mock") + } + ) -> Self { + Self( + authorizationStatus: authorizationStatus, + create: create, + destroy: destroy, + locationServicesEnabled: locationServicesEnabled, + requestLocation: requestLocation, + requestWhenInUseAuthorization: requestWhenInUseAuthorization + ) + } +} diff --git a/Examples/LocationManager/LocationManager/LocationManagerView.swift b/Examples/LocationManager/LocationManager/LocationManagerView.swift new file mode 100644 index 000000000000..0aae8b70ca64 --- /dev/null +++ b/Examples/LocationManager/LocationManager/LocationManagerView.swift @@ -0,0 +1,355 @@ +import Combine +import ComposableArchitecture +import MapKit +import SwiftUI + +private let readMe = """ + This application demonstrates how to work with CLLocationManager for getting the user's current \ + location, and MKLocalSearch for searching points of interest on the map. + + Zoom into any part of the map and tap a category to search for points of interest nearby. The \ + markers are also updated live if you drag the map around. + """ + +struct PointOfInterest: Equatable { + let coordinate: CLLocationCoordinate2D + let subtitle: String? + let title: String? +} + +struct AppState: Equatable { + var alert: String? + var isRequestingCurrentLocation = false + var pointOfInterestCategory: MKPointOfInterestCategory? + var pointsOfInterest: [PointOfInterest] = [] + var region: CoordinateRegion? + + static let pointOfInterestCategories: [MKPointOfInterestCategory] = [ + .cafe, + .museum, + .nightlife, + .park, + .restaurant, + ] +} + +enum AppAction: Equatable { + case categoryButtonTapped(MKPointOfInterestCategory) + case currentLocationButtonTapped + case dismissAlertButtonTapped + case localSearchResponse(Result) + case locationManager(LocationManagerClient.Action) + case onAppear + case updateRegion(CoordinateRegion?) +} + +struct AppEnvironment { + var localSearch: LocalSearchClient + var locationManager: LocationManagerClient +} + +private struct LocationManagerId: Hashable {} +private struct CancelSearchId: Hashable {} + +let appReducer = Reducer { state, action, environment in + switch action { + case let .categoryButtonTapped(category): + guard category != state.pointOfInterestCategory else { + state.pointOfInterestCategory = nil + state.pointsOfInterest = [] + return .cancel(id: CancelSearchId()) + } + + state.pointOfInterestCategory = category + + let request = MKLocalSearch.Request() + request.pointOfInterestFilter = MKPointOfInterestFilter(including: [category]) + if let region = state.region?.asMKCoordinateRegion { + request.region = region + } + return environment.localSearch + .search(request) + .catchToEffect() + .map(AppAction.localSearchResponse) + .cancellable(id: CancelSearchId(), cancelInFlight: true) + + case .currentLocationButtonTapped: + guard environment.locationManager.locationServicesEnabled() else { + state.alert = "Location services are turned off." + return .none + } + + switch environment.locationManager.authorizationStatus() { + case .notDetermined: + state.isRequestingCurrentLocation = true + return environment.locationManager + .requestWhenInUseAuthorization(LocationManagerId()) + .fireAndForget() + + case .restricted: + state.alert = "Please give us access to your location in settings." + return .none + + case .denied: + state.alert = "Please give us access to your location in settings." + return .none + + case .authorizedAlways, .authorizedWhenInUse: + return environment.locationManager + .requestLocation(LocationManagerId()) + .fireAndForget() + + @unknown default: + return .none + } + + case .dismissAlertButtonTapped: + state.alert = nil + return .none + + case let .localSearchResponse(.success(response)): + state.pointsOfInterest = response.mapItems.map { item in + PointOfInterest( + coordinate: item.placemark.coordinate, + subtitle: item.placemark.subtitle, + title: item.name + ) + } + return .none + + case .localSearchResponse(.failure): + state.alert = "Could not perform search. Please try again." + return .none + + case .locationManager: + return .none + + case .onAppear: + return environment.locationManager.create(LocationManagerId()) + .map(AppAction.locationManager) + + case let .updateRegion(region): + state.region = region + + guard + let category = state.pointOfInterestCategory, + let region = state.region?.asMKCoordinateRegion + else { return .none } + + let request = MKLocalSearch.Request() + request.pointOfInterestFilter = MKPointOfInterestFilter(including: [category]) + request.region = region + return environment.localSearch + .search(request) + .catchToEffect() + .map(AppAction.localSearchResponse) + .cancellable(id: CancelSearchId(), cancelInFlight: true) + } +} +.combined( + with: + locationManagerReducer + .pullback(state: \.self, action: /AppAction.locationManager, environment: { $0 }) +) +.debug() + +let locationManagerReducer = Reducer { + state, action, environment in + + switch action { + case .didChangeAuthorization(.authorizedAlways), + .didChangeAuthorization(.authorizedWhenInUse): + if state.isRequestingCurrentLocation { + return environment.locationManager + .requestLocation(LocationManagerId()) + .fireAndForget() + } + return .none + + case .didChangeAuthorization(.denied): + if state.isRequestingCurrentLocation { + state.alert = "Location makes this app better. Please consider giving us access." + state.isRequestingCurrentLocation = false + } + return .none + + case let .didUpdateLocations(locations): + state.isRequestingCurrentLocation = false + guard let location = locations.first else { return .none } + state.region = CoordinateRegion( + center: location.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) + ) + return .none + + case .didChangeAuthorization, + .didCreate, + .didFailWithError: + return .none + } +} + +struct LocationManagerView: View { + @Environment(\.colorScheme) var colorScheme + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + ZStack { + MapView( + pointsOfInterest: viewStore.pointsOfInterest, + region: viewStore.binding(get: { $0.region }, send: AppAction.updateRegion) + ) + .edgesIgnoringSafeArea([.all]) + + VStack(alignment: .trailing) { + Spacer() + + Button(action: { viewStore.send(.currentLocationButtonTapped) }) { + Image(systemName: "location") + .foregroundColor(Color.white) + .frame(width: 60, height: 60) + .background(Color.secondary) + .clipShape(Circle()) + .padding([.trailing], 16) + .padding([.bottom], 16) + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(AppState.pointOfInterestCategories, id: \.rawValue) { category in + Button(category.displayName) { viewStore.send(.categoryButtonTapped(category)) } + .padding([.all], 16) + .background( + category == viewStore.pointOfInterestCategory ? Color.blue : Color.secondary + ) + .foregroundColor(.white) + .cornerRadius(8) + } + } + .padding([.leading, .trailing]) + .padding([.bottom], 32) + } + } + } + .alert( + item: viewStore.binding( + get: { $0.alert.map(AppAlert.init(title:)) }, + send: AppAction.dismissAlertButtonTapped + ) + ) { alert in + Alert(title: Text(alert.title)) + } + .onAppear { viewStore.send(.onAppear) } + } + } +} + +struct AppAlert: Identifiable { + var title: String + + var id: String { self.title } +} + +extension MKPointOfInterestCategory { + fileprivate var displayName: String { + switch self { + case .cafe: + return "Cafe" + case .museum: + return "Museum" + case .nightlife: + return "Nightlife" + case .park: + return "Park" + case .restaurant: + return "Restaurant" + default: + return "N/A" + } + } +} + +extension PointOfInterest { + // NB: CLLocationCoordinate2D doesn't conform to Equatable + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.coordinate.latitude == rhs.coordinate.latitude + && lhs.coordinate.longitude == rhs.coordinate.longitude + && lhs.subtitle == rhs.subtitle + && lhs.title == rhs.title + } +} + +struct ContentView: View { + var body: some View { + NavigationView { + Form { + Section( + header: Text(readMe) + .font(.body) + .padding([.bottom]) + ) { + NavigationLink( + "Go to demo", + destination: LocationManagerView( + store: Store( + initialState: AppState(), + reducer: appReducer, + environment: AppEnvironment(localSearch: .live, locationManager: .live) + ) + ) + ) + } + } + .navigationBarTitle("Location Manager") + } + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + // NB: CLLocationManager mostly does not work in SwiftUI previews, so we provide a mock + // client that has all authorization allowed and mocks the device's current location + // to Brooklyn, NY. + let mockLocation = Location( + altitude: 0, + coordinate: CLLocationCoordinate2D(latitude: 40.6501, longitude: -73.94958), + course: 0, + courseAccuracy: 0, + floor: nil, + horizontalAccuracy: 0, + speed: 0, + speedAccuracy: 0, + timestamp: Date(timeIntervalSince1970: 1_234_567_890), + verticalAccuracy: 0 + ) + let locationManagerSubject = PassthroughSubject() + let locationManager = LocationManagerClient.mock( + authorizationStatus: { .authorizedAlways }, + create: { _ in locationManagerSubject.eraseToEffect() }, + locationServicesEnabled: { true }, + requestLocation: { _ in + .fireAndForget { locationManagerSubject.send(.didUpdateLocations([mockLocation])) } + }, + requestWhenInUseAuthorization: { _ in .fireAndForget { } } + ) + + let appView = LocationManagerView( + store: Store( + initialState: AppState(), + reducer: appReducer, + environment: AppEnvironment( + localSearch: .live, + locationManager: locationManager + ) + ) + ) + + return Group { + ContentView() + appView + appView + .environment(\.colorScheme, .dark) + } + } +} diff --git a/Examples/LocationManager/LocationManager/MapView.swift b/Examples/LocationManager/LocationManager/MapView.swift new file mode 100644 index 000000000000..ff47021829a6 --- /dev/null +++ b/Examples/LocationManager/LocationManager/MapView.swift @@ -0,0 +1,54 @@ +import MapKit +import SwiftUI + +class PointOfInterestAnnotation: NSObject, MKAnnotation { + let pointOfInterest: PointOfInterest + + init(pointOfInterest: PointOfInterest) { + self.pointOfInterest = pointOfInterest + } + + var coordinate: CLLocationCoordinate2D { self.pointOfInterest.coordinate } + var subtitle: String? { self.pointOfInterest.subtitle } + var title: String? { self.pointOfInterest.title } +} + +struct MapView: UIViewRepresentable { + let pointsOfInterest: [PointOfInterest] + @Binding var region: CoordinateRegion? + + func makeUIView(context: Context) -> MKMapView { + let mapView = MKMapView(frame: .zero) + mapView.showsUserLocation = true + return mapView + } + + func updateUIView(_ mapView: MKMapView, context: UIViewRepresentableContext) { + mapView.delegate = context.coordinator + + if let region = self.region { + mapView.setRegion(region.asMKCoordinateRegion, animated: true) + } + + mapView.removeAnnotations(mapView.annotations) + mapView.addAnnotations( + self.pointsOfInterest.map(PointOfInterestAnnotation.init(pointOfInterest:)) + ) + } + + func makeCoordinator() -> MapViewCoordinator { + MapViewCoordinator(self) + } +} + +class MapViewCoordinator: NSObject, MKMapViewDelegate { + var mapView: MapView + + init(_ control: MapView) { + self.mapView = control + } + + func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { + self.mapView.region = CoordinateRegion(coordinateRegion: mapView.region) + } +} diff --git a/Examples/LocationManager/LocationManager/SceneDelegate.swift b/Examples/LocationManager/LocationManager/SceneDelegate.swift new file mode 100644 index 000000000000..395ecaf61028 --- /dev/null +++ b/Examples/LocationManager/LocationManager/SceneDelegate.swift @@ -0,0 +1,28 @@ +import ComposableArchitecture +import SwiftUI +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + let contentView = ContentView() + self.window = (scene as? UIWindowScene).map(UIWindow.init(windowScene:)) + self.window?.rootViewController = UIHostingController(rootView: contentView) + self.window?.makeKeyAndVisible() + } +} + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + true + } +} diff --git a/Examples/LocationManager/LocationManagerTests/LocationManagerTests.swift b/Examples/LocationManager/LocationManagerTests/LocationManagerTests.swift new file mode 100644 index 000000000000..0567174ad070 --- /dev/null +++ b/Examples/LocationManager/LocationManagerTests/LocationManagerTests.swift @@ -0,0 +1,219 @@ +import Combine +import ComposableArchitecture +import CoreLocation +import MapKit +import XCTest + +@testable import LocationManager + +class LocationManagerTests: XCTestCase { + func testRequestLocation_Allow() { + var didRequestInUseAuthorization = false + var didRequestLocation = false + let locationManagerSubject = PassthroughSubject() + + let store = TestStore( + initialState: AppState(), + reducer: appReducer, + environment: AppEnvironment( + localSearch: .mock(), + locationManager: .mock( + authorizationStatus: { .notDetermined }, + create: { _ in locationManagerSubject.eraseToEffect() }, + locationServicesEnabled: { true }, + requestLocation: { _ in .fireAndForget { didRequestLocation = true } }, + requestWhenInUseAuthorization: { _ in + .fireAndForget { didRequestInUseAuthorization = true } + } + ) + ) + ) + + let currentLocation = Location( + altitude: 0, + coordinate: CLLocationCoordinate2D(latitude: 10, longitude: 20), + course: 0, + courseAccuracy: 0, + floor: nil, + horizontalAccuracy: 0, + speed: 0, + speedAccuracy: 0, + timestamp: Date(timeIntervalSince1970: 1_234_567_890), + verticalAccuracy: 0 + ) + + store.assert( + .send(.onAppear), + + // Tap on the button to request current location + .send(.currentLocationButtonTapped) { + $0.isRequestingCurrentLocation = true + }, + .do { + XCTAssertTrue(didRequestInUseAuthorization) + }, + + // Simulate being given authorized to access location + .do { + locationManagerSubject.send(.didChangeAuthorization(.authorizedAlways)) + }, + .receive(.locationManager(.didChangeAuthorization(.authorizedAlways))), + .do { + XCTAssertTrue(didRequestLocation) + }, + + // Simulate finding the user's current location + .do { + locationManagerSubject.send(.didUpdateLocations([currentLocation])) + }, + .receive(.locationManager(.didUpdateLocations([currentLocation]))) { + $0.isRequestingCurrentLocation = false + $0.region = CoordinateRegion( + center: CLLocationCoordinate2D(latitude: 10, longitude: 20), + span: MKCoordinateSpan.init(latitudeDelta: 0.05, longitudeDelta: 0.05) + ) + }, + + .do { + locationManagerSubject.send(completion: .finished) + } + ) + } + + func testRequestLocation_Deny() { + var didRequestInUseAuthorization = false + let locationManagerSubject = PassthroughSubject() + + let store = TestStore( + initialState: AppState(), + reducer: appReducer, + environment: AppEnvironment( + localSearch: .mock(), + locationManager: .mock( + authorizationStatus: { .notDetermined }, + create: { _ in locationManagerSubject.eraseToEffect() }, + locationServicesEnabled: { true }, + requestWhenInUseAuthorization: { _ in + .fireAndForget { didRequestInUseAuthorization = true } + } + ) + ) + ) + + store.assert( + .send(.onAppear), + + .send(.currentLocationButtonTapped) { + $0.isRequestingCurrentLocation = true + }, + .do { + XCTAssertTrue(didRequestInUseAuthorization) + }, + + // Simulate the user denying location access + .do { + locationManagerSubject.send(.didChangeAuthorization(.denied)) + }, + .receive(.locationManager(.didChangeAuthorization(.denied))) { + $0.alert = "Location makes this app better. Please consider giving us access." + $0.isRequestingCurrentLocation = false + }, + + .do { + locationManagerSubject.send(completion: .finished) + } + ) + } + + func testSearchPointsOfInterest_TapCategory() { + let mapItem = MapItem( + isCurrentLocation: false, + name: "Blob's Cafe", + phoneNumber: nil, + placemark: Placemark(), + pointOfInterestCategory: .cafe, + timeZone: nil, + url: nil + ) + let localSearchResponse = LocalSearchResponse( + boundingRegion: MKCoordinateRegion(), + mapItems: [mapItem] + ) + + let store = TestStore( + initialState: AppState(), + reducer: appReducer, + environment: AppEnvironment( + localSearch: .mock( + search: { _ in .result { .success(localSearchResponse) } } + ), + locationManager: .mock() + ) + ) + + store.assert( + .send(.categoryButtonTapped(.cafe)) { + $0.pointOfInterestCategory = .cafe + }, + .receive(.localSearchResponse(.success(localSearchResponse))) { + $0.pointsOfInterest = [ + PointOfInterest( + coordinate: CLLocationCoordinate2D(), + subtitle: nil, + title: "Blob's Cafe" + ) + ] + } + ) + } + + func testSearchPointsOfInterest_PanMap() { + let mapItem = MapItem( + isCurrentLocation: false, + name: "Blob's Cafe", + phoneNumber: nil, + placemark: Placemark(), + pointOfInterestCategory: .cafe, + timeZone: nil, + url: nil + ) + let localSearchResponse = LocalSearchResponse( + boundingRegion: MKCoordinateRegion(), + mapItems: [mapItem] + ) + + let store = TestStore( + initialState: AppState( + pointOfInterestCategory: .cafe + ), + reducer: appReducer, + environment: AppEnvironment( + localSearch: .mock( + search: { request in + return .result { .success(localSearchResponse) } + }), + locationManager: .mock() + ) + ) + + let coordinateRegion = CoordinateRegion( + center: CLLocationCoordinate2D(latitude: 10, longitude: 20), + span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 2) + ) + + store.assert( + .send(.updateRegion(coordinateRegion)) { + $0.region = coordinateRegion + }, + .receive(.localSearchResponse(.success(localSearchResponse))) { + $0.pointsOfInterest = [ + PointOfInterest( + coordinate: CLLocationCoordinate2D(), + subtitle: nil, + title: "Blob's Cafe" + ) + ] + } + ) + } +} diff --git a/Makefile b/Makefile index b5eaa3660403..39be50b174d3 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,10 @@ test-workspace: -scheme MotionManager \ -destination platform="$(PLATFORM_IOS)" \ -quiet + xcodebuild test \ + -scheme LocationManager \ + -destination platform="$(PLATFORM_IOS)" \ + -quiet xcodebuild test \ -scheme Search \ -destination platform="$(PLATFORM_IOS)" \