Implement changes for Day 69

This commit is contained in:
CypherPoet 2019-12-07 04:39:46 -06:00
parent 8c5c9844c1
commit 37f2923e70
22 changed files with 786 additions and 99 deletions

View File

@ -13,7 +13,7 @@ For each day that proves conducive to some kind of code or content, I'll make a
### Personal Note
I'm currently seeking freelance, remote opportunities as an iOS developer! If you're looking for an experienced software engineer who's been diving deep into SwiftUI, Combine, and iOS 13 since WWDC (and who prefers being paid in Bitcoin 🙂), and you could use some help with any of those things, please feel free to [reach out](mailto:CypherPoet@gmail.com) ✌️.
I'm currently seeking freelance, remote opportunities as an iOS developer! If you're looking for an experienced software engineer who's been diving deep into SwiftUI, Combine, and iOS 13 since WWDC (and who relishes being paid in Bitcoin 🙂), and you could use some help with any of those things, please feel free to [reach out](mailto:CypherPoet@gmail.com) ✌️.
## Days
@ -74,10 +74,11 @@ I'm currently seeking freelance, remote opportunities as an iOS developer! If yo
- **Day 65:** [_Project 13: Instafilter_ (Part Four)](./day-065/)
- **Day 66:** [_Project 13: Instafilter_ (Part Five)](./day-066/)
- **Day 67:** [_Project 13: Instafilter_ (Part Six)](./day-067/)
- **Day 68:** [_Project 14: PlaceCase_ (Part One)](./day-068/)
</details>
- **Day 68:** [_Project 14: PlaceCase_ (Part One)](./day-068/)
- **Day 69:** [_Project 14: PlaceCase_ (Part Two)](./day-069/)

View File

