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

import FirebaseAuth
import FirebaseAuthUIComponents
import SwiftUI

/// An ``AuthPickerView`` with custom sheet content, returned by
/// ``AuthPickerView/pickerContent(_:)`` and ``AuthPickerView/pickerDestination(_:)``.
///
/// 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.
/// You don't create it directly.
@MainActor
public struct AuthPickerContent<
Content: View,
PickerContent: View,
DestinationContent: View
>: View {
init(content: @escaping () -> Content,
pickerContent: @escaping () -> PickerContent,
destination: @escaping (AuthView) -> DestinationContent) {
self.content = content
self.pickerContent = pickerContent
self.destination = destination
}

@Environment(AuthService.self) private var authService
private let content: () -> Content
private let pickerContent: () -> PickerContent
private let destination: (AuthView) -> DestinationContent

// View-layer error state
@State private var error: AlertError?

public var body: some View {
@Bindable var authService = authService
content()
.sheet(isPresented: $authService.isPresented) {
@Bindable var navigator = authService.navigator
NavigationStack(path: $navigator.routes) {
root
.navigationTitle(authService.authenticationState == .unauthenticated ? authService
.string.authPickerTitle : "")
.navigationBarTitleDisplayMode(.large)
.toolbar {
toolbar
}
.navigationDestination(for: AuthView.self) { view in
destination(view)
}
}
.environment(\.reportError, reportError)
.errorAlert(
error: $error,
okButtonLabel: authService.string.okButtonLabel
)
.sheet(item: $authService.legacySignInRecovery) { _ in
LegacySignInRecoveryView()
.environment(authService)
}
.interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled)
// Apply account conflict handling at NavigationStack level
.accountConflictHandler()
// Apply MFA handling at NavigationStack level
.mfaHandler()
.environment(authService)
}
}

/// The sheet's first screen. The slot only replaces the signed-out screen, so signed-in users
/// always reach ``SignedInView`` and the progress overlay always shows while signing in.
@ViewBuilder
private var root: some View {
VStack {
if authService.authenticationState == .authenticated {
SignedInView()
} else {
pickerContent()
}
}
.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))
}
}
}

/// Replaces the signed-out first screen of the authentication sheet. See
/// ``AuthPickerView/pickerContent(_:)``.
public func pickerContent<NewPickerContent: View>(
@ViewBuilder _ pickerContent: @escaping () -> NewPickerContent
) -> AuthPickerContent<Content, NewPickerContent, DestinationContent> {
AuthPickerContent<Content, NewPickerContent, DestinationContent>(
content: content,
pickerContent: pickerContent,
destination: destination
)
}

/// Replaces the screens pushed inside the authentication sheet. See
/// ``AuthPickerView/pickerDestination(_:)``.
public func pickerDestination<NewDestinationContent: View>(
@ViewBuilder _ destination: @escaping (AuthView) -> NewDestinationContent
) -> AuthPickerContent<Content, PickerContent, NewDestinationContent> {
AuthPickerContent<Content, PickerContent, NewDestinationContent>(
content: content,
pickerContent: pickerContent,
destination: destination
)
}

/// Closure for reporting errors from child views
private func reportError(_ error: Error) {
Task { @MainActor in
self.error = AlertError(
message: authService.string.localizedErrorMessage(for: error),
underlyingError: error
)
}
}
Comment on lines +134 to +141

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since AuthPickerContent is annotated with @MainActor, the reportError method is already guaranteed to run on the main actor. Spawning a new asynchronous Task to update self.error is redundant and introduces an unnecessary dispatch hop. You can update the state directly.

  private func reportError(_ error: Error) {
    self.error = AlertError(
      message: authService.string.localizedErrorMessage(for: error),
      underlyingError: error
    )
  }


