diff --git a/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/MyContactFeature.xcscheme b/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/MyContactFeature.xcscheme new file mode 100644 index 0000000000000000000000000000000000000000..4eb2a43343a9c76a7ff475789759efbed4b3c270 --- /dev/null +++ b/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/MyContactFeature.xcscheme @@ -0,0 +1,78 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Scheme + LastUpgradeVersion = "1400" + version = "1.3"> + <BuildAction + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES"> + <BuildActionEntries> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "MyContactFeature" + BuildableName = "MyContactFeature" + BlueprintName = "MyContactFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </BuildActionEntry> + </BuildActionEntries> + </BuildAction> + <TestAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> + <Testables> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "MyContactFeatureTests" + BuildableName = "MyContactFeatureTests" + BlueprintName = "MyContactFeatureTests" + ReferencedContainer = "container:"> + </BuildableReference> + </TestableReference> + </Testables> + </TestAction> + <LaunchAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + launchStyle = "0" + useCustomWorkingDirectory = "NO" + ignoresPersistentStateOnLaunch = "NO" + debugDocumentVersioning = "YES" + debugServiceExtension = "internal" + allowLocationSimulation = "YES"> + </LaunchAction> + <ProfileAction + buildConfiguration = "Release" + shouldUseLaunchSchemeArgsEnv = "YES" + savedToolIdentifier = "" + useCustomWorkingDirectory = "NO" + debugDocumentVersioning = "YES"> + <MacroExpansion> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "MyContactFeature" + BuildableName = "MyContactFeature" + BlueprintName = "MyContactFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </MacroExpansion> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/Examples/xx-messenger/Package.swift b/Examples/xx-messenger/Package.swift index c5bcba6a09c4d828a7506a531d429f9d9ab8a92d..c0a4f9054a911c82a801544c2ff17e4629c0ecfb 100644 --- a/Examples/xx-messenger/Package.swift +++ b/Examples/xx-messenger/Package.swift @@ -26,6 +26,7 @@ let package = Package( .library(name: "ContactFeature", targets: ["ContactFeature"]), .library(name: "ContactsFeature", targets: ["ContactsFeature"]), .library(name: "HomeFeature", targets: ["HomeFeature"]), + .library(name: "MyContactFeature", targets: ["MyContactFeature"]), .library(name: "RegisterFeature", targets: ["RegisterFeature"]), .library(name: "RestoreFeature", targets: ["RestoreFeature"]), .library(name: "SendRequestFeature", targets: ["SendRequestFeature"]), @@ -39,7 +40,7 @@ let package = Package( ), .package( url: "https://github.com/pointfreeco/swift-composable-architecture.git", - .upToNextMajor(from: "0.40.1") + .upToNextMajor(from: "0.40.2") ), .package( url: "https://git.xx.network/elixxir/client-ios-db.git", @@ -51,7 +52,11 @@ let package = Package( ), .package( url: "https://github.com/pointfreeco/xctest-dynamic-overlay.git", - .upToNextMajor(from: "0.4.0") + .upToNextMajor(from: "0.4.1") + ), + .package( + url: "https://github.com/pointfreeco/swift-custom-dump.git", + .upToNextMajor(from: "0.5.2") ), ], targets: [ @@ -69,7 +74,8 @@ let package = Package( .testTarget( name: "AppCoreTests", dependencies: [ - .target(name: "AppCore") + .target(name: "AppCore"), + .product(name: "CustomDump", package: "swift-custom-dump"), ], swiftSettings: swiftSettings ), @@ -83,6 +89,7 @@ let package = Package( .target(name: "ContactFeature"), .target(name: "ContactsFeature"), .target(name: "HomeFeature"), + .target(name: "MyContactFeature"), .target(name: "RegisterFeature"), .target(name: "RestoreFeature"), .target(name: "SendRequestFeature"), @@ -100,6 +107,7 @@ let package = Package( name: "AppFeatureTests", dependencies: [ .target(name: "AppFeature"), + .product(name: "CustomDump", package: "swift-custom-dump"), ], swiftSettings: swiftSettings ), @@ -118,6 +126,7 @@ let package = Package( name: "ChatFeatureTests", dependencies: [ .target(name: "ChatFeature"), + .product(name: "CustomDump", package: "swift-custom-dump"), ], swiftSettings: swiftSettings ), @@ -135,6 +144,7 @@ let package = Package( name: "CheckContactAuthFeatureTests", dependencies: [ .target(name: "CheckContactAuthFeature"), + .product(name: "CustomDump", package: "swift-custom-dump"), ] ), .target( @@ -151,6 +161,7 @@ let package = Package( name: "ConfirmRequestFeatureTests", dependencies: [ .target(name: "ConfirmRequestFeature"), + .product(name: "CustomDump", package: "swift-custom-dump"), ] ), .target( @@ -173,6 +184,7 @@ let package = Package( name: "ContactFeatureTests", dependencies: [ .target(name: "ContactFeature"), + .product(name: "CustomDump", package: "swift-custom-dump"), ], swiftSettings: swiftSettings ), @@ -181,6 +193,7 @@ let package = Package( dependencies: [ .target(name: "AppCore"), .target(name: "ContactFeature"), + .target(name: "MyContactFeature"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "ComposablePresentation", package: "swift-composable-presentation"), .product(name: "XXClient", package: "elixxir-dapps-sdk-swift"), @@ -193,6 +206,7 @@ let package = Package( name: "ContactsFeatureTests", dependencies: [ .target(name: "ContactsFeature"), + .product(name: "CustomDump", package: "swift-custom-dump"), ], swiftSettings: swiftSettings ), @@ -213,6 +227,26 @@ let package = Package( name: "HomeFeatureTests", dependencies: [ .target(name: "HomeFeature"), + .product(name: "CustomDump", package: "swift-custom-dump"), + ], + swiftSettings: swiftSettings + ), + .target( + name: "MyContactFeature", + dependencies: [ + .target(name: "AppCore"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "XXClient", package: "elixxir-dapps-sdk-swift"), + .product(name: "XXMessengerClient", package: "elixxir-dapps-sdk-swift"), + .product(name: "XXModels", package: "client-ios-db"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "MyContactFeatureTests", + dependencies: [ + .target(name: "MyContactFeature"), + .product(name: "CustomDump", package: "swift-custom-dump"), ], swiftSettings: swiftSettings ), @@ -231,6 +265,7 @@ let package = Package( name: "RegisterFeatureTests", dependencies: [ .target(name: "RegisterFeature"), + .product(name: "CustomDump", package: "swift-custom-dump"), ], swiftSettings: swiftSettings ), @@ -245,6 +280,7 @@ let package = Package( name: "RestoreFeatureTests", dependencies: [ .target(name: "RestoreFeature"), + .product(name: "CustomDump", package: "swift-custom-dump"), ], swiftSettings: swiftSettings ), @@ -263,6 +299,7 @@ let package = Package( name: "SendRequestFeatureTests", dependencies: [ .target(name: "SendRequestFeature"), + .product(name: "CustomDump", package: "swift-custom-dump"), ], swiftSettings: swiftSettings ), @@ -282,6 +319,7 @@ let package = Package( name: "UserSearchFeatureTests", dependencies: [ .target(name: "UserSearchFeature"), + .product(name: "CustomDump", package: "swift-custom-dump"), ], swiftSettings: swiftSettings ), @@ -299,6 +337,7 @@ let package = Package( name: "VerifyContactFeatureTests", dependencies: [ .target(name: "VerifyContactFeature"), + .product(name: "CustomDump", package: "swift-custom-dump"), ] ), .target( @@ -313,6 +352,7 @@ let package = Package( name: "WelcomeFeatureTests", dependencies: [ .target(name: "WelcomeFeature"), + .product(name: "CustomDump", package: "swift-custom-dump"), ], swiftSettings: swiftSettings ), diff --git a/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme b/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme index 65400dff681f98714be5c8db74bc82b6558dfe17..aa97ea7d2a0f1ce2fded6d13e874f8f8caf21769 100644 --- a/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme +++ b/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme @@ -109,6 +109,16 @@ ReferencedContainer = "container:.."> </BuildableReference> </TestableReference> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "MyContactFeatureTests" + BuildableName = "MyContactFeatureTests" + BlueprintName = "MyContactFeatureTests" + ReferencedContainer = "container:.."> + </BuildableReference> + </TestableReference> <TestableReference skipped = "NO"> <BuildableReference diff --git a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift index 611123e0334c6078250bf0e782153975d6891799..d824d6d400b402270d341bb43fbd5f363c84a879 100644 --- a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift +++ b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift @@ -6,6 +6,7 @@ import ContactFeature import ContactsFeature import Foundation import HomeFeature +import MyContactFeature import RegisterFeature import RestoreFeature import SendRequestFeature @@ -122,7 +123,15 @@ extension AppEnvironment { db: dbManager.getDB, mainQueue: mainQueue, bgQueue: bgQueue, - contact: { contactEnvironment } + contact: { contactEnvironment }, + myContact: { + MyContactEnvironment( + messenger: messenger, + db: dbManager.getDB, + mainQueue: mainQueue, + bgQueue: bgQueue + ) + } ) }, userSearch: { diff --git a/Examples/xx-messenger/Sources/ContactsFeature/ContactsFeature.swift b/Examples/xx-messenger/Sources/ContactsFeature/ContactsFeature.swift index 1ded89de4cbf9dd44014395e3e79bf6ff2fdcc20..680a231ec8cc9e6e13487a849a65941bda7a7428 100644 --- a/Examples/xx-messenger/Sources/ContactsFeature/ContactsFeature.swift +++ b/Examples/xx-messenger/Sources/ContactsFeature/ContactsFeature.swift @@ -3,6 +3,7 @@ import ComposableArchitecture import ComposablePresentation import ContactFeature import Foundation +import MyContactFeature import XCTestDynamicOverlay import XXClient import XXMessengerClient @@ -12,16 +13,19 @@ public struct ContactsState: Equatable { public init( myId: Data? = nil, contacts: IdentifiedArrayOf<XXModels.Contact> = [], - contact: ContactState? = nil + contact: ContactState? = nil, + myContact: MyContactState? = nil ) { self.myId = myId self.contacts = contacts self.contact = contact + self.myContact = myContact } public var myId: Data? public var contacts: IdentifiedArrayOf<XXModels.Contact> public var contact: ContactState? + public var myContact: MyContactState? } public enum ContactsAction: Equatable { @@ -30,6 +34,9 @@ public enum ContactsAction: Equatable { case contactSelected(XXModels.Contact) case contactDismissed case contact(ContactAction) + case myContactSelected + case myContactDismissed + case myContact(MyContactAction) } public struct ContactsEnvironment { @@ -38,13 +45,15 @@ public struct ContactsEnvironment { db: DBManagerGetDB, mainQueue: AnySchedulerOf<DispatchQueue>, bgQueue: AnySchedulerOf<DispatchQueue>, - contact: @escaping () -> ContactEnvironment + contact: @escaping () -> ContactEnvironment, + myContact: @escaping () -> MyContactEnvironment ) { self.messenger = messenger self.db = db self.mainQueue = mainQueue self.bgQueue = bgQueue self.contact = contact + self.myContact = myContact } public var messenger: Messenger @@ -52,6 +61,7 @@ public struct ContactsEnvironment { public var mainQueue: AnySchedulerOf<DispatchQueue> public var bgQueue: AnySchedulerOf<DispatchQueue> public var contact: () -> ContactEnvironment + public var myContact: () -> MyContactEnvironment } #if DEBUG @@ -61,7 +71,8 @@ extension ContactsEnvironment { db: .unimplemented, mainQueue: .unimplemented, bgQueue: .unimplemented, - contact: { .unimplemented } + contact: { .unimplemented }, + myContact: { .unimplemented } ) } #endif @@ -96,7 +107,15 @@ public let contactsReducer = Reducer<ContactsState, ContactsAction, ContactsEnvi state.contact = nil return .none - case .contact(_): + case .myContactSelected: + state.myContact = MyContactState() + return .none + + case .myContactDismissed: + state.myContact = nil + return .none + + case .contact(_), .myContact(_): return .none } } @@ -107,3 +126,10 @@ public let contactsReducer = Reducer<ContactsState, ContactsAction, ContactsEnvi action: /ContactsAction.contact, environment: { $0.contact() } ) +.presenting( + myContactReducer, + state: .keyPath(\.myContact), + id: .notNil(), + action: /ContactsAction.myContact, + environment: { $0.myContact() } +) diff --git a/Examples/xx-messenger/Sources/ContactsFeature/ContactsView.swift b/Examples/xx-messenger/Sources/ContactsFeature/ContactsView.swift index ce811f9d7bab2d22cb530cc6f2725be004a603e4..e09725d92c84feeb7f8cb7da5dbc38cf9d998087 100644 --- a/Examples/xx-messenger/Sources/ContactsFeature/ContactsView.swift +++ b/Examples/xx-messenger/Sources/ContactsFeature/ContactsView.swift @@ -2,6 +2,7 @@ import AppCore import ComposableArchitecture import ComposablePresentation import ContactFeature +import MyContactFeature import SwiftUI import XXModels @@ -28,13 +29,21 @@ public struct ContactsView: View { ForEach(viewStore.contacts) { contact in if contact.id == viewStore.myId { Section { - VStack(alignment: .leading, spacing: 8) { - Label(contact.username ?? "", systemImage: "person") - Label(contact.email ?? "", systemImage: "envelope") - Label(contact.phone ?? "", systemImage: "phone") + Button { + viewStore.send(.myContactSelected) + } label: { + HStack { + VStack(alignment: .leading, spacing: 8) { + Label(contact.username ?? "", systemImage: "person") + Label(contact.email ?? "", systemImage: "envelope") + Label(contact.phone ?? "", systemImage: "phone") + } + .font(.callout) + .tint(Color.primary) + Spacer() + Image(systemName: "chevron.forward") + } } - .font(.callout) - .tint(Color.primary) } header: { Text("My contact") } @@ -70,6 +79,14 @@ public struct ContactsView: View { onDeactivate: { viewStore.send(.contactDismissed) }, destination: ContactView.init(store:) )) + .background(NavigationLinkWithStore( + store.scope( + state: \.myContact, + action: ContactsAction.myContact + ), + onDeactivate: { viewStore.send(.myContactDismissed) }, + destination: MyContactView.init(store:) + )) } } } diff --git a/Examples/xx-messenger/Sources/MyContactFeature/Alerts.swift b/Examples/xx-messenger/Sources/MyContactFeature/Alerts.swift new file mode 100644 index 0000000000000000000000000000000000000000..321139aec18ce7ae51b9be8097878a9133798236 --- /dev/null +++ b/Examples/xx-messenger/Sources/MyContactFeature/Alerts.swift @@ -0,0 +1,11 @@ +import ComposableArchitecture + +extension AlertState { + public static func error(_ message: String) -> AlertState<MyContactAction> { + AlertState<MyContactAction>( + title: TextState("Error"), + message: TextState(message), + buttons: [] + ) + } +} diff --git a/Examples/xx-messenger/Sources/MyContactFeature/MyContactFeature.swift b/Examples/xx-messenger/Sources/MyContactFeature/MyContactFeature.swift new file mode 100644 index 0000000000000000000000000000000000000000..b25d021257e34cbc8b23cafa4f4ef1c44815dbff --- /dev/null +++ b/Examples/xx-messenger/Sources/MyContactFeature/MyContactFeature.swift @@ -0,0 +1,315 @@ +import AppCore +import Combine +import ComposableArchitecture +import Foundation +import XCTestDynamicOverlay +import XXClient +import XXMessengerClient +import XXModels + +public struct MyContactState: Equatable { + public enum Field: String, Hashable { + case email + case emailCode + case phone + case phoneCode + } + + public init( + contact: XXModels.Contact? = nil, + focusedField: Field? = nil, + email: String = "", + emailConfirmationID: String? = nil, + emailConfirmationCode: String = "", + isRegisteringEmail: Bool = false, + isConfirmingEmail: Bool = false, + isUnregisteringEmail: Bool = false, + phone: String = "", + phoneConfirmationID: String? = nil, + phoneConfirmationCode: String = "", + isRegisteringPhone: Bool = false, + isConfirmingPhone: Bool = false, + isUnregisteringPhone: Bool = false, + isLoadingFacts: Bool = false, + alert: AlertState<MyContactAction>? = nil + ) { + self.contact = contact + self.focusedField = focusedField + self.email = email + self.emailConfirmationID = emailConfirmationID + self.emailConfirmationCode = emailConfirmationCode + self.isRegisteringEmail = isRegisteringEmail + self.isConfirmingEmail = isConfirmingEmail + self.isUnregisteringEmail = isUnregisteringEmail + self.phone = phone + self.phoneConfirmationID = phoneConfirmationID + self.phoneConfirmationCode = phoneConfirmationCode + self.isRegisteringPhone = isRegisteringPhone + self.isConfirmingPhone = isConfirmingPhone + self.isUnregisteringPhone = isUnregisteringPhone + self.isLoadingFacts = isLoadingFacts + self.alert = alert + } + + public var contact: XXModels.Contact? + @BindableState public var focusedField: Field? + @BindableState public var email: String + @BindableState public var emailConfirmationID: String? + @BindableState public var emailConfirmationCode: String + @BindableState public var isRegisteringEmail: Bool + @BindableState public var isConfirmingEmail: Bool + @BindableState public var isUnregisteringEmail: Bool + @BindableState public var phone: String + @BindableState public var phoneConfirmationID: String? + @BindableState public var phoneConfirmationCode: String + @BindableState public var isRegisteringPhone: Bool + @BindableState public var isConfirmingPhone: Bool + @BindableState public var isUnregisteringPhone: Bool + @BindableState public var isLoadingFacts: Bool + public var alert: AlertState<MyContactAction>? +} + +public enum MyContactAction: Equatable, BindableAction { + case start + case contactFetched(XXModels.Contact?) + case registerEmailTapped + case confirmEmailTapped + case unregisterEmailTapped + case registerPhoneTapped + case confirmPhoneTapped + case unregisterPhoneTapped + case loadFactsTapped + case didFail(String) + case alertDismissed + case binding(BindingAction<MyContactState>) +} + +public struct MyContactEnvironment { + public init( + messenger: Messenger, + db: DBManagerGetDB, + mainQueue: AnySchedulerOf<DispatchQueue>, + bgQueue: AnySchedulerOf<DispatchQueue> + ) { + self.messenger = messenger + self.db = db + self.mainQueue = mainQueue + self.bgQueue = bgQueue + } + + public var messenger: Messenger + public var db: DBManagerGetDB + public var mainQueue: AnySchedulerOf<DispatchQueue> + public var bgQueue: AnySchedulerOf<DispatchQueue> +} + +#if DEBUG +extension MyContactEnvironment { + public static let unimplemented = MyContactEnvironment( + messenger: .unimplemented, + db: .unimplemented, + mainQueue: .unimplemented, + bgQueue: .unimplemented + ) +} +#endif + +public let myContactReducer = Reducer<MyContactState, MyContactAction, MyContactEnvironment> +{ state, action, env in + enum DBFetchEffectID {} + + switch action { + case .start: + return Effect + .catching { try env.messenger.e2e.tryGet().getContact().getId() } + .tryMap { try env.db().fetchContactsPublisher(.init(id: [$0])) } + .flatMap { $0 } + .assertNoFailure() + .map(\.first) + .map(MyContactAction.contactFetched) + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + .cancellable(id: DBFetchEffectID.self, cancelInFlight: true) + + case .contactFetched(let contact): + state.contact = contact + return .none + + case .registerEmailTapped: + state.focusedField = nil + state.isRegisteringEmail = true + return Effect.run { [state] subscriber in + do { + let ud = try env.messenger.ud.tryGet() + let fact = Fact(type: .email, value: state.email) + let confirmationID = try ud.sendRegisterFact(fact) + subscriber.send(.set(\.$emailConfirmationID, confirmationID)) + } catch { + subscriber.send(.didFail(error.localizedDescription)) + } + subscriber.send(.set(\.$isRegisteringEmail, false)) + subscriber.send(completion: .finished) + return AnyCancellable {} + } + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .confirmEmailTapped: + guard let confirmationID = state.emailConfirmationID else { return .none } + state.focusedField = nil + state.isConfirmingEmail = true + return Effect.run { [state] subscriber in + do { + let ud = try env.messenger.ud.tryGet() + try ud.confirmFact(confirmationId: confirmationID, code: state.emailConfirmationCode) + let contactId = try env.messenger.e2e.tryGet().getContact().getId() + if var dbContact = try env.db().fetchContacts(.init(id: [contactId])).first { + dbContact.email = state.email + try env.db().saveContact(dbContact) + } + subscriber.send(.set(\.$email, "")) + subscriber.send(.set(\.$emailConfirmationID, nil)) + subscriber.send(.set(\.$emailConfirmationCode, "")) + } catch { + subscriber.send(.didFail(error.localizedDescription)) + } + subscriber.send(.set(\.$isConfirmingEmail, false)) + subscriber.send(completion: .finished) + return AnyCancellable {} + } + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .unregisterEmailTapped: + guard let email = state.contact?.email else { return .none } + state.isUnregisteringEmail = true + return Effect.run { [state] subscriber in + do { + let ud: UserDiscovery = try env.messenger.ud.tryGet() + let fact = Fact(type: .email, value: email) + try ud.removeFact(fact) + let contactId = try env.messenger.e2e.tryGet().getContact().getId() + if var dbContact = try env.db().fetchContacts(.init(id: [contactId])).first { + dbContact.email = nil + try env.db().saveContact(dbContact) + } + } catch { + subscriber.send(.didFail(error.localizedDescription)) + } + subscriber.send(.set(\.$isUnregisteringEmail, false)) + subscriber.send(completion: .finished) + return AnyCancellable {} + } + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .registerPhoneTapped: + state.focusedField = nil + state.isRegisteringPhone = true + return Effect.run { [state] subscriber in + do { + let ud = try env.messenger.ud.tryGet() + let fact = Fact(type: .phone, value: state.phone) + let confirmationID = try ud.sendRegisterFact(fact) + subscriber.send(.set(\.$phoneConfirmationID, confirmationID)) + } catch { + subscriber.send(.didFail(error.localizedDescription)) + } + subscriber.send(.set(\.$isRegisteringPhone, false)) + subscriber.send(completion: .finished) + return AnyCancellable {} + } + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .confirmPhoneTapped: + guard let confirmationID = state.phoneConfirmationID else { return .none } + state.focusedField = nil + state.isConfirmingPhone = true + return Effect.run { [state] subscriber in + do { + let ud = try env.messenger.ud.tryGet() + try ud.confirmFact(confirmationId: confirmationID, code: state.phoneConfirmationCode) + let contactId = try env.messenger.e2e.tryGet().getContact().getId() + if var dbContact = try env.db().fetchContacts(.init(id: [contactId])).first { + dbContact.phone = state.phone + try env.db().saveContact(dbContact) + } + subscriber.send(.set(\.$phone, "")) + subscriber.send(.set(\.$phoneConfirmationID, nil)) + subscriber.send(.set(\.$phoneConfirmationCode, "")) + } catch { + subscriber.send(.didFail(error.localizedDescription)) + } + subscriber.send(.set(\.$isConfirmingPhone, false)) + subscriber.send(completion: .finished) + return AnyCancellable {} + } + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .unregisterPhoneTapped: + guard let phone = state.contact?.phone else { return .none } + state.isUnregisteringPhone = true + return Effect.run { [state] subscriber in + do { + let ud: UserDiscovery = try env.messenger.ud.tryGet() + let fact = Fact(type: .phone, value: phone) + try ud.removeFact(fact) + let contactId = try env.messenger.e2e.tryGet().getContact().getId() + if var dbContact = try env.db().fetchContacts(.init(id: [contactId])).first { + dbContact.phone = nil + try env.db().saveContact(dbContact) + } + } catch { + subscriber.send(.didFail(error.localizedDescription)) + } + subscriber.send(.set(\.$isUnregisteringPhone, false)) + subscriber.send(completion: .finished) + return AnyCancellable {} + } + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .loadFactsTapped: + state.isLoadingFacts = true + return Effect.run { subscriber in + do { + let contactId = try env.messenger.e2e.tryGet().getContact().getId() + if var dbContact = try env.db().fetchContacts(.init(id: [contactId])).first { + let facts = try env.messenger.ud.tryGet().getFacts() + dbContact.email = facts.get(.email)?.value + dbContact.phone = facts.get(.phone)?.value + try env.db().saveContact(dbContact) + } + } catch { + subscriber.send(.didFail(error.localizedDescription)) + } + subscriber.send(.set(\.$isLoadingFacts, false)) + subscriber.send(completion: .finished) + return AnyCancellable {} + } + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .didFail(let failure): + state.alert = .error(failure) + return .none + + case .alertDismissed: + state.alert = nil + return .none + + case .binding(_): + return .none + } +} +.binding() diff --git a/Examples/xx-messenger/Sources/MyContactFeature/MyContactView.swift b/Examples/xx-messenger/Sources/MyContactFeature/MyContactView.swift new file mode 100644 index 0000000000000000000000000000000000000000..f32af24270f762b6a11f5a43602f013709e14344 --- /dev/null +++ b/Examples/xx-messenger/Sources/MyContactFeature/MyContactView.swift @@ -0,0 +1,248 @@ +import ComposableArchitecture +import SwiftUI +import XXModels + +public struct MyContactView: View { + public init(store: Store<MyContactState, MyContactAction>) { + self.store = store + } + + let store: Store<MyContactState, MyContactAction> + @FocusState var focusedField: MyContactState.Field? + + struct ViewState: Equatable { + init(state: MyContactState) { + contact = state.contact + focusedField = state.focusedField + email = state.email + emailConfirmation = state.emailConfirmationID != nil + emailCode = state.emailConfirmationCode + isRegisteringEmail = state.isRegisteringEmail + isConfirmingEmail = state.isConfirmingEmail + isUnregisteringEmail = state.isUnregisteringEmail + phone = state.phone + phoneConfirmation = state.phoneConfirmationID != nil + phoneCode = state.phoneConfirmationCode + isRegisteringPhone = state.isRegisteringPhone + isConfirmingPhone = state.isConfirmingPhone + isUnregisteringPhone = state.isUnregisteringPhone + isLoadingFacts = state.isLoadingFacts + } + + var contact: XXModels.Contact? + var focusedField: MyContactState.Field? + var email: String + var emailConfirmation: Bool + var emailCode: String + var isRegisteringEmail: Bool + var isConfirmingEmail: Bool + var isUnregisteringEmail: Bool + var phone: String + var phoneConfirmation: Bool + var phoneCode: String + var isRegisteringPhone: Bool + var isConfirmingPhone: Bool + var isUnregisteringPhone: Bool + var isLoadingFacts: Bool + } + + public var body: some View { + WithViewStore(store, observe: ViewState.init) { viewStore in + Form { + Section { + Text(viewStore.contact?.username ?? "") + } header: { + Label("Username", systemImage: "person") + } + + Section { + if let contact = viewStore.contact { + if let email = contact.email { + Text(email) + Button(role: .destructive) { + viewStore.send(.unregisterEmailTapped) + } label: { + HStack { + Text("Unregister") + Spacer() + if viewStore.isUnregisteringEmail { + ProgressView() + } + } + } + .disabled(viewStore.isUnregisteringEmail) + } else { + TextField( + text: viewStore.binding( + get: \.email, + send: { MyContactAction.set(\.$email, $0) } + ), + prompt: Text("Enter email"), + label: { Text("Email") } + ) + .focused($focusedField, equals: .email) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .disabled(viewStore.isRegisteringEmail || viewStore.emailConfirmation) + if viewStore.emailConfirmation { + TextField( + text: viewStore.binding( + get: \.emailCode, + send: { MyContactAction.set(\.$emailConfirmationCode, $0) } + ), + prompt: Text("Enter confirmation code"), + label: { Text("Confirmation code") } + ) + .focused($focusedField, equals: .emailCode) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .disabled(viewStore.isConfirmingEmail) + Button { + viewStore.send(.confirmEmailTapped) + } label: { + HStack { + Text("Confirm") + Spacer() + if viewStore.isConfirmingEmail { + ProgressView() + } + } + } + .disabled(viewStore.isConfirmingEmail) + } else { + Button { + viewStore.send(.registerEmailTapped) + } label: { + HStack { + Text("Register") + Spacer() + if viewStore.isRegisteringEmail { + ProgressView() + } + } + } + .disabled(viewStore.isRegisteringEmail) + } + } + } else { + Text("") + } + } header: { + Label("Email", systemImage: "envelope") + } + + Section { + if let contact = viewStore.contact { + if let phone = contact.phone { + Text(phone) + Button(role: .destructive) { + viewStore.send(.unregisterPhoneTapped) + } label: { + HStack { + Text("Unregister") + Spacer() + if viewStore.isUnregisteringPhone { + ProgressView() + } + } + } + .disabled(viewStore.isUnregisteringPhone) + } else { + TextField( + text: viewStore.binding( + get: \.phone, + send: { MyContactAction.set(\.$phone, $0) } + ), + prompt: Text("Enter phone"), + label: { Text("Phone") } + ) + .focused($focusedField, equals: .phone) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .disabled(viewStore.isRegisteringPhone || viewStore.phoneConfirmation) + if viewStore.phoneConfirmation { + TextField( + text: viewStore.binding( + get: \.phoneCode, + send: { MyContactAction.set(\.$phoneConfirmationCode, $0) } + ), + prompt: Text("Enter confirmation code"), + label: { Text("Confirmation code") } + ) + .focused($focusedField, equals: .phoneCode) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .disabled(viewStore.isConfirmingPhone) + Button { + viewStore.send(.confirmPhoneTapped) + } label: { + HStack { + Text("Confirm") + Spacer() + if viewStore.isConfirmingPhone { + ProgressView() + } + } + } + .disabled(viewStore.isConfirmingPhone) + } else { + Button { + viewStore.send(.registerPhoneTapped) + } label: { + HStack { + Text("Register") + Spacer() + if viewStore.isRegisteringPhone { + ProgressView() + } + } + } + .disabled(viewStore.isRegisteringPhone) + } + } + } else { + Text("") + } + } header: { + Label("Phone", systemImage: "phone") + } + + Section { + Button { + viewStore.send(.loadFactsTapped) + } label: { + HStack { + Text("Reload facts") + Spacer() + if viewStore.isLoadingFacts { + ProgressView() + } + } + } + .disabled(viewStore.isLoadingFacts) + } header: { + Text("Actions") + } + } + .navigationTitle("My Contact") + .task { viewStore.send(.start) } + .onChange(of: viewStore.focusedField) { focusedField = $0 } + .onChange(of: focusedField) { viewStore.send(.set(\.$focusedField, $0)) } + .alert(store.scope(state: \.alert), dismiss: .alertDismissed) + } + } +} + +#if DEBUG +public struct MyContactView_Previews: PreviewProvider { + public static var previews: some View { + NavigationView { + MyContactView(store: Store( + initialState: MyContactState(), + reducer: .empty, + environment: () + )) + } + } +} +#endif diff --git a/Examples/xx-messenger/Tests/AppFeatureTests/AppFeatureTests.swift b/Examples/xx-messenger/Tests/AppFeatureTests/AppFeatureTests.swift index 5a013b28b16126b422ef3d26b19407d0af2bff5c..fc58fe4eaf2ec4fa8be79df08205638c640d656b 100644 --- a/Examples/xx-messenger/Tests/AppFeatureTests/AppFeatureTests.swift +++ b/Examples/xx-messenger/Tests/AppFeatureTests/AppFeatureTests.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import CustomDump import HomeFeature import RestoreFeature import WelcomeFeature diff --git a/Examples/xx-messenger/Tests/ContactsFeatureTests/ContactsFeatureTests.swift b/Examples/xx-messenger/Tests/ContactsFeatureTests/ContactsFeatureTests.swift index a0c0291ef79865fa6c1f3d1ffeb25235bd520acf..ffd3eafee2f6d7216fd3a48bb3c2a63097725147 100644 --- a/Examples/xx-messenger/Tests/ContactsFeatureTests/ContactsFeatureTests.swift +++ b/Examples/xx-messenger/Tests/ContactsFeatureTests/ContactsFeatureTests.swift @@ -2,6 +2,7 @@ import Combine import ComposableArchitecture import ContactFeature import CustomDump +import MyContactFeature import XCTest import XXClient import XXMessengerClient @@ -94,4 +95,30 @@ final class ContactsFeatureTests: XCTestCase { $0.contact = nil } } + + func testSelectMyContact() { + let store = TestStore( + initialState: ContactsState(), + reducer: contactsReducer, + environment: .unimplemented + ) + + store.send(.myContactSelected) { + $0.myContact = MyContactState() + } + } + + func testDismissMyContact() { + let store = TestStore( + initialState: ContactsState( + myContact: MyContactState() + ), + reducer: contactsReducer, + environment: .unimplemented + ) + + store.send(.myContactDismissed) { + $0.myContact = nil + } + } } diff --git a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift index fc671b1ec7406823bd903d07307c8146e948ed39..d922d4f9f9e141ddaef8b577cdaccf6a0e24e922 100644 --- a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift +++ b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift @@ -1,6 +1,7 @@ import AppCore import ComposableArchitecture import ContactsFeature +import CustomDump import RegisterFeature import UserSearchFeature import XCTest diff --git a/Examples/xx-messenger/Tests/MyContactFeatureTests/MyContactFeatureTests.swift b/Examples/xx-messenger/Tests/MyContactFeatureTests/MyContactFeatureTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..8d5fac94338bfa3e9f6e9b413ef2c016304269fb --- /dev/null +++ b/Examples/xx-messenger/Tests/MyContactFeatureTests/MyContactFeatureTests.swift @@ -0,0 +1,761 @@ +import Combine +import ComposableArchitecture +import CustomDump +import XCTest +import XXClient +import XXMessengerClient +import XXModels +@testable import MyContactFeature + +final class MyContactFeatureTests: XCTestCase { + func testStart() { + let contactId = "contact-id".data(using: .utf8)! + + let store = TestStore( + initialState: MyContactState(), + reducer: myContactReducer, + environment: .unimplemented + ) + + var dbDidFetchContacts: [XXModels.Contact.Query] = [] + let dbContactsPublisher = PassthroughSubject<[XXModels.Contact], Error>() + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in contactId } + return contact + } + return e2e + } + store.environment.db.run = { + var db: Database = .failing + db.fetchContactsPublisher.run = { query in + dbDidFetchContacts.append(query) + return dbContactsPublisher.eraseToAnyPublisher() + } + return db + } + + store.send(.start) + + XCTAssertNoDifference(dbDidFetchContacts, [.init(id: [contactId])]) + + dbContactsPublisher.send([]) + + store.receive(.contactFetched(nil)) + + let contact = XXModels.Contact(id: contactId) + dbContactsPublisher.send([contact]) + + store.receive(.contactFetched(contact)) { + $0.contact = contact + } + + dbContactsPublisher.send(completion: .finished) + } + + func testRegisterEmail() { + let email = "test@email.com" + let confirmationID = "123" + + var didSendRegisterFact: [Fact] = [] + + let store = TestStore( + initialState: MyContactState(), + reducer: myContactReducer, + environment: .unimplemented + ) + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.ud.get = { + var ud: UserDiscovery = .unimplemented + ud.sendRegisterFact.run = { fact in + didSendRegisterFact.append(fact) + return confirmationID + } + return ud + } + + store.send(.set(\.$focusedField, .email)) { + $0.focusedField = .email + } + + store.send(.set(\.$email, email)) { + $0.email = email + } + + store.send(.registerEmailTapped) { + $0.focusedField = nil + $0.isRegisteringEmail = true + } + + XCTAssertNoDifference(didSendRegisterFact, [.init(type: .email, value: email)]) + + store.receive(.set(\.$emailConfirmationID, confirmationID)) { + $0.emailConfirmationID = confirmationID + } + + store.receive(.set(\.$isRegisteringEmail, false)) { + $0.isRegisteringEmail = false + } + } + + func testRegisterEmailFailure() { + struct Failure: Error {} + let failure = Failure() + + let store = TestStore( + initialState: MyContactState(), + reducer: myContactReducer, + environment: .unimplemented + ) + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.ud.get = { + var ud: UserDiscovery = .unimplemented + ud.sendRegisterFact.run = { _ in throw failure } + return ud + } + + store.send(.registerEmailTapped) { + $0.isRegisteringEmail = true + } + + store.receive(.didFail(failure.localizedDescription)) { + $0.alert = .error(failure.localizedDescription) + } + + store.receive(.set(\.$isRegisteringEmail, false)) { + $0.isRegisteringEmail = false + } + } + + func testConfirmEmail() { + let contactID = "contact-id".data(using: .utf8)! + let email = "test@email.com" + let confirmationID = "123" + let confirmationCode = "321" + let dbContact = XXModels.Contact(id: contactID) + + var didConfirmWithID: [String] = [] + var didConfirmWithCode: [String] = [] + var didFetchContacts: [XXModels.Contact.Query] = [] + var didSaveContact: [XXModels.Contact] = [] + + let store = TestStore( + initialState: MyContactState( + email: email, + emailConfirmationID: confirmationID + ), + reducer: myContactReducer, + environment: .unimplemented + ) + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.ud.get = { + var ud: UserDiscovery = .unimplemented + ud.confirmFact.run = { id, code in + didConfirmWithID.append(id) + didConfirmWithCode.append(code) + } + return ud + } + store.environment.messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in contactID } + return contact + } + return e2e + } + store.environment.db.run = { + var db: Database = .failing + db.fetchContacts.run = { query in + didFetchContacts.append(query) + return [dbContact] + } + db.saveContact.run = { contact in + didSaveContact.append(contact) + return contact + } + return db + } + + store.send(.set(\.$focusedField, .emailCode)) { + $0.focusedField = .emailCode + } + + store.send(.set(\.$emailConfirmationCode, confirmationCode)) { + $0.emailConfirmationCode = confirmationCode + } + + store.send(.confirmEmailTapped) { + $0.focusedField = nil + $0.isConfirmingEmail = true + } + + XCTAssertNoDifference(didConfirmWithID, [confirmationID]) + XCTAssertNoDifference(didConfirmWithCode, [confirmationCode]) + XCTAssertNoDifference(didFetchContacts, [.init(id: [contactID])]) + var expectedSavedContact = dbContact + expectedSavedContact.email = email + XCTAssertNoDifference(didSaveContact, [expectedSavedContact]) + + store.receive(.set(\.$email, "")) { + $0.email = "" + } + store.receive(.set(\.$emailConfirmationID, nil)) { + $0.emailConfirmationID = nil + } + store.receive(.set(\.$emailConfirmationCode, "")) { + $0.emailConfirmationCode = "" + } + store.receive(.set(\.$isConfirmingEmail, false)) { + $0.isConfirmingEmail = false + } + } + + func testConfirmEmailFailure() { + struct Failure: Error {} + let failure = Failure() + + let store = TestStore( + initialState: MyContactState( + emailConfirmationID: "123" + ), + reducer: myContactReducer, + environment: .unimplemented + ) + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.ud.get = { + var ud: UserDiscovery = .unimplemented + ud.confirmFact.run = { _, _ in throw failure } + return ud + } + + store.send(.confirmEmailTapped) { + $0.isConfirmingEmail = true + } + + store.receive(.didFail(failure.localizedDescription)) { + $0.alert = .error(failure.localizedDescription) + } + + store.receive(.set(\.$isConfirmingEmail, false)) { + $0.isConfirmingEmail = false + } + } + + func testUnregisterEmail() { + let contactID = "contact-id".data(using: .utf8)! + let email = "test@email.com" + let dbContact = XXModels.Contact(id: contactID, email: email) + + var didRemoveFact: [Fact] = [] + var didFetchContacts: [XXModels.Contact.Query] = [] + var didSaveContact: [XXModels.Contact] = [] + + let store = TestStore( + initialState: MyContactState( + contact: dbContact + ), + reducer: myContactReducer, + environment: .unimplemented + ) + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.ud.get = { + var ud: UserDiscovery = .unimplemented + ud.removeFact.run = { didRemoveFact.append($0) } + return ud + } + store.environment.messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in contactID } + return contact + } + return e2e + } + store.environment.db.run = { + var db: Database = .failing + db.fetchContacts.run = { query in + didFetchContacts.append(query) + return [dbContact] + } + db.saveContact.run = { contact in + didSaveContact.append(contact) + return contact + } + return db + } + + store.send(.unregisterEmailTapped) { + $0.isUnregisteringEmail = true + } + + XCTAssertNoDifference(didRemoveFact, [.init(type: .email, value: email)]) + XCTAssertNoDifference(didFetchContacts, [.init(id: [contactID])]) + var expectedSavedContact = dbContact + expectedSavedContact.email = nil + XCTAssertNoDifference(didSaveContact, [expectedSavedContact]) + + store.receive(.set(\.$isUnregisteringEmail, false)) { + $0.isUnregisteringEmail = false + } + } + + func testUnregisterEmailFailure() { + struct Failure: Error {} + let failure = Failure() + + let store = TestStore( + initialState: MyContactState( + contact: .init(id: Data(), email: "test@email.com") + ), + reducer: myContactReducer, + environment: .unimplemented + ) + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.ud.get = { + var ud: UserDiscovery = .unimplemented + ud.removeFact.run = { _ in throw failure } + return ud + } + + store.send(.unregisterEmailTapped) { + $0.isUnregisteringEmail = true + } + + store.receive(.didFail(failure.localizedDescription)) { + $0.alert = .error(failure.localizedDescription) + } + + store.receive(.set(\.$isUnregisteringEmail, false)) { + $0.isUnregisteringEmail = false + } + } + + func testRegisterPhone() { + let phone = "+123456789" + let confirmationID = "123" + + var didSendRegisterFact: [Fact] = [] + + let store = TestStore( + initialState: MyContactState(), + reducer: myContactReducer, + environment: .unimplemented + ) + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.ud.get = { + var ud: UserDiscovery = .unimplemented + ud.sendRegisterFact.run = { fact in + didSendRegisterFact.append(fact) + return confirmationID + } + return ud + } + + store.send(.set(\.$focusedField, .phone)) { + $0.focusedField = .phone + } + + store.send(.set(\.$phone, phone)) { + $0.phone = phone + } + + store.send(.registerPhoneTapped) { + $0.focusedField = nil + $0.isRegisteringPhone = true + } + + XCTAssertNoDifference(didSendRegisterFact, [.init(type: .phone, value: phone)]) + + store.receive(.set(\.$phoneConfirmationID, confirmationID)) { + $0.phoneConfirmationID = confirmationID + } + + store.receive(.set(\.$isRegisteringPhone, false)) { + $0.isRegisteringPhone = false + } + } + + func testRegisterPhoneFailure() { + struct Failure: Error {} + let failure = Failure() + + let store = TestStore( + initialState: MyContactState(), + reducer: myContactReducer, + environment: .unimplemented + ) + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.ud.get = { + var ud: UserDiscovery = .unimplemented + ud.sendRegisterFact.run = { _ in throw failure } + return ud + } + + store.send(.registerPhoneTapped) { + $0.isRegisteringPhone = true + } + + store.receive(.didFail(failure.localizedDescription)) { + $0.alert = .error(failure.localizedDescription) + } + + store.receive(.set(\.$isRegisteringPhone, false)) { + $0.isRegisteringPhone = false + } + } + + func testConfirmPhone() { + let contactID = "contact-id".data(using: .utf8)! + let phone = "+123456789" + let confirmationID = "123" + let confirmationCode = "321" + let dbContact = XXModels.Contact(id: contactID) + + var didConfirmWithID: [String] = [] + var didConfirmWithCode: [String] = [] + var didFetchContacts: [XXModels.Contact.Query] = [] + var didSaveContact: [XXModels.Contact] = [] + + let store = TestStore( + initialState: MyContactState( + phone: phone, + phoneConfirmationID: confirmationID + ), + reducer: myContactReducer, + environment: .unimplemented + ) + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.ud.get = { + var ud: UserDiscovery = .unimplemented + ud.confirmFact.run = { id, code in + didConfirmWithID.append(id) + didConfirmWithCode.append(code) + } + return ud + } + store.environment.messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in contactID } + return contact + } + return e2e + } + store.environment.db.run = { + var db: Database = .failing + db.fetchContacts.run = { query in + didFetchContacts.append(query) + return [dbContact] + } + db.saveContact.run = { contact in + didSaveContact.append(contact) + return contact + } + return db + } + + store.send(.set(\.$focusedField, .phoneCode)) { + $0.focusedField = .phoneCode + } + + store.send(.set(\.$phoneConfirmationCode, confirmationCode)) { + $0.phoneConfirmationCode = confirmationCode + } + + store.send(.confirmPhoneTapped) { + $0.focusedField = nil + $0.isConfirmingPhone = true + } + + XCTAssertNoDifference(didConfirmWithID, [confirmationID]) + XCTAssertNoDifference(didConfirmWithCode, [confirmationCode]) + XCTAssertNoDifference(didFetchContacts, [.init(id: [contactID])]) + var expectedSavedContact = dbContact + expectedSavedContact.phone = phone + XCTAssertNoDifference(didSaveContact, [expectedSavedContact]) + + store.receive(.set(\.$phone, "")) { + $0.phone = "" + } + store.receive(.set(\.$phoneConfirmationID, nil)) { + $0.phoneConfirmationID = nil + } + store.receive(.set(\.$phoneConfirmationCode, "")) { + $0.phoneConfirmationCode = "" + } + store.receive(.set(\.$isConfirmingPhone, false)) { + $0.isConfirmingPhone = false + } + } + + func testConfirmPhoneFailure() { + struct Failure: Error {} + let failure = Failure() + + let store = TestStore( + initialState: MyContactState( + phoneConfirmationID: "123" + ), + reducer: myContactReducer, + environment: .unimplemented + ) + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.ud.get = { + var ud: UserDiscovery = .unimplemented + ud.confirmFact.run = { _, _ in throw failure } + return ud + } + + store.send(.confirmPhoneTapped) { + $0.isConfirmingPhone = true + } + + store.receive(.didFail(failure.localizedDescription)) { + $0.alert = .error(failure.localizedDescription) + } + + store.receive(.set(\.$isConfirmingPhone, false)) { + $0.isConfirmingPhone = false + } + } + + func testUnregisterPhone() { + let contactID = "contact-id".data(using: .utf8)! + let phone = "+123456789" + let dbContact = XXModels.Contact(id: contactID, phone: phone) + + var didRemoveFact: [Fact] = [] + var didFetchContacts: [XXModels.Contact.Query] = [] + var didSaveContact: [XXModels.Contact] = [] + + let store = TestStore( + initialState: MyContactState( + contact: dbContact + ), + reducer: myContactReducer, + environment: .unimplemented + ) + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.ud.get = { + var ud: UserDiscovery = .unimplemented + ud.removeFact.run = { didRemoveFact.append($0) } + return ud + } + store.environment.messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in contactID } + return contact + } + return e2e + } + store.environment.db.run = { + var db: Database = .failing + db.fetchContacts.run = { query in + didFetchContacts.append(query) + return [dbContact] + } + db.saveContact.run = { contact in + didSaveContact.append(contact) + return contact + } + return db + } + + store.send(.unregisterPhoneTapped) { + $0.isUnregisteringPhone = true + } + + XCTAssertNoDifference(didRemoveFact, [.init(type: .phone, value: phone)]) + XCTAssertNoDifference(didFetchContacts, [.init(id: [contactID])]) + var expectedSavedContact = dbContact + expectedSavedContact.phone = nil + XCTAssertNoDifference(didSaveContact, [expectedSavedContact]) + + store.receive(.set(\.$isUnregisteringPhone, false)) { + $0.isUnregisteringPhone = false + } + } + + func testUnregisterPhoneFailure() { + struct Failure: Error {} + let failure = Failure() + + let store = TestStore( + initialState: MyContactState( + contact: .init(id: Data(), phone: "+123456789") + ), + reducer: myContactReducer, + environment: .unimplemented + ) + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.ud.get = { + var ud: UserDiscovery = .unimplemented + ud.removeFact.run = { _ in throw failure } + return ud + } + + store.send(.unregisterPhoneTapped) { + $0.isUnregisteringPhone = true + } + + store.receive(.didFail(failure.localizedDescription)) { + $0.alert = .error(failure.localizedDescription) + } + + store.receive(.set(\.$isUnregisteringPhone, false)) { + $0.isUnregisteringPhone = false + } + } + + func testLoadFactsFromClient() { + let contactId = "contact-id".data(using: .utf8)! + let dbContact = XXModels.Contact(id: contactId) + let email = "test@email.com" + let phone = "123456789" + + var didFetchContacts: [XXModels.Contact.Query] = [] + var didSaveContact: [XXModels.Contact] = [] + + let store = TestStore( + initialState: MyContactState(), + reducer: myContactReducer, + environment: .unimplemented + ) + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in contactId } + return contact + } + return e2e + } + store.environment.messenger.ud.get = { + var ud: UserDiscovery = .unimplemented + ud.getFacts.run = { + [ + Fact(type: .email, value: email), + Fact(type: .phone, value: phone), + ] + } + return ud + } + store.environment.db.run = { + var db: Database = .failing + db.fetchContacts.run = { query in + didFetchContacts.append(query) + return [dbContact] + } + db.saveContact.run = { contact in + didSaveContact.append(contact) + return contact + } + return db + } + + store.send(.loadFactsTapped) { + $0.isLoadingFacts = true + } + + XCTAssertNoDifference(didFetchContacts, [.init(id: [contactId])]) + var expectedSavedContact = dbContact + expectedSavedContact.email = email + expectedSavedContact.phone = phone + XCTAssertNoDifference(didSaveContact, [expectedSavedContact]) + + store.receive(.set(\.$isLoadingFacts, false)) { + $0.isLoadingFacts = false + } + } + + func testLoadFactsFromClientFailure() { + struct Failure: Error {} + let failure = Failure() + + let store = TestStore( + initialState: MyContactState(), + reducer: myContactReducer, + environment: .unimplemented + ) + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in throw failure } + return contact + } + return e2e + } + + store.send(.loadFactsTapped) { + $0.isLoadingFacts = true + } + + store.receive(.didFail(failure.localizedDescription)) { + $0.alert = .error(failure.localizedDescription) + } + + store.receive(.set(\.$isLoadingFacts, false)) { + $0.isLoadingFacts = false + } + } + + func testErrorAlert() { + let store = TestStore( + initialState: MyContactState(), + reducer: myContactReducer, + environment: .unimplemented + ) + + let failure = "Something went wrong" + + store.send(.didFail(failure)) { + $0.alert = .error(failure) + } + + store.send(.alertDismissed) { + $0.alert = nil + } + } +} diff --git a/Examples/xx-messenger/Tests/RegisterFeatureTests/RegisterFeatureTests.swift b/Examples/xx-messenger/Tests/RegisterFeatureTests/RegisterFeatureTests.swift index 6c802874d64bbd0dcf7e0ac5b9fee0de7e136d8f..8da1e1f15eb79dccd3f05c0a9ea656ede51d4a35 100644 --- a/Examples/xx-messenger/Tests/RegisterFeatureTests/RegisterFeatureTests.swift +++ b/Examples/xx-messenger/Tests/RegisterFeatureTests/RegisterFeatureTests.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import CustomDump import XCTest import XXClient import XXMessengerClient diff --git a/Examples/xx-messenger/Tests/SendRequestFeatureTests/SendRequestFeatureTests.swift b/Examples/xx-messenger/Tests/SendRequestFeatureTests/SendRequestFeatureTests.swift index cec3587e845624edc68ef537b00604b8a577c13c..3f35d3dfb2a6488ae2281eff7d1e468d0d0d8f8a 100644 --- a/Examples/xx-messenger/Tests/SendRequestFeatureTests/SendRequestFeatureTests.swift +++ b/Examples/xx-messenger/Tests/SendRequestFeatureTests/SendRequestFeatureTests.swift @@ -1,5 +1,6 @@ import Combine import ComposableArchitecture +import CustomDump import XCTest import XXClient import XXModels diff --git a/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift b/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift index 44731c1f16d41e0b95ab3ff1675f654d1ab2fcd5..33f1edb9d61d7186503b63f90c1f73f11251185f 100644 --- a/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift +++ b/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import ContactFeature +import CustomDump import XCTest import XXClient import XXMessengerClient @@ -64,6 +65,8 @@ final class UserSearchFeatureTests: XCTestCase { $0.failure = nil } + XCTAssertNoDifference(didSearchWithQuery, [.init(username: "Username")]) + store.receive(.didSucceed(contacts)) { $0.isSearching = false $0.failure = nil diff --git a/Examples/xx-messenger/XXMessenger.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/xx-messenger/XXMessenger.xcworkspace/xcshareddata/swiftpm/Package.resolved index 45de0ce85881cbcd30f418b3ac71b0983257de07..42a8b2ba71e97e1f4f1a04cdf740a1fb59c1242d 100644 --- a/Examples/xx-messenger/XXMessenger.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/xx-messenger/XXMessenger.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture.git", "state" : { - "revision" : "cbe013b42b3c368957f8f882c960b93845e1589d", - "version" : "0.40.1" + "revision" : "9ea8c763061287052a68d5e6723fed45e898b7d9", + "version" : "0.40.2" } }, { @@ -75,10 +75,10 @@ { "identity" : "swift-custom-dump", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-custom-dump", + "location" : "https://github.com/pointfreeco/swift-custom-dump.git", "state" : { - "revision" : "21ec1d717c07cea5a026979cb0471dd95c7087e7", - "version" : "0.5.0" + "revision" : "c9b6b940d95c0a925c63f6858943415714d8a981", + "version" : "0.5.2" } }, { @@ -95,8 +95,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay.git", "state" : { - "revision" : "38bc9242e4388b80bd23ddfdf3071428859e3260", - "version" : "0.4.0" + "revision" : "30314f1ece684dd60679d598a9b89107557b67d9", + "version" : "0.4.1" } } ], diff --git a/Package.resolved b/Package.resolved index 6741fe9d93c193203c994ec7bd191103eecea29b..225eb0fdb5e6eb944de29fa3bc030fed309735c0 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump.git", "state" : { - "revision" : "21ec1d717c07cea5a026979cb0471dd95c7087e7", - "version" : "0.5.0" + "revision" : "c9b6b940d95c0a925c63f6858943415714d8a981", + "version" : "0.5.2" } }, { @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay.git", "state" : { - "revision" : "38bc9242e4388b80bd23ddfdf3071428859e3260", - "version" : "0.4.0" + "revision" : "30314f1ece684dd60679d598a9b89107557b67d9", + "version" : "0.4.1" } } ], diff --git a/Package.swift b/Package.swift index 30e5c604ce0c444f011056ca371ffb100c704351..d34f37017ee095dcf806fe9fab7d6f82482b93e5 100644 --- a/Package.swift +++ b/Package.swift @@ -26,11 +26,11 @@ let package = Package( dependencies: [ .package( url: "https://github.com/pointfreeco/swift-custom-dump.git", - .upToNextMajor(from: "0.5.0") + .upToNextMajor(from: "0.5.2") ), .package( url: "https://github.com/pointfreeco/xctest-dynamic-overlay.git", - .upToNextMajor(from: "0.4.0") + .upToNextMajor(from: "0.4.1") ), .package( url: "https://github.com/kishikawakatsumi/KeychainAccess.git", diff --git a/xcode-remove-caches.sh b/xcode-remove-caches.sh new file mode 100755 index 0000000000000000000000000000000000000000..4d24eab47daf55841f3152f75e371388188612af --- /dev/null +++ b/xcode-remove-caches.sh @@ -0,0 +1,6 @@ +pkill -int com.apple.CoreSimulator.CoreSimulatorService +killall Xcode +rm -rf "$(getconf DARWIN_USER_CACHE_DIR)/org.llvm.clang/ModuleCache" +rm -rf "$(getconf DARWIN_USER_CACHE_DIR)/org.llvm.clang.$(whoami)/ModuleCache" +rm -rf ~/Library/Developer/Xcode/DerivedData/* +rm -rf ~/Library/Caches/com.apple.dt.Xcode/*