@ -10,10 +10,20 @@
F36D28F023987DE700095B66 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F36D28EF23987DE700095B66 /* AppDelegate.swift */; };
F36D28F223987DE700095B66 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F36D28F123987DE700095B66 /* SceneDelegate.swift */; };
F36D28F523987DE700095B66 /* PlaceCase.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F36D28F323987DE700095B66 /* PlaceCase.xcdatamodeld */; };
F36D28F723987DE700095B66 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F36D28F623987DE700095B66 /* ContentView.swift */; };
F36D28F923987DE900095B66 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F36D28F823987DE900095B66 /* Assets.xcassets */; };
F36D28FC23987DE900095B66 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F36D28FB23987DE900095B66 /* Preview Assets.xcassets */; };
F36D28FF23987DE900095B66 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F36D28FD23987DE900095B66 /* LaunchScreen.storyboard */; };
F36D295F239A071F00095B66 /* CoreDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F36D295E239A071F00095B66 /* CoreDataManager.swift */; };
F36D2965239A0ACA00095B66 /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F36D2963239A0ACA00095B66 /* MapView.swift */; };
F36D2966239A0ACA00095B66 /* MapView+Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F36D2964239A0ACA00095B66 /* MapView+Coordinator.swift */; };
F374DC0A239A431E0016CC65 /* LocationCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F374DC09239A431E0016CC65 /* LocationCollectionView.swift */; };
F374DC14239A4B2C0016CC65 /* Location+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F374DC12239A4B2B0016CC65 /* Location+CoreDataClass.swift */; };
F374DC15239A4B2C0016CC65 /* Location+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = F374DC13239A4B2B0016CC65 /* Location+CoreDataProperties.swift */; };
F374DC18239A4B790016CC65 /* LocationCollection+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F374DC16239A4B790016CC65 /* LocationCollection+CoreDataClass.swift */; };
F374DC19239A4B790016CC65 /* LocationCollection+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = F374DC17239A4B790016CC65 /* LocationCollection+CoreDataProperties.swift */; };
F374DC1C239A57620016CC65 /* Annotations.swift in Sources */ = {isa = PBXBuildFile; fileRef = F374DC1B239A57620016CC65 /* Annotations.swift */; };
F374DC36239B96780016CC65 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F374DC35239B96770016CC65 /* AuthenticationService.swift */; };
F3BF5BE1239BA2E900B9F8D8 /* LocationCollectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BF5BE0239BA2E900B9F8D8 /* LocationCollectionViewModel.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -21,11 +31,21 @@
F36D28EF23987DE700095B66 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
F36D28F123987DE700095B66 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
F36D28F423987DE700095B66 /* PlaceCase.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = PlaceCase.xcdatamodel; sourceTree = "<group>"; };
F36D28F623987DE700095B66 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
F36D28F823987DE900095B66 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
F36D28FB23987DE900095B66 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
F36D28FE23987DE900095B66 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
F36D290023987DE900095B66 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
F36D295E239A071F00095B66 /* CoreDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataManager.swift; sourceTree = "<group>"; };
F36D2963239A0ACA00095B66 /* MapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapView.swift; sourceTree = "<group>"; };
F36D2964239A0ACA00095B66 /* MapView+Coordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MapView+Coordinator.swift"; sourceTree = "<group>"; };
F374DC09239A431E0016CC65 /* LocationCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationCollectionView.swift; sourceTree = "<group>"; };
F374DC12239A4B2B0016CC65 /* Location+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Location+CoreDataClass.swift"; sourceTree = "<group>"; };
F374DC13239A4B2B0016CC65 /* Location+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Location+CoreDataProperties.swift"; sourceTree = "<group>"; };
F374DC16239A4B790016CC65 /* LocationCollection+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LocationCollection+CoreDataClass.swift"; sourceTree = "<group>"; };
F374DC17239A4B790016CC65 /* LocationCollection+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LocationCollection+CoreDataProperties.swift"; sourceTree = "<group>"; };
F374DC1B239A57620016CC65 /* Annotations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Annotations.swift; sourceTree = "<group>"; };
F374DC35239B96770016CC65 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = "<group>"; };
F3BF5BE0239BA2E900B9F8D8 /* LocationCollectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationCollectionViewModel.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -58,14 +78,15 @@
F36D28EE23987DE700095B66 /* PlaceCase */ = {
isa = PBXGroup;
children = (
F36D2960239A093800095B66 /* Reusables */,
F36D290023987DE900095B66 /* Info.plist */,
F36D28EF23987DE700095B66 /* AppDelegate.swift */,
F36D28F123987DE700095B66 /* SceneDelegate.swift */,
F36D28F623987DE700095B66 /* ContentView.swift */,
F36D28F823987DE900095B66 /* Assets.xcassets */,
F36D295A239A04B800095B66 /* Data */,
F36D28FD23987DE900095B66 /* LaunchScreen.storyboard */,
F36D290023987DE900095B66 /* Info.plist */,
F36D28F323987DE700095B66 /* PlaceCase.xcdatamodeld */,
F36D28FA23987DE900095B66 /* Preview Content */,
F36D295B239A04BC00095B66 /* Resources */,
F36D295C239A04C300095B66 /* Scenes */,
);
path = PlaceCase;
sourceTree = "<group>";
@ -73,11 +94,108 @@
F36D28FA23987DE900095B66 /* Preview Content */ = {
isa = PBXGroup;
children = (
F374DC1A239A57470016CC65 /* SampleData */,
F36D28FB23987DE900095B66 /* Preview Assets.xcassets */,
);
path = "Preview Content";
sourceTree = "<group>";
};
F36D295A239A04B800095B66 /* Data */ = {
isa = PBXGroup;
children = (
F374DC0F239A4ACA0016CC65 /* Models */,
F36D28F323987DE700095B66 /* PlaceCase.xcdatamodeld */,
F36D295E239A071F00095B66 /* CoreDataManager.swift */,
);
path = Data;
sourceTree = "<group>";
};
F36D295B239A04BC00095B66 /* Resources */ = {
isa = PBXGroup;
children = (
F36D28F823987DE900095B66 /* Assets.xcassets */,
);
path = Resources;
sourceTree = "<group>";
};
F36D295C239A04C300095B66 /* Scenes */ = {
isa = PBXGroup;
children = (
F374DC08239A42C90016CC65 /* Location Collection */,
);
path = Scenes;
sourceTree = "<group>";
};
F36D2960239A093800095B66 /* Reusables */ = {
isa = PBXGroup;
children = (
F36D2961239A093D00095B66 /* Views */,
F374DC35239B96770016CC65 /* AuthenticationService.swift */,
);
path = Reusables;
sourceTree = "<group>";
};
F36D2961239A093D00095B66 /* Views */ = {
isa = PBXGroup;
children = (
F36D2962239A0A6900095B66 /* MapView */,
);
path = Views;
sourceTree = "<group>";
};
F36D2962239A0A6900095B66 /* MapView */ = {
isa = PBXGroup;
children = (
F36D2963239A0ACA00095B66 /* MapView.swift */,
F36D2964239A0ACA00095B66 /* MapView+Coordinator.swift */,
);
path = MapView;
sourceTree = "<group>";
};
F374DC08239A42C90016CC65 /* Location Collection */ = {
isa = PBXGroup;
children = (
F374DC09239A431E0016CC65 /* LocationCollectionView.swift */,
F3BF5BE0239BA2E900B9F8D8 /* LocationCollectionViewModel.swift */,
);
path = "Location Collection";
sourceTree = "<group>";
};
F374DC0F239A4ACA0016CC65 /* Models */ = {
isa = PBXGroup;
children = (
F374DC11239A4AEC0016CC65 /* Location */,
F374DC10239A4AE00016CC65 /* Location Collection */,
);
path = Models;
sourceTree = "<group>";
};
F374DC10239A4AE00016CC65 /* Location Collection */ = {
isa = PBXGroup;
children = (
F374DC16239A4B790016CC65 /* LocationCollection+CoreDataClass.swift */,
F374DC17239A4B790016CC65 /* LocationCollection+CoreDataProperties.swift */,
);
path = "Location Collection";
sourceTree = "<group>";
};
F374DC11239A4AEC0016CC65 /* Location */ = {
isa = PBXGroup;
children = (
F374DC12239A4B2B0016CC65 /* Location+CoreDataClass.swift */,
F374DC13239A4B2B0016CC65 /* Location+CoreDataProperties.swift */,
);
path = Location;
sourceTree = "<group>";
};
F374DC1A239A57470016CC65 /* SampleData */ = {
isa = PBXGroup;
children = (
F374DC1B239A57620016CC65 /* Annotations.swift */,
);
path = SampleData;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -149,10 +267,20 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F374DC18239A4B790016CC65 /* LocationCollection+CoreDataClass.swift in Sources */,
F36D28F023987DE700095B66 /* AppDelegate.swift in Sources */,
F36D28F223987DE700095B66 /* SceneDelegate.swift in Sources */,
F36D28F723987DE700095B66 /* ContentView.swift in Sources */,
F36D2965239A0ACA00095B66 /* MapView.swift in Sources */,
F374DC19239A4B790016CC65 /* LocationCollection+CoreDataProperties.swift in Sources */,
F36D2966239A0ACA00095B66 /* MapView+Coordinator.swift in Sources */,
F374DC1C239A57620016CC65 /* Annotations.swift in Sources */,
F374DC14239A4B2C0016CC65 /* Location+CoreDataClass.swift in Sources */,
F374DC15239A4B2C0016CC65 /* Location+CoreDataProperties.swift in Sources */,
F3BF5BE1239BA2E900B9F8D8 /* LocationCollectionViewModel.swift in Sources */,
F36D295F239A071F00095B66 /* CoreDataManager.swift in Sources */,
F36D28F523987DE700095B66 /* PlaceCase.xcdatamodeld in Sources */,
F374DC36239B96780016CC65 /* AuthenticationService.swift in Sources */,
F374DC0A239A431E0016CC65 /* LocationCollectionView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -13,12 +13,14 @@ import CoreData
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
CoreDataManager.shared.setup()
return true
}
// MARK: UISceneSession Lifecycle
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
@ -27,56 +29,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
// Called when the user discards a scene session.
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
}
// MARK: - Core Data stack
lazy var persistentContainer: NSPersistentContainer = {
/*
The persistent container for the application. This implementation
creates and returns a container, having loaded the store for the
application to it. This property is optional since there are legitimate
error conditions that could cause the creation of the store to fail.
*/
let container = NSPersistentContainer(name: "PlaceCase")
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
/*
Typical reasons for an error here include:
* The parent directory does not exist, cannot be created, or disallows writing.
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
* The device is out of space.
* The store could not be migrated to the current model version.
Check the error message to determine what the actual problem was.
*/
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
return container
}()
// MARK: - Core Data Saving support
func saveContext () {
let context = persistentContainer.viewContext
if context.hasChanges {
do {
try context.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
}
}

View File

@ -1,21 +0,0 @@
//
// ContentView.swift
// PlaceCase
//
// Created by Brian Sipple on 12/4/19.
// Copyright © 2019 CypherPoet. All rights reserved.
//
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, World!")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

View File

@ -0,0 +1,106 @@
//
// CoreDataManager.swift
// PlaceCase
//
// Created by CypherPoet on 12/5/19.
//
//
import Foundation
import CoreData
final class CoreDataManager {
typealias LoadCompletionHandler = (() -> Void)
private var storeType: String = NSSQLiteStoreType
// MARK: - PersistentContainer
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: ManagedObjectModelName.application)
let description = container.persistentStoreDescriptions.first
description?.type = storeType
return container
}()
// MARK: - Managed Object Contexts
lazy var backgroundContext: NSManagedObjectContext = {
let context = self.persistentContainer.newBackgroundContext()
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return context
}()
lazy var mainContext: NSManagedObjectContext = {
let context = self.persistentContainer.viewContext
context.automaticallyMergesChangesFromParent = true
return context
}()
}
// MARK: - Private Methods
extension CoreDataManager {
private func loadPersistentStore(then completionHandler: @escaping LoadCompletionHandler) {
persistentContainer.loadPersistentStores { description, error in
guard error == nil else {
fatalError("Error while loading store: \(error!)")
}
completionHandler()
}
}
}
// MARK: - Public Methods
extension CoreDataManager {
func setup(storeType: String = NSSQLiteStoreType, then completionHandler: LoadCompletionHandler? = nil) {
self.storeType = storeType
loadPersistentStore() {
completionHandler?()
}
}
func save(_ context: NSManagedObjectContext) {
context.performAndWait {
if context.hasChanges {
do {
try context.save()
} catch {
// TODO: Better error handling would be needed here for a production app
let nsError = error as NSError
print("Error while attempting to save Core Data context named: \"\(context.name ?? "")\": \(nsError), \(nsError.userInfo)")
}
}
}
}
func saveContexts() {
[mainContext, backgroundContext].forEach(save(_:))
}
}
extension CoreDataManager {
static let shared = CoreDataManager()
}
extension CoreDataManager {
enum ManagedObjectModelName {
static let application = "PlaceCase"
}
}

