diff --git a/0156-searchable-pt1/README.md b/0156-searchable-pt1/README.md index b54e884d..72dbfbbd 100644 --- a/0156-searchable-pt1/README.md +++ b/0156-searchable-pt1/README.md @@ -1,5 +1,5 @@ ## [Point-Free](https://www.pointfree.co) -> #### This directory contains code from Point-Free Episode: [SwiftUI Focus State](https://www.pointfree.co/episodes/ep156-swiftui-searchable-part-1) +> #### This directory contains code from Point-Free Episode: [SwiftUI Searchable: Part 1](https://www.pointfree.co/episodes/ep156-swiftui-searchable-part-1) > -> Let’s develop a new application from scratch to explore SwiftUI’s new `.searchable` API. We’ll use MapKit to search for points of interest, and we will control this complex dependency so that our application can be fully testable. +> We finish our search-based application by adding and controlling another MapKit API, integrating it into our application so we can annotate a map with search results, and then we'll go the extra mile and write tests for the entire thing! diff --git a/0157-searchable-pt2/README.md b/0157-searchable-pt2/README.md new file mode 100644 index 00000000..102aace0 --- /dev/null +++ b/0157-searchable-pt2/README.md @@ -0,0 +1,5 @@ +## [Point-Free](https://www.pointfree.co) + +> #### This directory contains code from Point-Free Episode: [SwiftUI Searchable: Part 2](https://www.pointfree.co/episodes/ep157-swiftui-searchable-part-2) +> +> Let’s develop a new application from scratch to explore SwiftUI’s new `.searchable` API. We’ll use MapKit to search for points of interest, and we will control this complex dependency so that our application can be fully testable. diff --git a/0157-searchable-pt2/Search.playground/Contents.swift b/0157-searchable-pt2/Search.playground/Contents.swift new file mode 100644 index 00000000..a4f782a7 --- /dev/null +++ b/0157-searchable-pt2/Search.playground/Contents.swift @@ -0,0 +1,33 @@ +import MapKit + +let completer = MKLocalSearchCompleter() + +class LocalSearchCompleterDelegate: NSObject, MKLocalSearchCompleterDelegate { + func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { + print("succeeded") + dump(completer.results) + + + let search = MKLocalSearch(request: .init(completion: completer.results[0])) + Task { + let response = try await search.start() + print(response.mapItems) + + response.mapItems[0].placemark.coordinate + response.boundingRegion + } + + } + + func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) { + print("failed", error) + } +} + +let delegate = LocalSearchCompleterDelegate() +completer.delegate = delegate + +completer.queryFragment = "Apple Store" + +//MKLocalSearch.init(request: .init(coordinateRegion: <#T##MKCoordinateRegion#>)) + diff --git a/0157-searchable-pt2/Search.playground/contents.xcplayground b/0157-searchable-pt2/Search.playground/contents.xcplayground new file mode 100644 index 00000000..f64b06a8 --- /dev/null +++ b/0157-searchable-pt2/Search.playground/contents.xcplayground @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/0157-searchable-pt2/Search/Search.xcodeproj/project.pbxproj b/0157-searchable-pt2/Search/Search.xcodeproj/project.pbxproj new file mode 100644 index 00000000..c31cee66 --- /dev/null +++ b/0157-searchable-pt2/Search/Search.xcodeproj/project.pbxproj @@ -0,0 +1,512 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 55; + objects = { + +/* Begin PBXBuildFile section */ + 2A7640E326B987060003C3F4 /* SearchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A7640E226B987060003C3F4 /* SearchApp.swift */; }; + 2A7640E526B987060003C3F4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A7640E426B987060003C3F4 /* ContentView.swift */; }; + 2A7640E726B987070003C3F4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2A7640E626B987070003C3F4 /* Assets.xcassets */; }; + 2A7640EA26B987070003C3F4 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2A7640E926B987070003C3F4 /* Preview Assets.xcassets */; }; + 2A7640F426B987070003C3F4 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A7640F326B987070003C3F4 /* SearchTests.swift */; }; + 2A926AE226B9970300BAA496 /* LocalSearchCompleter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A926AE126B9970300BAA496 /* LocalSearchCompleter.swift */; }; + 2A926AE426B9A94500BAA496 /* LocalSearchClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A926AE326B9A94500BAA496 /* LocalSearchClient.swift */; }; + 4B83F2D926B98F0A0045BB86 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = 4B83F2D826B98F0A0045BB86 /* ComposableArchitecture */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 2A7640F026B987070003C3F4 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 2A7640D726B987060003C3F4 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 2A7640DE26B987060003C3F4; + remoteInfo = Search; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 2A7640DF26B987060003C3F4 /* Search.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Search.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 2A7640E226B987060003C3F4 /* SearchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchApp.swift; sourceTree = ""; }; + 2A7640E426B987060003C3F4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 2A7640E626B987070003C3F4 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 2A7640E926B987070003C3F4 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 2A7640EF26B987070003C3F4 /* SearchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SearchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 2A7640F326B987070003C3F4 /* SearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = ""; }; + 2A76410A26B994970003C3F4 /* Search.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; name = Search.playground; path = ../../Search.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; + 2A926AE126B9970300BAA496 /* LocalSearchCompleter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalSearchCompleter.swift; sourceTree = ""; }; + 2A926AE326B9A94500BAA496 /* LocalSearchClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalSearchClient.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 2A7640DC26B987060003C3F4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4B83F2D926B98F0A0045BB86 /* ComposableArchitecture in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2A7640EC26B987070003C3F4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2A7640D626B987060003C3F4 = { + isa = PBXGroup; + children = ( + 2A7640E126B987060003C3F4 /* Search */, + 2A7640F226B987070003C3F4 /* SearchTests */, + 2A7640E026B987060003C3F4 /* Products */, + ); + sourceTree = ""; + }; + 2A7640E026B987060003C3F4 /* Products */ = { + isa = PBXGroup; + children = ( + 2A7640DF26B987060003C3F4 /* Search.app */, + 2A7640EF26B987070003C3F4 /* SearchTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 2A7640E126B987060003C3F4 /* Search */ = { + isa = PBXGroup; + children = ( + 2A76410A26B994970003C3F4 /* Search.playground */, + 2A7640E426B987060003C3F4 /* ContentView.swift */, + 2A926AE326B9A94500BAA496 /* LocalSearchClient.swift */, + 2A926AE126B9970300BAA496 /* LocalSearchCompleter.swift */, + 2A7640E226B987060003C3F4 /* SearchApp.swift */, + 2A7640E626B987070003C3F4 /* Assets.xcassets */, + 2A7640E826B987070003C3F4 /* Preview Content */, + ); + path = Search; + sourceTree = ""; + }; + 2A7640E826B987070003C3F4 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 2A7640E926B987070003C3F4 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 2A7640F226B987070003C3F4 /* SearchTests */ = { + isa = PBXGroup; + children = ( + 2A7640F326B987070003C3F4 /* SearchTests.swift */, + ); + path = SearchTests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 2A7640DE26B987060003C3F4 /* Search */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2A76410126B987070003C3F4 /* Build configuration list for PBXNativeTarget "Search" */; + buildPhases = ( + 2A7640DB26B987060003C3F4 /* Sources */, + 2A7640DC26B987060003C3F4 /* Frameworks */, + 2A7640DD26B987060003C3F4 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Search; + packageProductDependencies = ( + 4B83F2D826B98F0A0045BB86 /* ComposableArchitecture */, + ); + productName = Search; + productReference = 2A7640DF26B987060003C3F4 /* Search.app */; + productType = "com.apple.product-type.application"; + }; + 2A7640EE26B987070003C3F4 /* SearchTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2A76410426B987070003C3F4 /* Build configuration list for PBXNativeTarget "SearchTests" */; + buildPhases = ( + 2A7640EB26B987070003C3F4 /* Sources */, + 2A7640EC26B987070003C3F4 /* Frameworks */, + 2A7640ED26B987070003C3F4 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 2A7640F126B987070003C3F4 /* PBXTargetDependency */, + ); + name = SearchTests; + productName = SearchTests; + productReference = 2A7640EF26B987070003C3F4 /* SearchTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 2A7640D726B987060003C3F4 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1300; + LastUpgradeCheck = 1300; + TargetAttributes = { + 2A7640DE26B987060003C3F4 = { + CreatedOnToolsVersion = 13.0; + }; + 2A7640EE26B987070003C3F4 = { + CreatedOnToolsVersion = 13.0; + TestTargetID = 2A7640DE26B987060003C3F4; + }; + }; + }; + buildConfigurationList = 2A7640DA26B987060003C3F4 /* Build configuration list for PBXProject "Search" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 2A7640D626B987060003C3F4; + packageReferences = ( + 4B83F2D726B98F0A0045BB86 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */, + ); + productRefGroup = 2A7640E026B987060003C3F4 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 2A7640DE26B987060003C3F4 /* Search */, + 2A7640EE26B987070003C3F4 /* SearchTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 2A7640DD26B987060003C3F4 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2A7640EA26B987070003C3F4 /* Preview Assets.xcassets in Resources */, + 2A7640E726B987070003C3F4 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2A7640ED26B987070003C3F4 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 2A7640DB26B987060003C3F4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2A7640E526B987060003C3F4 /* ContentView.swift in Sources */, + 2A926AE226B9970300BAA496 /* LocalSearchCompleter.swift in Sources */, + 2A926AE426B9A94500BAA496 /* LocalSearchClient.swift in Sources */, + 2A7640E326B987060003C3F4 /* SearchApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2A7640EB26B987070003C3F4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2A7640F426B987070003C3F4 /* SearchTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 2A7640F126B987070003C3F4 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 2A7640DE26B987060003C3F4 /* Search */; + targetProxy = 2A7640F026B987070003C3F4 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 2A7640FF26B987070003C3F4 /* 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++17"; + 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_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + 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; + }; + 2A76410026B987070003C3F4 /* 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++17"; + 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_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 2A76410226B987070003C3F4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Search/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Search; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 2A76410326B987070003C3F4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Search/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Search; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 2A76410526B987070003C3F4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SearchTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Search.app/Search"; + }; + name = Debug; + }; + 2A76410626B987070003C3F4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SearchTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Search.app/Search"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 2A7640DA26B987060003C3F4 /* Build configuration list for PBXProject "Search" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2A7640FF26B987070003C3F4 /* Debug */, + 2A76410026B987070003C3F4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2A76410126B987070003C3F4 /* Build configuration list for PBXNativeTarget "Search" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2A76410226B987070003C3F4 /* Debug */, + 2A76410326B987070003C3F4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2A76410426B987070003C3F4 /* Build configuration list for PBXNativeTarget "SearchTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2A76410526B987070003C3F4 /* Debug */, + 2A76410626B987070003C3F4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 4B83F2D726B98F0A0045BB86 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-composable-architecture.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.23.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4B83F2D826B98F0A0045BB86 /* ComposableArchitecture */ = { + isa = XCSwiftPackageProductDependency; + package = 4B83F2D726B98F0A0045BB86 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */; + productName = ComposableArchitecture; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 2A7640D726B987060003C3F4 /* Project object */; +} diff --git a/0157-searchable-pt2/Search/Search.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/0157-searchable-pt2/Search/Search.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/0157-searchable-pt2/Search/Search.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/0157-searchable-pt2/Search/Search.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/0157-searchable-pt2/Search/Search.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/0157-searchable-pt2/Search/Search.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/0157-searchable-pt2/Search/Search/Assets.xcassets/AccentColor.colorset/Contents.json b/0157-searchable-pt2/Search/Search/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/0157-searchable-pt2/Search/Search/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0157-searchable-pt2/Search/Search/Assets.xcassets/AppIcon.appiconset/Contents.json b/0157-searchable-pt2/Search/Search/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..9221b9bb --- /dev/null +++ b/0157-searchable-pt2/Search/Search/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "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" + }, + { + "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/0157-searchable-pt2/Search/Search/Assets.xcassets/Contents.json b/0157-searchable-pt2/Search/Search/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0157-searchable-pt2/Search/Search/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0157-searchable-pt2/Search/Search/ContentView.swift b/0157-searchable-pt2/Search/Search/ContentView.swift new file mode 100644 index 00000000..86fdf54c --- /dev/null +++ b/0157-searchable-pt2/Search/Search/ContentView.swift @@ -0,0 +1,260 @@ +import ComposableArchitecture +import MapKit +import SwiftUI + +struct CoordinateRegion: Equatable { + var center = LocationCoordinate2D() + var span = CoordinateSpan() +} + +extension CoordinateRegion { + init(rawValue: MKCoordinateRegion) { + self.init( + center: .init(rawValue: rawValue.center), + span: .init(rawValue: rawValue.span) + ) + } + + var rawValue: MKCoordinateRegion { + .init(center: self.center.rawValue, span: self.span.rawValue) + } +} + +struct LocationCoordinate2D: Equatable { + var latitude: CLLocationDegrees = 0 + var longitude: CLLocationDegrees = 0 +} + +extension LocationCoordinate2D { + init(rawValue: CLLocationCoordinate2D) { + self.init(latitude: rawValue.latitude, longitude: rawValue.longitude) + } + + var rawValue: CLLocationCoordinate2D { + .init(latitude: self.latitude, longitude: self.longitude) + } +} + +struct CoordinateSpan: Equatable { + var latitudeDelta: CLLocationDegrees = 0 + var longitudeDelta: CLLocationDegrees = 0 +} + +extension CoordinateSpan { + init(rawValue: MKCoordinateSpan) { + self.init(latitudeDelta: rawValue.latitudeDelta, longitudeDelta: rawValue.longitudeDelta) + } + + var rawValue: MKCoordinateSpan { + .init(latitudeDelta: self.latitudeDelta, longitudeDelta: self.longitudeDelta) + } +} + + +struct AppState: Equatable { + var completions: [LocalSearchCompletion] = [] + var mapItems: [MKMapItem] = [] + var query = "" + var region = CoordinateRegion( + center: .init(latitude: 40.7, longitude: -74), + span: .init(latitudeDelta: 0.075, longitudeDelta: 0.075) + ) +} + +enum AppAction: Equatable { + case completionsUpdated(Result<[LocalSearchCompletion], NSError>) + case onAppear + case queryChanged(String) + case regionChanged(CoordinateRegion) + case searchResponse(Result) + case tappedCompletion(LocalSearchCompletion) +} + +struct AppEnvironment { + var localSearch: LocalSearchClient + var localSearchCompleter: LocalSearchCompleter + var mainQueue: AnySchedulerOf +} + +let appReducer = Reducer< + AppState, + AppAction, + AppEnvironment +> { state, action, environment in + switch action { + case let .completionsUpdated(.success(completions)): + state.completions = completions + return .none + + case let .completionsUpdated(.failure(error)): + // TODO: error handling + return .none + + case .onAppear: + return environment.localSearchCompleter.completions() + .map { $0.mapError { $0 as NSError } } + .map(AppAction.completionsUpdated) + + case let .queryChanged(query): + state.query = query + return environment.localSearchCompleter.search(query) + .fireAndForget() + + case let .regionChanged(region): + state.region = region + return .none + + case let .searchResponse(.success(response)): + state.region = response.boundingRegion + state.mapItems = response.mapItems + return .none + + case let .searchResponse(.failure(error)): + // TODO: error handling + return .none + + case let .tappedCompletion(completion): + state.query = completion.title + return environment.localSearch.search(completion) + .mapError { $0 as NSError } + .receive(on: environment.mainQueue.animation()) + .catchToEffect() + .map(AppAction.searchResponse) + } +} + +extension LocalSearchCompletion: Identifiable { + var id: [String] { [self.title, self.subtitle] } +} + +extension MKMapItem: Identifiable {} + +struct ContentView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Map( + coordinateRegion: viewStore.binding( + get: \.region.rawValue, + send: { .regionChanged(.init(rawValue: $0)) } + ), + // interactionModes: <#T##MapInteractionModes#>, + // showsUserLocation: <#T##Bool#>, + // userTrackingMode: <#T##Binding?#>, + annotationItems: viewStore.mapItems, + annotationContent: { mapItem in + MapMarker(coordinate: mapItem.placemark.coordinate) + } + ) + .searchable( + text: viewStore.binding( + get: \.query, + send: AppAction.queryChanged + ) + // placement: <#T##SearchFieldPlacement#>, + // prompt: <#T##LocalizedStringKey#>, + // suggestions: <#T##() -> View#> + ) { + if viewStore.query.isEmpty { + HStack { + Text("Recent Searches") + Spacer() + Button(action: {}) { + Text("See all") + } + } + .font(.callout) + + HStack { + Image(systemName: "magnifyingglass") + Text("Apple • New York") + Spacer() + } + HStack { + Image(systemName: "magnifyingglass") + Text("Apple • New York") + Spacer() + } + HStack { + Image(systemName: "magnifyingglass") + Text("Apple • New York") + Spacer() + } + + HStack { + Text("Find nearby") + Spacer() + Button(action: {}) { + Text("See all") + } + } + .padding(.top) + .font(.callout) + + ScrollView(.horizontal) { + HStack { + ForEach(1...2, id: \.self) { _ in + VStack { + ForEach(1...2, id: \.self) { _ in + HStack { + Image(systemName: "bag.circle.fill") + .foregroundStyle(Color.white, Color.red) + .font(.title) + Text("Shopping") + } + .padding([.top, .bottom, .trailing], 4) + } + } + } + } + } + + HStack { + Text("Editors’ picks") + Spacer() + Button(action: {}) { + Text("See all") + } + } + .padding(.top) + .font(.callout) + } else { + ForEach(viewStore.completions) { completion in + Button(action: { viewStore.send(.tappedCompletion(completion)) }) { + VStack(alignment: .leading) { + Text(completion.title) + Text(completion.subtitle) + .font(.caption) + } + } + } + } + } + .navigationTitle("Places") + .navigationBarTitleDisplayMode(.inline) + .ignoresSafeArea(edges: .bottom) + .onAppear { + viewStore.send(.onAppear) + } + } + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + ContentView( + store: .init( + initialState: .init(), + reducer: appReducer, + environment: .init( + localSearch: .live, + localSearchCompleter: .live, + mainQueue: .main + ) + ) + ) + } + } +} diff --git a/0157-searchable-pt2/Search/Search/LocalSearchClient.swift b/0157-searchable-pt2/Search/Search/LocalSearchClient.swift new file mode 100644 index 00000000..dec583a1 --- /dev/null +++ b/0157-searchable-pt2/Search/Search/LocalSearchClient.swift @@ -0,0 +1,51 @@ +import ComposableArchitecture +import MapKit + +struct LocalSearchClient { + var search: (LocalSearchCompletion) -> Effect + + struct Response: Equatable { + var boundingRegion = CoordinateRegion() + var mapItems: [MKMapItem] = [] + } +} + +extension LocalSearchClient.Response { + init(rawValue: MKLocalSearch.Response) { + self.boundingRegion = .init(rawValue: rawValue.boundingRegion) + self.mapItems = rawValue.mapItems + } +} + +extension LocalSearchClient { + static let live = Self( + search: { completion in + .task { + .init( + rawValue: + try await MKLocalSearch(request: .init(completion: completion.rawValue!)) + .start() + ) + } + } + ) +} + +extension Effect { + static func task( + priority: TaskPriority? = nil, + operation: @escaping @Sendable () async throws -> Output + ) -> Self + where Failure == Error { + + .future { callback in + Task(priority: priority) { + do { + callback(.success(try await operation())) + } catch { + callback(.failure(error)) + } + } + } + } +} diff --git a/0157-searchable-pt2/Search/Search/LocalSearchCompleter.swift b/0157-searchable-pt2/Search/Search/LocalSearchCompleter.swift new file mode 100644 index 00000000..1c724ac9 --- /dev/null +++ b/0157-searchable-pt2/Search/Search/LocalSearchCompleter.swift @@ -0,0 +1,77 @@ +import Combine +import ComposableArchitecture +import MapKit + +struct LocalSearchCompleter { + var completions: () -> Effect, Never> + var search: (String) -> Effect +} + +extension LocalSearchCompleter { + static var live: Self { + class Delegate: NSObject, MKLocalSearchCompleterDelegate { + let subscriber: Effect, Never>.Subscriber + + init(subscriber: Effect, Never>.Subscriber) { + self.subscriber = subscriber + } + + func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { + self.subscriber.send( + .success( + completer.results + .map(LocalSearchCompletion.init(rawValue:)) + ) + ) + } + + func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) { + self.subscriber.send(.failure(error)) + } + } + + let completer = MKLocalSearchCompleter() + + return Self( + completions: { + Effect.run { subscriber in + let delegate = Delegate(subscriber: subscriber) + completer.delegate = delegate + + return AnyCancellable { + _ = delegate + } + } + }, + search: { queryFragment in + .fireAndForget { + completer.queryFragment = queryFragment + } + } + ) + } +} + +struct LocalSearchCompletion: Equatable { + let rawValue: MKLocalSearchCompletion? + + var subtitle: String + var title: String + + init(rawValue: MKLocalSearchCompletion) { + self.rawValue = rawValue + self.subtitle = rawValue.subtitle + self.title = rawValue.title + } + + init(subtitle: String, title: String) { + self.rawValue = nil + self.subtitle = subtitle + self.title = title + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.subtitle == rhs.subtitle + && lhs.title == rhs.title + } +} diff --git a/0157-searchable-pt2/Search/Search/Preview Content/Preview Assets.xcassets/Contents.json b/0157-searchable-pt2/Search/Search/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0157-searchable-pt2/Search/Search/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0157-searchable-pt2/Search/Search/SearchApp.swift b/0157-searchable-pt2/Search/Search/SearchApp.swift new file mode 100644 index 00000000..f064b429 --- /dev/null +++ b/0157-searchable-pt2/Search/Search/SearchApp.swift @@ -0,0 +1,23 @@ +import ComposableArchitecture +import SwiftUI + +@main +struct SearchApp: App { + var body: some Scene { + WindowGroup { + NavigationView { + ContentView( + store: .init( + initialState: .init(), + reducer: appReducer.debugActions(), + environment: .init( + localSearch: .live, + localSearchCompleter: .live, + mainQueue: .main + ) + ) + ) + } + } + } +} diff --git a/0157-searchable-pt2/Search/SearchTests/SearchTests.swift b/0157-searchable-pt2/Search/SearchTests/SearchTests.swift new file mode 100644 index 00000000..8fa9da06 --- /dev/null +++ b/0157-searchable-pt2/Search/SearchTests/SearchTests.swift @@ -0,0 +1,81 @@ +import Combine +import ComposableArchitecture +import MapKit +import XCTest +@testable import Search + +class SearchTests: XCTestCase { + func testExample() throws { + let completions = PassthroughSubject, Never>() + + let store = TestStore( + initialState: .init(), + reducer: appReducer, + environment: .init( + localSearch: .failing, + localSearchCompleter: .failing, + mainQueue: .immediate + ) + ) + + store.environment.localSearchCompleter.completions = { + completions.eraseToEffect() + } + let completion = LocalSearchCompletion( + subtitle: "Search Nearby", + title: "Apple Store" + ) + store.environment.localSearchCompleter.search = { query in + .fireAndForget { + completions.send(.success([completion])) + } + } + let response = LocalSearchClient.Response( + boundingRegion: .init( + center: .init(latitude: 0, longitude: 0), + span: .init(latitudeDelta: 1, longitudeDelta: 1) + ), + mapItems: [MKMapItem()] + ) + store.environment.localSearch.search = { _ in + .init(value: response) + } + defer { completions.send(completion: .finished) } + + store.send(.onAppear) + + store.send(.queryChanged("Apple")) { + $0.query = "Apple" + } + + store.receive(.completionsUpdated(.success([completion]))) { + $0.completions = [completion] + } + + store.send(.tappedCompletion(completion)) { + $0.query = "Apple Store" + } + + store.receive(.searchResponse(.success(response))) { + $0.region = response.boundingRegion + $0.mapItems = response.mapItems + } + } +} + +extension LocalSearchClient { + static let failing = Self( + search: { _ in .failing("LocalSearchClient.search is unimplemented") } + ) +} + +extension LocalSearchCompleter { + static let failing = Self( + completions: { + .failing("LocalSearchCompleter.completions is unimplemented") + }, + search: { _ in + .failing("LocalSearchCompleter.search is unimplemented") + } + ) +} diff --git a/README.md b/README.md index dbd2b9bf..53b9d43d 100644 --- a/README.md +++ b/README.md @@ -158,3 +158,4 @@ This repository is the home of code written on episodes of [Point-Free](https:// 1. [Async Refreshable: Composable Architecture](0154-refreshable-pt2) 1. [SwiftUI Focus State](0155-focus-state) 1. [SwiftUI Searchable: Part 1](0156-searchable-pt1) +1. [SwiftUI Searchable: Part 2](0157-searchable-pt2)