Setup location tracking capabilities in the LocationCollectionView

This commit is contained in:
CypherPoet 2020-01-08 02:05:24 -06:00
parent e470e8ad40
commit c59b394f73
12 changed files with 307 additions and 175 deletions

View File

@ -43,7 +43,7 @@
F39A41C023A40C3800F3B91D /* WikipediaAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39A41BF23A40C3800F3B91D /* WikipediaAPIService.swift */; };
F39A41C323A4179400F3B91D /* CypherPoetNetStack in Frameworks */ = {isa = PBXBuildFile; productRef = F39A41C223A4179400F3B91D /* CypherPoetNetStack */; };
F39A41C523A41B5200F3B91D /* Endpoint+WikipediaAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39A41C423A41B5200F3B91D /* Endpoint+WikipediaAPI.swift */; };
F3BF5BE1239BA2E900B9F8D8 /* LocationCollectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BF5BE0239BA2E900B9F8D8 /* LocationCollectionViewModel.swift */; };
F3BF5BE1239BA2E900B9F8D8 /* LocationCollectionView+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BF5BE0239BA2E900B9F8D8 /* LocationCollectionView+ViewModel.swift */; };
F3BF5BE7239CF84D00B9F8D8 /* LocationCollection+Computeds.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BF5BE6239CF84D00B9F8D8 /* LocationCollection+Computeds.swift */; };
F3BF5BE9239CF89B00B9F8D8 /* Location+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BF5BE8239CF89B00B9F8D8 /* Location+Comparable.swift */; };
F3BF5C0E239D332400B9F8D8 /* Location+LocationAnnotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BF5C0D239D332400B9F8D8 /* Location+LocationAnnotation.swift */; };
@ -96,7 +96,7 @@
F39A41BC23A3F53E00F3B91D /* EditLocationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditLocationView.swift; sourceTree = "<group>"; };
F39A41BF23A40C3800F3B91D /* WikipediaAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WikipediaAPIService.swift; sourceTree = "<group>"; };
F39A41C423A41B5200F3B91D /* Endpoint+WikipediaAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Endpoint+WikipediaAPI.swift"; sourceTree = "<group>"; };
F3BF5BE0239BA2E900B9F8D8 /* LocationCollectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationCollectionViewModel.swift; sourceTree = "<group>"; };
F3BF5BE0239BA2E900B9F8D8 /* LocationCollectionView+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LocationCollectionView+ViewModel.swift"; sourceTree = "<group>"; };
F3BF5BE6239CF84D00B9F8D8 /* LocationCollection+Computeds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LocationCollection+Computeds.swift"; sourceTree = "<group>"; };
F3BF5BE8239CF89B00B9F8D8 /* Location+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Location+Comparable.swift"; sourceTree = "<group>"; };
F3BF5C0D239D332400B9F8D8 /* Location+LocationAnnotation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Location+LocationAnnotation.swift"; sourceTree = "<group>"; };
@ -260,7 +260,7 @@
isa = PBXGroup;
children = (
F374DC09239A431E0016CC65 /* LocationCollectionView.swift */,
F3BF5BE0239BA2E900B9F8D8 /* LocationCollectionViewModel.swift */,
F3BF5BE0239BA2E900B9F8D8 /* LocationCollectionView+ViewModel.swift */,
F39A41BB23A3F52600F3B91D /* Edit Location */,
);
path = "Location Collection";
@ -461,7 +461,7 @@
F374DC14239A4B2C0016CC65 /* Location+CoreDataClass.swift in Sources */,
F374DC15239A4B2C0016CC65 /* Location+CoreDataProperties.swift in Sources */,
F3BF5C1A239D467F00B9F8D8 /* Location+FetchHelpers.swift in Sources */,
F3BF5BE1239BA2E900B9F8D8 /* LocationCollectionViewModel.swift in Sources */,
F3BF5BE1239BA2E900B9F8D8 /* LocationCollectionView+ViewModel.swift in Sources */,
F3C8F65423A82E1C008B66A7 /* WikiPagesState.swift in Sources */,
F36D295F239A071F00095B66 /* CoreDataManager.swift in Sources */,
F3C8F65623A837A7008B66A7 /* EditLocationViewModel.swift in Sources */,

