From 4197d79d240a0398ff1ed021f6289396f09d73c3 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Wed, 31 Aug 2022 14:55:30 +0100 Subject: [PATCH] Move app launch logic to AppFeature --- Examples/xx-messenger/Package.swift | 2 - .../AppFeature/AppEnvironment+Live.swift | 32 ++-- .../Sources/AppFeature/AppFeature.swift | 99 +++++++++-- .../Sources/AppFeature/AppView.swift | 97 +++++++++-- .../AppFeatureTests/AppFeatureTests.swift | 160 +++++++++++++++++- 5 files changed, 333 insertions(+), 57 deletions(-) diff --git a/Examples/xx-messenger/Package.swift b/Examples/xx-messenger/Package.swift index 5eb7bede..b91c4340 100644 --- a/Examples/xx-messenger/Package.swift +++ b/Examples/xx-messenger/Package.swift @@ -69,8 +69,6 @@ let package = Package( dependencies: [ .target(name: "AppCore"), .target(name: "HomeFeature"), - .target(name: "LaunchFeature"), - .target(name: "RegisterFeature"), .target(name: "RestoreFeature"), .target(name: "WelcomeFeature"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), diff --git a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift index ff97f803..eec6f199 100644 --- a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift +++ b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift @@ -1,7 +1,6 @@ import AppCore import Foundation import HomeFeature -import LaunchFeature import RegisterFeature import RestoreFeature import WelcomeFeature @@ -17,31 +16,20 @@ extension AppEnvironment { let bgQueue = DispatchQueue.global(qos: .background).eraseToAnyScheduler() return AppEnvironment( - launch: { - LaunchEnvironment( - dbManager: dbManager, + dbManager: dbManager, + messenger: messenger, + mainQueue: mainQueue, + bgQueue: bgQueue, + welcome: { + WelcomeEnvironment( messenger: messenger, mainQueue: mainQueue, - bgQueue: bgQueue, - welcome: { - WelcomeEnvironment( - messenger: messenger, - mainQueue: mainQueue, - bgQueue: bgQueue - ) - }, - restore: { - RestoreEnvironment() - }, - register: { - RegisterEnvironment( - messenger: messenger, - mainQueue: mainQueue, - bgQueue: bgQueue - ) - } + bgQueue: bgQueue ) }, + restore: { + RestoreEnvironment() + }, home: { HomeEnvironment( messenger: messenger, diff --git a/Examples/xx-messenger/Sources/AppFeature/AppFeature.swift b/Examples/xx-messenger/Sources/AppFeature/AppFeature.swift index 18927c0b..535d69fc 100644 --- a/Examples/xx-messenger/Sources/AppFeature/AppFeature.swift +++ b/Examples/xx-messenger/Sources/AppFeature/AppFeature.swift @@ -1,21 +1,33 @@ +import AppCore +import Combine import ComposableArchitecture import ComposablePresentation +import Foundation import HomeFeature -import LaunchFeature +import RestoreFeature +import WelcomeFeature +import XXMessengerClient struct AppState: Equatable { enum Screen: Equatable { - case launch(LaunchState) + case loading + case welcome(WelcomeState) + case restore(RestoreState) case home(HomeState) + case failure(String) } - var screen: Screen = .launch(LaunchState()) + @BindableState var screen: Screen = .loading } extension AppState.Screen { - var asLaunch: LaunchState? { - get { (/AppState.Screen.launch).extract(from: self) } - set { if let newValue = newValue { self = .launch(newValue) } } + var asWelcome: WelcomeState? { + get { (/AppState.Screen.welcome).extract(from: self) } + set { if let newValue = newValue { self = .welcome(newValue) } } + } + var asRestore: RestoreState? { + get { (/AppState.Screen.restore).extract(from: self) } + set { if let state = newValue { self = .restore(state) } } } var asHome: HomeState? { get { (/AppState.Screen.home).extract(from: self) } @@ -23,19 +35,32 @@ extension AppState.Screen { } } -enum AppAction: Equatable { +enum AppAction: Equatable, BindableAction { + case start + case binding(BindingAction<AppState>) + case welcome(WelcomeAction) + case restore(RestoreAction) case home(HomeAction) - case launch(LaunchAction) } struct AppEnvironment { - var launch: () -> LaunchEnvironment + var dbManager: DBManager + var messenger: Messenger + var mainQueue: AnySchedulerOf<DispatchQueue> + var bgQueue: AnySchedulerOf<DispatchQueue> + var welcome: () -> WelcomeEnvironment + var restore: () -> RestoreEnvironment var home: () -> HomeEnvironment } extension AppEnvironment { static let unimplemented = AppEnvironment( - launch: { .unimplemented }, + dbManager: .unimplemented, + messenger: .unimplemented, + mainQueue: .unimplemented, + bgQueue: .unimplemented, + welcome: { .unimplemented }, + restore: { .unimplemented }, home: { .unimplemented } ) } @@ -43,20 +68,60 @@ extension AppEnvironment { let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, env in switch action { - case .launch(.finished): - state.screen = .home(HomeState()) + case .start, .welcome(.finished), .restore(.finished): + state.screen = .loading + return .run { subscriber in + do { + if env.dbManager.hasDB() == false { + try env.dbManager.makeDB() + } + + if env.messenger.isLoaded() == false { + if env.messenger.isCreated() == false { + subscriber.send(.set(\.$screen, .welcome(WelcomeState()))) + subscriber.send(completion: .finished) + return AnyCancellable {} + } + try env.messenger.load() + } + + subscriber.send(.set(\.$screen, .home(HomeState()))) + } catch { + subscriber.send(.set(\.$screen, .failure(error.localizedDescription))) + } + subscriber.send(completion: .finished) + return AnyCancellable {} + } + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .welcome(.restoreTapped): + state.screen = .restore(RestoreState()) return .none - case .launch(_), .home(_): + case .welcome(.failed(let failure)): + state.screen = .failure(failure) + return .none + + case .binding(_), .welcome(_), .restore(_), .home(_): return .none } } +.binding() +.presenting( + welcomeReducer, + state: .keyPath(\.screen.asWelcome), + id: .notNil(), + action: /AppAction.welcome, + environment: { $0.welcome() } +) .presenting( - launchReducer, - state: .keyPath(\.screen.asLaunch), + restoreReducer, + state: .keyPath(\.screen.asRestore), id: .notNil(), - action: /AppAction.launch, - environment: { $0.launch() } + action: /AppAction.restore, + environment: { $0.restore() } ) .presenting( homeReducer, diff --git a/Examples/xx-messenger/Sources/AppFeature/AppView.swift b/Examples/xx-messenger/Sources/AppFeature/AppView.swift index 317560a3..2af6d352 100644 --- a/Examples/xx-messenger/Sources/AppFeature/AppView.swift +++ b/Examples/xx-messenger/Sources/AppFeature/AppView.swift @@ -1,19 +1,26 @@ import ComposableArchitecture -import SwiftUI import HomeFeature -import LaunchFeature +import RestoreFeature +import SwiftUI +import WelcomeFeature struct AppView: View { let store: Store<AppState, AppAction> enum ViewState: Equatable { - case launch + case loading + case welcome + case restore case home + case failure(String) init(_ state: AppState) { switch state.screen { - case .launch(_): self = .launch + case .loading: self = .loading + case .welcome(_): self = .welcome + case .restore(_): self = .restore case .home(_): self = .home + case .failure(let failure): self = .failure(failure) } } } @@ -21,20 +28,53 @@ struct AppView: View { var body: some View { WithViewStore(store.scope(state: ViewState.init)) { viewStore in ZStack { - SwitchStore(store.scope(state: \.screen)) { - CaseLet( - state: /AppState.Screen.launch, - action: AppAction.launch, + switch viewStore.state { + case .loading: + ProgressView { + Text("Loading") + } + .controlSize(.large) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.opacity) + + case .welcome: + IfLetStore( + store.scope( + state: { (/AppState.Screen.welcome).extract(from: $0.screen) }, + action: AppAction.welcome + ), + then: { store in + WelcomeView(store: store) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .opacity + )) + } + ) + + case .restore: + IfLetStore( + store.scope( + state: { (/AppState.Screen.restore).extract(from: $0.screen) }, + action: AppAction.restore + ), then: { store in - LaunchView(store: store) + RestoreView(store: store) .frame(maxWidth: .infinity, maxHeight: .infinity) - .transition(.opacity) + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .opacity + )) } ) - CaseLet( - state: /AppState.Screen.home, - action: AppAction.home, + case .home: + IfLetStore( + store.scope( + state: { (/AppState.Screen.home).extract(from: $0.screen) }, + action: AppAction.home + ), then: { store in HomeView(store: store) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -44,9 +84,40 @@ struct AppView: View { )) } ) + + case .failure(let failure): + NavigationView { + VStack(spacing: 0) { + ScrollView { + Text(failure) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + + Divider() + + Button { + viewStore.send(.start) + } label: { + Text("Retry") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding() + } + .navigationTitle("Error") + } + .navigationViewStyle(.stack) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .opacity + )) } } .animation(.default, value: viewStore.state) + .task { viewStore.send(.start) } } } } diff --git a/Examples/xx-messenger/Tests/AppFeatureTests/AppFeatureTests.swift b/Examples/xx-messenger/Tests/AppFeatureTests/AppFeatureTests.swift index 6563da86..166b6325 100644 --- a/Examples/xx-messenger/Tests/AppFeatureTests/AppFeatureTests.swift +++ b/Examples/xx-messenger/Tests/AppFeatureTests/AppFeatureTests.swift @@ -1,19 +1,173 @@ import ComposableArchitecture import HomeFeature +import RestoreFeature +import WelcomeFeature import XCTest @testable import AppFeature -@MainActor final class AppFeatureTests: XCTestCase { - func testLaunchFinished() async throws { + func testStartWithoutMessengerCreated() { let store = TestStore( initialState: AppState(), reducer: appReducer, environment: .unimplemented ) - await store.send(.launch(.finished)) { + let mainQueue = DispatchQueue.test + let bgQueue = DispatchQueue.test + var didMakeDB = 0 + + store.environment.mainQueue = mainQueue.eraseToAnyScheduler() + store.environment.bgQueue = bgQueue.eraseToAnyScheduler() + store.environment.dbManager.hasDB.run = { false } + store.environment.dbManager.makeDB.run = { didMakeDB += 1 } + store.environment.messenger.isLoaded.run = { false } + store.environment.messenger.isCreated.run = { false } + + store.send(.start) + + bgQueue.advance() + + XCTAssertNoDifference(didMakeDB, 1) + + mainQueue.advance() + + store.receive(.set(\.$screen, .welcome(WelcomeState()))) { + $0.screen = .welcome(WelcomeState()) + } + } + + func testStartWithMessengerCreated() { + let store = TestStore( + initialState: AppState(), + reducer: appReducer, + environment: .unimplemented + ) + + let mainQueue = DispatchQueue.test + let bgQueue = DispatchQueue.test + var didMakeDB = 0 + var messengerDidLoad = 0 + + store.environment.mainQueue = mainQueue.eraseToAnyScheduler() + store.environment.bgQueue = bgQueue.eraseToAnyScheduler() + store.environment.dbManager.hasDB.run = { false } + store.environment.dbManager.makeDB.run = { didMakeDB += 1 } + store.environment.messenger.isLoaded.run = { false } + store.environment.messenger.isCreated.run = { true } + store.environment.messenger.load.run = { messengerDidLoad += 1 } + + store.send(.start) + + bgQueue.advance() + + XCTAssertNoDifference(didMakeDB, 1) + XCTAssertNoDifference(messengerDidLoad, 1) + + mainQueue.advance() + + store.receive(.set(\.$screen, .home(HomeState()))) { + $0.screen = .home(HomeState()) + } + } + + func testWelcomeFinished() { + let store = TestStore( + initialState: AppState( + screen: .welcome(WelcomeState()) + ), + reducer: appReducer, + environment: .unimplemented + ) + + let mainQueue = DispatchQueue.test + let bgQueue = DispatchQueue.test + var messengerDidLoad = 0 + + store.environment.mainQueue = mainQueue.eraseToAnyScheduler() + store.environment.bgQueue = bgQueue.eraseToAnyScheduler() + store.environment.dbManager.hasDB.run = { true } + store.environment.messenger.isLoaded.run = { false } + store.environment.messenger.isCreated.run = { true } + store.environment.messenger.load.run = { messengerDidLoad += 1 } + + store.send(.welcome(.finished)) { + $0.screen = .loading + } + + bgQueue.advance() + + XCTAssertNoDifference(messengerDidLoad, 1) + + mainQueue.advance() + + store.receive(.set(\.$screen, .home(HomeState()))) { + $0.screen = .home(HomeState()) + } + } + + func testRestoreFinished() { + let store = TestStore( + initialState: AppState( + screen: .restore(RestoreState()) + ), + reducer: appReducer, + environment: .unimplemented + ) + + let mainQueue = DispatchQueue.test + let bgQueue = DispatchQueue.test + var messengerDidLoad = 0 + + store.environment.mainQueue = mainQueue.eraseToAnyScheduler() + store.environment.bgQueue = bgQueue.eraseToAnyScheduler() + store.environment.dbManager.hasDB.run = { true } + store.environment.messenger.isLoaded.run = { false } + store.environment.messenger.isCreated.run = { true } + store.environment.messenger.load.run = { messengerDidLoad += 1 } + + store.send(.restore(.finished)) { + $0.screen = .loading + } + + bgQueue.advance() + + XCTAssertNoDifference(messengerDidLoad, 1) + + mainQueue.advance() + + store.receive(.set(\.$screen, .home(HomeState()))) { $0.screen = .home(HomeState()) } } + + func testWelcomeRestoreTapped() { + let store = TestStore( + initialState: AppState( + screen: .welcome(WelcomeState()) + ), + reducer: appReducer, + environment: .unimplemented + ) + + store.send(.welcome(.restoreTapped)) { + $0.screen = .restore(RestoreState()) + } + } + + func testWelcomeFailed() { + let store = TestStore( + initialState: AppState( + screen: .welcome(WelcomeState()) + ), + reducer: appReducer, + environment: .unimplemented + ) + + let failure = "Something went wrong" + + store.send(.welcome(.failed(failure))) { + $0.screen = .failure(failure) + } + } } -- GitLab