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

Migrate RestoreFeature to ReducerProtocol

parent 5b6ce6a1
No related branches found
No related tags found
2 merge requests!126Migrate example app to ComposableArchitecture's ReducerProtocol,!102Release 1.0.0
import AppCore
import Combine
import ComposableArchitecture
import Foundation
import XCTestDynamicOverlay
import XXMessengerClient
import XXModels
public struct RestoreComponent: ReducerProtocol {
public struct State: Equatable {
public enum Field: String, Hashable {
case passphrase
}
public struct File: Equatable {
public init(name: String, data: Data) {
self.name = name
self.data = data
}
public var name: String
public var data: Data
}
public init(
file: File? = nil,
fileImportFailure: String? = nil,
restoreFailures: [String] = [],
focusedField: Field? = nil,
isImportingFile: Bool = false,
passphrase: String = "",
isRestoring: Bool = false
) {
self.file = file
self.fileImportFailure = fileImportFailure
self.restoreFailures = restoreFailures
self.focusedField = focusedField
self.isImportingFile = isImportingFile
self.passphrase = passphrase
self.isRestoring = isRestoring
}
public var file: File?
public var fileImportFailure: String?
public var restoreFailures: [String]
@BindableState public var focusedField: Field?
@BindableState public var isImportingFile: Bool
@BindableState public var passphrase: String
@BindableState public var isRestoring: Bool
}
public enum Action: Equatable, BindableAction {
case importFileTapped
case fileImport(Result<URL, NSError>)
case restoreTapped
case finished
case failed([NSError])
case binding(BindingAction<State>)
}
public init() {}
@Dependency(\.app.messenger) var messenger: Messenger
@Dependency(\.app.dbManager.getDB) var db: DBManagerGetDB
@Dependency(\.app.loadData) var loadData: URLDataLoader
@Dependency(\.app.now) var now: () -> Date
@Dependency(\.app.mainQueue) var mainQueue: AnySchedulerOf<DispatchQueue>
@Dependency(\.app.bgQueue) var bgQueue: AnySchedulerOf<DispatchQueue>
public var body: some ReducerProtocol<State, Action> {
BindingReducer()
Reduce { state, action in
switch action {
case .importFileTapped:
state.isImportingFile = true
state.fileImportFailure = nil
return .none
case .fileImport(.success(let url)):
state.isImportingFile = false
do {
state.file = .init(
name: url.lastPathComponent,
data: try loadData(url)
)
state.fileImportFailure = nil
} catch {
state.file = nil
state.fileImportFailure = error.localizedDescription
}
return .none
case .fileImport(.failure(let error)):
state.isImportingFile = false
state.file = nil
state.fileImportFailure = error.localizedDescription
return .none
case .restoreTapped:
guard let backupData = state.file?.data, backupData.count > 0 else { return .none }
let backupPassphrase = state.passphrase
state.isRestoring = true
state.restoreFailures = []
return Effect.result {
do {
let result = try messenger.restoreBackup(
backupData: backupData,
backupPassphrase: backupPassphrase
)
let facts = try messenger.ud.tryGet().getFacts()
try db().saveContact(Contact(
id: try messenger.e2e.tryGet().getContact().getId(),
username: facts.get(.username)?.value,
email: facts.get(.email)?.value,
phone: facts.get(.phone)?.value,
createdAt: now()
))
try result.restoredContacts.forEach { contactId in
if try db().fetchContacts(.init(id: [contactId])).isEmpty {
try db().saveContact(Contact(
id: contactId,
createdAt: now()
))
}
}
return .success(.finished)
} catch {
var errors = [error as NSError]
do {
try messenger.destroy()
} catch {
errors.append(error as NSError)
}
return .success(.failed(errors))
}
}
.subscribe(on: bgQueue)
.receive(on: mainQueue)
.eraseToEffect()
case .finished:
state.isRestoring = false
return .none
case .failed(let errors):
state.isRestoring = false
state.restoreFailures = errors.map(\.localizedDescription)
return .none
case .binding(_):
return .none
}
}
}
}
import AppCore
import Combine
import ComposableArchitecture
import Foundation
import XCTestDynamicOverlay
import XXMessengerClient
import XXModels
public struct RestoreState: Equatable {
public enum Field: String, Hashable {
case passphrase
}
public struct File: Equatable {
public init(name: String, data: Data) {
self.name = name
self.data = data
}
public var name: String
public var data: Data
}
public init(
file: File? = nil,
fileImportFailure: String? = nil,
restoreFailures: [String] = [],
focusedField: Field? = nil,
isImportingFile: Bool = false,
passphrase: String = "",
isRestoring: Bool = false
) {
self.file = file
self.fileImportFailure = fileImportFailure
self.restoreFailures = restoreFailures
self.focusedField = focusedField
self.isImportingFile = isImportingFile
self.passphrase = passphrase
self.isRestoring = isRestoring
}
public var file: File?
public var fileImportFailure: String?
public var restoreFailures: [String]
@BindableState public var focusedField: Field?
@BindableState public var isImportingFile: Bool
@BindableState public var passphrase: String
@BindableState public var isRestoring: Bool
}
public enum RestoreAction: Equatable, BindableAction {
case importFileTapped
case fileImport(Result<URL, NSError>)
case restoreTapped
case finished
case failed([NSError])
case binding(BindingAction<RestoreState>)
}
public struct RestoreEnvironment {
public init(
messenger: Messenger,
db: DBManagerGetDB,
loadData: URLDataLoader,
now: @escaping () -> Date,
mainQueue: AnySchedulerOf<DispatchQueue>,
bgQueue: AnySchedulerOf<DispatchQueue>
) {
self.messenger = messenger
self.db = db
self.loadData = loadData
self.now = now
self.mainQueue = mainQueue
self.bgQueue = bgQueue
}
public var messenger: Messenger
public var db: DBManagerGetDB
public var loadData: URLDataLoader
public var now: () -> Date
public var mainQueue: AnySchedulerOf<DispatchQueue>
public var bgQueue: AnySchedulerOf<DispatchQueue>
}
#if DEBUG
extension RestoreEnvironment {
public static let unimplemented = RestoreEnvironment(
messenger: .unimplemented,
db: .unimplemented,
loadData: .unimplemented,
now: XCTUnimplemented("\(Self.self).now"),
mainQueue: .unimplemented,
bgQueue: .unimplemented
)
}
#endif
public let restoreReducer = Reducer<RestoreState, RestoreAction, RestoreEnvironment>
{ state, action, env in
switch action {
case .importFileTapped:
state.isImportingFile = true
state.fileImportFailure = nil
return .none
case .fileImport(.success(let url)):
state.isImportingFile = false
do {
state.file = .init(
name: url.lastPathComponent,
data: try env.loadData(url)
)
state.fileImportFailure = nil
} catch {
state.file = nil
state.fileImportFailure = error.localizedDescription
}
return .none
case .fileImport(.failure(let error)):
state.isImportingFile = false
state.file = nil
state.fileImportFailure = error.localizedDescription
return .none
case .restoreTapped:
guard let backupData = state.file?.data, backupData.count > 0 else { return .none }
let backupPassphrase = state.passphrase
state.isRestoring = true
state.restoreFailures = []
return Effect.result {
do {
let result = try env.messenger.restoreBackup(
backupData: backupData,
backupPassphrase: backupPassphrase
)
let facts = try env.messenger.ud.tryGet().getFacts()
try env.db().saveContact(Contact(
id: try env.messenger.e2e.tryGet().getContact().getId(),
username: facts.get(.username)?.value,
email: facts.get(.email)?.value,
phone: facts.get(.phone)?.value,
createdAt: env.now()
))
try result.restoredContacts.forEach { contactId in
if try env.db().fetchContacts(.init(id: [contactId])).isEmpty {
try env.db().saveContact(Contact(
id: contactId,
createdAt: env.now()
))
}
}
return .success(.finished)
} catch {
var errors = [error as NSError]
do {
try env.messenger.destroy()
} catch {
errors.append(error as NSError)
}
return .success(.failed(errors))
}
}
.subscribe(on: env.bgQueue)
.receive(on: env.mainQueue)
.eraseToEffect()
case .finished:
state.isRestoring = false
return .none
case .failed(let errors):
state.isRestoring = false
state.restoreFailures = errors.map(\.localizedDescription)
return .none
case .binding(_):
return .none
}
}
.binding()
......@@ -2,12 +2,12 @@ import ComposableArchitecture
import SwiftUI
public struct RestoreView: View {
public init(store: Store<RestoreState, RestoreAction>) {
public init(store: StoreOf<RestoreComponent>) {
self.store = store
}
let store: Store<RestoreState, RestoreAction>
@FocusState var focusedField: RestoreState.Field?
let store: StoreOf<RestoreComponent>
@FocusState var focusedField: RestoreComponent.State.Field?
struct ViewState: Equatable {
struct File: Equatable {
......@@ -19,11 +19,11 @@ public struct RestoreView: View {
var isImportingFile: Bool
var passphrase: String
var isRestoring: Bool
var focusedField: RestoreState.Field?
var focusedField: RestoreComponent.State.Field?
var fileImportFailure: String?
var restoreFailures: [String]
init(state: RestoreState) {
init(state: RestoreComponent.State) {
file = state.file.map { .init(name: $0.name, size: $0.data.count) }
isImportingFile = state.isImportingFile
passphrase = state.passphrase
......@@ -61,7 +61,7 @@ public struct RestoreView: View {
}
}
@ViewBuilder func fileSection(_ viewStore: ViewStore<ViewState, RestoreAction>) -> some View {
@ViewBuilder func fileSection(_ viewStore: ViewStore<ViewState, RestoreComponent.Action>) -> some View {
Section {
if let file = viewStore.file {
HStack(alignment: .bottom) {
......@@ -100,7 +100,7 @@ public struct RestoreView: View {
}
}
@ViewBuilder func restoreSection(_ viewStore: ViewStore<ViewState, RestoreAction>) -> some View {
@ViewBuilder func restoreSection(_ viewStore: ViewStore<ViewState, RestoreComponent.Action>) -> some View {
Section {
SecureField("Passphrase", text: viewStore.binding(
get: \.passphrase,
......@@ -152,7 +152,7 @@ public struct RestoreView: View {
public struct RestoreView_Previews: PreviewProvider {
public static var previews: some View {
RestoreView(store: Store(
initialState: RestoreState(
initialState: RestoreComponent.State(
file: .init(name: "preview", data: Data()),
fileImportFailure: nil,
restoreFailures: [
......@@ -165,8 +165,7 @@ public struct RestoreView_Previews: PreviewProvider {
passphrase: "",
isRestoring: true
),
reducer: .empty,
environment: ()
reducer: EmptyReducer()
))
}
}
......
......@@ -6,19 +6,18 @@ import XXMessengerClient
import XXModels
@testable import RestoreFeature
final class RestoreFeatureTests: XCTestCase {
final class RestoreComponentTests: XCTestCase {
func testFileImport() {
let fileURL = URL(string: "file-url")!
var didLoadDataFromURL: [URL] = []
let dataFromURL = "data-from-url".data(using: .utf8)!
let store = TestStore(
initialState: RestoreState(),
reducer: restoreReducer,
environment: .unimplemented
initialState: RestoreComponent.State(),
reducer: RestoreComponent()
)
store.environment.loadData.load = { url in
store.dependencies.app.loadData.load = { url in
didLoadDataFromURL.append(url)
return dataFromURL
}
......@@ -41,11 +40,10 @@ final class RestoreFeatureTests: XCTestCase {
let failure = Failure()
let store = TestStore(
initialState: RestoreState(
initialState: RestoreComponent.State(
isImportingFile: true
),
reducer: restoreReducer,
environment: .unimplemented
reducer: RestoreComponent()
)
store.send(.fileImport(.failure(failure as NSError))) {
......@@ -60,14 +58,13 @@ final class RestoreFeatureTests: XCTestCase {
let failure = Failure()
let store = TestStore(
initialState: RestoreState(
initialState: RestoreComponent.State(
isImportingFile: true
),
reducer: restoreReducer,
environment: .unimplemented
reducer: RestoreComponent()
)
store.environment.loadData.load = { _ in throw failure }
store.dependencies.app.loadData.load = { _ in throw failure }
store.send(.fileImport(.success(URL(string: "test")!))) {
$0.isImportingFile = false
......@@ -102,23 +99,22 @@ final class RestoreFeatureTests: XCTestCase {
var didSaveContact: [XXModels.Contact] = []
let store = TestStore(
initialState: RestoreState(
initialState: RestoreComponent.State(
file: .init(name: "file-name", data: backupData)
),
reducer: restoreReducer,
environment: .unimplemented
reducer: RestoreComponent()
)
store.environment.bgQueue = .immediate
store.environment.mainQueue = .immediate
store.environment.now = { now }
store.environment.messenger.restoreBackup.run = { data, passphrase in
store.dependencies.app.bgQueue = .immediate
store.dependencies.app.mainQueue = .immediate
store.dependencies.app.now = { now }
store.dependencies.app.messenger.restoreBackup.run = { data, passphrase in
didRestoreWithData.append(data)
didRestoreWithPassphrase.append(passphrase)
udFacts = restoredFacts
return restoreResult
}
store.environment.messenger.e2e.get = {
store.dependencies.app.messenger.e2e.get = {
var e2e: E2E = .unimplemented
e2e.getContact.run = {
var contact: XXClient.Contact = .unimplemented(Data())
......@@ -127,12 +123,12 @@ final class RestoreFeatureTests: XCTestCase {
}
return e2e
}
store.environment.messenger.ud.get = {
store.dependencies.app.messenger.ud.get = {
var ud: UserDiscovery = .unimplemented
ud.getFacts.run = { udFacts }
return ud
}
store.environment.db.run = {
store.dependencies.app.dbManager.getDB.run = {
var db: Database = .unimplemented
db.fetchContacts.run = { query in
didFetchContacts.append(query)
......@@ -189,11 +185,10 @@ final class RestoreFeatureTests: XCTestCase {
func testRestoreWithoutFile() {
let store = TestStore(
initialState: RestoreState(
initialState: RestoreComponent.State(
file: nil
),
reducer: restoreReducer,
environment: .unimplemented
reducer: RestoreComponent()
)
store.send(.restoreTapped)
......@@ -206,17 +201,16 @@ final class RestoreFeatureTests: XCTestCase {
}
let store = TestStore(
initialState: RestoreState(
initialState: RestoreComponent.State(
file: .init(name: "name", data: "data".data(using: .utf8)!)
),
reducer: restoreReducer,
environment: .unimplemented
reducer: RestoreComponent()
)
store.environment.bgQueue = .immediate
store.environment.mainQueue = .immediate
store.environment.messenger.restoreBackup.run = { _, _ in throw Failure.restore }
store.environment.messenger.destroy.run = { throw Failure.destroy }
store.dependencies.app.bgQueue = .immediate
store.dependencies.app.mainQueue = .immediate
store.dependencies.app.messenger.restoreBackup.run = { _, _ in throw Failure.restore }
store.dependencies.app.messenger.destroy.run = { throw Failure.destroy }
store.send(.restoreTapped) {
$0.isRestoring = 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