diff --git a/Examples/xx-messenger/Sources/MyContactFeature/MyContactFeature.swift b/Examples/xx-messenger/Sources/MyContactFeature/MyContactFeature.swift index c08ed6b75635f299af58763af63e5c5627635b0e..99486c8af83cb5b66107523a65de13c72b0130a3 100644 --- a/Examples/xx-messenger/Sources/MyContactFeature/MyContactFeature.swift +++ b/Examples/xx-messenger/Sources/MyContactFeature/MyContactFeature.swift @@ -10,21 +10,39 @@ 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, phone: String = "", + phoneConfirmationID: String? = nil, + phoneConfirmationCode: String = "", + isRegisteringPhone: Bool = false, + isConfirmingPhone: 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.phone = phone + self.phoneConfirmationID = phoneConfirmationID + self.phoneConfirmationCode = phoneConfirmationCode + self.isRegisteringPhone = isRegisteringPhone + self.isConfirmingPhone = isConfirmingPhone self.isLoadingFacts = isLoadingFacts self.alert = alert } @@ -32,7 +50,15 @@ public struct MyContactState: Equatable { 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 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 isLoadingFacts: Bool public var alert: AlertState<MyContactAction>? } @@ -41,8 +67,10 @@ 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) @@ -103,13 +131,101 @@ public let myContactReducer = Reducer<MyContactState, MyContactAction, MyContact return .none case .registerEmailTapped: - return .none + 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: return .none case .registerPhoneTapped: - return .none + 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: return .none diff --git a/Examples/xx-messenger/Sources/MyContactFeature/MyContactView.swift b/Examples/xx-messenger/Sources/MyContactFeature/MyContactView.swift index 7eb8e7aaddbc5716dcb064eb33b62cbfe1ddb7bf..3f65ba8044fc24f163e0ff84e46a1f38e04eb0b5 100644 --- a/Examples/xx-messenger/Sources/MyContactFeature/MyContactView.swift +++ b/Examples/xx-messenger/Sources/MyContactFeature/MyContactView.swift @@ -15,14 +15,30 @@ public struct MyContactView: View { contact = state.contact focusedField = state.focusedField email = state.email + emailConfirmation = state.emailConfirmationID != nil + emailCode = state.emailConfirmationCode + isRegisteringEmail = state.isRegisteringEmail + isConfirmingEmail = state.isConfirmingEmail phone = state.phone + phoneConfirmation = state.phoneConfirmationID != nil + phoneCode = state.phoneConfirmationCode + isRegisteringPhone = state.isRegisteringPhone + isConfirmingPhone = state.isConfirmingPhone 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 phone: String + var phoneConfirmation: Bool + var phoneCode: String + var isRegisteringPhone: Bool + var isConfirmingPhone: Bool var isLoadingFacts: Bool } @@ -56,10 +72,45 @@ public struct MyContactView: View { .focused($focusedField, equals: .email) .textInputAutocapitalization(.never) .disableAutocorrection(true) - Button { - viewStore.send(.registerEmailTapped) - } label: { - Text("Register") + .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 { @@ -90,10 +141,45 @@ public struct MyContactView: View { .focused($focusedField, equals: .phone) .textInputAutocapitalization(.never) .disableAutocorrection(true) - Button { - viewStore.send(.registerPhoneTapped) - } label: { - Text("Register") + .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 { diff --git a/Examples/xx-messenger/Tests/MyContactFeatureTests/MyContactFeatureTests.swift b/Examples/xx-messenger/Tests/MyContactFeatureTests/MyContactFeatureTests.swift index 9c30f7df6453962dfd11413183d082f8195ece20..a4f25954cd3989c6d172b1fddf0a9fb2f2589ef3 100644 --- a/Examples/xx-messenger/Tests/MyContactFeatureTests/MyContactFeatureTests.swift +++ b/Examples/xx-messenger/Tests/MyContactFeatureTests/MyContactFeatureTests.swift @@ -60,6 +60,9 @@ final class MyContactFeatureTests: XCTestCase { func testRegisterEmail() { let email = "test@email.com" + let confirmationID = "123" + + var didSendRegisterFact: [Fact] = [] let store = TestStore( initialState: MyContactState(), @@ -67,11 +70,190 @@ final class MyContactFeatureTests: XCTestCase { 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) + 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() { @@ -85,7 +267,10 @@ final class MyContactFeatureTests: XCTestCase { } func testRegisterPhone() { - let phone = "123456789" + let phone = "+123456789" + let confirmationID = "123" + + var didSendRegisterFact: [Fact] = [] let store = TestStore( initialState: MyContactState(), @@ -93,13 +278,192 @@ final class MyContactFeatureTests: XCTestCase { 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) + 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 store = TestStore( initialState: MyContactState(),