From 87a0c2d8a4b96bc29acd55ced41b918f8bd53bc7 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/9] 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 730853add7c..2b4079cadd2 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 06e13f259a0f77e3c0845205b939587cdc85ea47 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/9] 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 b2db55c175df5c7ab82546baefe0123188206713 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/9] 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 1b274f0f0b7a2c5d9b04ea839cae4a703e079f7c 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/9] 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 2b58f2de330d4e377ea45306ea4e72ad7fa3d852 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/9] 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) + } +} From 3286b00f7c9576d94593d3688f80ab4e352af4f3 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:14:59 +0100 Subject: [PATCH 6/9] fix(auth): return the sign-in outcome from AuthProviderAction.triggerAction An action provider that signs in directly can now hand back .mfaRequired, so a custom method picker routes it to the standard MFA handler. Phone still returns nil after pushing its screen. --- .../Sources/Services/AuthService.swift | 17 ++++++++++------- .../Services/PhoneAuthProviderAuthUI.swift | 3 ++- .../TriggerSignInTests.swift | 19 ++++++++++++++++++- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 2b4079cadd2..9009d7a66fd 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -36,19 +36,23 @@ public protocol AuthProviderUI { /// 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. /// +/// Return the ``SignInOutcome`` when the action signs in directly, so `.mfaRequired` reaches the +/// standard MFA handler. Return `nil` when the action only starts a flow, such as pushing a +/// screen. +/// /// 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() +/// func triggerAction() async throws -> SignInOutcome? { +/// try await authService.signIn(credentials: myFlowCredential()) /// } /// } /// ``` public protocol AuthProviderAction: AuthProviderUI { - @MainActor func triggerAction() async throws + @MainActor func triggerAction() async throws -> SignInOutcome? } public enum AuthenticationState { @@ -210,13 +214,12 @@ public final class AuthService { /// 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(_:)``. + /// ``AuthProviderAction`` providers run ``AuthProviderAction/triggerAction()`` and this + /// returns its outcome. 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 + return try await actionProvider.triggerAction() } if let credentialProvider = provider.provider as? CredentialAuthProviderSwift { return try await signIn(credentialProvider) diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift index a816970098b..0c8c2417a51 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift @@ -43,7 +43,8 @@ public class PhoneAuthProviderAuthUI: AuthProviderUI { } extension PhoneAuthProviderAuthUI: AuthProviderAction { - @MainActor public func triggerAction() async throws { + @MainActor public func triggerAction() async throws -> SignInOutcome? { onTap() + return nil } } diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TriggerSignInTests.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TriggerSignInTests.swift index 00ae7d88c4b..3a32769e857 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TriggerSignInTests.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TriggerSignInTests.swift @@ -47,9 +47,11 @@ private class TestProviderUI: AuthProviderUI { private class TestActionProviderUI: TestProviderUI, AuthProviderAction { var actionCount = 0 + var outcome: SignInOutcome? - @MainActor func triggerAction() async throws { + @MainActor func triggerAction() async throws -> SignInOutcome? { actionCount += 1 + return outcome } } @@ -101,6 +103,21 @@ struct TriggerSignInTests { #expect(provider.actionCount == 1) } + @Test + func actionProviderReturnsItsOutcome() async throws { + let service = makeService() + let provider = TestActionProviderUI(id: "action", provider: PlainProvider()) + provider.outcome = .mfaRequired(MFARequired(hints: [])) + + let outcome = try await service.triggerSignIn(for: provider) + + guard case .mfaRequired = outcome else { + Issue.record("Expected .mfaRequired, got \(String(describing: outcome))") + return + } + #expect(provider.actionCount == 1) + } + @Test func unsupportedProviderThrows() async { let service = makeService() From dce717cc909ee838322fcf2fadfa49b74b35e018 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:24:47 +0100 Subject: [PATCH 7/9] docs(auth): clarify which AuthProviderAction outcomes can resolve MFA MFA can only be resolved for an outcome from AuthService.signIn(credentials:), so the protocol doc says so and the example stores its AuthService and runs on the main actor. Adds tests that an action's MFA outcome and account conflict reach their handlers. --- .../Sources/Services/AuthService.swift | 21 ++++++---- .../Views/DefaultAuthPickerContent.swift | 11 ++--- .../TriggerSignInTests.swift | 41 +++++++++++++++++++ 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 9009d7a66fd..df9888f7975 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -33,21 +33,26 @@ 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. +/// Lets ``AuthService/triggerSignIn(for:)`` start sign-in for a provider that isn't a plain +/// credential provider, such as phone or a custom flow that presents its own screens first. /// -/// Return the ``SignInOutcome`` when the action signs in directly, so `.mfaRequired` reaches the -/// standard MFA handler. Return `nil` when the action only starts a flow, such as pushing a -/// screen. +/// When the action signs in through ``AuthService/signIn(credentials:)``, return its outcome so +/// `.mfaRequired` reaches the standard MFA handler. MFA can only be resolved for an outcome from +/// that call, so return `nil` or `.signedIn` for any other sign-in, and `nil` when the action +/// only starts a flow, such as pushing a screen. /// /// 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 -> SignInOutcome? { -/// try await authService.signIn(credentials: myFlowCredential()) +/// final class MyProviderAuthUI: AuthProviderAction { +/// let authService: AuthService +/// // id, displayName, provider and authButton() as for any AuthProviderUI +/// +/// @MainActor func triggerAction() async throws -> SignInOutcome? { +/// let credential = try await presentMySignInScreen() +/// return try await authService.signIn(credentials: credential) /// } /// } /// ``` diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift index cb444ca8582..56aa71acdb4 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -18,9 +18,9 @@ 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. +/// 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 { @@ -126,8 +126,9 @@ public struct AuthMethodPickerState { /// ``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. + /// Signs in with a provider from ``providers``. MFA, account conflicts and errors are handled + /// for you, the same as the default buttons. 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``. diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TriggerSignInTests.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TriggerSignInTests.swift index 3a32769e857..360ff624820 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TriggerSignInTests.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TriggerSignInTests.swift @@ -48,9 +48,13 @@ private class TestProviderUI: AuthProviderUI { private class TestActionProviderUI: TestProviderUI, AuthProviderAction { var actionCount = 0 var outcome: SignInOutcome? + var error: Error? @MainActor func triggerAction() async throws -> SignInOutcome? { actionCount += 1 + if let error { + throw error + } return outcome } } @@ -196,6 +200,43 @@ struct AuthMethodPickerStateRouteTests { )) } + private func routeSelection(of provider: AuthProviderUI) async -> Calls { + configureFirebaseIfNeeded() + let service = AuthService() + let result: Result + do { + let outcome = try await service.triggerSignIn(for: provider) + result = .success(outcome) + } catch { + result = .failure(error) + } + return route(result) + } + + @Test + func actionMFAReachesMFAHandler() async { + let provider = TestActionProviderUI(id: "action", provider: PlainProvider()) + provider.outcome = .mfaRequired(MFARequired(hints: [])) + + let calls = await routeSelection(of: provider) + + #expect(calls.mfa.count == 1) + #expect(calls.conflicts.isEmpty) + #expect(calls.errors.isEmpty) + } + + @Test + func actionConflictReachesConflictHandler() async { + let provider = TestActionProviderUI(id: "action", provider: PlainProvider()) + provider.error = conflictError() + + let calls = await routeSelection(of: provider) + + #expect(calls.conflicts.count == 1) + #expect(calls.mfa.isEmpty) + #expect(calls.errors.isEmpty) + } + @Test func mfaRequiredGoesToMFAHandler() { let calls = route(.success(.mfaRequired(MFARequired(hints: [])))) From 35cfd3d56b0dc12c042fef97b3ea230894cbf24a Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:26:26 +0100 Subject: [PATCH 8/9] fix(auth): keep DefaultAuthMethodPicker full width inside a content-sized ScrollView --- .../Sources/Views/DefaultAuthPickerContent.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift index 56aa71acdb4..f6cf261b668 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -183,5 +183,8 @@ public struct DefaultAuthMethodPicker: View { authService.renderButtons() // 72% of the container width, matching a 14% inset on each side. .containerRelativeFrame(.horizontal) { length, _ in length * 0.72 } + // Fill the width so an enclosing `ScrollView`, which sizes to its content, spans the + // screen even when nothing else in it is full width. + .frame(maxWidth: .infinity) } } From 405c4614f5f24d7b9f769589f1bc6ba44f6ac609 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:36:27 +0100 Subject: [PATCH 9/9] docs(auth): note that DefaultAuthMethodPicker fills the available width --- .../Sources/Views/DefaultAuthPickerContent.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift index f6cf261b668..79f445e1ae1 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -171,8 +171,9 @@ public struct AuthMethodPickerState { /// 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. +/// The buttons are 72% of the nearest container's width, such as the enclosing `ScrollView`, +/// not of their direct parent. The picker itself fills the available width, so give it a +/// width with `.frame` when placing it in an `HStack`. @MainActor public struct DefaultAuthMethodPicker: View { public init() {}