View File

@ -16,5 +16,4 @@ extension LocationCollection {
return locations.sorted()
}
}

View File

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This application would like permission to read your current location. This will be used to enable current location pinning in a location collection map.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This application would like permission to read and load images from your Photos Album. These can be used to as custom images for a location. </string>
<key>CFBundleDevelopmentRegion</key>

View File

@ -15,12 +15,11 @@ extension SampleData {
enum LocationCollections {
static let `default`: LocationCollection = {
let context = CurrentApp.coreDataManager.mainContext
let collection = LocationCollection(context: context)
let collection = LocationCollection()
collection.title = "Default Collection"
collection.addToLocations(SampleData.Locations.santorini)
return collection
}()
}

View File

@ -16,14 +16,14 @@ extension SampleData {
enum Locations {
static let santorini: Location = {
let location = Location(context: CurrentApp.coreDataManager.mainContext)
let location = Location()
location.title = "Santorini"
location.subtitle = "An an island in the southern Aegean Sea, speculated to be the inspiration for the city of Atlantis."
location.latitude = 36.416667
location.longitude = 25.433333
return location
}()
}

View File

@ -15,16 +15,16 @@ import MapKit
extension LocationCollectionMapView {
class Coordinator: NSObject {
@Binding var centerCoordinate: CLLocationCoordinate2D
let onSelectLocation: ((Location) -> Void)?
let onCenterChanged: ((CLLocationCoordinate2D) -> Void)?
init(
centerCoordinate: Binding<CLLocationCoordinate2D>,
onSelectLocation: ((Location) -> Void)? = nil
onSelectLocation: ((Location) -> Void)? = nil,
onCenterChanged: ((CLLocationCoordinate2D) -> Void)? = nil
) {
self._centerCoordinate = centerCoordinate
self.onSelectLocation = onSelectLocation
self.onCenterChanged = onCenterChanged
}
}
}
@ -54,7 +54,7 @@ extension LocationCollectionMapView.Coordinator {
extension LocationCollectionMapView.Coordinator: MKMapViewDelegate {
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
centerCoordinate = mapView.centerCoordinate
onCenterChanged?(mapView.centerCoordinate)
}
@ -69,7 +69,6 @@ extension LocationCollectionMapView.Coordinator: MKMapViewDelegate {
) as? MKPinAnnotationView
?? MKPinAnnotationView(annotation: annotation, reuseIdentifier: ReuseIdentifier.pinAnnotation)
configure(annotationView)
return annotationView

View File

@ -13,10 +13,10 @@ import MapKit
struct LocationCollectionMapView {
var annotations: [LocationAnnotation] = []
@Binding var centerCoordinate: CLLocationCoordinate2D
var startingCenterCoordinate: CLLocationCoordinate2D?
let onSelectLocation: ((Location) -> Void)?
let onCenterChanged: ((CLLocationCoordinate2D) -> Void)?
}
@ -25,8 +25,8 @@ extension LocationCollectionMapView: UIViewRepresentable {
func makeCoordinator() -> LocationCollectionMapView.Coordinator {
Self.Coordinator(
centerCoordinate: $centerCoordinate,
onSelectLocation: onSelectLocation
onSelectLocation: onSelectLocation,
onCenterChanged: onCenterChanged
)
}
@ -52,6 +52,10 @@ extension LocationCollectionMapView: UIViewRepresentable {
mapView.removeAnnotations(mapView.annotations)
mapView.addAnnotations(annotations)
}
if let startingCenterCoordinate = startingCenterCoordinate {
mapView.setCenter(startingCenterCoordinate, animated: true)
}
}
}

View File

@ -0,0 +1,198 @@
//
// LocationCollectionView.ViewModel.swif {
// PlaceCase
//
// Created by CypherPoet on 12/7/19.
//
//
import SwiftUI
import Combine
import CoreData
import CoreLocation
extension LocationCollectionView {
final class ViewModel: NSObject, ObservableObject {
private var subscriptions = Set<AnyCancellable>()
private let locationManager = CLLocationManager()
enum AlertState {
case noAlert
case currentLocationDisabled
case locationSelected
case currentLocationReadingFailed
}
@ObservedObject var collection: LocationCollection
// MARK: - Published Outputs
@Published var locations: [Location] = []
@Published var selectedLocation: Location? = nil
// Trying to pass this down to another view as a binding causes
// that binding to get separated when this viewModel re-initializes.
// @Published var centerCoordinate = CLLocationCoordinate2D()
@Published var canReadCurrentLocation = false
@Published var userLocationCoordinate: CLLocationCoordinate2D? = nil
@Published var isShowingAlert = false
@Published var isShowingEditView = false
@Published var currentAlertState: AlertState = .noAlert
// MARK: - Init
init(collection: LocationCollection) {
self.collection = collection
super.init()
setupSubscribers()
self.fetchedResultsController.delegate = self
fetchLocations()
}
lazy var fetchRequest: NSFetchRequest<Location> = {
Location.fetchRequest(for: collection)
}()
lazy var fetchedResultsController: NSFetchedResultsController<Location> = {
.init(
fetchRequest: fetchRequest,
managedObjectContext: CurrentApp.coreDataManager.mainContext,
sectionNameKeyPath: nil,
cacheName: nil
)
}()
}
}
// MARK: - Publishers
extension LocationCollectionView.ViewModel {
private var isShowingAlertPublisher: AnyPublisher<Bool, Never> {
$currentAlertState
.map { alertState in alertState != .noAlert }
.print("isShowingAlertPublisher")
.eraseToAnyPublisher()
}
}
// MARK: - Computeds
extension LocationCollectionView.ViewModel {
var locationAuthStatus: CLAuthorizationStatus { CLLocationManager.authorizationStatus() }
var selectedLocationAlertTitle: String { selectedLocation?.title ?? "Undisclosed Location" }
var selectedLocationAlertMessage: String {
selectedLocation?.longDescription ?? "No description has been provided yet."
}
}
// MARK: - Public Methods
extension LocationCollectionView.ViewModel {
func fetchLocations() {
// TODO: Better error handling here?
try? fetchedResultsController.performFetch()
setLocations(from: fetchedResultsController)
}
// TODO: Could this functionality live in a separate redux state struct
// (e.g. UserLocationState)?
func requestLocationTrackingAuthorization() {
locationManager.requestWhenInUseAuthorization()
}
func activateLocationManager() {
switch locationAuthStatus {
case .notDetermined:
requestLocationTrackingAuthorization()
case .denied, .restricted:
self.currentAlertState = .currentLocationDisabled
case .authorizedAlways, .authorizedWhenInUse:
startUpdatingLocation()
@unknown default:
requestLocationTrackingAuthorization()
}
}
}
// MARK: - NSFetchedResultsControllerDelegate
extension LocationCollectionView.ViewModel: NSFetchedResultsControllerDelegate {
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
guard let controller = controller as? NSFetchedResultsController<Location> else { return }
print("controllerDidChangeContent")
setLocations(from: controller)
}
}
// MARK: - CLLocationManagerDelegate
extension LocationCollectionView.ViewModel: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let currentLocationCoordinate = locations.last?.coordinate else { return }
userLocationCoordinate = currentLocationCoordinate
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
// TODO: Better handling here
print("locationManager::didFailWithError: \(error.localizedDescription)")
currentAlertState = .currentLocationReadingFailed
}
}
// MARK: - Private Helpers
private extension LocationCollectionView.ViewModel {
func setLocations(from fetchedResultsController: NSFetchedResultsController<Location>) {
guard
let section = fetchedResultsController.sections?.first,
let fetchedLocations = section.objects as? [Location]
else {
locations = []
return
}
print("setLocations || Count: \(fetchedLocations.count)")
locations = fetchedLocations
}
func startUpdatingLocation() {
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
locationManager.requestLocation()
}
func setupSubscribers() {
isShowingAlertPublisher
.receive(on: DispatchQueue.main)
.assign(to: \.isShowingAlert, on: self)
.store(in: &subscriptions)
}
}

