From fb0634d46b03984b9a259253823ea3532adbcb10 Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 6 Jul 2026 20:03:20 +0100 Subject: [PATCH 01/24] Adjust EmailAuthView label font weights and drop hardcoded blue tint on the auth-flow switch label --- .../FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index fc6616a8f8..2fa44811d3 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -150,6 +150,7 @@ extension EmailAuthView: View { authService.navigator.push(.passwordRecovery) } label: { Text(authService.string.passwordButtonLabel) + .fontWeight(.medium) .frame(maxWidth: .infinity, alignment: .trailing) } .accessibilityIdentifier("password-recovery-button") @@ -228,8 +229,7 @@ extension EmailAuthView: View { ? authService.string.emailLoginFlowLabel : authService.string.emailSignUpFlowLabel ) - .fontWeight(.semibold) - .foregroundColor(.blue) + .fontWeight(.medium) } } .accessibilityIdentifier("switch-auth-flow") From dba5839e570ad2c1e67c48f7742bc19af4ece76d Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 6 Jul 2026 23:12:01 +0100 Subject: [PATCH 02/24] Add AuthTextFieldStyle and AuthCTAButtonStyle for environment-driven theming of text fields and CTA buttons --- .../Sources/Views/AuthCTAButtonModifier.swift | 79 +++++++++++++++++++ .../Sources/Views/EmailAuthView.swift | 2 +- .../Sources/Views/EmailLinkView.swift | 2 +- .../Sources/Views/EmailReauthView.swift | 2 +- .../Sources/Views/EnterPhoneNumberView.swift | 2 +- .../Views/EnterVerificationCodeView.swift | 2 +- .../Sources/Views/MFAEnrolmentView.swift | 8 +- .../Sources/Views/MFAManagementView.swift | 4 +- .../Sources/Views/PasswordRecoveryView.swift | 2 +- .../Sources/Views/PhoneReauthView.swift | 4 +- .../Sources/Views/SignedInView.swift | 12 +-- .../Sources/Views/UpdatePasswordView.swift | 2 +- .../Sources/Components/AuthTextField.swift | 15 ++-- .../Sources/Theme/AuthTextFieldStyle.swift | 69 ++++++++++++++++ 14 files changed, 179 insertions(+), 26 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTextFieldStyle.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift new file mode 100644 index 0000000000..a83e96c379 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift @@ -0,0 +1,79 @@ +// 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 + +/// Styling configuration for the primary call-to-action buttons used throughout the auth flow +/// (sign in, send code, update password, etc). Color already follows the environment's `.tint()` +/// via `.buttonStyle(.borderedProminent)`; this covers what `.tint()` doesn't — shape and font. +/// Both fields default to `nil`, in which case today's exact appearance is unchanged. +public struct AuthCTAButtonStyle: Sendable { + public var shape: ButtonBorderShape? + public var font: Font? + + public init(shape: ButtonBorderShape? = nil, font: Font? = nil) { + 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 shape/font from the environment's +/// ``AuthCTAButtonStyle``, in place of a bare `.buttonStyle(.borderedProminent)` call. +struct AuthCTAButtonModifier: ViewModifier { + @Environment(\.authCTAButtonStyle) private var style + + func body(content: Content) -> some View { + if let font = style.font { + content + .buttonStyle(.borderedProminent) + .buttonBorderShape(style.shape ?? .automatic) + .font(font) + } else { + content + .buttonStyle(.borderedProminent) + .buttonBorderShape(style.shape ?? .automatic) + } + } +} + +public extension View { + /// Applies the auth flow's primary call-to-action button styling, reading 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(shape: .capsule)) + /// ``` + func authCTAButtonStyle(_ style: AuthCTAButtonStyle) -> some View { + environment(\.authCTAButtonStyle, style) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index 2fa44811d3..a7020f8b7f 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -206,7 +206,7 @@ extension EmailAuthView: View { .disabled(!isValid) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .accessibilityIdentifier("sign-in-button") } Button(action: { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift index 6bdfe6812a..c64c86e570 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift @@ -68,7 +68,7 @@ extension EmailLinkView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(!CommonUtils.isValidEmail(email)) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift index 8d61630ce4..b1d8077d72 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift @@ -109,7 +109,7 @@ extension EmailReauthView: View { .frame(maxWidth: .infinity) } } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(password.isEmpty || isLoading) .accessibilityIdentifier("confirm-password-button") diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift index 95152335ca..62ef77969f 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift @@ -77,7 +77,7 @@ struct EnterPhoneNumberView: View { .frame(maxWidth: .infinity) } } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(authService.authenticationState == .authenticating || phoneNumber.isEmpty) .padding(.top, 8) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift index 2a1da9d406..700b63806e 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift @@ -87,7 +87,7 @@ struct EnterVerificationCodeView: View { .frame(maxWidth: .infinity) } } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(authService.authenticationState == .authenticating || verificationCode.count != 6) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift index 75b13b4765..e3274f3d95 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift @@ -349,7 +349,7 @@ extension MFAEnrolmentView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(!canStartEnrollment) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) @@ -435,7 +435,7 @@ extension MFAEnrolmentView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(!canSendSMSVerification) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) @@ -480,7 +480,7 @@ extension MFAEnrolmentView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(!canCompleteEnrollment) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) @@ -635,7 +635,7 @@ extension MFAEnrolmentView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(!canCompleteEnrollment) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift index eeeddb45b0..592cfee19c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift @@ -104,7 +104,7 @@ extension MFAManagementView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .accessibilityIdentifier("setup-mfa-button") @@ -128,7 +128,7 @@ extension MFAManagementView: View { } .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .accessibilityIdentifier("add-mfa-method-button") } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift index af14a1c167..80285bba2e 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift @@ -63,7 +63,7 @@ extension PasswordRecoveryView: View { } .disabled(!CommonUtils.isValidEmail(email)) .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .navigationTitle(authService.string.passwordRecoveryTitle) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift index 10821e9b7c..df2ca7f894 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift @@ -127,7 +127,7 @@ extension PhoneReauthView: View { .frame(maxWidth: .infinity) } } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(isLoading) .accessibilityIdentifier("send-verification-code-button") Button(authService.string.cancelButtonLabel) { @@ -170,7 +170,7 @@ extension PhoneReauthView: View { .frame(maxWidth: .infinity) } } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(verificationCode.count != 6 || isLoading) .accessibilityIdentifier("verify-button") diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift index e996ccc5b3..759481a261 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift @@ -58,7 +58,7 @@ extension SignedInView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .accessibilityIdentifier("verify-email-button") @@ -70,7 +70,7 @@ extension SignedInView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .accessibilityIdentifier("update-password-button") @@ -83,7 +83,7 @@ extension SignedInView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .accessibilityIdentifier("mfa-management-button") @@ -96,7 +96,7 @@ extension SignedInView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .accessibilityIdentifier("delete-account-button") @@ -118,7 +118,7 @@ extension SignedInView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .accessibilityIdentifier("sign-out-button") @@ -198,7 +198,7 @@ private struct DeleteAccountConfirmationSheet: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .tint(.red) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift index 8480beb818..9e6e3912c1 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift @@ -104,7 +104,7 @@ extension UpdatePasswordView: View { .disabled(!isValid) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .safeAreaPadding() diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift index 8e96711399..636ad44dd8 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift @@ -15,6 +15,7 @@ import SwiftUI public struct AuthTextField: View { + @Environment(\.authTextFieldStyle) private var style @FocusState private var isFocused: Bool @State var obscured: Bool = true @State var hasInteracted: Bool = false @@ -131,10 +132,14 @@ public struct AuthTextField: View { .padding(.vertical, 12) .padding(.horizontal, 12) .background { - RoundedRectangle(cornerRadius: 8) - .fill(Color.accentColor.opacity(0.05)) + RoundedRectangle(cornerRadius: style.cornerRadius ?? 8) + .fill((style.tint ?? Color.accentColor).opacity(0.05)) .strokeBorder(lineWidth: isFocused ? 3 : 1) - .foregroundStyle(isFocused ? Color.accentColor : Color(.systemFill)) + .foregroundStyle( + isFocused + ? (style.tint ?? Color.accentColor) + : (style.containerColor ?? Color(.systemFill)) + ) } .contentShape(Rectangle()) .onTapGesture { @@ -149,8 +154,8 @@ public struct AuthTextField: View { let isValid = validator.isValid(input: text) Text(validator.message) .font(.caption) - .strikethrough(isValid, color: .gray) - .foregroundStyle(isValid ? .gray : .red) + .strikethrough(isValid, color: style.secondaryColor ?? .gray) + .foregroundStyle(isValid ? (style.secondaryColor ?? .gray) : (style.errorColor ?? .red)) .fixedSize(horizontal: false, vertical: true) } } diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTextFieldStyle.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTextFieldStyle.swift new file mode 100644 index 0000000000..6fc0dc405c --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTextFieldStyle.swift @@ -0,0 +1,69 @@ +// 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 + +/// Styling configuration for ``AuthTextField``. All fields default to `nil`, in which case +/// `AuthTextField` falls back to its existing hardcoded appearance. +public struct AuthTextFieldStyle: Sendable { + public var tint: Color? + public var containerColor: Color? + public var secondaryColor: Color? + public var errorColor: Color? + public var cornerRadius: CGFloat? + + public init(tint: Color? = nil, + containerColor: Color? = nil, + secondaryColor: Color? = nil, + errorColor: Color? = nil, + cornerRadius: CGFloat? = nil) { + self.tint = tint + self.containerColor = containerColor + self.secondaryColor = secondaryColor + self.errorColor = errorColor + self.cornerRadius = cornerRadius + } + + public static let `default` = AuthTextFieldStyle() +} + +private struct AuthTextFieldStyleKey: EnvironmentKey { + static let defaultValue: AuthTextFieldStyle = .default +} + +public extension EnvironmentValues { + var authTextFieldStyle: AuthTextFieldStyle { + get { self[AuthTextFieldStyleKey.self] } + set { self[AuthTextFieldStyleKey.self] = newValue } + } +} + +public extension View { + /// Applies a custom appearance to every ``AuthTextField`` in this view's subtree. + /// + /// ```swift + /// AuthPickerView { ... } + /// .authTextFieldStyle( + /// AuthTextFieldStyle( + /// tint: theme.colors.tint, + /// containerColor: theme.colors.container, + /// secondaryColor: theme.colors.secondary, + /// errorColor: theme.colors.error + /// ) + /// ) + /// ``` + func authTextFieldStyle(_ style: AuthTextFieldStyle) -> some View { + environment(\.authTextFieldStyle, style) + } +} From 452606064eba02409daf2fd7768d0faed0614dd4 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 7 Jul 2026 01:44:30 +0100 Subject: [PATCH 03/24] Add AuthTypography for environment-driven custom font family, and close remaining unstyled-text gaps across the auth flow --- .../Sources/Views/AuthCTAButtonModifier.swift | 61 ++++++++--- .../Sources/Views/EmailAuthView.swift | 5 +- .../Sources/Views/EmailLinkReauthView.swift | 11 +- .../Sources/Views/EmailReauthView.swift | 6 +- .../Sources/Views/EnterPhoneNumberView.swift | 2 +- .../Views/EnterVerificationCodeView.swift | 4 +- .../Views/LegacySignInRecoveryView.swift | 6 +- .../Sources/Views/MFAEnrolmentView.swift | 47 ++++---- .../Sources/Views/MFAManagementView.swift | 20 ++-- .../Sources/Views/MFAResolutionView.swift | 30 +++--- .../Sources/Views/PasswordRecoveryView.swift | 4 +- .../Sources/Views/PhoneReauthView.swift | 12 +-- .../Sources/Views/PrivacyTOCsView.swift | 2 + .../Sources/Views/SignedInView.swift | 8 +- .../Components/AuthProviderButton.swift | 1 + .../Sources/Components/AuthTextField.swift | 10 +- .../Sources/Components/CountrySelector.swift | 7 +- .../VerificationCodeInputField.swift | 16 +-- .../Sources/Theme/AuthTypography.swift | 101 ++++++++++++++++++ 19 files changed, 251 insertions(+), 102 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift index a83e96c379..89f2a4ec5a 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift @@ -12,17 +12,26 @@ // 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). Color already follows the environment's `.tint()` -/// via `.buttonStyle(.borderedProminent)`; this covers what `.tint()` doesn't — shape and font. -/// Both fields default to `nil`, in which case today's exact appearance is unchanged. +/// (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(shape: ButtonBorderShape? = nil, font: Font? = nil) { + 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 } @@ -41,28 +50,44 @@ public extension EnvironmentValues { } } -/// Applies `.buttonStyle(.borderedProminent)` plus the shape/font from the environment's -/// ``AuthCTAButtonStyle``, in place of a bare `.buttonStyle(.borderedProminent)` call. +/// 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 { - if let font = style.font { - content - .buttonStyle(.borderedProminent) - .buttonBorderShape(style.shape ?? .automatic) - .font(font) + 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 - .buttonStyle(.borderedProminent) - .buttonBorderShape(style.shape ?? .automatic) + content.authFont(.headline) } } } public extension View { - /// Applies the auth flow's primary call-to-action button styling, reading shape/font from - /// the environment's ``AuthCTAButtonStyle`` (set via `.authCTAButtonStyle(_:)`). + /// 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()) } @@ -71,7 +96,9 @@ public extension View { /// /// ```swift /// AuthPickerView { ... } - /// .authCTAButtonStyle(AuthCTAButtonStyle(shape: .capsule)) + /// .authCTAButtonStyle( + /// AuthCTAButtonStyle(backgroundColor: theme.colors.tint, contentColor: .white, shape: .capsule) + /// ) /// ``` func authCTAButtonStyle(_ style: AuthCTAButtonStyle) -> some View { environment(\.authCTAButtonStyle, style) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index a7020f8b7f..5f59199573 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -150,7 +150,7 @@ extension EmailAuthView: View { authService.navigator.push(.passwordRecovery) } label: { Text(authService.string.passwordButtonLabel) - .fontWeight(.medium) + .authFont(.body, weight: .medium) .frame(maxWidth: .infinity, alignment: .trailing) } .accessibilityIdentifier("password-recovery-button") @@ -223,13 +223,14 @@ extension EmailAuthView: View { ? authService.string.dontHaveAnAccountYetLabel : authService.string.alreadyHaveAnAccountLabel ) + .authFont(.body) .foregroundStyle(Color(.label)) Text( authService.authenticationFlow == .signUp ? authService.string.emailLoginFlowLabel : authService.string.emailSignUpFlowLabel ) - .fontWeight(.medium) + .authFont(.body, weight: .medium) } } .accessibilityIdentifier("switch-auth-flow") diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift index f151cda431..04f5ab8c54 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift @@ -13,6 +13,7 @@ // limitations under the License. import FirebaseAuth +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -81,20 +82,20 @@ extension EmailLinkReauthView: View { .padding(.top, 32) Text("Check Your Email") - .font(.title) + .authFont(.title) .fontWeight(.bold) Text("We've sent a verification link to:") - .font(.body) + .authFont(.body) .foregroundStyle(.secondary) Text(email) - .font(.body) + .authFont(.body) .fontWeight(.medium) .padding(.horizontal) Text("Tap the link in the email to complete reauthentication.") - .font(.body) + .authFont(.body) .multilineTextAlignment(.center) .foregroundStyle(.secondary) .padding(.horizontal, 32) @@ -110,6 +111,7 @@ extension EmailLinkReauthView: View { .frame(height: 32) } else { Text("Resend Email") + .authFont(.body) .frame(height: 32) } } @@ -123,6 +125,7 @@ extension EmailLinkReauthView: View { ProgressView() .padding(.top, 32) Text("Sending verification email...") + .authFont(.body) .foregroundStyle(.secondary) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift index b1d8077d72..34a3fb497b 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift @@ -66,11 +66,11 @@ extension EmailReauthView: View { .foregroundColor(.blue) Text(authService.string.confirmPasswordTitle) - .font(.title) + .authFont(.title) .fontWeight(.bold) Text(authService.string.forSecurityEnterPasswordMessage) - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -78,7 +78,7 @@ extension EmailReauthView: View { VStack(spacing: 20) { Text(authService.string.emailPrefix(email: email)) - .font(.caption) + .authFont(.caption) .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 8) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift index 62ef77969f..54e41c1fa8 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift @@ -26,7 +26,7 @@ struct EnterPhoneNumberView: View { var body: some View { VStack(spacing: 16) { Text(authService.string.enterPhoneNumberPlaceholder) - .font(.subheadline) + .authFont(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift index 700b63806e..3a644e713c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift @@ -33,7 +33,7 @@ struct EnterVerificationCodeView: View { VStack(spacing: 16) { VStack(spacing: 8) { Text(authService.string.sentCodeMessage(phoneNumber: fullPhoneNumber)) - .font(.subheadline) + .authFont(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: .infinity, alignment: .leading) @@ -42,7 +42,7 @@ struct EnterVerificationCodeView: View { authService.navigator.pop() } label: { Text(authService.string.changeNumberButtonLabel) - .font(.caption) + .authFont(.caption) .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/LegacySignInRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/LegacySignInRecoveryView.swift index 11c1d35367..d49a600037 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/LegacySignInRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/LegacySignInRecoveryView.swift @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -26,8 +27,9 @@ struct LegacySignInRecoveryView: View { VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 12) { Text(authService.string.legacySignInRecoveryTitle) - .font(.title2.weight(.semibold)) + .authFont(.title2, weight: .semibold) Text(authService.string.legacySignInRecoveryMessage(email: recovery.email)) + .authFont(.body) .foregroundStyle(.secondary) } @@ -35,7 +37,7 @@ struct LegacySignInRecoveryView: View { if !recovery.unavailableProviders.isEmpty { Text(authService.string.legacySignInRecoveryUnavailableMessage) - .font(.footnote) + .authFont(.footnote) .foregroundStyle(.secondary) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift index e3274f3d95..c5928223f8 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift @@ -210,12 +210,12 @@ extension MFAEnrolmentView: View { if currentSession == nil { VStack(spacing: 8) { Text("Set Up Two-Factor Authentication") - .font(.largeTitle) + .authFont(.largeTitle) .fontWeight(.bold) .multilineTextAlignment(.center) Text("Add an extra layer of security to your account") - .font(.subheadline) + .authFont(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -230,13 +230,13 @@ extension MFAEnrolmentView: View { .foregroundColor(.orange) Text("Multi-Factor Authentication Disabled") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text( "MFA is not enabled in the current configuration. Please contact your administrator." ) - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -248,11 +248,11 @@ extension MFAEnrolmentView: View { .foregroundColor(.orange) Text("No Authentication Methods Available") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text("No MFA methods are configured as allowed. Please contact your administrator.") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -260,7 +260,7 @@ extension MFAEnrolmentView: View { } else { VStack(alignment: .leading, spacing: 12) { Text("Choose Authentication Method") - .font(.headline) + .authFont(.headline) Picker("Authentication Method", selection: $selectedFactorType) { ForEach(allowedFactorTypes, id: \.self) { factorType in @@ -309,11 +309,11 @@ extension MFAEnrolmentView: View { .foregroundColor(.blue) Text("SMS Authentication") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text("We'll send a verification code to your phone number each time you sign in.") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -324,13 +324,13 @@ extension MFAEnrolmentView: View { .foregroundColor(.green) Text("Authenticator App") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text( "Use an authenticator app like Google Authenticator or Authy to generate verification codes." ) - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -379,11 +379,11 @@ extension MFAEnrolmentView: View { .foregroundColor(.blue) Text("Enter Your Phone Number") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text("We'll send a verification code to this number") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -449,11 +449,11 @@ extension MFAEnrolmentView: View { .foregroundColor(.green) Text("Enter Verification Code") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text("We sent a code to \(session.phoneNumber ?? "your phone")") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -490,6 +490,7 @@ extension MFAEnrolmentView: View { sendSMSVerification() } label: { Text("Resend Code") + .authFont(.body) .padding(.vertical, 8) .frame(maxWidth: .infinity) } @@ -513,11 +514,11 @@ extension MFAEnrolmentView: View { .foregroundColor(.green) Text("Scan QR Code") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text("Scan with your authenticator app or tap to open directly") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) .lineLimit(nil) @@ -540,9 +541,9 @@ extension MFAEnrolmentView: View { HStack(spacing: 6) { Image(systemName: "arrow.up.forward.app.fill") - .font(.caption) + .authFont(.caption) Text("Tap to open in authenticator app") - .font(.caption) + .authFont(.caption) .fontWeight(.medium) } .foregroundColor(.blue) @@ -557,17 +558,17 @@ extension MFAEnrolmentView: View { .overlay( VStack { Image(systemName: "exclamationmark.triangle") - .font(.title) + .authFont(.title) .foregroundColor(.orange) Text("Unable to generate QR Code") - .font(.caption) + .authFont(.caption) } ) } VStack(spacing: 6) { Text("Manual Entry Key:") - .font(.headline) + .authFont(.headline) Button(action: { copyToClipboard(totpInfo.sharedSecretKey) @@ -592,7 +593,7 @@ extension MFAEnrolmentView: View { if showCopiedFeedback { Text("Copied to clipboard!") - .font(.caption) + .authFont(.caption) .foregroundColor(.green) .transition(.opacity) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift index 592cfee19c..2d337dd2c8 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift @@ -67,12 +67,12 @@ extension MFAManagementView: View { // Title section VStack { Text("Two-Factor Authentication") - .font(.largeTitle) + .authFont(.largeTitle) .fontWeight(.bold) .multilineTextAlignment(.center) Text("Manage your authentication methods") - .font(.subheadline) + .authFont(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -86,13 +86,13 @@ extension MFAManagementView: View { .foregroundColor(.orange) Text("No Authentication Methods") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text( "Set up two-factor authentication to add an extra layer of security to your account." ) - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) @@ -113,7 +113,7 @@ extension MFAManagementView: View { // Show enrolled factors VStack(alignment: .leading, spacing: 16) { Text("Enrolled Methods") - .font(.headline) + .authFont(.headline) .padding(.horizontal) ForEach(enrolledFactors) { factor in @@ -156,25 +156,25 @@ extension MFAManagementView: View { .foregroundColor(.green) } } - .font(.title2) + .authFont(.title2) VStack(alignment: .leading, spacing: 4) { Text(factor.displayName ?? authService.string.unnamedMethodLabel) - .font(.headline) + .authFont(.headline) if factor.factorID == PhoneMultiFactorID { let phoneInfo = factor as! PhoneMultiFactorInfo Text("SMS: \(phoneInfo.phoneNumber)") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } else { Text("Authenticator App") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } Text("Enrolled: \(DateFormatter.shortDate.string(from: factor.enrollmentDate))") - .font(.caption2) + .authFont(.caption2) .foregroundColor(.secondary) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift index f6e88f94ea..cf47658ccf 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift @@ -13,6 +13,7 @@ // limitations under the License. import FirebaseAuth +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -114,12 +115,12 @@ extension MFAResolutionView: View { .foregroundColor(.blue) Text("Two-Factor Authentication") - .font(.largeTitle) + .authFont(.largeTitle) .fontWeight(.bold) .accessibilityIdentifier("mfa-resolution-title") Text("Complete sign-in with your second factor") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -145,6 +146,7 @@ extension MFAResolutionView: View { .scaleEffect(0.8) } Text("Complete Sign-In") + .authFont(.body) } .frame(maxWidth: .infinity) .padding() @@ -158,6 +160,7 @@ extension MFAResolutionView: View { // Cancel Button Button(action: cancelResolution) { Text("Cancel") + .authFont(.body) .frame(maxWidth: .infinity) .padding() .background(Color.gray.opacity(0.2)) @@ -190,16 +193,16 @@ extension MFAResolutionView: View { .foregroundColor(.blue) Text("SMS Verification") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) if let phoneNumber = phoneNumber { Text("We'll send a code to ••••••\(String(phoneNumber.suffix(4)))") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) } else { Text("We'll send a verification code to your phone") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) } } @@ -214,6 +217,7 @@ extension MFAResolutionView: View { .scaleEffect(0.8) } Text("Send Code") + .authFont(.body) } .frame(maxWidth: .infinity) .padding() @@ -228,7 +232,7 @@ extension MFAResolutionView: View { // Verification code input VStack(alignment: .leading, spacing: 8) { Text("Verification Code") - .font(.headline) + .authFont(.headline) TextField("Enter 6-digit code", text: $verificationCode) .textFieldStyle(RoundedBorderTextFieldStyle()) @@ -250,17 +254,17 @@ extension MFAResolutionView: View { .foregroundColor(.green) Text("Authenticator App") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text("Enter the 6-digit code from your authenticator app") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) if let displayName = displayName { Text(authService.string.accountPrefix(displayName: displayName)) - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } } @@ -269,7 +273,7 @@ extension MFAResolutionView: View { // TOTP code input VStack(alignment: .leading, spacing: 8) { Text("Verification Code") - .font(.headline) + .authFont(.headline) TextField("Enter 6-digit code", text: $totpCode) .textFieldStyle(RoundedBorderTextFieldStyle()) @@ -285,7 +289,7 @@ extension MFAResolutionView: View { private func mfaHintsSelectionView(mfaRequired: MFARequired) -> some View { VStack(alignment: .leading, spacing: 12) { Text("Choose verification method:") - .font(.headline) + .authFont(.headline) .padding(.horizontal) // More idiomatic approach using indices @@ -311,7 +315,7 @@ extension MFAResolutionView: View { VStack(alignment: .leading) { Text(hintDisplayName(for: hint)) - .font(.body) + .authFont(.body) .foregroundColor(.primary) hintSubtitle(for: hint) @@ -345,7 +349,7 @@ extension MFAResolutionView: View { private func hintSubtitle(for hint: MFAHint) -> some View { if case let .phone(_, _, phoneNumber) = hint, let phone = phoneNumber { Text("••••••\(String(phone.suffix(4)))") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift index 80285bba2e..7e28ea2dee 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift @@ -78,15 +78,17 @@ extension PasswordRecoveryView: View { private var successSheet: some View { VStack { Text(authService.string.passwordRecoveryEmailSentTitle) - .font(.largeTitle) + .authFont(.largeTitle) .fontWeight(.bold) .padding() Text(authService.string.passwordRecoveryHelperMessage) + .authFont(.body) .padding() Divider() Text(String(format: authService.string.passwordRecoveryEmailSentMessage, sentEmail)) + .authFont(.body) .padding() Divider() diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift index df2ca7f894..deed8e5799 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift @@ -91,11 +91,11 @@ extension PhoneReauthView: View { .foregroundColor(.blue) Text(authService.string.verifyPhoneNumberTitle) - .font(.title) + .authFont(.title) .fontWeight(.bold) Text(authService.string.forSecurityVerifyPhoneMessage) - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -105,12 +105,12 @@ extension PhoneReauthView: View { // Initial state - sending SMS VStack(spacing: 20) { Text(authService.string.sendVerificationCodeToPhonePrefix) - .font(.subheadline) + .authFont(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) Text(phoneNumber) - .font(.headline) + .authFont(.headline) .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 8) @@ -139,12 +139,12 @@ extension PhoneReauthView: View { // Enter verification code VStack(spacing: 20) { Text(authService.string.enterSixDigitCodeSentToPrefix) - .font(.subheadline) + .authFont(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) Text(phoneNumber) - .font(.caption) + .authFont(.caption) .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 8) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift index f5e6bfb638..e4dc34c74f 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift @@ -18,6 +18,7 @@ // // Created by Russell Wheatley on 12/05/2025. // +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -66,6 +67,7 @@ extension PrivacyTOCsView: View { if let tosURL = authService.configuration.tosUrl, let privacyURL = authService.configuration.privacyPolicyUrl { Text(attributedMessage(tosURL: tosURL, privacyURL: privacyURL)) + .authFont(.body) .multilineTextAlignment(displayMode == .full ? .center : .trailing) .padding() } else { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift index 759481a261..cbf5b9c7f8 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift @@ -41,13 +41,14 @@ extension SignedInView: View { public var body: some View { VStack { Text(authService.string.signedInTitle) - .font(.largeTitle) + .authFont(.largeTitle) .fontWeight(.bold) .padding() .accessibilityIdentifier("signed-in-text") Text( "\(authService.currentUser?.email ?? authService.currentUser?.displayName ?? authService.currentUser?.phoneNumber ?? "")" ) + .authFont(.body) if authService.currentUser?.isEmailVerified == false { Button { Task { @@ -178,13 +179,13 @@ private struct DeleteAccountConfirmationSheet: View { .foregroundColor(.red) Text("Delete Account?") - .font(.title) + .authFont(.title) .fontWeight(.bold) Text( "This action cannot be undone. All your data will be permanently deleted. You may need to reauthenticate to complete this action." ) - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) @@ -208,6 +209,7 @@ private struct DeleteAccountConfirmationSheet: View { onCancel() } label: { Text("Cancel") + .authFont(.body) .padding(.vertical, 8) .frame(maxWidth: .infinity) } diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift index f8df18d14d..1dbd1e7138 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift @@ -42,6 +42,7 @@ public struct AuthProviderButton: View { providerIcon(for: icon, tint: style.iconTint) } Text(label) + .authFont(.body) .lineLimit(1) .truncationMode(.tail) .foregroundStyle(style.contentColor) diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift index 636ad44dd8..8151340c8a 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift @@ -16,6 +16,7 @@ import SwiftUI public struct AuthTextField: View { @Environment(\.authTextFieldStyle) private var style + @Environment(\.authTypography) private var typography @FocusState private var isFocused: Bool @State var obscured: Bool = true @State var hasInteracted: Bool = false @@ -69,16 +70,17 @@ public struct AuthTextField: View { public var body: some View { VStack(alignment: .leading) { Text(LocalizedStringResource(stringLiteral: label)) + .authFont(.body) HStack(spacing: 8) { leading() Group { if isSecureTextField { ZStack(alignment: .trailing) { - SecureField(label, text: $text, prompt: Text(prompt)) + SecureField(label, text: $text, prompt: Text(prompt).font(typography.resolvedFont(for: .body))) .opacity(obscured ? 1 : 0) .focused($isFocused) .frame(height: 24) - TextField(label, text: $text, prompt: Text(prompt)) + TextField(label, text: $text, prompt: Text(prompt).font(typography.resolvedFont(for: .body))) .opacity(obscured ? 0 : 1) .focused($isFocused) .frame(height: 24) @@ -101,7 +103,7 @@ public struct AuthTextField: View { TextField( label, text: $text, - prompt: Text(prompt) + prompt: Text(prompt).font(typography.resolvedFont(for: .body)) ) .frame(height: 24) } @@ -153,7 +155,7 @@ public struct AuthTextField: View { ForEach(validations) { validator in let isValid = validator.isValid(input: text) Text(validator.message) - .font(.caption) + .authFont(.caption) .strikethrough(isValid, color: style.secondaryColor ?? .gray) .foregroundStyle(isValid ? (style.secondaryColor ?? .gray) : (style.errorColor ?? .red)) .fixedSize(horizontal: false, vertical: true) diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift index 050d3c37ce..c908927d64 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift @@ -87,18 +87,19 @@ public struct CountrySelector: View { selectedCountry = country } label: { Text("\(country.flag) \(country.name) (\(country.dialCode))") + .authFont(.body) } .accessibilityIdentifier("country-option-\(country.code)") } } label: { HStack(spacing: 4) { Text(selectedCountry.flag) - .font(.title3) + .authFont(.title3) Text(selectedCountry.dialCode) - .font(.body) + .authFont(.body) .foregroundStyle(.primary) Image(systemName: "chevron.down") - .font(.caption2) + .authFont(.caption2) .foregroundStyle(.secondary) } } diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift index cc226a33c3..06eced331f 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift @@ -91,7 +91,7 @@ public struct VerificationCodeInputField: View { if isError, let errorMessage = errorMessage { Text(errorMessage) - .font(.caption) + .authFont(.caption) .foregroundColor(.red) .frame(maxWidth: .infinity, alignment: .leading) } @@ -102,7 +102,7 @@ public struct VerificationCodeInputField: View { ForEach(validations) { validator in let isValid = validator.isValid(input: code) Text(validator.message) - .font(.caption) + .authFont(.caption) .strikethrough(isValid, color: .gray) .foregroundStyle(isValid ? .gray : .red) .fixedSize(horizontal: false, vertical: true) @@ -505,7 +505,7 @@ private final class BackspaceUITextField: UITextField { return VStack(spacing: 32) { Text("Enter Verification Code") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) VerificationCodeInputField( @@ -519,7 +519,7 @@ private final class BackspaceUITextField: UITextField { ) Text("Current code: \(code)") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } .padding() @@ -530,7 +530,7 @@ private final class BackspaceUITextField: UITextField { return VStack(spacing: 32) { Text("Enter Verification Code") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) VerificationCodeInputField( @@ -546,7 +546,7 @@ private final class BackspaceUITextField: UITextField { ) Text("Current code: \(code)") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } .padding() @@ -557,7 +557,7 @@ private final class BackspaceUITextField: UITextField { return VStack(spacing: 32) { Text("Enter 4-Digit Code") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) VerificationCodeInputField( @@ -572,7 +572,7 @@ private final class BackspaceUITextField: UITextField { ) Text("Current code: \(code)") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } .padding() diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift new file mode 100644 index 0000000000..8578d311f5 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift @@ -0,0 +1,101 @@ +// 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 + +/// Typography configuration for the auth flow. `fontFamily` defaults to `nil`, in which case +/// text keeps using the system font at each semantic text style, exactly as it does today. +public struct AuthTypography: Sendable { + /// The PostScript name of a custom font registered with the app (e.g. via Info.plist). + public var fontFamily: String? + + public init(fontFamily: String? = nil) { + self.fontFamily = fontFamily + } + + public static let `default` = AuthTypography() + + /// Resolves a semantic text style against this typography's `fontFamily`, preserving Dynamic + /// Type scaling relative to `style`. Falls back to the system font when `fontFamily` is unset. + public func resolvedFont(for style: Font.TextStyle, weight: Font.Weight? = nil) -> Font { + let base: Font = if let fontFamily { + .custom(fontFamily, size: UIFont.preferredFont(forTextStyle: style.uiKit).pointSize, relativeTo: style) + } else { + .system(style) + } + return weight.map { base.weight($0) } ?? base + } +} + +private struct AuthTypographyKey: EnvironmentKey { + static let defaultValue: AuthTypography = .default +} + +public extension EnvironmentValues { + var authTypography: AuthTypography { + get { self[AuthTypographyKey.self] } + set { self[AuthTypographyKey.self] = newValue } + } +} + +public extension View { + /// Sets the custom font family used by every semantic text style (`.headline`, `.body`, + /// `.caption`, etc.) throughout the auth flow, while preserving Dynamic Type scaling relative + /// to each style. + /// + /// ```swift + /// AuthPickerView { ... } + /// .authTypography(AuthTypography(fontFamily: "Poppins-Regular")) + /// ``` + func authTypography(_ typography: AuthTypography) -> some View { + environment(\.authTypography, typography) + } +} + +public struct AuthFontModifier: ViewModifier { + @Environment(\.authTypography) private var typography + let style: Font.TextStyle + var weight: Font.Weight? + + public func body(content: Content) -> some View { + content.font(typography.resolvedFont(for: style, weight: weight)) + } +} + +public extension View { + /// Applies a semantic text style, resolved against the environment's ``AuthTypography`` — + /// use in place of a bare `.font(.headline)`/`.font(.caption)`/etc. call. + func authFont(_ style: Font.TextStyle, weight: Font.Weight? = nil) -> some View { + modifier(AuthFontModifier(style: style, weight: weight)) + } +} + +private extension Font.TextStyle { + var uiKit: UIFont.TextStyle { + switch self { + case .largeTitle: .largeTitle + case .title: .title1 + case .title2: .title2 + case .title3: .title3 + case .headline: .headline + case .subheadline: .subheadline + case .body: .body + case .callout: .callout + case .footnote: .footnote + case .caption: .caption1 + case .caption2: .caption2 + @unknown default: .body + } + } +} From 3df38020969b684424409185e226eeeed193a635 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:33:12 +0100 Subject: [PATCH 04/24] fix(auth): apply AuthTypography to typed text and the authenticating overlay --- .../FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift | 1 + .../Sources/Components/AuthTextField.swift | 1 + .../Sources/Components/VerificationCodeInputField.swift | 4 +++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 8454c79d8b..220ee80adc 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -128,6 +128,7 @@ extension AuthPickerView: View { .scaleEffect(1.25) .tint(.white) Text("Authenticating...") + .authFont(.body) .foregroundStyle(.white) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift index 8151340c8a..0bc5627269 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift @@ -108,6 +108,7 @@ public struct AuthTextField: View { .frame(height: 24) } } + .authFont(.body) } .frame(maxWidth: .infinity) .keyboardType(keyboardType) diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift index 06eced331f..8d7fb4d2be 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift @@ -293,6 +293,7 @@ public struct VerificationCodeInputField: View { } private struct SingleDigitField: View { + @Environment(\.authTypography) private var typography @Binding var digit: String let isError: Bool let isFocused: Bool @@ -331,7 +332,8 @@ private struct SingleDigitField: View { }, maxCharacters: maxDigits, configuration: { textField in - textField.font = .systemFont(ofSize: 24, weight: .medium) + textField.font = typography.fontFamily.flatMap { UIFont(name: $0, size: 24) } + ?? .systemFont(ofSize: 24, weight: .medium) textField.textAlignment = .center textField.keyboardType = .numberPad textField.textContentType = .oneTimeCode From a552c15cd7a500e5233c3849ebc12e702d59558f Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:01:56 +0100 Subject: [PATCH 05/24] feat(auth): re-export FirebaseAuthUIComponents from FirebaseAuthSwiftUI --- .../FirebaseAuthSwiftUI/Sources/Exports.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Exports.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Exports.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Exports.swift new file mode 100644 index 0000000000..fdc5d9c91e --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Exports.swift @@ -0,0 +1,17 @@ +// 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. + +// FirebaseAuthUIComponents is not a library product, so re-export it to make +// AuthTextFieldStyle and AuthTypography reachable from `import FirebaseAuthSwiftUI`. +@_exported import FirebaseAuthUIComponents From da943d31aec529367d1df428beea788640ef1b92 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:18:24 +0100 Subject: [PATCH 06/24] fix(auth): avoid double Dynamic Type scaling for custom font families --- .../Sources/Theme/AuthTypography.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift index 8578d311f5..02b245770a 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift @@ -29,8 +29,17 @@ public struct AuthTypography: Sendable { /// Resolves a semantic text style against this typography's `fontFamily`, preserving Dynamic /// Type scaling relative to `style`. Falls back to the system font when `fontFamily` is unset. public func resolvedFont(for style: Font.TextStyle, weight: Font.Weight? = nil) -> Font { + // Use the style's size at the default content size category; `relativeTo:` applies the + // user's Dynamic Type scaling, so an already-scaled size would be scaled twice. let base: Font = if let fontFamily { - .custom(fontFamily, size: UIFont.preferredFont(forTextStyle: style.uiKit).pointSize, relativeTo: style) + .custom( + fontFamily, + size: UIFont.preferredFont( + forTextStyle: style.uiKit, + compatibleWith: UITraitCollection(preferredContentSizeCategory: .large) + ).pointSize, + relativeTo: style + ) } else { .system(style) } From 840a7550d9cfede94f2cfc721058c6f925e5430a Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:55:49 +0100 Subject: [PATCH 07/24] fix(auth): address review findings on theming defaults and API surface - CTA labels fall back to body, not headline, so unthemed buttons keep their weight - Provider buttons keep the system font; Delete Account stays red under a CTA theme - Make the no-argument authCTAButtonStyle() and AuthFontModifier internal - Rename AuthTypography.fontFamily to fontName - Re-apply the custom font to verification code boxes on update - Move MFAResolutionView onto VerificationCodeInputField and shared button styles --- .../Sources/Views/AuthCTAButtonModifier.swift | 6 ++- .../Sources/Views/MFAResolutionView.swift | 49 +++++++------------ .../Sources/Views/SignedInView.swift | 3 +- .../Components/AuthProviderButton.swift | 1 - .../VerificationCodeInputField.swift | 9 +++- .../Sources/Theme/AuthTypography.swift | 24 ++++----- .../MFAResolutionUITests.swift | 14 ++++-- 7 files changed, 53 insertions(+), 53 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift index 89f2a4ec5a..568958e8e5 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift @@ -80,18 +80,20 @@ private struct CTAFontModifier: ViewModifier { if let explicitFont { content.font(explicitFont) } else { - content.authFont(.headline) + content.authFont(.body) } } } -public extension View { +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()) } +} +public extension View { /// Sets the ``AuthCTAButtonStyle`` used by every CTA button in this view's subtree. /// /// ```swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift index cf47658ccf..722b4ee825 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift @@ -17,11 +17,6 @@ import FirebaseAuthUIComponents import FirebaseCore import SwiftUI -private enum FocusableField: Hashable { - case verificationCode - case totpCode -} - @MainActor public struct MFAResolutionView { let mfaRequired: MFARequired @@ -35,8 +30,6 @@ public struct MFAResolutionView { @State private var selectedHintIndex = 0 @State private var verificationId: String? - @FocusState private var focus: FocusableField? - public init(mfaRequired: MFARequired) { self.mfaRequired = mfaRequired } @@ -146,14 +139,11 @@ extension MFAResolutionView: View { .scaleEffect(0.8) } Text("Complete Sign-In") - .authFont(.body) } + .padding(.vertical, 8) .frame(maxWidth: .infinity) - .padding() - .background(canCompleteResolution ? Color.blue : Color.gray) - .foregroundColor(.white) - .cornerRadius(8) } + .authCTAButtonStyle() .disabled(!canCompleteResolution) .accessibilityIdentifier("complete-resolution-button") @@ -161,12 +151,10 @@ extension MFAResolutionView: View { Button(action: cancelResolution) { Text("Cancel") .authFont(.body) + .padding(.vertical, 8) .frame(maxWidth: .infinity) - .padding() - .background(Color.gray.opacity(0.2)) - .foregroundColor(.primary) - .cornerRadius(8) } + .buttonStyle(.bordered) .accessibilityIdentifier("cancel-button") } .padding(.horizontal) @@ -217,14 +205,11 @@ extension MFAResolutionView: View { .scaleEffect(0.8) } Text("Send Code") - .authFont(.body) } + .padding(.vertical, 8) .frame(maxWidth: .infinity) - .padding() - .background(isLoading ? Color.gray : Color.blue) - .foregroundColor(.white) - .cornerRadius(8) } + .authCTAButtonStyle() .disabled(isLoading) .padding(.horizontal) .accessibilityIdentifier("send-sms-button") @@ -234,11 +219,12 @@ extension MFAResolutionView: View { Text("Verification Code") .authFont(.headline) - TextField("Enter 6-digit code", text: $verificationCode) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .keyboardType(.numberPad) - .focused($focus, equals: .verificationCode) - .accessibilityIdentifier("sms-verification-code-field") + VerificationCodeInputField( + code: $verificationCode, + validations: [FormValidators.verificationCode], + maintainsValidationMessage: true + ) + .accessibilityIdentifier("sms-verification-code-field") } .padding(.horizontal) } @@ -275,11 +261,12 @@ extension MFAResolutionView: View { Text("Verification Code") .authFont(.headline) - TextField("Enter 6-digit code", text: $totpCode) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .keyboardType(.numberPad) - .focused($focus, equals: .totpCode) - .accessibilityIdentifier("totp-verification-code-field") + VerificationCodeInputField( + code: $totpCode, + validations: [FormValidators.verificationCode], + maintainsValidationMessage: true + ) + .accessibilityIdentifier("totp-verification-code-field") } .padding(.horizontal) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift index cbf5b9c7f8..6e6da427f6 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift @@ -196,10 +196,11 @@ private struct DeleteAccountConfirmationSheet: View { onConfirm() } label: { Text("Delete Account") + .authFont(.body) .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .authCTAButtonStyle() + .buttonStyle(.borderedProminent) .tint(.red) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift index 1dbd1e7138..f8df18d14d 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift @@ -42,7 +42,6 @@ public struct AuthProviderButton: View { providerIcon(for: icon, tint: style.iconTint) } Text(label) - .authFont(.body) .lineLimit(1) .truncationMode(.tail) .foregroundStyle(style.contentColor) diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift index 8d7fb4d2be..bb714bc24d 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift @@ -331,9 +331,9 @@ private struct SingleDigitField: View { onFocusChanged(isFocused) }, maxCharacters: maxDigits, + font: typography.fontName.flatMap { UIFont(name: $0, size: 24) } + ?? .systemFont(ofSize: 24, weight: .medium), configuration: { textField in - textField.font = typography.fontFamily.flatMap { UIFont(name: $0, size: 24) } - ?? .systemFont(ofSize: 24, weight: .medium) textField.textAlignment = .center textField.keyboardType = .numberPad textField.textContentType = .oneTimeCode @@ -369,6 +369,7 @@ private struct BackspaceAwareTextField: UIViewRepresentable { let onDeleteBackwardWhenEmpty: () -> Void let onFocusChanged: (Bool) -> Void let maxCharacters: Int + let font: UIFont let configuration: (UITextField) -> Void let onTextChange: (String) -> Void @@ -382,6 +383,7 @@ private struct BackspaceAwareTextField: UIViewRepresentable { for: .editingChanged ) configuration(textField) + textField.font = font textField.onDeleteBackward = { [weak textField] in guard let textField else { return } if (textField.text ?? "").isEmpty { @@ -396,6 +398,9 @@ private struct BackspaceAwareTextField: UIViewRepresentable { if uiView.text != text { uiView.text = text } + if uiView.font != font { + uiView.font = font + } uiView.onDeleteBackward = { [weak uiView] in guard let uiView else { return } diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift index 02b245770a..adbf3dd709 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift @@ -14,26 +14,26 @@ import SwiftUI -/// Typography configuration for the auth flow. `fontFamily` defaults to `nil`, in which case +/// Typography configuration for the auth flow. `fontName` defaults to `nil`, in which case /// text keeps using the system font at each semantic text style, exactly as it does today. public struct AuthTypography: Sendable { /// The PostScript name of a custom font registered with the app (e.g. via Info.plist). - public var fontFamily: String? + public var fontName: String? - public init(fontFamily: String? = nil) { - self.fontFamily = fontFamily + public init(fontName: String? = nil) { + self.fontName = fontName } public static let `default` = AuthTypography() - /// Resolves a semantic text style against this typography's `fontFamily`, preserving Dynamic - /// Type scaling relative to `style`. Falls back to the system font when `fontFamily` is unset. + /// Resolves a semantic text style against this typography's `fontName`, preserving Dynamic + /// Type scaling relative to `style`. Falls back to the system font when `fontName` is unset. public func resolvedFont(for style: Font.TextStyle, weight: Font.Weight? = nil) -> Font { // Use the style's size at the default content size category; `relativeTo:` applies the // user's Dynamic Type scaling, so an already-scaled size would be scaled twice. - let base: Font = if let fontFamily { + let base: Font = if let fontName { .custom( - fontFamily, + fontName, size: UIFont.preferredFont( forTextStyle: style.uiKit, compatibleWith: UITraitCollection(preferredContentSizeCategory: .large) @@ -59,25 +59,25 @@ public extension EnvironmentValues { } public extension View { - /// Sets the custom font family used by every semantic text style (`.headline`, `.body`, + /// Sets the custom font used by every semantic text style (`.headline`, `.body`, /// `.caption`, etc.) throughout the auth flow, while preserving Dynamic Type scaling relative /// to each style. /// /// ```swift /// AuthPickerView { ... } - /// .authTypography(AuthTypography(fontFamily: "Poppins-Regular")) + /// .authTypography(AuthTypography(fontName: "Poppins-Regular")) /// ``` func authTypography(_ typography: AuthTypography) -> some View { environment(\.authTypography, typography) } } -public struct AuthFontModifier: ViewModifier { +struct AuthFontModifier: ViewModifier { @Environment(\.authTypography) private var typography let style: Font.TextStyle var weight: Font.Weight? - public func body(content: Content) -> some View { + func body(content: Content) -> some View { content.font(typography.resolvedFont(for: style, weight: weight)) } } diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift index 9024a19f83..4f117bceb5 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift @@ -93,10 +93,16 @@ final class MFAResolutionUITests: XCTestCase { return } - let codeField = app.textFields["sms-verification-code-field"] - XCTAssertTrue(codeField.waitForExistence(timeout: 10), "Code field should exist") - codeField.tap() - codeField.typeText(verificationCode) + let firstDigitField = app.otherElements["Digit 1 of 6"].textFields.firstMatch + XCTAssertTrue(firstDigitField.waitForExistence(timeout: 10), "Code field should exist") + // Paste each digit into its own box, matching MFAEnrolmentUITests. + for (index, digit) in verificationCode.enumerated() { + let field = app.otherElements["Digit \(index + 1) of 6"].textFields.firstMatch + UIPasteboard.general.string = String(digit) + field.tap() + field.press(forDuration: 1.2) + app.menuItems["Paste"].tap() + } let completeButton = app.buttons["complete-resolution-button"] XCTAssertTrue(completeButton.exists, "Complete button should exist") From 9cea9863ffa37f0ea7d826e3348c3401fa3e9857 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:11:07 +0100 Subject: [PATCH 08/24] fix(auth): apply AuthTextFieldStyle to the verification code input --- .../VerificationCodeInputField.swift | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift index bb714bc24d..b9971494a0 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift @@ -35,6 +35,7 @@ public struct VerificationCodeInputField: View { _digitFields = State(initialValue: Array(repeating: "", count: codeLength)) } + @Environment(\.authTextFieldStyle) private var style @Binding var code: String let codeLength: Int let isError: Bool @@ -92,7 +93,7 @@ public struct VerificationCodeInputField: View { if isError, let errorMessage = errorMessage { Text(errorMessage) .authFont(.caption) - .foregroundColor(.red) + .foregroundStyle(style.errorColor ?? .red) .frame(maxWidth: .infinity, alignment: .leading) } @@ -103,8 +104,8 @@ public struct VerificationCodeInputField: View { let isValid = validator.isValid(input: code) Text(validator.message) .authFont(.caption) - .strikethrough(isValid, color: .gray) - .foregroundStyle(isValid ? .gray : .red) + .strikethrough(isValid, color: style.secondaryColor ?? .gray) + .foregroundStyle(isValid ? (style.secondaryColor ?? .gray) : (style.errorColor ?? .red)) .fixedSize(horizontal: false, vertical: true) } } @@ -294,6 +295,7 @@ public struct VerificationCodeInputField: View { private struct SingleDigitField: View { @Environment(\.authTypography) private var typography + @Environment(\.authTextFieldStyle) private var style @Binding var digit: String let isError: Bool let isFocused: Bool @@ -311,9 +313,9 @@ private struct SingleDigitField: View { } private var borderColor: Color { - if isError { return .red } - if isFocused || !digit.isEmpty { return .accentColor } - return Color(.systemFill) + if isError { return style.errorColor ?? .red } + if isFocused || !digit.isEmpty { return style.tint ?? .accentColor } + return style.containerColor ?? Color(.systemFill) } var body: some View { @@ -346,10 +348,10 @@ private struct SingleDigitField: View { ) .frame(width: 48, height: 48) .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color.accentColor.opacity(0.05)) + RoundedRectangle(cornerRadius: style.cornerRadius ?? 8) + .fill((style.tint ?? Color.accentColor).opacity(0.05)) .overlay( - RoundedRectangle(cornerRadius: 8) + RoundedRectangle(cornerRadius: style.cornerRadius ?? 8) .stroke(borderColor, lineWidth: borderWidth) ) ) From 523dbdc898f0991c52fe92954530f227bea91b66 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:41:19 +0100 Subject: [PATCH 09/24] fix(auth): leave inherited font, shape and tint alone when no theme is set --- .../Sources/Views/AuthCTAButtonModifier.swift | 40 +++++++++++++++---- .../Sources/Views/AuthPickerView.swift | 2 +- .../Sources/Views/EmailAuthView.swift | 6 +-- .../Sources/Views/EmailLinkReauthView.swift | 4 +- .../Views/LegacySignInRecoveryView.swift | 2 +- .../Sources/Views/MFAEnrolmentView.swift | 2 +- .../Sources/Views/MFAResolutionView.swift | 2 +- .../Sources/Views/PasswordRecoveryView.swift | 4 +- .../Sources/Views/PrivacyTOCsView.swift | 2 +- .../Sources/Views/SignedInView.swift | 6 +-- .../Sources/Components/AuthTextField.swift | 16 +++++--- .../Sources/Components/CountrySelector.swift | 2 +- .../Sources/Theme/AuthTypography.swift | 23 +++++++++++ 13 files changed, 83 insertions(+), 28 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift index 568958e8e5..ad602136ce 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift @@ -51,10 +51,9 @@ public extension EnvironmentValues { } /// 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. +/// ``AuthCTAButtonStyle``, in place of a bare `.buttonStyle(.borderedProminent)` call. Unset +/// values leave the inherited shape, tint and font alone. When `style.font` is unset, the label +/// uses ``AuthTypography``'s custom font if one is set. struct AuthCTAButtonModifier: ViewModifier { @Environment(\.authCTAButtonStyle) private var style @@ -67,12 +66,39 @@ struct AuthCTAButtonModifier: ViewModifier { } } .buttonStyle(.borderedProminent) - .buttonBorderShape(style.shape ?? .automatic) - .tint(style.backgroundColor) + .modifier(CTAShapeModifier(shape: style.shape)) + .modifier(CTATintModifier(tint: style.backgroundColor)) .modifier(CTAFontModifier(explicitFont: style.font)) } } +// Each modifier below only applies a value when one is set, so an unset style leaves whatever +// the app set higher up (shape, tint, font) in effect, as before theming. + +private struct CTAShapeModifier: ViewModifier { + let shape: ButtonBorderShape? + + func body(content: Content) -> some View { + if let shape { + content.buttonBorderShape(shape) + } else { + content + } + } +} + +private struct CTATintModifier: ViewModifier { + let tint: Color? + + func body(content: Content) -> some View { + if let tint { + content.tint(tint) + } else { + content + } + } +} + private struct CTAFontModifier: ViewModifier { let explicitFont: Font? @@ -80,7 +106,7 @@ private struct CTAFontModifier: ViewModifier { if let explicitFont { content.font(explicitFont) } else { - content.authFont(.body) + content.authCustomFont(.body) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 220ee80adc..28fbe389d8 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -128,7 +128,7 @@ extension AuthPickerView: View { .scaleEffect(1.25) .tint(.white) Text("Authenticating...") - .authFont(.body) + .authCustomFont(.body) .foregroundStyle(.white) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index 5f59199573..7d06240f4a 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -150,7 +150,7 @@ extension EmailAuthView: View { authService.navigator.push(.passwordRecovery) } label: { Text(authService.string.passwordButtonLabel) - .authFont(.body, weight: .medium) + .authCustomFont(.body, weight: .medium) .frame(maxWidth: .infinity, alignment: .trailing) } .accessibilityIdentifier("password-recovery-button") @@ -223,14 +223,14 @@ extension EmailAuthView: View { ? authService.string.dontHaveAnAccountYetLabel : authService.string.alreadyHaveAnAccountLabel ) - .authFont(.body) + .authCustomFont(.body) .foregroundStyle(Color(.label)) Text( authService.authenticationFlow == .signUp ? authService.string.emailLoginFlowLabel : authService.string.emailSignUpFlowLabel ) - .authFont(.body, weight: .medium) + .authCustomFont(.body, weight: .medium) } } .accessibilityIdentifier("switch-auth-flow") diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift index 04f5ab8c54..fc896270f9 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift @@ -111,7 +111,7 @@ extension EmailLinkReauthView: View { .frame(height: 32) } else { Text("Resend Email") - .authFont(.body) + .authCustomFont(.body) .frame(height: 32) } } @@ -125,7 +125,7 @@ extension EmailLinkReauthView: View { ProgressView() .padding(.top, 32) Text("Sending verification email...") - .authFont(.body) + .authCustomFont(.body) .foregroundStyle(.secondary) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/LegacySignInRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/LegacySignInRecoveryView.swift index d49a600037..5ece47a703 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/LegacySignInRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/LegacySignInRecoveryView.swift @@ -29,7 +29,7 @@ struct LegacySignInRecoveryView: View { Text(authService.string.legacySignInRecoveryTitle) .authFont(.title2, weight: .semibold) Text(authService.string.legacySignInRecoveryMessage(email: recovery.email)) - .authFont(.body) + .authCustomFont(.body) .foregroundStyle(.secondary) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift index c5928223f8..7405ab1db1 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift @@ -490,7 +490,7 @@ extension MFAEnrolmentView: View { sendSMSVerification() } label: { Text("Resend Code") - .authFont(.body) + .authCustomFont(.body) .padding(.vertical, 8) .frame(maxWidth: .infinity) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift index 722b4ee825..e3dd9a28c2 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift @@ -150,7 +150,7 @@ extension MFAResolutionView: View { // Cancel Button Button(action: cancelResolution) { Text("Cancel") - .authFont(.body) + .authCustomFont(.body) .padding(.vertical, 8) .frame(maxWidth: .infinity) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift index 7e28ea2dee..73a60758e6 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift @@ -82,13 +82,13 @@ extension PasswordRecoveryView: View { .fontWeight(.bold) .padding() Text(authService.string.passwordRecoveryHelperMessage) - .authFont(.body) + .authCustomFont(.body) .padding() Divider() Text(String(format: authService.string.passwordRecoveryEmailSentMessage, sentEmail)) - .authFont(.body) + .authCustomFont(.body) .padding() Divider() diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift index e4dc34c74f..629532a823 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift @@ -67,7 +67,7 @@ extension PrivacyTOCsView: View { if let tosURL = authService.configuration.tosUrl, let privacyURL = authService.configuration.privacyPolicyUrl { Text(attributedMessage(tosURL: tosURL, privacyURL: privacyURL)) - .authFont(.body) + .authCustomFont(.body) .multilineTextAlignment(displayMode == .full ? .center : .trailing) .padding() } else { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift index 6e6da427f6..45ebffb82e 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift @@ -48,7 +48,7 @@ extension SignedInView: View { Text( "\(authService.currentUser?.email ?? authService.currentUser?.displayName ?? authService.currentUser?.phoneNumber ?? "")" ) - .authFont(.body) + .authCustomFont(.body) if authService.currentUser?.isEmailVerified == false { Button { Task { @@ -196,7 +196,7 @@ private struct DeleteAccountConfirmationSheet: View { onConfirm() } label: { Text("Delete Account") - .authFont(.body) + .authCustomFont(.body) .padding(.vertical, 8) .frame(maxWidth: .infinity) } @@ -210,7 +210,7 @@ private struct DeleteAccountConfirmationSheet: View { onCancel() } label: { Text("Cancel") - .authFont(.body) + .authCustomFont(.body) .padding(.vertical, 8) .frame(maxWidth: .infinity) } diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift index 0bc5627269..61cdb6b57a 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift @@ -63,6 +63,12 @@ public struct AuthTextField: View { self.leading = leading } + /// The placeholder uses the custom font only when one is set, so it otherwise inherits the + /// field's font as before theming. + private var promptText: Text { + typography.fontName == nil ? Text(prompt) : Text(prompt).font(typography.resolvedFont(for: .body)) + } + var allRequirementsMet: Bool { validations.allSatisfy { $0.isValid(input: text) } } @@ -70,17 +76,17 @@ public struct AuthTextField: View { public var body: some View { VStack(alignment: .leading) { Text(LocalizedStringResource(stringLiteral: label)) - .authFont(.body) + .authCustomFont(.body) HStack(spacing: 8) { leading() Group { if isSecureTextField { ZStack(alignment: .trailing) { - SecureField(label, text: $text, prompt: Text(prompt).font(typography.resolvedFont(for: .body))) + SecureField(label, text: $text, prompt: promptText) .opacity(obscured ? 1 : 0) .focused($isFocused) .frame(height: 24) - TextField(label, text: $text, prompt: Text(prompt).font(typography.resolvedFont(for: .body))) + TextField(label, text: $text, prompt: promptText) .opacity(obscured ? 0 : 1) .focused($isFocused) .frame(height: 24) @@ -103,12 +109,12 @@ public struct AuthTextField: View { TextField( label, text: $text, - prompt: Text(prompt).font(typography.resolvedFont(for: .body)) + prompt: promptText ) .frame(height: 24) } } - .authFont(.body) + .authCustomFont(.body) } .frame(maxWidth: .infinity) .keyboardType(keyboardType) diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift index c908927d64..049588b6c9 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift @@ -87,7 +87,7 @@ public struct CountrySelector: View { selectedCountry = country } label: { Text("\(country.flag) \(country.name) (\(country.dialCode))") - .authFont(.body) + .authCustomFont(.body) } .accessibilityIdentifier("country-option-\(country.code)") } diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift index adbf3dd709..629f6147b8 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift @@ -90,6 +90,29 @@ public extension View { } } +extension View { + /// Like `.authFont(_:weight:)`, but only when ``AuthTypography/fontName`` is set. Otherwise + /// the inherited font is left alone (only `weight` is applied), for text that had no explicit + /// font before theming, so an app-level `.font(...)` still reaches it. + package func authCustomFont(_ style: Font.TextStyle, weight: Font.Weight? = nil) -> some View { + modifier(AuthCustomFontModifier(style: style, weight: weight)) + } +} + +struct AuthCustomFontModifier: ViewModifier { + @Environment(\.authTypography) private var typography + let style: Font.TextStyle + var weight: Font.Weight? + + func body(content: Content) -> some View { + if typography.fontName != nil { + content.font(typography.resolvedFont(for: style, weight: weight)) + } else { + content.fontWeight(weight) + } + } +} + private extension Font.TextStyle { var uiKit: UIFont.TextStyle { switch self { From df9329957e1092724b7b6d0e990f33a9ad05d232 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:50:30 +0100 Subject: [PATCH 10/24] feat(auth): add pickerContent and pickerDestination slots to AuthPickerView --- .../Sources/Views/AuthPickerContent.swift | 126 ++++++++++++ .../Sources/Views/AuthPickerView.swift | 184 +++++------------- .../Views/DefaultAuthPickerContent.swift | 92 +++++++++ .../Views/DefaultAuthPickerDestination.swift | 63 ++++++ 4 files changed, 331 insertions(+), 134 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift new file mode 100644 index 0000000000..31635788a4 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift @@ -0,0 +1,126 @@ +// 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 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) { + pickerContent() + .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) + } + } + + /// Replaces the first screen of the authentication sheet. + public func pickerContent( + @ViewBuilder _ pickerContent: @escaping () -> NewPickerContent + ) -> AuthPickerContent { + AuthPickerContent( + content: content, + pickerContent: pickerContent, + destination: destination + ) + } + + /// Replaces the screens pushed inside the authentication sheet. + 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 28fbe389d8..16a998916a 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -17,152 +17,68 @@ 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...") - .authCustomFont(.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 first screen of the authentication sheet. + /// + /// ```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. Return + /// ``DefaultAuthPickerDestination`` for routes you don't customize. + /// + /// ```swift + /// AuthPickerView { authenticatedApp } + /// .pickerDestination { screen in + /// DefaultAuthPickerDestination(screen: screen) + /// .background(theme.colors.background) + /// } + /// ``` + 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..30ea1fc3c9 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -0,0 +1,92 @@ +// 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 first screen of the authentication sheet: the logo, email sign-in, the provider +/// buttons and the terms, or ``SignedInView`` once the user is signed in. +/// +/// 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 { + VStack { + if authService.authenticationState == .authenticated { + SignedInView() + } else { + signInContent + .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 signInContent: 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) + } + } + } + } +} + +/// 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..9cfc4ac97c --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift @@ -0,0 +1,63 @@ +// 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: +/// +/// ```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 + ) + } + } +} From 1f9a66e98228382427c8c135d92bd7f72213b6d4 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:18:17 +0100 Subject: [PATCH 11/24] fix(auth): keep signed-in and progress UI outside the pickerContent slot --- .../Sources/Views/AuthPickerContent.swift | 36 +++++++++++++++++-- .../Sources/Views/AuthPickerView.swift | 18 +++++++--- .../Views/DefaultAuthPickerContent.swift | 31 ++-------------- .../Views/DefaultAuthPickerDestination.swift | 3 +- 4 files changed, 51 insertions(+), 37 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift index 31635788a4..ae929011cb 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift @@ -13,6 +13,7 @@ // limitations under the License. import FirebaseAuth +import FirebaseAuthUIComponents import SwiftUI /// An ``AuthPickerView`` with custom sheet content, returned by @@ -49,7 +50,7 @@ public struct AuthPickerContent< .sheet(isPresented: $authService.isPresented) { @Bindable var navigator = authService.navigator NavigationStack(path: $navigator.routes) { - pickerContent() + root .navigationTitle(authService.authenticationState == .unauthenticated ? authService .string.authPickerTitle : "") .navigationBarTitleDisplayMode(.large) @@ -78,7 +79,35 @@ public struct AuthPickerContent< } } - /// Replaces the first screen of the authentication sheet. + /// 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 { @@ -89,7 +118,8 @@ public struct AuthPickerContent< ) } - /// Replaces the screens pushed inside the authentication sheet. + /// Replaces the screens pushed inside the authentication sheet. See + /// ``AuthPickerView/pickerDestination(_:)``. public func pickerDestination( @ViewBuilder _ destination: @escaping (AuthView) -> NewDestinationContent ) -> AuthPickerContent { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 16a998916a..e836347c5b 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -38,7 +38,8 @@ public struct AuthPickerView: View { ) } - /// Replaces the first screen of the authentication sheet. + /// 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 } @@ -57,14 +58,21 @@ public struct AuthPickerView: View { ) } - /// Replaces the screens pushed inside the authentication sheet. Return - /// ``DefaultAuthPickerDestination`` for routes you don't customize. + /// 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 - /// DefaultAuthPickerDestination(screen: screen) - /// .background(theme.colors.background) + /// switch screen { + /// case .enterPhoneNumber: + /// MyPhoneEntry() + /// default: + /// DefaultAuthPickerDestination(screen: screen) + /// } /// } /// ``` public func pickerDestination( diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift index 30ea1fc3c9..c6e95be541 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -15,8 +15,8 @@ import FirebaseAuthUIComponents import SwiftUI -/// The default first screen of the authentication sheet: the logo, email sign-in, the provider -/// buttons and the terms, or ``SignedInView`` once the user is signed in. +/// 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. @@ -30,32 +30,6 @@ public struct DefaultAuthPickerContent: View { private let authMethodPicker: () -> AuthMethodPicker public var body: some View { - VStack { - if authService.authenticationState == .authenticated { - SignedInView() - } else { - signInContent - .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 signInContent: some View { GeometryReader { proxy in ScrollView { VStack(spacing: 24) { @@ -73,6 +47,7 @@ public struct DefaultAuthPickerContent: View { } } } + .safeAreaPadding() } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift index 9cfc4ac97c..2d5762c3ca 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift @@ -16,7 +16,8 @@ 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: +/// 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 } From a6caa62c0873cb20d899451b61c91d8057e244a3 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:06:29 +0100 Subject: [PATCH 12/24] docs(auth): add usage examples to AuthPickerView and authFont --- .../Sources/Views/AuthPickerView.swift | 8 ++++++++ .../Sources/Theme/AuthTypography.swift | 10 ++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index e836347c5b..17b142d3cd 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -22,6 +22,14 @@ import SwiftUI /// /// 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. +/// +/// Example: +/// ```swift +/// AuthPickerView { +/// HomeView() +/// } +/// .environment(authService) +/// ``` @MainActor public struct AuthPickerView: View { public init(@ViewBuilder content: @escaping () -> Content = { EmptyView() }) { diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift index 629f6147b8..113ae587e7 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift @@ -83,8 +83,14 @@ struct AuthFontModifier: ViewModifier { } public extension View { - /// Applies a semantic text style, resolved against the environment's ``AuthTypography`` — - /// use in place of a bare `.font(.headline)`/`.font(.caption)`/etc. call. + /// Applies a semantic text style resolved against the environment's ``AuthTypography``, so + /// your own views inside the auth flow use the same font as the stock screens. + /// + /// Example: + /// ```swift + /// Text("Welcome back") + /// .authFont(.title2, weight: .semibold) + /// ``` func authFont(_ style: Font.TextStyle, weight: Font.Weight? = nil) -> some View { modifier(AuthFontModifier(style: style, weight: weight)) } From f0fa077fd615979007128707b748a497ad6a1091 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:43:54 +0100 Subject: [PATCH 13/24] fix(auth): let the authenticating overlay inherit the app font when no theme is set --- .../FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift index ae929011cb..526861fb52 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift @@ -97,7 +97,7 @@ public struct AuthPickerContent< .scaleEffect(1.25) .tint(.white) Text("Authenticating...") - .authFont(.body) + .authCustomFont(.body) .foregroundStyle(.white) } .frame(maxWidth: .infinity, maxHeight: .infinity) From df2c4fc5f7b4dfbe94ed469149ba49df7df6c241 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 14/24] 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 2145b6733e..2bd86e1605 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 c6e95be541..372ee3ade2 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 5d835b959d..a816970098 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 0000000000..00ae7d88c4 --- /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 c72d5993131040c6eeb1009a57305388c58d17d9 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 15/24] 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 372ee3ade2..7cf211d99b 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 74397c00dd8cbacde70c4310f24aeee0cdd48d04 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 16/24] 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 526861fb52..adb2d70b8c 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 17b142d3cd..36a01e7f22 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 7cf211d99b..84ce375888 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 6816cd1c507b2f9b1e2bc20c5fced9bad6149c7b 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 17/24] 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 adb2d70b8c..32852e8c47 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 36a01e7f22..db685ca1fc 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 84ce375888..cb444ca858 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 9ed03af898ac082040d48c89ad3d8b7b785c6dba 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 18/24] 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 0000000000..c88876b984 --- /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 02bb2b077c0023e998efc377fae8b843e5362090 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:26:09 +0100 Subject: [PATCH 19/24] feat(auth): add content slots to phone number and verification code screens Add EnterPhoneNumberScreen and EnterVerificationCodeScreen, generic over content that receives a PhoneNumberContentState or VerificationCodeContentState. The screens own the input state and the send, verify, resend and change-number actions; the content only draws. DefaultEnterPhoneNumberContent and DefaultEnterVerificationCodeContent hold the stock markup, and the existing views render them through the new screens. The verification code screen adds a resend button with a 60 second countdown. Verifying now goes through signIn(credentials:), so an MFA challenge reaches the resolution screen instead of being dropped. isLoading also covers sending the code, which previously showed no progress and allowed repeated taps. --- .../DefaultEnterPhoneNumberContent.swift | 74 ++++++++ .../DefaultEnterVerificationCodeContent.swift | 91 ++++++++++ .../Views/EnterPhoneNumberScreen.swift | 108 ++++++++++++ .../Sources/Views/EnterPhoneNumberView.swift | 69 +------- .../Views/EnterVerificationCodeScreen.swift | 163 ++++++++++++++++++ .../Views/EnterVerificationCodeView.swift | 79 +-------- 6 files changed, 441 insertions(+), 143 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterPhoneNumberContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterPhoneNumberContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterPhoneNumberContent.swift new file mode 100644 index 0000000000..1b5745afa8 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterPhoneNumberContent.swift @@ -0,0 +1,74 @@ +// 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 stock content of ``EnterPhoneNumberScreen``. +@MainActor +public struct DefaultEnterPhoneNumberContent: View { + public init(state: PhoneNumberContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: PhoneNumberContentState + + public var body: some View { + VStack(spacing: 16) { + Text(authService.string.enterPhoneNumberPlaceholder) + .authFont(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top) + + AuthTextField( + text: state.phoneNumber, + label: authService.string.phoneFieldLabel, + prompt: authService.string.enterPhoneNumberPlaceholder, + keyboardType: .phonePad, + contentType: .telephoneNumber, + validations: [ + FormValidators.phoneNumber, + ], + onChange: { _ in } + ) { + CountrySelector( + selectedCountry: state.selectedCountry, + enabled: !state.isLoading + ) + } + + Button(action: state.sendCode) { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.sendCodeButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .authCTAButtonStyle() + .disabled(state.isLoading || !state.isValid) + .padding(.top, 8) + + Spacer() + } + .navigationTitle(authService.string.phoneSignInTitle) + .padding(.horizontal) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift new file mode 100644 index 0000000000..4802e87953 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift @@ -0,0 +1,91 @@ +// 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 stock content of ``EnterVerificationCodeScreen``. +@MainActor +public struct DefaultEnterVerificationCodeContent: View { + public init(state: VerificationCodeContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: VerificationCodeContentState + + public var body: some View { + VStack(spacing: 32) { + VStack(spacing: 16) { + VStack(spacing: 8) { + Text(authService.string.sentCodeMessage(phoneNumber: state.fullPhoneNumber)) + .authFont(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + + Button(action: state.changeNumber) { + Text(authService.string.changeNumberButtonLabel) + .authFont(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.bottom) + .frame(maxWidth: .infinity, alignment: .leading) + + VerificationCodeInputField( + code: state.verificationCode, + validations: [ + FormValidators.verificationCode, + ], + maintainsValidationMessage: true + ) + + Button(action: state.verify) { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.verifyAndSignInButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .authCTAButtonStyle() + .disabled(state.isLoading || !state.isValid) + + Button(action: state.resendCode) { + Text(resendLabel) + .authFont(.body) + .frame(maxWidth: .infinity) + } + .disabled(state.isLoading || state.resendCountdown > 0) + .accessibilityIdentifier("resend-code-button") + } + + Spacer() + } + .navigationTitle(authService.string.enterVerificationCodeTitle) + .navigationBarTitleDisplayMode(.large) + .padding(.horizontal) + } + + private var resendLabel: String { + guard state.resendCountdown > 0 else { return authService.string.resendCodeLabel } + let remaining = Duration.seconds(state.resendCountdown) + .formatted(.time(pattern: .minuteSecond)) + return "\(authService.string.resendCodeLabel) (\(remaining))" + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift new file mode 100644 index 0000000000..5ffc5af9d8 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift @@ -0,0 +1,108 @@ +// 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 state and actions of the phone number screen, passed to ``EnterPhoneNumberScreen``'s +/// content. +public struct PhoneNumberContentState { + /// The national phone number, without the dial code. + public let phoneNumber: Binding + public let selectedCountry: Binding + /// Sends a verification code to the full phone number, then pushes + /// ``AuthView/enterVerificationCode(verificationID:fullPhoneNumber:)``. + public let sendCode: () -> Void + public let isValid: Bool + public let isLoading: Bool + /// The localized message of the last failed attempt, if any. + public let errorMessage: String? +} + +/// The phone number screen, with its content drawn by you. +/// +/// The screen sends the code, navigates, and reports errors. Your content only draws the UI from +/// the ``PhoneNumberContentState`` it receives. Wrap ``DefaultEnterPhoneNumberContent`` to keep +/// the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case .enterPhoneNumber: +/// EnterPhoneNumberScreen { state in +/// TextField("Phone number", text: state.phoneNumber) +/// Button("Send code", action: state.sendCode) +/// .disabled(!state.isValid || state.isLoading) +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct EnterPhoneNumberScreen: View { + public init(@ViewBuilder content: @escaping (PhoneNumberContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var phoneNumber: String = "" + @State private var selectedCountry: CountryData = .default + @State private var isSending = false + @State private var errorMessage: String? + + private let content: (PhoneNumberContentState) -> Content + + public var body: some View { + content(PhoneNumberContentState( + phoneNumber: $phoneNumber, + selectedCountry: $selectedCountry, + sendCode: sendCode, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + } + + private var isValid: Bool { + !phoneNumber.isEmpty + } + + private var isLoading: Bool { + isSending || authService.authenticationState == .authenticating + } + + private func sendCode() { + guard isValid, !isLoading else { return } + Task { + isSending = true + errorMessage = nil + defer { isSending = false } + do { + let fullPhoneNumber = selectedCountry.dialCode + phoneNumber + let id = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) + authService.navigator.push(.enterVerificationCode( + verificationID: id, + fullPhoneNumber: fullPhoneNumber + )) + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift index 54e41c1fa8..bfb7664412 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift @@ -12,79 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI struct EnterPhoneNumberView: View { - @Environment(AuthService.self) private var authService - @Environment(\.reportError) private var reportError - @State private var phoneNumber: String = "" - @State private var selectedCountry: CountryData = .default - var body: some View { - VStack(spacing: 16) { - Text(authService.string.enterPhoneNumberPlaceholder) - .authFont(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top) - - AuthTextField( - text: $phoneNumber, - label: authService.string.phoneFieldLabel, - prompt: authService.string.enterPhoneNumberPlaceholder, - keyboardType: .phonePad, - contentType: .telephoneNumber, - validations: [ - FormValidators.phoneNumber, - ], - onChange: { _ in } - ) { - CountrySelector( - selectedCountry: $selectedCountry, - enabled: !(authService.authenticationState == .authenticating) - ) - } - - Button(action: { - Task { - do { - let fullPhoneNumber = selectedCountry.dialCode + phoneNumber - let id = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) - authService.navigator.push(.enterVerificationCode( - verificationID: id, - fullPhoneNumber: fullPhoneNumber - )) - } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error - } - } - } - }) { - if authService.authenticationState == .authenticating { - ProgressView() - .frame(height: 32) - .frame(maxWidth: .infinity) - } else { - Text(authService.string.sendCodeButtonLabel) - .frame(height: 32) - .frame(maxWidth: .infinity) - } - } - .authCTAButtonStyle() - .disabled(authService.authenticationState == .authenticating || phoneNumber.isEmpty) - .padding(.top, 8) - - Spacer() - } - .navigationTitle(authService.string.phoneSignInTitle) - .padding(.horizontal) + EnterPhoneNumberScreen { DefaultEnterPhoneNumberContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift new file mode 100644 index 0000000000..0054c44b0d --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift @@ -0,0 +1,163 @@ +// 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 SwiftUI + +/// Seconds between sending a code and allowing a resend. +private let resendDelay: TimeInterval = 60 + +/// The state and actions of the verification code screen, passed to +/// ``EnterVerificationCodeScreen``'s content. +public struct VerificationCodeContentState { + public let verificationCode: Binding + /// Signs in with the code, routing MFA and account conflicts to their handlers. + public let verify: () -> Void + /// Sends a new code. Does nothing while ``resendCountdown`` is above zero. + public let resendCode: () -> Void + /// Pops back to the phone number screen. + public let changeNumber: () -> Void + /// The number the code was sent to, including the dial code. + public let fullPhoneNumber: String + /// Seconds left before ``resendCode`` is available; `0` when it is. + public let resendCountdown: Int + public let isValid: Bool + public let isLoading: Bool + /// The localized message of the last failed attempt, if any. + public let errorMessage: String? +} + +/// The verification code screen, with its content drawn by you. +/// +/// The screen signs in, resends codes, runs the resend countdown, and routes MFA, account +/// conflicts and errors. Your content only draws the UI from the +/// ``VerificationCodeContentState`` it receives. Wrap ``DefaultEnterVerificationCodeContent`` to +/// keep the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case let .enterVerificationCode(verificationID, fullPhoneNumber): +/// EnterVerificationCodeScreen( +/// verificationID: verificationID, +/// fullPhoneNumber: fullPhoneNumber +/// ) { state in +/// TextField("Code", text: state.verificationCode) +/// Button("Verify", action: state.verify) +/// .disabled(!state.isValid || state.isLoading) +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct EnterVerificationCodeScreen: View { + public init(verificationID: String, + fullPhoneNumber: String, + @ViewBuilder content: @escaping (VerificationCodeContentState) -> Content) { + _verificationID = State(initialValue: verificationID) + self.fullPhoneNumber = fullPhoneNumber + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.mfaHandler) private var mfaHandler + @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.reportError) private var reportError + @State private var verificationID: String + @State private var verificationCode: String = "" + @State private var resendAvailableAt = Date().addingTimeInterval(resendDelay) + @State private var resendCountdown = Int(resendDelay) + @State private var isWorking = false + @State private var errorMessage: String? + + private let fullPhoneNumber: String + private let content: (VerificationCodeContentState) -> Content + + public var body: some View { + content(VerificationCodeContentState( + verificationCode: $verificationCode, + verify: verify, + resendCode: resendCode, + changeNumber: { authService.navigator.pop() }, + fullPhoneNumber: fullPhoneNumber, + resendCountdown: resendCountdown, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .task(id: resendAvailableAt) { + repeat { + resendCountdown = max(0, Int(resendAvailableAt.timeIntervalSinceNow.rounded(.up))) + try? await Task.sleep(for: .seconds(1)) + } while resendCountdown > 0 && !Task.isCancelled + } + } + + private var isValid: Bool { + verificationCode.count == 6 + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func verify() { + guard isValid, !isLoading else { return } + Task { + isWorking = true + errorMessage = nil + defer { isWorking = false } + do { + let credential = PhoneAuthProvider.provider() + .credential(withVerificationID: verificationID, verificationCode: verificationCode) + let outcome = try await authService.signIn(credentials: credential) + if case let .mfaRequired(mfaInfo) = outcome, let onMFA = mfaHandler { + onMFA(mfaInfo) + return + } + authService.navigator.clear() + } catch { + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return + } + + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } + + private func resendCode() { + guard resendCountdown == 0, !isLoading else { return } + Task { + isWorking = true + errorMessage = nil + defer { isWorking = false } + do { + verificationID = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) + verificationCode = "" + resendAvailableAt = Date().addingTimeInterval(resendDelay) + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift index 3a644e713c..4924946f3c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift @@ -12,90 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor struct EnterVerificationCodeView: View { - @Environment(AuthService.self) private var authService - @Environment(\.accountConflictHandler) private var accountConflictHandler - @Environment(\.reportError) private var reportError - @State private var verificationCode: String = "" - let verificationID: String let fullPhoneNumber: String var body: some View { - @Bindable var authService = authService - VStack(spacing: 32) { - VStack(spacing: 16) { - VStack(spacing: 8) { - Text(authService.string.sentCodeMessage(phoneNumber: fullPhoneNumber)) - .authFont(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .leading) - - Button { - authService.navigator.pop() - } label: { - Text(authService.string.changeNumberButtonLabel) - .authFont(.caption) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .padding(.bottom) - .frame(maxWidth: .infinity, alignment: .leading) - - VerificationCodeInputField( - code: $verificationCode, - validations: [ - FormValidators.verificationCode, - ], - maintainsValidationMessage: true - ) - - Button(action: { - Task { - do { - try await authService.signInWithPhoneNumber( - verificationID: verificationID, - verificationCode: verificationCode - ) - authService.navigator.clear() - } catch { - if case let AuthServiceError.accountConflict(ctx) = error, - let onConflict = accountConflictHandler { - onConflict(ctx) - return - } - - reportError?(error) - throw error - } - } - }) { - if authService.authenticationState == .authenticating { - ProgressView() - .frame(height: 32) - .frame(maxWidth: .infinity) - } else { - Text(authService.string.verifyAndSignInButtonLabel) - .frame(height: 32) - .frame(maxWidth: .infinity) - } - } - .authCTAButtonStyle() - .disabled(authService.authenticationState == .authenticating || verificationCode.count != 6) - } - - Spacer() - } - .navigationTitle(authService.string.enterVerificationCodeTitle) - .navigationBarTitleDisplayMode(.large) - .padding(.horizontal) + EnterVerificationCodeScreen( + verificationID: verificationID, + fullPhoneNumber: fullPhoneNumber + ) { DefaultEnterVerificationCodeContent(state: $0) } } } From 8070ba59fc5706a2d41f5d053bd323607f56bfff Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:16:35 +0100 Subject: [PATCH 20/24] fix(auth): tighten phone screen actions and match the picker state API Set the in-flight flag before starting each action's Task, so a quick second tap can't send two codes or overlap verify and resend. Only clear the navigator on .signedIn, so an MFA challenge with no handler doesn't drop the user back to the picker. Type the state closures as @MainActor and give both state structs public initializers, matching AuthMethodPickerState. Clear errorMessage when the input changes, document every field, disable Change number while loading, and show the resend countdown as its own text instead of a concatenated string. --- .../DefaultEnterVerificationCodeContent.swift | 20 ++--- .../Views/EnterPhoneNumberScreen.swift | 43 +++++++++-- .../Views/EnterVerificationCodeScreen.swift | 77 ++++++++++++++----- 3 files changed, 102 insertions(+), 38 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift index 4802e87953..dd145f4e49 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift @@ -40,6 +40,7 @@ public struct DefaultEnterVerificationCodeContent: View { .authFont(.caption) .frame(maxWidth: .infinity, alignment: .leading) } + .disabled(state.isLoading) } .padding(.bottom) .frame(maxWidth: .infinity, alignment: .leading) @@ -67,9 +68,15 @@ public struct DefaultEnterVerificationCodeContent: View { .disabled(state.isLoading || !state.isValid) Button(action: state.resendCode) { - Text(resendLabel) - .authFont(.body) - .frame(maxWidth: .infinity) + HStack(spacing: 4) { + Text(authService.string.resendCodeLabel) + if state.resendCountdown > 0 { + Text(Duration.seconds(state.resendCountdown), format: .time(pattern: .minuteSecond)) + .monospacedDigit() + } + } + .authFont(.body) + .frame(maxWidth: .infinity) } .disabled(state.isLoading || state.resendCountdown > 0) .accessibilityIdentifier("resend-code-button") @@ -81,11 +88,4 @@ public struct DefaultEnterVerificationCodeContent: View { .navigationBarTitleDisplayMode(.large) .padding(.horizontal) } - - private var resendLabel: String { - guard state.resendCountdown > 0 else { return authService.string.resendCodeLabel } - let remaining = Duration.seconds(state.resendCountdown) - .formatted(.time(pattern: .minuteSecond)) - return "\(authService.string.resendCodeLabel) (\(remaining))" - } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift index 5ffc5af9d8..2b8535d3a5 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift @@ -20,14 +20,39 @@ import SwiftUI public struct PhoneNumberContentState { /// The national phone number, without the dial code. public let phoneNumber: Binding + + /// The country whose dial code prefixes ``phoneNumber``. public let selectedCountry: Binding + /// Sends a verification code to the full phone number, then pushes - /// ``AuthView/enterVerificationCode(verificationID:fullPhoneNumber:)``. - public let sendCode: () -> Void + /// ``AuthView/enterVerificationCode(verificationID:fullPhoneNumber:)``. Does nothing unless + /// ``isValid`` is `true` and ``isLoading`` is `false`. + public let sendCode: @MainActor () -> Void + + /// Whether ``phoneNumber`` is non-empty. public let isValid: Bool + + /// Whether a code is being sent, or the ``AuthService`` is signing in. public let isLoading: Bool - /// The localized message of the last failed attempt, if any. + + /// The localized message of the last failed attempt. It clears when the number or country + /// changes or the next attempt starts. The sheet's error alert still shows for the same failure. public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(phoneNumber: Binding, + selectedCountry: Binding, + sendCode: @escaping @MainActor () -> Void, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.phoneNumber = phoneNumber + self.selectedCountry = selectedCountry + self.sendCode = sendCode + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } } /// The phone number screen, with its content drawn by you. @@ -62,7 +87,7 @@ public struct EnterPhoneNumberScreen: View { @Environment(\.reportError) private var reportError @State private var phoneNumber: String = "" @State private var selectedCountry: CountryData = .default - @State private var isSending = false + @State private var isWorking = false @State private var errorMessage: String? private let content: (PhoneNumberContentState) -> Content @@ -76,6 +101,8 @@ public struct EnterPhoneNumberScreen: View { isLoading: isLoading, errorMessage: errorMessage )) + .onChange(of: phoneNumber) { errorMessage = nil } + .onChange(of: selectedCountry) { errorMessage = nil } } private var isValid: Bool { @@ -83,15 +110,15 @@ public struct EnterPhoneNumberScreen: View { } private var isLoading: Bool { - isSending || authService.authenticationState == .authenticating + isWorking || authService.authenticationState == .authenticating } private func sendCode() { guard isValid, !isLoading else { return } + isWorking = true + errorMessage = nil Task { - isSending = true - errorMessage = nil - defer { isSending = false } + defer { isWorking = false } do { let fullPhoneNumber = selectedCountry.dialCode + phoneNumber let id = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift index 0054c44b0d..5b681ccc8e 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift @@ -21,29 +21,64 @@ private let resendDelay: TimeInterval = 60 /// The state and actions of the verification code screen, passed to /// ``EnterVerificationCodeScreen``'s content. public struct VerificationCodeContentState { + /// The code the user typed. public let verificationCode: Binding - /// Signs in with the code, routing MFA and account conflicts to their handlers. - public let verify: () -> Void - /// Sends a new code. Does nothing while ``resendCountdown`` is above zero. - public let resendCode: () -> Void + + /// Signs in with the code, routing MFA and account conflicts to their handlers. Does nothing + /// unless ``isValid`` is `true` and ``isLoading`` is `false`. + public let verify: @MainActor () -> Void + + /// Sends a new code and clears ``verificationCode``. Does nothing while ``resendCountdown`` is + /// above zero or ``isLoading`` is `true`. + public let resendCode: @MainActor () -> Void + /// Pops back to the phone number screen. - public let changeNumber: () -> Void + public let changeNumber: @MainActor () -> Void + /// The number the code was sent to, including the dial code. public let fullPhoneNumber: String + /// Seconds left before ``resendCode`` is available; `0` when it is. public let resendCountdown: Int + + /// Whether ``verificationCode`` has 6 digits. public let isValid: Bool + + /// Whether a verify or resend is in flight, or the ``AuthService`` is signing in. public let isLoading: Bool - /// The localized message of the last failed attempt, if any. + + /// The localized message of the last failed verify or resend. It clears when the code changes + /// or the next attempt starts. The sheet's error alert still shows for the same failure. public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(verificationCode: Binding, + verify: @escaping @MainActor () -> Void, + resendCode: @escaping @MainActor () -> Void, + changeNumber: @escaping @MainActor () -> Void, + fullPhoneNumber: String, + resendCountdown: Int, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.verificationCode = verificationCode + self.verify = verify + self.resendCode = resendCode + self.changeNumber = changeNumber + self.fullPhoneNumber = fullPhoneNumber + self.resendCountdown = resendCountdown + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } } /// The verification code screen, with its content drawn by you. /// /// The screen signs in, resends codes, runs the resend countdown, and routes MFA, account -/// conflicts and errors. Your content only draws the UI from the -/// ``VerificationCodeContentState`` it receives. Wrap ``DefaultEnterVerificationCodeContent`` to -/// keep the stock markup. +/// conflicts and errors. It reads `verificationID` once; codes it resends replace it +/// internally. Your content only draws the UI from the ``VerificationCodeContentState`` it +/// receives. Wrap ``DefaultEnterVerificationCodeContent`` to keep the stock markup. /// /// Example: /// ```swift @@ -100,11 +135,13 @@ public struct EnterVerificationCodeScreen: View { isLoading: isLoading, errorMessage: errorMessage )) + .onChange(of: verificationCode) { errorMessage = nil } .task(id: resendAvailableAt) { - repeat { + while !Task.isCancelled { resendCountdown = max(0, Int(resendAvailableAt.timeIntervalSinceNow.rounded(.up))) + guard resendCountdown > 0 else { return } try? await Task.sleep(for: .seconds(1)) - } while resendCountdown > 0 && !Task.isCancelled + } } } @@ -118,19 +155,19 @@ public struct EnterVerificationCodeScreen: View { private func verify() { guard isValid, !isLoading else { return } + isWorking = true + errorMessage = nil Task { - isWorking = true - errorMessage = nil defer { isWorking = false } do { let credential = PhoneAuthProvider.provider() .credential(withVerificationID: verificationID, verificationCode: verificationCode) - let outcome = try await authService.signIn(credentials: credential) - if case let .mfaRequired(mfaInfo) = outcome, let onMFA = mfaHandler { - onMFA(mfaInfo) - return + switch try await authService.signIn(credentials: credential) { + case let .mfaRequired(mfaInfo): + mfaHandler?(mfaInfo) + case .signedIn: + authService.navigator.clear() } - authService.navigator.clear() } catch { if case let AuthServiceError.accountConflict(ctx) = error, let onConflict = accountConflictHandler { @@ -146,9 +183,9 @@ public struct EnterVerificationCodeScreen: View { private func resendCode() { guard resendCountdown == 0, !isLoading else { return } + isWorking = true + errorMessage = nil Task { - isWorking = true - errorMessage = nil defer { isWorking = false } do { verificationID = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) From ba7ecda7b74ecb31549e3b44d7f045346aba70a8 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:44:25 +0100 Subject: [PATCH 21/24] fix(auth): let the resend code button inherit the app font when no theme is set --- .../Sources/Views/DefaultEnterVerificationCodeContent.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift index dd145f4e49..9b724a838d 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift @@ -75,7 +75,7 @@ public struct DefaultEnterVerificationCodeContent: View { .monospacedDigit() } } - .authFont(.body) + .authCustomFont(.body) .frame(maxWidth: .infinity) } .disabled(state.isLoading || state.resendCountdown > 0) From 46e54a0bb0cccdeda27ba7a3361c20dcfcfba8ce Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:11:29 +0100 Subject: [PATCH 22/24] feat(auth): add content slots to email sign-in screens Add EmailAuthScreen, PasswordRecoveryScreen and EmailLinkScreen, which own the email flows' state and logic and hand their content a state struct. The stock markup moves to DefaultEmailAuthContent, DefaultPasswordRecoveryContent and DefaultEmailLinkContent, and the existing views render them through the screens. --- .../Views/DefaultEmailAuthContent.swift | 155 +++++++++++++ .../Views/DefaultEmailLinkContent.swift | 73 ++++++ .../DefaultPasswordRecoveryContent.swift | 92 ++++++++ .../Sources/Views/EmailAuthScreen.swift | 216 +++++++++++++++++ .../Sources/Views/EmailAuthView.swift | 217 +----------------- .../Sources/Views/EmailLinkScreen.swift | 167 ++++++++++++++ .../Sources/Views/EmailLinkView.swift | 91 +------- .../Views/PasswordRecoveryScreen.swift | 149 ++++++++++++ .../Sources/Views/PasswordRecoveryView.swift | 80 +------ 9 files changed, 855 insertions(+), 385 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift new file mode 100644 index 0000000000..24acc0a0c4 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift @@ -0,0 +1,155 @@ +// 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 + +private enum FocusableField: Hashable { + case email + case password + case confirmPassword +} + +/// The stock content of ``EmailAuthScreen``. +@MainActor +public struct DefaultEmailAuthContent: View { + public init(state: EmailAuthContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + @FocusState private var focus: FocusableField? + private let state: EmailAuthContentState + + public var body: some View { + VStack(spacing: 16) { + AuthTextField( + text: state.email, + label: authService.string.emailFieldLabel, + prompt: authService.string.emailInputLabel, + keyboardType: .emailAddress, + contentType: .emailAddress, + validations: [ + FormValidators.email, + ], + maintainsValidationMessage: state.flow == .signUp, + onSubmit: { _ in + self.focus = .password + }, + leading: { + Image(systemName: "at") + } + ) + .focused($focus, equals: .email) + .accessibilityIdentifier("email-field") + AuthTextField( + text: state.password, + label: authService.string.passwordFieldLabel, + prompt: authService.string.passwordInputLabel, + contentType: .password, + isSecureTextField: true, + validations: state.flow == .signUp ? [ + FormValidators.atLeast6Characters + ] : [], + maintainsValidationMessage: state.flow == .signUp, + onSubmit: { _ in + state.signIn() + }, + leading: { + Image(systemName: "lock") + } + ) + .submitLabel(.go) + .focused($focus, equals: .password) + .accessibilityIdentifier("password-field") + if state.flow == .signIn { + Button(action: state.goToPasswordRecovery) { + Text(authService.string.passwordButtonLabel) + .authCustomFont(.body, weight: .medium) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .accessibilityIdentifier("password-recovery-button") + } + + if state.flow == .signUp { + AuthTextField( + text: state.confirmPassword, + label: authService.string.confirmPasswordFieldLabel, + prompt: authService.string.confirmPasswordInputLabel, + contentType: .password, + isSecureTextField: true, + validations: [ + FormValidators.confirmPassword(password: state.password.wrappedValue), + ], + maintainsValidationMessage: true, + onSubmit: { _ in + state.signUp() + }, + leading: { + Image(systemName: "lock") + } + ) + .submitLabel(.go) + .focused($focus, equals: .confirmPassword) + .accessibilityIdentifier("confirm-password-field") + } + + Button { + if state.flow == .signIn { + state.signIn() + } else { + state.signUp() + } + } label: { + if !state.isLoading { + Text( + state.flow == .signIn + ? authService.string.signInWithEmailButtonLabel + : authService.string.signUpWithEmailButtonLabel + ) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } else { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + } + .disabled(!state.isValid) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .authCTAButtonStyle() + .accessibilityIdentifier("sign-in-button") + } + Button(action: state.switchFlow) { + HStack(spacing: 4) { + Text( + state.flow == .signIn + ? authService.string.dontHaveAnAccountYetLabel + : authService.string.alreadyHaveAnAccountLabel + ) + .authCustomFont(.body) + .foregroundStyle(Color(.label)) + Text( + state.flow == .signUp + ? authService.string.emailLoginFlowLabel + : authService.string.emailSignUpFlowLabel + ) + .authCustomFont(.body, weight: .medium) + } + } + .accessibilityIdentifier("switch-auth-flow") + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift new file mode 100644 index 0000000000..caaa7ec17f --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift @@ -0,0 +1,73 @@ +// 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 stock content of ``EmailLinkScreen``. +@MainActor +public struct DefaultEmailLinkContent: View { + public init(state: EmailLinkContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: EmailLinkContentState + + public var body: some View { + VStack(spacing: 24) { + AuthTextField( + text: state.email, + label: authService.string.signInLinkEmailFieldLabel, + prompt: authService.string.emailInputLabel, + keyboardType: .emailAddress, + contentType: .emailAddress, + validations: [ + FormValidators.email, + ], + leading: { + Image(systemName: "at") + } + ) + .accessibilityIdentifier("email-link-email-field") + Button(action: state.sendSignInLink) { + Text(authService.string.sendEmailLinkButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .disabled(!state.isValid) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle(authService.string.signInWithEmailLinkViewTitle) + .safeAreaPadding() + .alert( + authService.string.signInWithEmailLinkViewTitle, + isPresented: Binding( + get: { state.didSend }, + set: { isPresented in + if !isPresented { + state.dismissAlert() + } + } + ) + ) { + Button(authService.string.okButtonLabel, action: state.dismissAlert) + } message: { + Text(authService.string.signInWithEmailLinkViewMessage) + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift new file mode 100644 index 0000000000..aa7c45ee1d --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift @@ -0,0 +1,92 @@ +// 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 stock content of ``PasswordRecoveryScreen``. +@MainActor +public struct DefaultPasswordRecoveryContent: View { + public init(state: PasswordRecoveryContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: PasswordRecoveryContentState + + public var body: some View { + VStack(spacing: 24) { + AuthTextField( + text: state.email, + label: authService.string.passwordRecoveryEmailFieldLabel, + prompt: authService.string.emailInputLabel, + keyboardType: .emailAddress, + contentType: .emailAddress, + validations: [ + FormValidators.email, + ], + leading: { + Image(systemName: "at") + } + ) + Button(action: state.sendResetLink) { + Text(authService.string.forgotPasswordInputLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .disabled(!state.isValid) + .frame(maxWidth: .infinity) + .authCTAButtonStyle() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle(authService.string.passwordRecoveryTitle) + .safeAreaPadding() + .sheet(isPresented: Binding( + get: { state.didSend }, + set: { isPresented in + if !isPresented { + state.dismissSuccess() + } + } + )) { + successSheet + } + } + + @ViewBuilder + @MainActor + private var successSheet: some View { + VStack { + Text(authService.string.passwordRecoveryEmailSentTitle) + .authFont(.largeTitle) + .fontWeight(.bold) + .padding() + Text(authService.string.passwordRecoveryHelperMessage) + .authCustomFont(.body) + .padding() + + Divider() + + Text(String(format: authService.string.passwordRecoveryEmailSentMessage, state.sentEmail)) + .authCustomFont(.body) + .padding() + + Divider() + + Button(authService.string.okButtonLabel, action: state.dismissSuccess) + .padding() + } + .padding() + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift new file mode 100644 index 0000000000..7e8d8cddcf --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift @@ -0,0 +1,216 @@ +// 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 state and actions of the email and password form, passed to ``EmailAuthScreen``'s +/// content. +public struct EmailAuthContentState { + /// The email address. The screen fills it in from ``AuthService/suggestedEmailAddress``. + public let email: Binding + + /// The password. + public let password: Binding + + /// The repeated password, checked against ``password`` when ``flow`` is + /// ``AuthenticationFlow/signUp``. + public let confirmPassword: Binding + + /// Signs in with ``email`` and ``password``, routing MFA and account conflicts to their + /// handlers. Does nothing unless ``isValid`` is `true` and ``isLoading`` is `false`. + public let signIn: @MainActor () -> Void + + /// Creates an account with ``email`` and ``password``, routing MFA and account conflicts to + /// their handlers. Does nothing unless ``isValid`` is `true` and ``isLoading`` is `false`. + public let signUp: @MainActor () -> Void + + /// Toggles ``flow`` between sign-in and sign-up, with animation. + public let switchFlow: @MainActor () -> Void + + /// Pushes ``AuthView/passwordRecovery``. + public let goToPasswordRecovery: @MainActor () -> Void + + /// Whether the form signs in or signs up. Shared with ``AuthService/authenticationFlow``. + public let flow: AuthenticationFlow + + /// Whether the fields are valid for ``flow``: a valid email and a non-empty password to sign + /// in; a valid email, a password of at least 6 characters and a matching ``confirmPassword`` + /// to sign up. + public let isValid: Bool + + /// Whether a sign-in or sign-up is in flight, or the ``AuthService`` is signing in. + public let isLoading: Bool + + /// The localized message of the last failed attempt. It clears when a field or ``flow`` + /// changes or the next attempt starts. The sheet's error alert still shows for the same + /// failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(email: Binding, + password: Binding, + confirmPassword: Binding, + signIn: @escaping @MainActor () -> Void, + signUp: @escaping @MainActor () -> Void, + switchFlow: @escaping @MainActor () -> Void, + goToPasswordRecovery: @escaping @MainActor () -> Void, + flow: AuthenticationFlow, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.email = email + self.password = password + self.confirmPassword = confirmPassword + self.signIn = signIn + self.signUp = signUp + self.switchFlow = switchFlow + self.goToPasswordRecovery = goToPasswordRecovery + self.flow = flow + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The email and password form, with its content drawn by you. +/// +/// The screen signs in and signs up, switches the flow, navigates, and routes MFA, account +/// conflicts and errors. Your content only draws the UI from the ``EmailAuthContentState`` it +/// receives. Wrap ``DefaultEmailAuthContent`` to keep the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerContent { +/// EmailAuthScreen { state in +/// TextField("Email", text: state.email) +/// SecureField("Password", text: state.password) +/// if let errorMessage = state.errorMessage { +/// Text(errorMessage) +/// } +/// Button("Sign in", action: state.signIn) +/// .disabled(!state.isValid || state.isLoading) +/// } +/// } +/// ``` +@MainActor +public struct EmailAuthScreen: View { + public init(@ViewBuilder content: @escaping (EmailAuthContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.mfaHandler) private var mfaHandler + @Environment(\.reportError) private var reportError + @State private var email = "" + @State private var password = "" + @State private var confirmPassword = "" + @State private var isWorking = false + @State private var errorMessage: String? + + private let content: (EmailAuthContentState) -> Content + + public var body: some View { + content(EmailAuthContentState( + email: $email, + password: $password, + confirmPassword: $confirmPassword, + signIn: signIn, + signUp: signUp, + switchFlow: switchFlow, + goToPasswordRecovery: { authService.navigator.push(.passwordRecovery) }, + flow: authService.authenticationFlow, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .onChange(of: email) { errorMessage = nil } + .onChange(of: password) { errorMessage = nil } + .onChange(of: confirmPassword) { errorMessage = nil } + .onChange(of: authService.authenticationFlow) { errorMessage = nil } + .onAppear { + if let suggestedEmailAddress = authService.suggestedEmailAddress { + email = suggestedEmailAddress + } + } + .onChange(of: authService.suggestedEmailAddress) { _, suggestedEmailAddress in + if let suggestedEmailAddress { + email = suggestedEmailAddress + } + } + } + + private var isValid: Bool { + return if authService.authenticationFlow == .signIn { + FormValidators.email.isValid(input: email) && !password.isEmpty + } else { + FormValidators.email.isValid(input: email) && + FormValidators.atLeast6Characters.isValid(input: password) && + FormValidators.confirmPassword(password: password).isValid(input: confirmPassword) + } + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func switchFlow() { + withAnimation { + authService.authenticationFlow = + authService.authenticationFlow == .signIn ? .signUp : .signIn + } + } + + private func signIn() { + submit { [email, password] in + try await authService.signIn(email: email, password: password) + } + } + + private func signUp() { + submit { [email, password] in + try await authService.createUser(email: email, password: password) + } + } + + private func submit(_ attempt: @escaping @MainActor () async throws -> SignInOutcome) { + guard isValid, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + if case let .mfaRequired(mfaInfo) = try await attempt() { + mfaHandler?(mfaInfo) + } + } catch { + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return + } + + reportError?(error) + // The legacy sign-in recovery sheet explains this failure, so there is no message. + if case .legacySignInRecoveryPresented = error as? AuthServiceError { + return + } + + errorMessage = authService.string.localizedErrorMessage(for: error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index 7d06240f4a..96cc6a6961 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -18,232 +18,17 @@ // // Created by Russell Wheatley on 20/03/2025. // -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI -private enum FocusableField: Hashable { - case email - case password - case confirmPassword -} - @MainActor public struct EmailAuthView { - @Environment(AuthService.self) private var authService - @Environment(\.accountConflictHandler) private var accountConflictHandler - @Environment(\.mfaHandler) private var mfaHandler - @Environment(\.reportError) private var reportError - - @State private var email = "" - @State private var password = "" - @State private var confirmPassword = "" - - @FocusState private var focus: FocusableField? - public init() {} - - private var isValid: Bool { - return if authService.authenticationFlow == .signIn { - FormValidators.email.isValid(input: email) && !password.isEmpty - } else { - FormValidators.email.isValid(input: email) && - FormValidators.atLeast6Characters.isValid(input: password) && - FormValidators.confirmPassword(password: password).isValid(input: confirmPassword) - } - } - - private func signInWithEmailPassword() async throws { - do { - let outcome = try await authService.signIn(email: email, password: password) - - // Handle MFA at view level - if case let .mfaRequired(mfaInfo) = outcome, - let onMFA = mfaHandler { - onMFA(mfaInfo) - return - } - } catch { - if case let AuthServiceError.accountConflict(ctx) = error, - let onConflict = accountConflictHandler { - onConflict(ctx) - return - } - - reportError?(error) - if case .legacySignInRecoveryPresented = error as? AuthServiceError { - return - } - - throw error - } - } - - private func createUserWithEmailPassword() async throws { - do { - let outcome = try await authService.createUser(email: email, password: password) - - // Handle MFA at view level - if case let .mfaRequired(mfaInfo) = outcome, - let onMFA = mfaHandler { - onMFA(mfaInfo) - return - } - } catch { - if case let AuthServiceError.accountConflict(ctx) = error, - let onConflict = accountConflictHandler { - onConflict(ctx) - return - } - - reportError?(error) - throw error - } - } } extension EmailAuthView: View { public var body: some View { - VStack(spacing: 16) { - AuthTextField( - text: $email, - label: authService.string.emailFieldLabel, - prompt: authService.string.emailInputLabel, - keyboardType: .emailAddress, - contentType: .emailAddress, - validations: [ - FormValidators.email, - ], - maintainsValidationMessage: authService.authenticationFlow == .signUp, - onSubmit: { _ in - self.focus = .password - }, - leading: { - Image(systemName: "at") - } - ) - .focused($focus, equals: .email) - .accessibilityIdentifier("email-field") - AuthTextField( - text: $password, - label: authService.string.passwordFieldLabel, - prompt: authService.string.passwordInputLabel, - contentType: .password, - isSecureTextField: true, - validations: authService.authenticationFlow == .signUp ? [ - FormValidators.atLeast6Characters - ] : [], - maintainsValidationMessage: authService.authenticationFlow == .signUp, - onSubmit: { _ in - Task { try await signInWithEmailPassword() } - }, - leading: { - Image(systemName: "lock") - } - ) - .submitLabel(.go) - .focused($focus, equals: .password) - .accessibilityIdentifier("password-field") - if authService.authenticationFlow == .signIn { - Button { - authService.navigator.push(.passwordRecovery) - } label: { - Text(authService.string.passwordButtonLabel) - .authCustomFont(.body, weight: .medium) - .frame(maxWidth: .infinity, alignment: .trailing) - } - .accessibilityIdentifier("password-recovery-button") - } - - if authService.authenticationFlow == .signUp { - AuthTextField( - text: $confirmPassword, - label: authService.string.confirmPasswordFieldLabel, - prompt: authService.string.confirmPasswordInputLabel, - contentType: .password, - isSecureTextField: true, - validations: [ - FormValidators.confirmPassword(password: password), - ], - maintainsValidationMessage: true, - onSubmit: { _ in - Task { try await createUserWithEmailPassword() } - }, - leading: { - Image(systemName: "lock") - } - ) - .submitLabel(.go) - .focused($focus, equals: .confirmPassword) - .accessibilityIdentifier("confirm-password-field") - } - - Button(action: { - Task { - if authService.authenticationFlow == .signIn { - try await signInWithEmailPassword() - } else { - try await createUserWithEmailPassword() - } - } - }) { - if authService.authenticationState != .authenticating { - Text( - authService.authenticationFlow == .signIn - ? authService.string.signInWithEmailButtonLabel - : authService.string.signUpWithEmailButtonLabel - ) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } else { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - } - .disabled(!isValid) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .authCTAButtonStyle() - .accessibilityIdentifier("sign-in-button") - } - Button(action: { - withAnimation { - authService.authenticationFlow = - authService - .authenticationFlow == .signIn ? .signUp : .signIn - } - }) { - HStack(spacing: 4) { - Text( - authService - .authenticationFlow == .signIn - ? authService.string.dontHaveAnAccountYetLabel - : authService.string.alreadyHaveAnAccountLabel - ) - .authCustomFont(.body) - .foregroundStyle(Color(.label)) - Text( - authService.authenticationFlow == .signUp - ? authService.string.emailLoginFlowLabel - : authService.string.emailSignUpFlowLabel - ) - .authCustomFont(.body, weight: .medium) - } - } - .accessibilityIdentifier("switch-auth-flow") - .onAppear { - if let suggestedEmailAddress = authService.suggestedEmailAddress { - email = suggestedEmailAddress - } - } - .onChange(of: authService.suggestedEmailAddress) { _, suggestedEmailAddress in - if let suggestedEmailAddress { - email = suggestedEmailAddress - } - } + EmailAuthScreen { DefaultEmailAuthContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift new file mode 100644 index 0000000000..206cf50ca8 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift @@ -0,0 +1,167 @@ +// 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 state and actions of the email-link screen, passed to ``EmailLinkScreen``'s content. +public struct EmailLinkContentState { + /// The email address to send the sign-in link to. The screen fills it in from + /// ``AuthService/suggestedEmailAddress``. + public let email: Binding + + /// Sends a sign-in link to ``email``, then sets ``didSend``. Does nothing unless ``isValid`` is + /// `true` and ``isLoading`` is `false`. + public let sendSignInLink: @MainActor () -> Void + + /// Clears ``didSend``. + public let dismissAlert: @MainActor () -> Void + + /// Whether a sign-in link was sent and ``dismissAlert`` hasn't been called since. + public let didSend: Bool + + /// Whether ``email`` is a valid email address. + public let isValid: Bool + + /// Whether a link is being sent, or the ``AuthService`` is signing in. + public let isLoading: Bool + + /// The localized message of the last failed send or link sign-in. It clears when the email + /// changes or the next attempt starts. The sheet's error alert still shows for the same + /// failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(email: Binding, + sendSignInLink: @escaping @MainActor () -> Void, + dismissAlert: @escaping @MainActor () -> Void, + didSend: Bool, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.email = email + self.sendSignInLink = sendSignInLink + self.dismissAlert = dismissAlert + self.didSend = didSend + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The email-link screen, with its content drawn by you. +/// +/// The screen sends the link, signs in when the app opens it, and routes account conflicts and +/// errors. Your content only draws the UI from the ``EmailLinkContentState`` it receives. Wrap +/// ``DefaultEmailLinkContent`` to keep the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case .emailLink: +/// EmailLinkScreen { state in +/// TextField("Email", text: state.email) +/// Button("Send link", action: state.sendSignInLink) +/// .disabled(!state.isValid || state.isLoading) +/// if state.didSend { +/// Text("Check your inbox") +/// } +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct EmailLinkScreen: View { + public init(@ViewBuilder content: @escaping (EmailLinkContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.reportError) private var reportError + @State private var email = "" + @State private var didSend = false + @State private var isWorking = false + @State private var errorMessage: String? + + private let content: (EmailLinkContentState) -> Content + + public var body: some View { + content(EmailLinkContentState( + email: $email, + sendSignInLink: sendSignInLink, + dismissAlert: { didSend = false }, + didSend: didSend, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .onChange(of: email) { errorMessage = nil } + .onOpenURL { url in + errorMessage = nil + Task { + do { + try await authService.handleSignInLink(url: url) + } catch { + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return + } + + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } + .onAppear { + if let suggestedEmailAddress = authService.suggestedEmailAddress { + email = suggestedEmailAddress + } + } + .onChange(of: authService.suggestedEmailAddress) { _, suggestedEmailAddress in + if let suggestedEmailAddress { + email = suggestedEmailAddress + } + } + } + + private var isValid: Bool { + CommonUtils.isValidEmail(email) + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func sendSignInLink() { + guard isValid, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + try await authService.sendEmailSignInLink(email: email) + didSend = true + authService.emailLink = email + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift index c64c86e570..c5b8e776aa 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift @@ -12,106 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor public struct EmailLinkView { - @Environment(AuthService.self) private var authService - @Environment(\.accountConflictHandler) private var accountConflictHandler - @Environment(\.reportError) private var reportError - @State private var email = "" - @State private var showAlert = false - public init() {} - - private func sendEmailLink() async throws { - do { - try await authService.sendEmailSignInLink(email: email) - showAlert = true - } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error - } - } - } } extension EmailLinkView: View { public var body: some View { - VStack(spacing: 24) { - AuthTextField( - text: $email, - label: authService.string.signInLinkEmailFieldLabel, - prompt: authService.string.emailInputLabel, - keyboardType: .emailAddress, - contentType: .emailAddress, - validations: [ - FormValidators.email, - ], - leading: { - Image(systemName: "at") - } - ) - .accessibilityIdentifier("email-link-email-field") - Button { - Task { - try await sendEmailLink() - authService.emailLink = email - } - } label: { - Text(authService.string.sendEmailLinkButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .disabled(!CommonUtils.isValidEmail(email)) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .navigationTitle(authService.string.signInWithEmailLinkViewTitle) - .safeAreaPadding() - .alert( - authService.string.signInWithEmailLinkViewTitle, - isPresented: $showAlert - ) { - Button(authService.string.okButtonLabel) { - showAlert = false - } - } message: { - Text(authService.string.signInWithEmailLinkViewMessage) - } - .onOpenURL { url in - Task { - do { - try await authService.handleSignInLink(url: url) - } catch { - if case let AuthServiceError.accountConflict(ctx) = error, - let onConflict = accountConflictHandler { - onConflict(ctx) - return - } - - reportError?(error) - throw error - } - } - } - .onAppear { - if let suggestedEmailAddress = authService.suggestedEmailAddress { - email = suggestedEmailAddress - } - } - .onChange(of: authService.suggestedEmailAddress) { _, suggestedEmailAddress in - if let suggestedEmailAddress { - email = suggestedEmailAddress - } - } + EmailLinkScreen { DefaultEmailLinkContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift new file mode 100644 index 0000000000..df201b6975 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift @@ -0,0 +1,149 @@ +// 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 state and actions of the password recovery screen, passed to +/// ``PasswordRecoveryScreen``'s content. +public struct PasswordRecoveryContentState { + /// The email address to send the reset link to. + public let email: Binding + + /// Sends a password reset link to ``email``, then sets ``didSend``. Does nothing unless + /// ``isValid`` is `true` and ``isLoading`` is `false`. + public let sendResetLink: @MainActor () -> Void + + /// Clears ``didSend`` and ``email`` and returns to the first screen of the sheet. + public let dismissSuccess: @MainActor () -> Void + + /// Whether a reset link was sent and ``dismissSuccess`` hasn't been called since. + public let didSend: Bool + + /// The address the last reset link was sent to; empty until one is sent. + public let sentEmail: String + + /// Whether ``email`` is a valid email address. + public let isValid: Bool + + /// Whether a reset link is being sent. + public let isLoading: Bool + + /// The localized message of the last failed attempt. It clears when the email changes or the + /// next attempt starts. The sheet's error alert still shows for the same failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(email: Binding, + sendResetLink: @escaping @MainActor () -> Void, + dismissSuccess: @escaping @MainActor () -> Void, + didSend: Bool, + sentEmail: String, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.email = email + self.sendResetLink = sendResetLink + self.dismissSuccess = dismissSuccess + self.didSend = didSend + self.sentEmail = sentEmail + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The password recovery screen, with its content drawn by you. +/// +/// The screen sends the reset link, navigates, and reports errors. Your content only draws the +/// UI from the ``PasswordRecoveryContentState`` it receives. Wrap +/// ``DefaultPasswordRecoveryContent`` to keep the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case .passwordRecovery: +/// PasswordRecoveryScreen { state in +/// if state.didSend { +/// Text("Check \(state.sentEmail)") +/// Button("OK", action: state.dismissSuccess) +/// } else { +/// TextField("Email", text: state.email) +/// Button("Send reset link", action: state.sendResetLink) +/// .disabled(!state.isValid || state.isLoading) +/// } +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct PasswordRecoveryScreen: View { + public init(@ViewBuilder content: @escaping (PasswordRecoveryContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var email = "" + @State private var didSend = false + @State private var sentEmail = "" + @State private var isWorking = false + @State private var errorMessage: String? + + private let content: (PasswordRecoveryContentState) -> Content + + public var body: some View { + content(PasswordRecoveryContentState( + email: $email, + sendResetLink: sendResetLink, + dismissSuccess: dismissSuccess, + didSend: didSend, + sentEmail: sentEmail, + isValid: isValid, + isLoading: isWorking, + errorMessage: errorMessage + )) + .onChange(of: email) { errorMessage = nil } + } + + private var isValid: Bool { + CommonUtils.isValidEmail(email) + } + + private func sendResetLink() { + guard isValid, !isWorking else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + try await authService.sendPasswordRecoveryEmail(email: email) + sentEmail = email + didSend = true + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } + + private func dismissSuccess() { + didSend = false + email = "" + authService.navigator.clear() + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift index 73a60758e6..93a9a4f5af 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift @@ -12,95 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor public struct PasswordRecoveryView { - @Environment(AuthService.self) private var authService - @State private var email = "" - @State private var showSuccessSheet = false - @State private var sentEmail = "" - public init() {} - - private func sendPasswordRecoveryEmail() async { - do { - try await authService.sendPasswordRecoveryEmail(email: email) - sentEmail = email - showSuccessSheet = true - } catch { - // Error already displayed via modal by AuthService - } - } } extension PasswordRecoveryView: View { public var body: some View { - VStack(spacing: 24) { - AuthTextField( - text: $email, - label: authService.string.passwordRecoveryEmailFieldLabel, - prompt: authService.string.emailInputLabel, - keyboardType: .emailAddress, - contentType: .emailAddress, - validations: [ - FormValidators.email, - ], - leading: { - Image(systemName: "at") - } - ) - Button(action: { - Task { - await sendPasswordRecoveryEmail() - } - }) { - Text(authService.string.forgotPasswordInputLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .disabled(!CommonUtils.isValidEmail(email)) - .frame(maxWidth: .infinity) - .authCTAButtonStyle() - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .navigationTitle(authService.string.passwordRecoveryTitle) - .safeAreaPadding() - .sheet(isPresented: $showSuccessSheet) { - successSheet - } - } - - @ViewBuilder - @MainActor - private var successSheet: some View { - VStack { - Text(authService.string.passwordRecoveryEmailSentTitle) - .authFont(.largeTitle) - .fontWeight(.bold) - .padding() - Text(authService.string.passwordRecoveryHelperMessage) - .authCustomFont(.body) - .padding() - - Divider() - - Text(String(format: authService.string.passwordRecoveryEmailSentMessage, sentEmail)) - .authCustomFont(.body) - .padding() - - Divider() - - Button(authService.string.okButtonLabel) { - showSuccessSheet = false - email = "" - authService.navigator.clear() - } - .padding() - } - .padding() + PasswordRecoveryScreen { DefaultPasswordRecoveryContent(state: $0) } } } From b28b50a14cd7f131e2891ea5caaf580782df063a Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:31:36 +0100 Subject: [PATCH 23/24] fix(auth): address review of the email content slots Add goToEmailLink and hideSuccess to the state structs, keep the recovery screen when its success sheet is swiped away, handle an opened email link once even when custom content has several root views, move focus to confirm password on Go during sign-up, and align the recovery screen's isLoading with the other screens. --- .../Views/DefaultEmailAuthContent.swift | 6 +++- .../Views/DefaultEmailLinkContent.swift | 2 +- .../DefaultPasswordRecoveryContent.swift | 2 +- .../Sources/Views/EmailAuthScreen.swift | 28 ++++++++++++++----- .../Sources/Views/EmailLinkScreen.swift | 10 +++++-- .../Views/PasswordRecoveryScreen.swift | 20 ++++++++++--- 6 files changed, 52 insertions(+), 16 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift index 24acc0a0c4..c6a63a756c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift @@ -64,7 +64,11 @@ public struct DefaultEmailAuthContent: View { ] : [], maintainsValidationMessage: state.flow == .signUp, onSubmit: { _ in - state.signIn() + if state.flow == .signIn { + state.signIn() + } else { + focus = .confirmPassword + } }, leading: { Image(systemName: "lock") diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift index caaa7ec17f..6955b65dcd 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift @@ -65,7 +65,7 @@ public struct DefaultEmailLinkContent: View { } ) ) { - Button(authService.string.okButtonLabel, action: state.dismissAlert) + Button(authService.string.okButtonLabel) {} } message: { Text(authService.string.signInWithEmailLinkViewMessage) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift index aa7c45ee1d..f2fcbdfa84 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift @@ -56,7 +56,7 @@ public struct DefaultPasswordRecoveryContent: View { get: { state.didSend }, set: { isPresented in if !isPresented { - state.dismissSuccess() + state.hideSuccess() } } )) { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift index 7e8d8cddcf..1ef80fd4f4 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift @@ -42,6 +42,9 @@ public struct EmailAuthContentState { /// Pushes ``AuthView/passwordRecovery``. public let goToPasswordRecovery: @MainActor () -> Void + /// Starts email-link sign-in, the same as the stock email-link button. + public let goToEmailLink: @MainActor () -> Void + /// Whether the form signs in or signs up. Shared with ``AuthService/authenticationFlow``. public let flow: AuthenticationFlow @@ -66,6 +69,7 @@ public struct EmailAuthContentState { signUp: @escaping @MainActor () -> Void, switchFlow: @escaping @MainActor () -> Void, goToPasswordRecovery: @escaping @MainActor () -> Void, + goToEmailLink: @escaping @MainActor () -> Void, flow: AuthenticationFlow, isValid: Bool, isLoading: Bool, @@ -77,6 +81,7 @@ public struct EmailAuthContentState { self.signUp = signUp self.switchFlow = switchFlow self.goToPasswordRecovery = goToPasswordRecovery + self.goToEmailLink = goToEmailLink self.flow = flow self.isValid = isValid self.isLoading = isLoading @@ -90,18 +95,26 @@ public struct EmailAuthContentState { /// conflicts and errors. Your content only draws the UI from the ``EmailAuthContentState`` it /// receives. Wrap ``DefaultEmailAuthContent`` to keep the stock markup. /// +/// ``AuthPickerView/pickerContent(_:)`` replaces the whole first screen, so add the provider +/// buttons back with ``DefaultAuthMethodPicker``. +/// /// Example: /// ```swift /// AuthPickerView { authenticatedApp } /// .pickerContent { -/// EmailAuthScreen { state in -/// TextField("Email", text: state.email) -/// SecureField("Password", text: state.password) -/// if let errorMessage = state.errorMessage { -/// Text(errorMessage) +/// ScrollView { +/// VStack { +/// EmailAuthScreen { state in +/// TextField("Email", text: state.email) +/// SecureField("Password", text: state.password) +/// if let errorMessage = state.errorMessage { +/// Text(errorMessage) +/// } +/// Button("Sign in", action: state.signIn) +/// .disabled(!state.isValid || state.isLoading) +/// } +/// DefaultAuthMethodPicker() /// } -/// Button("Sign in", action: state.signIn) -/// .disabled(!state.isValid || state.isLoading) /// } /// } /// ``` @@ -132,6 +145,7 @@ public struct EmailAuthScreen: View { signUp: signUp, switchFlow: switchFlow, goToPasswordRecovery: { authService.navigator.push(.passwordRecovery) }, + goToEmailLink: { authService.selectEmailLink() }, flow: authService.authenticationFlow, isValid: isValid, isLoading: isLoading, diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift index 206cf50ca8..11577d6902 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift @@ -33,7 +33,7 @@ public struct EmailLinkContentState { /// Whether ``email`` is a valid email address. public let isValid: Bool - /// Whether a link is being sent, or the ``AuthService`` is signing in. + /// Whether a link is being sent or handled, or the ``AuthService`` is signing in. public let isLoading: Bool /// The localized message of the last failed send or link sign-in. It clears when the email @@ -96,6 +96,7 @@ public struct EmailLinkScreen: View { @State private var email = "" @State private var didSend = false @State private var isWorking = false + @State private var isHandlingLink = false @State private var errorMessage: String? private let content: (EmailLinkContentState) -> Content @@ -112,8 +113,13 @@ public struct EmailLinkScreen: View { )) .onChange(of: email) { errorMessage = nil } .onOpenURL { url in + // Content with several root views gets this handler once per view; the link is one-time, + // so only the first one handles it. + guard !isHandlingLink else { return } + isHandlingLink = true errorMessage = nil Task { + defer { isHandlingLink = false } do { try await authService.handleSignInLink(url: url) } catch { @@ -145,7 +151,7 @@ public struct EmailLinkScreen: View { } private var isLoading: Bool { - isWorking || authService.authenticationState == .authenticating + isWorking || isHandlingLink || authService.authenticationState == .authenticating } private func sendSignInLink() { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift index df201b6975..23f1f95abc 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift @@ -27,7 +27,12 @@ public struct PasswordRecoveryContentState { /// Clears ``didSend`` and ``email`` and returns to the first screen of the sheet. public let dismissSuccess: @MainActor () -> Void - /// Whether a reset link was sent and ``dismissSuccess`` hasn't been called since. + /// Clears ``didSend``, keeping ``email`` and staying on this screen, for example when the + /// user swipes a success sheet away. + public let hideSuccess: @MainActor () -> Void + + /// Whether a reset link was sent and neither ``dismissSuccess`` nor ``hideSuccess`` has been + /// called since. public let didSend: Bool /// The address the last reset link was sent to; empty until one is sent. @@ -36,7 +41,7 @@ public struct PasswordRecoveryContentState { /// Whether ``email`` is a valid email address. public let isValid: Bool - /// Whether a reset link is being sent. + /// Whether a reset link is being sent, or the ``AuthService`` is signing in. public let isLoading: Bool /// The localized message of the last failed attempt. It clears when the email changes or the @@ -47,6 +52,7 @@ public struct PasswordRecoveryContentState { public init(email: Binding, sendResetLink: @escaping @MainActor () -> Void, dismissSuccess: @escaping @MainActor () -> Void, + hideSuccess: @escaping @MainActor () -> Void, didSend: Bool, sentEmail: String, isValid: Bool, @@ -55,6 +61,7 @@ public struct PasswordRecoveryContentState { self.email = email self.sendResetLink = sendResetLink self.dismissSuccess = dismissSuccess + self.hideSuccess = hideSuccess self.didSend = didSend self.sentEmail = sentEmail self.isValid = isValid @@ -111,10 +118,11 @@ public struct PasswordRecoveryScreen: View { email: $email, sendResetLink: sendResetLink, dismissSuccess: dismissSuccess, + hideSuccess: { didSend = false }, didSend: didSend, sentEmail: sentEmail, isValid: isValid, - isLoading: isWorking, + isLoading: isLoading, errorMessage: errorMessage )) .onChange(of: email) { errorMessage = nil } @@ -124,8 +132,12 @@ public struct PasswordRecoveryScreen: View { CommonUtils.isValidEmail(email) } + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + private func sendResetLink() { - guard isValid, !isWorking else { return } + guard isValid, !isLoading else { return } isWorking = true errorMessage = nil Task { From cf0ffbe79adb8cd2e2326e4cbb9a605ea6250cc4 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:47:13 +0100 Subject: [PATCH 24/24] docs(auth): point email screen docs at the whole-screen pickerContent --- .../FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift | 6 +++--- .../FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index db685ca1fc..daaf2266e2 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -71,9 +71,9 @@ public struct AuthPickerView: View { /// 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:)``. + /// button stays), so show your own heading, 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 diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift index 1ef80fd4f4..c14261e320 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift @@ -95,8 +95,9 @@ public struct EmailAuthContentState { /// conflicts and errors. Your content only draws the UI from the ``EmailAuthContentState`` it /// receives. Wrap ``DefaultEmailAuthContent`` to keep the stock markup. /// -/// ``AuthPickerView/pickerContent(_:)`` replaces the whole first screen, so add the provider -/// buttons back with ``DefaultAuthMethodPicker``. +/// `AuthPickerView`'s `pickerContent { state in ... }` replaces the whole first screen, so add +/// your own provider buttons calling `state.select`, or the stock ones with +/// ``DefaultAuthMethodPicker``. /// /// Example: /// ```swift