Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ public protocol AuthProviderUI {
var provider: AuthProviderSwift { get }
}

/// Opt-in for providers whose sign-in doesn't produce a `CredentialAuthProviderSwift` credential
/// (e.g. phone, or a custom OAuth2 flow) — lets `AuthService.triggerSignIn(for:)` invoke their
/// action independent of rendering their button. Conforming to this is optional and doesn't
/// change `AuthProviderUI`'s existing requirements.
public protocol AuthProviderAction: AuthProviderUI {
@MainActor func triggerAction() async throws
}

public enum AuthenticationState {
case unauthenticated
case authenticating
Expand Down Expand Up @@ -184,6 +192,23 @@ public final class AuthService {
providers.append(providerWithButton)
}

public var registeredProviders: [AuthProviderUI] { providers }

/// Signs in with `provider`, independent of rendering its button — for credential-based
/// providers (Facebook/Google/Apple/Twitter/OAuth) this routes through the same `signIn(_:)`
/// their default button already calls; for providers conforming to `AuthProviderAction`
/// (e.g. phone) it invokes `triggerAction()` instead. Returns `nil` when the provider supports
/// neither path, or when its action doesn't produce a `SignInOutcome` (e.g. phone just
/// navigates to a code-entry screen).
public func triggerSignIn(for provider: AuthProviderUI) async throws -> SignInOutcome? {
if let credentialProvider = provider.provider as? CredentialAuthProviderSwift {
return try await signIn(credentialProvider)
} else if let actionable = provider as? AuthProviderAction {
try await actionable.triggerAction()
}
return nil
}

public func renderButtons(spacing: CGFloat = 16) -> AnyView {
AnyView(
VStack(spacing: spacing) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// 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. When
/// `style.font` is left unset, the button falls back to ``AuthTypography`` (via `.authFont(_:)`)
/// so it stays consistent with the rest of the auth flow's typography by default — an explicit
/// `style.font` still overrides that, for buttons that should intentionally look different.
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)
.buttonBorderShape(style.shape ?? .automatic)
.tint(style.backgroundColor)
.modifier(CTAFontModifier(explicitFont: style.font))
}
}

private struct CTAFontModifier: ViewModifier {
let explicitFont: Font?

func body(content: Content) -> some View {
if let explicitFont {
content.font(explicitFont)
} else {
content.authFont(.headline)
}
}
}

public 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())
}

/// 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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import FirebaseAuth
import FirebaseAuthUIComponents
import FirebaseCore
import SwiftUI

/// The default content shown inside the ``AuthPickerView`` sheet: the sign-in method picker
/// (or ``SignedInView`` when already authenticated), plus the authenticating overlay.
///
/// Use this together with ``AuthPickerView/pickerContent(_:)`` to customize its appearance
/// (background, tint, etc.) with plain SwiftUI modifiers, without rebuilding the auth UI from
/// scratch:
///
/// ```swift
/// AuthPickerView { authenticatedApp }
/// .pickerContent {
/// AuthPickerContentView()
/// .background(theme.colors.background)
/// }
/// ```
///
/// To customize the *layout* of the sign-in-method list itself (e.g. a grid of icon-only
/// buttons instead of the default stacked list), pass an `authMethodPicker` builder. It receives
/// the registered providers and a callback to invoke when one is selected — pair it with
/// `AuthService.triggerSignIn(for:)` if you build your own buttons, or use `onProviderSelected`
/// directly:
///
/// ```swift
/// AuthPickerContentView { providers, onProviderSelected in
/// LazyVGrid(columns: [GridItem(), GridItem()]) {
/// ForEach(providers, id: \.id) { provider in
/// Button(provider.displayName) { onProviderSelected(provider) }
/// }
/// }
/// }
/// ```
@MainActor
public struct AuthPickerContentView<AuthMethodPicker: View>: 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: ([AuthProviderUI], @escaping (AuthProviderUI) -> Void) -> AuthMethodPicker

public init() where AuthMethodPicker == DefaultProviderButtonsLayout {
authMethodPicker = { providers, onSelect in
DefaultProviderButtonsLayout(providers: providers, onSelect: onSelect)
}
}

public init(
@ViewBuilder authMethodPicker: @escaping (
[AuthProviderUI],
@escaping (AuthProviderUI) -> Void
) -> AuthMethodPicker
) {
self.authMethodPicker = authMethodPicker
}

public var body: some View {
@Bindable var authService = authService
VStack {
if authService.authenticationState == .authenticated {
SignedInView()
} else {
methodPickerScreen
.safeAreaPadding()
}
}
.overlay {
if authService.authenticationState == .authenticating {
VStack(spacing: 24) {
ProgressView()
.scaleEffect(1.25)
.tint(.white)
Text("Authenticating...")
.authFont(.body)
.foregroundStyle(.white)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.black.opacity(0.7))
}
}
}

@ViewBuilder
private var methodPickerScreen: 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()
authMethodPicker(authService.registeredProviders, handleProviderSelected)
PrivacyTOCsView(displayMode: .full)
}
}
}

/// Signs in with `provider` via `AuthService.triggerSignIn(for:)`, then handles the same
/// MFA/account-conflict/error cases each provider's own default button already handles inline
/// (e.g. `GenericOAuthButton`) — so a custom `authMethodPicker` layout behaves equivalently to
/// the default one.
private func handleProviderSelected(_ provider: AuthProviderUI) {
Task {
do {
if let outcome = try await authService.triggerSignIn(for: provider),
case let .mfaRequired(mfaInfo) = outcome,
let onMFA = mfaHandler {
onMFA(mfaInfo)
}
} catch {
if case let AuthServiceError.accountConflict(ctx) = error,
let onConflict = accountConflictHandler {
onConflict(ctx)
return
}
reportError?(error)
}
}
}
}

/// The default sign-in-method list layout: a `VStack` of each registered provider's own button
/// view, unchanged from what `AuthPickerContentView` has always shown. Uses
/// `.containerRelativeFrame(_:alignment:_:)` to reproduce the original
/// `.padding(.horizontal, proxy.size.width * 0.14)` behavior (72% of the container's width,
/// centered) without needing a `GeometryReader` — which would otherwise have to be exposed
/// through `authMethodPicker`'s signature just for this one layout's benefit, or nested inside
/// the `ScrollView`'s content where `GeometryReader`'s unbounded-height proposal causes sizing
/// bugs.
public struct DefaultProviderButtonsLayout: View {
@Environment(AuthService.self) private var authService
let providers: [AuthProviderUI]
let onSelect: (AuthProviderUI) -> Void

public var body: some View {
VStack {
authService.renderButtons()
}
.containerRelativeFrame(.horizontal) { length, _ in length * 0.72 }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// 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 content shown for each destination pushed inside the ``AuthPickerView`` sheet
/// (password recovery, email link, MFA, phone verification, etc.).
///
/// Use this together with ``AuthPickerView/pickerDestination(_:)`` to customize its appearance
/// (background, tint, etc.) with plain SwiftUI modifiers, without rebuilding the routing switch
/// from scratch:
///
/// ```swift
/// AuthPickerView { authenticatedApp }
/// .pickerDestination { screen in
/// AuthPickerDestinationView(screen: screen)
/// .background(theme.colors.background)
/// }
/// ```
@MainActor
public struct AuthPickerDestinationView: View {
let screen: AuthView

public init(screen: AuthView) {
self.screen = screen
}

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
)
}
}
}
Loading
Loading