View File

@ -0,0 +1,16 @@
//
// LocationCollection+CoreDataClass.swift
// PlaceCase
//
// Created by Brian Sipple on 12/6/19.
// Copyright © 2019 CypherPoet. All rights reserved.
//
//
import Foundation
import CoreData
@objc(LocationCollection)
public class LocationCollection: NSManagedObject {
}

View File

@ -0,0 +1,40 @@
//
// LocationCollection+CoreDataProperties.swift
// PlaceCase
//
// Created by Brian Sipple on 12/6/19.
// Copyright © 2019 CypherPoet. All rights reserved.
//
//
import Foundation
import CoreData
extension LocationCollection {
@nonobjc public class func fetchRequest() -> NSFetchRequest<LocationCollection> {
return NSFetchRequest<LocationCollection>(entityName: "LocationCollection")
}
@NSManaged public var name: String?
@NSManaged public var locations: NSSet?
}
// MARK: Generated accessors for locations
extension LocationCollection {
@objc(addLocationsObject:)
@NSManaged public func addToLocations(_ value: Location)
@objc(removeLocationsObject:)
@NSManaged public func removeFromLocations(_ value: Location)
@objc(addLocations:)
@NSManaged public func addToLocations(_ values: NSSet)
@objc(removeLocations:)
@NSManaged public func removeFromLocations(_ values: NSSet)
}

View File

@ -0,0 +1,16 @@
//
// Location+CoreDataClass.swift
// PlaceCase
//
// Created by Brian Sipple on 12/6/19.
// Copyright © 2019 CypherPoet. All rights reserved.
//
//
import Foundation
import CoreData
@objc(Location)
public class Location: NSManagedObject {
}

View File

@ -0,0 +1,27 @@
//
// Location+CoreDataProperties.swift
// PlaceCase
//
// Created by Brian Sipple on 12/6/19.
// Copyright © 2019 CypherPoet. All rights reserved.
//
//
import Foundation
import CoreData
import CoreLocation
extension Location {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Location> {
return NSFetchRequest<Location>(entityName: "Location")
}
@NSManaged public var name: String?
@NSManaged public var latitude: Double
@NSManaged public var longitude: Double
@NSManaged public var placemark: CLPlacemark?
@NSManaged public var locationDescription: String?
@NSManaged public var collection: LocationCollection?
}

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="15508" systemVersion="19B88" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Location" representedClassName="Location" syncable="YES">
<attribute name="latitude" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="locationDescription" optional="YES" attributeType="String"/>
<attribute name="longitude" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="placemark" optional="YES" attributeType="Transformable" customClassName="CLPlacemark"/>
<relationship name="collection" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="LocationCollection" inverseName="locations" inverseEntity="LocationCollection"/>
</entity>
<entity name="LocationCollection" representedClassName="LocationCollection" syncable="YES">
<attribute name="name" optional="YES" attributeType="String"/>
<relationship name="locations" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Location" inverseName="collection" inverseEntity="Location"/>
</entity>
<elements>
<element name="LocationCollection" positionX="-63" positionY="-18" width="128" height="73"/>
<element name="Location" positionX="-54" positionY="9" width="128" height="133"/>
</elements>
</model>

