diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift new file mode 100644 index 0000000000..ae929011cb --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift @@ -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( + @ViewBuilder _ pickerContent: @escaping () -> NewPickerContent + ) -> AuthPickerContent { + AuthPickerContent( + content: content, + pickerContent: pickerContent, + destination: destination + ) + } + + /// Replaces the screens pushed inside the authentication sheet. See + /// ``AuthPickerView/pickerDestination(_:)``. + public func pickerDestination( + @ViewBuilder _ destination: @escaping (AuthView) -> NewDestinationContent + ) -> AuthPickerContent { + AuthPickerContent( + 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 + ) + } + } + + @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)) + } + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 220ee80adc..e836347c5b 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -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 { +public struct AuthPickerView: 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( + @ViewBuilder _ pickerContent: @escaping () -> NewPickerContent + ) -> AuthPickerContent { + 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( + @ViewBuilder _ destination: @escaping (AuthView) -> NewDestinationContent + ) -> AuthPickerContent< + Content, + DefaultAuthPickerContent, + NewDestinationContent + > { + AuthPickerContent( + content: content, + pickerContent: { DefaultAuthPickerContent() }, + destination: destination + ) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift new file mode 100644 index 0000000000..c6e95be541 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -0,0 +1,67 @@ +// 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 + +/// 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 +/// modifiers such as a background. +@MainActor +public struct DefaultAuthPickerContent: View { + public init() where AuthMethodPicker == DefaultAuthMethodPicker { + authMethodPicker = { DefaultAuthMethodPicker() } + } + + @Environment(AuthService.self) private var authService + private let authMethodPicker: () -> 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) + } + } + } + .safeAreaPadding() + } +} + +/// The default provider list on ``DefaultAuthPickerContent``: every registered provider's +/// button, stacked vertically. +@MainActor +public struct DefaultAuthMethodPicker: View { + public init() {} + + @Environment(AuthService.self) private var authService + + public var body: some View { + VStack { + authService.renderButtons() + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift new file mode 100644 index 0000000000..2d5762c3ca --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift @@ -0,0 +1,64 @@ +// 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 screen for each route pushed inside the authentication sheet. +/// +/// Return it from ``AuthPickerView/pickerDestination(_:)`` for the routes you don't customize. +/// Keep a `default` branch: new ``AuthView`` routes may be added in future releases. +/// +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case .enterPhoneNumber: +/// MyPhoneEntry() +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct DefaultAuthPickerDestination: View { + public init(screen: AuthView) { + self.screen = screen + } + + private let screen: AuthView + + 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 + ) + } + } +}