Skip to content

Instantly share code, notes, and snippets.

@garsdle
Last active August 5, 2021 17:45
Show Gist options
  • Save garsdle/89755235d4d5f31326d0a467e8196314 to your computer and use it in GitHub Desktop.
Save garsdle/89755235d4d5f31326d0a467e8196314 to your computer and use it in GitHub Desktop.
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