View File

@ -49,6 +49,8 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSFaceIDUsageDescription</key>
<string>This app would like to use authentication to save the locations you create and list.</string>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="1" systemVersion="11A491" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="false" userDefinedModelVersionIdentifier="">
<elements/>
</model>

View File

@ -0,0 +1,35 @@
//
// Annotations.swift
// PlaceCase
//
// Created by CypherPoet on 12/6/19.
//
//
#if DEBUG
import Foundation
import MapKit
enum SampleData {}
extension SampleData {
enum Annotations {
static let santorini: MKPointAnnotation = {
let annotation = MKPointAnnotation()
annotation.title = "Santorini"
annotation.subtitle = "An an island in the southern Aegean Sea, speculated to be the inspiration for the city of Atlantis."
annotation.coordinate = CLLocationCoordinate2D(latitude: 36.416667, longitude: 25.433333)
return annotation
}()
}
}
#endif

View File

@ -0,0 +1,104 @@
//
// AuthenticationService.swift
// PlaceCase
//
// Created by CypherPoet on 12/7/19.
//
//
import SwiftUI
import Combine
import LocalAuthentication
/// Protocol describing some core functionality of the `LAContext` class
protocol LAContextType {
init()
func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool
func evaluatePolicy(_ policy: LAPolicy, localizedReason: String, reply: @escaping (Bool, Error?) -> Void)
}
extension LAContext: LAContextType {}
protocol AuthenticatingService {
/// - Parameter reason: The app-provided reason for requesting authentication,
/// which displays in the authentication dialog presented to the user.
func authenticate(reason: String) -> AnyPublisher<Void, Error>
}
final class AuthenticationService: AuthenticatingService {
static let authReason = "Please authenticate to unlock this app and access saved locations."
enum Error: Swift.Error {
case noBiometricsEnabled(Swift.Error?)
case evaluationFailed(Swift.Error?)
}
private let laContextType: LAContextType.Type
private var context: LAContextType?
init(laContextType: LAContextType.Type) {
self.laContextType = laContextType
}
func authenticate(reason: String) -> AnyPublisher<Void, Swift.Error> {
let context = laContextType.init()
self.context = context
var error: NSError?
guard context.canEvaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
error: &error
) else {
defer { self.context = nil }
return Fail(error: Error.noBiometricsEnabled(error))
.eraseToAnyPublisher()
}
return Future { promise in
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason
) { (wasSuccessful, error) in
defer { self.context = nil }
guard wasSuccessful else {
promise(.failure(Error.evaluationFailed(error)))
return
}
promise(.success(()))
}
}
.eraseToAnyPublisher()
}
}
#if DEBUG
extension SampleData {
class AuthService: AuthenticatingService {
func authenticate(reason: String) -> AnyPublisher<Void, Error> {
Empty().eraseToAnyPublisher()
}
}
}
#endif