@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
if !authService.configuration.shouldHideCancelButton {
Button {
authService.isPresented = false
} label: {
Image(systemName: "xmark")
.foregroundStyle(Color(UIColor.label))
}
Comment on lines +150 to +152

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using Color(UIColor.label) introduces a dependency on UIKit (via UIColor), which can cause compilation issues on non-iOS platforms (like macOS) and relies on transitive imports since UIKit is not explicitly imported in this file.

Since this is a SwiftUI view, you can use the platform-agnostic Color.primary instead, which automatically adapts to light and dark modes.

Suggested change
Image(systemName: "xmark")
.foregroundStyle(Color(UIColor.label))
}
Image(systemName: "xmark")
.foregroundStyle(Color.primary)
}

}
}
}
}
192 changes: 58 additions & 134 deletions FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,152 +17,76 @@ import FirebaseAuthUIComponents
import FirebaseCore
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
/// any pushed screen while the library keeps driving navigation, MFA and account conflicts.
@MainActor
public struct AuthPickerView<Content: View> {
public struct AuthPickerView<Content: View>: View {
public init(@ViewBuilder content: @escaping () -> Content = { EmptyView() }) {
self.content = content
}

@Environment(AuthService.self) private var authService
private let content: () -> Content

// View-layer error state
@State private var error: AlertError?
}

extension AuthPickerView: View {
public var body: some View {
@Bindable var authService = authService
content()
.sheet(isPresented: $authService.isPresented) {
@Bindable var navigator = authService.navigator
NavigationStack(path: $navigator.routes) {
authPickerViewInternal
.navigationTitle(authService.authenticationState == .unauthenticated ? authService
.string.authPickerTitle : "")
.navigationBarTitleDisplayMode(.large)
.toolbar {
toolbar
}
.navigationDestination(for: AuthView.self) { view in
switch view {
case AuthView.passwordRecovery:
PasswordRecoveryView()
case AuthView.emailLink:
EmailLinkView()
case AuthView.updatePassword:
UpdatePasswordView()
case AuthView.mfaEnrollment:
MFAEnrolmentView()
case AuthView.mfaManagement:
MFAManagementView()
case let .mfaResolution(mfaRequired):
MFAResolutionView(mfaRequired: mfaRequired)
case AuthView.enterPhoneNumber:
EnterPhoneNumberView()
case let .enterVerificationCode(verificationID, fullPhoneNumber):
EnterVerificationCodeView(
verificationID: verificationID,
fullPhoneNumber: fullPhoneNumber
)
}
}
}
.environment(\.reportError, reportError)
.errorAlert(
error: $error,
okButtonLabel: authService.string.okButtonLabel
)
.sheet(item: $authService.legacySignInRecovery) { _ in
LegacySignInRecoveryView()
.environment(authService)
}
.interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled)
// Apply account conflict handling at NavigationStack level
.accountConflictHandler()
// Apply MFA handling at NavigationStack level
.mfaHandler()
.environment(authService)
}
}

/// Closure for reporting errors from child views
private func reportError(_ error: Error) {
Task { @MainActor in
self.error = AlertError(
message: authService.string.localizedErrorMessage(for: error),
underlyingError: error
)
}
}

@ToolbarContentBuilder
var toolbar: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
if !authService.configuration.shouldHideCancelButton {
Button {
authService.isPresented = false
} label: {
Image(systemName: "xmark")
.foregroundStyle(Color(UIColor.label))
}
}
}
}

@ViewBuilder
var authPickerViewInternal: some View {
@Bindable var authService = authService
VStack {
if authService.authenticationState == .authenticated {
SignedInView()
} else {
authMethodPicker
.safeAreaPadding()
}
}
.overlay {
if authService.authenticationState == .authenticating {
VStack(spacing: 24) {
ProgressView()
.scaleEffect(1.25)
.tint(.white)
Text("Authenticating...")
.authFont(.body)
.foregroundStyle(.white)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.black.opacity(0.7))
}
}
AuthPickerContent(
content: content,
pickerContent: { DefaultAuthPickerContent() },
destination: { DefaultAuthPickerDestination(screen: $0) }
)
}

@ViewBuilder
var authMethodPicker: some View {
GeometryReader { proxy in
ScrollView {
VStack(spacing: 24) {
Image(authService.configuration.logo ?? Assets.firebaseAuthLogo)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 100, height: 100)
if authService.emailPasswordSignInEnabled {
EmailAuthView()
}
Divider()
otherSignInOptions(proxy)
PrivacyTOCsView(displayMode: .full)
}
}
}
/// Replaces the signed-out first screen of the authentication sheet. The library still shows
/// ``SignedInView`` to signed-in users and its progress overlay while signing in.
///
/// ```swift
/// AuthPickerView { authenticatedApp }
/// .pickerContent {
/// DefaultAuthPickerContent()
/// .background(theme.colors.background)
/// }
/// ```
public func pickerContent<NewPickerContent: View>(
@ViewBuilder _ pickerContent: @escaping () -> NewPickerContent
) -> AuthPickerContent<Content, NewPickerContent, DefaultAuthPickerDestination> {
AuthPickerContent(
content: content,
pickerContent: pickerContent,
destination: { DefaultAuthPickerDestination(screen: $0) }
)
}

@ViewBuilder
func otherSignInOptions(_ proxy: GeometryProxy) -> some View {
VStack {
authService.renderButtons()
}
.padding(.horizontal, proxy.size.width * 0.14)
/// Replaces the screens pushed inside the authentication sheet.
///
/// Always end a `switch` over the screen with a `default` branch that returns
/// ``DefaultAuthPickerDestination``. New ``AuthView`` routes may be added in future releases,
/// and the `default` branch keeps them working without a code change.
///
/// ```swift
/// AuthPickerView { authenticatedApp }
/// .pickerDestination { screen in
/// switch screen {
/// case .enterPhoneNumber:
/// MyPhoneEntry()
/// default:
/// DefaultAuthPickerDestination(screen: screen)
/// }
/// }
/// ```
public func pickerDestination<NewDestinationContent: View>(
@ViewBuilder _ destination: @escaping (AuthView) -> NewDestinationContent
) -> AuthPickerContent<
Content,
DefaultAuthPickerContent<DefaultAuthMethodPicker>,
NewDestinationContent
> {
AuthPickerContent(
content: content,
pickerContent: { DefaultAuthPickerContent() },
destination: destination
)
}
}

Expand Down
Loading
Loading