├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── MapItemPicker │ ├── Controllers │ ├── LocationController.swift │ ├── MapItemController │ │ ├── +LoadingState.swift │ │ ├── +MKMapFeatureAnnotation.swift │ │ ├── +MKMapItem.swift │ │ ├── +OSMItem.swift │ │ ├── +WDItem.swift │ │ ├── +loadLookAround.swift │ │ └── MapItemController.swift │ ├── MapItemSearchController.swift │ ├── MapItemSearchViewCoordinator.swift │ └── RecentMapItemsController.swift │ ├── Data │ ├── MIPAction.swift │ ├── MapItem │ │ ├── +FeatureType.swift │ │ ├── MapItem.swift │ │ ├── MapItemCategory.swift │ │ ├── MapItemImage.swift │ │ ├── OpenStreetMaps │ │ │ ├── OSM+retrieveForMapItem.swift │ │ │ └── OSMItem.swift │ │ ├── OpeningHours │ │ │ ├── DayTime.swift │ │ │ ├── DayTimeRange.swift │ │ │ ├── DisplayableWeekPortion.swift │ │ │ ├── OpeningHours.swift │ │ │ └── Weekday.swift │ │ └── WikiData │ │ │ ├── WD+retrieveForMapItem.swift │ │ │ ├── WD+retrieveImages.swift │ │ │ └── WDItem.swift │ └── MapItemSearchViewAction.swift │ ├── Extensions │ ├── ?=.swift │ ├── Array.swift │ ├── CLCircularRegion.swift │ ├── CLLocationCoordinate2D.swift │ ├── Font+compatibility.swift │ ├── Int.swift │ ├── MKCoordinateRegion.swift │ ├── MKLocalSearchCompletion.swift │ ├── MKMapConfiguration.swift │ ├── MKMapFeatureAnnotation.FeatureType.swift │ ├── MKMapItem.swift │ ├── MKMapRect.swift │ ├── MKPlacemark.swift │ ├── String+levenshtein.swift │ ├── String.swift │ ├── UIViewController.swift │ ├── View+compatibility.swift │ ├── View+listCellEmulationPadding.swift │ └── View+mapItemPickerSheet.swift │ ├── Protocols │ ├── MapAnnotationEquatable.swift │ └── MapOverlayEquatable.swift │ ├── Resources │ ├── de.lproj │ │ └── Localizable.strings │ └── en.lproj │ │ └── Localizable.strings │ └── UI │ ├── Components │ ├── AsyncPhotoZoomView.swift │ ├── CompassView.swift │ ├── ListEmulationSection.swift │ ├── LookaroundView.swift │ ├── MapControllerHolder.swift │ ├── MapStyleChooser.swift │ ├── MapViewController.swift │ ├── PhotoZoomView.swift │ ├── SearchCell.swift │ └── TopRightButtons.swift │ ├── ImageSheet │ └── ImageSheet.swift │ ├── MapItemDisplayView │ ├── +aboutSection.swift │ ├── +buttonSection.swift │ ├── +contactSection.swift │ ├── +detailsSection.swift │ ├── +factsSection.swift │ ├── +header.swift │ ├── +imageSection.swift │ ├── +legalSection.swift │ ├── +topScrollInfoView.swift │ ├── DetailListEmulationCell.swift │ ├── MapItemActionButtons.swift │ ├── MapItemDisplaySheet.swift │ ├── MapItemDisplayView.swift │ └── OpeningHoursCell.swift │ ├── MapItemPicker.swift │ ├── MapItemPickerConstants.swift │ ├── RecentMapItemsSection.swift │ ├── Sheets │ ├── LocalSearchCompletionSearchSheet.swift │ ├── MapItemClusterSheet.swift │ └── SearchSheet.swift │ └── StandardSearchView.swift └── Tests └── MapItemPickerTests └── MapItemPickerTests.swift /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: FiveSheepCo 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Swift 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | env: 13 | PACKAGE: MapItemPicker 14 | 15 | jobs: 16 | ios: 17 | runs-on: macos-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Setup Xcode 16 21 | uses: maxim-lobanov/setup-xcode@v1 22 | with: 23 | xcode-version: 16 24 | - name: Build (iOS) 25 | run: xcodebuild build -scheme $PACKAGE -sdk iphoneos -destination 'generic/platform=iOS,name=iPhone 15' 26 | - name: Test (iOS) 27 | run: xcodebuild test -scheme $PACKAGE -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Quintschaf GbR 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "SchafKit", 6 | "repositoryURL": "https://github.com/FiveSheepCo/SchafKit.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "543b2d8dba7069c638e526d71b0701a730c09994", 10 | "version": "1.3.0" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "MapItemPicker", 8 | defaultLocalization: "en", 9 | platforms: [ 10 | .macOS(.v10_15), 11 | .iOS(.v15), 12 | .watchOS(.v6), 13 | .tvOS(.v15) 14 | ], 15 | products: [ 16 | // Products define the executables and libraries a package produces, and make them visible to other packages. 17 | .library( 18 | name: "MapItemPicker", 19 | targets: ["MapItemPicker"] 20 | ), 21 | ], 22 | dependencies: [ 23 | // Dependencies declare other packages that this package depends on. 24 | .package(url: "https://github.com/FiveSheepCo/SchafKit.git", from: "1.0.0") 25 | ], 26 | targets: [ 27 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 28 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 29 | .target( 30 | name: "MapItemPicker", 31 | dependencies: ["SchafKit"], 32 | resources: [] 33 | ), 34 | .testTarget( 35 | name: "MapItemPickerTests", 36 | dependencies: ["MapItemPicker"] 37 | ), 38 | ] 39 | ) 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MapItemPicker 🗺️📍 2 | 3 | [![GithubCI_Status]][GithubCI_URL] 4 | [![FiveSheep_Badge]](https://fivesheep.co) 5 | [![LICENSE_BADGE]][LICENSE_URL] 6 | 7 | MapItemPicker is a simple, yet highly customizable and powerful location picker for SwiftUI. 8 | 9 |

10 | Sheet for New York City 11 | Search for Airport in Germany 12 | Sheet for Central Park 13 | Picker Inside a Full Screen Overlay 14 |