View File

@ -0,0 +1,51 @@
//
// MapView +Coordinator.swift
// PlaceCase
//
// Created by CypherPoet on 12/5/19.
//
//
import Foundation
import UIKit
import MapKit
extension MapView {
class Coordinator: NSObject {
enum ReuseIdentifier {
static let pinAnnotation = "Location List Pin"
}
}
}
extension MapView.Coordinator {
func configure(_ annotationView: MKAnnotationView) {
annotationView.canShowCallout = true
annotationView.isEnabled = true
}
}
// MARK: - MKMapViewDelegate
extension MapView.Coordinator: MKMapViewDelegate {
func mapView(
_ mapView: MKMapView,
viewFor annotation: MKAnnotation
) -> MKAnnotationView? {
let annotationView = mapView.dequeueReusableAnnotationView(
withIdentifier: ReuseIdentifier.pinAnnotation
)
?? MKPinAnnotationView(annotation: annotation, reuseIdentifier: ReuseIdentifier.pinAnnotation)
configure(annotationView)
return annotationView
}
}

View File

@ -0,0 +1,58 @@
//
// MapView.swift
// PlaceCase
//
// Created by CypherPoet on 12/5/19.
//
//
import SwiftUI
import MapKit
struct MapView {
var annotations: [MKPointAnnotation] = []
}
// MARK: - UIViewRepresentable
extension MapView: UIViewRepresentable {
func makeCoordinator() -> MapView.Coordinator {
Self.Coordinator()
}
func makeUIView(
context: UIViewRepresentableContext<MapView>
) -> MKMapView {
let mapView = MKMapView()
mapView.addAnnotations(annotations)
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(
_ mapView: MKMapView,
context: UIViewRepresentableContext<MapView>
) {
// TODO
}
}
// MARK: - Preview
struct MapView_Previews: PreviewProvider {
static var previews: some View {
MapView(
annotations: [SampleData.Annotations.santorini]
)
}
}

View File

@ -8,33 +8,43 @@
import UIKit
import SwiftUI
import LocalAuthentication
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
// Get the managed object context from the shared persistent container.
let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
// Create the SwiftUI view and set the context as the value for the managedObjectContext environment keyPath.
// Add `@Environment(\.managedObjectContext)` in the views that will need the context.
let contentView = ContentView().environment(\.managedObjectContext, context)
let managedObjectContext = CoreDataManager.shared.mainContext
// Create the SwiftUI view that provides the window contents.
let entryView = LocationCollectionView(
viewModel: LocationCollectionViewModel(
// authService: appState.authService // TODO: Use an `AppState` object and store the authService there.
authService: AuthenticationService(laContextType: LAContext.self)
)
)
.accentColor(.pink)
.environment(\.managedObjectContext, managedObjectContext)
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
window.rootViewController = UIHostingController(rootView: entryView)
self.window = window
window.makeKeyAndVisible()
}
func sceneDidDisconnect(_ scene: UIScene) {
// Called as the scene is being released by the system.
// This occurs shortly after the scene enters the background, or when its session is discarded.
@ -61,11 +71,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// Called as the scene transitions from the foreground to the background.
// Use this method to save data, release shared resources, and store enough scene-specific state information
// to restore the scene back to its current state.
// Save changes in the application's managed object context when the application transitions to the background.
(UIApplication.shared.delegate as? AppDelegate)?.saveContext()
CoreDataManager.shared.saveContexts()
}
}

