Last active
August 5, 2021 17:45
-
-
Save garsdle/89755235d4d5f31326d0a467e8196314 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
import Combine | |
import ComposableArchitecture | |
// MARK: - MODELS | |
struct Counter: Identifiable, Equatable { | |
let id: UUID | |
let createdAt: Date | |
var count: Int | |
} | |
struct FactPrompt: Equatable { | |
let count: Int | |
var fact: String | |
var isLoading = false | |
} | |
struct Alert: Equatable, Identifiable { | |
var message: String | |
var title: String | |
var id: String { | |
self.title + self.message | |
} | |
} | |
// MARK: - REPO | |
class AppRepo { | |
@Published private var counters: [Counter.ID: Counter] = [:] | |
@Published private var factPrompt: FactPrompt? | |
@Published private var factAlert: Alert? | |
func countersPublisher() -> AnyPublisher<[Counter], Never> { | |
$counters | |
.map { $0.lazy.map(\.value).sorted(by: \.createdAt) } | |
.eraseToAnyPublisher() | |
} | |
func counterPublisher(_ counterId: Counter.ID) -> AnyPublisher<Counter, Never> { | |
$counters.compactMap(\.[counterId]).eraseToAnyPublisher() | |
} | |
func alertPublisher() -> AnyPublisher<Alert?, Never> { | |
$factAlert.eraseToAnyPublisher() | |
} | |
func get(counterId: Counter.ID) -> Counter? { | |
counters[counterId] | |
} | |
func update(counter: Counter) { | |
counters[counter.id] = counter | |
} | |
func remove(counterId: Counter.ID) { | |
counters.removeValue(forKey: counterId) | |
} | |
func factPromptPublisher() -> AnyPublisher<FactPrompt?, Never> { | |
$factPrompt.eraseToAnyPublisher() | |
} | |
func getFactPrompt() -> FactPrompt? { | |
factPrompt | |
} | |
func update(factPrompt: FactPrompt?) { | |
self.factPrompt = factPrompt | |
} | |
func update(alert: Alert?) { | |
self.factAlert = alert | |
} | |
} | |
extension EnvironmentValues { | |
var appRepo: AppRepo { | |
get { self[AppRepo.self] } | |
set { self[AppRepo.self] = newValue } | |
} | |
} | |
extension AppRepo: EnvironmentKey { | |
static var defaultValue: AppRepo { | |
AppRepo() | |
} | |
} | |
// MARK: - INTERACTOR | |
struct AppInteractorEnvironment { | |
let uuid: () -> UUID | |
let date: () -> Date | |
let mainQueue: AnySchedulerOf<DispatchQueue> | |
let getCounter: (Counter.ID) -> Counter? | |
let updateCounter: (Counter) -> Void | |
let removeCounter: (Counter.ID) -> Void | |
let factClient: FactClient | |
let getFactPrompt: () -> FactPrompt? | |
let updateFactPrompt: (FactPrompt?) -> Void | |
let updateAlert: (Alert?) -> Void | |
} | |
extension AppInteractorEnvironment { | |
static func live(appRepo: AppRepo) -> Self { | |
Self(uuid: UUID.init, | |
date: Date.init, | |
mainQueue: .main, | |
getCounter: appRepo.get(counterId:), | |
updateCounter: appRepo.update(counter:), | |
removeCounter: appRepo.remove(counterId:), | |
factClient: .live, | |
getFactPrompt: appRepo.getFactPrompt, | |
updateFactPrompt: appRepo.update(factPrompt:), | |
updateAlert: appRepo.update(alert:)) | |
} | |
} | |
class AppInteractor { | |
private let environment: AppInteractorEnvironment | |
private var factCancellable: AnyCancellable? | |
init(environment: AppInteractorEnvironment) { | |
self.environment = environment | |
} | |
func addCounter() { | |
let newCounter = Counter(id: environment.uuid(), createdAt: environment.date(), count: 0) | |
environment.updateCounter(newCounter) | |
} | |
func decrement(counterId: Counter.ID) { | |
guard var counter = environment.getCounter(counterId) else { return } | |
counter.count -= 1 | |
environment.updateCounter(counter) | |
} | |
func increment(counterId: Counter.ID) { | |
guard var counter = environment.getCounter(counterId) else { return } | |
counter.count += 1 | |
environment.updateCounter(counter) | |
} | |
func remove(counterId: Counter.ID) { | |
environment.removeCounter(counterId) | |
} | |
func fectchFactPrompt(count: Int) { | |
let factPrompt = FactPrompt(count: count, fact: "", isLoading: true) | |
environment.updateFactPrompt(factPrompt) | |
factCancellable = environment.factClient.fetch(count) | |
.receive(on: environment.mainQueue) | |
.catchToEffect() | |
.sink(receiveValue: factResponse(result:)) | |
} | |
func factResponse(result: Result<String, FactClient.Error>) { | |
switch result { | |
case .failure(let error): | |
print(error) | |
let alert = Alert(message: "\(error)", title: "Failed to fetch fact") | |
environment.updateAlert(alert) | |
case .success(let fact): | |
guard var factPrompt = environment.getFactPrompt() else { return } | |
factPrompt.fact = fact | |
factPrompt.isLoading = false | |
print(factPrompt) | |
environment.updateFactPrompt(factPrompt) | |
} | |
} | |
func dismissFactPrompt() { | |
environment.updateFactPrompt(nil) | |
} | |
func dismissAlert() { | |
environment.updateAlert(nil) | |
} | |
} | |
extension EnvironmentValues { | |
var appInteractor: AppInteractor { | |
get { self[AppInteractor.self] } | |
set { self[AppInteractor.self] = newValue } | |
} | |
} | |
extension AppInteractor: EnvironmentKey { | |
static var defaultValue: AppInteractor { | |
AppInteractor(environment: .live(appRepo: .defaultValue)) | |
} | |
} | |
// MARK: - APP | |
@main | |
struct DerivedBehaviorApp: App { | |
let appRepo = AppRepo() | |
var body: some Scene { | |
WindowGroup { | |
NavigationView { | |
MainView(model: .init(environment: .init(countersPublisher: appRepo.countersPublisher(), | |
factPromptPublisher: appRepo.factPromptPublisher()))) | |
.environment(\.appInteractor, AppInteractor(environment: .live(appRepo: appRepo))) | |
.environment(\.appRepo, appRepo) | |
} | |
} | |
} | |
} | |
// MARK: - MAIN | |
struct MainEnvironment { | |
let countersPublisher: AnyPublisher<[Counter], Never> | |
let factPromptPublisher: AnyPublisher<FactPrompt?, Never> | |
} | |
class MainViewModel: ObservableObject { | |
@Published private(set) var counters: [Counter] = [] | |
@Published private(set) var factPrompt: FactPrompt? | |
init(environment: MainEnvironment) { | |
environment.countersPublisher.assign(to: &$counters) | |
environment.factPromptPublisher.assign(to: &$factPrompt) | |
} | |
} | |
struct MainView: View { | |
@Environment(\.appInteractor) var appInteractor | |
@Environment(\.appRepo) var appRepo | |
@StateObject var model: MainViewModel | |
var body: some View { | |
ZStack(alignment: .bottom) { | |
List { | |
ForEach(model.counters) { counter in | |
CounterRow(model: .init(counter: counter, | |
environment: .init(counterPublisher: appRepo.counterPublisher, | |
alertPublisher: appRepo.alertPublisher()))) | |
} | |
} | |
.listStyle(PlainListStyle()) | |
if let factPrompt = model.factPrompt { | |
FactPromptView(model: .init(factPrompt: factPrompt, | |
environment: .init(factPromptPublisher: appRepo.factPromptPublisher().ignoreNil()))) | |
} | |
} | |
.navigationTitle("Counters") | |
.navigationBarItems( | |
trailing: Button("Add", action: appInteractor.addCounter) | |
) | |
} | |
} | |
// MARK: - COUNTER | |
struct CounterEnvironment { | |
let counterPublisher: (Counter.ID) -> AnyPublisher<Counter, Never> | |
let alertPublisher: AnyPublisher<Alert?, Never> | |
} | |
class CounterViewModel: ObservableObject { | |
@Published private(set) var counter: Counter | |
@Published private(set) var alert: Alert? | |
var count: String { | |
"\(counter.count)" | |
} | |
init(counter: Counter, environment: CounterEnvironment) { | |
self.counter = counter | |
environment.alertPublisher.assign(to: &$alert) | |
} | |
} | |
struct CounterRow: View { | |
@Environment(\.appInteractor) var appInteractor | |
@StateObject var model: CounterViewModel | |
var body: some View { | |
HStack { | |
VStack { | |
HStack { | |
Button("-", action: { appInteractor.decrement(counterId: model.counter.id) }) | |
Text(model.count) | |
Button("+", action: { appInteractor.increment(counterId: model.counter.id) }) | |
} | |
Button("Fact", action: { appInteractor.fectchFactPrompt(count: model.counter.count) }) | |
} | |
.alert(item: Binding(get: { model.alert }, set: { _ in appInteractor.dismissAlert() })) { alert in | |
SwiftUI.Alert( | |
title: Text(alert.title), | |
message: Text(alert.message) | |
) | |
} | |
Spacer() | |
Button("Remove", action: { appInteractor.remove(counterId: model.counter.id) }) | |
} | |
.padding() | |
.buttonStyle(PlainButtonStyle()) | |
} | |
} | |
// MARK: - FACT | |
struct FactPromptEnvironment { | |
let factPromptPublisher: AnyPublisher<FactPrompt, Never> | |
} | |
class FactPromptViewModel: ObservableObject { | |
@Published private(set) var factPrompt: FactPrompt | |
init(factPrompt: FactPrompt, environment: FactPromptEnvironment) { | |
self.factPrompt = factPrompt | |
environment.factPromptPublisher.assign(to: &$factPrompt) | |
} | |
} | |
struct FactPromptView: View { | |
@Environment(\.appInteractor) var appInteractor | |
@StateObject var model: FactPromptViewModel | |
var body: some View { | |
VStack(alignment: .leading, spacing: 16) { | |
VStack(alignment: .leading, spacing: 12) { | |
HStack { | |
Image(systemName: "info.circle.fill") | |
Text("Fact") | |
} | |
.font(.title3.bold()) | |
if model.factPrompt.isLoading { | |
ProgressView() | |
} else { | |
Text(model.factPrompt.fact) | |
} | |
} | |
HStack(spacing: 12) { | |
Button("Get another fact") { | |
appInteractor.fectchFactPrompt(count: model.factPrompt.count) | |
} | |
Button("Dismiss", action: appInteractor.dismissAlert) | |
} | |
} | |
.padding() | |
.frame(maxWidth: .infinity, alignment: .leading) | |
.background(Color.white) | |
.cornerRadius(8) | |
.shadow(color: .black.opacity(0.1), radius: 20) | |
.padding() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment