From d0a7d479907f6a0864d6d5e99b479b65dc4378ee Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 6 Jul 2026 00:48:07 +0100 Subject: [PATCH 1/7] Add AuthPickerView.pickerContent(_:) and pickerDestination(_:) to customize the auth sheet's root and destination content without breaking the existing initializer --- .../Sources/Views/AuthPickerContentView.swift | 92 +++++++++++ .../Views/AuthPickerDestinationView.swift | 62 ++++++++ .../Sources/Views/AuthPickerView.swift | 143 ++++++++---------- 3 files changed, 219 insertions(+), 78 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContentView.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerDestinationView.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContentView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContentView.swift new file mode 100644 index 0000000000..6d1ea85b9e --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContentView.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 FirebaseAuth +import FirebaseAuthUIComponents +import FirebaseCore +import SwiftUI + +/// The default content shown inside the ``AuthPickerView`` sheet: the sign-in method picker +/// (or ``SignedInView`` when already authenticated), plus the authenticating overlay. +/// +/// Use this together with ``AuthPickerView/pickerContent(_:)`` to customize its appearance +/// (background, tint, etc.) with plain SwiftUI modifiers, without rebuilding the auth UI from +/// scratch: +/// +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerContent { +/// AuthPickerContentView() +/// .background(theme.colors.background) +/// } +/// ``` +@MainActor +public struct AuthPickerContentView: View { + @Environment(AuthService.self) private var authService + + public init() {} + + public var body: 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...") + .foregroundStyle(.white) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.black.opacity(0.7)) + } + } + } + + @ViewBuilder + private 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) + } + } + } + } + + @ViewBuilder + private func otherSignInOptions(_ proxy: GeometryProxy) -> some View { + VStack { + authService.renderButtons() + } + .padding(.horizontal, proxy.size.width * 0.14) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerDestinationView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerDestinationView.swift new file mode 100644 index 0000000000..0597929117 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerDestinationView.swift @@ -0,0 +1,62 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SwiftUI + +/// The default content shown for each destination pushed inside the ``AuthPickerView`` sheet +/// (password recovery, email link, MFA, phone verification, etc.). +/// +/// Use this together with ``AuthPickerView/pickerDestination(_:)`` to customize its appearance +/// (background, tint, etc.) with plain SwiftUI modifiers, without rebuilding the routing switch +/// from scratch: +/// +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// AuthPickerDestinationView(screen: screen) +/// .background(theme.colors.background) +/// } +/// ``` +@MainActor +public struct AuthPickerDestinationView: View { + let screen: AuthView + + public init(screen: AuthView) { + self.screen = screen + } + + public var body: some View { + switch screen { + case AuthView.passwordRecovery: + PasswordRecoveryView() + case AuthView.emailLink: + EmailLinkView() + case AuthView.updatePassword: + UpdatePasswordView() + case AuthView.mfaEnrollment: + MFAEnrolmentView() + case AuthView.mfaManagement: + MFAManagementView() + case let .mfaResolution(mfaRequired): + MFAResolutionView(mfaRequired: mfaRequired) + case AuthView.enterPhoneNumber: + EnterPhoneNumberView() + case let .enterVerificationCode(verificationID, fullPhoneNumber): + EnterVerificationCodeView( + verificationID: verificationID, + fullPhoneNumber: fullPhoneNumber + ) + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 1976878125..965ebb4033 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -18,9 +18,22 @@ import FirebaseCore import SwiftUI @MainActor -public struct AuthPickerView { - public init(@ViewBuilder content: @escaping () -> Content = { EmptyView() }) { +public struct AuthPickerView { + public init(@ViewBuilder content: @escaping () -> Content = { EmptyView() }) + where PickerContent == AuthPickerContentView, DestinationContent == AuthPickerDestinationView { self.content = content + pickerContentOverride = { AuthPickerContentView() } + pickerDestinationOverride = { AuthPickerDestinationView(screen: $0) } + } + + fileprivate init( + content: @escaping () -> Content, + pickerContentOverride: @escaping () -> PickerContent, + pickerDestinationOverride: @escaping (AuthView) -> DestinationContent + ) { + self.content = content + self.pickerContentOverride = pickerContentOverride + self.pickerDestinationOverride = pickerDestinationOverride } @Environment(AuthService.self) private var authService @@ -28,6 +41,54 @@ public struct AuthPickerView { // View-layer error state @State private var error: AlertError? + + private let pickerContentOverride: () -> PickerContent + private let pickerDestinationOverride: (AuthView) -> DestinationContent +} + +public extension AuthPickerView { + /// Overrides the content shown inside the auth picker sheet, allowing full customization + /// (background, tint, etc.) via plain SwiftUI modifiers, without changing how + /// `AuthPickerView` itself is constructed. + /// + /// ```swift + /// AuthPickerView { authenticatedApp } + /// .pickerContent { + /// AuthPickerContentView() + /// .background(theme.colors.background) + /// } + /// ``` + func pickerContent( + @ViewBuilder _ content: @escaping () -> NewPickerContent + ) -> AuthPickerView { + AuthPickerView( + content: self.content, + pickerContentOverride: content, + pickerDestinationOverride: pickerDestinationOverride + ) + } + + /// Overrides the content shown for each destination pushed inside the auth picker sheet + /// (password recovery, email link, MFA, phone verification, etc.), allowing full + /// customization via plain SwiftUI modifiers, without changing how `AuthPickerView` itself + /// is constructed. + /// + /// ```swift + /// AuthPickerView { authenticatedApp } + /// .pickerDestination { screen in + /// AuthPickerDestinationView(screen: screen) + /// .background(theme.colors.background) + /// } + /// ``` + func pickerDestination( + @ViewBuilder _ content: @escaping (AuthView) -> NewDestinationContent + ) -> AuthPickerView { + AuthPickerView( + content: self.content, + pickerContentOverride: pickerContentOverride, + pickerDestinationOverride: content + ) + } } extension AuthPickerView: View { @@ -37,7 +98,7 @@ extension AuthPickerView: View { .sheet(isPresented: $authService.isPresented) { @Bindable var navigator = authService.navigator NavigationStack(path: $navigator.routes) { - authPickerViewInternal + pickerContentOverride() .navigationTitle(authService.authenticationState == .unauthenticated ? authService .string.authPickerTitle : "") .navigationBarTitleDisplayMode(.large) @@ -45,27 +106,7 @@ extension AuthPickerView: View { 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 - ) - } + pickerDestinationOverride(view) } } .environment(\.reportError, reportError) @@ -108,60 +149,6 @@ extension AuthPickerView: View { } } } - - @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...") - .foregroundStyle(.white) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.black.opacity(0.7)) - } - } - } - - @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) - } - } - } - } - - @ViewBuilder - func otherSignInOptions(_ proxy: GeometryProxy) -> some View { - VStack { - authService.renderButtons() - } - .padding(.horizontal, proxy.size.width * 0.14) - } } #Preview { From 6486416bcfcd545f6a7614cefc83bc64fd0f2813 Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 6 Jul 2026 20:03:20 +0100 Subject: [PATCH 2/7] 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 8c0dbca020c5495d844a4ce91fd6e67241c1221a Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 6 Jul 2026 23:12:01 +0100 Subject: [PATCH 3/7] 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 413dc24870b92bf5968cbbbe2c3a5d436a948795 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 7 Jul 2026 01:44:30 +0100 Subject: [PATCH 4/7] 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/AuthPickerContentView.swift | 1 + .../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 ++++++++++++++++++ 20 files changed, 252 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/AuthPickerContentView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContentView.swift index 6d1ea85b9e..7e41b21bf6 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContentView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContentView.swift @@ -54,6 +54,7 @@ public struct AuthPickerContentView: View { .scaleEffect(1.25) .tint(.white) Text("Authenticating...") + .authFont(.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 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 efc2c84fbff26c37dc36b209187bcfbb39765623 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 7 Jul 2026 14:13:27 +0100 Subject: [PATCH 5/7] Add customizable method-picker layout via AuthPickerContentView(authMethodPicker:), with non-breaking AuthProviderAction dispatch for non-credential providers --- .../Sources/Services/AuthService.swift | 28 +++++ .../Sources/Views/AuthPickerContentView.swift | 108 ++++++++++++++---- .../Sources/Views/AuthPickerView.swift | 3 +- .../Services/PhoneAuthProviderAuthUI.swift | 6 + 4 files changed, 124 insertions(+), 21 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 2145b6733e..6ef38f58f2 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -33,6 +33,14 @@ public protocol AuthProviderUI { var provider: AuthProviderSwift { get } } +/// Opt-in for providers whose sign-in doesn't produce a `CredentialAuthProviderSwift` credential +/// (e.g. phone, or a custom OAuth2 flow) — lets `AuthService.triggerSignIn(for:)` invoke their +/// action independent of rendering their button. Conforming to this is optional and doesn't +/// change `AuthProviderUI`'s existing requirements. +public protocol AuthProviderAction: AuthProviderUI { + @MainActor func triggerAction() async throws +} + public enum AuthenticationState { case unauthenticated case authenticating @@ -184,6 +192,26 @@ public final class AuthService { providers.append(providerWithButton) } + /// The providers registered via `registerProvider(providerWithButton:)`, in registration order. + /// Use this to build a custom method-picker layout (see `AuthPickerContentView.init(authMethodPicker:)`); + /// pair each provider with `triggerSignIn(for:)` to sign in without needing its default button view. + public var registeredProviders: [AuthProviderUI] { providers } + + /// Signs in with `provider`, independent of rendering its button — for credential-based + /// providers (Facebook/Google/Apple/Twitter/OAuth) this routes through the same `signIn(_:)` + /// their default button already calls; for providers conforming to `AuthProviderAction` + /// (e.g. phone) it invokes `triggerAction()` instead. Returns `nil` when the provider supports + /// neither path, or when its action doesn't produce a `SignInOutcome` (e.g. phone just + /// navigates to a code-entry screen). + public func triggerSignIn(for provider: AuthProviderUI) async throws -> SignInOutcome? { + if let credentialProvider = provider.provider as? CredentialAuthProviderSwift { + return try await signIn(credentialProvider) + } else if let actionable = provider as? AuthProviderAction { + try await actionable.triggerAction() + } + return nil + } + public func renderButtons(spacing: CGFloat = 16) -> AnyView { AnyView( VStack(spacing: spacing) { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContentView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContentView.swift index 7e41b21bf6..7367414585 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContentView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContentView.swift @@ -31,11 +31,45 @@ import SwiftUI /// .background(theme.colors.background) /// } /// ``` +/// +/// To customize the *layout* of the sign-in-method list itself (e.g. a grid of icon-only +/// buttons instead of the default stacked list), pass an `authMethodPicker` builder. It receives +/// the registered providers and a callback to invoke when one is selected — pair it with +/// `AuthService.triggerSignIn(for:)` if you build your own buttons, or use `onProviderSelected` +/// directly: +/// +/// ```swift +/// AuthPickerContentView { providers, onProviderSelected in +/// LazyVGrid(columns: [GridItem(), GridItem()]) { +/// ForEach(providers, id: \.id) { provider in +/// Button(provider.displayName) { onProviderSelected(provider) } +/// } +/// } +/// } +/// ``` @MainActor -public struct AuthPickerContentView: View { +public struct AuthPickerContentView: View { @Environment(AuthService.self) private var authService + @Environment(\.mfaHandler) private var mfaHandler + @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.reportError) private var reportError - public init() {} + private let authMethodPicker: ([AuthProviderUI], @escaping (AuthProviderUI) -> Void) -> AuthMethodPicker + + public init() where AuthMethodPicker == DefaultProviderButtonsLayout { + authMethodPicker = { providers, onSelect in + DefaultProviderButtonsLayout(providers: providers, onSelect: onSelect) + } + } + + public init( + @ViewBuilder authMethodPicker: @escaping ( + [AuthProviderUI], + @escaping (AuthProviderUI) -> Void + ) -> AuthMethodPicker + ) { + self.authMethodPicker = authMethodPicker + } public var body: some View { @Bindable var authService = authService @@ -43,7 +77,7 @@ public struct AuthPickerContentView: View { if authService.authenticationState == .authenticated { SignedInView() } else { - authMethodPicker + methodPickerScreen .safeAreaPadding() } } @@ -64,30 +98,64 @@ public struct AuthPickerContentView: View { } @ViewBuilder - private 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) + private var methodPickerScreen: some View { + ScrollView { + VStack(spacing: 24) { + Image(authService.configuration.logo ?? Assets.firebaseAuthLogo) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 100, height: 100) + if authService.emailPasswordSignInEnabled { + EmailAuthView() } + Divider() + authMethodPicker(authService.registeredProviders, handleProviderSelected) + PrivacyTOCsView(displayMode: .full) } } } - @ViewBuilder - private func otherSignInOptions(_ proxy: GeometryProxy) -> some View { + /// Signs in with `provider` via `AuthService.triggerSignIn(for:)`, then handles the same + /// MFA/account-conflict/error cases each provider's own default button already handles inline + /// (e.g. `GenericOAuthButton`) — so a custom `authMethodPicker` layout behaves equivalently to + /// the default one. + private func handleProviderSelected(_ provider: AuthProviderUI) { + Task { + do { + if let outcome = try await authService.triggerSignIn(for: provider), + case let .mfaRequired(mfaInfo) = outcome, + let onMFA = mfaHandler { + onMFA(mfaInfo) + } + } catch { + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return + } + reportError?(error) + } + } + } +} + +/// The default sign-in-method list layout: a `VStack` of each registered provider's own button +/// view, unchanged from what `AuthPickerContentView` has always shown. Uses +/// `.containerRelativeFrame(_:alignment:_:)` to reproduce the original +/// `.padding(.horizontal, proxy.size.width * 0.14)` behavior (72% of the container's width, +/// centered) without needing a `GeometryReader` — which would otherwise have to be exposed +/// through `authMethodPicker`'s signature just for this one layout's benefit, or nested inside +/// the `ScrollView`'s content where `GeometryReader`'s unbounded-height proposal causes sizing +/// bugs. +public struct DefaultProviderButtonsLayout: View { + @Environment(AuthService.self) private var authService + let providers: [AuthProviderUI] + let onSelect: (AuthProviderUI) -> Void + + public var body: some View { VStack { authService.renderButtons() } - .padding(.horizontal, proxy.size.width * 0.14) + .containerRelativeFrame(.horizontal) { length, _ in length * 0.72 } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 965ebb4033..7b33641350 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -20,7 +20,8 @@ import SwiftUI @MainActor public struct AuthPickerView { public init(@ViewBuilder content: @escaping () -> Content = { EmptyView() }) - where PickerContent == AuthPickerContentView, DestinationContent == AuthPickerDestinationView { + where PickerContent == AuthPickerContentView, + DestinationContent == AuthPickerDestinationView { self.content = content pickerContentOverride = { AuthPickerContentView() } pickerDestinationOverride = { AuthPickerDestinationView(screen: $0) } 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() + } +} From ce6968a599a6c0cd3a32bb52ceeddd7ac42d55db Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 8 Jul 2026 11:57:50 +0100 Subject: [PATCH 6/7] Add customized AuthPickerView sample with scrollable method picker --- .../Application/ContentView.swift | 24 +++++ .../CustomizedAuthPickerExample.swift | 102 ++++++++++++++++++ .../Examples/SpotlightMethodPicker.swift | 79 ++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/CustomizedAuthPickerExample.swift create mode 100644 samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/SpotlightMethodPicker.swift diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift index 722c4b46cc..66bfc85cf3 100644 --- a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift @@ -95,6 +95,30 @@ struct ContentView: View { } } .tint(Color(.label)) + NavigationLink { + CustomizedAuthPickerExample() + .navigationTitle("Customizing AuthPickerView") + } label: { + VStack(alignment: .leading, spacing: 16) { + Text("Customized AuthPickerView example") + .font(.headline) + .fontWeight(.bold) + Text("How to restyle and relayout the auth picker") + Text( + "• pickerContent/pickerDestination\n• Custom authMethodPicker layout\n• AuthTextFieldStyle/AuthCTAButtonStyle/AuthTypography" + ) + .font(.caption) + .foregroundColor(.secondary) + } + .multilineTextAlignment(.leading) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color(UIColor.secondarySystemBackground)) + } + } + .tint(Color(.label)) NavigationLink { CustomViewExample() .navigationTitle("Using AuthService") diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/CustomizedAuthPickerExample.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/CustomizedAuthPickerExample.swift new file mode 100644 index 0000000000..45412ad5f6 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/CustomizedAuthPickerExample.swift @@ -0,0 +1,102 @@ +// 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 FirebaseAuthSwiftUI +import FirebaseAuthUIComponents +import SwiftUI + +/// Demonstrates `AuthPickerView`'s full customization surface — `pickerContent`/ +/// `pickerDestination`, a custom `authMethodPicker` layout (see `SpotlightMethodPicker`), +/// `AuthTextFieldStyle`, `AuthCTAButtonStyle`, and `AuthTypography` — using fixed values instead +/// of an app-wide theme system, so each hook is easy to see in isolation. Swap the constants +/// below for values read from your own theme if you want them switchable at runtime. +struct CustomizedAuthPickerExample: View { + @Environment(AuthService.self) private var authService + + private let tint = Color.orange + private let background = Color(red: 42 / 255, green: 1 / 255, blue: 52 / 255) + private let container = Color(.secondarySystemBackground) + private let secondary = Color.gray + private let error = Color.red + + var body: some View { + AuthPickerView { + authenticatedApp + } + .pickerContent { + AuthPickerContentView { providers, onProviderSelected in + SpotlightMethodPicker(providers: providers, onProviderSelected: onProviderSelected) + } + .tint(tint) + .background(background) + } + .pickerDestination { screen in + AuthPickerDestinationView(screen: screen) + .tint(tint) + .background(background) + } + .authTextFieldStyle( + AuthTextFieldStyle( + tint: tint, + containerColor: container, + secondaryColor: secondary, + errorColor: error + ) + ) + .authTypography( + AuthTypography(fontFamily: "AmericanTypewriter") + ) + .authCTAButtonStyle( + AuthCTAButtonStyle(backgroundColor: tint, contentColor: .white, shape: .capsule) + ) + } + + var authenticatedApp: some View { + NavigationStack { + VStack { + if authService.authenticationState == .unauthenticated { + Text("Not Authenticated") + Button { + authService.isPresented = true + } label: { + Text("Authenticate") + } + .buttonStyle(.bordered) + } else { + Text("Authenticated - \(authService.currentUser?.email ?? "")") + Button { + authService.isPresented = true // Reopen the sheet + } label: { + Text("Manage Account") + } + .buttonStyle(.bordered) + Button { + Task { + try? await authService.signOut() + } + } label: { + Text("Sign Out") + } + .buttonStyle(.borderedProminent) + } + } + } + } +} + +#Preview { + CustomizedAuthPickerExample() + .environment(AuthService().withEmailSignIn()) +} diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/SpotlightMethodPicker.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/SpotlightMethodPicker.swift new file mode 100644 index 0000000000..deee7c6faa --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/SpotlightMethodPicker.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 FirebaseAuthSwiftUI +import FirebaseAuthUIComponents +import SwiftUI + +/// A custom sign-in-method layout — a horizontally scrollable row of icon buttons — +/// demonstrating `AuthPickerContentView.init(authMethodPicker:)` in place of the default +/// stacked button list. +struct SpotlightMethodPicker: View { + let providers: [AuthProviderUI] + let onProviderSelected: (AuthProviderUI) -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 20) { + ForEach(providers, id: \.id) { provider in + let style = styleForProvider(provider) + Button { + onProviderSelected(provider) + } label: { + VStack(spacing: 8) { + Circle() + .fill(style.backgroundColor) + .frame(width: 56, height: 56) + .overlay { + if let icon = style.icon { + icon + .resizable() + .scaledToFit() + .frame(width: 26, height: 26) + .foregroundStyle(style.contentColor) + } else { + Text(provider.displayName.prefix(1)) + .foregroundStyle(style.contentColor) + } + } + Text(provider.displayName) + .authFont(.body) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("spotlight-provider-\(provider.id)") + } + } + .padding(.horizontal, 32) + } + } + + /// Maps each registered provider to its brand `ProviderStyle` — mirrors what + /// `AuthService.renderButtons()` already does internally for the default layout, since a + /// custom `authMethodPicker` builds its own buttons instead of reusing each provider's + /// wrapper view. + private func styleForProvider(_ provider: AuthProviderUI) -> ProviderStyle { + switch provider.id { + case "apple.com": .apple + case "google.com": .google + case "facebook.com": .facebook + case "twitter.com": .twitter + case "github.com": .github + case "microsoft.com": .microsoft + case "yahoo.com": .yahoo + case "phone": .phone + default: .empty + } + } +} From 74bc68c537184247b6f37b8959d76621fec06bf8 Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 8 Jul 2026 12:02:50 +0100 Subject: [PATCH 7/7] chore: cleanup --- .../FirebaseAuthSwiftUI/Sources/Services/AuthService.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 6ef38f58f2..9e3fe1f845 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -192,9 +192,6 @@ public final class AuthService { providers.append(providerWithButton) } - /// The providers registered via `registerProvider(providerWithButton:)`, in registration order. - /// Use this to build a custom method-picker layout (see `AuthPickerContentView.init(authMethodPicker:)`); - /// pair each provider with `triggerSignIn(for:)` to sign in without needing its default button view. public var registeredProviders: [AuthProviderUI] { providers } /// Signs in with `provider`, independent of rendering its button — for credential-based