View File

@ -0,0 +1,62 @@
//
// LocationCollectionView.swift
// PlaceCase
//
// Created by CypherPoet on 12/6/19.
//
//
import SwiftUI
struct LocationCollectionView: View {
@ObservedObject private(set) var viewModel: LocationCollectionViewModel
}
// MARK: - Body
extension LocationCollectionView {
var body: some View {
Group {
if viewModel.isAuthenticated {
MapView(annotations: [SampleData.Annotations.santorini])
} else {
Text("This is app is locked.")
}
}
.edgesIgnoringSafeArea(.all)
.onAppear(perform: viewModel.onAppear)
}
}
// MARK: - Computeds
extension LocationCollectionView {
}
// MARK: - View Variables
extension LocationCollectionView {
}
// MARK: - Preview
struct LocationCollectionView_Previews: PreviewProvider {
static var previews: some View {
LocationCollectionView(
viewModel: LocationCollectionViewModel(
authService: SampleData.AuthService()
)
)
}
}

View File

@ -0,0 +1,84 @@
//
// LocationCollectionViewModel.swift
// PlaceCase
//
// Created by CypherPoet on 12/7/19.
//
//
import SwiftUI
import Combine
final class LocationCollectionViewModel: ObservableObject {
private var subscriptions = Set<AnyCancellable>()
private let authService: AuthenticatingService
// MARK: - Published Properties
@Published var isAuthenticated: Bool = false
// MARK: - Init
init(
authService: AuthenticatingService
) {
self.authService = authService
setupSubscribers()
}
}
// MARK: - Publishers
extension LocationCollectionViewModel {
}
// MARK: - Computeds
extension LocationCollectionViewModel {
}
// MARK: - Public Methods
extension LocationCollectionViewModel {
func onAppear() {
authService
.authenticate(reason: AuthenticationService.authReason)
// 📝 Attempting to use `receive(on:)` here will cause the event to be dropped.
// This appears to be a bug: https://forums.swift.org/t/combine-receive-on-runloop-main-loses-sent-value-how-can-i-make-it-work/28631/47
//
// .receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { completion in
switch completion {
case .failure(let error):
print("Authentication failed: \(error)")
self.isAuthenticated = false
default:
print("Authentication completed")
}
},
receiveValue: {
print("Authentication value received")
DispatchQueue.main.async { [weak self] in
self?.isAuthenticated = true
}
}
)
.store(in: &subscriptions)
}
}
// MARK: - Private Helpers
private extension LocationCollectionViewModel {
func setupSubscribers() {
}
}