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/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/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..c14261e320 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift @@ -0,0 +1,231 @@ +// 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. + public let switchFlow: @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``. + 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, + 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.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 { + public init(@ViewBuilder content: @escaping (EmailAuthContentState) -> Content) { + 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 content: (EmailAuthContentState) -> Content + + public var body: some View { + content(EmailAuthContentState( + email: $email, + password: $password, + confirmPassword: $confirmPassword, + signIn: signIn, + signUp: signUp, + switchFlow: switchFlow, + goToPasswordRecovery: { authService.navigator.push(.passwordRecovery) }, + goToEmailLink: { authService.selectEmailLink() }, + flow: authService.authenticationFlow, + 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 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 var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func switchFlow() { + 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/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) } } }