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)" \