Skip to content
Snippets Groups Projects
Commit 4197d79d authored by Dariusz Rybicki's avatar Dariusz Rybicki
Browse files

Move app launch logic to AppFeature

parent f1f148bb
No related branches found
No related tags found
2 merge requests!102Release 1.0.0,!57Update messenger example
...@@ -69,8 +69,6 @@ let package = Package( ...@@ -69,8 +69,6 @@ let package = Package(
dependencies: [ dependencies: [
.target(name: "AppCore"), .target(name: "AppCore"),
.target(name: "HomeFeature"), .target(name: "HomeFeature"),
.target(name: "LaunchFeature"),
.target(name: "RegisterFeature"),
.target(name: "RestoreFeature"), .target(name: "RestoreFeature"),
.target(name: "WelcomeFeature"), .target(name: "WelcomeFeature"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
......
import AppCore import AppCore
import Foundation import Foundation
import HomeFeature import HomeFeature
import LaunchFeature
import RegisterFeature import RegisterFeature
import RestoreFeature import RestoreFeature
import WelcomeFeature import WelcomeFeature
...@@ -17,31 +16,20 @@ extension AppEnvironment { ...@@ -17,31 +16,20 @@ extension AppEnvironment {
let bgQueue = DispatchQueue.global(qos: .background).eraseToAnyScheduler() let bgQueue = DispatchQueue.global(qos: .background).eraseToAnyScheduler()
return AppEnvironment( return AppEnvironment(
launch: { dbManager: dbManager,
LaunchEnvironment( messenger: messenger,
dbManager: dbManager, mainQueue: mainQueue,
bgQueue: bgQueue,
welcome: {
WelcomeEnvironment(
messenger: messenger, messenger: messenger,
mainQueue: mainQueue, mainQueue: mainQueue,
bgQueue: bgQueue, bgQueue: bgQueue
welcome: {
WelcomeEnvironment(
messenger: messenger,
mainQueue: mainQueue,
bgQueue: bgQueue
)
},
restore: {
RestoreEnvironment()
},
register: {
RegisterEnvironment(
messenger: messenger,
mainQueue: mainQueue,
bgQueue: bgQueue
)
}
) )
}, },
restore: {
RestoreEnvironment()
},
home: { home: {
HomeEnvironment( HomeEnvironment(
messenger: messenger, messenger: messenger,
......
import AppCore
import Combine
import ComposableArchitecture import ComposableArchitecture
import ComposablePresentation import ComposablePresentation
import Foundation
import HomeFeature import HomeFeature
import LaunchFeature import RestoreFeature
import WelcomeFeature
import XXMessengerClient
struct AppState: Equatable { struct AppState: Equatable {
enum Screen: Equatable { enum Screen: Equatable {
case launch(LaunchState) case loading
case welcome(WelcomeState)
case restore(RestoreState)
case home(HomeState) case home(HomeState)
case failure(String)
} }
var screen: Screen = .launch(LaunchState()) @BindableState var screen: Screen = .loading
} }
extension AppState.Screen { extension AppState.Screen {
var asLaunch: LaunchState? { var asWelcome: WelcomeState? {
get { (/AppState.Screen.launch).extract(from: self) } get { (/AppState.Screen.welcome).extract(from: self) }
set { if let newValue = newValue { self = .launch(newValue) } } 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? { var asHome: HomeState? {
get { (/AppState.Screen.home).extract(from: self) } get { (/AppState.Screen.home).extract(from: self) }
...@@ -23,19 +35,32 @@ extension AppState.Screen { ...@@ -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 home(HomeAction)
case launch(LaunchAction)
} }
struct AppEnvironment { 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 var home: () -> HomeEnvironment
} }
extension AppEnvironment { extension AppEnvironment {
static let unimplemented = AppEnvironment( static let unimplemented = AppEnvironment(
launch: { .unimplemented }, dbManager: .unimplemented,
messenger: .unimplemented,
mainQueue: .unimplemented,
bgQueue: .unimplemented,
welcome: { .unimplemented },
restore: { .unimplemented },
home: { .unimplemented } home: { .unimplemented }
) )
} }
...@@ -43,20 +68,60 @@ extension AppEnvironment { ...@@ -43,20 +68,60 @@ extension AppEnvironment {
let appReducer = Reducer<AppState, AppAction, AppEnvironment> let appReducer = Reducer<AppState, AppAction, AppEnvironment>
{ state, action, env in { state, action, env in
switch action { switch action {
case .launch(.finished): case .start, .welcome(.finished), .restore(.finished):
state.screen = .home(HomeState()) 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 return .none
case .launch(_), .home(_): case .welcome(.failed(let failure)):
state.screen = .failure(failure)
return .none
case .binding(_), .welcome(_), .restore(_), .home(_):
return .none return .none
} }
} }
.binding()
.presenting(
welcomeReducer,
state: .keyPath(\.screen.asWelcome),
id: .notNil(),
action: /AppAction.welcome,
environment: { $0.welcome() }
)
.presenting( .presenting(
launchReducer, restoreReducer,
state: .keyPath(\.screen.asLaunch), state: .keyPath(\.screen.asRestore),
id: .notNil(), id: .notNil(),
action: /AppAction.launch, action: /AppAction.restore,
environment: { $0.launch() } environment: { $0.restore() }
) )
.presenting( .presenting(
homeReducer, homeReducer,
......
import ComposableArchitecture import ComposableArchitecture
import SwiftUI
import HomeFeature import HomeFeature
import LaunchFeature import RestoreFeature
import SwiftUI
import WelcomeFeature
struct AppView: View { struct AppView: View {
let store: Store<AppState, AppAction> let store: Store<AppState, AppAction>
enum ViewState: Equatable { enum ViewState: Equatable {
case launch case loading
case welcome
case restore
case home case home
case failure(String)
init(_ state: AppState) { init(_ state: AppState) {
switch state.screen { switch state.screen {
case .launch(_): self = .launch case .loading: self = .loading
case .welcome(_): self = .welcome
case .restore(_): self = .restore
case .home(_): self = .home case .home(_): self = .home
case .failure(let failure): self = .failure(failure)
} }
} }
} }
...@@ -21,20 +28,53 @@ struct AppView: View { ...@@ -21,20 +28,53 @@ struct AppView: View {
var body: some View { var body: some View {
WithViewStore(store.scope(state: ViewState.init)) { viewStore in WithViewStore(store.scope(state: ViewState.init)) { viewStore in
ZStack { ZStack {
SwitchStore(store.scope(state: \.screen)) { switch viewStore.state {
CaseLet( case .loading:
state: /AppState.Screen.launch, ProgressView {
action: AppAction.launch, 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 then: { store in
LaunchView(store: store) RestoreView(store: store)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.transition(.opacity) .transition(.asymmetric(
insertion: .move(edge: .trailing),
removal: .opacity
))
} }
) )
CaseLet( case .home:
state: /AppState.Screen.home, IfLetStore(
action: AppAction.home, store.scope(
state: { (/AppState.Screen.home).extract(from: $0.screen) },
action: AppAction.home
),
then: { store in then: { store in
HomeView(store: store) HomeView(store: store)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
...@@ -44,9 +84,40 @@ struct AppView: View { ...@@ -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) .animation(.default, value: viewStore.state)
.task { viewStore.send(.start) }
} }
} }
} }
......
import ComposableArchitecture import ComposableArchitecture
import HomeFeature import HomeFeature
import RestoreFeature
import WelcomeFeature
import XCTest import XCTest
@testable import AppFeature @testable import AppFeature
@MainActor
final class AppFeatureTests: XCTestCase { final class AppFeatureTests: XCTestCase {
func testLaunchFinished() async throws { func testStartWithoutMessengerCreated() {
let store = TestStore( let store = TestStore(
initialState: AppState(), initialState: AppState(),
reducer: appReducer, reducer: appReducer,
environment: .unimplemented 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()) $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)
}
}
} }
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment