This commit is contained in:
John Estropia
2026-07-15 11:50:43 +09:00
parent 7db1cfecfb
commit 890e150b95
135 changed files with 2895 additions and 2526 deletions
@@ -2,18 +2,17 @@
// Demo
// Copyright © 2020 John Rommel Estropia, Inc. All rights reserved.
import Combine
import CoreStore
import SwiftUI
// MARK: - Modern.PokedexDemo
extension Modern.PokedexDemo {
// MARK: - Modern.PokedexDemo.MainView
struct MainView<ListView: View>: View {
// MARK: Internal
init(
@@ -22,16 +21,16 @@ extension Modern.PokedexDemo {
self.listView = listView
}
// MARK: View
var body: some View {
ZStack {
self.listView()
.frame(minHeight: 0, maxHeight: .infinity)
.edgesIgnoringSafeArea(.vertical)
.frame(minHeight: 0, maxHeight: .infinity)
.ignoresSafeArea(.container, edges: .vertical)
if self.pokedexEntries.isEmpty {
@@ -56,10 +55,10 @@ extension Modern.PokedexDemo {
.padding()
}
}
.navigationBarTitle("Pokedex")
.navigationTitle("Pokedex")
}
// MARK: Private
@ListState(
@@ -68,28 +67,10 @@ extension Modern.PokedexDemo {
in: Modern.PokedexDemo.dataStack
)
private var pokedexEntries
@ObservedObject
private var service: Modern.PokedexDemo.Service = .init()
@State
private var service = Modern.PokedexDemo.Service()
private let listView: () -> ListView
}
}
#if DEBUG
@available(iOS 14.0, *)
struct _Demo_Modern_PokedexDemo_MainView_Preview: PreviewProvider {
// MARK: PreviewProvider
static var previews: some View {
Modern.PokedexDemo.MainView(
listView: Modern.PokedexDemo.UIKit.ListView.init
)
}
}
#endif
@@ -2,64 +2,48 @@
// Demo
// Copyright © 2020 John Rommel Estropia, Inc. All rights reserved.
import Foundation
import Combine
import CoreData
import CoreStore
import UIKit
import Foundation
import Observation
// MARK: - Modern.PokedexDemo
extension Modern.PokedexDemo {
// MARK: - Modern.PokedexDemo.Service
final class Service: ObservableObject {
@MainActor
@Observable
final class Service {
/**
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> {
private static func importPokedexEntries(from data: Data) async throws {
return .init { promise in
do {
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)))
try await Modern.PokedexDemo.dataStack.async.perform { transaction -> Void in
let json: Dictionary<String, Any> = try self.parseJSON(
try JSONSerialization.jsonObject(with: 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)
}
}
)
)
}
}
catch {
throw self.mapError(error)
}
}
@@ -67,134 +51,93 @@ extension Modern.PokedexDemo {
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> {
for detailsObjectID: NSManagedObjectID,
from data: Data
) async throws -> ObjectSnapshot<Modern.PokedexDemo.Species> {
return .init { promise in
let speciesObjectID = try await Modern.PokedexDemo.dataStack.async.perform { transaction -> NSManagedObjectID 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)))
}
}
let json: Dictionary<String, Any> = try self.parseJSON(
try JSONSerialization.jsonObject(with: data, options: [])
)
guard
let species = try transaction.importUniqueObject(
Into<Modern.PokedexDemo.Species>(),
source: json
)
else {
throw Modern.PokedexDemo.Service.Error.unexpected
}
transaction
.edit(Into<Modern.PokedexDemo.Details>(), detailsObjectID)?
.species = species
return species.objectID()
}
guard
let species: Modern.PokedexDemo.Species = Modern.PokedexDemo.dataStack.fetchExisting(speciesObjectID),
let snapshot = species.asSnapshot()
else {
throw Modern.PokedexDemo.Service.Error.unexpected
}
return snapshot
}
/**
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> {
for detailsObjectID: NSManagedObjectID,
from dataArray: [Data]
) async throws {
return .init { promise in
do {
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 {
try await Modern.PokedexDemo.dataStack.async.perform { transaction -> Void in
let forms = try transaction.importUniqueObjects(
Into<Modern.PokedexDemo.Form>(),
sourceArray: dataArray.map { data in
throw Modern.PokedexDemo.Service.Error.unexpected
try self.parseJSON(
try JSONSerialization.jsonObject(with: data, options: [])
) as [String: Any]
}
details.asEditable(in: transaction)?.forms = forms
},
success: {
)
guard !forms.isEmpty else {
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)))
}
throw Modern.PokedexDemo.Service.Error.unexpected
}
)
transaction
.edit(Into<Modern.PokedexDemo.Details>(), detailsObjectID)?
.forms = forms
}
}
catch {
throw self.mapError(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()
}
}
private(set) var isLoading: Bool = false
init() {}
static func parseJSON<Output>(
static nonisolated 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,
@@ -203,20 +146,20 @@ extension Modern.PokedexDemo {
)
}
}
static func parseJSON<JSONType, Output>(
static nonisolated 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(
@@ -224,7 +167,7 @@ extension Modern.PokedexDemo {
actual: type(of: transformed),
file: "\(file):\(line)"
)
case let any:
throw Modern.PokedexDemo.Service.Error.parseError(
expected: Output.self,
@@ -233,153 +176,235 @@ extension Modern.PokedexDemo {
)
}
}
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: {}
)
self.pokedexEntriesTask?.cancel()
self.pokedexEntriesTask = Task { [weak self] in
await self?.runFetchPokedexEntries()
}
}
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)
self.fetchFormsIfNeeded(
key: species.$id,
detailsObjectID: details.objectID(),
species: 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 {
let key = pokedexEntry.$id
guard self.detailTasks[key] == nil 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 }
let speciesURL = pokedexEntry.$speciesURL
let detailsObjectID = details.objectID()
self.detailTasks[key] = Task { [weak self] in
guard let self else {
return
}
defer {
self.detailTasks.removeValue(forKey: key)
}
await self.fetchSpecies(
key: key,
detailsObjectID: detailsObjectID,
speciesURL: speciesURL
)
}
}
// MARK: Private
@ObservationIgnored
private static let pokedexURL = URL(
string: "https://pokeapi.co/api/v2/pokemon?limit=10000&offset=0"
)!
@ObservationIgnored
private var pokedexEntriesTask: Task<Void, Never>?
@ObservationIgnored
private var detailTasks: [String: Task<Void, Never>] = [:]
private static func mapError(_ error: CoreStoreError) -> Modern.PokedexDemo.Service.Error {
switch error {
case .userError(let error as Modern.PokedexDemo.Service.Error):
return error
case .userError(let error):
return .otherError(error)
case let error:
return .saveError(error)
}
}
private func runFetchPokedexEntries() async {
self.isLoading = true
defer {
self.isLoading = false
self.pokedexEntriesTask = nil
}
do {
let (data, _) = try await URLSession.shared.data(from: Self.pokedexURL)
try Task.checkCancellation()
try await Self.importPokedexEntries(from: data)
}
catch is CancellationError {
return
}
catch let error as Modern.PokedexDemo.Service.Error {
print(error)
}
catch let error as URLError {
print(Modern.PokedexDemo.Service.Error.networkError(error))
}
catch {
print(Modern.PokedexDemo.Service.Error.otherError(error))
}
}
private func fetchSpecies(
key: String,
detailsObjectID: NSManagedObjectID,
speciesURL: URL
) async {
do {
let (data, _) = try await URLSession.shared.data(from: speciesURL)
try Task.checkCancellation()
let species = try await Self.importSpecies(
for: detailsObjectID,
from: data
)
guard species.$details?.snapshot?.$forms.isEmpty == true else {
return
}
await self.fetchForms(
detailsObjectID: detailsObjectID,
formsURLs: species.$formsURLs
)
}
catch is CancellationError {
return
}
catch let error as Modern.PokedexDemo.Service.Error {
print(error)
}
catch let error as URLError {
print(Modern.PokedexDemo.Service.Error.networkError(error))
}
catch {
print(Modern.PokedexDemo.Service.Error.otherError(error))
}
}
private func fetchFormsIfNeeded(
key: String,
detailsObjectID: NSManagedObjectID,
species: ObjectSnapshot<Modern.PokedexDemo.Species>
) {
guard species.$details?.snapshot?.$forms.isEmpty == true else {
return
}
guard self.detailTasks[key] == nil else {
return
}
let formsURLs = species.$formsURLs
self.detailTasks[key] = Task { [weak self] in
guard let self else {
return
}
defer {
self.detailTasks.removeValue(forKey: key)
}
await self.fetchForms(
detailsObjectID: detailsObjectID,
formsURLs: formsURLs
)
}
}
private func fetchForms(
detailsObjectID: NSManagedObjectID,
formsURLs: [URL]
) async {
do {
var dataArray: [Data] = []
dataArray.reserveCapacity(formsURLs.count)
for url in formsURLs {
let (data, _) = try await URLSession.shared.data(from: url)
try Task.checkCancellation()
dataArray.append(data)
}
try await Self.importForms(
for: detailsObjectID,
from: dataArray
)
}
catch is CancellationError {
return
}
catch let error as Modern.PokedexDemo.Service.Error {
print(error)
}
catch let error as URLError {
print(Modern.PokedexDemo.Service.Error.networkError(error))
}
catch {
print(Modern.PokedexDemo.Service.Error.otherError(error))
}
}
// 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)
@@ -12,12 +12,11 @@ extension Modern.PokedexDemo.UIKit {
// MARK: - Modern.PokedexDemo.ListView
struct ListView: UIViewControllerRepresentable {
// MARK: Internal
init() {
self.service = Modern.PokedexDemo.Service.init()
self._service = State(initialValue: Modern.PokedexDemo.Service())
self.listPublisher = Modern.PokedexDemo.dataStack
.publishList(
From<Modern.PokedexDemo.PokedexEntry>()
@@ -41,30 +40,20 @@ extension Modern.PokedexDemo.UIKit {
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Self.Context) {}
static func dismantleUIViewController(_ uiViewController: UIViewControllerType, coordinator: Void) {}
// MARK: Private
@ObservedObject
@State
private var service: Modern.PokedexDemo.Service
private let listPublisher: ListPublisher<Modern.PokedexDemo.PokedexEntry>
}
}
#if DEBUG
struct _Demo_Modern_PokedexDemo_UIKit_ListView_Preview: PreviewProvider {
// MARK: PreviewProvider
static var previews: some View {
let service = Modern.PokedexDemo.Service()
service.fetchPokedexEntries()
return Modern.PokedexDemo.UIKit.ListView()
}
// MARK: - Preview
#Preview {
Modern.PokedexDemo.UIKit.ListView()
}
#endif
@@ -11,12 +11,13 @@ extension Modern {
// MARK: - Modern.PokedexDemo
/**
Sample usages for importing external data into `CoreStoreObject` attributes
*/
Sample usages for importing external data into `CoreStoreObject` attributes
*/
enum PokedexDemo {
// MARK: Internal
@MainActor
static let dataStack: DataStack = {
let dataStack = DataStack(
@@ -49,6 +50,7 @@ extension Modern {
return dataStack
}()
@MainActor
static let pokedexEntries: ListPublisher<Modern.PokedexDemo.PokedexEntry> = Modern.PokedexDemo.dataStack.publishList(
From<Modern.PokedexDemo.PokedexEntry>()
.orderBy(.ascending(\.$index))