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 @@
"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",
"kind" : "remoteSourceControl",
......
......@@ -31,6 +31,10 @@ let package = Package(
url: "https://github.com/darrarski/swift-composable-presentation.git",
.upToNextMajor(from: "0.5.2")
),
.package(
url: "https://github.com/kishikawakatsumi/KeychainAccess.git",
.upToNextMajor(from: "4.2.2")
),
],
targets: [
.target(
......@@ -50,6 +54,10 @@ let package = Package(
name: "ComposablePresentation",
package: "swift-composable-presentation"
),
.product(
name: "KeychainAccess",
package: "KeychainAccess"
),
]
),
.testTarget(
......@@ -65,6 +73,10 @@ let package = Package(
name: "ComposableArchitecture",
package: "swift-composable-architecture"
),
.product(
name: "ElixxirDAppsSDK",
package: "elixxir-dapps-sdk-swift"
),
]
),
.testTarget(
......
import Combine
import ComposableArchitecture
import ElixxirDAppsSDK
import LandingFeature
import SessionFeature
import SwiftUI
......@@ -18,8 +20,24 @@ struct App: SwiftUI.App {
extension AppEnvironment {
static func live() -> AppEnvironment {
AppEnvironment(
landing: LandingEnvironment(),
let clientSubject = CurrentValueSubject<Client?, Never>(nil)
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()
)
}
......
import Combine
import ComposableArchitecture
import ComposablePresentation
import LandingFeature
......@@ -38,11 +39,14 @@ extension AppState.Scene {
enum AppAction: Equatable {
case viewDidLoad
case clientDidChange(hasClient: Bool)
case landing(LandingAction)
case session(SessionAction)
}
struct AppEnvironment {
var hasClient: AnyPublisher<Bool, Never>
var mainScheduler: AnySchedulerOf<DispatchQueue>
var landing: LandingEnvironment
var session: SessionEnvironment
}
......@@ -51,6 +55,22 @@ let appReducer = Reducer<AppState, AppAction, AppEnvironment>
{ state, action, env in
switch action {
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
case .landing(_), .session(_):
......@@ -75,6 +95,8 @@ let appReducer = Reducer<AppState, AppAction, AppEnvironment>
#if DEBUG
extension AppEnvironment {
static let failing = AppEnvironment(
hasClient: Empty().eraseToAnyPublisher(),
mainScheduler: .failing,
landing: .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 ElixxirDAppsSDK
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 {
case viewDidLoad
case makeClient
case didMakeClient
case didFailMakingClient(NSError)
case removeStoredClient
case didRemoveStoredClient
case didFailRemovingStoredClient(NSError)
}
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>
{ state, action, env in
switch action {
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
}
}
#if DEBUG
extension LandingEnvironment {
public static let failing = LandingEnvironment()
public static let failing = LandingEnvironment(
clientStorage: .failing,
setClient: { _ in fatalError() },
bgScheduler: .failing,
mainScheduler: .failing
)
}
#endif
......@@ -9,12 +9,53 @@ public struct LandingView: View {
let store: Store<LandingState, LandingAction>
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 {
WithViewStore(store.scope(state: ViewState.init)) { viewStore in
Text("LandingView")
Form {
Button {
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 {
public var body: some View {
WithViewStore(store.scope(state: ViewState.init)) { viewStore in
Text("SessionView")
.navigationTitle("Session")
.task {
viewStore.send(.viewDidLoad)
}
......
import Combine
import ComposableArchitecture
import LandingFeature
import SessionFeature
import XCTest
@testable import AppFeature
final class AppFeatureTests: XCTestCase {
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(
initialState: AppState(),
reducer: appReducer,
environment: .failing
environment: env
)
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
final class LandingFeatureTests: XCTestCase {
func testViewDidLoad() throws {
var env = LandingEnvironment.failing
env.clientStorage.hasStoredClient = { true }
let store = TestStore(
initialState: LandingState(),
reducer: landingReducer,
environment: .failing
environment: env
)
store.send(.viewDidLoad)
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(
initialState: LandingState(),
reducer: landingReducer,
environment: env
)
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.
Please register or to comment