diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index df9888f797..13ef95d119 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -73,6 +73,12 @@ public enum AuthenticationFlow { public enum AuthView: Hashable { case passwordRecovery + /// A pushed email and password sign-in screen, ``SignInView`` by default. Reached with + /// ``AuthMethodPickerState/goToEmailSignIn``. + case signIn + /// A pushed email and password sign-up screen, ``SignUpView`` by default. Reached with + /// ``EmailAuthContentState/goToSignUp``. + case signUp case emailLink case updatePassword case mfaEnrollment @@ -159,6 +165,12 @@ public class Navigator { public func clear() { routes.removeAll() } + + /// Removes pushed ``AuthView/signIn`` and ``AuthView/signUp`` screens, which have nothing left + /// to do once the user is signed in. + func removeEmailRoutes() { + routes.removeAll { $0 == .signIn || $0 == .signUp } + } } @MainActor @@ -292,6 +304,7 @@ public final class AuthService { isReauthenticating = false legacySignInRecovery = nil suggestedEmailAddress = nil + navigator.clear() updateAuthenticationState() } @@ -1243,7 +1256,13 @@ extension AuthService { switch option.id { case EmailAuthProviderID: + // An app whose first screen has no email form reached email sign-in through a pushed + // route, so return to that screen for the suggested address. + let usedEmailRoute = navigator.routes.contains { $0 == .signIn || $0 == .signUp } navigator.clear() + if usedEmailRoute { + navigator.push(.signIn) + } case emailLinkSignInMethod: navigator.clear() navigator.push(.emailLink) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift index 32852e8c47..12c46ac05c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift @@ -52,7 +52,8 @@ public struct AuthPickerContent< public var body: some View { @Bindable var authService = authService content() - .sheet(isPresented: $authService.isPresented) { + // Reopening the sheet starts from the first screen, not a half-finished pushed one. + .sheet(isPresented: $authService.isPresented, onDismiss: authService.navigator.clear) { @Bindable var navigator = authService.navigator NavigationStack(path: $navigator.routes) { root @@ -81,6 +82,13 @@ public struct AuthPickerContent< .environment(authService) } .interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled) + // Close pushed email screens however the user got signed in, including account + // conflicts the library resolves by signing in for them. + .onChange(of: authService.authenticationState) { _, state in + if state == .authenticated { + authService.navigator.removeEmailRoutes() + } + } // Apply account conflict handling at NavigationStack level .accountConflictHandler() // Apply MFA handling at NavigationStack level diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index db685ca1fc..daaf2266e2 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -71,9 +71,9 @@ public struct AuthPickerView: View { /// account conflicts and errors are handled the same as the default buttons. /// /// Your screen replaces the logo, email sign-in, terms and the large "Sign in" title (the close - /// button stays), so show your own heading, email entry (for example one built around - /// ``EmailAuthView``) and any legal text you need. To keep the stock screen and only lay out - /// the provider buttons, use ``DefaultAuthPickerContent/init(authMethodPicker:)``. + /// button stays), so show your own heading, email entry (for example ``EmailAuthScreen``) and + /// any legal text you need. To keep the stock screen and only lay out the provider buttons, use + /// ``DefaultAuthPickerContent/init(authMethodPicker:)``. /// /// Example: /// ```swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift index 79f445e1ae..c5f863f7d6 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -95,7 +95,9 @@ struct AuthMethodPickerStateReader: View { providers: authService.registeredProviders, select: selectProvider, isEmailLinkEnabled: authService.emailLinkSignInEnabled, - selectEmailLink: authService.selectEmailLink + selectEmailLink: authService.selectEmailLink, + isEmailSignInEnabled: authService.emailPasswordSignInEnabled, + goToEmailSignIn: { authService.navigator.push(.signIn) } )) } @@ -137,15 +139,28 @@ public struct AuthMethodPickerState { /// Starts email-link sign-in, the same as the stock email-link button. public let selectEmailLink: @MainActor () -> Void + /// Whether email and password sign-in is enabled on the ``AuthService``. + public let isEmailSignInEnabled: Bool + + /// Pushes ``AuthView/signIn``, an email and password sign-in screen of its own, for a first + /// screen that shows only provider buttons and a "Continue with email" button. Use it from + /// `AuthPickerView`'s whole-screen `pickerContent`; ``DefaultAuthPickerContent`` already shows + /// the email form. + public let goToEmailSignIn: @MainActor () -> Void + /// Creates a state, for example to preview a custom layout with stub actions. public init(providers: [AuthProviderUI], select: @escaping @MainActor (AuthProviderUI) -> Void, isEmailLinkEnabled: Bool, - selectEmailLink: @escaping @MainActor () -> Void) { + selectEmailLink: @escaping @MainActor () -> Void, + isEmailSignInEnabled: Bool = false, + goToEmailSignIn: @escaping @MainActor () -> Void = {}) { self.providers = providers self.select = select self.isEmailLinkEnabled = isEmailLinkEnabled self.selectEmailLink = selectEmailLink + self.isEmailSignInEnabled = isEmailSignInEnabled + self.goToEmailSignIn = goToEmailSignIn } /// Hands a provider sign-in result to the same handlers the default provider buttons use: diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift index 2d5762c3ca..316b0c722c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift @@ -42,6 +42,10 @@ public struct DefaultAuthPickerDestination: View { switch screen { case AuthView.passwordRecovery: PasswordRecoveryView() + case AuthView.signIn: + SignInView() + case AuthView.signUp: + SignUpView() case AuthView.emailLink: EmailLinkView() case AuthView.updatePassword: diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift new file mode 100644 index 0000000000..c6a63a756c --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift @@ -0,0 +1,159 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuthUIComponents +import SwiftUI + +private enum FocusableField: Hashable { + case email + case password + case confirmPassword +} + +/// The stock content of ``EmailAuthScreen``. +@MainActor +public struct DefaultEmailAuthContent: View { + public init(state: EmailAuthContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + @FocusState private var focus: FocusableField? + private let state: EmailAuthContentState + + public var body: some View { + VStack(spacing: 16) { + AuthTextField( + text: state.email, + label: authService.string.emailFieldLabel, + prompt: authService.string.emailInputLabel, + keyboardType: .emailAddress, + contentType: .emailAddress, + validations: [ + FormValidators.email, + ], + maintainsValidationMessage: state.flow == .signUp, + onSubmit: { _ in + self.focus = .password + }, + leading: { + Image(systemName: "at") + } + ) + .focused($focus, equals: .email) + .accessibilityIdentifier("email-field") + AuthTextField( + text: state.password, + label: authService.string.passwordFieldLabel, + prompt: authService.string.passwordInputLabel, + contentType: .password, + isSecureTextField: true, + validations: state.flow == .signUp ? [ + FormValidators.atLeast6Characters + ] : [], + maintainsValidationMessage: state.flow == .signUp, + onSubmit: { _ in + if state.flow == .signIn { + state.signIn() + } else { + focus = .confirmPassword + } + }, + leading: { + Image(systemName: "lock") + } + ) + .submitLabel(.go) + .focused($focus, equals: .password) + .accessibilityIdentifier("password-field") + if state.flow == .signIn { + Button(action: state.goToPasswordRecovery) { + Text(authService.string.passwordButtonLabel) + .authCustomFont(.body, weight: .medium) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .accessibilityIdentifier("password-recovery-button") + } + + if state.flow == .signUp { + AuthTextField( + text: state.confirmPassword, + label: authService.string.confirmPasswordFieldLabel, + prompt: authService.string.confirmPasswordInputLabel, + contentType: .password, + isSecureTextField: true, + validations: [ + FormValidators.confirmPassword(password: state.password.wrappedValue), + ], + maintainsValidationMessage: true, + onSubmit: { _ in + state.signUp() + }, + leading: { + Image(systemName: "lock") + } + ) + .submitLabel(.go) + .focused($focus, equals: .confirmPassword) + .accessibilityIdentifier("confirm-password-field") + } + + Button { + if state.flow == .signIn { + state.signIn() + } else { + state.signUp() + } + } label: { + if !state.isLoading { + Text( + state.flow == .signIn + ? authService.string.signInWithEmailButtonLabel + : authService.string.signUpWithEmailButtonLabel + ) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } else { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + } + .disabled(!state.isValid) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .authCTAButtonStyle() + .accessibilityIdentifier("sign-in-button") + } + Button(action: state.switchFlow) { + HStack(spacing: 4) { + Text( + state.flow == .signIn + ? authService.string.dontHaveAnAccountYetLabel + : authService.string.alreadyHaveAnAccountLabel + ) + .authCustomFont(.body) + .foregroundStyle(Color(.label)) + Text( + state.flow == .signUp + ? authService.string.emailLoginFlowLabel + : authService.string.emailSignUpFlowLabel + ) + .authCustomFont(.body, weight: .medium) + } + } + .accessibilityIdentifier("switch-auth-flow") + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift new file mode 100644 index 0000000000..6955b65dcd --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift @@ -0,0 +1,73 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The stock content of ``EmailLinkScreen``. +@MainActor +public struct DefaultEmailLinkContent: View { + public init(state: EmailLinkContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: EmailLinkContentState + + public var body: some View { + VStack(spacing: 24) { + AuthTextField( + text: state.email, + label: authService.string.signInLinkEmailFieldLabel, + prompt: authService.string.emailInputLabel, + keyboardType: .emailAddress, + contentType: .emailAddress, + validations: [ + FormValidators.email, + ], + leading: { + Image(systemName: "at") + } + ) + .accessibilityIdentifier("email-link-email-field") + Button(action: state.sendSignInLink) { + Text(authService.string.sendEmailLinkButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .disabled(!state.isValid) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle(authService.string.signInWithEmailLinkViewTitle) + .safeAreaPadding() + .alert( + authService.string.signInWithEmailLinkViewTitle, + isPresented: Binding( + get: { state.didSend }, + set: { isPresented in + if !isPresented { + state.dismissAlert() + } + } + ) + ) { + Button(authService.string.okButtonLabel) {} + } message: { + Text(authService.string.signInWithEmailLinkViewMessage) + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterPhoneNumberContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterPhoneNumberContent.swift new file mode 100644 index 0000000000..1b5745afa8 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterPhoneNumberContent.swift @@ -0,0 +1,74 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The stock content of ``EnterPhoneNumberScreen``. +@MainActor +public struct DefaultEnterPhoneNumberContent: View { + public init(state: PhoneNumberContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: PhoneNumberContentState + + public var body: some View { + VStack(spacing: 16) { + Text(authService.string.enterPhoneNumberPlaceholder) + .authFont(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top) + + AuthTextField( + text: state.phoneNumber, + label: authService.string.phoneFieldLabel, + prompt: authService.string.enterPhoneNumberPlaceholder, + keyboardType: .phonePad, + contentType: .telephoneNumber, + validations: [ + FormValidators.phoneNumber, + ], + onChange: { _ in } + ) { + CountrySelector( + selectedCountry: state.selectedCountry, + enabled: !state.isLoading + ) + } + + Button(action: state.sendCode) { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.sendCodeButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .authCTAButtonStyle() + .disabled(state.isLoading || !state.isValid) + .padding(.top, 8) + + Spacer() + } + .navigationTitle(authService.string.phoneSignInTitle) + .padding(.horizontal) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift new file mode 100644 index 0000000000..9b724a838d --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift @@ -0,0 +1,91 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The stock content of ``EnterVerificationCodeScreen``. +@MainActor +public struct DefaultEnterVerificationCodeContent: View { + public init(state: VerificationCodeContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: VerificationCodeContentState + + public var body: some View { + VStack(spacing: 32) { + VStack(spacing: 16) { + VStack(spacing: 8) { + Text(authService.string.sentCodeMessage(phoneNumber: state.fullPhoneNumber)) + .authFont(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + + Button(action: state.changeNumber) { + Text(authService.string.changeNumberButtonLabel) + .authFont(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + } + .disabled(state.isLoading) + } + .padding(.bottom) + .frame(maxWidth: .infinity, alignment: .leading) + + VerificationCodeInputField( + code: state.verificationCode, + validations: [ + FormValidators.verificationCode, + ], + maintainsValidationMessage: true + ) + + Button(action: state.verify) { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.verifyAndSignInButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .authCTAButtonStyle() + .disabled(state.isLoading || !state.isValid) + + Button(action: state.resendCode) { + HStack(spacing: 4) { + Text(authService.string.resendCodeLabel) + if state.resendCountdown > 0 { + Text(Duration.seconds(state.resendCountdown), format: .time(pattern: .minuteSecond)) + .monospacedDigit() + } + } + .authCustomFont(.body) + .frame(maxWidth: .infinity) + } + .disabled(state.isLoading || state.resendCountdown > 0) + .accessibilityIdentifier("resend-code-button") + } + + Spacer() + } + .navigationTitle(authService.string.enterVerificationCodeTitle) + .navigationBarTitleDisplayMode(.large) + .padding(.horizontal) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift new file mode 100644 index 0000000000..f2fcbdfa84 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift @@ -0,0 +1,92 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The stock content of ``PasswordRecoveryScreen``. +@MainActor +public struct DefaultPasswordRecoveryContent: View { + public init(state: PasswordRecoveryContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: PasswordRecoveryContentState + + public var body: some View { + VStack(spacing: 24) { + AuthTextField( + text: state.email, + label: authService.string.passwordRecoveryEmailFieldLabel, + prompt: authService.string.emailInputLabel, + keyboardType: .emailAddress, + contentType: .emailAddress, + validations: [ + FormValidators.email, + ], + leading: { + Image(systemName: "at") + } + ) + Button(action: state.sendResetLink) { + Text(authService.string.forgotPasswordInputLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .disabled(!state.isValid) + .frame(maxWidth: .infinity) + .authCTAButtonStyle() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle(authService.string.passwordRecoveryTitle) + .safeAreaPadding() + .sheet(isPresented: Binding( + get: { state.didSend }, + set: { isPresented in + if !isPresented { + state.hideSuccess() + } + } + )) { + successSheet + } + } + + @ViewBuilder + @MainActor + private var successSheet: some View { + VStack { + Text(authService.string.passwordRecoveryEmailSentTitle) + .authFont(.largeTitle) + .fontWeight(.bold) + .padding() + Text(authService.string.passwordRecoveryHelperMessage) + .authCustomFont(.body) + .padding() + + Divider() + + Text(String(format: authService.string.passwordRecoveryEmailSentMessage, state.sentEmail)) + .authCustomFont(.body) + .padding() + + Divider() + + Button(authService.string.okButtonLabel, action: state.dismissSuccess) + .padding() + } + .padding() + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift new file mode 100644 index 0000000000..f4b74e297a --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift @@ -0,0 +1,264 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The state and actions of the email and password form, passed to ``EmailAuthScreen``'s +/// content. +public struct EmailAuthContentState { + /// The email address. The screen fills it in from ``AuthService/suggestedEmailAddress``. + public let email: Binding + + /// The password. + public let password: Binding + + /// The repeated password, checked against ``password`` when ``flow`` is + /// ``AuthenticationFlow/signUp``. + public let confirmPassword: Binding + + /// Signs in with ``email`` and ``password``, routing MFA and account conflicts to their + /// handlers. Does nothing unless ``isValid`` is `true` and ``isLoading`` is `false`. + public let signIn: @MainActor () -> Void + + /// Creates an account with ``email`` and ``password``, routing MFA and account conflicts to + /// their handlers. Does nothing unless ``isValid`` is `true` and ``isLoading`` is `false`. + public let signUp: @MainActor () -> Void + + /// Toggles ``flow`` between sign-in and sign-up, with animation. On a screen with a fixed + /// flow it navigates instead: sign-up pops back and sign-in pushes ``AuthView/signUp``. + public let switchFlow: @MainActor () -> Void + + /// Pushes ``AuthView/signUp``, for a sign-up screen of its own instead of toggling ``flow``. + public let goToSignUp: @MainActor () -> Void + + /// Pushes ``AuthView/passwordRecovery``. + public let goToPasswordRecovery: @MainActor () -> Void + + /// Starts email-link sign-in, the same as the stock email-link button. + public let goToEmailLink: @MainActor () -> Void + + /// Whether the form signs in or signs up. Shared with ``AuthService/authenticationFlow`` + /// unless the screen was created with a fixed flow. + public let flow: AuthenticationFlow + + /// Whether the fields are valid for ``flow``: a valid email and a non-empty password to sign + /// in; a valid email, a password of at least 6 characters and a matching ``confirmPassword`` + /// to sign up. + public let isValid: Bool + + /// Whether a sign-in or sign-up is in flight, or the ``AuthService`` is signing in. + public let isLoading: Bool + + /// The localized message of the last failed attempt. It clears when a field or ``flow`` + /// changes or the next attempt starts. The sheet's error alert still shows for the same + /// failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(email: Binding, + password: Binding, + confirmPassword: Binding, + signIn: @escaping @MainActor () -> Void, + signUp: @escaping @MainActor () -> Void, + switchFlow: @escaping @MainActor () -> Void, + goToSignUp: @escaping @MainActor () -> Void, + goToPasswordRecovery: @escaping @MainActor () -> Void, + goToEmailLink: @escaping @MainActor () -> Void, + flow: AuthenticationFlow, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.email = email + self.password = password + self.confirmPassword = confirmPassword + self.signIn = signIn + self.signUp = signUp + self.switchFlow = switchFlow + self.goToSignUp = goToSignUp + self.goToPasswordRecovery = goToPasswordRecovery + self.goToEmailLink = goToEmailLink + self.flow = flow + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The email and password form, with its content drawn by you. +/// +/// The screen signs in and signs up, switches the flow, navigates, and routes MFA, account +/// conflicts and errors. Your content only draws the UI from the ``EmailAuthContentState`` it +/// receives. Wrap ``DefaultEmailAuthContent`` to keep the stock markup. +/// +/// `AuthPickerView`'s `pickerContent { state in ... }` replaces the whole first screen, so add +/// your own provider buttons calling `state.select`, or the stock ones with +/// ``DefaultAuthMethodPicker``. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerContent { +/// ScrollView { +/// VStack { +/// EmailAuthScreen { state in +/// TextField("Email", text: state.email) +/// SecureField("Password", text: state.password) +/// if let errorMessage = state.errorMessage { +/// Text(errorMessage) +/// } +/// Button("Sign in", action: state.signIn) +/// .disabled(!state.isValid || state.isLoading) +/// } +/// DefaultAuthMethodPicker() +/// } +/// } +/// } +/// ``` +@MainActor +public struct EmailAuthScreen: View { + /// Creates the screen. Leave `flow` `nil` to follow ``AuthService/authenticationFlow`` and + /// toggle it in place, as the stock first screen does. + /// + /// Pass a `flow` to fix it and make ``EmailAuthContentState/switchFlow`` navigate instead: + /// `.signIn` pushes ``AuthView/signUp``, so a first screen fixed to `.signIn` gives the stock + /// "Sign up" link its own sign-up screen, as ``SignInView`` does for ``AuthView/signIn``. Use + /// `.signUp` only for an ``AuthView/signUp`` destination, where `switchFlow` pops back to the + /// screen that pushed it. + public init(flow: AuthenticationFlow? = nil, + @ViewBuilder content: @escaping (EmailAuthContentState) -> Content) { + fixedFlow = flow + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.mfaHandler) private var mfaHandler + @Environment(\.reportError) private var reportError + @State private var email = "" + @State private var password = "" + @State private var confirmPassword = "" + @State private var isWorking = false + @State private var errorMessage: String? + + private let fixedFlow: AuthenticationFlow? + private let content: (EmailAuthContentState) -> Content + + public var body: some View { + content(EmailAuthContentState( + email: $email, + password: $password, + confirmPassword: $confirmPassword, + signIn: signIn, + signUp: signUp, + switchFlow: switchFlow, + goToSignUp: { authService.navigator.push(.signUp) }, + goToPasswordRecovery: { authService.navigator.push(.passwordRecovery) }, + goToEmailLink: { authService.selectEmailLink() }, + flow: flow, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .onChange(of: email) { errorMessage = nil } + .onChange(of: password) { errorMessage = nil } + .onChange(of: confirmPassword) { errorMessage = nil } + .onChange(of: authService.authenticationFlow) { errorMessage = nil } + .onAppear { + if let suggestedEmailAddress = authService.suggestedEmailAddress { + email = suggestedEmailAddress + } + } + .onChange(of: authService.suggestedEmailAddress) { _, suggestedEmailAddress in + if let suggestedEmailAddress { + email = suggestedEmailAddress + } + } + } + + private var flow: AuthenticationFlow { + fixedFlow ?? authService.authenticationFlow + } + + private var isValid: Bool { + return if flow == .signIn { + FormValidators.email.isValid(input: email) && !password.isEmpty + } else { + FormValidators.email.isValid(input: email) && + FormValidators.atLeast6Characters.isValid(input: password) && + FormValidators.confirmPassword(password: password).isValid(input: confirmPassword) + } + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func switchFlow() { + switch fixedFlow { + case .signUp: + authService.navigator.pop() + return + case .signIn: + authService.navigator.push(.signUp) + return + case nil: + break + } + withAnimation { + authService.authenticationFlow = + authService.authenticationFlow == .signIn ? .signUp : .signIn + } + } + + private func signIn() { + submit { [email, password] in + try await authService.signIn(email: email, password: password) + } + } + + private func signUp() { + submit { [email, password] in + try await authService.createUser(email: email, password: password) + } + } + + private func submit(_ attempt: @escaping @MainActor () async throws -> SignInOutcome) { + guard isValid, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + if case let .mfaRequired(mfaInfo) = try await attempt() { + mfaHandler?(mfaInfo) + } + } catch { + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return + } + + reportError?(error) + // The legacy sign-in recovery sheet explains this failure, so there is no message. + if case .legacySignInRecoveryPresented = error as? AuthServiceError { + return + } + + errorMessage = authService.string.localizedErrorMessage(for: error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index 7d06240f4a..96cc6a6961 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -18,232 +18,17 @@ // // Created by Russell Wheatley on 20/03/2025. // -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI -private enum FocusableField: Hashable { - case email - case password - case confirmPassword -} - @MainActor public struct EmailAuthView { - @Environment(AuthService.self) private var authService - @Environment(\.accountConflictHandler) private var accountConflictHandler - @Environment(\.mfaHandler) private var mfaHandler - @Environment(\.reportError) private var reportError - - @State private var email = "" - @State private var password = "" - @State private var confirmPassword = "" - - @FocusState private var focus: FocusableField? - public init() {} - - private var isValid: Bool { - return if authService.authenticationFlow == .signIn { - FormValidators.email.isValid(input: email) && !password.isEmpty - } else { - FormValidators.email.isValid(input: email) && - FormValidators.atLeast6Characters.isValid(input: password) && - FormValidators.confirmPassword(password: password).isValid(input: confirmPassword) - } - } - - private func signInWithEmailPassword() async throws { - do { - let outcome = try await authService.signIn(email: email, password: password) - - // Handle MFA at view level - if case let .mfaRequired(mfaInfo) = outcome, - let onMFA = mfaHandler { - onMFA(mfaInfo) - return - } - } catch { - if case let AuthServiceError.accountConflict(ctx) = error, - let onConflict = accountConflictHandler { - onConflict(ctx) - return - } - - reportError?(error) - if case .legacySignInRecoveryPresented = error as? AuthServiceError { - return - } - - throw error - } - } - - private func createUserWithEmailPassword() async throws { - do { - let outcome = try await authService.createUser(email: email, password: password) - - // Handle MFA at view level - if case let .mfaRequired(mfaInfo) = outcome, - let onMFA = mfaHandler { - onMFA(mfaInfo) - return - } - } catch { - if case let AuthServiceError.accountConflict(ctx) = error, - let onConflict = accountConflictHandler { - onConflict(ctx) - return - } - - reportError?(error) - throw error - } - } } extension EmailAuthView: View { public var body: some View { - VStack(spacing: 16) { - AuthTextField( - text: $email, - label: authService.string.emailFieldLabel, - prompt: authService.string.emailInputLabel, - keyboardType: .emailAddress, - contentType: .emailAddress, - validations: [ - FormValidators.email, - ], - maintainsValidationMessage: authService.authenticationFlow == .signUp, - onSubmit: { _ in - self.focus = .password - }, - leading: { - Image(systemName: "at") - } - ) - .focused($focus, equals: .email) - .accessibilityIdentifier("email-field") - AuthTextField( - text: $password, - label: authService.string.passwordFieldLabel, - prompt: authService.string.passwordInputLabel, - contentType: .password, - isSecureTextField: true, - validations: authService.authenticationFlow == .signUp ? [ - FormValidators.atLeast6Characters - ] : [], - maintainsValidationMessage: authService.authenticationFlow == .signUp, - onSubmit: { _ in - Task { try await signInWithEmailPassword() } - }, - leading: { - Image(systemName: "lock") - } - ) - .submitLabel(.go) - .focused($focus, equals: .password) - .accessibilityIdentifier("password-field") - if authService.authenticationFlow == .signIn { - Button { - authService.navigator.push(.passwordRecovery) - } label: { - Text(authService.string.passwordButtonLabel) - .authCustomFont(.body, weight: .medium) - .frame(maxWidth: .infinity, alignment: .trailing) - } - .accessibilityIdentifier("password-recovery-button") - } - - if authService.authenticationFlow == .signUp { - AuthTextField( - text: $confirmPassword, - label: authService.string.confirmPasswordFieldLabel, - prompt: authService.string.confirmPasswordInputLabel, - contentType: .password, - isSecureTextField: true, - validations: [ - FormValidators.confirmPassword(password: password), - ], - maintainsValidationMessage: true, - onSubmit: { _ in - Task { try await createUserWithEmailPassword() } - }, - leading: { - Image(systemName: "lock") - } - ) - .submitLabel(.go) - .focused($focus, equals: .confirmPassword) - .accessibilityIdentifier("confirm-password-field") - } - - Button(action: { - Task { - if authService.authenticationFlow == .signIn { - try await signInWithEmailPassword() - } else { - try await createUserWithEmailPassword() - } - } - }) { - if authService.authenticationState != .authenticating { - Text( - authService.authenticationFlow == .signIn - ? authService.string.signInWithEmailButtonLabel - : authService.string.signUpWithEmailButtonLabel - ) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } else { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - } - .disabled(!isValid) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .authCTAButtonStyle() - .accessibilityIdentifier("sign-in-button") - } - Button(action: { - withAnimation { - authService.authenticationFlow = - authService - .authenticationFlow == .signIn ? .signUp : .signIn - } - }) { - HStack(spacing: 4) { - Text( - authService - .authenticationFlow == .signIn - ? authService.string.dontHaveAnAccountYetLabel - : authService.string.alreadyHaveAnAccountLabel - ) - .authCustomFont(.body) - .foregroundStyle(Color(.label)) - Text( - authService.authenticationFlow == .signUp - ? authService.string.emailLoginFlowLabel - : authService.string.emailSignUpFlowLabel - ) - .authCustomFont(.body, weight: .medium) - } - } - .accessibilityIdentifier("switch-auth-flow") - .onAppear { - if let suggestedEmailAddress = authService.suggestedEmailAddress { - email = suggestedEmailAddress - } - } - .onChange(of: authService.suggestedEmailAddress) { _, suggestedEmailAddress in - if let suggestedEmailAddress { - email = suggestedEmailAddress - } - } + EmailAuthScreen { DefaultEmailAuthContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift new file mode 100644 index 0000000000..11577d6902 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift @@ -0,0 +1,173 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SwiftUI + +/// The state and actions of the email-link screen, passed to ``EmailLinkScreen``'s content. +public struct EmailLinkContentState { + /// The email address to send the sign-in link to. The screen fills it in from + /// ``AuthService/suggestedEmailAddress``. + public let email: Binding + + /// Sends a sign-in link to ``email``, then sets ``didSend``. Does nothing unless ``isValid`` is + /// `true` and ``isLoading`` is `false`. + public let sendSignInLink: @MainActor () -> Void + + /// Clears ``didSend``. + public let dismissAlert: @MainActor () -> Void + + /// Whether a sign-in link was sent and ``dismissAlert`` hasn't been called since. + public let didSend: Bool + + /// Whether ``email`` is a valid email address. + public let isValid: Bool + + /// Whether a link is being sent or handled, or the ``AuthService`` is signing in. + public let isLoading: Bool + + /// The localized message of the last failed send or link sign-in. It clears when the email + /// changes or the next attempt starts. The sheet's error alert still shows for the same + /// failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(email: Binding, + sendSignInLink: @escaping @MainActor () -> Void, + dismissAlert: @escaping @MainActor () -> Void, + didSend: Bool, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.email = email + self.sendSignInLink = sendSignInLink + self.dismissAlert = dismissAlert + self.didSend = didSend + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The email-link screen, with its content drawn by you. +/// +/// The screen sends the link, signs in when the app opens it, and routes account conflicts and +/// errors. Your content only draws the UI from the ``EmailLinkContentState`` it receives. Wrap +/// ``DefaultEmailLinkContent`` to keep the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case .emailLink: +/// EmailLinkScreen { state in +/// TextField("Email", text: state.email) +/// Button("Send link", action: state.sendSignInLink) +/// .disabled(!state.isValid || state.isLoading) +/// if state.didSend { +/// Text("Check your inbox") +/// } +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct EmailLinkScreen: View { + public init(@ViewBuilder content: @escaping (EmailLinkContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.reportError) private var reportError + @State private var email = "" + @State private var didSend = false + @State private var isWorking = false + @State private var isHandlingLink = false + @State private var errorMessage: String? + + private let content: (EmailLinkContentState) -> Content + + public var body: some View { + content(EmailLinkContentState( + email: $email, + sendSignInLink: sendSignInLink, + dismissAlert: { didSend = false }, + didSend: didSend, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .onChange(of: email) { errorMessage = nil } + .onOpenURL { url in + // Content with several root views gets this handler once per view; the link is one-time, + // so only the first one handles it. + guard !isHandlingLink else { return } + isHandlingLink = true + errorMessage = nil + Task { + defer { isHandlingLink = false } + do { + try await authService.handleSignInLink(url: url) + } catch { + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return + } + + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } + .onAppear { + if let suggestedEmailAddress = authService.suggestedEmailAddress { + email = suggestedEmailAddress + } + } + .onChange(of: authService.suggestedEmailAddress) { _, suggestedEmailAddress in + if let suggestedEmailAddress { + email = suggestedEmailAddress + } + } + } + + private var isValid: Bool { + CommonUtils.isValidEmail(email) + } + + private var isLoading: Bool { + isWorking || isHandlingLink || authService.authenticationState == .authenticating + } + + private func sendSignInLink() { + guard isValid, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + try await authService.sendEmailSignInLink(email: email) + didSend = true + authService.emailLink = email + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift index c64c86e570..c5b8e776aa 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift @@ -12,106 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor public struct EmailLinkView { - @Environment(AuthService.self) private var authService - @Environment(\.accountConflictHandler) private var accountConflictHandler - @Environment(\.reportError) private var reportError - @State private var email = "" - @State private var showAlert = false - public init() {} - - private func sendEmailLink() async throws { - do { - try await authService.sendEmailSignInLink(email: email) - showAlert = true - } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error - } - } - } } extension EmailLinkView: View { public var body: some View { - VStack(spacing: 24) { - AuthTextField( - text: $email, - label: authService.string.signInLinkEmailFieldLabel, - prompt: authService.string.emailInputLabel, - keyboardType: .emailAddress, - contentType: .emailAddress, - validations: [ - FormValidators.email, - ], - leading: { - Image(systemName: "at") - } - ) - .accessibilityIdentifier("email-link-email-field") - Button { - Task { - try await sendEmailLink() - authService.emailLink = email - } - } label: { - Text(authService.string.sendEmailLinkButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .disabled(!CommonUtils.isValidEmail(email)) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .navigationTitle(authService.string.signInWithEmailLinkViewTitle) - .safeAreaPadding() - .alert( - authService.string.signInWithEmailLinkViewTitle, - isPresented: $showAlert - ) { - Button(authService.string.okButtonLabel) { - showAlert = false - } - } message: { - Text(authService.string.signInWithEmailLinkViewMessage) - } - .onOpenURL { url in - Task { - do { - try await authService.handleSignInLink(url: url) - } catch { - if case let AuthServiceError.accountConflict(ctx) = error, - let onConflict = accountConflictHandler { - onConflict(ctx) - return - } - - reportError?(error) - throw error - } - } - } - .onAppear { - if let suggestedEmailAddress = authService.suggestedEmailAddress { - email = suggestedEmailAddress - } - } - .onChange(of: authService.suggestedEmailAddress) { _, suggestedEmailAddress in - if let suggestedEmailAddress { - email = suggestedEmailAddress - } - } + EmailLinkScreen { DefaultEmailLinkContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift new file mode 100644 index 0000000000..2b8535d3a5 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift @@ -0,0 +1,135 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The state and actions of the phone number screen, passed to ``EnterPhoneNumberScreen``'s +/// content. +public struct PhoneNumberContentState { + /// The national phone number, without the dial code. + public let phoneNumber: Binding + + /// The country whose dial code prefixes ``phoneNumber``. + public let selectedCountry: Binding + + /// Sends a verification code to the full phone number, then pushes + /// ``AuthView/enterVerificationCode(verificationID:fullPhoneNumber:)``. Does nothing unless + /// ``isValid`` is `true` and ``isLoading`` is `false`. + public let sendCode: @MainActor () -> Void + + /// Whether ``phoneNumber`` is non-empty. + public let isValid: Bool + + /// Whether a code is being sent, or the ``AuthService`` is signing in. + public let isLoading: Bool + + /// The localized message of the last failed attempt. It clears when the number or country + /// changes or the next attempt starts. The sheet's error alert still shows for the same failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(phoneNumber: Binding, + selectedCountry: Binding, + sendCode: @escaping @MainActor () -> Void, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.phoneNumber = phoneNumber + self.selectedCountry = selectedCountry + self.sendCode = sendCode + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The phone number screen, with its content drawn by you. +/// +/// The screen sends the code, navigates, and reports errors. Your content only draws the UI from +/// the ``PhoneNumberContentState`` it receives. Wrap ``DefaultEnterPhoneNumberContent`` to keep +/// the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case .enterPhoneNumber: +/// EnterPhoneNumberScreen { state in +/// TextField("Phone number", text: state.phoneNumber) +/// Button("Send code", action: state.sendCode) +/// .disabled(!state.isValid || state.isLoading) +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct EnterPhoneNumberScreen: View { + public init(@ViewBuilder content: @escaping (PhoneNumberContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var phoneNumber: String = "" + @State private var selectedCountry: CountryData = .default + @State private var isWorking = false + @State private var errorMessage: String? + + private let content: (PhoneNumberContentState) -> Content + + public var body: some View { + content(PhoneNumberContentState( + phoneNumber: $phoneNumber, + selectedCountry: $selectedCountry, + sendCode: sendCode, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .onChange(of: phoneNumber) { errorMessage = nil } + .onChange(of: selectedCountry) { errorMessage = nil } + } + + private var isValid: Bool { + !phoneNumber.isEmpty + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func sendCode() { + guard isValid, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + let fullPhoneNumber = selectedCountry.dialCode + phoneNumber + let id = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) + authService.navigator.push(.enterVerificationCode( + verificationID: id, + fullPhoneNumber: fullPhoneNumber + )) + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift index 54e41c1fa8..bfb7664412 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift @@ -12,79 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI struct EnterPhoneNumberView: View { - @Environment(AuthService.self) private var authService - @Environment(\.reportError) private var reportError - @State private var phoneNumber: String = "" - @State private var selectedCountry: CountryData = .default - var body: some View { - VStack(spacing: 16) { - Text(authService.string.enterPhoneNumberPlaceholder) - .authFont(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top) - - AuthTextField( - text: $phoneNumber, - label: authService.string.phoneFieldLabel, - prompt: authService.string.enterPhoneNumberPlaceholder, - keyboardType: .phonePad, - contentType: .telephoneNumber, - validations: [ - FormValidators.phoneNumber, - ], - onChange: { _ in } - ) { - CountrySelector( - selectedCountry: $selectedCountry, - enabled: !(authService.authenticationState == .authenticating) - ) - } - - Button(action: { - Task { - do { - let fullPhoneNumber = selectedCountry.dialCode + phoneNumber - let id = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) - authService.navigator.push(.enterVerificationCode( - verificationID: id, - fullPhoneNumber: fullPhoneNumber - )) - } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error - } - } - } - }) { - if authService.authenticationState == .authenticating { - ProgressView() - .frame(height: 32) - .frame(maxWidth: .infinity) - } else { - Text(authService.string.sendCodeButtonLabel) - .frame(height: 32) - .frame(maxWidth: .infinity) - } - } - .authCTAButtonStyle() - .disabled(authService.authenticationState == .authenticating || phoneNumber.isEmpty) - .padding(.top, 8) - - Spacer() - } - .navigationTitle(authService.string.phoneSignInTitle) - .padding(.horizontal) + EnterPhoneNumberScreen { DefaultEnterPhoneNumberContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift new file mode 100644 index 0000000000..5b681ccc8e --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift @@ -0,0 +1,200 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +import SwiftUI + +/// Seconds between sending a code and allowing a resend. +private let resendDelay: TimeInterval = 60 + +/// The state and actions of the verification code screen, passed to +/// ``EnterVerificationCodeScreen``'s content. +public struct VerificationCodeContentState { + /// The code the user typed. + public let verificationCode: Binding + + /// Signs in with the code, routing MFA and account conflicts to their handlers. Does nothing + /// unless ``isValid`` is `true` and ``isLoading`` is `false`. + public let verify: @MainActor () -> Void + + /// Sends a new code and clears ``verificationCode``. Does nothing while ``resendCountdown`` is + /// above zero or ``isLoading`` is `true`. + public let resendCode: @MainActor () -> Void + + /// Pops back to the phone number screen. + public let changeNumber: @MainActor () -> Void + + /// The number the code was sent to, including the dial code. + public let fullPhoneNumber: String + + /// Seconds left before ``resendCode`` is available; `0` when it is. + public let resendCountdown: Int + + /// Whether ``verificationCode`` has 6 digits. + public let isValid: Bool + + /// Whether a verify or resend is in flight, or the ``AuthService`` is signing in. + public let isLoading: Bool + + /// The localized message of the last failed verify or resend. It clears when the code changes + /// or the next attempt starts. The sheet's error alert still shows for the same failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(verificationCode: Binding, + verify: @escaping @MainActor () -> Void, + resendCode: @escaping @MainActor () -> Void, + changeNumber: @escaping @MainActor () -> Void, + fullPhoneNumber: String, + resendCountdown: Int, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.verificationCode = verificationCode + self.verify = verify + self.resendCode = resendCode + self.changeNumber = changeNumber + self.fullPhoneNumber = fullPhoneNumber + self.resendCountdown = resendCountdown + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The verification code screen, with its content drawn by you. +/// +/// The screen signs in, resends codes, runs the resend countdown, and routes MFA, account +/// conflicts and errors. It reads `verificationID` once; codes it resends replace it +/// internally. Your content only draws the UI from the ``VerificationCodeContentState`` it +/// receives. Wrap ``DefaultEnterVerificationCodeContent`` to keep the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case let .enterVerificationCode(verificationID, fullPhoneNumber): +/// EnterVerificationCodeScreen( +/// verificationID: verificationID, +/// fullPhoneNumber: fullPhoneNumber +/// ) { state in +/// TextField("Code", text: state.verificationCode) +/// Button("Verify", action: state.verify) +/// .disabled(!state.isValid || state.isLoading) +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct EnterVerificationCodeScreen: View { + public init(verificationID: String, + fullPhoneNumber: String, + @ViewBuilder content: @escaping (VerificationCodeContentState) -> Content) { + _verificationID = State(initialValue: verificationID) + self.fullPhoneNumber = fullPhoneNumber + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.mfaHandler) private var mfaHandler + @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.reportError) private var reportError + @State private var verificationID: String + @State private var verificationCode: String = "" + @State private var resendAvailableAt = Date().addingTimeInterval(resendDelay) + @State private var resendCountdown = Int(resendDelay) + @State private var isWorking = false + @State private var errorMessage: String? + + private let fullPhoneNumber: String + private let content: (VerificationCodeContentState) -> Content + + public var body: some View { + content(VerificationCodeContentState( + verificationCode: $verificationCode, + verify: verify, + resendCode: resendCode, + changeNumber: { authService.navigator.pop() }, + fullPhoneNumber: fullPhoneNumber, + resendCountdown: resendCountdown, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .onChange(of: verificationCode) { errorMessage = nil } + .task(id: resendAvailableAt) { + while !Task.isCancelled { + resendCountdown = max(0, Int(resendAvailableAt.timeIntervalSinceNow.rounded(.up))) + guard resendCountdown > 0 else { return } + try? await Task.sleep(for: .seconds(1)) + } + } + } + + private var isValid: Bool { + verificationCode.count == 6 + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func verify() { + guard isValid, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + let credential = PhoneAuthProvider.provider() + .credential(withVerificationID: verificationID, verificationCode: verificationCode) + switch try await authService.signIn(credentials: credential) { + case let .mfaRequired(mfaInfo): + mfaHandler?(mfaInfo) + case .signedIn: + authService.navigator.clear() + } + } catch { + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return + } + + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } + + private func resendCode() { + guard resendCountdown == 0, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + verificationID = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) + verificationCode = "" + resendAvailableAt = Date().addingTimeInterval(resendDelay) + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift index 3a644e713c..4924946f3c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift @@ -12,90 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor struct EnterVerificationCodeView: View { - @Environment(AuthService.self) private var authService - @Environment(\.accountConflictHandler) private var accountConflictHandler - @Environment(\.reportError) private var reportError - @State private var verificationCode: String = "" - let verificationID: String let fullPhoneNumber: String var body: some View { - @Bindable var authService = authService - VStack(spacing: 32) { - VStack(spacing: 16) { - VStack(spacing: 8) { - Text(authService.string.sentCodeMessage(phoneNumber: fullPhoneNumber)) - .authFont(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .leading) - - Button { - authService.navigator.pop() - } label: { - Text(authService.string.changeNumberButtonLabel) - .authFont(.caption) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .padding(.bottom) - .frame(maxWidth: .infinity, alignment: .leading) - - VerificationCodeInputField( - code: $verificationCode, - validations: [ - FormValidators.verificationCode, - ], - maintainsValidationMessage: true - ) - - Button(action: { - Task { - do { - try await authService.signInWithPhoneNumber( - verificationID: verificationID, - verificationCode: verificationCode - ) - authService.navigator.clear() - } catch { - if case let AuthServiceError.accountConflict(ctx) = error, - let onConflict = accountConflictHandler { - onConflict(ctx) - return - } - - reportError?(error) - throw error - } - } - }) { - if authService.authenticationState == .authenticating { - ProgressView() - .frame(height: 32) - .frame(maxWidth: .infinity) - } else { - Text(authService.string.verifyAndSignInButtonLabel) - .frame(height: 32) - .frame(maxWidth: .infinity) - } - } - .authCTAButtonStyle() - .disabled(authService.authenticationState == .authenticating || verificationCode.count != 6) - } - - Spacer() - } - .navigationTitle(authService.string.enterVerificationCodeTitle) - .navigationBarTitleDisplayMode(.large) - .padding(.horizontal) + EnterVerificationCodeScreen( + verificationID: verificationID, + fullPhoneNumber: fullPhoneNumber + ) { DefaultEnterVerificationCodeContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift new file mode 100644 index 0000000000..23f1f95abc --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift @@ -0,0 +1,161 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SwiftUI + +/// The state and actions of the password recovery screen, passed to +/// ``PasswordRecoveryScreen``'s content. +public struct PasswordRecoveryContentState { + /// The email address to send the reset link to. + public let email: Binding + + /// Sends a password reset link to ``email``, then sets ``didSend``. Does nothing unless + /// ``isValid`` is `true` and ``isLoading`` is `false`. + public let sendResetLink: @MainActor () -> Void + + /// Clears ``didSend`` and ``email`` and returns to the first screen of the sheet. + public let dismissSuccess: @MainActor () -> Void + + /// Clears ``didSend``, keeping ``email`` and staying on this screen, for example when the + /// user swipes a success sheet away. + public let hideSuccess: @MainActor () -> Void + + /// Whether a reset link was sent and neither ``dismissSuccess`` nor ``hideSuccess`` has been + /// called since. + public let didSend: Bool + + /// The address the last reset link was sent to; empty until one is sent. + public let sentEmail: String + + /// Whether ``email`` is a valid email address. + public let isValid: Bool + + /// Whether a reset link is being sent, or the ``AuthService`` is signing in. + public let isLoading: Bool + + /// The localized message of the last failed attempt. It clears when the email changes or the + /// next attempt starts. The sheet's error alert still shows for the same failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(email: Binding, + sendResetLink: @escaping @MainActor () -> Void, + dismissSuccess: @escaping @MainActor () -> Void, + hideSuccess: @escaping @MainActor () -> Void, + didSend: Bool, + sentEmail: String, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.email = email + self.sendResetLink = sendResetLink + self.dismissSuccess = dismissSuccess + self.hideSuccess = hideSuccess + self.didSend = didSend + self.sentEmail = sentEmail + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The password recovery screen, with its content drawn by you. +/// +/// The screen sends the reset link, navigates, and reports errors. Your content only draws the +/// UI from the ``PasswordRecoveryContentState`` it receives. Wrap +/// ``DefaultPasswordRecoveryContent`` to keep the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case .passwordRecovery: +/// PasswordRecoveryScreen { state in +/// if state.didSend { +/// Text("Check \(state.sentEmail)") +/// Button("OK", action: state.dismissSuccess) +/// } else { +/// TextField("Email", text: state.email) +/// Button("Send reset link", action: state.sendResetLink) +/// .disabled(!state.isValid || state.isLoading) +/// } +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct PasswordRecoveryScreen: View { + public init(@ViewBuilder content: @escaping (PasswordRecoveryContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var email = "" + @State private var didSend = false + @State private var sentEmail = "" + @State private var isWorking = false + @State private var errorMessage: String? + + private let content: (PasswordRecoveryContentState) -> Content + + public var body: some View { + content(PasswordRecoveryContentState( + email: $email, + sendResetLink: sendResetLink, + dismissSuccess: dismissSuccess, + hideSuccess: { didSend = false }, + didSend: didSend, + sentEmail: sentEmail, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .onChange(of: email) { errorMessage = nil } + } + + private var isValid: Bool { + CommonUtils.isValidEmail(email) + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func sendResetLink() { + guard isValid, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + try await authService.sendPasswordRecoveryEmail(email: email) + sentEmail = email + didSend = true + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } + + private func dismissSuccess() { + didSend = false + email = "" + authService.navigator.clear() + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift index 73a60758e6..93a9a4f5af 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift @@ -12,95 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor public struct PasswordRecoveryView { - @Environment(AuthService.self) private var authService - @State private var email = "" - @State private var showSuccessSheet = false - @State private var sentEmail = "" - public init() {} - - private func sendPasswordRecoveryEmail() async { - do { - try await authService.sendPasswordRecoveryEmail(email: email) - sentEmail = email - showSuccessSheet = true - } catch { - // Error already displayed via modal by AuthService - } - } } extension PasswordRecoveryView: View { public var body: some View { - VStack(spacing: 24) { - AuthTextField( - text: $email, - label: authService.string.passwordRecoveryEmailFieldLabel, - prompt: authService.string.emailInputLabel, - keyboardType: .emailAddress, - contentType: .emailAddress, - validations: [ - FormValidators.email, - ], - leading: { - Image(systemName: "at") - } - ) - Button(action: { - Task { - await sendPasswordRecoveryEmail() - } - }) { - Text(authService.string.forgotPasswordInputLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .disabled(!CommonUtils.isValidEmail(email)) - .frame(maxWidth: .infinity) - .authCTAButtonStyle() - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .navigationTitle(authService.string.passwordRecoveryTitle) - .safeAreaPadding() - .sheet(isPresented: $showSuccessSheet) { - successSheet - } - } - - @ViewBuilder - @MainActor - private var successSheet: some View { - VStack { - Text(authService.string.passwordRecoveryEmailSentTitle) - .authFont(.largeTitle) - .fontWeight(.bold) - .padding() - Text(authService.string.passwordRecoveryHelperMessage) - .authCustomFont(.body) - .padding() - - Divider() - - Text(String(format: authService.string.passwordRecoveryEmailSentMessage, sentEmail)) - .authCustomFont(.body) - .padding() - - Divider() - - Button(authService.string.okButtonLabel) { - showSuccessSheet = false - email = "" - authService.navigator.clear() - } - .padding() - } - .padding() + PasswordRecoveryScreen { DefaultPasswordRecoveryContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignInView.swift new file mode 100644 index 0000000000..dae247c7e5 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignInView.swift @@ -0,0 +1,43 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseCore +import SwiftUI + +/// The default ``AuthView/signIn`` screen: the stock email and password form, fixed to sign-in. +/// Its "Sign up" link pushes ``AuthView/signUp``. +@MainActor +public struct SignInView { + public init() {} + + @Environment(AuthService.self) private var authService +} + +extension SignInView: View { + public var body: some View { + ScrollView { + EmailAuthScreen(flow: .signIn) { DefaultEmailAuthContent(state: $0) } + } + .safeAreaPadding() + .navigationTitle(authService.string.signInWithEmailButtonLabel) + } +} + +#Preview { + FirebaseOptions.dummyConfigurationForPreview() + return NavigationStack { + SignInView() + } + .environment(AuthService()) +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignUpView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignUpView.swift new file mode 100644 index 0000000000..f04e740aff --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignUpView.swift @@ -0,0 +1,42 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseCore +import SwiftUI + +/// The default ``AuthView/signUp`` screen: the stock email and password form, fixed to sign-up. +@MainActor +public struct SignUpView { + public init() {} + + @Environment(AuthService.self) private var authService +} + +extension SignUpView: View { + public var body: some View { + ScrollView { + EmailAuthScreen(flow: .signUp) { DefaultEmailAuthContent(state: $0) } + } + .safeAreaPadding() + .navigationTitle(authService.string.signUpWithEmailButtonLabel) + } +} + +#Preview { + FirebaseOptions.dummyConfigurationForPreview() + return NavigationStack { + SignUpView() + } + .environment(AuthService()) +} diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/EmailRouteTests.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/EmailRouteTests.swift new file mode 100644 index 0000000000..e88aafeeb5 --- /dev/null +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/EmailRouteTests.swift @@ -0,0 +1,72 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +@testable import FirebaseAuthSwiftUI +import Testing + +@MainActor +struct EmailRouteTests { + private func makeService() -> AuthService { + configureFirebaseIfNeeded() + return AuthService() + } + + private let emailOption = LegacySignInOption(id: EmailAuthProviderID, displayName: "Email") + + @Test + func removeEmailRoutesKeepsOtherRoutes() { + let navigator = Navigator() + navigator.push(.signIn) + navigator.push(.signUp) + navigator.push(.passwordRecovery) + + navigator.removeEmailRoutes() + + #expect(navigator.routes == [.passwordRecovery]) + } + + @Test + func legacyEmailRecoveryReturnsToPushedSignIn() { + let service = makeService() + service.navigator.push(.signIn) + service.navigator.push(.signUp) + + service.startLegacyRecovery(option: emailOption, email: "user@example.com") + + #expect(service.navigator.routes == [.signIn]) + #expect(service.suggestedEmailAddress == "user@example.com") + } + + @Test + func legacyEmailRecoveryReturnsToFirstScreenWithoutEmailRoute() { + let service = makeService() + service.navigator.push(.enterPhoneNumber) + + service.startLegacyRecovery(option: emailOption, email: "user@example.com") + + #expect(service.navigator.routes.isEmpty) + } + + @Test + func signOutClearsNavigation() async throws { + let service = makeService() + service.navigator.push(.signIn) + service.navigator.push(.signUp) + + try await service.signOut() + + #expect(service.navigator.routes.isEmpty) + } +}