15 | 16 | ## Description 17 | 18 | A lot of apps need some kind of view to find and select locations. Sadly, Apple doesn't offer a view for this in their frameworks and a lot of the information displayed in the Maps app that makes it easy to search for and discover map items is not exposed on `MKMapItem`. MapItemPicker uses data from MapKit, OpenStreetMaps and Wikidata to deliver a simple yet beautiful and comprehensive map item picker. 19 | 20 | ## Example Code 21 | 22 | ### Simple Picker 23 | 24 | #### Convenience Method 25 | ```Swift 26 | .mapItemPickerSheet(isPresented: $showsSheet) { mapItem in 27 | print("Map Item:", mapItem) 28 | } 29 | ``` 30 | 31 | #### Customizable View 32 | ```Swift 33 | .fullScreenCover(isPresented: $showsSheet) { 34 | NavigationView { 35 | MapItemPicker( 36 | primaryMapItemAction: .init( 37 | title: "select", 38 | imageName: "checkmark.circle.fill", 39 | handler: { mapItem in 40 | print("Map Item:", mapItem) 41 | return true 42 | } 43 | ) 44 | ) 45 | .toolbar { 46 | ToolbarItem(placement: .navigationBarLeading) { 47 | Button("cancel") { 48 | showsSheet = false 49 | } 50 | } 51 | } 52 | .navigationTitle(Text("select")) 53 | .navigationBarTitleDisplayMode(.inline) 54 | .toolbarBackground(.visible, for: .navigationBar) 55 | } 56 | } 57 | ``` 58 | 59 | ### Advanced Map View with Configured Standard View 60 | 61 | ```Swift 62 | MapItemPicker( 63 | annotations: [MKPointAnnotation.chicago], 64 | annotationView: { annotation in 65 | MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: nil) 66 | }, 67 | annotationSelectionHandler: { annotation in 68 | print("selected:", annotation) 69 | }, 70 | overlays: [MKPolyline.newYorkToLosAngeles], 71 | overlayRenderer: { overlay in 72 | MKPolylineRenderer(polyline: overlay as! MKPolyline, color: .red) 73 | }, 74 | primaryMapItemAction: .init(title: "select", imageName: "checkmark.circle.fill", handler: { mapItem in 75 | print("Map Item:", mapItem) 76 | return true 77 | }), 78 | additionalMapItemActions: [ 79 | .init( 80 | title: "addTo", 81 | imageName: "plus.square", 82 | subActions: [ 83 | .init(title: "Collection A", imageName: "square.on.square", handler: { mapItem in return false }), 84 | .init(title: "Collection B", imageName: "square.on.square", handler: { mapItem in return false }) 85 | ] 86 | ) 87 | ], 88 | showsLocationButton: false, 89 | additionalTopRightButtons: [ 90 | .init( 91 | imageName: "magnifyingglass", 92 | handler: { searchControllerShown = true } 93 | ) 94 | ], 95 | initialRegion: MKCoordinateRegion.unitedStates, 96 | standardView: { Text("Standard View") }, 97 | searchControllerShown: $searchControllerShown, 98 | standardSearchView: { Text("Search View") } 99 | ) 100 | ``` 101 | 102 | ## Localization 103 | 104 | MapItemPicker contains localizations for categories, titles of sections in the views and other strings. Currently, only English and German are supported. If you can provide localization for any other language, please submit a PR. You can copy the strings from the English `Localizable.strings` file at `Sources/MapItemPicker/Resources/en.lproj`. It's not a lot of localization keys, you will propably be done in 5 minutes. 105 | 106 | ## TODO 107 | 108 | - [ ] A lot of MapItems currently have a type of simply 'Location' (Localization Key 'mapItem.type.item') before loading Wikidata and/or OpenStreetMaps data. This includes cities, mountains and other items for which Apple doesn't provide a `MKPointOfInterestCategory` and should be resolved. 109 | - [ ] Add more datasources. This can be free ones like Wikidata and OpenStreetMaps, as well as paid ones for which each application can provide their own API key. 110 | - [ ] Add the ability to edit opening hours etc. and report back to OpenStreetMaps 111 | - [ ] Add more filters like "Is Open" in Search 112 | - [ ] Add Unit Tests 113 | - [ ] Add example App with UI Tests 114 | - [ ] Compile Documentation 115 | 116 | 117 | 118 | [GithubCI_Status]: https://github.com/FiveSheepCo/MapItemPicker/actions/workflows/ci.yml/badge.svg?branch=main 119 | [GithubCI_URL]: https://github.com/FiveSheepCo/MapItemPicker/actions/workflows/ci.yml 120 | [FiveSheep_Badge]: https://badgen.net/badge/Built%20and%20maintained%20by/FiveSheep/cyan 121 | [LICENSE_BADGE]: https://badgen.net/github/license/FiveSheepCo/MapItemPicker 122 | [LICENSE_URL]: https://github.com/FiveSheepCo/MapItemPicker/blob/master/LICENSE 123 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Controllers/LocationController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreLocation 3 | import MapKit 4 | 5 | final class LocationController: NSObject, CLLocationManagerDelegate, ObservableObject { 6 | let locationManager: CLLocationManager = .init() 7 | 8 | @Published var isAuthorized: Bool = false 9 | @Published var userTrackingMode: MKUserTrackingMode = .none 10 | 11 | var displayedImage: String { 12 | if !isAuthorized { 13 | return "location.slash" 14 | } 15 | 16 | switch userTrackingMode { 17 | case .none: return "location" 18 | case .follow: return "location.fill" 19 | case .followWithHeading: return "location.north.line.fill" 20 | @unknown default: return "location" 21 | } 22 | } 23 | 24 | override init() { 25 | super.init() 26 | 27 | locationManager.delegate = self 28 | } 29 | 30 | @available(iOS 14.0, *) 31 | func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { 32 | locationManager(manager, didChangeAuthorization: manager.authorizationStatus) 33 | } 34 | 35 | func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { 36 | isAuthorized = [.authorizedAlways, .authorizedWhenInUse].contains(status) 37 | } 38 | 39 | func authorizeIfPossible() { 40 | locationManager.requestWhenInUseAuthorization() 41 | } 42 | 43 | func tapped(coordinator: MapItemPickerController) { 44 | if !isAuthorized { 45 | authorizeIfPossible() 46 | return 47 | } 48 | 49 | switch userTrackingMode { 50 | case .none: userTrackingMode = .follow 51 | case .follow: userTrackingMode = .followWithHeading 52 | default: userTrackingMode = .none 53 | } 54 | 55 | coordinator.currentMapView?.setUserTrackingMode(userTrackingMode, animated: true) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Controllers/MapItemController/+LoadingState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension MapItemController { 4 | enum LoadingState { 5 | case notLoaded, inProgress, error(Error), successWithoutResult, success 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Controllers/MapItemController/+MKMapFeatureAnnotation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import MapKit 3 | 4 | extension MapItemController { 5 | 6 | @available(iOS 16.0, *) 7 | convenience init?(mapFeatureAnnotation: MKMapFeatureAnnotation) { 8 | guard let name = mapFeatureAnnotation.title ?? mapFeatureAnnotation.subtitle else { return nil } 9 | 10 | self.init( 11 | item: .init( 12 | name: name, 13 | location: mapFeatureAnnotation.coordinate, 14 | featureAnnotationType: .init(rawValue: mapFeatureAnnotation.featureType.rawValue) 15 | ) 16 | ) 17 | 18 | originatingMapFeatureAnnotation = mapFeatureAnnotation 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Controllers/MapItemController/+MKMapItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import MapKit 3 | import CoreLocation 4 | 5 | extension MapItemController { 6 | 7 | convenience init?(mapItem: MKMapItem) { 8 | guard let name = mapItem.name else { return nil } 9 | 10 | self.init( 11 | item: .init( 12 | name: name, 13 | location: mapItem.placemark.coordinate 14 | ) 15 | ) 16 | update(with: mapItem) 17 | } 18 | 19 | func loadMKMapItem() { 20 | guard #available(iOS 16, *), case .notLoaded = mKMapItemLoadingState, let originatingMapFeatureAnnotation else { return } 21 | mKMapItemLoadingState = .inProgress 22 | 23 | Task { 24 | do { 25 | let item = try await MKMapItemRequest(mapFeatureAnnotation: originatingMapFeatureAnnotation).mapItem 26 | self.update(with: item) 27 | } 28 | catch { 29 | mKMapItemLoadingState = .error(error) 30 | } 31 | } 32 | } 33 | 34 | private func update(with mapItem: MKMapItem) { 35 | mKMapItemLoadingState = .success 36 | 37 | let placemark = mapItem.placemark 38 | var item = self.item 39 | 40 | if let category = mapItem.pointOfInterestCategory { 41 | item.category ?= MapItemCategory(nativeCategory: category) 42 | } 43 | 44 | if let region = mapItem.placemark.region as? CLCircularRegion { 45 | item.region = .init(center: region.center, radius: region.radius, identifier: region.identifier) 46 | } 47 | 48 | item.street ?= placemark.thoroughfare 49 | item.housenumber ?= placemark.subThoroughfare 50 | item.postcode ?= placemark.postalCode 51 | item.city ?= placemark.locality 52 | item.cityRegion ?= placemark.subLocality 53 | item.state ?= placemark.administrativeArea 54 | item.stateRegion ?= placemark.subAdministrativeArea 55 | item.country ?= placemark.country 56 | 57 | item.inlandWater ?= placemark.inlandWater 58 | item.ocean ?= placemark.ocean 59 | 60 | item.phone ?= mapItem.phoneNumber 61 | item.website ?= mapItem.url?.absoluteString 62 | 63 | self.item = item 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Controllers/MapItemController/+OSMItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension MapItemController { 4 | 5 | convenience init?(place: OSMItem) { 6 | guard let name = place.name else { return nil } 7 | 8 | self.init( 9 | item: .init( 10 | name: name, 11 | location: .init(latitude: 0, longitude: 0), // TODO: This 12 | region: nil // TODO: This? 13 | ) 14 | ) 15 | update(with: place) 16 | 17 | fatalError() 18 | } 19 | 20 | func loadOSMItem() { 21 | guard case .notLoaded = oSMItemLoadingState else { return } 22 | oSMItemLoadingState = .inProgress 23 | 24 | Task { 25 | do { 26 | guard let item = try await OSMItem.retrieve(for: item) else { 27 | oSMItemLoadingState = .successWithoutResult 28 | return 29 | } 30 | self.update(with: item) 31 | } 32 | catch { 33 | oSMItemLoadingState = .error(error) 34 | } 35 | } 36 | } 37 | 38 | private func update(with place: OSMItem) { 39 | oSMItemLoadingState = .success 40 | 41 | var item = self.item 42 | 43 | item.identifiers[.openStreetMap] = place.id 44 | 45 | item.notes ?= place.description 46 | item.street ?= place.street 47 | item.housenumber ?= place.housenumber 48 | item.postcode ?= place.postcode 49 | item.city ?= place.city 50 | 51 | item.phone ?= place.phone 52 | item.website ?= place.website 53 | 54 | item.wikidataBrand ?= place.wikidataBrand 55 | item.wikipediaBrand ?= place.wikipediaBrand 56 | 57 | item.hasVegetarianFood ?= place.hasVegetarianFood 58 | item.hasVeganFood ?= place.hasVeganFood 59 | 60 | item.indoorSeating ?= place.indoorSeating 61 | item.outdoorSeating ?= place.outdoorSeating 62 | item.internetAccess ?= place.internetAccess 63 | item.smoking ?= place.smoking 64 | item.takeaway ?= place.takeaway 65 | item.wheelchair ?= place.wheelchair 66 | 67 | item.level ?= place.level 68 | 69 | item.openingHours ?= place.openingHours 70 | 71 | self.item = item 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Controllers/MapItemController/+WDItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension MapItemController { 4 | 5 | func loadWDItem() { 6 | guard case .notLoaded = wDItemLoadingState else { return } 7 | wDItemLoadingState = .inProgress 8 | 9 | Task { 10 | do { 11 | guard let item = try await WDItem.retrieve(for: item) else { 12 | wDItemLoadingState = .successWithoutResult 13 | return 14 | } 15 | self.update(with: item) 16 | self.loadWDItemImages() 17 | } 18 | catch { 19 | wDItemLoadingState = .error(error) 20 | } 21 | } 22 | } 23 | 24 | func loadWDItemImages() { 25 | guard 26 | case .notLoaded = wDItemImagesLoadingState, 27 | case .notLoaded = wDItemViewCategoryImagesLoadingState 28 | else { return } 29 | 30 | wDItemImagesLoadingState = .inProgress 31 | wDItemViewCategoryImagesLoadingState = .inProgress 32 | 33 | Task { 34 | // Standard Image + Nighttime View Image 35 | do { 36 | guard let images = try await WDItem.retrieveStandardImages(for: self.item) else { 37 | wDItemImagesLoadingState = .notLoaded 38 | return 39 | } 40 | 41 | add(images: images) 42 | wDItemImagesLoadingState = .success 43 | } 44 | catch { 45 | wDItemImagesLoadingState = .error(error) 46 | } 47 | 48 | // View Category 49 | do { 50 | guard let images = try await WDItem.retrieveViewCategoryImages(for: self.item) else { 51 | wDItemViewCategoryImagesLoadingState = .notLoaded 52 | return 53 | } 54 | 55 | add(images: images) 56 | wDItemViewCategoryImagesLoadingState = .success 57 | } 58 | catch { 59 | wDItemViewCategoryImagesLoadingState = .error(error) 60 | } 61 | } 62 | } 63 | 64 | private func update(with place: WDItem) { 65 | wDItemLoadingState = .success 66 | 67 | item.identifiers[.wikidata] = place.identifier 68 | if let commonsImageCatagory = place.commonsImageCatagory { 69 | item.identifiers[.wikimediaCommonsCategory] = commonsImageCatagory 70 | } 71 | if let wikidataCommonsImageFilename = place.imageFileTitle { 72 | item.identifiers[.wikidataCommonsImageFilename] = wikidataCommonsImageFilename 73 | } 74 | if let wikidataCommonsNighttimeViewImageFilename = place.nighttimeImageFileTitle { 75 | item.identifiers[.wikidataCommonsNighttimeViewImageFilename] = wikidataCommonsNighttimeViewImageFilename 76 | } 77 | 78 | item.categoryString ?= place.type ?? place.description 79 | item.wikiDescription ?= place.description 80 | item.wikipediaURL ?= place.url 81 | item.website ?= place.website 82 | 83 | item.area ?= place.area 84 | item.altitude ?= place.altitude 85 | item.population ?= place.population 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Controllers/MapItemController/+loadLookAround.swift: -------------------------------------------------------------------------------- 1 | import MapKit 2 | 3 | extension MapItemController { 4 | 5 | func loadLookAround() { 6 | guard case .notLoaded = lookaroundLoadingState, #available(iOS 16.0, *) else { return } 7 | lookaroundLoadingState = .inProgress 8 | 9 | Task { 10 | let sceneRequest = MKLookAroundSceneRequest(coordinate: item.location) 11 | do { 12 | if let scene = try await sceneRequest.scene { 13 | lookaroundLoadingState = .success 14 | lookaroundScene = scene 15 | } else { 16 | lookaroundLoadingState = .successWithoutResult 17 | } 18 | } 19 | catch { 20 | lookaroundLoadingState = .error(error) 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Controllers/MapItemController/MapItemController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import MapKit 3 | import SchafKit 4 | 5 | class MapItemController: NSObject, ObservableObject, Identifiable { 6 | 7 | let id = UUID() 8 | 9 | var coordinate: CLLocationCoordinate2D { item.location } 10 | 11 | override func isEqual(_ object: Any?) -> Bool { 12 | guard let other = object as? MapItemController else { 13 | return false 14 | } 15 | 16 | return self.hashValue == other.hashValue 17 | } 18 | 19 | override var hash: Int { 20 | item.hashValue 21 | } 22 | 23 | @BackgroundPublished var item: MapItem 24 | 25 | @available(iOS 16, *) 26 | var originatingMapFeatureAnnotation: MKMapFeatureAnnotation? { 27 | get { originatingMapFeatureAnnotationStorage as? MKMapFeatureAnnotation } 28 | set { originatingMapFeatureAnnotationStorage = newValue } 29 | } 30 | private var originatingMapFeatureAnnotationStorage: NSObject? 31 | 32 | @available(iOS 16, *) 33 | var lookaroundScene: MKLookAroundScene? { 34 | get { lookaroundSceneStorage as? MKLookAroundScene } 35 | set { lookaroundSceneStorage = newValue } 36 | } 37 | @BackgroundPublished private var lookaroundSceneStorage: NSObject? 38 | 39 | @BackgroundPublished private(set) var images: [MapItemImage] = [] 40 | 41 | // MARK: - Loading States 42 | var mKMapItemLoadingState: LoadingState = .notLoaded 43 | var oSMItemLoadingState: LoadingState = .notLoaded 44 | var wDItemLoadingState: LoadingState = .notLoaded 45 | var wDItemImagesLoadingState: LoadingState = .notLoaded 46 | var wDItemViewCategoryImagesLoadingState: LoadingState = .notLoaded 47 | var lookaroundLoadingState: LoadingState = .notLoaded 48 | 49 | init(item: MapItem) { 50 | self.item = item 51 | } 52 | 53 | func loadRemaining() { 54 | loadMKMapItem() 55 | loadOSMItem() 56 | loadWDItem() 57 | loadLookAround() 58 | } 59 | 60 | func add(images: [MapItemImage]) { 61 | self.images = (self.images + images).removingDuplicates() 62 | } 63 | } 64 | 65 | extension MapItemController: MKAnnotation { 66 | 67 | var title: String? { 68 | self.item.name 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Controllers/MapItemSearchController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import MapKit 3 | import SwiftUI 4 | import Combine 5 | 6 | extension MapItemSearchController { 7 | enum State { 8 | case noSearch, searching, completed, error(Error?) 9 | } 10 | } 11 | 12 | final class MapItemSearchController: NSObject, ObservableObject { 13 | 14 | weak var coordinator: MapItemPickerController? 15 | 16 | @Published var searchTerm: String = .empty { didSet { reload() } } 17 | @Published var filteredCategories: [MapItemCategory] = [] { didSet { reload() } } 18 | 19 | @Published var completions: [MKLocalSearchCompletion] = [] 20 | @Published var items: [MapItemController] = [] 21 | 22 | private var cityItems: [MapItemController] = [] { 23 | didSet { reloadItems() } 24 | } 25 | private var otherItems: [MapItemController] = [] { 26 | didSet { reloadItems() } 27 | } 28 | 29 | // MARK: - State 30 | 31 | // TODO: handle errors 32 | var isNoSearch: Bool { 33 | [cityItemsState, otherItemsState, completionsState].allSatisfy { state in 34 | if case .noSearch = state { 35 | return true 36 | } 37 | return false 38 | } 39 | } 40 | var isAnySearching: Bool { 41 | [cityItemsState, otherItemsState, completionsState].any { state in 42 | if case .searching = state { 43 | return true 44 | } 45 | return false 46 | } 47 | } 48 | var areAllSearching: Bool { 49 | [cityItemsState, otherItemsState, completionsState].allSatisfy { state in 50 | if case .searching = state { 51 | return true 52 | } 53 | return false 54 | } 55 | } 56 | @Published var cityItemsState: State = .noSearch 57 | @Published var otherItemsState: State = .noSearch 58 | @Published var completionsState: State = .noSearch 59 | private var otherItemsRequestRegion: MKCoordinateRegion? = nil 60 | private var otherItemsResultRegion: MKCoordinateRegion? = nil 61 | 62 | // MARK: - Completion Items Storage 63 | 64 | @Published var searchedCompletion: MKLocalSearchCompletion? = nil { 65 | didSet { 66 | if searchedCompletion == nil { 67 | completionLocalSearch?.cancel() 68 | completionLocalSearch = nil 69 | completionItems = nil 70 | } 71 | } 72 | } 73 | private var completionLocalSearch: MKLocalSearch? = nil 74 | @Published var completionItems: [MapItemController]? = nil 75 | 76 | private let geocoder = CLGeocoder() 77 | private let completer = MKLocalSearchCompleter() 78 | private var lastLocalSearch: MKLocalSearch? = nil 79 | 80 | override init() { 81 | super.init() 82 | 83 | completer.resultTypes = .query 84 | completer.delegate = self 85 | } 86 | 87 | // MARK: - Region Change 88 | 89 | func regionChanged() { 90 | guard 91 | let currentRegion = coordinator?.region, 92 | let otherItemsResultRegion, 93 | let otherItemsRequestRegion, 94 | coordinator?.selectedMapItem == nil && coordinator?.selectedMapItemCluster == nil 95 | else { return } 96 | 97 | let regionChange = currentRegion.span.longitudeDelta / otherItemsRequestRegion.span.longitudeDelta 98 | if 99 | !MKMapRect(otherItemsRequestRegion).contains(MKMapPoint(currentRegion.center)) || 100 | !(0.65...1.5).contains(regionChange) 101 | { 102 | reload() 103 | } 104 | } 105 | 106 | // MARK: - Filters 107 | 108 | func clearFilters() { 109 | filteredCategories = [] 110 | } 111 | 112 | // MARK: - Reload 113 | 114 | private func reload() { 115 | guard let region = coordinator?.region else { return } 116 | 117 | reloadLocalSearch(region: region) 118 | reloadGeocoder() 119 | reloadCompleter(region: region) 120 | } 121 | 122 | private func reloadItems() { 123 | items = (cityItems + otherItems).removingDuplicates() 124 | } 125 | } 126 | 127 | // MARK: - Local Search 128 | extension MapItemSearchController { 129 | fileprivate func reloadLocalSearch(region: MKCoordinateRegion) { 130 | self.otherItemsRequestRegion = region 131 | self.otherItemsResultRegion = nil 132 | self.lastLocalSearch?.cancel() 133 | self.lastLocalSearch = nil 134 | 135 | guard !searchTerm.isEmpty || !filteredCategories.isEmpty else { 136 | otherItems = [] 137 | otherItemsState = .noSearch 138 | return 139 | } 140 | self.otherItemsState = .searching 141 | 142 | // TODO: When the region of a `MKLocalSearch.Request` is so small that no or few items would be returned, it just returns results near the user no matter where the region is. This region minimum size is different per category. We should just make the region bigger until we get a reasonable result. This is testable by navigating into an ocean and choosing any category. 143 | let search: MKLocalSearch 144 | let filter = filteredCategories.isEmpty ? nil : MKPointOfInterestFilter(including: filteredCategories.map(\.nativeCategory)) 145 | if !searchTerm.isEmpty { 146 | let request = MKLocalSearch.Request() 147 | request.region = region 148 | request.pointOfInterestFilter = filter 149 | request.naturalLanguageQuery = searchTerm 150 | search = MKLocalSearch(request: request) 151 | } else { 152 | // TODO: let request = MKLocalPointsOfInterestRequest(coordinateRegion: region) 153 | let request = MKLocalSearch.Request() 154 | request.region = region 155 | request.naturalLanguageQuery = filteredCategories.first?.name 156 | request.pointOfInterestFilter = filter 157 | search = MKLocalSearch(request: request) 158 | } 159 | 160 | search.start { response, error in 161 | guard let response else { 162 | self.otherItemsState = .error(error) 163 | return 164 | } 165 | 166 | self.otherItems = response.mapItems.compactMap(MapItemController.init(mapItem:)) 167 | self.otherItemsState = .completed 168 | self.otherItemsResultRegion = response.boundingRegion 169 | } 170 | lastLocalSearch = search 171 | } 172 | } 173 | 174 | // MARK: - Geocoder 175 | extension MapItemSearchController { 176 | fileprivate func reloadGeocoder() { 177 | geocoder.cancelGeocode() 178 | 179 | guard !searchTerm.isEmpty else { 180 | cityItems = [] 181 | cityItemsState = .noSearch 182 | return 183 | } 184 | self.cityItemsState = .searching 185 | 186 | geocoder.geocodeAddressString(searchTerm) { placemarks, error in 187 | guard let placemarks else { 188 | self.cityItemsState = .error(error) 189 | return 190 | } 191 | 192 | self.cityItems = placemarks.compactMap { MapItemController(mapItem: MKMapItem(placemark: .init(placemark: $0))) } 193 | self.cityItemsState = .completed 194 | } 195 | } 196 | } 197 | 198 | // MARK: - SearchCompleter 199 | extension MapItemSearchController: MKLocalSearchCompleterDelegate { 200 | fileprivate func reloadCompleter(region: MKCoordinateRegion) { 201 | completer.cancel() 202 | 203 | guard !searchTerm.isEmpty else { 204 | completions = [] 205 | completionsState = .noSearch 206 | return 207 | } 208 | self.completionsState = .searching 209 | 210 | completer.region = region 211 | // TODO: completer.pointOfInterestFilter = filter? 212 | completer.queryFragment = searchTerm 213 | } 214 | 215 | func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { 216 | // This hack is necessary since, as usual, apples frameworks don't work and `suggestor.resultTypes = .query` has NO effect. 217 | completions = completer.results.filter({ !$0.subtitle.contains(where: \.isNumber) }) 218 | completionsState = .completed 219 | } 220 | 221 | func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) { 222 | completionsState = .error(error) 223 | } 224 | } 225 | 226 | // MARK: Completion Items 227 | extension MapItemSearchController { 228 | 229 | var singularCompletionItem: MapItemController? { 230 | completionItems?.count == 1 ? completionItems?.first : nil 231 | } 232 | 233 | func search(with completion: MKLocalSearchCompletion) { 234 | completionLocalSearch?.cancel() 235 | completionItems = nil 236 | 237 | searchedCompletion = completion 238 | 239 | completionLocalSearch = MKLocalSearch(request: .init(completion: completion)) 240 | completionLocalSearch!.start(completionHandler: { response, error in 241 | guard let response else { 242 | // TODO: ErrorHelper.shared.errorOccured(error!) 243 | return 244 | } 245 | 246 | RunLoop.main.perform { 247 | self.completionItems = response.mapItems.compactMap(MapItemController.init(mapItem:)) 248 | if let singularCompletionItem = self.singularCompletionItem { 249 | RecentMapItemsController.shared.addOrUpdate(mapItem: singularCompletionItem.item) 250 | } 251 | } 252 | }) 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Controllers/MapItemSearchViewCoordinator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import MapKit 4 | 5 | /// A class that coordinates a MapItemPicker. It can retrieve or set the region of the map view. 6 | public final class MapItemPickerController: NSObject, ObservableObject { 7 | @Published private(set) var selectedMapItem: MapItemController? = nil { 8 | didSet { 9 | if oldValue != selectedMapItem { 10 | RecentMapItemsController.shared.mapItemWasSelected(selectedMapItem) 11 | } 12 | } 13 | } 14 | @Published var selectedMapItemCluster: MKClusterAnnotation? = nil 15 | @Published private(set) var shouldShowTopLeftButtons: Bool = true 16 | private var savedRectToSet: (rect: MKMapRect, animated: Bool)? = nil 17 | @Published internal var currentMapView: MKMapView? { 18 | didSet { 19 | if let savedRectToSet { 20 | self.savedRectToSet = nil 21 | set(rect: savedRectToSet.rect, animated: savedRectToSet.animated) 22 | } 23 | } 24 | } 25 | internal var currentMainController: UIViewController? 26 | 27 | let locationController = LocationController() 28 | let searcher = MapItemSearchController() 29 | 30 | var annotationSelectionHandler: ((MKAnnotation) -> Void)! = nil 31 | var overlayRenderer: ((MKOverlay) -> MKOverlayRenderer)! = nil 32 | var annotationView: ((MKAnnotation) -> MKAnnotationView)! = nil 33 | 34 | /// Creates a new `MapItemPickerController`. 35 | public override init() { 36 | super.init() 37 | 38 | searcher.coordinator = self 39 | } 40 | 41 | func manuallySet(selectedMapItem: MapItemController?) { 42 | // This usually happens within a view update so we use a Task here 43 | Task { @MainActor in 44 | guard let mapView = currentMapView else { return } 45 | 46 | self.selectedMapItem = selectedMapItem 47 | reloadSelectedAnnotation() 48 | } 49 | } 50 | 51 | func reloadSelectedAnnotation() { 52 | let selectedMapItem = self.selectedMapItem ?? searcher.singularCompletionItem 53 | 54 | guard let mapView = currentMapView else { return } 55 | 56 | if let selectedMapItem { 57 | let annotations = mapView.selectedAnnotations + mapView.annotations//.filter({ !($0 is MKClusterAnnotation) }) 58 | let annotation = 59 | annotations.first(where: { 60 | ($0 as? MKClusterAnnotation)?.memberAnnotations.contains(annotation: selectedMapItem) ?? false 61 | }) ?? selectedMapItem 62 | 63 | let point = MKMapPoint(annotation.coordinate) 64 | if !mapView.visibleMapRect.contains(point) { 65 | setBestRegion(for: [point], animated: true) 66 | } 67 | mapView.selectAnnotation(annotation, animated: true) 68 | } else if let selectedMapItemCluster { 69 | mapView.selectAnnotation(selectedMapItemCluster, animated: true) 70 | } else { 71 | mapView.deselectAnnotation(nil, animated: true) 72 | } 73 | } 74 | 75 | } 76 | 77 | // MARK: - MKMapViewDelegate 78 | extension MapItemPickerController: MKMapViewDelegate { 79 | 80 | public func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { 81 | overlayRenderer(overlay) 82 | } 83 | 84 | public func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { 85 | if let coordinator = annotation as? MapItemController { 86 | let view = MKMarkerAnnotationView(annotation: coordinator, reuseIdentifier: nil) 87 | view.glyphImage = .init(systemName: coordinator.item.imageName) 88 | view.markerTintColor = coordinator.item.uiColor 89 | view.clusteringIdentifier = "mapItem" 90 | return view 91 | } else if let user = annotation as? MKUserLocation { 92 | return mapView.dequeueReusableAnnotationView(withIdentifier: "userLocation") ?? 93 | MKUserLocationView(annotation: user, reuseIdentifier: "userLocation") 94 | } else if let cluster = annotation as? MKClusterAnnotation, cluster.memberAnnotations.contains(where: { $0 is MapItemController }) { 95 | let coordinators = cluster.memberAnnotations.filter({ $0 is MapItemController }) as! [MapItemController] 96 | let occurancesByColor: [UIColor: Int]? = coordinators.reduce(into: [:]) { partialResult, coordinator in 97 | partialResult[coordinator.item.uiColor, default: 0] += 1 98 | } 99 | let color = occurancesByColor?.keys(for: occurancesByColor?.values.max() ?? 0).first ?? .gray 100 | 101 | let view = MKMarkerAnnotationView(annotation: cluster, reuseIdentifier: nil) 102 | view.glyphText = "\(coordinators.count)" 103 | view.markerTintColor = color 104 | return view 105 | } else if #available(iOS 16, *), let featureAnnotation = annotation as? MKMapFeatureAnnotation { 106 | let view = MKMarkerAnnotationView(annotation: featureAnnotation, reuseIdentifier: nil) 107 | view.glyphImage = featureAnnotation.iconStyle?.image 108 | view.markerTintColor = featureAnnotation.iconStyle?.backgroundColor 109 | return view 110 | } 111 | 112 | return annotationView(annotation) 113 | } 114 | 115 | public func mapView(_ mapView: MKMapView, clusterAnnotationForMemberAnnotations memberAnnotations: [MKAnnotation]) -> MKClusterAnnotation { 116 | .init(memberAnnotations: memberAnnotations) 117 | } 118 | 119 | public func mapView(_ mapView: MKMapView, didSelect annotation: MKAnnotation) { 120 | if let completionItem = searcher.singularCompletionItem, annotation as? MapItemController == completionItem { return } 121 | 122 | var selectedMapItem: MapItemController? = nil 123 | if let coordinator = annotation as? MapItemController { 124 | selectedMapItem = coordinator 125 | } else if let cluster = annotation as? MKClusterAnnotation, cluster.memberAnnotations.first is MapItemController { 126 | if let alreadySelected = self.selectedMapItem, cluster.memberAnnotations.contains(annotation: alreadySelected) { 127 | return 128 | } 129 | selectedMapItemCluster = cluster 130 | } else if #available(iOS 16, *), let item = annotation as? MKMapFeatureAnnotation { 131 | selectedMapItem = .init(mapFeatureAnnotation: item) 132 | } else { 133 | annotationSelectionHandler(annotation) 134 | } 135 | 136 | Task { @MainActor in 137 | self.selectedMapItem = selectedMapItem 138 | } 139 | } 140 | 141 | // This function is necessary since the annotation handed to `mapView(_ mapView: MKMapView, didDeselect annotation: MKAnnotation)` is sometimes nil. Casting this within the original function works in debug builds, but not in release builds (due to optimization, propably). 142 | private func didDeselect(optional annotation: MKAnnotation?) { 143 | guard let annotation = annotation else { return } 144 | 145 | if let cluster = annotation as? MKClusterAnnotation, cluster == selectedMapItemCluster { 146 | DispatchQueue.main.async { self.selectedMapItemCluster = nil } 147 | } else if 148 | let eq1 = annotation as? MapAnnotationEquatable, 149 | let eq2 = selectedMapItem as? MapAnnotationEquatable, 150 | eq1.annotationIsEqual(to: eq2) 151 | { 152 | DispatchQueue.main.async { self.selectedMapItem = nil } 153 | } else if annotation === selectedMapItem { 154 | DispatchQueue.main.async { self.selectedMapItem = nil } 155 | } 156 | } 157 | 158 | public func mapView(_ mapView: MKMapView, didDeselect annotation: MKAnnotation) { 159 | didDeselect(optional: annotation) 160 | } 161 | 162 | // MARK: Compatibility 163 | 164 | public func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) { 165 | if #available(iOS 16, *) { return } 166 | 167 | if let annotation = view.annotation { 168 | self.mapView(mapView, didSelect: annotation) 169 | } 170 | } 171 | 172 | public func mapView(_ mapView: MKMapView, didDeselect view: MKAnnotationView) { 173 | if #available(iOS 16, *) { return } 174 | 175 | if let annotation = view.annotation { 176 | self.mapView(mapView, didDeselect: annotation) 177 | } 178 | } 179 | 180 | public func mapView(_ mapView: MKMapView, didChange mode: MKUserTrackingMode, animated: Bool) { 181 | locationController.userTrackingMode = mode 182 | } 183 | 184 | public func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) { 185 | searcher.regionChanged() 186 | } 187 | } 188 | 189 | // MARK: - UISheetPresentationControllerDelegate 190 | extension MapItemPickerController: UISheetPresentationControllerDelegate { 191 | public func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) { 192 | withAnimation { 193 | shouldShowTopLeftButtons = sheetPresentationController.selectedDetentIdentifier != bigDetentIdentifier 194 | } 195 | } 196 | } 197 | 198 | // MARK: - Region Coordination 199 | extension MapItemPickerController { 200 | /// The region currently displayed by the map view. 201 | public var region: MKCoordinateRegion { currentMapView?.region ?? .unitedStates } 202 | 203 | /// Changes the currently visible portion of the map. 204 | /// - Parameters: 205 | /// - rect: The map rectangle to make visible in the map view. 206 | /// - animated: Specify `true` if you want the map view to animate the transition to the new map rectangle or `false` if you want the map to center on the specified rectangle immediately. 207 | public func set(rect: MKMapRect, animated: Bool) { 208 | let currentPresentationDetent = currentMapView?.window?.rootViewController?.highestPresentedController.sheetPresentationController?.selectedDetentIdentifier 209 | 210 | guard let currentMapView else { 211 | savedRectToSet = (rect, animated) 212 | return 213 | } 214 | 215 | currentMapView.setVisibleMapRect( 216 | rect, 217 | edgePadding: .init( 218 | top: 16, 219 | left: 16, 220 | bottom: (currentPresentationDetent == miniDetentIdentifier ? miniDetentHeight : standardDetentHeight) + 16, 221 | right: TopRightButtons.Constants.size + TopRightButtons.Constants.padding * 2 222 | ), 223 | animated: animated 224 | ) 225 | } 226 | 227 | /// Changes the currently visible portion of the map to the best found region for the given coordinates. 228 | /// - Parameters: 229 | /// - coordinates: The coordinates to form the map rectangle to make visible in the map view. 230 | /// - animated: Specify `true` if you want the map view to animate the transition to the new map rectangle or `false` if you want the map to center on the specified rectangle immediately. 231 | public func setBestRegion(for coordinates: [CLLocationCoordinate2D], animated: Bool) { 232 | setBestRegion(for: coordinates.map(MKMapPoint.init), animated: animated) 233 | } 234 | 235 | /// Changes the currently visible portion of the map to the best found region for the given points. 236 | /// - Parameters: 237 | /// - points: The points to form the map rectangle to make visible in the map view. 238 | /// - animated: Specify `true` if you want the map view to animate the transition to the new map rectangle or `false` if you want the map to center on the specified rectangle immediately. 239 | public func setBestRegion(for points: [MKMapPoint], animated: Bool) { 240 | if let rect = MKMapRect(bestFor: points) { 241 | set(rect: rect, animated: animated) 242 | } 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Controllers/RecentMapItemsController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import Combine 4 | import SchafKit 5 | 6 | /// A controller that holds the most recently selected map items. 7 | public class RecentMapItemsController: ObservableObject { 8 | /// The shared instance of `RecentMapItemsController`. 9 | public static let shared = RecentMapItemsController() 10 | 11 | // MARK: - Constants 12 | 13 | enum Constants { 14 | static let directory = SKDirectory.caches.directoryByAppending(path: MapItemPickerConstants.cacheDirectoryName, createIfNonexistant: true) 15 | 16 | static let recentMapItemsFilename = "recentMapItems.json" 17 | static let maximumNumberOfRecentMapItems = 20 18 | } 19 | 20 | // MARK: - Initializer 21 | 22 | private init() { 23 | recentMapItems = Constants.directory.getData(at: Constants.recentMapItemsFilename).map { data in 24 | (try? JSONDecoder().decode([MapItem].self, from: data)) ?? [] 25 | } ?? [] 26 | } 27 | 28 | // MARK: - Variables 29 | 30 | var currentMapItemControllerObserver: AnyCancellable? 31 | /// The most recently selected map items. 32 | @Published public private(set) var recentMapItems: [MapItem] { 33 | didSet { 34 | saveRecentMapItems() 35 | } 36 | } 37 | 38 | // MARK: - Public Functions 39 | 40 | /// Adds or updates the given map item. 41 | /// 42 | /// - note: If a similar map item is already in the stack, it will be removed and the given map item will be added to the top of the list. 43 | public func addOrUpdate(mapItem: MapItem) { 44 | var newMapItems = recentMapItems 45 | 46 | newMapItems.removeAll(where: { 47 | mapItem.name == $0.name && mapItem.location == $0.location 48 | }) 49 | newMapItems.insert(mapItem, at: 0) 50 | 51 | while newMapItems.count > Constants.maximumNumberOfRecentMapItems { 52 | newMapItems.removeLast() 53 | } 54 | 55 | recentMapItems = newMapItems 56 | } 57 | 58 | // MARK: - Internal Functions 59 | 60 | func mapItemWasSelected(_ mapItemController: MapItemController?) { 61 | guard let mapItemController else { 62 | currentMapItemControllerObserver = nil 63 | return 64 | } 65 | 66 | addOrUpdate(mapItem: mapItemController.item) 67 | currentMapItemControllerObserver = mapItemController.objectWillChange.sink { _ in 68 | RunLoop.main.perform { 69 | self.addOrUpdate(mapItem: mapItemController.item) 70 | } 71 | } 72 | } 73 | 74 | private func saveRecentMapItems() { 75 | if let data = try? JSONEncoder().encode(recentMapItems) { 76 | Constants.directory.save(data: data, at: Constants.recentMapItemsFilename) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Data/MIPAction.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct MIPAction: Identifiable { 4 | 5 | public let id = UUID() 6 | 7 | public let imageName: String 8 | public let handler: () -> Void 9 | 10 | public init(imageName: String, handler: @escaping () -> Void) { 11 | self.imageName = imageName 12 | self.handler = handler 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Data/MapItem/+FeatureType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension MapItem { 4 | public enum FeatureType : Int, Codable { 5 | case pointOfInterest = 0 6 | case territory = 1 7 | case physicalFeature = 2 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Data/MapItem/MapItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import MapKit 3 | import SwiftUI 4 | import AddressBook 5 | import SchafKit 6 | 7 | public struct MapItem: Codable { 8 | public init(name: String, location: CLLocationCoordinate2D, region: CLCodableCircularRegion? = nil, featureAnnotationType: FeatureType? = nil, category: MapItemCategory? = nil, notes: String? = nil, street: String? = nil, housenumber: String? = nil, postcode: String? = nil, cityRegion: String? = nil, city: String? = nil, state: String? = nil, stateRegion: String? = nil, country: String? = nil, phone: String? = nil, website: String? = nil, wikidataBrand: String? = nil, wikipediaBrand: String? = nil, hasVegetarianFood: ExclusivityBool? = nil, hasVeganFood: ExclusivityBool? = nil, indoorSeating: PlaceBool? = nil, outdoorSeating: PlaceBool? = nil, internetAccess: InternetAccessType? = nil, smoking: PlaceBool? = nil, takeaway: ExclusivityBool? = nil, wheelchair: WheelchairBool? = nil, level: String? = nil, openingHours: OpeningHours? = nil) { 9 | self.name = name 10 | self.location = location 11 | self.region = region 12 | self.featureAnnotationType = featureAnnotationType 13 | self.identifiers = [:] 14 | self.category = category 15 | self.notes = notes 16 | self.street = street 17 | self.housenumber = housenumber 18 | self.postcode = postcode 19 | self.cityRegion = cityRegion 20 | self.city = city 21 | self.state = state 22 | self.stateRegion = stateRegion 23 | self.country = country 24 | self.phone = phone 25 | self.website = website 26 | self.wikidataBrand = wikidataBrand 27 | self.wikipediaBrand = wikipediaBrand 28 | self.hasVegetarianFood = hasVegetarianFood 29 | self.hasVeganFood = hasVeganFood 30 | self.indoorSeating = indoorSeating 31 | self.outdoorSeating = outdoorSeating 32 | self.internetAccess = internetAccess 33 | self.smoking = smoking 34 | self.takeaway = takeaway 35 | self.wheelchair = wheelchair 36 | self.level = level 37 | self.openingHours = openingHours 38 | } 39 | 40 | enum IdentifierType: String, Codable { 41 | case openStreetMap, wikidata, wikimediaCommonsCategory, wikidataCommonsImageFilename, wikidataCommonsNighttimeViewImageFilename 42 | } 43 | 44 | public let name: String 45 | public let location: CLLocationCoordinate2D 46 | public var region: CLCodableCircularRegion? 47 | public let featureAnnotationType: FeatureType? 48 | 49 | var identifiers: [IdentifierType: String] 50 | 51 | public var altitude: Int? 52 | public var area: Double? 53 | public var population: Int? 54 | 55 | public var category: MapItemCategory? 56 | public var categoryString: String? 57 | public var wikiDescription: String? 58 | 59 | public var notes: String? 60 | 61 | public var street: String? 62 | public var housenumber: String? 63 | public var postcode: String? 64 | public var cityRegion: String? 65 | public var city: String? 66 | public var state: String? 67 | public var stateRegion: String? 68 | public var country: String? 69 | 70 | public var inlandWater: String? 71 | public var ocean: String? 72 | 73 | public var phone: String? 74 | public var website: String? 75 | 76 | public var wikidataBrand: String? 77 | public var wikipediaBrand: String? 78 | public var wikipediaURL: String? 79 | 80 | public var hasVegetarianFood: ExclusivityBool? 81 | public var hasVeganFood: ExclusivityBool? 82 | 83 | public var indoorSeating: PlaceBool? 84 | public var outdoorSeating: PlaceBool? 85 | public var internetAccess: InternetAccessType? 86 | public var smoking: PlaceBool? 87 | public var takeaway: ExclusivityBool? 88 | public var wheelchair: WheelchairBool? 89 | 90 | public var level: String? 91 | 92 | public var openingHours: OpeningHours? 93 | 94 | var imageName: String { 95 | if let category { 96 | return category.imageName 97 | } 98 | guard let featureAnnotationType, featureAnnotationType == .territory else { 99 | return "mappin" 100 | } 101 | 102 | if street != nil { 103 | return "mappin" 104 | } 105 | if city != nil { 106 | return "building.2.fill" 107 | } 108 | return "flag.fill" 109 | } 110 | 111 | var color: Color { 112 | .init(uiColor: uiColor) 113 | } 114 | 115 | var uiColor: UIColor { 116 | if let category { 117 | return category.color 118 | } 119 | 120 | if featureAnnotationType == .territory && street == nil { 121 | return .gray 122 | } 123 | 124 | return .init(red: 1, green: 0.25, blue: 0.25) 125 | } 126 | 127 | var typeName: String { 128 | if let category { 129 | return category.name 130 | } 131 | if let categoryString { 132 | return categoryString.capitalized 133 | } 134 | 135 | // TODO: Make this functional without a `featureAnnotationType` (See README TODO #1) 136 | guard let featureAnnotationType, featureAnnotationType == .territory else { 137 | return "mapItem.type.item".moduleLocalized 138 | } 139 | 140 | if street != nil { 141 | return "mapItem.type.address".moduleLocalized 142 | } 143 | if cityRegion != nil && cityRegion != city { 144 | return "mapItem.type.cityRegion".moduleLocalized 145 | } 146 | if city != nil { 147 | return "mapItem.type.city".moduleLocalized 148 | } 149 | if inlandWater != nil { 150 | return "mapItem.type.inlandWater".moduleLocalized 151 | } 152 | if state != nil { 153 | return "mapItem.type.state".moduleLocalized 154 | } 155 | if ocean != nil { 156 | return "mapItem.type.ocean".moduleLocalized 157 | } 158 | if country != nil { 159 | return "mapItem.type.country".moduleLocalized 160 | } 161 | return "mapItem.type.territory".moduleLocalized 162 | } 163 | 164 | var addressLines: [String] { 165 | [ 166 | [street, housenumber].removingNils().joined(separator: " "), 167 | [postcode, city].removingNils().joined(separator: " "), 168 | state, 169 | country 170 | ] 171 | .removingNils() 172 | .filter({ $0.count > 1 }) 173 | } 174 | 175 | var readableAddress: String? { 176 | [[street, housenumber].removingNils().joined(separator: " "), city, state, country] 177 | .removingNils() 178 | .filter({ $0.count > 1 }) 179 | .enumerated() 180 | .filter({ $0.offset > 0 || $0.element != self.name }) 181 | .map(\.element) 182 | .joined(separator: ", ") 183 | .nilIfEmpty 184 | } 185 | 186 | var subtitle: String { 187 | if let readableAddress { 188 | return "\(typeName) · \(readableAddress)" 189 | } 190 | return typeName 191 | } 192 | 193 | public var nativeMapItemRepresentation: MKMapItem { 194 | let mapItem = MKMapItem( 195 | placemark: MapItemMKPlacemark(mapItem: self) 196 | ) 197 | 198 | mapItem.name = name 199 | mapItem.pointOfInterestCategory = category?.nativeCategory 200 | mapItem.phoneNumber = phone 201 | mapItem.url = website.map({ URL(string: $0) }) ?? nil 202 | 203 | return mapItem 204 | } 205 | 206 | public var nativeLocation: CLLocation { 207 | .init(latitude: location.latitude, longitude: location.longitude) 208 | } 209 | } 210 | 211 | class MapItemMKPlacemark: MKPlacemark, @unchecked Sendable { 212 | let mapItem: MapItem 213 | 214 | init(mapItem: MapItem) { 215 | self.mapItem = mapItem 216 | 217 | super.init(coordinate: mapItem.location) 218 | } 219 | 220 | required init?(coder: NSCoder) { 221 | fatalError("init(coder:) has not been implemented") 222 | } 223 | 224 | override var thoroughfare: String? { 225 | mapItem.street 226 | } 227 | 228 | override var subThoroughfare: String? { 229 | mapItem.housenumber 230 | } 231 | 232 | override var postalCode: String? { 233 | mapItem.postcode 234 | } 235 | 236 | override var locality: String? { 237 | mapItem.city 238 | } 239 | 240 | override var subLocality: String? { 241 | mapItem.cityRegion 242 | } 243 | 244 | override var administrativeArea: String? { 245 | mapItem.state 246 | } 247 | 248 | override var subAdministrativeArea: String? { 249 | mapItem.stateRegion 250 | } 251 | 252 | override var country: String? { 253 | mapItem.country 254 | } 255 | 256 | // TODO: Country Code 257 | } 258 | 259 | extension MapItem: Hashable, Equatable { 260 | public func hash(into hasher: inout Hasher) { 261 | hasher.combine(name) 262 | hasher.combine(category) 263 | hasher.combine(city) 264 | hasher.combine(stateRegion) 265 | hasher.combine(state) 266 | hasher.combine(country) 267 | } 268 | 269 | public static func ==(lhs: MapItem, rhs: MapItem) -> Bool { 270 | lhs.hashValue == rhs.hashValue 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Data/MapItem/MapItemCategory.swift: -------------------------------------------------------------------------------- 1 | import MapKit 2 | import SwiftUI 3 | 4 | public enum MapItemCategory: String, Codable, CaseIterable, Identifiable { 5 | case airport, amusementPark, aquarium, atm, bakery, bank, beach, brewery, cafe, campground, carRental, evCharger, fireStation, fitnessCenter, foodMarket, gasStation, hospital, hotel, laundry, library, marina, movieTheater, museum, nationalPark, nightlife, park, parking, pharmacy, police, postOffice, publicTransport, restaurant, restroom, school, stadium, store, theater, university, winery, zoo 6 | 7 | init?(nativeCategory: MKPointOfInterestCategory) { 8 | switch nativeCategory { 9 | case .airport: 10 | self = .airport 11 | case .amusementPark: 12 | self = .amusementPark 13 | case .aquarium: 14 | self = .aquarium 15 | case .atm: 16 | self = .atm 17 | case .bakery: 18 | self = .bakery 19 | case .bank: 20 | self = .bank 21 | case .beach: 22 | self = .beach 23 | case .brewery: 24 | self = .brewery 25 | case .cafe: 26 | self = .cafe 27 | case .campground: 28 | self = .campground 29 | case .carRental: 30 | self = .carRental 31 | case .evCharger: 32 | self = .evCharger 33 | case .fireStation: 34 | self = .fireStation 35 | case .fitnessCenter: 36 | self = .fitnessCenter 37 | case .foodMarket: 38 | self = .foodMarket 39 | case .gasStation: 40 | self = .gasStation 41 | case .hospital: 42 | self = .hospital 43 | case .hotel: 44 | self = .hotel 45 | case .laundry: 46 | self = .laundry 47 | case .library: 48 | self = .library 49 | case .marina: 50 | self = .marina 51 | case .movieTheater: 52 | self = .movieTheater 53 | case .museum: 54 | self = .museum 55 | case .nationalPark: 56 | self = .nationalPark 57 | case .nightlife: 58 | self = .nightlife 59 | case .park: 60 | self = .park 61 | case .parking: 62 | self = .parking 63 | case .pharmacy: 64 | self = .pharmacy 65 | case .police: 66 | self = .police 67 | case .postOffice: 68 | self = .postOffice 69 | case .publicTransport: 70 | self = .publicTransport 71 | case .restaurant: 72 | self = .restaurant 73 | case .restroom: 74 | self = .restroom 75 | case .school: 76 | self = .school 77 | case .stadium: 78 | self = .stadium 79 | case .store: 80 | self = .store 81 | case .theater: 82 | self = .theater 83 | case .university: 84 | self = .university 85 | case .winery: 86 | self = .winery 87 | case .zoo: 88 | self = .zoo 89 | default: 90 | return nil 91 | } 92 | } 93 | 94 | var nativeCategory: MKPointOfInterestCategory { 95 | switch self { 96 | case .airport: .airport 97 | case .amusementPark: .amusementPark 98 | case .aquarium: .aquarium 99 | case .atm: .atm 100 | case .bakery: .bakery 101 | case .bank: .bank 102 | case .beach: .beach 103 | case .brewery: .brewery 104 | case .cafe: .cafe 105 | case .campground: .campground 106 | case .carRental: .carRental 107 | case .evCharger: .evCharger 108 | case .fireStation: .fireStation 109 | case .fitnessCenter: .fitnessCenter 110 | case .foodMarket: .foodMarket 111 | case .gasStation: .gasStation 112 | case .hospital: .hospital 113 | case .hotel: .hotel 114 | case .laundry: .laundry 115 | case .library: .library 116 | case .marina: .marina 117 | case .movieTheater: .movieTheater 118 | case .museum: .museum 119 | case .nationalPark: .nationalPark 120 | case .nightlife: .nightlife 121 | case .park: .park 122 | case .parking: .parking 123 | case .pharmacy: .pharmacy 124 | case .police: .police 125 | case .postOffice: .postOffice 126 | case .publicTransport: .publicTransport 127 | case .restaurant: .restaurant 128 | case .restroom: .restroom 129 | case .school: .school 130 | case .stadium: .stadium 131 | case .store: .store 132 | case .theater: .theater 133 | case .university: .university 134 | case .winery: .winery 135 | case .zoo: .zoo 136 | } 137 | } 138 | 139 | public var id: String { rawValue } 140 | 141 | public var name: String { 142 | "category.\(rawValue)".moduleLocalized 143 | } 144 | 145 | public var circledImageName: String? { 146 | switch self { 147 | case .airport: 148 | return "airplane.circle.fill" 149 | case .amusementPark: 150 | return nil // No equivalent for "sparkles.circle.fill" 151 | case .aquarium: 152 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 153 | return "fish.circle.fill" 154 | } 155 | return "mappin.circle.fill" 156 | case .atm: 157 | return "dollarsign.circle.fill" 158 | case .bakery: 159 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 160 | return nil // No equivalent for "birthday.cake.circle.fill" 161 | } 162 | return "mappin.circle.fill" 163 | case .bank: 164 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 165 | return "person.bust.circle.fill" 166 | } 167 | return "dollarsign.circle.fill" 168 | case .beach: 169 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 170 | return nil // No "beach.umbrella.circle.fill" 171 | } 172 | return "drop.circle.fill" 173 | case .brewery: 174 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 175 | return nil // No "wineglass.circle.fill" 176 | } 177 | return nil 178 | case .cafe: 179 | return nil // No "cup.and.saucer.circle.fill" 180 | case .campground: 181 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 182 | return nil // No "tent.circle.fill" 183 | } 184 | return nil 185 | case .carRental: 186 | return nil // No "car.2.circle.fill" 187 | case .evCharger: 188 | return nil // No "powerplug.circle.fill" 189 | case .fireStation: 190 | return "flame.circle.fill" 191 | case .fitnessCenter: 192 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 193 | return nil // No "dumbbell.circle.fill" 194 | } 195 | return nil 196 | case .foodMarket: 197 | return nil // No "basket.circle.fill" 198 | case .gasStation: 199 | return "fuelpump.circle.fill" 200 | case .hospital: 201 | return "cross.circle.fill" 202 | case .hotel: 203 | return "bed.double.circle.fill" 204 | case .laundry: 205 | if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) { 206 | return "tshirt.circle.fill" 207 | } 208 | return nil 209 | case .library: 210 | return "books.vertical.circle.fill" 211 | case .marina: 212 | return nil // No "ferry.circle.fill" 213 | case .movieTheater: 214 | return "theatermasks.circle.fill" 215 | case .museum: 216 | return "building.columns.circle.fill" 217 | case .nationalPark: 218 | return "star.circle.fill" 219 | case .nightlife: 220 | if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) { 221 | return "figure.dance.circle.fill" 222 | } 223 | return nil 224 | case .park: 225 | if #available(iOS 16.1, macOS 13.0, tvOS 16.1, watchOS 9.1, *) { 226 | return "tree.circle.fill" 227 | } 228 | return "leaf.circle.fill" 229 | case .parking: 230 | return "parkingsign.circle.fill" 231 | case .pharmacy: 232 | return "pills.circle.fill" 233 | case .police: 234 | return "shield.circle.fill" 235 | case .postOffice: 236 | return "envelope.circle.fill" 237 | case .publicTransport: 238 | return "tram.circle.fill" 239 | case .restaurant: 240 | return "fork.knife.circle.fill" 241 | case .restroom: 242 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 243 | return "toilet.circle.fill" 244 | } 245 | return "mappin.circle.fill" 246 | case .school: 247 | return "graduationcap.circle.fill" 248 | case .stadium: 249 | return "sportscourt.circle.fill" 250 | case .store: 251 | return "bag.circle.fill" 252 | case .theater: 253 | return "theatermasks.circle.fill" 254 | case .university: 255 | return "graduationcap.circle.fill" 256 | case .winery: 257 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 258 | return nil // No "wineglass.circle.fill" 259 | } 260 | return nil // No "takeoutbag.and.cup.and.straw.fill" 261 | case .zoo: 262 | return nil // No "tortoise.circle.fill" 263 | } 264 | } 265 | 266 | public var imageName: String { 267 | switch self { 268 | case .airport: 269 | return "airplane" 270 | case .amusementPark: 271 | return "sparkles" 272 | case .aquarium: 273 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 274 | return "fish.fill" 275 | } 276 | return "mappin" 277 | case .atm: 278 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 279 | return "dollarsign" 280 | } 281 | return "dollarsign.circle.fill" 282 | case .bakery: 283 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 284 | return "birthday.cake" 285 | } 286 | return "mappin" 287 | case .bank: 288 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 289 | return "person.bust.fill" 290 | } 291 | return "dollarsign.circle.fill" 292 | case .beach: 293 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 294 | return "beach.umbrella.fill" 295 | } 296 | return "drop.fill" 297 | case .brewery: 298 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 299 | return "wineglass.fill" 300 | } 301 | return "takeoutbag.and.cup.and.straw.fill" 302 | case .cafe: 303 | return "cup.and.saucer.fill" 304 | case .campground: 305 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 306 | return "tent.fill" 307 | } 308 | return "powersleep" 309 | case .carRental: 310 | return "car.2.fill" 311 | case .evCharger: 312 | return "powerplug.fill" 313 | case .fireStation: 314 | return "flame.fill" 315 | case .fitnessCenter: 316 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 317 | return "dumbbell.fill" 318 | } 319 | return "sportscourt.fill" 320 | case .foodMarket: 321 | return "basket.fill" 322 | case .gasStation: 323 | return "fuelpump.fill" 324 | case .hospital: 325 | return "cross.fill" 326 | case .hotel: 327 | return "bed.double.fill" 328 | case .laundry: 329 | return "tshirt.fill" 330 | case .library: 331 | return "books.vertical.fill" 332 | case .marina: 333 | return "ferry.fill" 334 | case .movieTheater: 335 | return "theatermasks.fill" 336 | case .museum: 337 | return "building.columns.fill" 338 | case .nationalPark: 339 | return "star.fill" 340 | case .nightlife: 341 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 342 | return "figure.dance" 343 | } 344 | return "takeoutbag.and.cup.and.straw.fill" 345 | case .park: 346 | if #available(iOS 16.1, macOS 13.0, tvOS 16.1, watchOS 9.1, *) { 347 | return "tree.fill" 348 | } 349 | return "leaf.fill" 350 | case .parking: 351 | return "parkingsign" 352 | case .pharmacy: 353 | return "pills.fill" 354 | case .police: 355 | return "shield.fill" 356 | case .postOffice: 357 | return "envelope.fill" 358 | case .publicTransport: 359 | return "tram.fill" 360 | case .restaurant: 361 | return "fork.knife" 362 | case .restroom: 363 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 364 | return "toilet.fill" 365 | } 366 | return "mappin" 367 | case .school: 368 | return "graduationcap.fill" 369 | case .stadium: 370 | return "sportscourt.fill" 371 | case .store: 372 | return "bag.fill" 373 | case .theater: 374 | return "theatermasks.fill" 375 | case .university: 376 | return "graduationcap.fill" 377 | case .winery: 378 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 379 | return "wineglass.fill" 380 | } 381 | return "takeoutbag.and.cup.and.straw.fill" 382 | case .zoo: 383 | return "tortoise.fill" 384 | } 385 | } 386 | 387 | public var color: UIColor { 388 | switch self { 389 | case .bakery, .brewery, .cafe, .restaurant, .nightlife: 390 | return .systemOrange 391 | case .laundry, .foodMarket, .store: 392 | return .systemYellow 393 | case .amusementPark, .aquarium, .movieTheater, .museum, .theater, .winery, .zoo: 394 | return .systemPink 395 | case .atm, .bank, .carRental, .police, .postOffice: 396 | return .systemGray 397 | case .fitnessCenter, .beach, .marina: 398 | return .systemCyan 399 | case .airport, .gasStation, .parking, .publicTransport: 400 | return .systemBlue 401 | case .evCharger: 402 | return .systemMint 403 | case .campground, .nationalPark, .park, .stadium: 404 | return .systemGreen 405 | case .fireStation, .hospital, .pharmacy: 406 | return .systemRed 407 | case .hotel, .restroom: 408 | return .systemPurple 409 | case .library, .school, .university: 410 | return .systemBrown 411 | } 412 | } 413 | } 414 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Data/MapItem/MapItemImage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | struct MapItemImage: Identifiable, Hashable { 5 | 6 | enum Source: String { 7 | case wikipedia 8 | 9 | var nameLocalizationKey: String { 10 | "image.source.\(rawValue)" 11 | } 12 | } 13 | 14 | var id: String { url.absoluteString } 15 | 16 | func hash(into hasher: inout Hasher) { 17 | hasher.combine(url) 18 | } 19 | 20 | let url: URL 21 | let thumbnailUrl: URL 22 | let description: String? 23 | 24 | let source: Source 25 | let sourceUrl: URL 26 | } 27 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Data/MapItem/OpenStreetMaps/OSM+retrieveForMapItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import MapKit 3 | import SchafKit 4 | 5 | public extension OSMItem { 6 | static func retrieve(for mapItem: MapItem) async throws -> OSMItem? { 7 | 8 | func normalize(string: String) -> String { // TODO: str. -> strasse, etc. 9 | let lowercased = string.lowercased() 10 | let lettersOnly = lowercased.filter(\.isLetter) 11 | 12 | if lettersOnly.isEmpty { 13 | return lowercased 14 | } 15 | return lettersOnly 16 | } 17 | 18 | let c = mapItem.location 19 | let requestString = "way[\"amenity\"](around:100,\(c.latitude),\(c.longitude));".replacingOccurrences(of: "\"", with: "%22") 20 | 21 | let data = try await OpenStreetMapOverpassAPI.shared.send(request: requestString) 22 | let places = try JSONDecoder().decode(TopLevelItemDecoding.self, from: data) 23 | 24 | let itemName = normalize(string: mapItem.name) 25 | 26 | let itemStreet: String? = mapItem.street.map(normalize(string:)) 27 | let houseNumber: String? = mapItem.housenumber?.lowercased() 28 | 29 | let itemPostcode = mapItem.postcode?.lowercased() 30 | 31 | let minimumScore = 6.0 32 | var currentPlace: OSMItem? 33 | var currentScore: Double? = nil 34 | for place in places.elements.compactMap(\.tags) { 35 | var score = 0.0 36 | 37 | if let name = place.name { 38 | let normalized = normalize(string: name) 39 | if itemName == normalized { 40 | score += 10 41 | } // TODO: levenshtein 42 | } 43 | 44 | if let itemStreet, let street = place.street, itemStreet == normalize(string: street) { 45 | score += 3 46 | } 47 | 48 | if let itemNumber = houseNumber, itemNumber == place.housenumber?.lowercased() { 49 | score += 3 50 | } 51 | 52 | if let itemPostcode = itemPostcode, itemPostcode == place.postcode?.lowercased() { 53 | score += 2 54 | } 55 | 56 | // TODO: Location 57 | 58 | if score >= minimumScore { 59 | if let currentScore, currentScore > score { 60 | continue 61 | } 62 | currentPlace = place 63 | currentScore = score 64 | } 65 | } 66 | 67 | return currentPlace 68 | } 69 | } 70 | 71 | class OpenStreetMapOverpassAPI { 72 | static let shared = OpenStreetMapOverpassAPI(url: "https://overpass-api.de/api") 73 | 74 | let endpoint: SKNetworking.Endpoint 75 | 76 | init(url: String) { 77 | endpoint = .init(url: url) 78 | } 79 | 80 | func send(request: String) async throws -> Data { 81 | try await endpoint.request(path: "interpreter?data=[out:json];(\(request));out;%3E;").data 82 | } 83 | } 84 | 85 | private struct TopLevelItemDecoding: Decodable { 86 | struct Element: Decodable { 87 | 88 | let type: String 89 | let id: Int 90 | 91 | // Way 92 | let nodes: [Int]? 93 | let tags: OSMItem? 94 | 95 | // Node 96 | let lat: Double? 97 | let lon: Double? 98 | } 99 | 100 | let elements: [Element] 101 | } 102 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Data/MapItem/OpenStreetMaps/OSMItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SchafKit 3 | 4 | public struct OSMItem: Decodable { 5 | enum CodingKeys: String, CodingKey { 6 | case id = "id" 7 | case name = "name", description = "description" 8 | case city = "addr:city", housenumber = "addr:housenumber", postcode = "addr:postcode", street = "addr:street" 9 | case brand = "brand", wikidataBrand = "brand:wikidata", wikipediaBrand = "brand:wikipedia" 10 | case amenity = "amenity", cuisine = "cuisine", hasVegetarianFood = "diet:vegetarian", hasVeganFood = "diet:vegan" 11 | case indoorSeating = "indoor_seating", outdoorSeating = "outdoor_seating", internetAccess = "internet_access", smoking = "smoking", takeaway = "takeaway", wheelchair = "wheelchair" 12 | case level = "level" 13 | case openingHours = "opening_hours" 14 | case phone = "phone", website = "website" 15 | } 16 | 17 | public let id: String? 18 | 19 | public let name: String? 20 | public let description: String? 21 | 22 | public let street: String? 23 | public let housenumber: String? 24 | public let postcode: String? 25 | public let city: String? 26 | 27 | public let phone: String? 28 | public let website: String? 29 | 30 | public let amenity: String? // TODO: Enum 31 | public let brand: String? 32 | public let wikidataBrand: String? 33 | public let wikipediaBrand: String? 34 | 35 | public let cuisine: String? // TODO: Enum 36 | public let hasVegetarianFood: ExclusivityBool? 37 | public let hasVeganFood: ExclusivityBool? 38 | 39 | public let indoorSeating: PlaceBool? 40 | public let outdoorSeating: PlaceBool? 41 | public let internetAccess: InternetAccessType? 42 | public let smoking: PlaceBool? 43 | public let takeaway: ExclusivityBool? 44 | public let wheelchair: WheelchairBool? 45 | 46 | public let level: String? 47 | 48 | public let openingHours: OpeningHours? 49 | 50 | /* TODO: 51 | "payment:american_express": "yes", 52 | "payment:maestro": "yes", 53 | "payment:mastercard": "yes", 54 | "payment:visa": "yes", 55 | "dogs": "no" 56 | "note:opening_hours": "oberer Teil des Restaurants hat ab 10:00 Uhr geöffnet", 57 | "operator" 58 | "contact:fax": "+49 911 2355033", 59 | "contact:phone": "+49 911 2355032", 60 | "toilets": "yes", 61 | see toilet thing 62 | website 63 | */ 64 | } 65 | 66 | public enum WheelchairBool: String, Codable { 67 | case yes, no, limited, designated 68 | } 69 | 70 | public enum ExclusivityBool: String, Codable { 71 | case yes, no, only 72 | } 73 | 74 | public enum PlaceBool: String, Codable { 75 | case yes, no 76 | 77 | public init(from decoder: Decoder) throws { 78 | let container = try decoder.singleValueContainer() 79 | 80 | let string = try container.decode(String.self) 81 | if string == "no" { 82 | self = .no 83 | } else { 84 | self = .yes 85 | } 86 | } 87 | } 88 | 89 | public enum InternetAccessType: String, Codable { 90 | case yes, no, wlan, terminal, service, wired 91 | } 92 | 93 | 94 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Data/MapItem/OpeningHours/DayTime.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension OpeningHours { 4 | 5 | public struct DayTime: Equatable, Comparable, Hashable { 6 | 7 | public static func < (lhs: OpeningHours.DayTime, rhs: OpeningHours.DayTime) -> Bool { 8 | lhs.dayMinutes < rhs.dayMinutes 9 | } 10 | 11 | static let midnight = DayTime(hour: 0, minute: 0) 12 | 13 | public let hour: Int 14 | public let minute: Int 15 | 16 | private var dayMinutes: Int { 17 | hour * 60 + minute 18 | } 19 | 20 | init?(string: String) { 21 | let parts = string.components(separatedBy: ":") 22 | guard 23 | parts.count == 2, 24 | let hour = Int(parts[0]), 25 | let minute = Int(parts[1]) 26 | else { return nil } 27 | 28 | self.hour = hour % 24 29 | self.minute = minute 30 | } 31 | 32 | init(hour: Int, minute: Int) { 33 | self.hour = hour 34 | self.minute = minute 35 | } 36 | 37 | var displayString: String { 38 | let calendar = Calendar.current 39 | 40 | guard let date = calendar.date(from: DateComponents(hour: hour, minute: minute)) else { 41 | return "?" 42 | } 43 | 44 | return DateFormatter(timeStyle: .short).string(from: date) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Data/MapItem/OpeningHours/DayTimeRange.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension OpeningHours { 4 | 5 | public struct DayTimeRange: Equatable, Hashable { 6 | 7 | public let from: DayTime 8 | public let to: DayTime 9 | 10 | init?(string: String) { 11 | let fromToParts = string.components(separatedBy: "-") 12 | guard fromToParts.count == 2 else { return nil } 13 | 14 | guard let from = DayTime(string: fromToParts[0]), let to = DayTime(string: fromToParts[1]) else { 15 | return nil 16 | } 17 | 18 | self.from = from 19 | self.to = to 20 | } 21 | 22 | init(from: OpeningHours.DayTime, to: OpeningHours.DayTime) { 23 | self.from = from 24 | self.to = to 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Data/MapItem/OpeningHours/DisplayableWeekPortion.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension OpeningHours { 4 | 5 | public struct DisplayableWeekPortion: Equatable, Hashable { 6 | public let weekdays: [Weekday] 7 | public let ranges: [DayTimeRange] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Data/MapItem/OpeningHours/OpeningHours.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct OpeningHours: Codable, Equatable, Hashable { 4 | 5 | public let sortedDisplayableWeekPortions: [DisplayableWeekPortion] 6 | 7 | public func encode(to encoder: Encoder) throws { 8 | fatalError() // TODO: This 9 | } 10 | 11 | public init(from decoder: Decoder) throws { 12 | let container = try decoder.singleValueContainer() 13 | let string = try container.decode(String.self) 14 | 15 | self.init(string: string) 16 | } 17 | 18 | init(sortedDisplayableWeekPortions: [DisplayableWeekPortion]) { 19 | self.sortedDisplayableWeekPortions = sortedDisplayableWeekPortions 20 | } 21 | 22 | init(string: String) { 23 | if string == "24/7" { 24 | self.sortedDisplayableWeekPortions = [.init(weekdays: Weekday.allWeekdays, ranges: [.init(from: .midnight, to: .midnight)])] 25 | return 26 | } 27 | 28 | var portions: [Weekday: [DayTimeRange]] = [:] 29 | 30 | var weekdays: [Weekday] = [] 31 | var iterationAlreadyAddedHours: Bool = false 32 | for semicolonPart in string.lowercased().components(separatedBy: [";", ", "]) { 33 | 34 | var remaining = semicolonPart.trimmingCharacters(in: .whitespaces) 35 | 36 | if remaining.first?.isLetter == true { 37 | if iterationAlreadyAddedHours { 38 | weekdays = [] 39 | iterationAlreadyAddedHours = false 40 | } 41 | 42 | let spaceSeparatedComponents = remaining.components(separatedBy: .whitespaces) 43 | guard let firstComponent = spaceSeparatedComponents.first, !firstComponent.isEmpty else { continue } 44 | 45 | let weekdayParts = spaceSeparatedComponents[0].components(separatedBy: ",") 46 | for weekdayPart in weekdayParts { 47 | let fromToParts = weekdayPart.components(separatedBy: "-") 48 | 49 | if fromToParts.count > 1 { 50 | guard let firstWeekday = Weekday(rawValue: fromToParts[0]), 51 | let lastWeekday = Weekday(rawValue: fromToParts[1]) 52 | else { assertionFailure() ; continue } 53 | 54 | var currentWeekday = firstWeekday 55 | while currentWeekday != lastWeekday { 56 | weekdays.append(currentWeekday) 57 | guard let next = currentWeekday.next else { break } 58 | currentWeekday = next 59 | } 60 | weekdays.append(currentWeekday) 61 | } else { 62 | guard let weekday = Weekday(rawValue: fromToParts[0]) else { assertionFailure() ; continue } 63 | weekdays.append(weekday) 64 | } 65 | } 66 | 67 | guard spaceSeparatedComponents.count == 2 else { continue } 68 | remaining = spaceSeparatedComponents[1] 69 | iterationAlreadyAddedHours = true 70 | } 71 | 72 | let hourString = remaining 73 | let hourRanges: [DayTimeRange] 74 | if hourString == "off" { 75 | hourRanges = [] 76 | } else { 77 | hourRanges = hourString 78 | .components(separatedBy: ",") 79 | .compactMap { rangePart in 80 | DayTimeRange(string: rangePart) 81 | } 82 | } 83 | 84 | for weekday in weekdays { 85 | guard !hourRanges.isEmpty else { 86 | portions[weekday] = [] 87 | continue 88 | } 89 | 90 | for hourRange in hourRanges { 91 | portions[weekday, default: []].append(hourRange) 92 | } 93 | } 94 | } 95 | 96 | for (weekday, hourRanges) in portions { 97 | for (index, hourRange) in hourRanges.enumerated() { 98 | if hourRange.from == .midnight, 99 | let last = weekday.last, 100 | let extendableIndex = portions[last]?.firstIndex(where: { $0.to == .midnight && $0.from > hourRange.to }) { 101 | 102 | let original = portions[last]![extendableIndex] 103 | portions[last]![extendableIndex] = .init(from: original.from, to: hourRange.to) 104 | portions[weekday]!.remove(at: index) 105 | break 106 | } 107 | } 108 | } 109 | 110 | let portionsArray = Array(portions) 111 | let dict = Dictionary( 112 | grouping: portionsArray, 113 | by: { SortHashable(isHoliday: $0.key == .holiday, isSchoolHoliday: $0.key == .schoolHoliday, ranges: $0.value) } 114 | ) 115 | self.sortedDisplayableWeekPortions = dict 116 | .values 117 | .map { element in 118 | DisplayableWeekPortion(weekdays: element.map({ $0.key }).weekdaySorted, ranges: element[0].value) 119 | } 120 | .filter({ !$0.ranges.isEmpty || $0.weekdays.contains(.holiday) || $0.weekdays.contains(.schoolHoliday) }) 121 | .sorted(by: \.weekdays.last!.sortIndex) 122 | } 123 | 124 | private struct SortHashable: Hashable { 125 | let isHoliday: Bool 126 | let isSchoolHoliday: Bool 127 | let ranges: [OpeningHours.DayTimeRange] 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Data/MapItem/OpeningHours/Weekday.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension OpeningHours { 4 | public enum Weekday: String, Codable { 5 | static var allWeekdays: [Weekday] { 6 | [.monday, .tuesday, .wednesday, .thursday, .friday, .saturday, .sunday] 7 | } 8 | 9 | case monday = "mo", tuesday = "tu", wednesday = "we", thursday = "th", friday = "fr", saturday = "sa", sunday = "su" 10 | case holiday = "ph", schoolHoliday = "sh" 11 | 12 | var next: Weekday? { 13 | switch self { 14 | case .monday: return .tuesday 15 | case .tuesday: return .wednesday 16 | case .wednesday: return .thursday 17 | case .thursday: return .friday 18 | case .friday: return .saturday 19 | case .saturday: return .sunday 20 | case .sunday: return .monday 21 | default: return nil 22 | } 23 | } 24 | 25 | var last: Weekday? { 26 | switch self { 27 | case .monday: return .sunday 28 | case .tuesday: return .monday 29 | case .wednesday: return .tuesday 30 | case .thursday: return .wednesday 31 | case .friday: return .thursday 32 | case .saturday: return .friday 33 | case .sunday: return .saturday 34 | default: return nil 35 | } 36 | } 37 | 38 | var sortIndex: Int { 39 | switch self { 40 | case .monday: return 0 41 | case .tuesday: return 1 42 | case .wednesday: return 2 43 | case .thursday: return 3 44 | case .friday: return 4 45 | case .saturday: return 5 46 | case .sunday: return 6 47 | case .holiday: return 7 48 | case .schoolHoliday: return 8 49 | } 50 | } 51 | 52 | var calendarIndex: Int? { 53 | switch self { 54 | case .sunday: return 0 55 | case .monday: return 1 56 | case .tuesday: return 2 57 | case .wednesday: return 3 58 | case .thursday: return 4 59 | case .friday: return 5 60 | case .saturday: return 6 61 | default: return nil 62 | } 63 | } 64 | 65 | var localizedName: String { 66 | guard let calendarIndex else { 67 | switch self { 68 | case .holiday: 69 | return "openingHours.holiday".moduleLocalized 70 | case .schoolHoliday: 71 | return "openingHours.schoolHoliday".moduleLocalized 72 | default: return "-" 73 | } 74 | } 75 | 76 | let prefLanguage = Locale.preferredLanguages[0] 77 | var calendar = Calendar(identifier: .gregorian) 78 | calendar.locale = NSLocale(localeIdentifier: prefLanguage) as Locale 79 | return calendar.standaloneWeekdaySymbols[calendarIndex] 80 | } 81 | 82 | var shortLocalizedName: String { 83 | guard let calendarIndex else { 84 | return localizedName 85 | } 86 | 87 | let prefLanguage = Locale.preferredLanguages[0] 88 | var calendar = Calendar(identifier: .gregorian) 89 | calendar.locale = NSLocale(localeIdentifier: prefLanguage) as Locale 90 | return calendar.shortStandaloneWeekdaySymbols[calendarIndex] 91 | } 92 | } 93 | } 94 | 95 | extension Array where Element == OpeningHours.Weekday { 96 | var weekdaySorted: Self { 97 | var new = sorted(by: \.sortIndex) 98 | 99 | guard new.count < 7 else { 100 | return new 101 | } 102 | 103 | while let last = new.last, last == new.first!.last { 104 | new.insert(new.removeLast(), at: 0) 105 | } 106 | 107 | return new 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Data/MapItem/WikiData/WD+retrieveForMapItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreLocation 3 | import SchafKit 4 | 5 | private let endpoint = SKNetworking.Endpoint(url: "https://query.wikidata.org/") 6 | 7 | extension String { 8 | /// A URL-encoded version of the receiver. 9 | var urlParameterEncoded : String { 10 | // TODO: Option to percent encode space 11 | var allowedCharacters = CharacterSet.alphanumerics 12 | allowedCharacters.insert(charactersIn: "-_.~") 13 | 14 | return (self.addingPercentEncoding(withAllowedCharacters: allowedCharacters) ?? self) 15 | } 16 | } 17 | 18 | extension WDItem { 19 | static private func buildQuery(name: String, location: CLLocationCoordinate2D) -> String { 20 | let geoPoint = "\"Point(\(location.longitude) \(location.latitude))\"^^geo:wktLiteral" 21 | let c = name.count 22 | let inQuery = (c-2...c+2).map(String.init).joined(separator: ",") 23 | let language = Locale.preferredLanguages.first?.components(separatedBy: ["_", "-"]).first ?? "en" 24 | 25 | return """ 26 | SELECT 27 | ?item 28 | (SAMPLE(?article) as ?wikiURL) 29 | (group_concat(distinct ?itemType;separator=",") as ?types) 30 | (SAMPLE(?viewCategory) as $view) 31 | (SAMPLE(?itemDescription) as ?description) 32 | (group_concat(distinct ?itemLabel;separator=",") as ?label) 33 | (MIN($dist) as $distance) 34 | (SAMPLE(?itemPopulation) as ?population) 35 | (SAMPLE(?itemArea) as ?area) 36 | (SAMPLE(?itemWebsite) as ?website) 37 | (SAMPLE(?itemAltitude) as ?altitude) 38 | (SAMPLE(?itemImage) as ?image) 39 | (SAMPLE(?itemNighttimeImage) as ?nighttimeImage) 40 | { 41 | SERVICE wikibase:around { 42 | ?item wdt:P625 ?location . 43 | bd:serviceParam wikibase:center \(geoPoint) . 44 | bd:serviceParam wikibase:radius "5" . 45 | } 46 | ?item rdfs:label ?itemLabel. 47 | FILTER (strlen(?itemLabel) IN (\(inQuery))). 48 | FILTER contains(lcase(?itemLabel), '\(name.lowercased())'). 49 | OPTIONAL { ?item wdt:P576 ?dissolved. } 50 | FILTER(!BOUND(?dissolved)) # is not dissolved 51 | 52 | # Description 53 | OPTIONAL { 54 | ?item schema:description ?itemDescription 55 | FILTER (LANG(?itemDescription) = "\(language)"). 56 | } 57 | # Item Type 58 | OPTIONAL { 59 | ?item wdt:P31 ?itemTypeType . 60 | ?itemTypeType rdfs:label ?itemType 61 | FILTER (LANG(?itemType) = "\(language)"). 62 | } 63 | # Wikimedia Category 64 | OPTIONAL { 65 | ?item wdt:P8989 ?viewsId . 66 | ?viewsId rdfs:label ?viewCategory 67 | FILTER (LANG(?viewCategory) = "en"). 68 | } 69 | # Wikipedia URL 70 | OPTIONAL { 71 | ?article schema:about ?item . 72 | ?article schema:inLanguage "\(language)" . 73 | ?article schema:isPartOf . 74 | } 75 | # Other Properties 76 | Optional { ?item wdt:P1082 ?itemPopulation } 77 | Optional { ?item p:P2046/psn:P2046/wikibase:quantityAmount ?itemArea } 78 | Optional { ?item wdt:P856 ?itemWebsite } 79 | Optional { ?item wdt:P2044 ?itemAltitude } 80 | Optional { ?item wdt:P18 ?itemImage } 81 | Optional { ?item wdt:P3451 ?itemNighttimeImage } 82 | 83 | BIND(geof:distance(\(geoPoint), ?location) as ?dist) # bind distance 84 | SERVICE wikibase:label { bd:serviceParam wikibase:language "[AUTO_LANGUAGE],en". } # set language 85 | } 86 | GROUP BY ?item 87 | ORDER BY ?distance 88 | """ // TODO: Language Stuff 89 | } 90 | 91 | private static func send(query: String) async throws -> Data { 92 | try await endpoint.request( 93 | path: "sparql?query=\(query.urlParameterEncoded)", 94 | options: [.headerFields(value: [.accept: "application/sparql-results+json"])] 95 | ).data 96 | } 97 | 98 | private static func retrieveImageFilename(for originatingUrl: String?) -> String? { 99 | guard 100 | let originatingUrl, 101 | originatingUrl.starts(with: "http://commons.wikimedia.org/wiki/Special:FilePath/") 102 | else { return nil } 103 | 104 | return originatingUrl[51...] 105 | } 106 | 107 | static func retrieve(for mapItem: MapItem) async throws -> WDItem? { 108 | let name = mapItem.name 109 | 110 | let query = buildQuery(name: name, location: mapItem.location) 111 | let data = try await send(query: query) 112 | 113 | let raw = try JSONDecoder().decode(_RawWDQueryResult.self, from: data) 114 | let results = raw.results.bindings.compactMap({ binding in 115 | Double(binding.distance.value).map({ 116 | _WDQueryResult( 117 | url: binding.item.value, 118 | names: binding.label.value.components(separatedBy: ","), 119 | type: binding.types?.value.components(separatedBy: ",").sorted(by: \.count).first, 120 | description: binding.description?.value, 121 | view: binding.view?.value, 122 | distanceInKilometers: $0, 123 | wikiURL: binding.wikiURL?.value, 124 | population: binding.population?.value.toInt, 125 | area: binding.area?.value.toDouble, 126 | website: binding.website?.value, 127 | altitude: binding.altitude?.value.toInt, 128 | imageFileTitle: retrieveImageFilename(for: binding.image?.value), 129 | nighttimeImageFileTitle: retrieveImageFilename(for: binding.nighttimeImage?.value) 130 | ) 131 | }) 132 | }) 133 | 134 | let lowerName = name.lowercased() 135 | var lowestStringDistance = 3 136 | var bestResults: [_WDQueryResult] = [] 137 | 138 | for result in results { 139 | guard let lowestLev = result.names.min(of: { singleName in 140 | singleName.lowercased().damerauLevenshtein(lowerName, max: lowestStringDistance + 1) 141 | }) else { continue } 142 | 143 | if lowestLev == lowestStringDistance { 144 | bestResults.append(result) 145 | } else if lowestLev < lowestStringDistance { 146 | lowestStringDistance = lowestLev 147 | bestResults = [result] 148 | } 149 | } 150 | 151 | let lowestUrlCount = bestResults.min(of: \.url.count) 152 | let bestResult = bestResults.filter({ $0.url.count == lowestUrlCount }).sorted(by: \.distanceInKilometers).first 153 | 154 | guard 155 | let bestResult, 156 | let bestIdentifier = bestResult.url.components(separatedBy: "/").last, 157 | bestIdentifier.first == "Q" 158 | else { 159 | return nil 160 | } 161 | 162 | return .init( 163 | identifier: bestIdentifier, 164 | description: bestResult.description, 165 | type: bestResult.type, 166 | commonsImageCatagory: bestResult.view, 167 | url: bestResult.wikiURL, 168 | population: bestResult.population, 169 | area: bestResult.area, 170 | website: bestResult.website, 171 | altitude: bestResult.altitude, 172 | imageFileTitle: bestResult.imageFileTitle, 173 | nighttimeImageFileTitle: bestResult.nighttimeImageFileTitle 174 | ) 175 | } 176 | } 177 | 178 | // TODO: Remove? 179 | //struct _RawWDEntityResult: Decodable { 180 | // 181 | // struct Sitelink: Decodable { 182 | // let url: String 183 | // } 184 | // 185 | // struct Statement: Decodable { 186 | // struct Value: Decodable { 187 | // let content: String 188 | // } 189 | // 190 | // let value: Value 191 | // } 192 | // 193 | // /// Labels by language identifier, e.g. ["en": "New York City"]. 194 | // let labels: [String: String] 195 | // /// Descriptions by language identifier, e.g. ["en": "most populous city in the United States of America"]. 196 | // let descriptions: [String: String] 197 | // /// Sitelinks by language identifier. 198 | // let sitelinks: [String: Sitelink] 199 | // /// Statements, which are basically references. 200 | // let statements: [String: [Statement]] 201 | //} 202 | 203 | struct _WDQueryResult { 204 | let url: String 205 | let names: [String] 206 | let type: String? 207 | let description: String? 208 | let view: String? 209 | let distanceInKilometers: Double 210 | let wikiURL: String? 211 | let population: Int? 212 | let area: Double? 213 | let website: String? 214 | let altitude: Int? 215 | let imageFileTitle: String? 216 | let nighttimeImageFileTitle: String? 217 | } 218 | 219 | struct _RawWDQueryResult: Decodable { 220 | 221 | struct StringBinding: Decodable { 222 | let value: String 223 | } 224 | 225 | struct Binding: Decodable { 226 | /// The url of the item, e.g. 'http://www.wikidata.org/entity/Q187725' 227 | let item: StringBinding 228 | /// The type of the item, e.g. 'town' 229 | let types: StringBinding? 230 | /// The description of the item, e.g. 'city in the German state of Bavaria' 231 | let description: StringBinding? 232 | /// The label of the item, e.g. 'Nuremberg' 233 | let label: StringBinding 234 | /// The distance in kilometers of the item, e.g. '2.1983287276152437' 235 | let distance: StringBinding 236 | /// A category to retrieve images in 'P8989', e.g. 'Category:Views of New York City'. 237 | let view: StringBinding? 238 | /// A local wiki URL, e.g. 'https://de.wikipedia.org/wiki/Nürnberg'. 239 | let wikiURL: StringBinding? 240 | 241 | let population: StringBinding? 242 | let area: StringBinding? 243 | let website: StringBinding? 244 | let altitude: StringBinding? 245 | 246 | let image: StringBinding? 247 | let nighttimeImage: StringBinding? 248 | } 249 | 250 | struct Results: Decodable { 251 | let bindings: [Binding] 252 | } 253 | 254 | let results: Results 255 | } 256 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Data/MapItem/WikiData/WD+retrieveImages.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SchafKit 3 | 4 | extension WDItem { 5 | static private func decodeImageInfos( 6 | from data: Data, 7 | isFromViewCategory: Bool = false 8 | ) throws -> [MapItemImage]? { 9 | let raw = try JSONDecoder().decode(_RawImageInfoResult.self, from: data) 10 | .query.pages.values 11 | .flatMap({ $0.imageinfo }) 12 | .filter({ info in 13 | if isFromViewCategory { 14 | let categories = info.extmetadata.Categories.value 15 | 16 | // There are a lot of very old images in this view category, which is why we're going to remove anything that looks even remotely strange. 17 | // This gets rid of all images with a category that looks like a year before 2000. 18 | guard categories.regexMatches(with: "\\D1?\\d\\d\\d\\D")!.isEmpty else { return false } 19 | 20 | // Remove things that are no photographs. 21 | guard !["lithograph", "engraving", "etching"].contains(categories.lowercased()) else { return false } 22 | } 23 | 24 | return true 25 | }) 26 | .sorted(by: \.extmetadata.DateTimeOriginal.value, ascending: false) 27 | .sorted(by: \.extmetadata.Categories.value.count, ascending: false) 28 | 29 | return raw.compactMap({ 30 | if 31 | let url = URL(string: $0.url), 32 | let thumbnailUrl = URL(string: $0.thumburl), 33 | let sourceUrl = URL(string: $0.descriptionurl) 34 | { 35 | return MapItemImage( 36 | url: url, 37 | thumbnailUrl: thumbnailUrl, 38 | description: $0.extmetadata.ImageDescription?.value, 39 | source: .wikipedia, 40 | sourceUrl: sourceUrl 41 | ) 42 | } 43 | return nil 44 | }) 45 | } 46 | 47 | static func retrieveViewCategoryImages(for mapItem: MapItem) async throws -> [MapItemImage]? { 48 | guard let id = mapItem.identifiers[.wikimediaCommonsCategory] else { return nil } 49 | 50 | let category = id.urlParameterEncoded 51 | let url = "https://commons.wikimedia.org/w/api.php?action=query&generator=categorymembers&gcmtitle=\(category)&gcmlimit=100&gcmtype=file&prop=imageinfo&iiprop=extmetadata%7Curl&format=json&iiurlheight=400" 52 | let data = try await SKNetworking.request(url: url).data 53 | 54 | return try decodeImageInfos(from: data, isFromViewCategory: true) 55 | } 56 | 57 | static func retrieveStandardImages(for mapItem: MapItem) async throws -> [MapItemImage]? { 58 | let prefix = "File%3A" 59 | let separator = "%7C\(prefix)" 60 | let filenames = [ 61 | mapItem.identifiers[.wikidataCommonsImageFilename], 62 | mapItem.identifiers[.wikidataCommonsNighttimeViewImageFilename] 63 | ] 64 | .removingNils() 65 | 66 | guard !filenames.isEmpty else { return nil } 67 | 68 | let titles = prefix + filenames.joined(separator: separator) 69 | let url = "https://commons.wikimedia.org/w/api.php?action=query&prop=imageinfo&iiprop=extmetadata%7Curl&redirects&format=json&iiurlheight=400&titles=\(titles)" 70 | 71 | let data = try! await SKNetworking.request(url: url).data 72 | 73 | return try decodeImageInfos(from: data) 74 | } 75 | } 76 | 77 | private struct _RawImageInfoResult: Decodable { 78 | struct ExtMetadata: Decodable { 79 | struct StringValue: Decodable { 80 | let value: String 81 | } 82 | 83 | let Categories: StringValue 84 | let License: StringValue? 85 | let DateTimeOriginal: StringValue 86 | let ImageDescription: StringValue? 87 | } 88 | 89 | struct ImageInfo: Decodable { 90 | let url: String 91 | let thumburl: String 92 | let descriptionurl: String 93 | 94 | let extmetadata: ExtMetadata 95 | } 96 | 97 | struct Page: Decodable { 98 | let imageinfo: [ImageInfo] 99 | } 100 | 101 | struct Query: Decodable { 102 | let pages: [String: Page] 103 | } 104 | 105 | let query: Query 106 | } 107 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Data/MapItem/WikiData/WDItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct WDItem: Codable { 4 | let identifier: String 5 | let description: String? 6 | let type: String? 7 | let commonsImageCatagory: String? 8 | let url: String? 9 | let population: Int? 10 | let area: Double? 11 | let website: String? 12 | let altitude: Int? 13 | let imageFileTitle: String? 14 | let nighttimeImageFileTitle: String? 15 | } 16 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Data/MapItemSearchViewAction.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | /// An action to display for a map item. 5 | public struct MapItemPickerAction: Identifiable { 6 | 7 | /// Block to execute when an action is selected by the user. The return value describes whether to dismiss the map item sheet after selection. 8 | public typealias Handler = (MapItem) -> Bool 9 | 10 | /// The type of action. 11 | public enum ActionType { 12 | /// An action that has a single handler that is executed when tapped on the action button. 13 | case single(Handler) 14 | /// An action that has subactions. These appear in form of a menu resp. submenu when the action is tapped. 15 | case subActions([MapItemPickerAction]) 16 | } 17 | 18 | public let id = UUID() 19 | 20 | /// The title of the action. 21 | public let title: LocalizedStringKey 22 | /// The system image name of the action. 23 | public let imageName: String 24 | /// The type of action that is executed when the corresponding button or menu item is tapped. 25 | public let action: ActionType 26 | 27 | /// Creates a `MapItemPickerAction` from the given parameters. 28 | /// - Parameters: 29 | /// - title: The title of the action. 30 | /// - imageName: The system image name of the action. 31 | /// - handler: The action that is executed when the corresponding button or menu item is tapped. 32 | public init(title: LocalizedStringKey, imageName: String, handler: @escaping (MapItem) -> Bool) { 33 | self.title = title 34 | self.imageName = imageName 35 | self.action = .single(handler) 36 | } 37 | 38 | /// Creates a `MapItemPickerAction` from the given parameters. 39 | /// - Parameters: 40 | /// - title: The title of the action. 41 | /// - imageName: The system image name of the action. 42 | /// - subActions: The actions that are displayed in a (sub-) menu when the corresponding button or menu item is tapped. 43 | public init(title: LocalizedStringKey, imageName: String, subActions: [MapItemPickerAction]) { 44 | self.title = title 45 | self.imageName = imageName 46 | self.action = .subActions(subActions) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Extensions/?=.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | infix operator ?= : AssignmentPrecedence 4 | extension Optional { 5 | static func ?=(lhs: inout Self, rhs: Self) { 6 | if case .none = lhs { 7 | lhs = rhs 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Extensions/Array.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import MapKit 3 | 4 | extension Array where Element: MKAnnotation { 5 | func contains(annotation: Element) -> Bool { 6 | guard let equatable = annotation as? MapAnnotationEquatable else { 7 | return contains(exactObject: annotation) 8 | } 9 | 10 | return any({ ($0 as? MapAnnotationEquatable)?.annotationIsEqual(to: equatable) ?? false }) 11 | } 12 | } 13 | 14 | extension Array where Element: MKOverlay { 15 | func contains(overlay: Element) -> Bool { 16 | guard let equatable = overlay as? MapOverlayEquatable else { 17 | return contains(exactObject: overlay) 18 | } 19 | 20 | return any({ ($0 as? MapOverlayEquatable)?.overlayIsEqual(to: equatable) ?? false }) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Extensions/CLCircularRegion.swift: -------------------------------------------------------------------------------- 1 | import CoreLocation 2 | 3 | public class CLCodableCircularRegion: CLCircularRegion, Codable { 4 | private enum CodingKeys: String, CodingKey { 5 | case center, radius, identifier 6 | } 7 | 8 | public func encode(to encoder: Encoder) throws { 9 | var container = encoder.container(keyedBy: CodingKeys.self) 10 | 11 | try container.encode(center, forKey: .center) 12 | try container.encode(radius, forKey: .radius) 13 | try container.encode(identifier, forKey: .identifier) 14 | } 15 | 16 | required public convenience init(from decoder: Decoder) throws { 17 | let container = try decoder.container(keyedBy: CodingKeys.self) 18 | 19 | self.init( 20 | center: try container.decode(CLLocationCoordinate2D.self, forKey: .center), 21 | radius: try container.decode(CLLocationDistance.self, forKey: .radius), 22 | identifier: try container.decode(String.self, forKey: .identifier) 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Extensions/CLLocationCoordinate2D.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreLocation 3 | 4 | extension CLLocationCoordinate2D: @retroactive Equatable, @retroactive Hashable, Codable { 5 | public func hash(into hasher: inout Hasher) { 6 | hasher.combine(latitude) 7 | hasher.combine(longitude) 8 | } 9 | 10 | public static func == (lhs: CLLocationCoordinate2D, rhs: CLLocationCoordinate2D) -> Bool { 11 | lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude // TODO: leeway? 12 | } 13 | 14 | private enum CodingKeys: String, CodingKey { 15 | case latitude, longitude 16 | } 17 | 18 | public func encode(to encoder: Encoder) throws { 19 | var container = encoder.container(keyedBy: CodingKeys.self) 20 | 21 | try container.encode(latitude, forKey: .latitude) 22 | try container.encode(longitude, forKey: .longitude) 23 | } 24 | 25 | public init(from decoder: Decoder) throws { 26 | let container = try decoder.container(keyedBy: CodingKeys.self) 27 | 28 | self.init() 29 | 30 | self.latitude = try container.decode(CLLocationDegrees.self, forKey: .latitude) 31 | self.longitude = try container.decode(CLLocationDegrees.self, forKey: .longitude) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Extensions/Font+compatibility.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | extension Font { 5 | static var title3Compatible: Font { 6 | if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) { 7 | return .title3 8 | } else { 9 | return .init(UIFont.preferredFont(forTextStyle: .title3)) 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Extensions/Int.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SchafKit 3 | 4 | extension Int { 5 | func compatibleFormatted() -> String { 6 | if #available(iOS 15, *) { 7 | return formatted() 8 | } 9 | return Double(self).toFormattedString(decimals: 0) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Extensions/MKCoordinateRegion.swift: -------------------------------------------------------------------------------- 1 | import MapKit 2 | 3 | extension MKCoordinateRegion: @retroactive Equatable 4 | { 5 | public static func == (lhs: MKCoordinateRegion, rhs: MKCoordinateRegion) -> Bool 6 | { 7 | if lhs.center.latitude != rhs.center.latitude || lhs.center.longitude != rhs.center.longitude 8 | { 9 | return false 10 | } 11 | if lhs.span.latitudeDelta != rhs.span.latitudeDelta || lhs.span.longitudeDelta != rhs.span.longitudeDelta 12 | { 13 | return false 14 | } 15 | return true 16 | } 17 | 18 | static var unitedStates: MKCoordinateRegion { 19 | .init( 20 | center: .init(latitude: 38, longitude: -100), 21 | span: .init(latitudeDelta: 60, longitudeDelta: 60) 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Extensions/MKLocalSearchCompletion.swift: -------------------------------------------------------------------------------- 1 | import MapKit 2 | 3 | extension MKLocalSearchCompletion: @retroactive Identifiable { 4 | public var id: String { 5 | "\(title)//\(subtitle)" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Extensions/MKMapConfiguration.swift: -------------------------------------------------------------------------------- 1 | import MapKit 2 | import SwiftUI 3 | 4 | @available(iOS 16.0, *) 5 | extension MKMapConfiguration { 6 | static var cases: [MKMapConfiguration] { 7 | let traffic = MKStandardMapConfiguration() 8 | traffic.showsTraffic = true 9 | return [MKStandardMapConfiguration(), traffic, MKHybridMapConfiguration()] 10 | } 11 | 12 | var imageName: String { 13 | if self is MKHybridMapConfiguration { 14 | return "globe.europe.africa.fill" 15 | } else if let standard = self as? MKStandardMapConfiguration, standard.showsTraffic { 16 | return "car" 17 | } 18 | 19 | return "map" 20 | } 21 | 22 | var title: LocalizedStringKey { 23 | if self is MKHybridMapConfiguration { 24 | return "MKMapConfiguration.title.hybrid" 25 | } else if let standard = self as? MKStandardMapConfiguration, standard.showsTraffic { 26 | return "MKMapConfiguration.title.traffic" 27 | } 28 | 29 | return "MKMapConfiguration.title.standard" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Extensions/MKMapFeatureAnnotation.FeatureType.swift: -------------------------------------------------------------------------------- 1 | import MapKit 2 | 3 | @available(iOS 16.0, *) 4 | extension MKMapFeatureAnnotation.FeatureType: Codable {} 5 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Extensions/MKMapItem.swift: -------------------------------------------------------------------------------- 1 | import MapKit 2 | import SwiftUI 3 | 4 | extension MKMapItem: @retroactive Identifiable { 5 | public var id: Int { 6 | hashValue 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Extensions/MKMapRect.swift: -------------------------------------------------------------------------------- 1 | import MapKit 2 | 3 | public extension MKMapRect { 4 | init(_ region: MKCoordinateRegion) { 5 | let topLeft = CLLocationCoordinate2D(latitude: region.center.latitude + (region.span.latitudeDelta/2), longitude: region.center.longitude - (region.span.longitudeDelta/2)) 6 | let bottomRight = CLLocationCoordinate2D(latitude: region.center.latitude - (region.span.latitudeDelta/2), longitude: region.center.longitude + (region.span.longitudeDelta/2)) 7 | 8 | let a = MKMapPoint(topLeft) 9 | let b = MKMapPoint(bottomRight) 10 | 11 | self.init(origin: MKMapPoint(x:min(a.x,b.x), y:min(a.y,b.y)), size: MKMapSize(width: abs(a.x-b.x), height: abs(a.y-b.y))) 12 | } 13 | 14 | init(center: MKMapPoint, size: MKMapSize) { 15 | self.init( 16 | origin: .init( 17 | x: center.x - size.width / 2, 18 | y: center.y - size.height / 2 19 | ), 20 | size: size 21 | ) 22 | } 23 | 24 | init?(bestFor coordinates: [CLLocationCoordinate2D]) { 25 | self.init(bestFor: coordinates.map(MKMapPoint.init)) 26 | } 27 | 28 | init?(bestFor points: [MKMapPoint]) { 29 | guard !points.isEmpty else { return nil } 30 | 31 | var points = points 32 | let length: CGFloat = points.count == 1 ? 100000 : 5000 33 | let mapSize = MKMapSize(width: length, height: length) 34 | var rect = MKMapRect( 35 | center: points.removeFirst(), 36 | size: mapSize 37 | ) 38 | 39 | for point in points { 40 | rect = rect.union( 41 | .init( 42 | center: point, 43 | size: mapSize 44 | ) 45 | ) 46 | } 47 | 48 | self = rect 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Extensions/MKPlacemark.swift: -------------------------------------------------------------------------------- 1 | import MapKit 2 | 3 | extension MKPlacemark { 4 | } 5 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Extensions/String+levenshtein.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import SchafKit 4 | 5 | extension String { 6 | public func levenshtein(_ other: String) -> Int { 7 | let sCount = self.count 8 | let oCount = other.count 9 | 10 | guard sCount != 0 else { 11 | return oCount 12 | } 13 | 14 | guard oCount != 0 else { 15 | return sCount 16 | } 17 | 18 | let line: [Int] = Array(repeating: 0, count: oCount + 1) 19 | var mat: [[Int]] = Array(repeating: line, count: sCount + 1) 20 | 21 | for i in 0...sCount { 22 | mat[i][0] = i 23 | } 24 | 25 | for j in 0...oCount { 26 | mat[0][j] = j 27 | } 28 | 29 | for j in 1...oCount { 30 | for i in 1...sCount { 31 | if self[i - 1] == other[j - 1] { 32 | mat[i][j] = mat[i - 1][j - 1] // no operation 33 | } else { 34 | let del = mat[i - 1][j] + 1 // deletion 35 | let ins = mat[i][j - 1] + 1 // insertion 36 | let sub = mat[i - 1][j - 1] + 1 // substitution 37 | mat[i][j] = min(min(del, ins), sub) 38 | } 39 | } 40 | } 41 | 42 | return mat[sCount][oCount] 43 | } 44 | 45 | public func damerauLevenshtein(_ target: String, max: Int?) -> Int { 46 | let selfCount = self.count 47 | let targetCount = target.count 48 | 49 | if self == target { 50 | return 0 51 | } 52 | if selfCount == 0 { 53 | return targetCount 54 | } 55 | if targetCount == 0 { 56 | return selfCount 57 | } 58 | 59 | // Fast Check 60 | 61 | var matching = 0 62 | var characters = Array(self) 63 | for char in target { 64 | if let index = characters.firstIndex(of: char) { 65 | matching += 1 66 | characters.remove(at: index) 67 | } 68 | } 69 | let smallestCount = min(selfCount, targetCount) 70 | if matching < (smallestCount * 3) / 4 { 71 | return smallestCount 72 | } 73 | 74 | // Fast Check END 75 | 76 | if let max, abs(selfCount - targetCount) >= max { 77 | return max 78 | } 79 | 80 | return actualDamerauLevenshtein(selfCount: selfCount, targetCount: targetCount, target: target) 81 | } 82 | 83 | private func actualDamerauLevenshtein( 84 | selfCount: Int, 85 | targetCount: Int, 86 | target: String 87 | ) -> Int { 88 | var da: [Character: Int] = [:] 89 | var d = Array(repeating: Array(repeating: 0, count: targetCount + 2), count: selfCount + 2) 90 | 91 | let maxdist = selfCount + targetCount 92 | d[0][0] = maxdist 93 | for i in 1...selfCount + 1 { 94 | d[i][0] = maxdist 95 | d[i][1] = i - 1 96 | } 97 | for j in 1...targetCount + 1 { 98 | d[0][j] = maxdist 99 | d[1][j] = j - 1 100 | } 101 | 102 | let selfChars = Array(self) 103 | let targetChars = Array(target) 104 | 105 | for i in 2...selfCount + 1 { 106 | var db = 1 107 | 108 | for j in 2...targetCount + 1 { 109 | let k = da[targetChars[j - 2]] ?? 1 110 | let l = db 111 | 112 | var cost = 1 113 | if selfChars[i - 2] == targetChars[j - 2] { 114 | cost = 0 115 | db = j 116 | } 117 | 118 | let substition = d[i - 1][j - 1] + cost 119 | let injection = d[i][j - 1] + 1 120 | let deletion = d[i - 1][j] + 1 121 | let selfIdx = i - k - 1 122 | let targetIdx = j - l - 1 123 | let transposition = d[k - 1][l - 1] + selfIdx + 1 + targetIdx 124 | 125 | d[i][j] = Swift.min( 126 | substition, 127 | injection, 128 | deletion, 129 | transposition 130 | ) 131 | } 132 | 133 | da[selfChars[i - 2]] = i 134 | } 135 | 136 | return d[selfCount + 1][targetCount + 1] 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Extensions/String.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | var moduleLocalized: String { 5 | NSLocalizedString(self, bundle: Bundle.module, comment: .empty) 6 | } 7 | 8 | func components(separatedBy strings: [String]) -> [String] { 9 | var result = [self] 10 | 11 | for separator in strings { 12 | result = result.flatMap({ $0.components(separatedBy: separator) }) 13 | } 14 | 15 | return result 16 | } 17 | 18 | var capitalizedSentence: String { 19 | prefix(1).capitalized + dropFirst() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Extensions/UIViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIViewController { 4 | var highestPresentedController: UIViewController { 5 | presentedViewController?.highestPresentedController ?? self 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Extensions/View+compatibility.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension NavigationView { 4 | 5 | static var backgroundVisibilityAdjustable: Bool { 6 | if #available(iOS 16.0, *) { 7 | return true 8 | } 9 | return false 10 | } 11 | } 12 | 13 | extension View { 14 | 15 | @ViewBuilder func navigationBarBackgroundVisible() -> some View { 16 | if #available(iOS 16.0, *) { 17 | self.toolbarBackground(.visible, for: .navigationBar) 18 | } else { 19 | self 20 | } 21 | } 22 | 23 | func compatibleFullScreen(isPresented: Binding, @ViewBuilder content: @escaping () -> Content) -> some View { 24 | self.modifier(FullScreenModifier(isPresented: isPresented, builder: content)) 25 | } 26 | 27 | @ViewBuilder func textCaseUppercase() -> some View { 28 | if #available(iOS 14.0, *) { 29 | self.textCase(.uppercase) 30 | } else { 31 | self 32 | } 33 | } 34 | 35 | @ViewBuilder func overlayCompatible(alignment: Alignment, overlay: () -> T) -> some View { 36 | if #available(iOS 15, *) { 37 | self.overlay(alignment: alignment, content: overlay) 38 | } else { 39 | self.overlay(overlay(), alignment: alignment) 40 | } 41 | } 42 | } 43 | 44 | private struct FullScreenModifier: ViewModifier { 45 | let isPresented: Binding 46 | let builder: () -> V 47 | 48 | @ViewBuilder 49 | func body(content: Content) -> some View { 50 | if #available(iOS 14.0, *) { 51 | content.fullScreenCover(isPresented: isPresented, content: builder) 52 | } else { 53 | content.sheet(isPresented: isPresented, content: builder) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Extensions/View+listCellEmulationPadding.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension View { 4 | func listCellEmulationPadding() -> some View { 5 | self 6 | .padding(.vertical, 12) 7 | .padding(.horizontal) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Extensions/View+mapItemPickerSheet.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension View { 4 | func mapItemPickerSheet(isPresented: Binding, action: @escaping (MapItem) -> Void) -> some View { 5 | self.compatibleFullScreen(isPresented: isPresented) { 6 | NavigationView { 7 | MapItemPicker( 8 | primaryMapItemAction: .init( 9 | title: .init("select".moduleLocalized), 10 | imageName: "checkmark.circle.fill", 11 | handler: { mapItem in 12 | action(mapItem) 13 | isPresented.wrappedValue = false 14 | return true 15 | } 16 | ) 17 | ) 18 | .navigationBarItems( 19 | leading: Button("cancel".moduleLocalized) { 20 | isPresented.wrappedValue = false 21 | } 22 | ) 23 | .navigationBarTitle(Text("select", bundle: .module), displayMode: .inline) 24 | .navigationBarBackgroundVisible() 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Protocols/MapAnnotationEquatable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import MapKit 3 | 4 | /// A protocol that is used to check whether two `MKAnnotation`s are equal. 5 | /// 6 | /// - note: In a `ConfigurableMapItemPicker`, elements are updated when they are not equal and retained when they are. When `MKAnnotations` don't implement the `MapAnnotationEquatable` protocol, they are updated when they are not the exact object instance anymore. 7 | public protocol MapAnnotationEquatable: MKAnnotation { 8 | func annotationIsEqual(to other: MapAnnotationEquatable) -> Bool 9 | } 10 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Protocols/MapOverlayEquatable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import MapKit 3 | 4 | /// A protocol that is used to check whether two `MKOverlay`s are equal. 5 | /// 6 | /// - note: In a `ConfigurableMapItemPicker`, elements are updated when they are not equal and retained when they are. When `MKOverlay`s don't implement the `MapOverlayEquatable` protocol, they are updated when they are not the exact object instance anymore. 7 | public protocol MapOverlayEquatable: MKOverlay { 8 | func overlayIsEqual(to other: MapOverlayEquatable) -> Bool 9 | } 10 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Resources/de.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | // MARK: General 2 | select = "Auswählen"; 3 | cancel = "Abbrechen"; 4 | done = "Erledigt"; 5 | legal = "Rechtliches"; // This is displayed at the bottom of the Map Item Sheet, as a link that leads to the apple legal page containing license notices for the data apple uses, which also contains all services MapItemPicker uses. 6 | 7 | // MARK: Search 8 | search = "Ort suchen"; 9 | search.categories = "Kategorien"; 10 | search.category = "Suche nach Kategorie"; 11 | search.results = "Ergebnisse"; 12 | search.moreSuggestions = "Weitere Vorschläge"; 13 | search.loading = "Laden..."; 14 | search.clearFilters = "Filter löschen"; 15 | search.recentMapItems = "Zuletzt"; 16 | search.recentMapItems.showMore = "Mehr zeigen"; 17 | search.recentMapItems.showLess = "Weniger zeigen"; 18 | 19 | // MARK: Abstract Map Item Types 20 | mapItem.type.item = "Ort"; 21 | mapItem.type.address = "Adresse"; 22 | mapItem.type.cityRegion = "Nachbarschaft"; 23 | mapItem.type.city = "Stadt"; 24 | mapItem.type.inlandWater = "Gewässer"; 25 | mapItem.type.state = "Land"; 26 | mapItem.type.ocean = "Ozean"; 27 | mapItem.type.country = "Staat"; 28 | mapItem.type.territory = "Gebiet"; 29 | 30 | // MARK: Item Sheet Buttons 31 | itemSheet.action.call = " Anrufen"; 32 | itemSheet.action.visitWebsite = "Website"; 33 | itemSheet.action.lookaround = "Lookaround"; 34 | 35 | // MARK: Item Sheet Top Scrollable Items 36 | itemSheet.topScrollableItems.area = " Fläche"; 37 | itemSheet.topScrollableItems.altitude = "Höhe"; 38 | itemSheet.topScrollableItems.population = "Einwohnerzahl"; 39 | 40 | // MARK: Item Sheet About Section 41 | itemSheet.about = "Über"; 42 | itemSheet.about.moreOn = "Mehr auf"; 43 | 44 | // MARK: Item Sheet Facts Section 45 | itemSheet.facts = "Fakten"; 46 | itemSheet.facts.vegan = "Vegan"; 47 | itemSheet.facts.vegetarisch = "Vegetarisch"; 48 | itemSheet.facts.takeaway = "Zum Mitnehmen"; 49 | itemSheet.facts.indoorSeating = "Innensitzplätze"; 50 | itemSheet.facts.outdoorSeating = "Außensitzplätze"; 51 | itemSheet.facts.smoking = "Rauchen"; 52 | itemSheet.facts.internetAccess = "Internetzugang verfügbar"; 53 | itemSheet.facts.internetAccess.no = "Kein Internetzugang"; 54 | itemSheet.facts.internetAccess.wifi = "WLAN verfügbar"; 55 | itemSheet.facts.internetAccess.terminal = "Internetzugang über bereitgestellte PCs"; 56 | itemSheet.facts.internetAccess.wired = "Kabelgebundener Internetzugang verfügbar"; 57 | itemSheet.facts.wheelchair = "Rollstuhlgerechter Zugang"; 58 | itemSheet.facts.wheelchair.no = "Kein Rollstuhlgerechter Zugang"; 59 | itemSheet.facts.wheelchair.designated = "Ausgewiesener Rollstuhlzugang"; 60 | itemSheet.facts.wheelchair.limited = "Eingeschränkter Zugang für Rollstuhlfahrer"; 61 | "itemSheet.facts.no %@" = "Kein %@"; 62 | "itemSheet.facts.only %@" = "Nur %@"; 63 | 64 | // MARK: Item Sheet Details Section 65 | itemSheet.details = "Details"; 66 | itemSheet.details.location = "Ort"; 67 | itemSheet.details.coordinates = "Koordinaten"; 68 | itemSheet.details.level = "Stock"; 69 | 70 | // MARK: Item Sheet Contact Section 71 | itemSheet.contact = "Kontakt"; 72 | itemSheet.contact.website = "Website"; 73 | itemSheet.contact.phone = "Telefonnummer"; 74 | 75 | // MARK: Map Item Categories 76 | category.airport = "Flughafen"; 77 | category.amusementPark = "Vergnügungspark"; 78 | category.aquarium = "Aquarium"; 79 | category.atm = "Geldautomat"; 80 | category.bakery = "Bäckerei"; 81 | category.bank = "Bank"; 82 | category.beach = "Strand"; 83 | category.brewery = "Brauerei"; 84 | category.cafe = "Cafe"; 85 | category.campground = "Zeltplatz"; 86 | category.carRental = "Autovermietung"; 87 | category.evCharger = "Ladestation für Elektrofahrzeuge"; 88 | category.fireStation = "Feuerwache"; 89 | category.fitnessCenter = "Fitnessstudio"; 90 | category.foodMarket = "Lebensmittelmarkt"; 91 | category.gasStation = "Tankstelle"; 92 | category.hospital = "Krankenhaus"; 93 | category.hotel = "Hotel"; 94 | category.laundry = "Wäscherei"; 95 | category.library = "Bibliothek"; 96 | category.marina = "Hafen"; 97 | category.movieTheater = "Kino"; 98 | category.museum = "Museum"; 99 | category.nationalPark = "Nationalpark"; 100 | category.nightlife = "Nachtleben"; 101 | category.park = "Park"; 102 | category.parking = "Parken"; 103 | category.pharmacy = "Apotheke"; 104 | category.police = "Polizei"; 105 | category.postOffice = "Postamt"; 106 | category.publicTransport = "ÖPNV"; 107 | category.restaurant = "Restaurant"; 108 | category.restroom = "Toilette"; 109 | category.school = "Schule"; 110 | category.stadium = "Stadion"; 111 | category.store = "Geschäft"; 112 | category.theater = "Theater"; 113 | category.university = "Universität"; 114 | category.winery = "Weingut"; 115 | category.zoo = "Zoo"; 116 | 117 | // MARK: Map View Modes 118 | MKMapConfiguration.title.standard = "Standard"; 119 | MKMapConfiguration.title.traffic = "Verkehr"; 120 | MKMapConfiguration.title.hybrid = "Sattelit"; 121 | 122 | // MARK: Image View 123 | image = "Bild"; 124 | image.source.teaser = "Von"; 125 | image.source.wikipedia = "Wikipedia"; 126 | 127 | // MARK: Opening Hours 128 | openingHours = "Öffnungszeiten"; 129 | openingHours.holiday = "Feiertage"; 130 | openingHours.schoolHoliday = "Schulferien"; 131 | 132 | Übersetzt mit www.DeepL.com/Translator (kostenlose Version) 133 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/Resources/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | // MARK: General 2 | select = "Select"; 3 | cancel = "Cancel"; 4 | done = "Done"; 5 | legal = "Legal"; // This is displayed at the bottom of the Map Item Sheet, as a link that leads to the apple legal page containing license notices for the data apple uses, which also contains all services MapItemPicker uses. 6 | 7 | // MARK: Search 8 | search = "Search Place"; 9 | search.categories = "Categories"; 10 | search.category = "Search by Category"; 11 | search.results = "Results"; 12 | search.moreSuggestions = "More Suggestions"; 13 | search.loading = "Loading..."; 14 | search.clearFilters = "Clear Filters"; 15 | search.recentMapItems = "Recents"; 16 | search.recentMapItems.showMore = "Show More"; 17 | search.recentMapItems.showLess = "Show Less"; 18 | 19 | // MARK: Abstract Map Item Types 20 | mapItem.type.item = "Location"; 21 | mapItem.type.address = "Address"; 22 | mapItem.type.cityRegion = "Neighborhood"; 23 | mapItem.type.city = "City"; 24 | mapItem.type.inlandWater = "Water Body"; 25 | mapItem.type.state = "State"; 26 | mapItem.type.ocean = "Ocean"; 27 | mapItem.type.country = "Country"; 28 | mapItem.type.territory = "Territory"; 29 | 30 | // MARK: Item Sheet Buttons 31 | itemSheet.action.call = "Call"; 32 | itemSheet.action.visitWebsite = "Website"; 33 | itemSheet.action.lookaround = "Lookaround"; 34 | 35 | // MARK: Item Sheet Top Scrollable Items 36 | itemSheet.topScrollableItems.area = "Area"; 37 | itemSheet.topScrollableItems.altitude = "Altitude"; 38 | itemSheet.topScrollableItems.population = "Population"; 39 | 40 | // MARK: Item Sheet About Section 41 | itemSheet.about = "About"; 42 | itemSheet.about.moreOn = "More on"; 43 | 44 | // MARK: Item Sheet Facts Section 45 | itemSheet.facts = "Facts"; 46 | itemSheet.facts.vegan = "Vegan"; 47 | itemSheet.facts.vegetarian = "Vegetarian"; 48 | itemSheet.facts.takeaway = "Takeaway"; 49 | itemSheet.facts.indoorSeating = "Indoor Seating"; 50 | itemSheet.facts.outdoorSeating = "Outdoor Seating"; 51 | itemSheet.facts.smoking = "Smoking"; 52 | itemSheet.facts.internetAccess = "Internet Access Available"; 53 | itemSheet.facts.internetAccess.no = "No Internet Access"; 54 | itemSheet.facts.internetAccess.wifi = "Wifi Available"; 55 | itemSheet.facts.internetAccess.terminal = "Terminal Internet Access Available"; 56 | itemSheet.facts.internetAccess.wired = "Wired Internet Access Available"; 57 | itemSheet.facts.wheelchair = "Wheelchair Access"; 58 | itemSheet.facts.wheelchair.no = "No Wheelchair Access"; 59 | itemSheet.facts.wheelchair.designated = "Designated Wheelchair Access"; 60 | itemSheet.facts.wheelchair.limited = "Limited Wheelchair Access"; 61 | "itemSheet.facts.no %@" = "No %@"; 62 | "itemSheet.facts.only %@" = "Only %@"; 63 | 64 | // MARK: Item Sheet Details Section 65 | itemSheet.details = "Details"; 66 | itemSheet.details.location = "Location"; 67 | itemSheet.details.coordinates = "Coordinates"; 68 | itemSheet.details.level = "Level"; 69 | 70 | // MARK: Item Sheet Contact Section 71 | itemSheet.contact = "Contact"; 72 | itemSheet.contact.website = "Website"; 73 | itemSheet.contact.phone = "Phone Number"; 74 | 75 | // MARK: Map Item Categories 76 | category.airport = "Airport"; 77 | category.amusementPark = "Amusement Park"; 78 | category.aquarium = "Aquarium"; 79 | category.atm = "ATM"; 80 | category.bakery = "Bakery"; 81 | category.bank = "Bank"; 82 | category.beach = "Beach"; 83 | category.brewery = "Brewery"; 84 | category.cafe = "Cafe"; 85 | category.campground = "Campground"; 86 | category.carRental = "Car Rental"; 87 | category.evCharger = "EV Charger"; 88 | category.fireStation = "Fire Station"; 89 | category.fitnessCenter = "Fitness Center"; 90 | category.foodMarket = "Food Market"; 91 | category.gasStation = "Gas Station"; 92 | category.hospital = "Hospital"; 93 | category.hotel = "Hotel"; 94 | category.laundry = "Laundry"; 95 | category.library = "Library"; 96 | category.marina = "Marina"; 97 | category.movieTheater = "Movie Theater"; 98 | category.museum = "Museum"; 99 | category.nationalPark = "National Park"; 100 | category.nightlife = "Nightlife"; 101 | category.park = "Park"; 102 | category.parking = "Parking"; 103 | category.pharmacy = "Pharmacy"; 104 | category.police = "Police"; 105 | category.postOffice = "Post Office"; 106 | category.publicTransport = "Public Transport"; 107 | category.restaurant = "Restaurant"; 108 | category.restroom = "Restroom"; 109 | category.school = "School"; 110 | category.stadium = "Stadium"; 111 | category.store = "Store"; 112 | category.theater = "Theater"; 113 | category.university = "University"; 114 | category.winery = "Winery"; 115 | category.zoo = "Zoo"; 116 | 117 | // MARK: Map View Modes 118 | MKMapConfiguration.title.standard = "Standard"; 119 | MKMapConfiguration.title.traffic = "Traffic"; 120 | MKMapConfiguration.title.hybrid = "Sattelite"; 121 | 122 | // MARK: Image View 123 | image = "Image"; 124 | image.source.teaser = "From"; 125 | image.source.wikipedia = "Wikipedia"; 126 | 127 | // MARK: Opening Hours 128 | openingHours = "Hours"; 129 | openingHours.holiday = "Holidays"; 130 | openingHours.schoolHoliday = "School Holidays"; 131 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/Components/AsyncPhotoZoomView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SchafKit 3 | 4 | struct AsyncPhotoZoomView: View { 5 | let url: String 6 | 7 | @State var image: UIImage? 8 | 9 | var body: some View { 10 | if let image { 11 | PhotoZoomView(image: image) 12 | } else { 13 | VStack { 14 | Spacer() 15 | if #available(iOS 14.0, *) { 16 | ProgressView() 17 | } else { 18 | Text("search.loading", bundle: .module) 19 | } 20 | Spacer() 21 | } 22 | .onAppear { 23 | SKNetworking.request(url: url) { result in 24 | switch result { 25 | case .success(let result): 26 | if let image = UIImage(data: result.data) { 27 | self.image = image 28 | } 29 | case .failure: 30 | // TODO: Show Error 31 | break 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/Components/CompassView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import MapKit 3 | 4 | struct CompassView: UIViewRepresentable { 5 | let mapView: MKMapView? 6 | 7 | func makeUIView(context: Context) -> MKCompassButton { 8 | let button = MKCompassButton(mapView: mapView) 9 | button.compassVisibility = .adaptive 10 | return button 11 | } 12 | 13 | func updateUIView(_ uiView: MKCompassButton, context: Context) { 14 | uiView.mapView = mapView 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/Components/ListEmulationSection.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ListEmulationSection: View { 4 | let headerText: LocalizedStringKey 5 | @ViewBuilder let content: () -> T 6 | 7 | var body: some View { 8 | VStack(spacing: 8) { 9 | HStack { 10 | Text(headerText, bundle: .module) 11 | .font(.title3Compatible.bold()) 12 | .padding(.horizontal, 12) 13 | Spacer() 14 | } 15 | VStack(spacing: 0) { 16 | content() 17 | } 18 | .background(Color.secondarySystemBackground) 19 | .cornerRadius(8) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/Components/LookaroundView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import MapKit 3 | 4 | @available(iOS 16.0, *) 5 | struct LookaroundView: UIViewControllerRepresentable { 6 | let lookaroundScene: MKLookAroundScene 7 | 8 | func makeUIViewController(context: Context) -> MKLookAroundViewController { 9 | MKLookAroundViewController(scene: lookaroundScene) 10 | } 11 | 12 | func updateUIViewController(_ uiViewController: MKLookAroundViewController, context: Context) {} 13 | } 14 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/Components/MapControllerHolder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import MapKit 4 | 5 | struct MapControllerHolder: UIViewControllerRepresentable { 6 | @ObservedObject var coordinator: MapItemPickerController 7 | @ObservedObject var searcher: MapItemSearchController 8 | 9 | @Binding var searchControllerShown: Bool 10 | 11 | let annotations: [MKAnnotation] 12 | let overlays: [MKOverlay] 13 | let primaryAction: MapItemPickerAction 14 | let actions: [MapItemPickerAction] 15 | let standardView: () -> StandardView 16 | let standardSearchView: () -> SearchView 17 | 18 | func makeUIViewController(context: Context) -> MapViewController { 19 | let controller = MapViewController( 20 | coordinator: coordinator, 21 | primaryAction: primaryAction, 22 | actions: actions, 23 | searchSheetDismissHandler: { searchControllerShown = false }, 24 | standardView: standardView, 25 | standardSearchView: standardSearchView 26 | ) 27 | 28 | RunLoop.main.perform { 29 | coordinator.currentMapView = controller.mapView 30 | coordinator.currentMainController = controller 31 | controller.update(searchSheetShown: searchControllerShown) 32 | } 33 | 34 | return controller 35 | } 36 | 37 | func updateUIViewController(_ uiViewController: MapViewController, context: Context) { 38 | let view = uiViewController.mapView 39 | 40 | view.delegate = coordinator // (1) This should be set in makeUIView, but it is getting reset to `nil` 41 | view.translatesAutoresizingMaskIntoConstraints = false // (2) In the absence of this, we get constraints error on rotation; and again, it seems one should do this in makeUIView, but has to be here 42 | 43 | uiViewController.primaryAction = primaryAction 44 | uiViewController.actions = actions 45 | refreshAnnotations(view: view) 46 | refreshOverlays(view: view) 47 | 48 | uiViewController.update(selectedCluster: coordinator.selectedMapItemCluster) 49 | uiViewController.update(mapItemController: coordinator.selectedMapItem) 50 | uiViewController.update(localSearchCompletion: coordinator.searcher.searchedCompletion) 51 | 52 | uiViewController.update(searchSheetShown: searchControllerShown) 53 | } 54 | 55 | func refreshAnnotations(view: MKMapView) { 56 | var newAnnotations: [MKAnnotation] = (coordinator.searcher.completionItems ?? coordinator.searcher.items) + annotations 57 | if let selected = coordinator.selectedMapItem, !newAnnotations.contains(annotation: selected) { 58 | newAnnotations.append(selected) 59 | } 60 | let oldAnnotations = view.annotations 61 | 62 | let annotationsToAdd = newAnnotations.filter({ !oldAnnotations.contains(annotation: $0) }) 63 | let annotationsToRemove = oldAnnotations.filter({ 64 | if #available(iOS 16, *), $0 is MKMapFeatureAnnotation { 65 | return false 66 | } 67 | 68 | return !newAnnotations.contains(annotation: $0) && 69 | !($0 is MKClusterAnnotation) 70 | }) 71 | 72 | view.removeAnnotations(annotationsToRemove) 73 | view.addAnnotations(annotationsToAdd) 74 | 75 | // Sometimes the selectedAnnotation was not in the annotations before and thus has to be selected now. 76 | if let selected = coordinator.selectedMapItem, annotationsToAdd.contains(annotation: selected) { 77 | coordinator.reloadSelectedAnnotation() 78 | } 79 | } 80 | 81 | func refreshOverlays(view: MKMapView) { 82 | let newOverlays = overlays 83 | let oldOverlays = view.overlays 84 | 85 | let overlaysToAdd = newOverlays.filter({ !oldOverlays.contains(overlay: $0) }) 86 | let overlaysToRemove = oldOverlays.filter({ !newOverlays.contains(overlay: $0) }) 87 | 88 | view.removeOverlays(overlaysToRemove) 89 | view.addOverlays(overlaysToAdd) 90 | } 91 | } 92 | 93 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/Components/MapStyleChooser.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import MapKit 3 | 4 | extension TopRightButtons { 5 | @available(iOS 16.0, *) 6 | struct MapStyleChooser: View { 7 | @ObservedObject var coordinator: MapItemPickerController 8 | 9 | private let cases = MKMapConfiguration.cases 10 | @State private var selectedIndex: Int = 0 11 | 12 | var body: some View { 13 | Menu { 14 | Picker( 15 | "", 16 | selection: $selectedIndex 17 | ) { 18 | ForEach(0..<3) { caseIndex in 19 | let singleCase = cases[caseIndex] 20 | HStack { 21 | Image(systemName: singleCase.imageName) 22 | Text(singleCase.title, bundle: Bundle.module) 23 | } 24 | .tag(caseIndex) 25 | } 26 | } 27 | } label: { 28 | SingleDisplay(imageName: cases[selectedIndex].imageName) 29 | } 30 | .onAppear { 31 | selectedIndex = cases.firstIndex(where: { config in 32 | config.title == coordinator.currentMapView?.preferredConfiguration.title 33 | }) ?? 0 34 | } 35 | .onChange(of: selectedIndex) { selectedIndex in 36 | coordinator.currentMapView?.preferredConfiguration = cases[selectedIndex] 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/Components/MapViewController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import MapKit 3 | 4 | let miniDetentHeight: CGFloat = 80 5 | let standardDetentHeight: CGFloat = 400 6 | let miniDetentIdentifier = UISheetPresentationController.Detent.Identifier("mini") 7 | let standardDetentIdentifier = UISheetPresentationController.Detent.Identifier("standard") 8 | let bigDetentIdentifier = UISheetPresentationController.Detent.Identifier("big") 9 | private let standardDetents: [UISheetPresentationController.Detent] = { () -> [UISheetPresentationController.Detent] in 10 | if #available(iOS 16, *) { 11 | return [ 12 | .custom(identifier: miniDetentIdentifier, resolver: { _ in miniDetentHeight }), 13 | .custom(identifier: standardDetentIdentifier, resolver: { _ in standardDetentHeight }), 14 | // -1 so that the screen doesn't get smaller, see https://stackoverflow.com/questions/75635250/leaving-the-presentingviewcontroller-full-screen-while-presenting-a-pagesheet?noredirect=1#comment133439969_75635250 15 | .custom(identifier: bigDetentIdentifier, resolver: { context in context.maximumDetentValue - 1 }) 16 | ] 17 | } 18 | return [.medium(), .large()] 19 | }() 20 | 21 | class MapViewController: UIViewController { 22 | let mapView = MKMapView() 23 | let coordinator: MapItemPickerController 24 | 25 | let standardSheet: UIHostingController 26 | let searchSheet: UIHostingController> 27 | var shownSearchSheet: UIHostingController>? 28 | 29 | var primaryAction: MapItemPickerAction 30 | var actions: [MapItemPickerAction] 31 | 32 | var mapItemDisplaySheet: UIHostingController? = nil 33 | var mapItemClusterSheet: UIHostingController? = nil 34 | var localSearchCompletionSearchSheet: UIHostingController? = nil 35 | 36 | private var mainSheet: UIViewController { 37 | (standardSheet.rootView is EmptyView) ? searchSheet : standardSheet 38 | } 39 | 40 | init( 41 | coordinator: MapItemPickerController, 42 | primaryAction: MapItemPickerAction, 43 | actions: [MapItemPickerAction], 44 | searchSheetDismissHandler: @escaping () -> Void, 45 | standardView: @escaping () -> StandardView, 46 | standardSearchView: @escaping () -> SearchView 47 | ) { 48 | self.coordinator = coordinator 49 | self.standardSheet = UIHostingController(rootView: standardView()) 50 | self.searchSheet = UIHostingController(rootView: SearchSheet( 51 | coordinator: coordinator, 52 | searcher: coordinator.searcher, 53 | dismissHandler: (standardSheet.rootView is EmptyView) ? nil : searchSheetDismissHandler, 54 | standardView: standardSearchView 55 | )) 56 | self.primaryAction = primaryAction 57 | self.actions = actions 58 | 59 | super.init(nibName: nil, bundle: nil) 60 | 61 | mapView.showsUserLocation = true 62 | mapView.showsCompass = false 63 | if #available(iOS 16.0, *) { 64 | mapView.selectableMapFeatures = [.physicalFeatures, .pointsOfInterest, .territories] 65 | } 66 | 67 | self.view = mapView 68 | } 69 | 70 | func update(mapItemController: MapItemController?) { 71 | if let mapItemDisplaySheet { 72 | if let mapItemController { 73 | mapItemDisplaySheet.rootView.itemCoordinator = mapItemController 74 | } else { 75 | self.mapItemDisplaySheet = nil 76 | } 77 | } else if let mapItemController { 78 | let sheet = UIHostingController(rootView: .init( 79 | coordinator: coordinator, 80 | itemCoordinator: mapItemController, 81 | primaryAction: primaryAction, 82 | actions: actions, 83 | dismissHandler: { [self] in 84 | searchSheet.rootView.coordinator.manuallySet(selectedMapItem: nil) 85 | coordinator.sheetPresentationControllerDidChangeSelectedDetentIdentifier( 86 | localSearchCompletionSearchSheet?.sheetPresentationController ?? 87 | searchSheet.sheetPresentationController! 88 | ) 89 | }, 90 | shouldScroll: true, 91 | shouldAddPadding: true 92 | )) 93 | mapItemDisplaySheet = sheet 94 | } 95 | 96 | updateSheets() 97 | } 98 | 99 | func update(selectedCluster: MKClusterAnnotation?) { 100 | if let mapItemClusterSheet { 101 | if let selectedCluster { 102 | mapItemClusterSheet.rootView.cluster = selectedCluster 103 | } else { 104 | self.mapItemClusterSheet = nil 105 | } 106 | } else if let selectedCluster { 107 | let sheet = UIHostingController(rootView: 108 | .init( 109 | coordinator: coordinator, 110 | cluster: selectedCluster, 111 | dismissHandler: { 112 | self.coordinator.selectedMapItemCluster = nil 113 | self.coordinator.reloadSelectedAnnotation() 114 | self.coordinator.sheetPresentationControllerDidChangeSelectedDetentIdentifier(self.mainSheet.topmostViewController.sheetPresentationController!) 115 | } 116 | ) 117 | ) 118 | mapItemClusterSheet = sheet 119 | } 120 | 121 | updateSheets() 122 | } 123 | 124 | func update(localSearchCompletion: MKLocalSearchCompletion?) { 125 | if localSearchCompletion != localSearchCompletionSearchSheet?.rootView.completion { 126 | localSearchCompletionSearchSheet = nil 127 | 128 | if let localSearchCompletion { 129 | let sheet = UIHostingController( 130 | rootView: .init( 131 | completion: localSearchCompletion, 132 | searcher: searchSheet.rootView.coordinator.searcher, 133 | coordinator: searchSheet.rootView.coordinator, 134 | primaryAction: primaryAction, 135 | actions: actions 136 | ) { [self] in 137 | coordinator.searcher.searchedCompletion = nil 138 | coordinator.sheetPresentationControllerDidChangeSelectedDetentIdentifier(searchSheet.sheetPresentationController!) 139 | } 140 | ) 141 | localSearchCompletionSearchSheet = sheet 142 | } 143 | } 144 | 145 | updateSheets() 146 | } 147 | 148 | required init?(coder: NSCoder) { 149 | fatalError("init(coder:) has not been implemented") 150 | } 151 | 152 | override func viewWillAppear(_ animated: Bool) { 153 | super.viewWillAppear(animated) 154 | 155 | presentWithDetents(mainSheet) 156 | } 157 | 158 | private func updateSheets() { 159 | let currentlyPresentedSheet = mainSheet.topmostViewController 160 | if 161 | currentlyPresentedSheet != mainSheet && 162 | ![shownSearchSheet, mapItemDisplaySheet, mapItemClusterSheet, localSearchCompletionSearchSheet].contains(exactObject: currentlyPresentedSheet) 163 | { 164 | currentlyPresentedSheet.dismiss(animated: true) { 165 | currentlyPresentedSheet.isModalInPresentation = false 166 | self.updateSheets() 167 | } 168 | return 169 | } 170 | 171 | if let mapItemDisplaySheet { 172 | presentWithDetents(mapItemDisplaySheet) 173 | } else if let mapItemClusterSheet { 174 | presentWithDetents(mapItemClusterSheet) 175 | } else if let localSearchCompletionSearchSheet { 176 | presentWithDetents(localSearchCompletionSearchSheet) 177 | } else if let shownSearchSheet { 178 | presentWithDetents(shownSearchSheet) 179 | } 180 | } 181 | 182 | func update(searchSheetShown: Bool) { 183 | if searchSheetShown && shownSearchSheet == nil { 184 | shownSearchSheet = searchSheet 185 | } else if !searchSheetShown && shownSearchSheet != nil { 186 | shownSearchSheet = nil 187 | } else { 188 | return 189 | } 190 | 191 | mapItemDisplaySheet = nil 192 | mapItemClusterSheet = nil 193 | localSearchCompletionSearchSheet = nil 194 | 195 | coordinator.manuallySet(selectedMapItem: nil) 196 | 197 | updateSheets() 198 | } 199 | 200 | private func presentWithDetents(_ controller: UIViewController) { 201 | guard !controller.isModalInPresentation else { return } 202 | 203 | controller.isModalInPresentation = true 204 | controller.modalPresentationStyle = .pageSheet 205 | let presentationController = controller.sheetPresentationController! 206 | presentationController.prefersGrabberVisible = true 207 | presentationController.prefersScrollingExpandsWhenScrolledToEdge = false 208 | presentationController.detents = standardDetents 209 | presentationController.selectedDetentIdentifier = standardDetentIdentifier 210 | if #available(iOS 16, *) { 211 | presentationController.largestUndimmedDetentIdentifier = bigDetentIdentifier 212 | } else { 213 | presentationController.largestUndimmedDetentIdentifier = .large 214 | } 215 | presentationController.delegate = searchSheet.rootView.coordinator 216 | 217 | if controller == mainSheet { 218 | RunLoop.main.perform { [self] in 219 | if controller.isBeingPresented { return } 220 | present(controller, animated: false) 221 | } 222 | } else { 223 | // This dismisses software keybaords so that they are not stuck inside one of the views that is not on screen anymore. It has to be outside of the RunLoop block, so that it doesn't mess with our presentation. 224 | mainSheet.view.window?.firstResponder?.resignFirstResponder() 225 | 226 | RunLoop.main.perform { [self] in 227 | let topmostController = mainSheet.topmostViewController 228 | 229 | guard let topmostSheetPresentation = topmostController.sheetPresentationController else { 230 | print("Error: Could not find sheetPresentationController in topmost controller:", topmostController) 231 | return 232 | } 233 | 234 | topmostSheetPresentation.animateChanges { 235 | topmostController.sheetPresentationController!.selectedDetentIdentifier = standardDetentIdentifier 236 | } 237 | topmostController.present(controller) 238 | } 239 | } 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/Components/PhotoZoomView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | import PDFKit 4 | 5 | struct PhotoZoomView: UIViewRepresentable { 6 | let image: UIImage 7 | 8 | func makeUIView(context: Context) -> PDFView { 9 | let view = PDFView() 10 | view.document = PDFDocument() 11 | guard let page = PDFPage(image: image) else { return view } 12 | view.document?.insert(page, at: 0) 13 | view.autoScales = true 14 | view.displayMode = .singlePage 15 | return view 16 | } 17 | 18 | func updateUIView(_ uiView: PDFView, context: Context) { } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/Components/SearchCell.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SearchCell: View { 4 | let systemImageName: String 5 | let color: Color 6 | let title: String 7 | let subtitle: String 8 | let action: () -> Void 9 | 10 | init(systemImageName: String, color: Color, title: String, subtitle: String, action: @escaping () -> Void) { 11 | self.systemImageName = systemImageName 12 | self.color = color 13 | self.title = title 14 | self.subtitle = subtitle 15 | self.action = action 16 | } 17 | 18 | init(mapItemController: MapItemController, coordinator: MapItemPickerController) { 19 | self.systemImageName = mapItemController.item.imageName 20 | self.color = mapItemController.item.color 21 | self.title = mapItemController.item.name 22 | self.subtitle = mapItemController.item.subtitle 23 | self.action = { 24 | coordinator.manuallySet(selectedMapItem: mapItemController) 25 | } 26 | } 27 | 28 | var body: some View { 29 | Button(action: action) { 30 | HStack { 31 | Circle() 32 | .fill(color) 33 | .overlay { 34 | Image(systemName: systemImageName) 35 | .resizable() 36 | .scaledToFit() 37 | .padding(6) 38 | .foregroundColor(.white) 39 | } 40 | .frame(width: 30) 41 | VStack(alignment: .leading) { 42 | Text(title) 43 | .font(.body.bold()) 44 | if !subtitle.isEmpty { 45 | Text(subtitle) 46 | .font(.caption) 47 | .opacity(0.75) 48 | } 49 | } 50 | Spacer() 51 | } 52 | .frame(height: 30) 53 | .foregroundColor(.label) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/Components/TopRightButtons.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SchafKit 3 | 4 | struct TopRightButtons: View { 5 | @ObservedObject var coordinator: MapItemPickerController 6 | let showsLocationButton: Bool 7 | let additionalTopRightButtons: [MIPAction] 8 | 9 | enum Constants { 10 | static let size: CGFloat = 44 11 | static let glyphSize: CGFloat = 22 12 | static let padding: CGFloat = 10 13 | } 14 | 15 | struct SingleDisplay: View { 16 | let imageName: String 17 | 18 | var body: some View { 19 | Rectangle() 20 | .fill(Color.clear) 21 | .frame(height: Constants.size) 22 | .overlay( 23 | Image(systemName: imageName) 24 | .font(.system(size: Constants.glyphSize)) 25 | .foregroundColor(.label) 26 | ) 27 | } 28 | } 29 | 30 | struct Single: View { 31 | let imageName: String 32 | let handler: () -> Void 33 | 34 | var body: some View { 35 | Button(action: handler) { 36 | SingleDisplay(imageName: imageName) 37 | } 38 | } 39 | } 40 | 41 | struct Location: View { 42 | @ObservedObject var helper: LocationController 43 | @ObservedObject var coordinator: MapItemPickerController 44 | 45 | var body: some View { 46 | Single(imageName: helper.displayedImage) { 47 | helper.tapped(coordinator: coordinator) 48 | } 49 | } 50 | } 51 | 52 | var body: some View { 53 | VStack { 54 | VStack(spacing: 0) { 55 | if #available(iOS 16, *) { 56 | MapStyleChooser(coordinator: coordinator) 57 | } 58 | if showsLocationButton { 59 | if #available(iOS 16, *) { Divider() } 60 | Location(helper: coordinator.locationController, coordinator: coordinator) 61 | } 62 | ForEach(additionalTopRightButtons) { button in 63 | Divider() 64 | Single(imageName: button.imageName, handler: button.handler) 65 | } 66 | } 67 | .frame(width: Constants.size) 68 | .background(Blur(.regular)) 69 | .cornerRadius(6) 70 | .shadow(radius: 1) 71 | CompassView(mapView: coordinator.currentMapView) 72 | .frame(width: Constants.size, height: Constants.size) 73 | } 74 | .padding(.top, 8) 75 | .padding(.trailing, Constants.padding) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/ImageSheet/ImageSheet.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ImageSheet: View { 4 | let image: MapItemImage 5 | let dismiss: () -> Void 6 | 7 | var html: NSAttributedString? { 8 | if let data = image.description?.data(using: .utf8) { 9 | if let string = try? NSMutableAttributedString( 10 | data: data, 11 | options: [.documentType: NSAttributedString.DocumentType.html], 12 | documentAttributes: nil 13 | ) { 14 | string.setAttributes( 15 | [.font: UIFont.systemFont(ofSize: UIFont.systemFontSize)], 16 | range: .init(location: 0, length: string.length) 17 | ) 18 | return string 19 | } 20 | } 21 | return nil 22 | } 23 | 24 | var footer: some View { 25 | VStack(alignment: .leading) { 26 | if let html { 27 | if #available(iOS 15, *) { 28 | Text(AttributedString(html)) 29 | } else { 30 | Text(html.string) 31 | } 32 | } else if let description = image.description { 33 | Text(description) 34 | } 35 | if #available(iOS 15, *) { 36 | ( 37 | Text("image.source.teaser", bundle: .module) + 38 | Text(" ") + 39 | Text(AttributedString( 40 | image.source.nameLocalizationKey.moduleLocalized, 41 | attributes: .init([.link: image.sourceUrl]) 42 | )) 43 | ) 44 | .padding(.top) 45 | } else { 46 | HStack { 47 | Text("image.source.teaser", bundle: .module) 48 | Button(image.source.nameLocalizationKey.moduleLocalized) { 49 | UIApplication.shared.open(image.sourceUrl) 50 | } 51 | } 52 | } 53 | } 54 | } 55 | 56 | var body: some View { 57 | NavigationView { 58 | VStack(spacing: 0) { 59 | AsyncPhotoZoomView(url: image.url.absoluteString) 60 | Divider() 61 | HStack { 62 | footer 63 | Spacer(minLength: 0) 64 | } 65 | .multilineTextAlignment(.leading) 66 | .padding([.horizontal, .top]) 67 | .background(Color.systemBackground) 68 | } 69 | .navigationBarItems( 70 | leading: Button { 71 | dismiss() 72 | } label: { 73 | Text("done", bundle: .module) 74 | } 75 | ) 76 | .navigationBarTitle(Text("image", bundle: .module), displayMode: .inline) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/MapItemDisplayView/+aboutSection.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension MapItemDisplaySheet { 4 | @ViewBuilder var aboutSection: some View { 5 | if let about = item.wikiDescription { 6 | VStack { 7 | ListEmulationSection(headerText: "itemSheet.about") { 8 | HStack { 9 | Text(about.capitalizedSentence) 10 | .listCellEmulationPadding() 11 | Spacer(minLength: 0) 12 | } 13 | } 14 | if let wikipediaURL = item.wikipediaURL, let url = URL(string: wikipediaURL), #available(iOS 15, *) { 15 | HStack { 16 | Text("itemSheet.about.moreOn", bundle: .module) + Text(" ") + 17 | Text(AttributedString("Wikipedia", attributes: .init([.link: url]))) 18 | Spacer(minLength: 0) 19 | } 20 | .padding(.leading, 12) 21 | } 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/MapItemDisplayView/+buttonSection.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension MapItemDisplaySheet { 4 | @ViewBuilder var buttonSection: some View { 5 | if let coordinator, let primaryAction { 6 | MapItemActionButtons(coordinator: coordinator, item: item, primaryAction: primaryAction, actions: actions) 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/MapItemDisplayView/+contactSection.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension MapItemDisplaySheet { 4 | 5 | @ViewBuilder var contactSection: some View { 6 | if item.website != nil || item.phone != nil { 7 | ListEmulationSection(headerText: "itemSheet.contact") { 8 | if let website = item.website, let url = URL(string: website) { 9 | DetailListEmulationCell( 10 | title: "itemSheet.contact.website", 11 | detail: website, 12 | link: url 13 | ) 14 | if item.phone != nil { 15 | Divider() 16 | } 17 | } 18 | if let phone = item.phone, let url = URL(string: "telprompt://\(phone.filter({ !$0.isWhitespace }))") { 19 | DetailListEmulationCell( 20 | title: "itemSheet.contact.phone", 21 | detail: phone, 22 | link: url 23 | ) 24 | } 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/MapItemDisplayView/+detailsSection.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension MapItemDisplaySheet { 4 | private var coordinateString: String { 5 | let lat = item.location.latitude 6 | let lon = item.location.longitude 7 | 8 | return "\(lat.magnitude.toFormattedString(decimals: 5))° \(lat >= 0 ? "N" : "S"), \(lon.magnitude.toFormattedString(decimals: 5))° \(lon >= 0 ? "E" : "W")" 9 | } 10 | 11 | @ViewBuilder var detailsSection: some View { 12 | ListEmulationSection(headerText: "itemSheet.details") { 13 | if let openingHours = item.openingHours { 14 | OpeningHoursCell(openingHours: openingHours) 15 | Divider() 16 | } 17 | let lines = item.addressLines 18 | if !lines.isEmpty { 19 | DetailListEmulationCell( 20 | title: "itemSheet.details.location", 21 | detail: lines.joined(separator: .newline), 22 | link: nil 23 | ) 24 | Divider() 25 | } 26 | DetailListEmulationCell( 27 | title: "itemSheet.details.coordinates", 28 | detail: coordinateString, 29 | link: nil 30 | ) 31 | if let level = item.level { 32 | Divider() 33 | DetailListEmulationCell( 34 | title: "itemSheet.details.level", 35 | detail: "\(level)", 36 | link: nil 37 | ) 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/MapItemDisplayView/+factsSection.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension MapItemDisplaySheet { 4 | 5 | struct Fact: Identifiable { 6 | let id = UUID() 7 | 8 | let imageName: String 9 | let title: LocalizedStringKey 10 | } 11 | 12 | private var facts: [Fact] { 13 | var result = [Fact]() 14 | 15 | if let hasVeganFood = item.hasVeganFood { 16 | result.append(.init( 17 | imageName: "leaf", 18 | title: hasVeganFood.title(mainTitleKey: "itemSheet.facts.vegan") 19 | )) 20 | } 21 | if let hasVegetarianFood = item.hasVegetarianFood { 22 | result.append(.init( 23 | imageName: "leaf", 24 | title: hasVegetarianFood.title(mainTitleKey: "itemSheet.facts.vegetarian") 25 | )) 26 | } 27 | 28 | if let takeaway = item.takeaway { 29 | result.append(.init( 30 | imageName: "takeoutbag.and.cup.and.straw", 31 | title: takeaway.title(mainTitleKey: "itemSheet.facts.takeaway") 32 | )) 33 | } 34 | if let indoorSeating = item.indoorSeating { 35 | result.append(.init( 36 | imageName: "chair", 37 | title: indoorSeating.title(mainTitleKey: "itemSheet.facts.indoorSeating") 38 | )) 39 | } 40 | if let outdoorSeating = item.outdoorSeating { 41 | result.append(.init( 42 | imageName: "sun.max.fill", 43 | title: outdoorSeating.title(mainTitleKey: "itemSheet.facts.outdoorSeating") 44 | )) 45 | } 46 | if let smoking = item.smoking { 47 | result.append(.init( 48 | imageName: smoking == .yes ? "checkmark.circle" : "nosign", 49 | title: smoking.title(mainTitleKey: "itemSheet.facts.smoking") 50 | )) 51 | } 52 | 53 | if let internetAccess = item.internetAccess { 54 | let fact: Fact? 55 | 56 | switch internetAccess { 57 | case .yes: 58 | fact = .init( 59 | imageName: "wifi", 60 | title: "itemSheet.facts.internetAccess" 61 | ) 62 | case .no: 63 | fact = .init( 64 | imageName: "wifi.slash", 65 | title: "itemSheet.facts.internetAccess.no" 66 | ) 67 | case .wlan: 68 | fact = .init( 69 | imageName: "wifi", 70 | title: "itemSheet.facts.internetAccess.wifi" 71 | ) 72 | case .terminal: 73 | fact = .init( 74 | imageName: "desktopcomputer", 75 | title: "itemSheet.facts.internetAccess.terminal" 76 | ) 77 | case .service: 78 | fact = nil 79 | case .wired: 80 | fact = .init( 81 | imageName: "cable.connector", 82 | title: "itemSheet.facts.internetAccess.wired" 83 | ) 84 | } 85 | 86 | if let fact { 87 | result.append(fact) 88 | } 89 | } 90 | 91 | if let wheelchair = item.wheelchair { 92 | let fact: Fact 93 | 94 | switch wheelchair { 95 | case .yes: 96 | fact = .init( 97 | imageName: "figure.roll", 98 | title: "itemSheet.facts.wheelchair" 99 | ) 100 | case .no: 101 | fact = .init( 102 | imageName: "exclamationmark.triangle", 103 | title: "itemSheet.facts.wheelchair.no" 104 | ) 105 | case .designated: 106 | fact = .init( 107 | imageName: "figure.roll", 108 | title: "itemSheet.facts.wheelchair.designated" 109 | ) 110 | case .limited: 111 | fact = .init( 112 | imageName: "figure.roll", 113 | title: "itemSheet.facts.wheelchair.limited" 114 | ) 115 | } 116 | 117 | result.append(fact) 118 | } 119 | 120 | return result 121 | } 122 | 123 | @ViewBuilder var factsSection: some View { 124 | let facts = self.facts 125 | if !facts.isEmpty { 126 | ListEmulationSection(headerText: "itemSheet.facts") { 127 | HStack { 128 | VStack(alignment: .leading, spacing: 4) { 129 | ForEach(facts) { fact in 130 | HStack { 131 | Image(systemName: fact.imageName) 132 | Text(fact.title, bundle: .module) 133 | } 134 | } 135 | } 136 | Spacer(minLength: 0) 137 | } 138 | .padding() 139 | } 140 | } 141 | } 142 | } 143 | 144 | extension ExclusivityBool { 145 | func title(mainTitleKey: String) -> LocalizedStringKey { 146 | let localized = mainTitleKey.moduleLocalized 147 | switch self { 148 | case .yes: 149 | return .init(localized) 150 | case .no: 151 | return "itemSheet.facts.no \(localized)" 152 | case .only: 153 | return "itemSheet.facts.only \(localized)" 154 | } 155 | } 156 | } 157 | 158 | extension PlaceBool { 159 | func title(mainTitleKey: String) -> LocalizedStringKey { 160 | let localized = mainTitleKey.moduleLocalized 161 | switch self { 162 | case .yes: 163 | return .init(localized) 164 | case .no: 165 | return "itemSheet.facts.no \(localized)" 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/MapItemDisplayView/+header.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension MapItemDisplaySheet { 4 | @ViewBuilder var header: some View { 5 | HStack(alignment: .top) { 6 | VStack(alignment: .leading) { 7 | Text(item.name) 8 | .font(.title.bold()) 9 | Text(item.subtitle) 10 | } 11 | Spacer() 12 | if let dismissHandler { 13 | Button(action: dismissHandler) { 14 | Image(systemName: "xmark.circle.fill") 15 | .font(.title) 16 | .foregroundColor(.gray) 17 | } 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/MapItemDisplayView/+imageSection.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | private let imageHeight: CGFloat = 200 4 | private let imageWidth: CGFloat = 150 5 | 6 | extension MapItemDisplaySheet { 7 | 8 | struct ImageSection: View { 9 | @ObservedObject var itemCoordinator: MapItemController 10 | @State private var shownImage: MapItemImage? 11 | 12 | @ViewBuilder private var lookaroundView: some View { 13 | if #available(iOS 16, *), let lookaroundScene = itemCoordinator.lookaroundScene { 14 | LookaroundView(lookaroundScene: lookaroundScene) 15 | .cornerRadius(8) 16 | } 17 | } 18 | 19 | var body: some View { 20 | if !itemCoordinator.images.isEmpty { 21 | ScrollView(.horizontal, showsIndicators: false) { 22 | HStack { 23 | lookaroundView.frame(width: imageWidth) 24 | LazyHStack { 25 | ForEach(itemCoordinator.images) { image in 26 | Button { 27 | shownImage = image 28 | } label: { 29 | AsyncImage(url: image.thumbnailUrl) { image in 30 | image.resizable().scaledToFill() 31 | } placeholder: { 32 | Color.gray.opacity(0.25) 33 | } 34 | .frame(width: imageWidth, height: imageHeight) 35 | .cornerRadius(8) 36 | } 37 | } 38 | } 39 | } 40 | } 41 | .frame(height: imageHeight) 42 | .fullScreenCover(item: $shownImage) { image in 43 | ImageSheet(image: image, dismiss: { shownImage = nil }) 44 | } 45 | } else { 46 | lookaroundView 47 | .frame(height: imageHeight) 48 | } 49 | } 50 | } 51 | 52 | @ViewBuilder var imageSection: some View { 53 | ImageSection(itemCoordinator: itemCoordinator) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/MapItemDisplayView/+legalSection.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension MapItemDisplaySheet { 4 | var legalSection: some View { 5 | HStack { 6 | Spacer() 7 | Button { 8 | UIApplication.shared.open(.init(string: "https://gspe21-ssl.ls.apple.com/html/attribution-252.html")!) 9 | } label: { 10 | Text("legal", bundle: .module) 11 | .underline() 12 | .font(.caption) 13 | .foregroundColor(.gray) 14 | } 15 | } 16 | .padding(.horizontal, 4) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/MapItemDisplayView/+topScrollInfoView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension MapItemDisplaySheet { 4 | @ViewBuilder var topScrollInfoView: some View { 5 | let items = topScrollableItems 6 | if !items.isEmpty { 7 | VStack { 8 | Divider() 9 | ScrollView(.horizontal, showsIndicators: false) { 10 | HStack { 11 | ForEach(items) { item in 12 | VStack(alignment: .leading, spacing: 4) { 13 | Text(item.title, bundle: .module) 14 | .font(.caption.bold()) 15 | .textCaseUppercase() 16 | .opacity(0.75) 17 | HStack(spacing: 4) { 18 | Image(systemName: item.imageName).opacity(0.5) 19 | Text(item.value) 20 | } 21 | .font(.body.bold()) 22 | } 23 | if item != items.last { 24 | Divider() 25 | } 26 | } 27 | } 28 | } 29 | Divider() 30 | } 31 | } 32 | } 33 | } 34 | 35 | extension MapItemDisplaySheet { 36 | struct DisplayItem: Identifiable, Equatable { 37 | var id: String { imageName } 38 | 39 | let imageName: String 40 | let title: LocalizedStringKey 41 | let value: String 42 | } 43 | 44 | var topScrollableItems: [DisplayItem] { 45 | var items: [DisplayItem] = [] 46 | 47 | if let population = item.population { 48 | items.append(.init( 49 | imageName: "person.3.fill", 50 | title: "itemSheet.topScrollableItems.population", 51 | value: population.compatibleFormatted() 52 | )) 53 | } 54 | if let area = item.area { 55 | items.append(.init( 56 | imageName: "rectangle.dashed", 57 | title: "itemSheet.topScrollableItems.area", 58 | value: "\((area / 1000000).toFormattedString()) km²" 59 | )) 60 | } 61 | if let altitude = item.altitude { 62 | items.append(.init( 63 | imageName: "water.waves.and.arrow.up", 64 | title: "itemSheet.topScrollableItems.altitude", 65 | value: "\(altitude.compatibleFormatted()) m" 66 | )) 67 | } 68 | 69 | return items 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/MapItemDisplayView/DetailListEmulationCell.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct DetailListEmulationCell: View { 4 | let title: LocalizedStringKey 5 | let detail: String 6 | let link: URL? 7 | 8 | var body: some View { 9 | HStack { 10 | VStack(alignment: .leading) { 11 | Text(title, bundle: .module) 12 | .font(.callout) 13 | .opacity(0.75) 14 | if let link, #available(iOS 14, *) { 15 | Link(destination: link) { 16 | Text(detail).multilineTextAlignment(.leading) 17 | } 18 | } else { 19 | Text(detail) 20 | } 21 | } 22 | .multilineTextAlignment(.leading) 23 | Spacer() 24 | } 25 | .listCellEmulationPadding() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/MapItemDisplayView/MapItemActionButtons.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import MapKit 4 | 5 | struct MapItemActionButtons: View { 6 | enum Constants { 7 | static let spacing: CGFloat = 8 8 | } 9 | 10 | let coordinator: MapItemPickerController 11 | let item: MapItem 12 | let primaryAction: MapItemPickerAction 13 | let actions: [MapItemPickerAction] 14 | 15 | @State var height: CGFloat? = nil 16 | 17 | struct Single: View { 18 | @Environment(\.colorScheme) var colorScheme 19 | 20 | let coordinator: MapItemPickerController 21 | let item: MapItem 22 | 23 | let imageName: String 24 | let text: LocalizedStringKey 25 | var textIsFromModule: Bool = true 26 | let action: MapItemPickerAction.ActionType 27 | var isProminent = false 28 | let width: CGFloat 29 | 30 | var inner: some View { 31 | VStack(spacing: 4) { 32 | Image(systemName: imageName) 33 | .resizable() 34 | .scaledToFit() 35 | .frame(height: 14) 36 | Text(text, bundle: textIsFromModule ? .module : .main) 37 | } 38 | .padding(.horizontal, 4) 39 | .padding(.vertical, 8) 40 | .frame(width: width) 41 | } 42 | 43 | func subActionsView(for subActions: [MapItemPickerAction]) -> some View { 44 | ForEach(subActions) { subAction in 45 | Single( 46 | coordinator: coordinator, 47 | item: item, 48 | imageName: subAction.imageName, 49 | text: subAction.title, 50 | textIsFromModule: false, 51 | action: subAction.action, 52 | width: width 53 | ) 54 | } 55 | } 56 | 57 | @ViewBuilder var button: some View { 58 | switch action { 59 | case .single(let action): 60 | Button { 61 | if action(item) { 62 | coordinator.manuallySet(selectedMapItem: nil) 63 | coordinator.searcher.searchTerm = .empty 64 | } 65 | } label: { inner } 66 | case .subActions(let subActions): 67 | if #available(iOS 14, *) { 68 | Menu { subActionsView(for: subActions) } label: { inner } 69 | } else { 70 | inner.contextMenu { subActionsView(for: subActions) } 71 | } 72 | } 73 | } 74 | 75 | var body: some View { 76 | button 77 | .foregroundColor(!isProminent && colorScheme == .light ? .accentColor : .white) 78 | .background(isProminent ? Color.accentColor : Color.secondarySystemBackground) 79 | .cornerRadius(8) 80 | } 81 | } 82 | 83 | func single(for action: MapItemPickerAction, isProminent: Bool = false, width: CGFloat) -> some View { 84 | Single( 85 | coordinator: coordinator, 86 | item: item, 87 | imageName: action.imageName, 88 | text: action.title, 89 | textIsFromModule: false, 90 | action: action.action, 91 | isProminent: isProminent, 92 | width: width 93 | ) 94 | } 95 | 96 | var numberOfItems: Int { 97 | 1 + actions.count + (item.phone == nil ? 0 : 1) + (item.website == nil ? 0 : 1) 98 | } 99 | 100 | func buttons(buttonWidth: CGFloat) -> some View { 101 | HStack(spacing: Constants.spacing) { 102 | single(for: primaryAction, isProminent: true, width: buttonWidth) 103 | ForEach(actions) { action in 104 | single(for: action, width: buttonWidth) 105 | } 106 | if let phone = item.phone { 107 | Single( 108 | coordinator: coordinator, 109 | item: item, 110 | imageName: "phone.fill", 111 | text: "itemSheet.action.call", 112 | action: .single({ _ in 113 | UIApplication.shared.open(URL(string: "telprompt://\(phone.filter({ !$0.isWhitespace }))")!) 114 | return false 115 | }), 116 | width: buttonWidth 117 | ) 118 | } 119 | if let website = item.website { 120 | Single( 121 | coordinator: coordinator, 122 | item: item, 123 | imageName: "safari", 124 | text: "itemSheet.action.visitWebsite", 125 | action: .single({ _ in 126 | UIApplication.shared.open(URL(string: website)!) 127 | return false 128 | }), 129 | width: buttonWidth 130 | ) 131 | } 132 | } 133 | } 134 | 135 | var body: some View { 136 | GeometryReader { proxy in 137 | let numberOfItems = self.numberOfItems 138 | let numberOfItemsFloat = CGFloat(numberOfItems) 139 | Group { 140 | if numberOfItems <= 4 { 141 | buttons(buttonWidth: (proxy.size.width - (numberOfItemsFloat - 1) * Constants.spacing) / numberOfItemsFloat) 142 | } else { 143 | ScrollView(.horizontal) { 144 | buttons(buttonWidth: proxy.size.width / 4.5) 145 | } 146 | } 147 | } 148 | .sizeReader { size in 149 | if size.height != height { 150 | height = size.height 151 | } 152 | } 153 | } 154 | .frame(height: height) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/MapItemDisplayView/MapItemDisplaySheet.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import MapKit 3 | 4 | struct MapItemDisplaySheet: View { 5 | 6 | enum Constants { 7 | static let padding: CGFloat = 12 8 | static let sectionPadding: CGFloat = 8 9 | } 10 | 11 | let coordinator: MapItemPickerController? 12 | @ObservedObject var itemCoordinator: MapItemController 13 | let primaryAction: MapItemPickerAction? 14 | let actions: [MapItemPickerAction] 15 | let dismissHandler: (() -> Void)? 16 | let shouldScroll: Bool 17 | let shouldAddPadding: Bool 18 | 19 | var item: MapItem { itemCoordinator.item } 20 | 21 | var content: some View { 22 | VStack { 23 | Group { 24 | topScrollInfoView 25 | buttonSection 26 | imageSection 27 | } 28 | .padding(.bottom, 4) 29 | Group { 30 | aboutSection.padding(.top, 4) 31 | contactSection 32 | factsSection 33 | detailsSection 34 | } 35 | .padding(.bottom) 36 | legalSection 37 | } 38 | .padding(.horizontal, shouldAddPadding ? Constants.padding : 0) 39 | } 40 | 41 | var body: some View { 42 | VStack { 43 | header.padding([.horizontal, .top], shouldAddPadding ? Constants.padding : 0) 44 | if shouldScroll { 45 | ScrollView { 46 | content 47 | } 48 | } else { 49 | content 50 | } 51 | } 52 | .onAppearAndChange(of: itemCoordinator, perform: { itemCoordinator in 53 | itemCoordinator.loadRemaining() 54 | }) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/MapItemDisplayView/MapItemDisplayView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct MapItemDisplayView: View { 4 | let mapItem: MapItem 5 | let shouldScroll: Bool 6 | 7 | @State private var coordinator: MapItemController? 8 | 9 | public init(mapItem: MapItem, shouldScroll: Bool) { 10 | self.mapItem = mapItem 11 | self.shouldScroll = shouldScroll 12 | } 13 | 14 | public var body: some View { 15 | if let coordinator { 16 | MapItemDisplaySheet( 17 | coordinator: nil, 18 | itemCoordinator: coordinator, 19 | primaryAction: nil, 20 | actions: [], 21 | dismissHandler: nil, 22 | shouldScroll: shouldScroll, 23 | shouldAddPadding: false 24 | ) 25 | } else { 26 | Rectangle() 27 | .onAppear { coordinator = .init(item: mapItem) } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/MapItemDisplayView/OpeningHoursCell.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct OpeningHoursCell: View { 4 | let openingHours: OpeningHours 5 | 6 | var body: some View { 7 | VStack(alignment: .leading) { 8 | Text("openingHours", bundle: .module) 9 | .font(.callout) 10 | .opacity(0.75) 11 | ForEach(openingHours.sortedDisplayableWeekPortions, id: \.hashValue) { portion in 12 | if let firstWeekday = portion.weekdays.first { 13 | HStack(alignment: .top) { 14 | if portion.weekdays.count > 1 { 15 | let lastWeekday = portion.weekdays.last! 16 | Text("\(firstWeekday.shortLocalizedName) - \(lastWeekday.shortLocalizedName)") 17 | } else { 18 | Text(firstWeekday.localizedName) 19 | } 20 | Spacer() 21 | VStack { 22 | ForEach(portion.ranges, id: \.hashValue) { range in 23 | Text("\(range.from.displayString) - \(range.to.displayString)") 24 | } 25 | } 26 | } 27 | } 28 | } 29 | } 30 | .listCellEmulationPadding() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/MapItemPickerConstants.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum MapItemPickerConstants { 4 | /// The name of the cache directory MapItemPicker uses. 5 | public static let cacheDirectoryName = "MapItemPicker" 6 | } 7 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/RecentMapItemsSection.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | /// The section displaying the recently selected map items in the search view. 5 | public struct RecentMapItemsSection: View { 6 | @ObservedObject var controller: RecentMapItemsController = .shared 7 | @EnvironmentObject var coordinator: MapItemPickerController 8 | 9 | @State private var showMore: Bool = false 10 | 11 | public var body: some View { 12 | if !controller.recentMapItems.isEmpty { 13 | Section { 14 | ForEach(controller.recentMapItems.sliced(upTo: showMore ? RecentMapItemsController.Constants.maximumNumberOfRecentMapItems : 3), id: \.location) { mapItem in 15 | SearchCell(mapItemController: .init(item: mapItem), coordinator: coordinator) 16 | } 17 | } header: { 18 | HStack(alignment: .bottom) { 19 | Text("search.recentMapItems", bundle: .module) 20 | Spacer() 21 | if controller.recentMapItems.count > 3 { 22 | Button { 23 | showMore.toggle() 24 | } label: { 25 | Text(showMore ? "search.recentMapItems.showLess" : "search.recentMapItems.showMore", bundle: .module) 26 | .textCase(.none) 27 | .font(.footnote) 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/Sheets/LocalSearchCompletionSearchSheet.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import MapKit 3 | 4 | struct LocalSearchCompletionSearchSheet: View { 5 | let completion: MKLocalSearchCompletion 6 | @ObservedObject var searcher: MapItemSearchController 7 | @ObservedObject var coordinator: MapItemPickerController 8 | 9 | let primaryAction: MapItemPickerAction 10 | let actions: [MapItemPickerAction] 11 | let dismissHandler: () -> Void 12 | 13 | var body: some View { 14 | if let items = searcher.completionItems, items.count == 1 { 15 | MapItemDisplaySheet( 16 | coordinator: coordinator, 17 | itemCoordinator: items[0], 18 | primaryAction: primaryAction, 19 | actions: actions, 20 | dismissHandler: dismissHandler, 21 | shouldScroll: true, 22 | shouldAddPadding: true 23 | ) 24 | .onAppear { 25 | coordinator.reloadSelectedAnnotation() 26 | } 27 | } else { 28 | VStack { 29 | HStack(alignment: .top) { 30 | VStack(alignment: .leading) { 31 | Text(completion.title) 32 | .font(.title.bold()) 33 | Text(completion.subtitle) 34 | } 35 | Spacer() 36 | Button(action: dismissHandler) { 37 | Image(systemName: "xmark.circle.fill") 38 | .font(.title) 39 | .foregroundColor(.gray) 40 | } 41 | } 42 | .padding([.horizontal, .top]) 43 | if let items = searcher.completionItems { 44 | List { 45 | ForEach(items) { itemCoordinator in 46 | let item = itemCoordinator.item 47 | SearchCell( 48 | systemImageName: item.imageName, 49 | color: item.color, 50 | title: item.name, 51 | subtitle: item.subtitle 52 | ) { 53 | coordinator.manuallySet(selectedMapItem: itemCoordinator) 54 | } 55 | } 56 | } 57 | } else { 58 | Spacer() 59 | if #available(iOS 14.0, *) { ProgressView() } 60 | Text("search.loading", bundle: .module).padding(.top, 4) 61 | Spacer() 62 | } 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/Sheets/MapItemClusterSheet.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import MapKit 3 | 4 | struct MapItemClusterSheet: View { 5 | @ObservedObject var coordinator: MapItemPickerController 6 | 7 | var cluster: MKClusterAnnotation 8 | let dismissHandler: () -> Void 9 | 10 | private var items: [MapItemController] { 11 | cluster.memberAnnotations as? [MapItemController] ?? [] 12 | } 13 | 14 | var body: some View { 15 | VStack(spacing: 0) { 16 | HStack(alignment: .top) { 17 | VStack(alignment: .leading) { 18 | Text(cluster.title ?? .empty) 19 | .font(.title.bold()) 20 | Text(cluster.subtitle ?? .empty) 21 | } 22 | Spacer() 23 | Button(action: dismissHandler) { 24 | Image(systemName: "xmark.circle.fill") 25 | .font(.title) 26 | .foregroundColor(.gray) 27 | } 28 | } 29 | .padding([.horizontal, .top]) 30 | List { 31 | ForEach(items) { itemCoordinator in 32 | let item = itemCoordinator.item 33 | SearchCell( 34 | systemImageName: item.imageName, 35 | color: item.color, 36 | title: item.name, 37 | subtitle: item.subtitle 38 | ) { 39 | coordinator.manuallySet(selectedMapItem: itemCoordinator) 40 | } 41 | } 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/Sheets/SearchSheet.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SchafKit 3 | 4 | struct SearchSheet: View { 5 | @ObservedObject var coordinator: MapItemPickerController 6 | @ObservedObject var searcher: MapItemSearchController 7 | 8 | let dismissHandler: (() -> Void)? 9 | let standardView: () -> SearchView 10 | 11 | @available(iOS 15.0, *) 12 | struct SearchField: View { 13 | @ObservedObject var searcher: MapItemSearchController 14 | @FocusState private var searchFieldIsFocused: Bool 15 | 16 | let foregroundColor: Color 17 | 18 | var body: some View { 19 | TextField(text: $searcher.searchTerm) { 20 | Text("search", bundle: Bundle.module).foregroundColor(foregroundColor) 21 | } 22 | .focused($searchFieldIsFocused) 23 | Button { 24 | searcher.searchTerm = .empty 25 | searchFieldIsFocused = false 26 | } label: { 27 | Image(systemName: "xmark.circle.fill") 28 | .foregroundColor(foregroundColor) 29 | } 30 | .opacity(searcher.searchTerm.isEmpty ? 0 : 1) 31 | } 32 | } 33 | 34 | @ViewBuilder var filters: some View { 35 | if #available(iOS 14, *) { 36 | HStack { 37 | Menu { 38 | if !searcher.filteredCategories.isEmpty { 39 | ForEach(searcher.filteredCategories) { category in 40 | Button { 41 | searcher.filteredCategories.remove(subject: category) 42 | } label: { 43 | Image(systemName: category.imageName) 44 | Text("✓ " + category.name) 45 | } 46 | } 47 | Divider() 48 | } 49 | ForEach(MapItemCategory.allCases) { category in 50 | if !searcher.filteredCategories.contains(category) { 51 | Button { 52 | searcher.filteredCategories.append(category) 53 | } label: { 54 | Image(systemName: category.imageName) 55 | Text("  " + category.name) 56 | } 57 | } 58 | } 59 | } label: { 60 | HStack { 61 | Text("search.categories", bundle: .module) 62 | Image(systemName: "chevron.down") 63 | } 64 | .foregroundColor(.white) 65 | .padding(.horizontal, 8) 66 | .padding(.vertical, 4) 67 | .background(searcher.filteredCategories.isEmpty ? Color.gray.opacity(0.5) : Color.accentColor.opacity(0.5)) 68 | .cornerRadius(.greatestFiniteMagnitude) 69 | } 70 | if !searcher.filteredCategories.isEmpty { 71 | Button { 72 | searcher.clearFilters() 73 | } label: { 74 | HStack { 75 | Text("search.clearFilters", bundle: .module) 76 | Image(systemName: "xmark.circle.fill") 77 | } 78 | .foregroundColor(.white) 79 | .padding(.horizontal, 8) 80 | .padding(.vertical, 4) 81 | .background(Color.accentColor.opacity(0.5)) 82 | .cornerRadius(.greatestFiniteMagnitude) 83 | } 84 | } 85 | Spacer() 86 | } 87 | .textCase(nil) 88 | } 89 | } 90 | 91 | var body: some View { 92 | VStack { 93 | HStack { 94 | HStack(spacing: 2) { 95 | let mildColor = Color.label.opacity(0.75) 96 | Image(systemName: "magnifyingglass") 97 | .foregroundColor(mildColor) 98 | if #available(iOS 15, *) { 99 | SearchField(searcher: searcher, foregroundColor: mildColor) 100 | } else { 101 | TextField("search".moduleLocalized, text: $searcher.searchTerm) 102 | Button { 103 | searcher.searchTerm = .empty 104 | } label: { 105 | Image(systemName: "xmark.circle.fill") 106 | .foregroundColor(mildColor) 107 | } 108 | .opacity(searcher.searchTerm.isEmpty ? 0 : 1) 109 | } 110 | } 111 | .padding(.horizontal, 4) 112 | .padding(.vertical, 6) 113 | .background(Color.secondarySystemGroupedBackground) 114 | .cornerRadius(6) 115 | if let dismissHandler { 116 | Button { 117 | dismissHandler() 118 | searcher.searchTerm = .empty 119 | searcher.clearFilters() 120 | } label: { 121 | Image(systemName: "xmark.circle.fill") 122 | .font(.title) 123 | .foregroundColor(.gray) 124 | } 125 | } 126 | } 127 | .padding([.top, .horizontal]) 128 | ZStack { 129 | standardView() 130 | .environmentObject(coordinator) 131 | .opacity(searcher.isNoSearch ? 1 : 0) 132 | if !searcher.isNoSearch { 133 | List { 134 | Section { 135 | ForEach(searcher.completions.sliced(upTo: 2)) { item in 136 | SearchCell( 137 | systemImageName: "magnifyingglass", 138 | color: .gray, 139 | title: item.title, 140 | subtitle: item.subtitle 141 | ) { 142 | searcher.search(with: item) 143 | } 144 | } 145 | ForEach(searcher.items) { itemController in 146 | SearchCell(mapItemController: itemController, coordinator: coordinator) 147 | } 148 | } header: { 149 | VStack { 150 | filters.padding(.horizontal, -16) 151 | HStack { 152 | Text("search.results", bundle: .module) 153 | Spacer() 154 | if searcher.isAnySearching { 155 | if #available(iOS 14.0, *) { 156 | ProgressView() 157 | } else { 158 | Text("search.loading", bundle: .module) 159 | } 160 | } 161 | } 162 | .frame(minHeight: 18) 163 | } 164 | } 165 | let moreSuggestions = searcher.completions.sliced(upFrom: 2) 166 | if !moreSuggestions.isEmpty { 167 | Section { 168 | ForEach(moreSuggestions) { item in 169 | SearchCell( 170 | systemImageName: "magnifyingglass", 171 | color: .gray, 172 | title: item.title, 173 | subtitle: item.subtitle 174 | ) { 175 | searcher.search(with: item) 176 | } 177 | } 178 | } header: { 179 | Text("search.moreSuggestions", bundle: .module) 180 | } 181 | } 182 | } 183 | } 184 | } 185 | } 186 | .background(Color.systemGroupedBackground) 187 | .onDisappear { 188 | if dismissHandler == nil { 189 | coordinator.currentMainController?.dismiss(animated: true) 190 | } 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /Sources/MapItemPicker/UI/StandardSearchView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// The view displayed below the search bar by default. 4 | public struct StandardSearchView: View { 5 | @EnvironmentObject private var coordinator: MapItemPickerController 6 | 7 | public init() {} 8 | 9 | public var body: some View { 10 | List { 11 | RecentMapItemsSection() 12 | Section("search.category".moduleLocalized) { 13 | ForEach(MapItemCategory.allCases) { category in 14 | SearchCell( 15 | systemImageName: category.imageName, 16 | color: Color(category.color), 17 | title: category.name, 18 | subtitle: .empty, 19 | action: { 20 | coordinator.searcher.filteredCategories = [category] 21 | } 22 | ) 23 | } 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Tests/MapItemPickerTests/MapItemPickerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import MapItemPicker 3 | 4 | final class MapItemPickerTests: XCTestCase { 5 | func testMapItemCategoryImagesExist() { 6 | for category in MapItemCategory.allCases where UIImage(systemName: category.imageName) == nil { 7 | XCTFail("Category Image `\(category.imageName)` for `\(category.name)` does not exist for \(UIDevice.current.systemVersion)") 8 | } 9 | XCTAssertTrue(true) 10 | } 11 | } 12 | --------------------------------------------------------------------------------