├── .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 |
11 |
12 |
13 |
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 |
--------------------------------------------------------------------------------