diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Exports.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Exports.swift new file mode 100644 index 00000000000..fdc5d9c91e9 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Exports.swift @@ -0,0 +1,17 @@ +// 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. + +// FirebaseAuthUIComponents is not a library product, so re-export it to make +// AuthTextFieldStyle and AuthTypography reachable from `import FirebaseAuthSwiftUI`. +@_exported import FirebaseAuthUIComponents diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 2145b6733e7..2bd86e1605e 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -33,6 +33,24 @@ public protocol AuthProviderUI { var provider: AuthProviderSwift { get } } +/// Lets ``AuthService/triggerSignIn(for:)`` start sign-in for a provider whose flow doesn't +/// produce a credential, such as phone or a custom flow that presents its own screens. +/// +/// Adopt it on a custom provider so a custom method picker layout can start its sign-in through +/// ``AuthMethodPickerState/select``. Otherwise, start sign-in from the provider's own button. +/// +/// Example: +/// ```swift +/// extension MyProviderAuthUI: AuthProviderAction { +/// func triggerAction() async throws { +/// try await startMySignInFlow() +/// } +/// } +/// ``` +public protocol AuthProviderAction: AuthProviderUI { + @MainActor func triggerAction() async throws +} + public enum AuthenticationState { case unauthenticated case authenticating @@ -184,6 +202,40 @@ public final class AuthService { providers.append(providerWithButton) } + /// The providers registered with ``registerProvider(providerWithButton:)``, in registration + /// order. Email and email-link sign-in aren't included. + public var registeredProviders: [AuthProviderUI] { + providers + } + + /// Starts sign-in with `provider` without rendering its button. + /// + /// ``AuthProviderAction`` providers run ``AuthProviderAction/triggerAction()``, in which case + /// this returns `nil`. Other credential-based providers sign in through ``signIn(_:)``. + /// Any other provider throws ``AuthServiceError/providerAuthenticationFailed(_:)``. + public func triggerSignIn(for provider: AuthProviderUI) async throws -> SignInOutcome? { + if let actionProvider = provider as? AuthProviderAction { + try await actionProvider.triggerAction() + return nil + } + if let credentialProvider = provider.provider as? CredentialAuthProviderSwift { + return try await signIn(credentialProvider) + } + throw AuthServiceError.providerAuthenticationFailed( + "\(provider.displayName) doesn't support starting sign-in programmatically. " + + "Adopt AuthProviderAction on the provider, or start sign-in from its own button." + ) + } + + /// Runs the email-link sign-in action, the same as the stock email-link button. + func selectEmailLink() { + if let callback = emailLinkSignInCallback { + callback() + } else { + navigator.push(.emailLink) + } + } + public func renderButtons(spacing: CGFloat = 16) -> AnyView { AnyView( VStack(spacing: spacing) { @@ -193,11 +245,7 @@ public final class AuthService { style: .email, accessibilityId: "sign-in-with-email-link-button" ) { - if let callback = self.emailLinkSignInCallback { - callback() - } else { - self.navigator.push(.emailLink) - } + self.selectEmailLink() } } ForEach(providers, id: \.id) { provider in diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift new file mode 100644 index 00000000000..ad602136ce7 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift @@ -0,0 +1,134 @@ +// 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 + +/// Styling configuration for the primary call-to-action buttons used throughout the auth flow +/// (sign in, send code, update password, etc). All fields default to `nil`, in which case +/// today's exact appearance is unchanged — including `backgroundColor`, which otherwise already +/// follows the environment's `.tint()` via `.buttonStyle(.borderedProminent)`; setting it here +/// overrides that for CTA buttons specifically, without affecting `.tint()` elsewhere. +public struct AuthCTAButtonStyle: Sendable { + public var backgroundColor: Color? + public var contentColor: Color? + public var shape: ButtonBorderShape? + public var font: Font? + + public init(backgroundColor: Color? = nil, + contentColor: Color? = nil, + shape: ButtonBorderShape? = nil, + font: Font? = nil) { + self.backgroundColor = backgroundColor + self.contentColor = contentColor + self.shape = shape + self.font = font + } + + public static let `default` = AuthCTAButtonStyle() +} + +private struct AuthCTAButtonStyleKey: EnvironmentKey { + static let defaultValue: AuthCTAButtonStyle = .default +} + +public extension EnvironmentValues { + var authCTAButtonStyle: AuthCTAButtonStyle { + get { self[AuthCTAButtonStyleKey.self] } + set { self[AuthCTAButtonStyleKey.self] = newValue } + } +} + +/// Applies `.buttonStyle(.borderedProminent)` plus the colors/shape/font from the environment's +/// ``AuthCTAButtonStyle``, in place of a bare `.buttonStyle(.borderedProminent)` call. Unset +/// values leave the inherited shape, tint and font alone. When `style.font` is unset, the label +/// uses ``AuthTypography``'s custom font if one is set. +struct AuthCTAButtonModifier: ViewModifier { + @Environment(\.authCTAButtonStyle) private var style + + func body(content: Content) -> some View { + Group { + if let contentColor = style.contentColor { + content.foregroundStyle(contentColor) + } else { + content + } + } + .buttonStyle(.borderedProminent) + .modifier(CTAShapeModifier(shape: style.shape)) + .modifier(CTATintModifier(tint: style.backgroundColor)) + .modifier(CTAFontModifier(explicitFont: style.font)) + } +} + +// Each modifier below only applies a value when one is set, so an unset style leaves whatever +// the app set higher up (shape, tint, font) in effect, as before theming. + +private struct CTAShapeModifier: ViewModifier { + let shape: ButtonBorderShape? + + func body(content: Content) -> some View { + if let shape { + content.buttonBorderShape(shape) + } else { + content + } + } +} + +private struct CTATintModifier: ViewModifier { + let tint: Color? + + func body(content: Content) -> some View { + if let tint { + content.tint(tint) + } else { + content + } + } +} + +private struct CTAFontModifier: ViewModifier { + let explicitFont: Font? + + func body(content: Content) -> some View { + if let explicitFont { + content.font(explicitFont) + } else { + content.authCustomFont(.body) + } + } +} + +extension View { + /// Applies the auth flow's primary call-to-action button styling, reading colors/shape/font + /// from the environment's ``AuthCTAButtonStyle`` (set via `.authCTAButtonStyle(_:)`). + func authCTAButtonStyle() -> some View { + modifier(AuthCTAButtonModifier()) + } +} + +public extension View { + /// Sets the ``AuthCTAButtonStyle`` used by every CTA button in this view's subtree. + /// + /// ```swift + /// AuthPickerView { ... } + /// .authCTAButtonStyle( + /// AuthCTAButtonStyle(backgroundColor: theme.colors.tint, contentColor: .white, shape: .capsule) + /// ) + /// ``` + func authCTAButtonStyle(_ style: AuthCTAButtonStyle) -> some View { + environment(\.authCTAButtonStyle, style) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift new file mode 100644 index 00000000000..7705768b5ce --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift @@ -0,0 +1,205 @@ +// 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 FirebaseAuthUIComponents +import SwiftUI + +/// An ``AuthPickerView`` with custom sheet content, returned by +/// `AuthPickerView`'s `pickerContent`, ``AuthPickerView/pickerDestination(_:)`` and +/// ``AuthPickerView/signedInContent(_:)`` modifiers. +/// +/// It owns the authentication sheet, its `NavigationStack`, error alerts, and MFA and +/// account-conflict handling; only the first screen, the signed-in screen and the pushed screens +/// come from the slots. You don't create it directly. +@MainActor +public struct AuthPickerContent< + Content: View, + PickerContent: View, + DestinationContent: View, + SignedInContent: View +>: View { + init(content: @escaping () -> Content, + pickerContent: @escaping (AuthMethodPickerState) -> PickerContent, + destination: @escaping (AuthView) -> DestinationContent, + signedInContent: @escaping (SignedInContentState) -> SignedInContent, + showsPickerTitle: Bool = true) { + self.content = content + self.pickerContent = pickerContent + self.destination = destination + self.signedInContent = signedInContent + self.showsPickerTitle = showsPickerTitle + } + + @Environment(AuthService.self) private var authService + private let content: () -> Content + private let pickerContent: (AuthMethodPickerState) -> PickerContent + private let destination: (AuthView) -> DestinationContent + private let signedInContent: (SignedInContentState) -> SignedInContent + /// `false` when the whole first screen is custom, so the library's large title doesn't sit + /// above the app's own heading. The close button stays so the sheet can still be dismissed. + private let showsPickerTitle: Bool + + // View-layer error state + @State private var error: AlertError? + + public var body: some View { + @Bindable var authService = authService + content() + .sheet(isPresented: $authService.isPresented) { + @Bindable var navigator = authService.navigator + NavigationStack(path: $navigator.routes) { + root + .navigationTitle( + showsPickerTitle && authService.authenticationState == .unauthenticated + ? authService.string.authPickerTitle : "" + ) + .navigationBarTitleDisplayMode(showsPickerTitle ? .large : .inline) + .toolbar { + toolbar + } + .navigationDestination(for: AuthView.self) { view in + destination(view) + // Pushed screens inherit an `.automatic` title mode from the root. Keep them + // large when the root is inline because a custom first screen hid its title. + .navigationBarTitleDisplayMode(showsPickerTitle ? .automatic : .large) + } + } + .environment(\.reportError, reportError) + .errorAlert( + error: $error, + okButtonLabel: authService.string.okButtonLabel + ) + .sheet(item: $authService.legacySignInRecovery) { _ in + LegacySignInRecoveryView() + .environment(authService) + } + .interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled) + // Apply account conflict handling at NavigationStack level + .accountConflictHandler() + // Apply MFA handling at NavigationStack level + .mfaHandler() + .environment(authService) + } + } + + /// The sheet's first screen: the signed-in slot for signed-in users, otherwise the picker slot. + /// The progress overlay always shows while signing in, whichever slot is on screen. + @ViewBuilder + private var root: some View { + VStack { + if authService.authenticationState == .authenticated { + SignedInScreen(content: signedInContent) + } else { + AuthMethodPickerStateReader { state in + pickerContent(state) + } + } + } + .overlay { + if authService.authenticationState == .authenticating { + VStack(spacing: 24) { + ProgressView() + .scaleEffect(1.25) + .tint(.white) + Text("Authenticating...") + .authCustomFont(.body) + .foregroundStyle(.white) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.black.opacity(0.7)) + } + } + } + + /// Replaces the signed-out first screen of the authentication sheet. See + /// the no-argument `pickerContent` on ``AuthPickerView``. + public func pickerContent( + @ViewBuilder _ pickerContent: @escaping () -> NewPickerContent + ) -> AuthPickerContent { + AuthPickerContent( + content: content, + pickerContent: { _ in pickerContent() }, + destination: destination, + signedInContent: signedInContent, + showsPickerTitle: true + ) + } + + /// Replaces the whole signed-out first screen with your own, receiving an + /// ``AuthMethodPickerState``. See the matching `pickerContent` on ``AuthPickerView``. + public func pickerContent( + @ViewBuilder _ pickerContent: @escaping (AuthMethodPickerState) -> NewPickerContent + ) -> AuthPickerContent { + AuthPickerContent( + content: content, + pickerContent: pickerContent, + destination: destination, + signedInContent: signedInContent, + showsPickerTitle: false + ) + } + + /// Replaces the screens pushed inside the authentication sheet. See + /// ``AuthPickerView/pickerDestination(_:)``. + public func pickerDestination( + @ViewBuilder _ destination: @escaping (AuthView) -> NewDestinationContent + ) -> AuthPickerContent { + AuthPickerContent( + content: content, + pickerContent: pickerContent, + destination: destination, + signedInContent: signedInContent, + showsPickerTitle: showsPickerTitle + ) + } + + /// Replaces the signed-in screen of the authentication sheet. See + /// ``AuthPickerView/signedInContent(_:)``. + public func signedInContent( + @ViewBuilder _ signedInContent: @escaping (SignedInContentState) -> NewSignedInContent + ) -> AuthPickerContent { + AuthPickerContent( + content: content, + pickerContent: pickerContent, + destination: destination, + signedInContent: signedInContent, + showsPickerTitle: showsPickerTitle + ) + } + + /// Closure for reporting errors from child views + private func reportError(_ error: Error) { + Task { @MainActor in + self.error = AlertError( + message: authService.string.localizedErrorMessage(for: error), + underlyingError: error + ) + } + } + + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + if !authService.configuration.shouldHideCancelButton { + Button { + authService.isPresented = false + } label: { + Image(systemName: "xmark") + .foregroundStyle(Color(UIColor.label)) + } + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 8454c79d8b0..59f91f9e0cb 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -17,151 +17,159 @@ import FirebaseAuthUIComponents import FirebaseCore import SwiftUI +/// Wraps your app content and presents the authentication sheet whenever +/// `AuthService.isPresented` is `true`. +/// +/// Use the `pickerContent` modifiers, ``pickerDestination(_:)`` and ``signedInContent(_:)`` to +/// replace the sheet's first screen, any pushed screen or the signed-in screen while the library +/// keeps driving navigation, MFA and account conflicts. +/// +/// Example: +/// ```swift +/// AuthPickerView { +/// HomeView() +/// } +/// .environment(authService) +/// ``` @MainActor -public struct AuthPickerView { +public struct AuthPickerView: View { public init(@ViewBuilder content: @escaping () -> Content = { EmptyView() }) { self.content = content } - @Environment(AuthService.self) private var authService private let content: () -> Content - // View-layer error state - @State private var error: AlertError? -} - -extension AuthPickerView: View { public var body: some View { - @Bindable var authService = authService - content() - .sheet(isPresented: $authService.isPresented) { - @Bindable var navigator = authService.navigator - NavigationStack(path: $navigator.routes) { - authPickerViewInternal - .navigationTitle(authService.authenticationState == .unauthenticated ? authService - .string.authPickerTitle : "") - .navigationBarTitleDisplayMode(.large) - .toolbar { - toolbar - } - .navigationDestination(for: AuthView.self) { view in - switch view { - case AuthView.passwordRecovery: - PasswordRecoveryView() - case AuthView.emailLink: - EmailLinkView() - case AuthView.updatePassword: - UpdatePasswordView() - case AuthView.mfaEnrollment: - MFAEnrolmentView() - case AuthView.mfaManagement: - MFAManagementView() - case let .mfaResolution(mfaRequired): - MFAResolutionView(mfaRequired: mfaRequired) - case AuthView.enterPhoneNumber: - EnterPhoneNumberView() - case let .enterVerificationCode(verificationID, fullPhoneNumber): - EnterVerificationCodeView( - verificationID: verificationID, - fullPhoneNumber: fullPhoneNumber - ) - } - } - } - .environment(\.reportError, reportError) - .errorAlert( - error: $error, - okButtonLabel: authService.string.okButtonLabel - ) - .sheet(item: $authService.legacySignInRecovery) { _ in - LegacySignInRecoveryView() - .environment(authService) - } - .interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled) - // Apply account conflict handling at NavigationStack level - .accountConflictHandler() - // Apply MFA handling at NavigationStack level - .mfaHandler() - .environment(authService) - } - } - - /// Closure for reporting errors from child views - private func reportError(_ error: Error) { - Task { @MainActor in - self.error = AlertError( - message: authService.string.localizedErrorMessage(for: error), - underlyingError: error - ) - } + AuthPickerContent( + content: content, + pickerContent: { _ in DefaultAuthPickerContent() }, + destination: { DefaultAuthPickerDestination(screen: $0) }, + signedInContent: { DefaultSignedInContent(state: $0) } + ) } - @ToolbarContentBuilder - var toolbar: some ToolbarContent { - ToolbarItem(placement: .topBarTrailing) { - if !authService.configuration.shouldHideCancelButton { - Button { - authService.isPresented = false - } label: { - Image(systemName: "xmark") - .foregroundStyle(Color(UIColor.label)) - } - } - } + /// Replaces the signed-out first screen of the authentication sheet. Signed-in users still see + /// ``DefaultSignedInContent`` unless you replace it with ``signedInContent(_:)``, and the + /// progress overlay still shows while signing in. + /// + /// ```swift + /// AuthPickerView { authenticatedApp } + /// .pickerContent { + /// DefaultAuthPickerContent() + /// .background(theme.colors.background) + /// } + /// ``` + public func pickerContent( + @ViewBuilder _ pickerContent: @escaping () -> NewPickerContent + ) -> AuthPickerContent< + Content, + NewPickerContent, + DefaultAuthPickerDestination, + DefaultSignedInContent + > { + AuthPickerContent( + content: content, + pickerContent: { _ in pickerContent() }, + destination: { DefaultAuthPickerDestination(screen: $0) }, + signedInContent: { DefaultSignedInContent(state: $0) } + ) } - @ViewBuilder - var authPickerViewInternal: some View { - @Bindable var authService = authService - VStack { - if authService.authenticationState == .authenticated { - SignedInView() - } else { - authMethodPicker - .safeAreaPadding() - } - } - .overlay { - if authService.authenticationState == .authenticating { - VStack(spacing: 24) { - ProgressView() - .scaleEffect(1.25) - .tint(.white) - Text("Authenticating...") - .foregroundStyle(.white) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.black.opacity(0.7)) - } - } + /// Replaces the whole signed-out first screen with your own, receiving an + /// ``AuthMethodPickerState`` to sign in with the registered providers. Call its `select` so MFA, + /// 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:)``. + /// + /// Example: + /// ```swift + /// AuthPickerView { authenticatedApp } + /// .pickerContent { state in + /// MyLandingScreen(state: state) + /// } + /// ``` + public func pickerContent( + @ViewBuilder _ pickerContent: @escaping (AuthMethodPickerState) -> NewPickerContent + ) -> AuthPickerContent< + Content, + NewPickerContent, + DefaultAuthPickerDestination, + DefaultSignedInContent + > { + AuthPickerContent( + content: content, + pickerContent: pickerContent, + destination: { DefaultAuthPickerDestination(screen: $0) }, + signedInContent: { DefaultSignedInContent(state: $0) }, + showsPickerTitle: false + ) } - @ViewBuilder - var authMethodPicker: some View { - GeometryReader { proxy in - ScrollView { - VStack(spacing: 24) { - Image(authService.configuration.logo ?? Assets.firebaseAuthLogo) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 100, height: 100) - if authService.emailPasswordSignInEnabled { - EmailAuthView() - } - Divider() - otherSignInOptions(proxy) - PrivacyTOCsView(displayMode: .full) - } - } - } + /// Replaces the screens pushed inside the authentication sheet. + /// + /// Always end a `switch` over the screen with a `default` branch that returns + /// ``DefaultAuthPickerDestination``. New ``AuthView`` routes may be added in future releases, + /// and the `default` branch keeps them working without a code change. + /// + /// ```swift + /// AuthPickerView { authenticatedApp } + /// .pickerDestination { screen in + /// switch screen { + /// case .enterPhoneNumber: + /// MyPhoneEntry() + /// default: + /// DefaultAuthPickerDestination(screen: screen) + /// } + /// } + /// ``` + public func pickerDestination( + @ViewBuilder _ destination: @escaping (AuthView) -> NewDestinationContent + ) -> AuthPickerContent< + Content, + DefaultAuthPickerContent, + NewDestinationContent, + DefaultSignedInContent + > { + AuthPickerContent( + content: content, + pickerContent: { _ in DefaultAuthPickerContent() }, + destination: destination, + signedInContent: { DefaultSignedInContent(state: $0) } + ) } - @ViewBuilder - func otherSignInOptions(_ proxy: GeometryProxy) -> some View { - VStack { - authService.renderButtons() - } - .padding(.horizontal, proxy.size.width * 0.14) + /// Replaces the screen the authentication sheet shows to signed-in users. Your content receives + /// a ``SignedInContentState``: the library still signs out, deletes the account behind its + /// confirmation and reauthentication, sends the verification email and navigates. Wrap + /// ``DefaultSignedInContent`` to keep the stock markup. + /// + /// ```swift + /// AuthPickerView { authenticatedApp } + /// .signedInContent { state in + /// MyAccountView( + /// email: state.user?.email, + /// onDeleteAccount: state.deleteAccount, + /// onSignOut: state.signOut + /// ) + /// } + /// ``` + public func signedInContent( + @ViewBuilder _ signedInContent: @escaping (SignedInContentState) -> NewSignedInContent + ) -> AuthPickerContent< + Content, + DefaultAuthPickerContent, + DefaultAuthPickerDestination, + NewSignedInContent + > { + AuthPickerContent( + content: content, + pickerContent: { _ in DefaultAuthPickerContent() }, + destination: { DefaultAuthPickerDestination(screen: $0) }, + signedInContent: signedInContent + ) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift new file mode 100644 index 00000000000..cb444ca8582 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -0,0 +1,186 @@ +// 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 default signed-out first screen of the authentication sheet: the logo, email sign-in, +/// the provider buttons and the terms. +/// +/// Return it from `AuthPickerView`'s no-argument `pickerContent` to keep the stock screen while adding +/// modifiers such as a background, or use ``init(authMethodPicker:)`` to lay out the provider +/// buttons yourself. +@MainActor +public struct DefaultAuthPickerContent: View { + public init() where AuthMethodPicker == DefaultAuthMethodPicker { + authMethodPicker = { _ in DefaultAuthMethodPicker() } + } + + /// Creates the default screen with your own layout for the provider buttons. + /// + /// The closure receives an ``AuthMethodPickerState``. Call its `select` to sign in with a + /// provider, so MFA, account conflicts and errors are handled the same as the default buttons. + /// Show an email-link button when `isEmailLinkEnabled` is `true`. + /// + /// Example: + /// ```swift + /// DefaultAuthPickerContent { state in + /// ForEach(state.providers, id: \.id) { provider in + /// Button(provider.displayName) { state.select(provider) } + /// } + /// if state.isEmailLinkEnabled { + /// Button("Email link", action: state.selectEmailLink) + /// } + /// } + /// ``` + public init(@ViewBuilder authMethodPicker: @escaping (AuthMethodPickerState) + -> AuthMethodPicker) { + self.authMethodPicker = authMethodPicker + } + + @Environment(AuthService.self) private var authService + private let authMethodPicker: (AuthMethodPickerState) -> AuthMethodPicker + + public var body: some View { + ScrollView { + VStack(spacing: 24) { + Image(authService.configuration.logo ?? Assets.firebaseAuthLogo) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 100, height: 100) + if authService.emailPasswordSignInEnabled { + EmailAuthView() + } + Divider() + VStack { + AuthMethodPickerStateReader { state in + authMethodPicker(state) + } + } + PrivacyTOCsView(displayMode: .full) + } + } + .safeAreaPadding() + } +} + +/// Builds an ``AuthMethodPickerState`` from the environment and passes it to its content, so +/// provider selection routes MFA, account conflicts and errors through the same handlers as the +/// default buttons. Used by ``DefaultAuthPickerContent`` and ``AuthPickerContent``. +@MainActor +struct AuthMethodPickerStateReader: View { + init(@ViewBuilder content: @escaping (AuthMethodPickerState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.mfaHandler) private var mfaHandler + @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.reportError) private var reportError + private let content: (AuthMethodPickerState) -> Content + + var body: some View { + content(AuthMethodPickerState( + providers: authService.registeredProviders, + select: selectProvider, + isEmailLinkEnabled: authService.emailLinkSignInEnabled, + selectEmailLink: authService.selectEmailLink + )) + } + + private func selectProvider(_ provider: AuthProviderUI) { + Task { + let result: Result + do { + let outcome = try await authService.triggerSignIn(for: provider) + result = .success(outcome) + } catch { + result = .failure(error) + } + AuthMethodPickerState.route( + result, + mfaHandler: mfaHandler, + accountConflictHandler: accountConflictHandler, + reportError: reportError + ) + } + } +} + +/// The providers and sign-in actions passed to a custom first screen in +/// `AuthPickerView.pickerContent { state in ... }`, or to a custom provider layout in +/// ``DefaultAuthPickerContent/init(authMethodPicker:)``. +public struct AuthMethodPickerState { + /// The registered providers, in registration order. Email-link sign-in isn't included; see + /// ``isEmailLinkEnabled``. + public let providers: [AuthProviderUI] + + /// Signs in with a provider from ``providers``. A provider that is neither credential-based + /// nor an ``AuthProviderAction`` reports an error instead. + public let select: @MainActor (AuthProviderUI) -> Void + + /// Whether email-link sign-in is enabled on the ``AuthService``. + public let isEmailLinkEnabled: Bool + + /// Starts email-link sign-in, the same as the stock email-link button. + public let selectEmailLink: @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) { + self.providers = providers + self.select = select + self.isEmailLinkEnabled = isEmailLinkEnabled + self.selectEmailLink = selectEmailLink + } + + /// Hands a provider sign-in result to the same handlers the default provider buttons use: + /// MFA and account conflicts go to their handlers, and other errors are reported. + @MainActor + static func route(_ result: Result, + mfaHandler: ((MFARequired) -> Void)?, + accountConflictHandler: ((AccountConflictContext) -> Void)?, + reportError: ((Error) -> Void)?) { + switch result { + case let .success(.mfaRequired(mfaInfo)): + mfaHandler?(mfaInfo) + case .success: + break + case let .failure(AuthServiceError.accountConflict(ctx)) where accountConflictHandler != nil: + accountConflictHandler?(ctx) + case let .failure(error): + reportError?(error) + } + } +} + +/// The default provider list on ``DefaultAuthPickerContent``: every registered provider's +/// button, stacked vertically and inset from the screen edges. +/// +/// Its width is 72% of the nearest container, such as the enclosing `ScrollView`, not of its +/// direct parent. +@MainActor +public struct DefaultAuthMethodPicker: View { + public init() {} + + @Environment(AuthService.self) private var authService + + public var body: some View { + authService.renderButtons() + // 72% of the container width, matching a 14% inset on each side. + .containerRelativeFrame(.horizontal) { length, _ in length * 0.72 } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift new file mode 100644 index 00000000000..2d5762c3ca7 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift @@ -0,0 +1,64 @@ +// 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 default screen for each route pushed inside the authentication sheet. +/// +/// Return it from ``AuthPickerView/pickerDestination(_:)`` for the routes you don't customize. +/// Keep a `default` branch: new ``AuthView`` routes may be added in future releases. +/// +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case .enterPhoneNumber: +/// MyPhoneEntry() +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct DefaultAuthPickerDestination: View { + public init(screen: AuthView) { + self.screen = screen + } + + private let screen: AuthView + + public var body: some View { + switch screen { + case AuthView.passwordRecovery: + PasswordRecoveryView() + case AuthView.emailLink: + EmailLinkView() + case AuthView.updatePassword: + UpdatePasswordView() + case AuthView.mfaEnrollment: + MFAEnrolmentView() + case AuthView.mfaManagement: + MFAManagementView() + case let .mfaResolution(mfaRequired): + MFAResolutionView(mfaRequired: mfaRequired) + case AuthView.enterPhoneNumber: + EnterPhoneNumberView() + case let .enterVerificationCode(verificationID, fullPhoneNumber): + EnterVerificationCodeView( + verificationID: verificationID, + fullPhoneNumber: fullPhoneNumber + ) + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterPhoneNumberContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterPhoneNumberContent.swift new file mode 100644 index 00000000000..1b5745afa8f --- /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 00000000000..9b724a838d6 --- /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/DefaultSignedInContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultSignedInContent.swift new file mode 100644 index 00000000000..c2ddf3122f2 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultSignedInContent.swift @@ -0,0 +1,94 @@ +// 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 stock signed-in screen, the default content of ``AuthPickerView/signedInContent(_:)``. +/// Wrap it to keep the stock markup. +@MainActor +public struct DefaultSignedInContent: View { + public init(state: SignedInContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: SignedInContentState + + public var body: some View { + VStack { + Text(authService.string.signedInTitle) + .authFont(.largeTitle) + .fontWeight(.bold) + .padding() + .accessibilityIdentifier("signed-in-text") + Text( + "\(state.user?.email ?? state.user?.displayName ?? state.user?.phoneNumber ?? "")" + ) + .authCustomFont(.body) + if !state.isEmailVerified { + Button(action: state.verifyEmail) { + Text(authService.string.sendEmailVerificationButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("verify-email-button") + } + Button(action: state.updatePassword) { + Text(authService.string.updatePasswordButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("update-password-button") + + if state.isMFAEnabled { + Button(action: state.goToMFAManagement) { + Text(authService.string.manageTwoFactorAuthenticationLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("mfa-management-button") + } + + Button(action: state.deleteAccount) { + Text(authService.string.deleteAccountButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("delete-account-button") + + Button(action: state.signOut) { + Text(authService.string.signOutButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("sign-out-button") + } + .safeAreaPadding() + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index fc6616a8f8b..7d06240f4a1 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -150,6 +150,7 @@ extension EmailAuthView: View { authService.navigator.push(.passwordRecovery) } label: { Text(authService.string.passwordButtonLabel) + .authCustomFont(.body, weight: .medium) .frame(maxWidth: .infinity, alignment: .trailing) } .accessibilityIdentifier("password-recovery-button") @@ -205,7 +206,7 @@ extension EmailAuthView: View { .disabled(!isValid) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .accessibilityIdentifier("sign-in-button") } Button(action: { @@ -222,14 +223,14 @@ extension EmailAuthView: View { ? authService.string.dontHaveAnAccountYetLabel : authService.string.alreadyHaveAnAccountLabel ) + .authCustomFont(.body) .foregroundStyle(Color(.label)) Text( authService.authenticationFlow == .signUp ? authService.string.emailLoginFlowLabel : authService.string.emailSignUpFlowLabel ) - .fontWeight(.semibold) - .foregroundColor(.blue) + .authCustomFont(.body, weight: .medium) } } .accessibilityIdentifier("switch-auth-flow") diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift index f151cda4311..fc896270f90 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift @@ -13,6 +13,7 @@ // limitations under the License. import FirebaseAuth +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -81,20 +82,20 @@ extension EmailLinkReauthView: View { .padding(.top, 32) Text("Check Your Email") - .font(.title) + .authFont(.title) .fontWeight(.bold) Text("We've sent a verification link to:") - .font(.body) + .authFont(.body) .foregroundStyle(.secondary) Text(email) - .font(.body) + .authFont(.body) .fontWeight(.medium) .padding(.horizontal) Text("Tap the link in the email to complete reauthentication.") - .font(.body) + .authFont(.body) .multilineTextAlignment(.center) .foregroundStyle(.secondary) .padding(.horizontal, 32) @@ -110,6 +111,7 @@ extension EmailLinkReauthView: View { .frame(height: 32) } else { Text("Resend Email") + .authCustomFont(.body) .frame(height: 32) } } @@ -123,6 +125,7 @@ extension EmailLinkReauthView: View { ProgressView() .padding(.top, 32) Text("Sending verification email...") + .authCustomFont(.body) .foregroundStyle(.secondary) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift index 6bdfe6812ad..c64c86e5701 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift @@ -68,7 +68,7 @@ extension EmailLinkView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(!CommonUtils.isValidEmail(email)) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift index 8d61630ce47..34a3fb497b1 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift @@ -66,11 +66,11 @@ extension EmailReauthView: View { .foregroundColor(.blue) Text(authService.string.confirmPasswordTitle) - .font(.title) + .authFont(.title) .fontWeight(.bold) Text(authService.string.forSecurityEnterPasswordMessage) - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -78,7 +78,7 @@ extension EmailReauthView: View { VStack(spacing: 20) { Text(authService.string.emailPrefix(email: email)) - .font(.caption) + .authFont(.caption) .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 8) @@ -109,7 +109,7 @@ extension EmailReauthView: View { .frame(maxWidth: .infinity) } } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(password.isEmpty || isLoading) .accessibilityIdentifier("confirm-password-button") diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift new file mode 100644 index 00000000000..2b8535d3a55 --- /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 95152335ca3..bfb76644125 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) - .font(.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) - } - } - .buttonStyle(.borderedProminent) - .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 00000000000..5b681ccc8ed --- /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 2a1da9d4063..4924946f3c9 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)) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .leading) - - Button { - authService.navigator.pop() - } label: { - Text(authService.string.changeNumberButtonLabel) - .font(.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) - } - } - .buttonStyle(.borderedProminent) - .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/LegacySignInRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/LegacySignInRecoveryView.swift index 11c1d353675..5ece47a7031 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/LegacySignInRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/LegacySignInRecoveryView.swift @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -26,8 +27,9 @@ struct LegacySignInRecoveryView: View { VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 12) { Text(authService.string.legacySignInRecoveryTitle) - .font(.title2.weight(.semibold)) + .authFont(.title2, weight: .semibold) Text(authService.string.legacySignInRecoveryMessage(email: recovery.email)) + .authCustomFont(.body) .foregroundStyle(.secondary) } @@ -35,7 +37,7 @@ struct LegacySignInRecoveryView: View { if !recovery.unavailableProviders.isEmpty { Text(authService.string.legacySignInRecoveryUnavailableMessage) - .font(.footnote) + .authFont(.footnote) .foregroundStyle(.secondary) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift index 75b13b47657..7405ab1db16 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift @@ -210,12 +210,12 @@ extension MFAEnrolmentView: View { if currentSession == nil { VStack(spacing: 8) { Text("Set Up Two-Factor Authentication") - .font(.largeTitle) + .authFont(.largeTitle) .fontWeight(.bold) .multilineTextAlignment(.center) Text("Add an extra layer of security to your account") - .font(.subheadline) + .authFont(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -230,13 +230,13 @@ extension MFAEnrolmentView: View { .foregroundColor(.orange) Text("Multi-Factor Authentication Disabled") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text( "MFA is not enabled in the current configuration. Please contact your administrator." ) - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -248,11 +248,11 @@ extension MFAEnrolmentView: View { .foregroundColor(.orange) Text("No Authentication Methods Available") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text("No MFA methods are configured as allowed. Please contact your administrator.") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -260,7 +260,7 @@ extension MFAEnrolmentView: View { } else { VStack(alignment: .leading, spacing: 12) { Text("Choose Authentication Method") - .font(.headline) + .authFont(.headline) Picker("Authentication Method", selection: $selectedFactorType) { ForEach(allowedFactorTypes, id: \.self) { factorType in @@ -309,11 +309,11 @@ extension MFAEnrolmentView: View { .foregroundColor(.blue) Text("SMS Authentication") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text("We'll send a verification code to your phone number each time you sign in.") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -324,13 +324,13 @@ extension MFAEnrolmentView: View { .foregroundColor(.green) Text("Authenticator App") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text( "Use an authenticator app like Google Authenticator or Authy to generate verification codes." ) - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -349,7 +349,7 @@ extension MFAEnrolmentView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(!canStartEnrollment) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) @@ -379,11 +379,11 @@ extension MFAEnrolmentView: View { .foregroundColor(.blue) Text("Enter Your Phone Number") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text("We'll send a verification code to this number") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -435,7 +435,7 @@ extension MFAEnrolmentView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(!canSendSMSVerification) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) @@ -449,11 +449,11 @@ extension MFAEnrolmentView: View { .foregroundColor(.green) Text("Enter Verification Code") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text("We sent a code to \(session.phoneNumber ?? "your phone")") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -480,7 +480,7 @@ extension MFAEnrolmentView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(!canCompleteEnrollment) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) @@ -490,6 +490,7 @@ extension MFAEnrolmentView: View { sendSMSVerification() } label: { Text("Resend Code") + .authCustomFont(.body) .padding(.vertical, 8) .frame(maxWidth: .infinity) } @@ -513,11 +514,11 @@ extension MFAEnrolmentView: View { .foregroundColor(.green) Text("Scan QR Code") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text("Scan with your authenticator app or tap to open directly") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) .lineLimit(nil) @@ -540,9 +541,9 @@ extension MFAEnrolmentView: View { HStack(spacing: 6) { Image(systemName: "arrow.up.forward.app.fill") - .font(.caption) + .authFont(.caption) Text("Tap to open in authenticator app") - .font(.caption) + .authFont(.caption) .fontWeight(.medium) } .foregroundColor(.blue) @@ -557,17 +558,17 @@ extension MFAEnrolmentView: View { .overlay( VStack { Image(systemName: "exclamationmark.triangle") - .font(.title) + .authFont(.title) .foregroundColor(.orange) Text("Unable to generate QR Code") - .font(.caption) + .authFont(.caption) } ) } VStack(spacing: 6) { Text("Manual Entry Key:") - .font(.headline) + .authFont(.headline) Button(action: { copyToClipboard(totpInfo.sharedSecretKey) @@ -592,7 +593,7 @@ extension MFAEnrolmentView: View { if showCopiedFeedback { Text("Copied to clipboard!") - .font(.caption) + .authFont(.caption) .foregroundColor(.green) .transition(.opacity) } @@ -635,7 +636,7 @@ extension MFAEnrolmentView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(!canCompleteEnrollment) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift index eeeddb45b08..2d337dd2c85 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift @@ -67,12 +67,12 @@ extension MFAManagementView: View { // Title section VStack { Text("Two-Factor Authentication") - .font(.largeTitle) + .authFont(.largeTitle) .fontWeight(.bold) .multilineTextAlignment(.center) Text("Manage your authentication methods") - .font(.subheadline) + .authFont(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -86,13 +86,13 @@ extension MFAManagementView: View { .foregroundColor(.orange) Text("No Authentication Methods") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text( "Set up two-factor authentication to add an extra layer of security to your account." ) - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) @@ -104,7 +104,7 @@ extension MFAManagementView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .accessibilityIdentifier("setup-mfa-button") @@ -113,7 +113,7 @@ extension MFAManagementView: View { // Show enrolled factors VStack(alignment: .leading, spacing: 16) { Text("Enrolled Methods") - .font(.headline) + .authFont(.headline) .padding(.horizontal) ForEach(enrolledFactors) { factor in @@ -128,7 +128,7 @@ extension MFAManagementView: View { } .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .accessibilityIdentifier("add-mfa-method-button") } } @@ -156,25 +156,25 @@ extension MFAManagementView: View { .foregroundColor(.green) } } - .font(.title2) + .authFont(.title2) VStack(alignment: .leading, spacing: 4) { Text(factor.displayName ?? authService.string.unnamedMethodLabel) - .font(.headline) + .authFont(.headline) if factor.factorID == PhoneMultiFactorID { let phoneInfo = factor as! PhoneMultiFactorInfo Text("SMS: \(phoneInfo.phoneNumber)") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } else { Text("Authenticator App") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } Text("Enrolled: \(DateFormatter.shortDate.string(from: factor.enrollmentDate))") - .font(.caption2) + .authFont(.caption2) .foregroundColor(.secondary) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift index f6e88f94ea0..e3dd9a28c2e 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift @@ -13,14 +13,10 @@ // limitations under the License. import FirebaseAuth +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI -private enum FocusableField: Hashable { - case verificationCode - case totpCode -} - @MainActor public struct MFAResolutionView { let mfaRequired: MFARequired @@ -34,8 +30,6 @@ public struct MFAResolutionView { @State private var selectedHintIndex = 0 @State private var verificationId: String? - @FocusState private var focus: FocusableField? - public init(mfaRequired: MFARequired) { self.mfaRequired = mfaRequired } @@ -114,12 +108,12 @@ extension MFAResolutionView: View { .foregroundColor(.blue) Text("Two-Factor Authentication") - .font(.largeTitle) + .authFont(.largeTitle) .fontWeight(.bold) .accessibilityIdentifier("mfa-resolution-title") Text("Complete sign-in with your second factor") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -146,24 +140,21 @@ extension MFAResolutionView: View { } Text("Complete Sign-In") } + .padding(.vertical, 8) .frame(maxWidth: .infinity) - .padding() - .background(canCompleteResolution ? Color.blue : Color.gray) - .foregroundColor(.white) - .cornerRadius(8) } + .authCTAButtonStyle() .disabled(!canCompleteResolution) .accessibilityIdentifier("complete-resolution-button") // Cancel Button Button(action: cancelResolution) { Text("Cancel") + .authCustomFont(.body) + .padding(.vertical, 8) .frame(maxWidth: .infinity) - .padding() - .background(Color.gray.opacity(0.2)) - .foregroundColor(.primary) - .cornerRadius(8) } + .buttonStyle(.bordered) .accessibilityIdentifier("cancel-button") } .padding(.horizontal) @@ -190,16 +181,16 @@ extension MFAResolutionView: View { .foregroundColor(.blue) Text("SMS Verification") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) if let phoneNumber = phoneNumber { Text("We'll send a code to ••••••\(String(phoneNumber.suffix(4)))") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) } else { Text("We'll send a verification code to your phone") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) } } @@ -215,12 +206,10 @@ extension MFAResolutionView: View { } Text("Send Code") } + .padding(.vertical, 8) .frame(maxWidth: .infinity) - .padding() - .background(isLoading ? Color.gray : Color.blue) - .foregroundColor(.white) - .cornerRadius(8) } + .authCTAButtonStyle() .disabled(isLoading) .padding(.horizontal) .accessibilityIdentifier("send-sms-button") @@ -228,13 +217,14 @@ extension MFAResolutionView: View { // Verification code input VStack(alignment: .leading, spacing: 8) { Text("Verification Code") - .font(.headline) - - TextField("Enter 6-digit code", text: $verificationCode) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .keyboardType(.numberPad) - .focused($focus, equals: .verificationCode) - .accessibilityIdentifier("sms-verification-code-field") + .authFont(.headline) + + VerificationCodeInputField( + code: $verificationCode, + validations: [FormValidators.verificationCode], + maintainsValidationMessage: true + ) + .accessibilityIdentifier("sms-verification-code-field") } .padding(.horizontal) } @@ -250,17 +240,17 @@ extension MFAResolutionView: View { .foregroundColor(.green) Text("Authenticator App") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text("Enter the 6-digit code from your authenticator app") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) if let displayName = displayName { Text(authService.string.accountPrefix(displayName: displayName)) - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } } @@ -269,13 +259,14 @@ extension MFAResolutionView: View { // TOTP code input VStack(alignment: .leading, spacing: 8) { Text("Verification Code") - .font(.headline) + .authFont(.headline) - TextField("Enter 6-digit code", text: $totpCode) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .keyboardType(.numberPad) - .focused($focus, equals: .totpCode) - .accessibilityIdentifier("totp-verification-code-field") + VerificationCodeInputField( + code: $totpCode, + validations: [FormValidators.verificationCode], + maintainsValidationMessage: true + ) + .accessibilityIdentifier("totp-verification-code-field") } .padding(.horizontal) } @@ -285,7 +276,7 @@ extension MFAResolutionView: View { private func mfaHintsSelectionView(mfaRequired: MFARequired) -> some View { VStack(alignment: .leading, spacing: 12) { Text("Choose verification method:") - .font(.headline) + .authFont(.headline) .padding(.horizontal) // More idiomatic approach using indices @@ -311,7 +302,7 @@ extension MFAResolutionView: View { VStack(alignment: .leading) { Text(hintDisplayName(for: hint)) - .font(.body) + .authFont(.body) .foregroundColor(.primary) hintSubtitle(for: hint) @@ -345,7 +336,7 @@ extension MFAResolutionView: View { private func hintSubtitle(for hint: MFAHint) -> some View { if case let .phone(_, _, phoneNumber) = hint, let phone = phoneNumber { Text("••••••\(String(phone.suffix(4)))") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift index af14a1c1676..73a60758e6e 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift @@ -63,7 +63,7 @@ extension PasswordRecoveryView: View { } .disabled(!CommonUtils.isValidEmail(email)) .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .navigationTitle(authService.string.passwordRecoveryTitle) @@ -78,15 +78,17 @@ extension PasswordRecoveryView: View { private var successSheet: some View { VStack { Text(authService.string.passwordRecoveryEmailSentTitle) - .font(.largeTitle) + .authFont(.largeTitle) .fontWeight(.bold) .padding() Text(authService.string.passwordRecoveryHelperMessage) + .authCustomFont(.body) .padding() Divider() Text(String(format: authService.string.passwordRecoveryEmailSentMessage, sentEmail)) + .authCustomFont(.body) .padding() Divider() diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift index 10821e9b7c9..deed8e5799d 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift @@ -91,11 +91,11 @@ extension PhoneReauthView: View { .foregroundColor(.blue) Text(authService.string.verifyPhoneNumberTitle) - .font(.title) + .authFont(.title) .fontWeight(.bold) Text(authService.string.forSecurityVerifyPhoneMessage) - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -105,12 +105,12 @@ extension PhoneReauthView: View { // Initial state - sending SMS VStack(spacing: 20) { Text(authService.string.sendVerificationCodeToPhonePrefix) - .font(.subheadline) + .authFont(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) Text(phoneNumber) - .font(.headline) + .authFont(.headline) .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 8) @@ -127,7 +127,7 @@ extension PhoneReauthView: View { .frame(maxWidth: .infinity) } } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(isLoading) .accessibilityIdentifier("send-verification-code-button") Button(authService.string.cancelButtonLabel) { @@ -139,12 +139,12 @@ extension PhoneReauthView: View { // Enter verification code VStack(spacing: 20) { Text(authService.string.enterSixDigitCodeSentToPrefix) - .font(.subheadline) + .authFont(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) Text(phoneNumber) - .font(.caption) + .authFont(.caption) .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 8) @@ -170,7 +170,7 @@ extension PhoneReauthView: View { .frame(maxWidth: .infinity) } } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(verificationCode.count != 6 || isLoading) .accessibilityIdentifier("verify-button") diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift index f5e6bfb6381..629532a8233 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift @@ -18,6 +18,7 @@ // // Created by Russell Wheatley on 12/05/2025. // +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -66,6 +67,7 @@ extension PrivacyTOCsView: View { if let tosURL = authService.configuration.tosUrl, let privacyURL = authService.configuration.privacyPolicyUrl { Text(attributedMessage(tosURL: tosURL, privacyURL: privacyURL)) + .authCustomFont(.body) .multilineTextAlignment(displayMode == .full ? .center : .trailing) .padding() } else { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift index b20b23a59f0..3cb7bd8a1a7 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift @@ -55,7 +55,7 @@ struct ReauthenticationModifier: ViewModifier { } } // Sheet for phone reauthentication - .sheet(isPresented: $coordinator.showingPhoneReauth) { + .sheet(isPresented: $coordinator.showingPhoneReauth, onDismiss: cancelIfPending) { if case let .phone(context) = coordinator.reauthContext { PhoneReauthView( phoneNumber: context.phoneNumber, @@ -64,7 +64,7 @@ struct ReauthenticationModifier: ViewModifier { } } // Sheet for email reauthentication - .sheet(isPresented: $coordinator.showingEmailPasswordPrompt) { + .sheet(isPresented: $coordinator.showingEmailPasswordPrompt, onDismiss: cancelIfPending) { if case let .email(context) = coordinator.reauthContext { EmailReauthView( email: context.email, @@ -89,7 +89,7 @@ struct ReauthenticationModifier: ViewModifier { } } // Sheet for email link reauthentication - .sheet(isPresented: $coordinator.showingEmailLinkReauth) { + .sheet(isPresented: $coordinator.showingEmailLinkReauth, onDismiss: cancelIfPending) { if case let .emailLink(context) = coordinator.reauthContext { EmailLinkReauthView( email: context.email, @@ -99,6 +99,13 @@ struct ReauthenticationModifier: ViewModifier { } } + /// Treats a sheet swiped away mid-flow as a cancel, so the waiting operation finishes. + private func cancelIfPending() { + if coordinator.reauthContext != nil { + coordinator.reauthCancelled() + } + } + private func performReauth() { Task { do { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInScreen.swift new file mode 100644 index 00000000000..831ec64adc6 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInScreen.swift @@ -0,0 +1,247 @@ +// 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 + +/// The state and actions of the signed-in screen, passed to the content of +/// ``AuthPickerView/signedInContent(_:)``. +public struct SignedInContentState { + /// Signs the user out. Does nothing while ``isLoading`` is `true`. + public let signOut: @MainActor () -> Void + + /// Asks the user to confirm, then deletes the account, reauthenticating first when Firebase + /// requires it. Does nothing while ``isLoading`` is `true`. + public let deleteAccount: @MainActor () -> Void + + /// Pushes ``AuthView/updatePassword``. + public let updatePassword: @MainActor () -> Void + + /// Sends a verification email to the user, then shows an alert telling them to tap the link. + /// Does nothing while ``isLoading`` is `true`. + public let verifyEmail: @MainActor () -> Void + + /// Pushes ``AuthView/mfaManagement``. Offer it only when ``isMFAEnabled`` is `true`. + public let goToMFAManagement: @MainActor () -> Void + + /// The signed-in user. + public let user: User? + + /// Whether the user's email is verified; `true` when there is no user. + public let isEmailVerified: Bool + + /// Whether ``AuthConfiguration/mfaEnabled`` is set. Offer ``goToMFAManagement`` only when it is + /// `true`. + public let isMFAEnabled: Bool + + /// Whether a sign out, account deletion or verification email is in flight. + public let isLoading: Bool + + /// The localized message of the last failed sign out, account deletion or verification email. + /// It clears when the next of those starts, and stays `nil` when the user cancels + /// reauthentication. 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(signOut: @escaping @MainActor () -> Void, + deleteAccount: @escaping @MainActor () -> Void, + updatePassword: @escaping @MainActor () -> Void, + verifyEmail: @escaping @MainActor () -> Void, + goToMFAManagement: @escaping @MainActor () -> Void, + user: User?, + isEmailVerified: Bool, + isMFAEnabled: Bool, + isLoading: Bool, + errorMessage: String?) { + self.signOut = signOut + self.deleteAccount = deleteAccount + self.updatePassword = updatePassword + self.verifyEmail = verifyEmail + self.goToMFAManagement = goToMFAManagement + self.user = user + self.isEmailVerified = isEmailVerified + self.isMFAEnabled = isMFAEnabled + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// Runs the signed-in screen's actions, delete confirmation, reauthentication and alerts, and +/// draws its content from the ``SignedInContentState`` it builds. +@MainActor +struct SignedInScreen: View { + init(@ViewBuilder content: @escaping (SignedInContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var showDeleteConfirmation = false + @State private var showEmailVerificationSent = false + @State private var reauthCoordinator = ReauthenticationCoordinator() + @State private var isWorking = false + @State private var errorMessage: String? + + private let content: (SignedInContentState) -> Content + + var body: some View { + // A single container, so the sheet and alerts below attach once even when the content is + // several views. + VStack { + content(state) + } + .withReauthentication(coordinator: reauthCoordinator) + .sheet(isPresented: $showDeleteConfirmation) { + DeleteAccountConfirmationSheet( + onConfirm: { + showDeleteConfirmation = false + deleteAccount() + }, + onCancel: { + showDeleteConfirmation = false + } + ) + .presentationDetents([.medium]) + } + .alert( + authService.string.verifyEmailSheetMessage, + isPresented: $showEmailVerificationSent + ) { + Button(authService.string.okButtonLabel) { + showEmailVerificationSent = false + } + } message: { + Text(authService.string.emailVerificationTapLinkMessage) + } + } + + private var state: SignedInContentState { + SignedInContentState( + signOut: signOut, + deleteAccount: { + if !isWorking { + showDeleteConfirmation = true + } + }, + updatePassword: { authService.navigator.push(.updatePassword) }, + verifyEmail: verifyEmail, + goToMFAManagement: { authService.navigator.push(.mfaManagement) }, + user: authService.currentUser, + isEmailVerified: authService.currentUser?.isEmailVerified != false, + isMFAEnabled: authService.configuration.mfaEnabled, + isLoading: isWorking, + errorMessage: errorMessage + ) + } + + private func signOut() { + perform { try await authService.signOut() } + } + + private func verifyEmail() { + perform { + try await authService.sendEmailVerification() + showEmailVerificationSent = true + } + } + + private func deleteAccount() { + perform { + try await withReauthenticationIfNeeded( + authService: authService, + coordinator: reauthCoordinator + ) { + try await authService.deleteUser() + } + } + } + + private func perform(_ action: @escaping @MainActor () async throws -> Void) { + guard !isWorking else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + try await action() + } catch { + if case AuthServiceError.signInCancelled = error { + // The user dismissed reauthentication; the sheet still reports it, as before. + } else { + errorMessage = authService.string.localizedErrorMessage(for: error) + } + reportError?(error) + } + } + } +} + +private struct DeleteAccountConfirmationSheet: View { + @Environment(AuthService.self) private var authService + let onConfirm: () -> Void + let onCancel: () -> Void + + var body: some View { + VStack(spacing: 24) { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 60)) + .foregroundColor(.red) + + Text("Delete Account?") + .authFont(.title) + .fontWeight(.bold) + + Text( + "This action cannot be undone. All your data will be permanently deleted. You may need to reauthenticate to complete this action." + ) + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + VStack(spacing: 12) { + Button { + onConfirm() + } label: { + Text("Delete Account") + .authCustomFont(.body) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.red) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("confirm-delete-button") + + Button { + onCancel() + } label: { + Text("Cancel") + .authCustomFont(.body) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("cancel-delete-button") + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .safeAreaPadding() + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift index e996ccc5b36..43198057981 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift @@ -15,210 +15,17 @@ import FirebaseCore import SwiftUI +/// The stock signed-in screen: ``DefaultSignedInContent`` with the library's account actions. The +/// authentication sheet shows it to signed-in users unless you replace it with +/// ``AuthPickerView/signedInContent(_:)``. @MainActor public struct SignedInView { - @Environment(AuthService.self) private var authService - @Environment(\.reportError) private var reportError - @State private var showDeleteConfirmation = false - @State private var showEmailVerificationSent = false - @State private var reauthCoordinator = ReauthenticationCoordinator() - - private func sendEmailVerification() async throws { - do { - try await authService.sendEmailVerification() - showEmailVerificationSent = true - } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error - } - } - } + public init() {} } extension SignedInView: View { public var body: some View { - VStack { - Text(authService.string.signedInTitle) - .font(.largeTitle) - .fontWeight(.bold) - .padding() - .accessibilityIdentifier("signed-in-text") - Text( - "\(authService.currentUser?.email ?? authService.currentUser?.displayName ?? authService.currentUser?.phoneNumber ?? "")" - ) - if authService.currentUser?.isEmailVerified == false { - Button { - Task { - try await sendEmailVerification() - } - } label: { - Text(authService.string.sendEmailVerificationButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("verify-email-button") - } - Button { - authService.navigator.push(.updatePassword) - } label: { - Text(authService.string.updatePasswordButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("update-password-button") - - if authService.configuration.mfaEnabled { - Button { - authService.navigator.push(.mfaManagement) - } label: { - Text(authService.string.manageTwoFactorAuthenticationLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("mfa-management-button") - } - - Button { - showDeleteConfirmation = true - } label: { - Text(authService.string.deleteAccountButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("delete-account-button") - - Button { - Task { - do { - try await authService.signOut() - } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error - } - } - } - } label: { - Text(authService.string.signOutButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("sign-out-button") - } - .safeAreaPadding() - .withReauthentication(coordinator: reauthCoordinator) - .sheet(isPresented: $showDeleteConfirmation) { - DeleteAccountConfirmationSheet( - onConfirm: { - showDeleteConfirmation = false - Task { - do { - try await withReauthenticationIfNeeded( - authService: authService, - coordinator: reauthCoordinator - ) { - try await authService.deleteUser() - } - } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error - } - } - } - }, - onCancel: { - showDeleteConfirmation = false - } - ) - .presentationDetents([.medium]) - } - .alert( - authService.string.verifyEmailSheetMessage, - isPresented: $showEmailVerificationSent - ) { - Button(authService.string.okButtonLabel) { - showEmailVerificationSent = false - } - } message: { - Text(authService.string.emailVerificationTapLinkMessage) - } - } -} - -private struct DeleteAccountConfirmationSheet: View { - @Environment(AuthService.self) private var authService - let onConfirm: () -> Void - let onCancel: () -> Void - - var body: some View { - VStack(spacing: 24) { - VStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 60)) - .foregroundColor(.red) - - Text("Delete Account?") - .font(.title) - .fontWeight(.bold) - - Text( - "This action cannot be undone. All your data will be permanently deleted. You may need to reauthenticate to complete this action." - ) - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - - VStack(spacing: 12) { - Button { - onConfirm() - } label: { - Text("Delete Account") - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .tint(.red) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("confirm-delete-button") - - Button { - onCancel() - } label: { - Text("Cancel") - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("cancel-delete-button") - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .safeAreaPadding() + SignedInScreen { DefaultSignedInContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift index 8480beb818b..9e6e3912c1b 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift @@ -104,7 +104,7 @@ extension UpdatePasswordView: View { .disabled(!isValid) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .safeAreaPadding() diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/AuthMethodPickerStateTests.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/AuthMethodPickerStateTests.swift new file mode 100644 index 00000000000..c88876b984c --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/AuthMethodPickerStateTests.swift @@ -0,0 +1,100 @@ +// 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 Foundation +import Testing + +/// Records which handler `AuthMethodPickerState.route` called. +@MainActor +private final class RouteRecorder { + var mfa: MFARequired? + var conflict: AccountConflictContext? + var error: Error? + + func route(_ result: Result, handlesConflicts: Bool = true) { + AuthMethodPickerState.route( + result, + mfaHandler: { self.mfa = $0 }, + accountConflictHandler: handlesConflicts ? { self.conflict = $0 } : nil, + reportError: { self.error = $0 } + ) + } +} + +private struct TestError: Error {} + +private func conflictContext() -> AccountConflictContext { + AccountConflictContext( + conflictType: .accountExistsWithDifferentCredential, + credential: EmailAuthProvider.credential(withEmail: "test@example.com", password: "123456"), + underlyingError: TestError(), + message: "conflict", + email: "test@example.com" + ) +} + +@Suite("AuthMethodPickerState routing") +@MainActor +struct AuthMethodPickerStateRoutingTests { + @Test("MFA required goes to the MFA handler") + func mfaRequired() { + let recorder = RouteRecorder() + let mfa = MFARequired(hints: [.totp(displayName: nil, uid: "uid")]) + recorder.route(.success(.mfaRequired(mfa))) + #expect(recorder.mfa == mfa) + #expect(recorder.conflict == nil) + #expect(recorder.error == nil) + } + + @Test("A completed sign-in calls no handler") + func signedIn() { + let recorder = RouteRecorder() + recorder.route(.success(.signedIn(nil))) + recorder.route(.success(nil)) + #expect(recorder.mfa == nil) + #expect(recorder.conflict == nil) + #expect(recorder.error == nil) + } + + @Test("An account conflict goes to the conflict handler") + func accountConflict() { + let recorder = RouteRecorder() + let context = conflictContext() + recorder.route(.failure(AuthServiceError.accountConflict(context))) + #expect(recorder.conflict == context) + #expect(recorder.error == nil) + } + + @Test("An account conflict is reported when there is no conflict handler") + func accountConflictWithoutHandler() { + let recorder = RouteRecorder() + recorder.route( + .failure(AuthServiceError.accountConflict(conflictContext())), + handlesConflicts: false + ) + #expect(recorder.conflict == nil) + #expect(recorder.error != nil) + } + + @Test("Other errors are reported") + func otherError() { + let recorder = RouteRecorder() + recorder.route(.failure(TestError())) + #expect(recorder.error is TestError) + #expect(recorder.mfa == nil) + #expect(recorder.conflict == nil) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift index 8e967113990..61cdb6b57ab 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift @@ -15,6 +15,8 @@ import SwiftUI public struct AuthTextField: View { + @Environment(\.authTextFieldStyle) private var style + @Environment(\.authTypography) private var typography @FocusState private var isFocused: Bool @State var obscured: Bool = true @State var hasInteracted: Bool = false @@ -61,6 +63,12 @@ public struct AuthTextField: View { self.leading = leading } + /// The placeholder uses the custom font only when one is set, so it otherwise inherits the + /// field's font as before theming. + private var promptText: Text { + typography.fontName == nil ? Text(prompt) : Text(prompt).font(typography.resolvedFont(for: .body)) + } + var allRequirementsMet: Bool { validations.allSatisfy { $0.isValid(input: text) } } @@ -68,16 +76,17 @@ public struct AuthTextField: View { public var body: some View { VStack(alignment: .leading) { Text(LocalizedStringResource(stringLiteral: label)) + .authCustomFont(.body) HStack(spacing: 8) { leading() Group { if isSecureTextField { ZStack(alignment: .trailing) { - SecureField(label, text: $text, prompt: Text(prompt)) + SecureField(label, text: $text, prompt: promptText) .opacity(obscured ? 1 : 0) .focused($isFocused) .frame(height: 24) - TextField(label, text: $text, prompt: Text(prompt)) + TextField(label, text: $text, prompt: promptText) .opacity(obscured ? 0 : 1) .focused($isFocused) .frame(height: 24) @@ -100,11 +109,12 @@ public struct AuthTextField: View { TextField( label, text: $text, - prompt: Text(prompt) + prompt: promptText ) .frame(height: 24) } } + .authCustomFont(.body) } .frame(maxWidth: .infinity) .keyboardType(keyboardType) @@ -131,10 +141,14 @@ public struct AuthTextField: View { .padding(.vertical, 12) .padding(.horizontal, 12) .background { - RoundedRectangle(cornerRadius: 8) - .fill(Color.accentColor.opacity(0.05)) + RoundedRectangle(cornerRadius: style.cornerRadius ?? 8) + .fill((style.tint ?? Color.accentColor).opacity(0.05)) .strokeBorder(lineWidth: isFocused ? 3 : 1) - .foregroundStyle(isFocused ? Color.accentColor : Color(.systemFill)) + .foregroundStyle( + isFocused + ? (style.tint ?? Color.accentColor) + : (style.containerColor ?? Color(.systemFill)) + ) } .contentShape(Rectangle()) .onTapGesture { @@ -148,9 +162,9 @@ public struct AuthTextField: View { ForEach(validations) { validator in let isValid = validator.isValid(input: text) Text(validator.message) - .font(.caption) - .strikethrough(isValid, color: .gray) - .foregroundStyle(isValid ? .gray : .red) + .authFont(.caption) + .strikethrough(isValid, color: style.secondaryColor ?? .gray) + .foregroundStyle(isValid ? (style.secondaryColor ?? .gray) : (style.errorColor ?? .red)) .fixedSize(horizontal: false, vertical: true) } } diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift index 050d3c37cea..049588b6c9c 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift @@ -87,18 +87,19 @@ public struct CountrySelector: View { selectedCountry = country } label: { Text("\(country.flag) \(country.name) (\(country.dialCode))") + .authCustomFont(.body) } .accessibilityIdentifier("country-option-\(country.code)") } } label: { HStack(spacing: 4) { Text(selectedCountry.flag) - .font(.title3) + .authFont(.title3) Text(selectedCountry.dialCode) - .font(.body) + .authFont(.body) .foregroundStyle(.primary) Image(systemName: "chevron.down") - .font(.caption2) + .authFont(.caption2) .foregroundStyle(.secondary) } } diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift index cc226a33c32..b9971494a0d 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift @@ -35,6 +35,7 @@ public struct VerificationCodeInputField: View { _digitFields = State(initialValue: Array(repeating: "", count: codeLength)) } + @Environment(\.authTextFieldStyle) private var style @Binding var code: String let codeLength: Int let isError: Bool @@ -91,8 +92,8 @@ public struct VerificationCodeInputField: View { if isError, let errorMessage = errorMessage { Text(errorMessage) - .font(.caption) - .foregroundColor(.red) + .authFont(.caption) + .foregroundStyle(style.errorColor ?? .red) .frame(maxWidth: .infinity, alignment: .leading) } @@ -102,9 +103,9 @@ public struct VerificationCodeInputField: View { ForEach(validations) { validator in let isValid = validator.isValid(input: code) Text(validator.message) - .font(.caption) - .strikethrough(isValid, color: .gray) - .foregroundStyle(isValid ? .gray : .red) + .authFont(.caption) + .strikethrough(isValid, color: style.secondaryColor ?? .gray) + .foregroundStyle(isValid ? (style.secondaryColor ?? .gray) : (style.errorColor ?? .red)) .fixedSize(horizontal: false, vertical: true) } } @@ -293,6 +294,8 @@ public struct VerificationCodeInputField: View { } private struct SingleDigitField: View { + @Environment(\.authTypography) private var typography + @Environment(\.authTextFieldStyle) private var style @Binding var digit: String let isError: Bool let isFocused: Bool @@ -310,9 +313,9 @@ private struct SingleDigitField: View { } private var borderColor: Color { - if isError { return .red } - if isFocused || !digit.isEmpty { return .accentColor } - return Color(.systemFill) + if isError { return style.errorColor ?? .red } + if isFocused || !digit.isEmpty { return style.tint ?? .accentColor } + return style.containerColor ?? Color(.systemFill) } var body: some View { @@ -330,8 +333,9 @@ private struct SingleDigitField: View { onFocusChanged(isFocused) }, maxCharacters: maxDigits, + font: typography.fontName.flatMap { UIFont(name: $0, size: 24) } + ?? .systemFont(ofSize: 24, weight: .medium), configuration: { textField in - textField.font = .systemFont(ofSize: 24, weight: .medium) textField.textAlignment = .center textField.keyboardType = .numberPad textField.textContentType = .oneTimeCode @@ -344,10 +348,10 @@ private struct SingleDigitField: View { ) .frame(width: 48, height: 48) .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color.accentColor.opacity(0.05)) + RoundedRectangle(cornerRadius: style.cornerRadius ?? 8) + .fill((style.tint ?? Color.accentColor).opacity(0.05)) .overlay( - RoundedRectangle(cornerRadius: 8) + RoundedRectangle(cornerRadius: style.cornerRadius ?? 8) .stroke(borderColor, lineWidth: borderWidth) ) ) @@ -367,6 +371,7 @@ private struct BackspaceAwareTextField: UIViewRepresentable { let onDeleteBackwardWhenEmpty: () -> Void let onFocusChanged: (Bool) -> Void let maxCharacters: Int + let font: UIFont let configuration: (UITextField) -> Void let onTextChange: (String) -> Void @@ -380,6 +385,7 @@ private struct BackspaceAwareTextField: UIViewRepresentable { for: .editingChanged ) configuration(textField) + textField.font = font textField.onDeleteBackward = { [weak textField] in guard let textField else { return } if (textField.text ?? "").isEmpty { @@ -394,6 +400,9 @@ private struct BackspaceAwareTextField: UIViewRepresentable { if uiView.text != text { uiView.text = text } + if uiView.font != font { + uiView.font = font + } uiView.onDeleteBackward = { [weak uiView] in guard let uiView else { return } @@ -505,7 +514,7 @@ private final class BackspaceUITextField: UITextField { return VStack(spacing: 32) { Text("Enter Verification Code") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) VerificationCodeInputField( @@ -519,7 +528,7 @@ private final class BackspaceUITextField: UITextField { ) Text("Current code: \(code)") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } .padding() @@ -530,7 +539,7 @@ private final class BackspaceUITextField: UITextField { return VStack(spacing: 32) { Text("Enter Verification Code") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) VerificationCodeInputField( @@ -546,7 +555,7 @@ private final class BackspaceUITextField: UITextField { ) Text("Current code: \(code)") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } .padding() @@ -557,7 +566,7 @@ private final class BackspaceUITextField: UITextField { return VStack(spacing: 32) { Text("Enter 4-Digit Code") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) VerificationCodeInputField( @@ -572,7 +581,7 @@ private final class BackspaceUITextField: UITextField { ) Text("Current code: \(code)") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } .padding() diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTextFieldStyle.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTextFieldStyle.swift new file mode 100644 index 00000000000..6fc0dc405c5 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTextFieldStyle.swift @@ -0,0 +1,69 @@ +// 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 + +/// Styling configuration for ``AuthTextField``. All fields default to `nil`, in which case +/// `AuthTextField` falls back to its existing hardcoded appearance. +public struct AuthTextFieldStyle: Sendable { + public var tint: Color? + public var containerColor: Color? + public var secondaryColor: Color? + public var errorColor: Color? + public var cornerRadius: CGFloat? + + public init(tint: Color? = nil, + containerColor: Color? = nil, + secondaryColor: Color? = nil, + errorColor: Color? = nil, + cornerRadius: CGFloat? = nil) { + self.tint = tint + self.containerColor = containerColor + self.secondaryColor = secondaryColor + self.errorColor = errorColor + self.cornerRadius = cornerRadius + } + + public static let `default` = AuthTextFieldStyle() +} + +private struct AuthTextFieldStyleKey: EnvironmentKey { + static let defaultValue: AuthTextFieldStyle = .default +} + +public extension EnvironmentValues { + var authTextFieldStyle: AuthTextFieldStyle { + get { self[AuthTextFieldStyleKey.self] } + set { self[AuthTextFieldStyleKey.self] = newValue } + } +} + +public extension View { + /// Applies a custom appearance to every ``AuthTextField`` in this view's subtree. + /// + /// ```swift + /// AuthPickerView { ... } + /// .authTextFieldStyle( + /// AuthTextFieldStyle( + /// tint: theme.colors.tint, + /// containerColor: theme.colors.container, + /// secondaryColor: theme.colors.secondary, + /// errorColor: theme.colors.error + /// ) + /// ) + /// ``` + func authTextFieldStyle(_ style: AuthTextFieldStyle) -> some View { + environment(\.authTextFieldStyle, style) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift new file mode 100644 index 00000000000..113ae587e76 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift @@ -0,0 +1,139 @@ +// 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 + +/// Typography configuration for the auth flow. `fontName` defaults to `nil`, in which case +/// text keeps using the system font at each semantic text style, exactly as it does today. +public struct AuthTypography: Sendable { + /// The PostScript name of a custom font registered with the app (e.g. via Info.plist). + public var fontName: String? + + public init(fontName: String? = nil) { + self.fontName = fontName + } + + public static let `default` = AuthTypography() + + /// Resolves a semantic text style against this typography's `fontName`, preserving Dynamic + /// Type scaling relative to `style`. Falls back to the system font when `fontName` is unset. + public func resolvedFont(for style: Font.TextStyle, weight: Font.Weight? = nil) -> Font { + // Use the style's size at the default content size category; `relativeTo:` applies the + // user's Dynamic Type scaling, so an already-scaled size would be scaled twice. + let base: Font = if let fontName { + .custom( + fontName, + size: UIFont.preferredFont( + forTextStyle: style.uiKit, + compatibleWith: UITraitCollection(preferredContentSizeCategory: .large) + ).pointSize, + relativeTo: style + ) + } else { + .system(style) + } + return weight.map { base.weight($0) } ?? base + } +} + +private struct AuthTypographyKey: EnvironmentKey { + static let defaultValue: AuthTypography = .default +} + +public extension EnvironmentValues { + var authTypography: AuthTypography { + get { self[AuthTypographyKey.self] } + set { self[AuthTypographyKey.self] = newValue } + } +} + +public extension View { + /// Sets the custom font used by every semantic text style (`.headline`, `.body`, + /// `.caption`, etc.) throughout the auth flow, while preserving Dynamic Type scaling relative + /// to each style. + /// + /// ```swift + /// AuthPickerView { ... } + /// .authTypography(AuthTypography(fontName: "Poppins-Regular")) + /// ``` + func authTypography(_ typography: AuthTypography) -> some View { + environment(\.authTypography, typography) + } +} + +struct AuthFontModifier: ViewModifier { + @Environment(\.authTypography) private var typography + let style: Font.TextStyle + var weight: Font.Weight? + + func body(content: Content) -> some View { + content.font(typography.resolvedFont(for: style, weight: weight)) + } +} + +public extension View { + /// Applies a semantic text style resolved against the environment's ``AuthTypography``, so + /// your own views inside the auth flow use the same font as the stock screens. + /// + /// Example: + /// ```swift + /// Text("Welcome back") + /// .authFont(.title2, weight: .semibold) + /// ``` + func authFont(_ style: Font.TextStyle, weight: Font.Weight? = nil) -> some View { + modifier(AuthFontModifier(style: style, weight: weight)) + } +} + +extension View { + /// Like `.authFont(_:weight:)`, but only when ``AuthTypography/fontName`` is set. Otherwise + /// the inherited font is left alone (only `weight` is applied), for text that had no explicit + /// font before theming, so an app-level `.font(...)` still reaches it. + package func authCustomFont(_ style: Font.TextStyle, weight: Font.Weight? = nil) -> some View { + modifier(AuthCustomFontModifier(style: style, weight: weight)) + } +} + +struct AuthCustomFontModifier: ViewModifier { + @Environment(\.authTypography) private var typography + let style: Font.TextStyle + var weight: Font.Weight? + + func body(content: Content) -> some View { + if typography.fontName != nil { + content.font(typography.resolvedFont(for: style, weight: weight)) + } else { + content.fontWeight(weight) + } + } +} + +private extension Font.TextStyle { + var uiKit: UIFont.TextStyle { + switch self { + case .largeTitle: .largeTitle + case .title: .title1 + case .title2: .title2 + case .title3: .title3 + case .headline: .headline + case .subheadline: .subheadline + case .body: .body + case .callout: .callout + case .footnote: .footnote + case .caption: .caption1 + case .caption2: .caption2 + @unknown default: .body + } + } +} diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift index 5d835b959d0..a816970098b 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift @@ -41,3 +41,9 @@ public class PhoneAuthProviderAuthUI: AuthProviderUI { return AnyView(PhoneAuthButtonView(onTap: mainActorClosure)) } } + +extension PhoneAuthProviderAuthUI: AuthProviderAction { + @MainActor public func triggerAction() async throws { + onTap() + } +} diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TriggerSignInTests.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TriggerSignInTests.swift new file mode 100644 index 00000000000..00ae7d88c4b --- /dev/null +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TriggerSignInTests.swift @@ -0,0 +1,225 @@ +// 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 FirebaseCore +import FirebasePhoneAuthSwiftUI +import SwiftUI +import Testing + +private struct TestCredentialError: Error {} + +private final class FailingCredentialProvider: CredentialAuthProviderSwift { + func createAuthCredential() async throws -> AuthCredential { + throw TestCredentialError() + } +} + +private final class PlainProvider: AuthProviderSwift {} + +private class TestProviderUI: AuthProviderUI { + let id: String + let displayName: String + let provider: AuthProviderSwift + + init(id: String, provider: AuthProviderSwift) { + self.id = id + displayName = id + self.provider = provider + } + + @MainActor func authButton() -> AnyView { + AnyView(EmptyView()) + } +} + +private class TestActionProviderUI: TestProviderUI, AuthProviderAction { + var actionCount = 0 + + @MainActor func triggerAction() async throws { + actionCount += 1 + } +} + +@MainActor +struct TriggerSignInTests { + private func makeService() -> AuthService { + configureFirebaseIfNeeded() + return AuthService() + } + + @Test + func registeredProvidersKeepRegistrationOrder() { + let service = makeService() + service.registerProvider(providerWithButton: TestProviderUI(id: "a", provider: PlainProvider())) + service.registerProvider(providerWithButton: TestProviderUI(id: "b", provider: PlainProvider())) + + #expect(service.registeredProviders.map(\.id) == ["a", "b"]) + } + + @Test + func credentialProviderSignsInThroughItsCredential() async { + let service = makeService() + let provider = TestProviderUI(id: "credential", provider: FailingCredentialProvider()) + + await #expect(throws: TestCredentialError.self) { + try await service.triggerSignIn(for: provider) + } + } + + @Test + func actionTakesPrecedenceOverCredential() async throws { + let service = makeService() + let provider = TestActionProviderUI(id: "both", provider: FailingCredentialProvider()) + + let outcome = try await service.triggerSignIn(for: provider) + + #expect(outcome == nil) + #expect(provider.actionCount == 1) + } + + @Test + func actionProviderRunsItsAction() async throws { + let service = makeService() + let provider = TestActionProviderUI(id: "action", provider: PlainProvider()) + + let outcome = try await service.triggerSignIn(for: provider) + + #expect(outcome == nil) + #expect(provider.actionCount == 1) + } + + @Test + func unsupportedProviderThrows() async { + let service = makeService() + let provider = TestProviderUI(id: "custom", provider: PlainProvider()) + + await #expect { + try await service.triggerSignIn(for: provider) + } throws: { error in + guard case AuthServiceError.providerAuthenticationFailed = error else { return false } + return true + } + } + + @Test + func phoneProviderRunsItsTapAction() async throws { + let service = makeService() + var tapCount = 0 + let provider = PhoneAuthProviderAuthUI { tapCount += 1 } + + let outcome = try await service.triggerSignIn(for: provider) + + #expect(outcome == nil) + #expect(tapCount == 1) + } + + @Test + func selectEmailLinkRunsCustomCallback() { + let service = makeService() + var callbackCount = 0 + _ = service.withEmailLinkSignIn { callbackCount += 1 } + + service.selectEmailLink() + + #expect(callbackCount == 1) + #expect(service.authView == nil) + } + + @Test + func selectEmailLinkPushesEmailLinkScreenByDefault() { + let service = makeService() + _ = service.withEmailLinkSignIn() + + service.selectEmailLink() + + #expect(service.authView == .emailLink) + } +} + +@MainActor +struct AuthMethodPickerStateRouteTests { + private final class Calls { + var mfa: [MFARequired] = [] + var conflicts: [AccountConflictContext] = [] + var errors: [Error] = [] + } + + private func route(_ result: Result, + withConflictHandler: Bool = true) -> Calls { + let calls = Calls() + AuthMethodPickerState.route( + result, + mfaHandler: { calls.mfa.append($0) }, + accountConflictHandler: withConflictHandler ? { calls.conflicts.append($0) } : nil, + reportError: { calls.errors.append($0) } + ) + return calls + } + + private func conflictError() -> AuthServiceError { + .accountConflict(AccountConflictContext( + conflictType: .accountExistsWithDifferentCredential, + credential: EmailAuthProvider.credential(withEmail: "a@b.com", password: "123456"), + underlyingError: TestCredentialError(), + message: "conflict", + email: "a@b.com" + )) + } + + @Test + func mfaRequiredGoesToMFAHandler() { + let calls = route(.success(.mfaRequired(MFARequired(hints: [])))) + + #expect(calls.mfa.count == 1) + #expect(calls.conflicts.isEmpty) + #expect(calls.errors.isEmpty) + } + + @Test + func successAndNoOutcomeCallNothing() { + for result: Result in [.success(nil), .success(.signedIn(nil))] { + let calls = route(result) + + #expect(calls.mfa.isEmpty) + #expect(calls.conflicts.isEmpty) + #expect(calls.errors.isEmpty) + } + } + + @Test + func accountConflictGoesToConflictHandler() { + let calls = route(.failure(conflictError())) + + #expect(calls.conflicts.count == 1) + #expect(calls.errors.isEmpty) + } + + @Test + func accountConflictWithoutHandlerIsReported() { + let calls = route(.failure(conflictError()), withConflictHandler: false) + + #expect(calls.errors.count == 1) + } + + @Test + func otherErrorsAreReported() { + let calls = route(.failure(TestCredentialError())) + + #expect(calls.errors.first is TestCredentialError) + #expect(calls.mfa.isEmpty) + #expect(calls.conflicts.isEmpty) + } +} diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift index 9024a19f83d..4f117bceb58 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift @@ -93,10 +93,16 @@ final class MFAResolutionUITests: XCTestCase { return } - let codeField = app.textFields["sms-verification-code-field"] - XCTAssertTrue(codeField.waitForExistence(timeout: 10), "Code field should exist") - codeField.tap() - codeField.typeText(verificationCode) + let firstDigitField = app.otherElements["Digit 1 of 6"].textFields.firstMatch + XCTAssertTrue(firstDigitField.waitForExistence(timeout: 10), "Code field should exist") + // Paste each digit into its own box, matching MFAEnrolmentUITests. + for (index, digit) in verificationCode.enumerated() { + let field = app.otherElements["Digit \(index + 1) of 6"].textFields.firstMatch + UIPasteboard.general.string = String(digit) + field.tap() + field.press(forDuration: 1.2) + app.menuItems["Paste"].tap() + } let completeButton = app.buttons["complete-resolution-button"] XCTAssertTrue(completeButton.exists, "Complete button should exist")