Skip to content

Commit 82d2a23

Browse files
ComposableCoreLocation (#120)
* wip * wip * wip * wip * wip * basics * wip * wip * wip got LocalSearchClient spm package * got a common spm package * basics of desktop * renamed * wip * clean up * Fix * clean up * formatting and remove deprecated * wip * move stuff around, readmes * fix * clean up * image * typo * docs * wip * typo * rename * wip * clean up * clean up * rename * wip * Update README.md * custom button * format * wip * fix * error info * public interface * set, alpha * add error * fix * alpha * internal * Revert "internal" This reverts commit 97da6f3. * wip * wip * docs * rename * cleanup * add fatal error messages to mock * fixes * fixes * clean up * 13.3 fixes * another fix * 13.3 fixes * wip * fix mac tests * wip * just use double * fix * Revert "fix" This reverts commit 4565a64. * fix * Fix? * Fix * More fix Co-authored-by: Stephen Celis <[email protected]>
1 parent 115b7c6 commit 82d2a23

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+3837
-841
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<Scheme
3+
LastUpgradeVersion = "1140"
4+
version = "1.3">
5+
<BuildAction
6+
parallelizeBuildables = "YES"
7+
buildImplicitDependencies = "YES">
8+
<BuildActionEntries>
9+
<BuildActionEntry
10+
buildForTesting = "YES"
11+
buildForRunning = "YES"
12+
buildForProfiling = "YES"
13+
buildForArchiving = "YES"
14+
buildForAnalyzing = "YES">
15+
<BuildableReference
16+
BuildableIdentifier = "primary"
17+
BlueprintIdentifier = "ComposableCoreLocation"
18+
BuildableName = "ComposableCoreLocation"
19+
BlueprintName = "ComposableCoreLocation"
20+
ReferencedContainer = "container:">
21+
</BuildableReference>
22+
</BuildActionEntry>
23+
</BuildActionEntries>
24+
</BuildAction>
25+
<TestAction
26+
buildConfiguration = "Debug"
27+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
28+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
29+
shouldUseLaunchSchemeArgsEnv = "YES">
30+
<Testables>
31+
<TestableReference
32+
skipped = "NO">
33+
<BuildableReference
34+
BuildableIdentifier = "primary"
35+
BlueprintIdentifier = "ComposableCoreLocationTests"
36+
BuildableName = "ComposableCoreLocationTests"
37+
BlueprintName = "ComposableCoreLocationTests"
38+
ReferencedContainer = "container:">
39+
</BuildableReference>
40+
</TestableReference>
41+
</Testables>
42+
</TestAction>
43+
<LaunchAction
44+
buildConfiguration = "Debug"
45+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
46+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
47+
launchStyle = "0"
48+
useCustomWorkingDirectory = "NO"
49+
ignoresPersistentStateOnLaunch = "NO"
50+
debugDocumentVersioning = "YES"
51+
debugServiceExtension = "internal"
52+
allowLocationSimulation = "YES">
53+
</LaunchAction>
54+
<ProfileAction
55+
buildConfiguration = "Release"
56+
shouldUseLaunchSchemeArgsEnv = "YES"
57+
savedToolIdentifier = ""
58+
useCustomWorkingDirectory = "NO"
59+
debugDocumentVersioning = "YES">
60+
<MacroExpansion>
61+
<BuildableReference
62+
BuildableIdentifier = "primary"
63+
BlueprintIdentifier = "ComposableCoreLocation"
64+
BuildableName = "ComposableCoreLocation"
65+
BlueprintName = "ComposableCoreLocation"
66+
ReferencedContainer = "container:">
67+
</BuildableReference>
68+
</MacroExpansion>
69+
</ProfileAction>
70+
<AnalyzeAction
71+
buildConfiguration = "Debug">
72+
</AnalyzeAction>
73+
<ArchiveAction
74+
buildConfiguration = "Release"
75+
revealArchiveInOrganizer = "YES">
76+
</ArchiveAction>
77+
</Scheme>
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import ComposableArchitecture
2+
import ComposableCoreLocation
3+
import MapKit
4+
5+
public struct PointOfInterest: Equatable {
6+
public let coordinate: CLLocationCoordinate2D
7+
public let subtitle: String?
8+
public let title: String?
9+
10+
public init(
11+
coordinate: CLLocationCoordinate2D,
12+
subtitle: String?,
13+
title: String?
14+
) {
15+
self.coordinate = coordinate
16+
self.subtitle = subtitle
17+
self.title = title
18+
}
19+
}
20+
21+
public struct AppState: Equatable {
22+
public var alert: String?
23+
public var isRequestingCurrentLocation = false
24+
public var pointOfInterestCategory: MKPointOfInterestCategory?
25+
public var pointsOfInterest: [PointOfInterest] = []
26+
public var region: CoordinateRegion?
27+
28+
public init(
29+
alert: String? = nil,
30+
isRequestingCurrentLocation: Bool = false,
31+
pointOfInterestCategory: MKPointOfInterestCategory? = nil,
32+
pointsOfInterest: [PointOfInterest] = [],
33+
region: CoordinateRegion? = nil
34+
) {
35+
self.alert = alert
36+
self.isRequestingCurrentLocation = isRequestingCurrentLocation
37+
self.pointOfInterestCategory = pointOfInterestCategory
38+
self.pointsOfInterest = pointsOfInterest
39+
self.region = region
40+
}
41+
42+
public static let pointOfInterestCategories: [MKPointOfInterestCategory] = [
43+
.cafe,
44+
.museum,
45+
.nightlife,
46+
.park,
47+
.restaurant,
48+
]
49+
}
50+
51+
public enum AppAction: Equatable {
52+
case categoryButtonTapped(MKPointOfInterestCategory)
53+
case currentLocationButtonTapped
54+
case dismissAlertButtonTapped
55+
case localSearchResponse(Result<LocalSearchResponse, LocalSearchClient.Error>)
56+
case locationManager(LocationManager.Action)
57+
case onAppear
58+
case updateRegion(CoordinateRegion?)
59+
}
60+
61+
public struct AppEnvironment {
62+
public var localSearch: LocalSearchClient
63+
public var locationManager: LocationManager
64+
65+
public init(
66+
localSearch: LocalSearchClient,
67+
locationManager: LocationManager
68+
) {
69+
self.localSearch = localSearch
70+
self.locationManager = locationManager
71+
}
72+
}
73+
74+
private struct LocationManagerId: Hashable {}
75+
private struct CancelSearchId: Hashable {}
76+
77+
public let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
78+
switch action {
79+
case let .categoryButtonTapped(category):
80+
guard category != state.pointOfInterestCategory else {
81+
state.pointOfInterestCategory = nil
82+
state.pointsOfInterest = []
83+
return .cancel(id: CancelSearchId())
84+
}
85+
86+
state.pointOfInterestCategory = category
87+
88+
let request = MKLocalSearch.Request()
89+
request.pointOfInterestFilter = MKPointOfInterestFilter(including: [category])
90+
if let region = state.region?.asMKCoordinateRegion {
91+
request.region = region
92+
}
93+
return environment.localSearch
94+
.search(request)
95+
.catchToEffect()
96+
.map(AppAction.localSearchResponse)
97+
.cancellable(id: CancelSearchId(), cancelInFlight: true)
98+
99+
case .currentLocationButtonTapped:
100+
guard environment.locationManager.locationServicesEnabled() else {
101+
state.alert = "Location services are turned off."
102+
return .none
103+
}
104+
105+
switch environment.locationManager.authorizationStatus() {
106+
case .notDetermined:
107+
state.isRequestingCurrentLocation = true
108+
#if os(macOS)
109+
return environment.locationManager
110+
.requestAlwaysAuthorization(id: LocationManagerId())
111+
.fireAndForget()
112+
#else
113+
return environment.locationManager
114+
.requestWhenInUseAuthorization(id: LocationManagerId())
115+
.fireAndForget()
116+
#endif
117+
118+
case .restricted:
119+
state.alert = "Please give us access to your location in settings."
120+
return .none
121+
122+
case .denied:
123+
state.alert = "Please give us access to your location in settings."
124+
return .none
125+
126+
case .authorizedAlways, .authorizedWhenInUse:
127+
return environment.locationManager
128+
.requestLocation(id: LocationManagerId())
129+
.fireAndForget()
130+
131+
@unknown default:
132+
return .none
133+
}
134+
135+
case .dismissAlertButtonTapped:
136+
state.alert = nil
137+
return .none
138+
139+
case let .localSearchResponse(.success(response)):
140+
state.pointsOfInterest = response.mapItems.map { item in
141+
PointOfInterest(
142+
coordinate: item.placemark.coordinate,
143+
subtitle: item.placemark.subtitle,
144+
title: item.name
145+
)
146+
}
147+
return .none
148+
149+
case .localSearchResponse(.failure):
150+
state.alert = "Could not perform search. Please try again."
151+
return .none
152+
153+
case .locationManager:
154+
return .none
155+
156+
case .onAppear:
157+
return environment.locationManager.create(id: LocationManagerId())
158+
.map(AppAction.locationManager)
159+
160+
case let .updateRegion(region):
161+
state.region = region
162+
163+
guard
164+
let category = state.pointOfInterestCategory,
165+
let region = state.region?.asMKCoordinateRegion
166+
else { return .none }
167+
168+
let request = MKLocalSearch.Request()
169+
request.pointOfInterestFilter = MKPointOfInterestFilter(including: [category])
170+
request.region = region
171+
return environment.localSearch
172+
.search(request)
173+
.catchToEffect()
174+
.map(AppAction.localSearchResponse)
175+
.cancellable(id: CancelSearchId(), cancelInFlight: true)
176+
}
177+
}
178+
.combined(
179+
with:
180+
locationManagerReducer
181+
.pullback(state: \.self, action: /AppAction.locationManager, environment: { $0 })
182+
)
183+
.debug()
184+
185+
private let locationManagerReducer = Reducer<AppState, LocationManager.Action, AppEnvironment>
186+
{
187+
state, action, environment in
188+
189+
switch action {
190+
case .didChangeAuthorization(.authorizedAlways),
191+
.didChangeAuthorization(.authorizedWhenInUse):
192+
if state.isRequestingCurrentLocation {
193+
return environment.locationManager
194+
.requestLocation(id: LocationManagerId())
195+
.fireAndForget()
196+
}
197+
return .none
198+
199+
case .didChangeAuthorization(.denied):
200+
if state.isRequestingCurrentLocation {
201+
state.alert = "Location makes this app better. Please consider giving us access."
202+
state.isRequestingCurrentLocation = false
203+
}
204+
return .none
205+
206+
case let .didUpdateLocations(locations):
207+
state.isRequestingCurrentLocation = false
208+
guard let location = locations.first else { return .none }
209+
state.region = CoordinateRegion(
210+
center: location.coordinate,
211+
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
212+
)
213+
return .none
214+
215+
default:
216+
return .none
217+
}
218+
}
219+
220+
extension PointOfInterest {
221+
// NB: CLLocationCoordinate2D doesn't conform to Equatable
222+
public static func == (lhs: Self, rhs: Self) -> Bool {
223+
lhs.coordinate.latitude == rhs.coordinate.latitude
224+
&& lhs.coordinate.longitude == rhs.coordinate.longitude
225+
&& lhs.subtitle == rhs.subtitle
226+
&& lhs.title == rhs.title
227+
}
228+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import MapKit
2+
3+
public struct AppAlert: Identifiable {
4+
public var title: String
5+
6+
public init(title: String) {
7+
self.title = title
8+
}
9+
10+
public var id: String { self.title }
11+
}
12+
13+
extension MKPointOfInterestCategory {
14+
public var displayName: String {
15+
switch self {
16+
case .cafe:
17+
return "Cafe"
18+
case .museum:
19+
return "Museum"
20+
case .nightlife:
21+
return "Nightlife"
22+
case .park:
23+
return "Park"
24+
case .restaurant:
25+
return "Restaurant"
26+
default:
27+
return "N/A"
28+
}
29+
}
30+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import ComposableArchitecture
2+
import MapKit
3+
4+
public struct LocalSearchClient {
5+
public var search: (MKLocalSearch.Request) -> Effect<LocalSearchResponse, Error>
6+
7+
public init(search: @escaping (MKLocalSearch.Request) -> Effect<LocalSearchResponse, Error>) {
8+
self.search = search
9+
}
10+
11+
public struct Error: Swift.Error, Equatable {
12+
public init() {}
13+
}
14+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import ComposableArchitecture
33
import MapKit
44

55
extension LocalSearchClient {
6-
static let live = LocalSearchClient(
6+
public static let live = LocalSearchClient(
77
search: { request in
88
Effect.future { callback in
99
MKLocalSearch(request: request).start { response, error in
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import ComposableArchitecture
22
import MapKit
33

44
extension LocalSearchClient {
5-
static func mock(
5+
public static func mock(
66
search: @escaping (MKLocalSearch.Request) -> Effect<
77
LocalSearchResponse, LocalSearchClient.Error
88
> = { _ in fatalError() }

0 commit comments

Comments
 (0)