This commit is contained in:
John Estropia
2020-08-29 23:05:07 +09:00
parent 9d36582c10
commit 611bc53c9a
21 changed files with 593 additions and 602 deletions

View File

@@ -15,7 +15,7 @@ extension Modern.ColorsDemo.UIKit {
final class ListViewController: UITableViewController {
/**
Sample 1: Setting up a `DiffableDataSource.TableViewAdapter` that will manage tableView snapshot updates automatically. We can use the built-in `DiffableDataSource.TableViewAdapter` type directly, but in our case we want to enabled `UITableView` cell deletions so we create a custom subclass `DeletionEnabledDataSource` (see declatation below).
Sample 1: Setting up a `DiffableDataSource.TableViewAdapter` that will manage tableView snapshot updates automatically. We can use the built-in `DiffableDataSource.TableViewAdapter` type directly, but in our case we want to enabled `UITableView` cell deletions so we create a custom subclass `DeletionEnabledDataSource` (see declaration below).
*/
private lazy var dataSource: DiffableDataSource.TableViewAdapter<Modern.ColorsDemo.Palette> = DeletionEnabledDataSource(
tableView: self.tableView,

View File

@@ -0,0 +1,27 @@
//
// Demo
// Copyright © 2020 John Rommel Estropia, Inc. All rights reserved.
import CoreStore
import UIKit
// MARK: - Modern.PokedexDemo
extension Modern.PokedexDemo {
// MARK: - Modern.PokedexDemo.Details
final class Details: CoreStoreObject {
// MARK: Internal
@Field.Relationship("pokedexEntry", inverse: \.$details)
var pokedexEntry: Modern.PokedexDemo.PokedexEntry?
@Field.Relationship("species")
var species: Modern.PokedexDemo.Species?
@Field.Relationship("forms")
var forms: [Modern.PokedexDemo.Form]
}
}

View File

@@ -102,6 +102,7 @@ extension Modern.PokedexDemo {
do {
nameLabel.translatesAutoresizingMaskIntoConstraints = false
nameLabel.textColor = UIColor.white
nameLabel.font = UIFont.systemFont(ofSize: 14, weight: .bold)
nameLabel.numberOfLines = 0
nameLabel.textAlignment = .center
@@ -244,20 +245,20 @@ extension Modern.PokedexDemo {
return
}
self.pokemonDetails = newValue.snapshot?.$pokemonDetails
self.details = newValue.snapshot?.$details
self.didUpdateData(animated: true)
}
self.pokemonDetails = newValue?.snapshot?.$pokemonDetails
self.details = newValue?.snapshot?.$details
}
}
private var pokemonDetails: ObjectPublisher<Modern.PokedexDemo.PokemonDetails>? {
private var details: ObjectPublisher<Modern.PokedexDemo.Details>? {
didSet {
let newValue = self.pokemonDetails
let newValue = self.details
guard newValue != oldValue else {
return
@@ -269,24 +270,24 @@ extension Modern.PokedexDemo {
return
}
let pokemonDetails = newValue.snapshot
self.pokemonForm = pokemonDetails?.$pokemonForm
self.pokemonDisplays = pokemonDetails?.$pokemonDisplays
let details = newValue.snapshot
self.species = details?.$species
self.forms = details?.$forms
self.didUpdateData(animated: true)
}
let pokemonDetails = newValue?.snapshot
self.pokemonForm = pokemonDetails?.$pokemonForm
self.pokemonDisplays = pokemonDetails?.$pokemonDisplays
let details = newValue?.snapshot
self.species = details?.$species
self.forms = details?.$forms
}
}
private var pokemonForm: ObjectPublisher<Modern.PokedexDemo.PokemonForm>? {
private var species: ObjectPublisher<Modern.PokedexDemo.Species>? {
didSet {
let newValue = self.pokemonForm
let newValue = self.species
guard newValue != oldValue else {
return
@@ -299,19 +300,19 @@ extension Modern.PokedexDemo {
}
}
private var rotationCancellable: AnyCancellable?
private var pokemonDisplays: [ObjectPublisher<Modern.PokedexDemo.PokemonDisplay>]? {
private var formsRotationCancellable: AnyCancellable?
private var forms: [ObjectPublisher<Modern.PokedexDemo.Form>]? {
didSet {
let newValue = self.pokemonDisplays
let newValue = self.forms
guard newValue != oldValue else {
return
}
self.pokemonDisplay = newValue?.first
self.currentForm = newValue?.first
self.rotationCancellable = newValue.flatMap { newValue in
self.formsRotationCancellable = newValue.flatMap { newValue in
guard !newValue.isEmpty else {
@@ -328,7 +329,7 @@ extension Modern.PokedexDemo {
return
}
self.pokemonDisplay = newValue[index % newValue.count]
self.currentForm = newValue[index % newValue.count]
self.didUpdateData(animated: true)
}
)
@@ -336,11 +337,11 @@ extension Modern.PokedexDemo {
}
}
private var pokemonDisplay: ObjectPublisher<Modern.PokedexDemo.PokemonDisplay>? {
private var currentForm: ObjectPublisher<Modern.PokedexDemo.Form>? {
didSet {
let newValue = self.pokemonDisplay
let newValue = self.currentForm
guard newValue != oldValue else {
return
@@ -356,25 +357,25 @@ extension Modern.PokedexDemo {
private func didUpdateData(animated: Bool) {
let pokedexEntry = self.pokedexEntry?.snapshot
let pokemonForm = self.pokemonForm?.snapshot
let pokemonDisplay = self.pokemonDisplay?.snapshot
let species = self.species?.snapshot
let currentForm = self.currentForm?.snapshot
self.placeholderLabel.text = pokedexEntry?.$id
self.placeholderLabel.isHidden = pokemonForm != nil
self.placeholderLabel.isHidden = species != nil
self.type1View.backgroundColor = pokemonForm?.$pokemonType1.color
self.type1View.backgroundColor = species?.$pokemonType1.color
?? UIColor.clear
self.type1View.isHidden = pokemonForm == nil
self.type1View.isHidden = species == nil
self.type2View.backgroundColor = pokemonForm?.$pokemonType2?.color
?? pokemonForm?.$pokemonType1.color
self.type2View.backgroundColor = species?.$pokemonType2?.color
?? species?.$pokemonType1.color
?? UIColor.clear
self.type2View.isHidden = pokemonForm == nil
self.type2View.isHidden = species == nil
self.nameLabel.text = pokemonDisplay?.$name ?? pokemonForm?.$name
self.nameLabel.isHidden = pokemonDisplay == nil && pokemonForm == nil
self.nameLabel.text = currentForm?.$name ?? species?.$name
self.nameLabel.isHidden = currentForm == nil && species == nil
self.imageURL = pokemonDisplay?.$spriteURL
self.imageURL = currentForm?.$spriteURL
guard animated else {

View File

@@ -14,50 +14,6 @@ extension Modern.PokedexDemo {
final class ListViewController: UICollectionViewController {
/**
Sample 1: Setting up a `DiffableDataSource.TableViewAdapter` that will manage tableView snapshot updates automatically. We can use the built-in `DiffableDataSource.TableViewAdapter` type directly, but in our case we want to enabled `UITableView` cell deletions so we create a custom subclass `DeletionEnabledDataSource` (see declatation below).
*/
private lazy var dataSource: DiffableDataSource.CollectionViewAdapter<Modern.PokedexDemo.PokedexEntry> = .init(
collectionView: self.collectionView,
dataStack: Modern.PokedexDemo.dataStack,
cellProvider: { (collectionView, indexPath, pokedexEntry) in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: Modern.PokedexDemo.ItemCell.reuseIdentifier,
for: indexPath
) as! Modern.PokedexDemo.ItemCell
cell.setPokedexEntry(pokedexEntry, service: self.service)
return cell
}
)
/**
Sample 2: Once the views are created, we can start binding `ListPublisher` updates to the `DiffableDataSource`. We typically call this at the end of `viewDidLoad`. Note that the `addObserver`'s closure argument will only be called on the succeeding updates, so to immediately display the current values, we need to call `dataSource.apply()` once.
*/
private func startObservingList() {
self.listPublisher.addObserver(self) { (listPublisher) in
self.dataSource.apply(
listPublisher.snapshot,
animatingDifferences: true
)
}
self.dataSource.apply(
self.listPublisher.snapshot,
animatingDifferences: false
)
}
/**
Sample 3: We can end monitoring updates anytime. `removeObserver()` was called here for illustration purposes only. `ListPublisher`s safely remove deallocated observers automatically.
*/
deinit {
self.listPublisher.removeObserver(self)
}
// MARK: Internal
init(
@@ -84,6 +40,17 @@ extension Modern.PokedexDemo {
)
super.init(collectionViewLayout: layout)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError()
}
deinit {
self.listPublisher.removeObserver(self)
}
// MARK: UIViewController
@@ -92,6 +59,8 @@ extension Modern.PokedexDemo {
super.viewDidLoad()
self.collectionView.backgroundColor = UIColor.systemBackground
self.collectionView.register(
Modern.PokedexDemo.ItemCell.self,
forCellWithReuseIdentifier: Modern.PokedexDemo.ItemCell.reuseIdentifier
@@ -106,10 +75,34 @@ extension Modern.PokedexDemo {
private let service: Modern.PokedexDemo.Service
private let listPublisher: ListPublisher<Modern.PokedexDemo.PokedexEntry>
@available(*, unavailable)
required init?(coder: NSCoder) {
private lazy var dataSource: DiffableDataSource.CollectionViewAdapter<Modern.PokedexDemo.PokedexEntry> = .init(
collectionView: self.collectionView,
dataStack: Modern.PokedexDemo.dataStack,
cellProvider: { (collectionView, indexPath, pokedexEntry) in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: Modern.PokedexDemo.ItemCell.reuseIdentifier,
for: indexPath
) as! Modern.PokedexDemo.ItemCell
cell.setPokedexEntry(pokedexEntry, service: self.service)
return cell
}
)
private func startObservingList() {
fatalError()
self.listPublisher.addObserver(self) { (listPublisher) in
self.dataSource.apply(
listPublisher.snapshot,
animatingDifferences: true
)
}
self.dataSource.apply(
self.listPublisher.snapshot,
animatingDifferences: false
)
}
}
}

View File

@@ -14,13 +14,6 @@ extension Modern.PokedexDemo {
struct MainView: View {
/**
Sample 1: Setting a sectioned `ListPublisher` declared as an `@ObservedObject`
*/
@ObservedObject
private var pokedexEntries: ListPublisher<Modern.PokedexDemo.PokedexEntry>
// MARK: Internal
init() {
@@ -44,7 +37,7 @@ extension Modern.PokedexDemo {
if pokedexEntries.isEmpty {
VStack(alignment: .center, spacing: 20) {
VStack(alignment: .center, spacing: 30) {
Text("This demo needs to make a network connection to download Pokedex entries")
.multilineTextAlignment(.center)
if self.service.isLoading {
@@ -70,6 +63,9 @@ extension Modern.PokedexDemo {
// MARK: Private
@ObservedObject
private var pokedexEntries: ListPublisher<Modern.PokedexDemo.PokedexEntry>
@ObservedObject
private var service: Modern.PokedexDemo.Service = .init()

View File

@@ -1,27 +0,0 @@
//
// Demo
// Copyright © 2020 John Rommel Estropia, Inc. All rights reserved.
import CoreStore
import UIKit
// MARK: - Modern.PokedexDemo
extension Modern.PokedexDemo {
// MARK: - Modern.PokedexDemo.PokemonDetails
final class PokemonDetails: CoreStoreObject {
// MARK: Internal
@Field.Relationship("pokedexEntry", inverse: \.$pokemonDetails)
var pokedexEntry: Modern.PokedexDemo.PokedexEntry?
@Field.Relationship("pokemonForm")
var pokemonForm: Modern.PokedexDemo.PokemonForm?
@Field.Relationship("pokemonDisplays")
var pokemonDisplays: [Modern.PokedexDemo.PokemonDisplay]
}
}

View File

@@ -1,399 +0,0 @@
//
// Demo
// Copyright © 2020 John Rommel Estropia, Inc. All rights reserved.
import Foundation
import Combine
import CoreStore
import UIKit
// MARK: - Modern.PokedexDemo
extension Modern.PokedexDemo {
// MARK: - Modern.PokedexDemo.Service
final class Service: ObservableObject {
// MARK: Internal
private(set) var isLoading: Bool = false {
willSet {
self.objectWillChange.send()
}
}
private(set) var lastError: (error: Modern.PokedexDemo.Service.Error, retry: () -> Void)? {
willSet {
self.objectWillChange.send()
}
}
init() {}
static func parseJSON<Output>(
_ json: Any?,
file: StaticString = #file,
line: Int = #line
) throws -> Output {
switch json {
case let json as Output:
return json
case let any:
throw Modern.PokedexDemo.Service.Error.parseError(
expected: Output.self,
actual: type(of: any),
file: "\(file):\(line)"
)
}
}
static func parseJSON<JSONType, Output>(
_ json: Any?,
transformer: (JSONType) throws -> Output?,
file: StaticString = #file,
line: Int = #line
) throws -> Output {
switch json {
case let json as JSONType:
let transformed = try transformer(json)
if let json = transformed {
return json
}
throw Modern.PokedexDemo.Service.Error.parseError(
expected: Output.self,
actual: type(of: transformed),
file: "\(file):\(line)"
)
case let any:
throw Modern.PokedexDemo.Service.Error.parseError(
expected: Output.self,
actual: type(of: any),
file: "\(file):\(line)"
)
}
}
func fetchPokedexEntries() {
self.cancellable["pokedexEntries"] = self.pokedexEntries
.receive(on: DispatchQueue.main)
.handleEvents(
receiveSubscription: { [weak self] _ in
guard let self = self else {
return
}
self.lastError = nil
self.isLoading = true
}
)
.sink(
receiveCompletion: { [weak self] completion in
guard let self = self else {
return
}
self.isLoading = false
switch completion {
case .finished:
self.lastError = nil
case .failure(let error):
print(error)
self.lastError = (
error: error,
retry: { [weak self] in
self?.fetchPokedexEntries()
}
)
}
},
receiveValue: {}
)
}
func fetchDetails(for pokedexEntry: ObjectSnapshot<Modern.PokedexDemo.PokedexEntry>) {
self.fetchPokemonFormIfNeeded(for: pokedexEntry)
}
private func fetchPokemonFormIfNeeded(for pokedexEntry: ObjectSnapshot<Modern.PokedexDemo.PokedexEntry>) {
guard let pokemonDetails = pokedexEntry.$pokemonDetails?.snapshot else {
return
}
if let pokedexForm = pokemonDetails.$pokemonForm?.snapshot {
self.fetchPokemonDisplayIfNeeded(for: pokedexForm)
return
}
self.cancellable["pokemonForm.\(pokedexEntry.$id)"] = URLSession.shared
.dataTaskPublisher(for: pokedexEntry.$pokemonFormURL)
.mapError({ .networkError($0) })
.flatMap(
{ output in
return Future<ObjectSnapshot<Modern.PokedexDemo.PokemonForm>, Modern.PokedexDemo.Service.Error> { promise in
Modern.PokedexDemo.dataStack.perform(
asynchronous: { transaction -> Modern.PokedexDemo.PokemonForm in
let json: Dictionary<String, Any> = try Self.parseJSON(
try JSONSerialization.jsonObject(with: output.data, options: [])
)
guard let pokedexForm = try transaction.importUniqueObject(
Into<Modern.PokedexDemo.PokemonForm>(),
source: json
) else {
throw Modern.PokedexDemo.Service.Error.unexpected
}
pokemonDetails.asEditable(in: transaction)?.pokemonForm = pokedexForm
return pokedexForm
},
success: { pokemonForm in
promise(.success(pokemonForm.asSnapshot(in: Modern.PokedexDemo.dataStack)!))
},
failure: { error in
switch error {
case .userError(let error):
switch error {
case let error as Modern.PokedexDemo.Service.Error:
promise(.failure(error))
case let error:
promise(.failure(.otherError(error)))
}
case let error:
promise(.failure(.saveError(error)))
}
}
)
}
}
)
.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
break
case .failure(let error):
print(error)
}
},
receiveValue: { pokemonForm in
self.fetchPokemonDisplayIfNeeded(for: pokemonForm)
}
)
}
func fetchPokemonDisplayIfNeeded(for pokemonForm: ObjectSnapshot<Modern.PokedexDemo.PokemonForm>) {
guard
let pokemonDetails = pokemonForm.$pokemonDetails?.snapshot,
pokemonDetails.$pokemonDisplays.isEmpty
else {
return
}
self.cancellable["pokemonDisplay.\(pokemonForm.$id)"] = pokemonForm
.$pokemonDisplayURLs
.map(
{ url in
URLSession.shared
.dataTaskPublisher(for: url)
.mapError({ Modern.PokedexDemo.Service.Error.networkError($0) })
.tryMap(
{ output -> Dictionary<String, Any> in
try Self.parseJSON(
try JSONSerialization.jsonObject(with: output.data, options: [])
)
}
)
.mapError(
{ error -> Modern.PokedexDemo.Service.Error in
switch error {
case let error as Modern.PokedexDemo.Service.Error:
return error
case let error:
return Modern.PokedexDemo.Service.Error.otherError(error)
}
}
)
.eraseToAnyPublisher()
}
)
.reduce(
into: Just<[Dictionary<String, Any>]>([])
.setFailureType(to: Modern.PokedexDemo.Service.Error.self)
.eraseToAnyPublisher(),
{ (result, publisher) in
result = result
.zip(publisher, { $0 + [$1] })
.eraseToAnyPublisher()
}
)
.flatMap(
{ outputs in
return Future<Void, Modern.PokedexDemo.Service.Error> { promise in
Modern.PokedexDemo.dataStack.perform(
asynchronous: { transaction -> Void in
let pokemonDisplays = try transaction.importUniqueObjects(
Into<Modern.PokedexDemo.PokemonDisplay>(),
sourceArray: outputs
)
guard !pokemonDisplays.isEmpty else {
throw Modern.PokedexDemo.Service.Error.unexpected
}
pokemonDetails.asEditable(in: transaction)?.pokemonDisplays = pokemonDisplays
},
success: {
promise(.success(()))
},
failure: { error in
switch error {
case .userError(let error):
switch error {
case let error as Modern.PokedexDemo.Service.Error:
promise(.failure(error))
case let error:
promise(.failure(.otherError(error)))
}
case let error:
promise(.failure(.saveError(error)))
}
}
)
}
}
)
.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
break
case .failure(let error):
print(error)
}
},
receiveValue: { output in
}
)
}
// MARK: Private
private var cancellable: Dictionary<String, AnyCancellable> = [:]
private lazy var pokedexEntries: AnyPublisher<Void, Modern.PokedexDemo.Service.Error> = URLSession.shared
.dataTaskPublisher(
for: URL(string: "https://pokeapi.co/api/v2/pokemon?limit=10000&offset=0")!
)
.mapError({ .networkError($0) })
.flatMap(
{ output in
return Future<Void, Modern.PokedexDemo.Service.Error> { promise in
do {
let json: Dictionary<String, Any> = try Self.parseJSON(
try JSONSerialization.jsonObject(with: output.data, options: [])
)
let results: [Dictionary<String, Any>] = try Self.parseJSON(
json["results"]
)
Modern.PokedexDemo.dataStack.perform(
asynchronous: { transaction -> Void in
_ = try transaction.importUniqueObjects(
Into<Modern.PokedexDemo.PokedexEntry>(),
sourceArray: results.enumerated().map { (index, json) in
(index: index, json: json)
}
)
},
success: { result in
promise(.success(result))
},
failure: { error in
promise(.failure(.saveError(error)))
}
)
}
catch let error as Modern.PokedexDemo.Service.Error {
promise(.failure(error))
}
catch {
promise(.failure(.otherError(error)))
}
}
}
)
.eraseToAnyPublisher()
// MARK: - Modern.PokedexDemo.Service.Error
enum Error: Swift.Error {
case networkError(URLError)
case parseError(expected: Any.Type, actual: Any.Type, file: String)
case saveError(CoreStoreError)
case otherError(Swift.Error)
case unexpected
}
}
}

View File

@@ -24,9 +24,9 @@ extension Modern {
modelVersion: "V1",
entities: [
Entity<Modern.PokedexDemo.PokedexEntry>("PokedexEntry"),
Entity<Modern.PokedexDemo.PokemonDetails>("PokemonDetails"),
Entity<Modern.PokedexDemo.PokemonForm>("PokemonForm"),
Entity<Modern.PokedexDemo.PokemonDisplay>("PokemonDisplay")
Entity<Modern.PokedexDemo.Details>("Details"),
Entity<Modern.PokedexDemo.Species>("Species"),
Entity<Modern.PokedexDemo.Form>("Form")
]
)
)

View File

@@ -9,9 +9,12 @@ import UIKit
extension Modern.PokedexDemo {
// MARK: - Modern.PokedexDemo.PokemonDisplay
// MARK: - Modern.PokedexDemo.Form
final class PokemonDisplay: CoreStoreObject, ImportableUniqueObject {
/**
Sample 1: This sample shows how to declare `CoreStoreObject` subclasses that implement `ImportableUniqueObject`. For this class the `ImportSource` is a JSON `Dictionary`.
*/
final class Form: CoreStoreObject, ImportableUniqueObject {
// MARK: Internal
@@ -25,8 +28,8 @@ extension Modern.PokedexDemo {
var spriteURL: URL?
@Field.Relationship("pokemonDetails", inverse: \.$pokemonDisplays)
var pokemonDetails: Modern.PokedexDemo.PokemonDetails?
@Field.Relationship("details", inverse: \.$forms)
var details: Modern.PokedexDemo.Details?
// MARK: ImportableObject
@@ -38,7 +41,7 @@ extension Modern.PokedexDemo {
typealias UniqueIDType = Int
static let uniqueIDKeyPath: String = String(keyPath: \Modern.PokedexDemo.PokemonDisplay.$id)
static let uniqueIDKeyPath: String = String(keyPath: \Modern.PokedexDemo.Form.$id)
var uniqueIDValue: UniqueIDType {

View File

@@ -9,7 +9,10 @@ import CoreStore
extension Modern.PokedexDemo {
// MARK: - Modern.PokedexDemo.PokedexEntry
/**
Sample 1: This sample shows how to declare `CoreStoreObject` subclasses that implement `ImportableUniqueObject`. For this class the `ImportSource` is a tuple.
*/
final class PokedexEntry: CoreStoreObject, ImportableUniqueObject {
// MARK: Internal
@@ -21,14 +24,14 @@ extension Modern.PokedexDemo {
var id: String = ""
@Field.Stored(
"pokemonFormURL",
"speciesURL",
dynamicInitialValue: { URL(string: "data:application/json,%7B%7D")! }
)
var pokemonFormURL: URL
var speciesURL: URL
@Field.Relationship("pokemonDetails")
var pokemonDetails: Modern.PokedexDemo.PokemonDetails?
@Field.Relationship("details")
var details: Modern.PokedexDemo.Details?
// MARK: ImportableObject
@@ -37,7 +40,8 @@ extension Modern.PokedexDemo {
func didInsert(from source: ImportSource, in transaction: BaseDataTransaction) throws {
self.pokemonDetails = transaction.create(Into<Modern.PokedexDemo.PokemonDetails>())
self.details = transaction.create(Into<Modern.PokedexDemo.Details>())
try self.update(from: source, in: transaction)
}
@@ -64,7 +68,7 @@ extension Modern.PokedexDemo {
let json = source.json
self.index = source.index
self.pokemonFormURL = try Modern.PokedexDemo.Service.parseJSON(json["url"], transformer: URL.init(string:))
self.speciesURL = try Modern.PokedexDemo.Service.parseJSON(json["url"], transformer: URL.init(string:))
}
}
}

View File

@@ -11,6 +11,9 @@ extension Modern.PokedexDemo {
// MARK: - Modern.PokedexDemo.Move
/**
Sample 1: Types that will be used with `@Field.Stored` need to implement both `ImportableAttributeType` and `FieldStorableType`. In this case, `RawRepresentable` types with primitive `RawValue`s have built-in implementations so we only have to declare conformance to `ImportableAttributeType` and `FieldStorableType`.
*/
enum PokemonType: String, CaseIterable, ImportableAttributeType, FieldStorableType {
// MARK: Internal

View File

@@ -0,0 +1,390 @@
//
// Demo
// Copyright © 2020 John Rommel Estropia, Inc. All rights reserved.
import Foundation
import Combine
import CoreStore
import UIKit
// MARK: - Modern.PokedexDemo
extension Modern.PokedexDemo {
// MARK: - Modern.PokedexDemo.Service
final class Service: ObservableObject {
/**
Sample 1: Importing a list of JSON data into `ImportableUniqueObject`s whose `ImportSource` are tuples
*/
private static func importPokedexEntries(
from output: URLSession.DataTaskPublisher.Output
) -> Future<Void, Modern.PokedexDemo.Service.Error> {
return .init { promise in
Modern.PokedexDemo.dataStack.perform(
asynchronous: { transaction -> Void in
let json: Dictionary<String, Any> = try self.parseJSON(
try JSONSerialization.jsonObject(with: output.data, options: [])
)
let results: [Dictionary<String, Any>] = try self.parseJSON(
json["results"]
)
_ = try transaction.importUniqueObjects(
Into<Modern.PokedexDemo.PokedexEntry>(),
sourceArray: results.enumerated().map { (index, json) in
(index: index, json: json)
}
)
},
success: { result in
promise(.success(result))
},
failure: { error in
switch error {
case .userError(let error as Modern.PokedexDemo.Service.Error):
promise(.failure(error))
case .userError(let error):
promise(.failure(.otherError(error)))
case let error:
promise(.failure(.saveError(error)))
}
}
)
}
}
/**
Sample 2: Importing a single JSON data into an `ImportableUniqueObject` whose `ImportSource` is a JSON `Dictionary`
*/
private static func importSpecies(
for details: ObjectSnapshot<Modern.PokedexDemo.Details>,
from output: URLSession.DataTaskPublisher.Output
) -> Future<ObjectSnapshot<Modern.PokedexDemo.Species>, Modern.PokedexDemo.Service.Error> {
return .init { promise in
Modern.PokedexDemo.dataStack.perform(
asynchronous: { transaction -> Modern.PokedexDemo.Species in
let json: Dictionary<String, Any> = try self.parseJSON(
try JSONSerialization.jsonObject(with: output.data, options: [])
)
guard
let species = try transaction.importUniqueObject(
Into<Modern.PokedexDemo.Species>(),
source: json
)
else {
throw Modern.PokedexDemo.Service.Error.unexpected
}
details.asEditable(in: transaction)?.species = species
return species
},
success: { species in
promise(.success(species.asSnapshot(in: Modern.PokedexDemo.dataStack)!))
},
failure: { error in
switch error {
case .userError(let error as Modern.PokedexDemo.Service.Error):
promise(.failure(error))
case .userError(let error):
promise(.failure(.otherError(error)))
case let error:
promise(.failure(.saveError(error)))
}
}
)
}
}
/**
Sample 3: Importing a list of JSON data into `ImportableUniqueObject`s whose `ImportSource` are JSON `Dictionary`s
*/
private static func importForms(
for details: ObjectSnapshot<Modern.PokedexDemo.Details>,
from outputs: [URLSession.DataTaskPublisher.Output]
) -> Future<Void, Modern.PokedexDemo.Service.Error> {
return .init { promise in
Modern.PokedexDemo.dataStack.perform(
asynchronous: { transaction -> Void in
let forms = try transaction.importUniqueObjects(
Into<Modern.PokedexDemo.Form>(),
sourceArray: outputs.map { output in
return try self.parseJSON(
try JSONSerialization.jsonObject(with: output.data, options: [])
)
}
)
guard !forms.isEmpty else {
throw Modern.PokedexDemo.Service.Error.unexpected
}
details.asEditable(in: transaction)?.forms = forms
},
success: {
promise(.success(()))
},
failure: { error in
switch error {
case .userError(let error as Modern.PokedexDemo.Service.Error):
promise(.failure(error))
case .userError(let error):
promise(.failure(.otherError(error)))
case let error:
promise(.failure(.saveError(error)))
}
}
)
}
}
// MARK: Internal
private(set) var isLoading: Bool = false {
willSet {
self.objectWillChange.send()
}
}
private(set) var lastError: (error: Modern.PokedexDemo.Service.Error, retry: () -> Void)? {
willSet {
self.objectWillChange.send()
}
}
init() {}
static func parseJSON<Output>(
_ json: Any?,
file: StaticString = #file,
line: Int = #line
) throws -> Output {
switch json {
case let json as Output:
return json
case let any:
throw Modern.PokedexDemo.Service.Error.parseError(
expected: Output.self,
actual: type(of: any),
file: "\(file):\(line)"
)
}
}
static func parseJSON<JSONType, Output>(
_ json: Any?,
transformer: (JSONType) throws -> Output?,
file: StaticString = #file,
line: Int = #line
) throws -> Output {
switch json {
case let json as JSONType:
let transformed = try transformer(json)
if let json = transformed {
return json
}
throw Modern.PokedexDemo.Service.Error.parseError(
expected: Output.self,
actual: type(of: transformed),
file: "\(file):\(line)"
)
case let any:
throw Modern.PokedexDemo.Service.Error.parseError(
expected: Output.self,
actual: type(of: any),
file: "\(file):\(line)"
)
}
}
func fetchPokedexEntries() {
self.cancellable["pokedexEntries"] = self.pokedexEntries
.receive(on: DispatchQueue.main)
.handleEvents(
receiveSubscription: { [weak self] _ in
guard let self = self else {
return
}
self.lastError = nil
self.isLoading = true
}
)
.sink(
receiveCompletion: { [weak self] completion in
guard let self = self else {
return
}
self.isLoading = false
switch completion {
case .finished:
self.lastError = nil
case .failure(let error):
print(error)
self.lastError = (
error: error,
retry: { [weak self] in
self?.fetchPokedexEntries()
}
)
}
},
receiveValue: {}
)
}
func fetchDetails(for pokedexEntry: ObjectSnapshot<Modern.PokedexDemo.PokedexEntry>) {
self.fetchSpeciesIfNeeded(for: pokedexEntry)
}
// MARK: Private
private var cancellable: Dictionary<String, AnyCancellable> = [:]
private lazy var pokedexEntries: AnyPublisher<Void, Modern.PokedexDemo.Service.Error> = URLSession.shared
.dataTaskPublisher(
for: URL(string: "https://pokeapi.co/api/v2/pokemon?limit=10000&offset=0")!
)
.mapError({ .networkError($0) })
.flatMap(Self.importPokedexEntries(from:))
.eraseToAnyPublisher()
private func fetchSpeciesIfNeeded(for pokedexEntry: ObjectSnapshot<Modern.PokedexDemo.PokedexEntry>) {
guard let details = pokedexEntry.$details?.snapshot else {
return
}
if let species = details.$species?.snapshot {
self.fetchFormsIfNeeded(for: species)
return
}
self.cancellable["species.\(pokedexEntry.$id)"] = URLSession.shared
.dataTaskPublisher(for: pokedexEntry.$speciesURL)
.mapError({ .networkError($0) })
.flatMap({ Self.importSpecies(for: details, from: $0) })
.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
break
case .failure(let error):
print(error)
}
},
receiveValue: { species in
self.fetchFormsIfNeeded(for: species)
}
)
}
private func fetchFormsIfNeeded(for species: ObjectSnapshot<Modern.PokedexDemo.Species>) {
guard
let details = species.$details?.snapshot,
details.$forms.isEmpty
else {
return
}
self.cancellable["forms.\(species.$id)"] = species
.$formsURLs
.map(
{
URLSession.shared
.dataTaskPublisher(for: $0)
.mapError({ Modern.PokedexDemo.Service.Error.networkError($0) })
.eraseToAnyPublisher()
}
)
.reduce(
into: Just<[URLSession.DataTaskPublisher.Output]>([])
.setFailureType(to: Modern.PokedexDemo.Service.Error.self)
.eraseToAnyPublisher(),
{ (result, publisher) in
result = result
.zip(publisher, { $0 + [$1] })
.eraseToAnyPublisher()
}
)
.flatMap({ Self.importForms(for: details, from: $0) })
.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
break
case .failure(let error):
print(error)
}
},
receiveValue: { _ in }
)
}
// MARK: - Modern.PokedexDemo.Service.Error
enum Error: Swift.Error {
case networkError(URLError)
case parseError(expected: Any.Type, actual: Any.Type, file: String)
case saveError(CoreStoreError)
case otherError(Swift.Error)
case unexpected
}
}
}

View File

@@ -8,9 +8,12 @@ import CoreStore
extension Modern.PokedexDemo {
// MARK: - Modern.PokedexDemo.PokemonForm
// MARK: - Modern.PokedexDemo.Species
final class PokemonForm: CoreStoreObject, ImportableUniqueObject {
/**
Sample 1: This sample shows how to declare `CoreStoreObject` subclasses that implement `ImportableUniqueObject`. For this class the `ImportSource` is a JSON `Dictionary`.
*/
final class Species: CoreStoreObject, ImportableUniqueObject {
// MARK: Internal
@@ -50,14 +53,14 @@ extension Modern.PokedexDemo {
@Field.Coded(
"pokemonDisplayURLs",
"formsURLs",
coder: FieldCoders.Json.self
)
var pokemonDisplayURLs: [URL] = []
var formsURLs: [URL] = []
@Field.Relationship("pokemonDetails", inverse: \.$pokemonForm)
var pokemonDetails: Modern.PokedexDemo.PokemonDetails?
@Field.Relationship("details", inverse: \.$species)
var details: Modern.PokedexDemo.Details?
// MARK: ImportableObject
@@ -69,7 +72,7 @@ extension Modern.PokedexDemo {
typealias UniqueIDType = Int
static let uniqueIDKeyPath: String = String(keyPath: \Modern.PokedexDemo.PokemonForm.$id)
static let uniqueIDKeyPath: String = String(keyPath: \Modern.PokedexDemo.Species.$id)
var uniqueIDValue: UniqueIDType {
@@ -129,11 +132,8 @@ extension Modern.PokedexDemo {
}
}
self.pokemonDisplayURLs = try (Service.parseJSON(json["forms"]) as [Dictionary<String, Any>]).map { json in
let pokemonDisplayURL = try Service.parseJSON(json["url"], transformer: URL.init(string:))
return pokemonDisplayURL
}
self.formsURLs = try (Service.parseJSON(json["forms"]) as [Dictionary<String, Any>])
.map({ try Service.parseJSON($0["url"], transformer: URL.init(string:)) })
}
}
}