diff --git a/Examples/xx-messenger/Sources/RestoreFeature/RestoreComponent.swift b/Examples/xx-messenger/Sources/RestoreFeature/RestoreComponent.swift new file mode 100644 index 0000000000000000000000000000000000000000..5a7ef06de236b757cfc82ebbab07b1829ba45822 --- /dev/null +++ b/Examples/xx-messenger/Sources/RestoreFeature/RestoreComponent.swift @@ -0,0 +1,155 @@ +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 + } + } + } +} diff --git a/Examples/xx-messenger/Sources/RestoreFeature/RestoreFeature.swift b/Examples/xx-messenger/Sources/RestoreFeature/RestoreFeature.swift deleted file mode 100644 index 6b3d61d340a7932b6367f71894a0efd707ae81d2..0000000000000000000000000000000000000000 --- a/Examples/xx-messenger/Sources/RestoreFeature/RestoreFeature.swift +++ /dev/null @@ -1,181 +0,0 @@ -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() diff --git a/Examples/xx-messenger/Sources/RestoreFeature/RestoreView.swift b/Examples/xx-messenger/Sources/RestoreFeature/RestoreView.swift index 281f3f061e4a3ff5ccc1913bb45e45a03cff6576..503b868d44a587b19fb89728b2e9c7e713606301 100644 --- a/Examples/xx-messenger/Sources/RestoreFeature/RestoreView.swift +++ b/Examples/xx-messenger/Sources/RestoreFeature/RestoreView.swift @@ -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() )) } } diff --git a/Examples/xx-messenger/Tests/RestoreFeatureTests/RestoreFeatureTests.swift b/Examples/xx-messenger/Tests/RestoreFeatureTests/RestoreComponentTests.swift similarity index 79% rename from Examples/xx-messenger/Tests/RestoreFeatureTests/RestoreFeatureTests.swift rename to Examples/xx-messenger/Tests/RestoreFeatureTests/RestoreComponentTests.swift index 716d7650255b277cad37fb40ad5a73c4aee5bace..8a15fb747fb0124bd972593a8a81233d38c4c89c 100644 --- a/Examples/xx-messenger/Tests/RestoreFeatureTests/RestoreFeatureTests.swift +++ b/Examples/xx-messenger/Tests/RestoreFeatureTests/RestoreComponentTests.swift @@ -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