View File

@ -12,13 +12,9 @@ import MapKit
struct LocationCollectionView: View {
@EnvironmentObject var store: AppStore
@ObservedObject var viewModel: LocationCollectionViewModel
@ObservedObject var viewModel: ViewModel
@State private var centerCoordinate = CLLocationCoordinate2D()
@State var selectedLocation: Location? = nil
@State private var isShowingEditView = false
@State private var isShowingSelectedLocationAlert = false
}
@ -28,25 +24,24 @@ extension LocationCollectionView {
var body: some View {
ZStack {
mapUnderlay
centerIndicator
addLocationButton
mapControls
}
.alert(isPresented: $viewModel.isShowingAlert, content: { self.currentAlert })
.sheet(
isPresented: $isShowingEditView,
isPresented: $viewModel.isShowingEditView,
onDismiss: {
guard let context = self.selectedLocation?.managedObjectContext else {
guard let context = self.viewModel.selectedLocation?.managedObjectContext else {
fatalError()
}
CurrentApp.coreDataManager.save(context)
}
) {
if self.selectedLocation != nil {
if self.viewModel.selectedLocation != nil {
EditLocationView(
viewModel: EditLocationViewModel(
location: self.selectedLocation!,
location: self.viewModel.selectedLocation!,
wikiPagesState: self.store.state.wikiPagesState
)
)
@ -55,23 +50,41 @@ extension LocationCollectionView {
Text("No Location found for editing")
}
}
.alert(isPresented: $isShowingSelectedLocationAlert) {
Alert(
title: Text(selectedLocation?.title ?? "Undisclosed Location"),
message: Text(selectedLocation?.longDescription ?? "No description has been provided yet."),
primaryButton: .default(
Text("Edit"),
action: { self.isShowingEditView = true }
),
secondaryButton: .cancel(Text("OK"))
)
}
}
}
// MARK: - Computeds
extension LocationCollectionView {
var currentAlert: Alert {
switch viewModel.currentAlertState {
case .currentLocationDisabled:
return Alert(
title: Text("Location Services are Disabled"),
message: Text("Location authorization settings can be changed from within the Settings app."),
dismissButton: .default(Text("👌 Got It"))
)
case .currentLocationReadingFailed:
return Alert(
title: Text("Error while attempting to read location."),
message: Text("Your current location could not be determined."),
dismissButton: .default(Text("OK"))
)
case .locationSelected:
return Alert(
title: Text(viewModel.selectedLocationAlertTitle),
message: Text(viewModel.selectedLocationAlertMessage),
primaryButton: .default(
Text("Edit"),
action: { self.viewModel.isShowingEditView = true }
),
secondaryButton: .cancel(Text("OK"))
)
default:
preconditionFailure("No alert should be set")
}
}
}
@ -80,9 +93,10 @@ extension LocationCollectionView {
private var mapUnderlay: some View {
LocationCollectionMapView(
annotations: viewModel.collection.locationsArray,
centerCoordinate: $centerCoordinate,
onSelectLocation: locationSelected(_:)
annotations: viewModel.locations,
startingCenterCoordinate: viewModel.userLocationCoordinate,
onSelectLocation: locationSelected(_:),
onCenterChanged: centerCoordinateChanged(_:)
)
.edgesIgnoringSafeArea(.all)
}
@ -96,27 +110,50 @@ extension LocationCollectionView {
}
private var addLocationButton: some View {
private var mapControls: some View {
VStack(alignment: .trailing) {
Spacer()
HStack(alignment: .bottom) {
Spacer()
Button(action: {
self.createNewLocation()
}) {
Image(systemName: "plus.rectangle.fill")
.font(.title)
.padding(24)
.background(Color.accentColor.opacity(0.8))
.foregroundColor(.white)
VStack {
snapToCurrentLocationButton
addLocationButton
}
.clipShape(Circle())
.padding(.trailing)
}
}
}
private var addLocationButton: some View {
Button(action: {
self.createNewLocation()
}) {
Image(systemName: "plus.rectangle.fill")
.font(.title)
.padding(24)
.background(Color.accentColor.opacity(0.8))
.foregroundColor(.white)
}
.clipShape(Circle())
}
private var snapToCurrentLocationButton: some View {
Button(action: {
self.viewModel.activateLocationManager()
}) {
Image(systemName: "location.fill")
.font(.headline)
.padding(18)
.background(Color.accentColor.opacity(0.8))
.foregroundColor(.white)
}
.clipShape(Circle())
}
}
@ -139,10 +176,13 @@ private extension LocationCollectionView {
func locationSelected(_ location: Location) {
// viewModel.selectedLocation = location
// viewModel.isShowingSelectedLocationAlert = true
self.selectedLocation = location
self.isShowingSelectedLocationAlert = true
viewModel.selectedLocation = location
viewModel.currentAlertState = .locationSelected
}
func centerCoordinateChanged(_ newCoordinate: CLLocationCoordinate2D) {
centerCoordinate = newCoordinate
}
}
@ -153,8 +193,9 @@ struct LocationCollectionView_Previews: PreviewProvider {
static var previews: some View {
LocationCollectionView(
viewModel: LocationCollectionViewModel(collection: SampleData.LocationCollections.default)
viewModel: .init(collection: SampleData.LocationCollections.default)
)
.environment(\.managedObjectContext, CurrentApp.coreDataManager.mainContext)
.environmentObject(SampleData.SampleAppStore.default)
}
}

View File

@ -1,96 +0,0 @@
//
// LocationCollectionViewModel.swift
// PlaceCase
//
// Created by CypherPoet on 12/7/19.
//
//
import SwiftUI
import Combine
import CoreData
final class LocationCollectionViewModel: NSObject, ObservableObject {
private var subscriptions = Set<AnyCancellable>()
@ObservedObject var collection: LocationCollection
// MARK: - Published Outputs
@Published var locations: [Location] = []
// MARK: - Init
init(collection: LocationCollection) {
self.collection = collection
super.init()
}
lazy var fetchRequest: NSFetchRequest<Location> = {
Location.fetchRequest(for: collection)
}()
lazy var fetchedResultsController: NSFetchedResultsController<Location> = {
.init(
fetchRequest: fetchRequest,
managedObjectContext: CurrentApp.coreDataManager.mainContext,
sectionNameKeyPath: nil,
cacheName: nil
)
}()
}
// MARK: - Publishers
extension LocationCollectionViewModel {
}
// MARK: - Computeds
extension LocationCollectionViewModel {
}
// MARK: - Public Methods
extension LocationCollectionViewModel {
func fetchLocations() {
// TODO: Better error handling here?
try? fetchedResultsController.performFetch()
setLocations(from: fetchedResultsController)
}
}
// MARK: - NSFetchedResultsControllerDelegate
extension LocationCollectionViewModel: NSFetchedResultsControllerDelegate {
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
guard let controller = controller as? NSFetchedResultsController<Location> else { return }
setLocations(from: controller)
}
}
// MARK: - Private Helpers
private extension LocationCollectionViewModel {
func setLocations(from fetchedResultsController: NSFetchedResultsController<Location>) {
guard
let section = fetchedResultsController.sections?.first,
let fetchedLocations = section.objects as? [Location]
else {
locations = []
return
}
locations = fetchedLocations
}
}

View File

@ -83,17 +83,6 @@ extension LocationCollectionsContainerView {
}
private extension LocationCollectionsContainerView {
func destination(for locationCollection: LocationCollection) -> LocationCollectionView {
let viewModel = LocationCollectionViewModel(collection: locationCollection)
return LocationCollectionView(viewModel: viewModel)
}
}
// MARK: - Preview
struct LocationCollectionsContainerView_Previews: PreviewProvider {

View File

@ -31,7 +31,6 @@ final class LocationCollectionsContainerViewModel: ObservableObject {
}
// MARK: - Public Methods
extension LocationCollectionsContainerViewModel {
@ -67,9 +66,7 @@ extension LocationCollectionsContainerViewModel {
static func destination(for locationCollection: LocationCollection) -> LocationCollectionView {
let viewModel = LocationCollectionViewModel(collection: locationCollection)
return .init(viewModel: viewModel)
.init(viewModel: .init(collection: locationCollection))
}
}