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
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -29,17 +29,22 @@ public struct AuthPickerContent<
DestinationContent: View
>: View {
init(content: @escaping () -> Content,
pickerContent: @escaping () -> PickerContent,
destination: @escaping (AuthView) -> DestinationContent) {
pickerContent: @escaping (AuthMethodPickerState) -> PickerContent,
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: () -> PickerContent
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?
Expand All @@ -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)
Expand Down Expand Up @@ -87,7 +97,9 @@ public struct AuthPickerContent<
if authService.authenticationState == .authenticated {
SignedInView()
} else {
pickerContent()
AuthMethodPickerStateReader { state in
pickerContent(state)
}
}
}
.overlay {
Expand All @@ -107,14 +119,28 @@ 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<NewPickerContent: View>(
@ViewBuilder _ pickerContent: @escaping () -> NewPickerContent
) -> AuthPickerContent<Content, NewPickerContent, DestinationContent> {
AuthPickerContent<Content, NewPickerContent, DestinationContent>(
content: content,
pickerContent: { _ in pickerContent() },
destination: destination,
showsPickerTitle: true
)
}

/// Replaces the whole signed-out first screen with your own, receiving an
/// ``AuthMethodPickerState``. See the matching `pickerContent` on ``AuthPickerView``.
public func pickerContent<NewPickerContent: View>(
@ViewBuilder _ pickerContent: @escaping (AuthMethodPickerState) -> NewPickerContent
) -> AuthPickerContent<Content, NewPickerContent, DestinationContent> {
AuthPickerContent<Content, NewPickerContent, DestinationContent>(
content: content,
pickerContent: pickerContent,
destination: destination
destination: destination,
showsPickerTitle: false
)
}

Expand All @@ -126,7 +152,8 @@ public struct AuthPickerContent<
AuthPickerContent<Content, PickerContent, NewDestinationContent>(
content: content,
pickerContent: pickerContent,
destination: destination
destination: destination,
showsPickerTitle: showsPickerTitle
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -41,7 +41,7 @@ public struct AuthPickerView<Content: View>: View {
public var body: some View {
AuthPickerContent(
content: content,
pickerContent: { DefaultAuthPickerContent() },
pickerContent: { _ in DefaultAuthPickerContent() },
destination: { DefaultAuthPickerDestination(screen: $0) }
)
}
Expand All @@ -61,11 +61,38 @@ public struct AuthPickerView<Content: View>: View {
) -> AuthPickerContent<Content, NewPickerContent, DefaultAuthPickerDestination> {
AuthPickerContent(
content: content,
pickerContent: pickerContent,
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, terms and the large "Sign in" title (the close
/// button stays), so show your own heading, email entry (for example one built around
/// ``EmailAuthView``) and any legal text you need. To keep the stock screen and only lay out
/// the provider buttons, use ``DefaultAuthPickerContent/init(authMethodPicker:)``.
///
/// Example:
/// ```swift
/// AuthPickerView { authenticatedApp }
/// .pickerContent { state in
/// MyLandingScreen(state: state)
/// }
/// ```
public func pickerContent<NewPickerContent: View>(
@ViewBuilder _ pickerContent: @escaping (AuthMethodPickerState) -> NewPickerContent
) -> AuthPickerContent<Content, NewPickerContent, DefaultAuthPickerDestination> {
AuthPickerContent(
content: content,
pickerContent: pickerContent,
destination: { DefaultAuthPickerDestination(screen: $0) },
showsPickerTitle: false
)
}

/// Replaces the screens pushed inside the authentication sheet.
///
/// Always end a `switch` over the screen with a `default` branch that returns
Expand All @@ -92,7 +119,7 @@ public struct AuthPickerView<Content: View>: View {
> {
AuthPickerContent(
content: content,
pickerContent: { DefaultAuthPickerContent() },
pickerContent: { _ in DefaultAuthPickerContent() },
destination: destination
)
}
Expand Down
Loading
Loading