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

Implement creating client

- create and store new client
- load stored client
- remove stored client
parent 717b8588
No related branches found
No related tags found
1 merge request!1Client management
Showing with 429 additions and 12 deletions
...@@ -9,6 +9,15 @@ ...@@ -9,6 +9,15 @@
"version" : "0.5.3" "version" : "0.5.3"
} }
}, },
{
"identity" : "keychainaccess",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kishikawakatsumi/KeychainAccess.git",
"state" : {
"revision" : "84e546727d66f1adc5439debad16270d0fdd04e7",
"version" : "4.2.2"
}
},
{ {
"identity" : "swift-case-paths", "identity" : "swift-case-paths",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
......
...@@ -31,6 +31,10 @@ let package = Package( ...@@ -31,6 +31,10 @@ let package = Package(
url: "https://github.com/darrarski/swift-composable-presentation.git", url: "https://github.com/darrarski/swift-composable-presentation.git",
.upToNextMajor(from: "0.5.2") .upToNextMajor(from: "0.5.2")
), ),
.package(
url: "https://github.com/kishikawakatsumi/KeychainAccess.git",
.upToNextMajor(from: "4.2.2")
),
], ],
targets: [ targets: [
.target( .target(
...@@ -50,6 +54,10 @@ let package = Package( ...@@ -50,6 +54,10 @@ let package = Package(
name: "ComposablePresentation", name: "ComposablePresentation",
package: "swift-composable-presentation" package: "swift-composable-presentation"
), ),
.product(
name: "KeychainAccess",
package: "KeychainAccess"
),
] ]
), ),
.testTarget( .testTarget(
...@@ -65,6 +73,10 @@ let package = Package( ...@@ -65,6 +73,10 @@ let package = Package(
name: "ComposableArchitecture", name: "ComposableArchitecture",
package: "swift-composable-architecture" package: "swift-composable-architecture"
), ),
.product(
name: "ElixxirDAppsSDK",
package: "elixxir-dapps-sdk-swift"
),
] ]
), ),
.testTarget( .testTarget(
......
import Combine
import ComposableArchitecture import ComposableArchitecture
import ElixxirDAppsSDK
import LandingFeature import LandingFeature
import SessionFeature import SessionFeature
import SwiftUI import SwiftUI
...@@ -18,8 +20,24 @@ struct App: SwiftUI.App { ...@@ -18,8 +20,24 @@ struct App: SwiftUI.App {
extension AppEnvironment { extension AppEnvironment {
static func live() -> AppEnvironment { static func live() -> AppEnvironment {
AppEnvironment( let clientSubject = CurrentValueSubject<Client?, Never>(nil)
landing: LandingEnvironment(), let mainScheduler = DispatchQueue.main.eraseToAnyScheduler()
let bgScheduler = DispatchQueue(
label: "xx.network.dApps.ExampleApp.bg",
qos: .background
).eraseToAnyScheduler()
return AppEnvironment(
hasClient: clientSubject.map { $0 != nil }.eraseToAnyPublisher(),
mainScheduler: mainScheduler,
landing: LandingEnvironment(
clientStorage: .live(
passwordStorage: .keychain
),
setClient: { clientSubject.send($0) },
bgScheduler: bgScheduler,
mainScheduler: mainScheduler
),
session: SessionEnvironment() session: SessionEnvironment()
) )
} }
......
import Combine
import ComposableArchitecture import ComposableArchitecture
import ComposablePresentation import ComposablePresentation
import LandingFeature import LandingFeature
...@@ -38,11 +39,14 @@ extension AppState.Scene { ...@@ -38,11 +39,14 @@ extension AppState.Scene {
enum AppAction: Equatable { enum AppAction: Equatable {
case viewDidLoad case viewDidLoad
case clientDidChange(hasClient: Bool)
case landing(LandingAction) case landing(LandingAction)
case session(SessionAction) case session(SessionAction)
} }
struct AppEnvironment { struct AppEnvironment {
var hasClient: AnyPublisher<Bool, Never>
var mainScheduler: AnySchedulerOf<DispatchQueue>
var landing: LandingEnvironment var landing: LandingEnvironment
var session: SessionEnvironment var session: SessionEnvironment
} }
...@@ -51,6 +55,22 @@ let appReducer = Reducer<AppState, AppAction, AppEnvironment> ...@@ -51,6 +55,22 @@ let appReducer = Reducer<AppState, AppAction, AppEnvironment>
{ state, action, env in { state, action, env in
switch action { switch action {
case .viewDidLoad: case .viewDidLoad:
struct HasClientEffectId: Hashable {}
return env.hasClient
.removeDuplicates()
.map(AppAction.clientDidChange(hasClient:))
.receive(on: env.mainScheduler)
.eraseToEffect()
.cancellable(id: HasClientEffectId(), cancelInFlight: true)
case .clientDidChange(let hasClient):
if hasClient {
let sessionState = state.scene.asSession ?? SessionState()
state.scene = .session(sessionState)
} else {
let landingState = state.scene.asLanding ?? LandingState()
state.scene = .landing(landingState)
}
return .none return .none
case .landing(_), .session(_): case .landing(_), .session(_):
...@@ -75,6 +95,8 @@ let appReducer = Reducer<AppState, AppAction, AppEnvironment> ...@@ -75,6 +95,8 @@ let appReducer = Reducer<AppState, AppAction, AppEnvironment>
#if DEBUG #if DEBUG
extension AppEnvironment { extension AppEnvironment {
static let failing = AppEnvironment( static let failing = AppEnvironment(
hasClient: Empty().eraseToAnyPublisher(),
mainScheduler: .failing,
landing: .failing, landing: .failing,
session: .failing session: .failing
) )
......
import ElixxirDAppsSDK
import KeychainAccess
extension PasswordStorage {
static let keychain: PasswordStorage = {
let keychain = KeychainAccess.Keychain(
service: "xx.network.client"
)
return PasswordStorage(
save: { password in keychain[data: "password"] = password},
load: { try keychain[data: "password"] ?? { throw PasswordStorageMissingPasswordError() }() }
)
}()
}
import Combine
import ComposableArchitecture import ComposableArchitecture
import ElixxirDAppsSDK
public struct LandingState: Equatable { public struct LandingState: Equatable {
public init() {} public init(
hasStoredClient: Bool = false,
isMakingClient: Bool = false,
isRemovingClient: Bool = false
) {
self.hasStoredClient = hasStoredClient
self.isMakingClient = isMakingClient
self.isRemovingClient = isRemovingClient
}
var hasStoredClient: Bool
var isMakingClient: Bool
var isRemovingClient: Bool
} }
public enum LandingAction: Equatable { public enum LandingAction: Equatable {
case viewDidLoad case viewDidLoad
case makeClient
case didMakeClient
case didFailMakingClient(NSError)
case removeStoredClient
case didRemoveStoredClient
case didFailRemovingStoredClient(NSError)
} }
public struct LandingEnvironment { public struct LandingEnvironment {
public init() {} public init(
clientStorage: ClientStorage,
setClient: @escaping (Client) -> Void,
bgScheduler: AnySchedulerOf<DispatchQueue>,
mainScheduler: AnySchedulerOf<DispatchQueue>
) {
self.clientStorage = clientStorage
self.setClient = setClient
self.bgScheduler = bgScheduler
self.mainScheduler = mainScheduler
}
public var clientStorage: ClientStorage
public var setClient: (Client) -> Void
public var bgScheduler: AnySchedulerOf<DispatchQueue>
public var mainScheduler: AnySchedulerOf<DispatchQueue>
} }
public let landingReducer = Reducer<LandingState, LandingAction, LandingEnvironment> public let landingReducer = Reducer<LandingState, LandingAction, LandingEnvironment>
{ state, action, env in { state, action, env in
switch action { switch action {
case .viewDidLoad: case .viewDidLoad:
state.hasStoredClient = env.clientStorage.hasStoredClient()
return .none
case .makeClient:
state.isMakingClient = true
return Effect.future { fulfill in
do {
if env.clientStorage.hasStoredClient() {
env.setClient(try env.clientStorage.loadClient())
} else {
env.setClient(try env.clientStorage.createClient())
}
fulfill(.success(.didMakeClient))
} catch {
fulfill(.success(.didFailMakingClient(error as NSError)))
}
}
.subscribe(on: env.bgScheduler)
.receive(on: env.mainScheduler)
.eraseToEffect()
case .didMakeClient:
state.isMakingClient = false
state.hasStoredClient = env.clientStorage.hasStoredClient()
return .none
case .didFailMakingClient(let error):
state.isMakingClient = false
state.hasStoredClient = env.clientStorage.hasStoredClient()
// TODO: handle error
return .none
case .removeStoredClient:
state.isRemovingClient = true
return Effect.future { fulfill in
do {
try env.clientStorage.removeClient()
fulfill(.success(.didRemoveStoredClient))
} catch {
fulfill(.success(.didFailRemovingStoredClient(error as NSError)))
}
}
.subscribe(on: env.bgScheduler)
.receive(on: env.mainScheduler)
.eraseToEffect()
case .didRemoveStoredClient:
state.isRemovingClient = false
state.hasStoredClient = env.clientStorage.hasStoredClient()
return .none
case .didFailRemovingStoredClient(let error):
state.isRemovingClient = false
state.hasStoredClient = env.clientStorage.hasStoredClient()
// TODO: handle error
return .none return .none
} }
} }
#if DEBUG #if DEBUG
extension LandingEnvironment { extension LandingEnvironment {
public static let failing = LandingEnvironment() public static let failing = LandingEnvironment(
clientStorage: .failing,
setClient: { _ in fatalError() },
bgScheduler: .failing,
mainScheduler: .failing
)
} }
#endif #endif
...@@ -9,15 +9,56 @@ public struct LandingView: View { ...@@ -9,15 +9,56 @@ public struct LandingView: View {
let store: Store<LandingState, LandingAction> let store: Store<LandingState, LandingAction>
struct ViewState: Equatable { struct ViewState: Equatable {
init(state: LandingState) {} let hasStoredClient: Bool
let isMakingClient: Bool
let isRemovingClient: Bool
init(state: LandingState) {
hasStoredClient = state.hasStoredClient
isMakingClient = state.isMakingClient
isRemovingClient = state.isRemovingClient
}
var isLoading: Bool {
isMakingClient ||
isRemovingClient
}
} }
public var body: some View { public var body: some View {
WithViewStore(store.scope(state: ViewState.init)) { viewStore in WithViewStore(store.scope(state: ViewState.init)) { viewStore in
Text("LandingView") Form {
.task { Button {
viewStore.send(.viewDidLoad) viewStore.send(.makeClient)
} label: {
HStack {
Text(viewStore.hasStoredClient ? "Load stored client" : "Create new client")
Spacer()
if viewStore.isMakingClient {
ProgressView()
}
}
}
if viewStore.hasStoredClient {
Button(role: .destructive) {
viewStore.send(.removeStoredClient)
} label: {
HStack {
Text("Remove stored client")
Spacer()
if viewStore.isRemovingClient {
ProgressView()
}
}
}
} }
}
.navigationTitle("Landing")
.disabled(viewStore.isLoading)
.task {
viewStore.send(.viewDidLoad)
}
} }
} }
} }
......
...@@ -15,6 +15,7 @@ public struct SessionView: View { ...@@ -15,6 +15,7 @@ public struct SessionView: View {
public var body: some View { public var body: some View {
WithViewStore(store.scope(state: ViewState.init)) { viewStore in WithViewStore(store.scope(state: ViewState.init)) { viewStore in
Text("SessionView") Text("SessionView")
.navigationTitle("Session")
.task { .task {
viewStore.send(.viewDidLoad) viewStore.send(.viewDidLoad)
} }
......
import Combine
import ComposableArchitecture import ComposableArchitecture
import LandingFeature
import SessionFeature
import XCTest import XCTest
@testable import AppFeature @testable import AppFeature
final class AppFeatureTests: XCTestCase { final class AppFeatureTests: XCTestCase {
func testViewDidLoad() throws { func testViewDidLoad() throws {
let hasClient = PassthroughSubject<Bool, Never>()
let mainScheduler = DispatchQueue.test
var env = AppEnvironment.failing
env.hasClient = hasClient.eraseToAnyPublisher()
env.mainScheduler = mainScheduler.eraseToAnyScheduler()
let store = TestStore( let store = TestStore(
initialState: AppState(), initialState: AppState(),
reducer: appReducer, reducer: appReducer,
environment: .failing environment: env
) )
store.send(.viewDidLoad) store.send(.viewDidLoad)
hasClient.send(false)
mainScheduler.advance()
store.receive(.clientDidChange(hasClient: false))
hasClient.send(true)
mainScheduler.advance()
store.receive(.clientDidChange(hasClient: true)) {
$0.scene = .session(SessionState())
}
hasClient.send(true)
mainScheduler.advance()
hasClient.send(false)
mainScheduler.advance()
store.receive(.clientDidChange(hasClient: false)) {
$0.scene = .landing(LandingState())
}
hasClient.send(completion: .finished)
mainScheduler.advance()
} }
} }
...@@ -4,12 +4,182 @@ import XCTest ...@@ -4,12 +4,182 @@ import XCTest
final class LandingFeatureTests: XCTestCase { final class LandingFeatureTests: XCTestCase {
func testViewDidLoad() throws { func testViewDidLoad() throws {
var env = LandingEnvironment.failing
env.clientStorage.hasStoredClient = { true }
let store = TestStore(
initialState: LandingState(),
reducer: landingReducer,
environment: env
)
store.send(.viewDidLoad) {
$0.hasStoredClient = true
}
}
func testCreateClient() {
var hasStoredClient = false
var didSetClient = false
let bgScheduler = DispatchQueue.test
let mainScheduler = DispatchQueue.test
var env = LandingEnvironment.failing
env.clientStorage.hasStoredClient = { hasStoredClient }
env.clientStorage.createClient = { .failing }
env.setClient = { _ in didSetClient = true }
env.bgScheduler = bgScheduler.eraseToAnyScheduler()
env.mainScheduler = mainScheduler.eraseToAnyScheduler()
let store = TestStore(
initialState: LandingState(),
reducer: landingReducer,
environment: env
)
store.send(.makeClient) {
$0.isMakingClient = true
}
bgScheduler.advance()
XCTAssertTrue(didSetClient)
hasStoredClient = true
mainScheduler.advance()
store.receive(.didMakeClient) {
$0.isMakingClient = false
$0.hasStoredClient = true
}
}
func testLoadStoredClient() {
var didSetClient = false
let bgScheduler = DispatchQueue.test
let mainScheduler = DispatchQueue.test
var env = LandingEnvironment.failing
env.clientStorage.hasStoredClient = { true }
env.clientStorage.loadClient = { .failing }
env.setClient = { _ in didSetClient = true }
env.bgScheduler = bgScheduler.eraseToAnyScheduler()
env.mainScheduler = mainScheduler.eraseToAnyScheduler()
let store = TestStore(
initialState: LandingState(),
reducer: landingReducer,
environment: env
)
store.send(.makeClient) {
$0.isMakingClient = true
}
bgScheduler.advance()
XCTAssertTrue(didSetClient)
mainScheduler.advance()
store.receive(.didMakeClient) {
$0.isMakingClient = false
$0.hasStoredClient = true
}
}
func testMakeClientFailure() {
let error = NSError(domain: "test", code: 1234)
let bgScheduler = DispatchQueue.test
let mainScheduler = DispatchQueue.test
var env = LandingEnvironment.failing
env.clientStorage.hasStoredClient = { false }
env.clientStorage.createClient = { throw error }
env.bgScheduler = bgScheduler.eraseToAnyScheduler()
env.mainScheduler = mainScheduler.eraseToAnyScheduler()
let store = TestStore(
initialState: LandingState(),
reducer: landingReducer,
environment: env
)
store.send(.makeClient) {
$0.isMakingClient = true
}
bgScheduler.advance()
mainScheduler.advance()
store.receive(.didFailMakingClient(error)) {
$0.isMakingClient = false
$0.hasStoredClient = false
}
}
func testRemoveStoredClient() {
var hasStoredClient = true
var didRemoveClient = false
let bgScheduler = DispatchQueue.test
let mainScheduler = DispatchQueue.test
var env = LandingEnvironment.failing
env.clientStorage.hasStoredClient = { hasStoredClient }
env.clientStorage.removeClient = { didRemoveClient = true }
env.bgScheduler = bgScheduler.eraseToAnyScheduler()
env.mainScheduler = mainScheduler.eraseToAnyScheduler()
let store = TestStore( let store = TestStore(
initialState: LandingState(), initialState: LandingState(),
reducer: landingReducer, reducer: landingReducer,
environment: .failing environment: env
) )
store.send(.viewDidLoad) store.send(.removeStoredClient) {
$0.isRemovingClient = true
}
bgScheduler.advance()
XCTAssertTrue(didRemoveClient)
hasStoredClient = false
mainScheduler.advance()
store.receive(.didRemoveStoredClient) {
$0.isRemovingClient = false
$0.hasStoredClient = false
}
}
func testRemoveStoredClientFailure() {
let error = NSError(domain: "test", code: 1234)
let bgScheduler = DispatchQueue.test
let mainScheduler = DispatchQueue.test
var env = LandingEnvironment.failing
env.clientStorage.hasStoredClient = { true }
env.clientStorage.removeClient = { throw error }
env.bgScheduler = bgScheduler.eraseToAnyScheduler()
env.mainScheduler = mainScheduler.eraseToAnyScheduler()
let store = TestStore(
initialState: LandingState(),
reducer: landingReducer,
environment: env
)
store.send(.removeStoredClient) {
$0.isRemovingClient = true
}
bgScheduler.advance()
mainScheduler.advance()
store.receive(.didFailRemovingStoredClient(error)) {
$0.isRemovingClient = false
$0.hasStoredClient = true
}
} }
} }
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