From df2c4fc5f7b4dfbe94ed469149ba49df7df6c241 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:31:42 +0100 Subject: [PATCH 1/5] feat(auth): support custom method picker layouts in DefaultAuthPickerContent Add DefaultAuthPickerContent(authMethodPicker:), which hands the layout an AuthMethodPickerState with the registered providers, a select action that routes MFA, account conflicts and errors like the default buttons, and the email-link state and action. Add AuthService.registeredProviders and triggerSignIn(for:), plus the opt-in AuthProviderAction protocol for providers whose sign-in yields no credential; PhoneAuthProviderAuthUI adopts it. Providers that support neither path report an error instead of doing nothing. Move the 14% horizontal inset into DefaultAuthMethodPicker so custom layouts are not padded. --- .../Sources/Services/AuthService.swift | 58 ++++- .../Views/DefaultAuthPickerContent.swift | 140 +++++++++-- .../Services/PhoneAuthProviderAuthUI.swift | 6 + .../TriggerSignInTests.swift | 225 ++++++++++++++++++ 4 files changed, 406 insertions(+), 23 deletions(-) create mode 100644 e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TriggerSignInTests.swift 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/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift index c6e95be5414..372ee3ade25 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -19,40 +19,142 @@ import SwiftUI /// the provider buttons and the terms. /// /// Return it from ``AuthPickerView/pickerContent(_:)`` to keep the stock screen while adding -/// modifiers such as a background. +/// 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 = { 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: () -> AuthMethodPicker + @Environment(\.mfaHandler) private var mfaHandler + @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.reportError) private var reportError + private let authMethodPicker: (AuthMethodPickerState) -> AuthMethodPicker public var body: 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() - authMethodPicker() - .padding(.horizontal, proxy.size.width * 0.14) - PrivacyTOCsView(displayMode: .full) + 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 { + authMethodPicker(pickerState) } + PrivacyTOCsView(displayMode: .full) } } .safeAreaPadding() } + + private var pickerState: AuthMethodPickerState { + AuthMethodPickerState( + providers: authService.registeredProviders, + select: selectProvider, + isEmailLinkEnabled: authService.emailLinkSignInEnabled, + selectEmailLink: authService.selectEmailLink + ) + } + + private func selectProvider(_ provider: AuthProviderUI) { + Task { + let result: Result + do { + result = try await .success(authService.triggerSignIn(for: provider)) + } catch { + result = .failure(error) + } + AuthMethodPickerState.route( + result, + mfaHandler: mfaHandler, + accountConflictHandler: accountConflictHandler, + reportError: reportError + ) + } + } +} + +/// The providers and sign-in actions passed to a custom 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. +/// 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() {} @@ -63,5 +165,7 @@ public struct DefaultAuthMethodPicker: View { VStack { 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/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) + } +} From c72d5993131040c6eeb1009a57305388c58d17d9 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:52:56 +0100 Subject: [PATCH 2/5] refactor(auth): simplify provider selection and default method picker --- .../Sources/Views/DefaultAuthPickerContent.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift index 372ee3ade25..7cf211d99b7 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -88,7 +88,8 @@ public struct DefaultAuthPickerContent: View { Task { let result: Result do { - result = try await .success(authService.triggerSignIn(for: provider)) + let outcome = try await authService.triggerSignIn(for: provider) + result = .success(outcome) } catch { result = .failure(error) } @@ -162,10 +163,8 @@ public struct DefaultAuthMethodPicker: View { @Environment(AuthService.self) private var authService public var body: some View { - VStack { - authService.renderButtons() - } - // 72% of the container width, matching a 14% inset on each side. - .containerRelativeFrame(.horizontal) { length, _ in length * 0.72 } + authService.renderButtons() + // 72% of the container width, matching a 14% inset on each side. + .containerRelativeFrame(.horizontal) { length, _ in length * 0.72 } } } From 74397c00dd8cbacde70c4310f24aeee0cdd48d04 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:32:40 +0100 Subject: [PATCH 3/5] feat(auth): pass AuthMethodPickerState to a whole-screen pickerContent --- .../Sources/Views/AuthPickerContent.swift | 20 ++++++++++-- .../Sources/Views/AuthPickerView.swift | 29 +++++++++++++++-- .../Views/DefaultAuthPickerContent.swift | 32 ++++++++++++++----- 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift index 526861fb523..adb2d70b8ce 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift @@ -29,7 +29,7 @@ public struct AuthPickerContent< DestinationContent: View >: View { init(content: @escaping () -> Content, - pickerContent: @escaping () -> PickerContent, + pickerContent: @escaping (AuthMethodPickerState) -> PickerContent, destination: @escaping (AuthView) -> DestinationContent) { self.content = content self.pickerContent = pickerContent @@ -38,7 +38,7 @@ public struct AuthPickerContent< @Environment(AuthService.self) private var authService private let content: () -> Content - private let pickerContent: () -> PickerContent + private let pickerContent: (AuthMethodPickerState) -> PickerContent private let destination: (AuthView) -> DestinationContent // View-layer error state @@ -87,7 +87,9 @@ public struct AuthPickerContent< if authService.authenticationState == .authenticated { SignedInView() } else { - pickerContent() + AuthMethodPickerStateReader { state in + pickerContent(state) + } } } .overlay { @@ -110,6 +112,18 @@ public struct AuthPickerContent< /// ``AuthPickerView/pickerContent(_:)``. public func pickerContent( @ViewBuilder _ pickerContent: @escaping () -> NewPickerContent + ) -> AuthPickerContent { + AuthPickerContent( + content: content, + pickerContent: { _ in pickerContent() }, + destination: destination + ) + } + + /// 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, diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 17b142d3cd1..36a01e7f22f 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -41,7 +41,7 @@ public struct AuthPickerView: View { public var body: some View { AuthPickerContent( content: content, - pickerContent: { DefaultAuthPickerContent() }, + pickerContent: { _ in DefaultAuthPickerContent() }, destination: { DefaultAuthPickerDestination(screen: $0) } ) } @@ -58,6 +58,31 @@ public struct AuthPickerView: View { /// ``` public func pickerContent( @ViewBuilder _ pickerContent: @escaping () -> NewPickerContent + ) -> AuthPickerContent { + AuthPickerContent( + content: content, + pickerContent: { _ in pickerContent() }, + destination: { DefaultAuthPickerDestination(screen: $0) } + ) + } + + /// 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 and terms too, so show your own email entry + /// (for example ``EmailAuthScreen``) and any legal text you need. To keep the stock screen and + /// only lay out the provider buttons, use ``DefaultAuthPickerContent/init(authMethodPicker:)``. + /// + /// Example: + /// ```swift + /// AuthPickerView { authenticatedApp } + /// .pickerContent { state in + /// MyLandingScreen(state: state) + /// } + /// ``` + public func pickerContent( + @ViewBuilder _ pickerContent: @escaping (AuthMethodPickerState) -> NewPickerContent ) -> AuthPickerContent { AuthPickerContent( content: content, @@ -92,7 +117,7 @@ public struct AuthPickerView: View { > { AuthPickerContent( content: content, - pickerContent: { DefaultAuthPickerContent() }, + pickerContent: { _ in DefaultAuthPickerContent() }, destination: destination ) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift index 7cf211d99b7..84ce375888e 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -50,9 +50,6 @@ public struct DefaultAuthPickerContent: View { } @Environment(AuthService.self) private var authService - @Environment(\.mfaHandler) private var mfaHandler - @Environment(\.accountConflictHandler) private var accountConflictHandler - @Environment(\.reportError) private var reportError private let authMethodPicker: (AuthMethodPickerState) -> AuthMethodPicker public var body: some View { @@ -67,21 +64,39 @@ public struct DefaultAuthPickerContent: View { } Divider() VStack { - authMethodPicker(pickerState) + 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 - private var pickerState: AuthMethodPickerState { - AuthMethodPickerState( + var body: some View { + content(AuthMethodPickerState( providers: authService.registeredProviders, select: selectProvider, isEmailLinkEnabled: authService.emailLinkSignInEnabled, selectEmailLink: authService.selectEmailLink - ) + )) } private func selectProvider(_ provider: AuthProviderUI) { @@ -103,7 +118,8 @@ public struct DefaultAuthPickerContent: View { } } -/// The providers and sign-in actions passed to a custom layout in +/// 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 From 6816cd1c507b2f9b1e2bc20c5fced9bad6149c7b Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:45:04 +0100 Subject: [PATCH 4/5] fix(auth): hide the picker title for a whole-screen pickerContent and fix doc links --- .../Sources/Views/AuthPickerContent.swift | 31 +++++++++++++------ .../Sources/Views/AuthPickerView.swift | 12 ++++--- .../Views/DefaultAuthPickerContent.swift | 2 +- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift index adb2d70b8ce..32852e8c47a 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift @@ -17,7 +17,7 @@ import FirebaseAuthUIComponents import SwiftUI /// An ``AuthPickerView`` with custom sheet content, returned by -/// ``AuthPickerView/pickerContent(_:)`` and ``AuthPickerView/pickerDestination(_:)``. +/// `AuthPickerView`'s `pickerContent` and ``AuthPickerView/pickerDestination(_:)`` modifiers. /// /// It owns the authentication sheet, its `NavigationStack`, error alerts, and MFA and /// account-conflict handling; only the first screen and the pushed screens come from the slots. @@ -30,16 +30,21 @@ public struct AuthPickerContent< >: View { init(content: @escaping () -> Content, pickerContent: @escaping (AuthMethodPickerState) -> PickerContent, - destination: @escaping (AuthView) -> DestinationContent) { + destination: @escaping (AuthView) -> DestinationContent, + showsPickerTitle: Bool = true) { self.content = content self.pickerContent = pickerContent self.destination = destination + self.showsPickerTitle = showsPickerTitle } @Environment(AuthService.self) private var authService private let content: () -> Content private let pickerContent: (AuthMethodPickerState) -> PickerContent private let destination: (AuthView) -> DestinationContent + /// `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? @@ -51,14 +56,19 @@ public struct AuthPickerContent< @Bindable var navigator = authService.navigator NavigationStack(path: $navigator.routes) { root - .navigationTitle(authService.authenticationState == .unauthenticated ? authService - .string.authPickerTitle : "") - .navigationBarTitleDisplayMode(.large) + .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) @@ -109,14 +119,15 @@ public struct AuthPickerContent< } /// Replaces the signed-out first screen of the authentication sheet. See - /// ``AuthPickerView/pickerContent(_:)``. + /// the no-argument `pickerContent` on ``AuthPickerView``. public func pickerContent( @ViewBuilder _ pickerContent: @escaping () -> NewPickerContent ) -> AuthPickerContent { AuthPickerContent( content: content, pickerContent: { _ in pickerContent() }, - destination: destination + destination: destination, + showsPickerTitle: true ) } @@ -128,7 +139,8 @@ public struct AuthPickerContent< AuthPickerContent( content: content, pickerContent: pickerContent, - destination: destination + destination: destination, + showsPickerTitle: false ) } @@ -140,7 +152,8 @@ public struct AuthPickerContent< AuthPickerContent( content: content, pickerContent: pickerContent, - destination: destination + destination: destination, + showsPickerTitle: showsPickerTitle ) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 36a01e7f22f..db685ca1fc0 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -20,7 +20,7 @@ import SwiftUI /// Wraps your app content and presents the authentication sheet whenever /// `AuthService.isPresented` is `true`. /// -/// Use ``pickerContent(_:)`` and ``pickerDestination(_:)`` to replace the sheet's first screen or +/// Use the `pickerContent` modifiers and ``pickerDestination(_:)`` to replace the sheet's first screen or /// any pushed screen while the library keeps driving navigation, MFA and account conflicts. /// /// Example: @@ -70,9 +70,10 @@ public struct AuthPickerView: View { /// ``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 and terms too, so show your own email entry - /// (for example ``EmailAuthScreen``) and any legal text you need. To keep the stock screen and - /// only lay out the provider buttons, use ``DefaultAuthPickerContent/init(authMethodPicker:)``. + /// 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 @@ -87,7 +88,8 @@ public struct AuthPickerView: View { AuthPickerContent( content: content, pickerContent: pickerContent, - destination: { DefaultAuthPickerDestination(screen: $0) } + destination: { DefaultAuthPickerDestination(screen: $0) }, + showsPickerTitle: false ) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift index 84ce375888e..cb444ca8582 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -18,7 +18,7 @@ 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/pickerContent(_:)`` to keep the stock screen while adding +/// 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 From 9ed03af898ac082040d48c89ad3d8b7b785c6dba Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:09:17 +0100 Subject: [PATCH 5/5] test(auth): cover AuthMethodPickerState routing to the MFA, conflict and error handlers --- .../AuthMethodPickerStateTests.swift | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/AuthMethodPickerStateTests.swift 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) + } +}