diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Exports.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Exports.swift new file mode 100644 index 00000000000..fdc5d9c91e9 --- /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 diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift new file mode 100644 index 00000000000..568958e8e5a --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.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 + +/// Styling configuration for the primary call-to-action buttons used throughout the auth flow +/// (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(backgroundColor: Color? = nil, + contentColor: Color? = nil, + shape: ButtonBorderShape? = nil, + font: Font? = nil) { + self.backgroundColor = backgroundColor + self.contentColor = contentColor + 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 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 { + 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.authFont(.body) + } + } +} + +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 + /// AuthPickerView { ... } + /// .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/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 8454c79d8b0..220ee80adc5 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/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index fc6616a8f8b..5f591995739 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) + .authFont(.body, weight: .medium) .frame(maxWidth: .infinity, alignment: .trailing) } .accessibilityIdentifier("password-recovery-button") @@ -205,7 +206,7 @@ extension EmailAuthView: View { .disabled(!isValid) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .accessibilityIdentifier("sign-in-button") } Button(action: { @@ -222,14 +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(.semibold) - .foregroundColor(.blue) + .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 f151cda4311..04f5ab8c541 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/EmailLinkView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift index 6bdfe6812ad..c64c86e5701 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 8d61630ce47..34a3fb497b1 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) @@ -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 95152335ca3..54e41c1fa89 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) @@ -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 2a1da9d4063..3a644e713c3 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) } } @@ -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/LegacySignInRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/LegacySignInRecoveryView.swift index 11c1d353675..d49a600037a 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 75b13b47657..c5928223f8b 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) } @@ -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) @@ -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) } @@ -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) @@ -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) } @@ -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) @@ -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) } @@ -635,7 +636,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 eeeddb45b08..2d337dd2c85 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) @@ -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") @@ -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 @@ -128,7 +128,7 @@ extension MFAManagementView: View { } .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .accessibilityIdentifier("add-mfa-method-button") } } @@ -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 f6e88f94ea0..722b4ee8250 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift @@ -13,14 +13,10 @@ // limitations under the License. import FirebaseAuth +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI -private enum FocusableField: Hashable { - case verificationCode - case totpCode -} - @MainActor public struct MFAResolutionView { let mfaRequired: MFARequired @@ -34,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 } @@ -114,12 +108,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) } @@ -146,24 +140,21 @@ extension MFAResolutionView: View { } Text("Complete Sign-In") } + .padding(.vertical, 8) .frame(maxWidth: .infinity) - .padding() - .background(canCompleteResolution ? Color.blue : Color.gray) - .foregroundColor(.white) - .cornerRadius(8) } + .authCTAButtonStyle() .disabled(!canCompleteResolution) .accessibilityIdentifier("complete-resolution-button") // Cancel Button 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) @@ -190,16 +181,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) } } @@ -215,12 +206,10 @@ extension MFAResolutionView: View { } Text("Send Code") } + .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") @@ -228,13 +217,14 @@ extension MFAResolutionView: View { // Verification code input VStack(alignment: .leading, spacing: 8) { Text("Verification Code") - .font(.headline) - - TextField("Enter 6-digit code", text: $verificationCode) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .keyboardType(.numberPad) - .focused($focus, equals: .verificationCode) - .accessibilityIdentifier("sms-verification-code-field") + .authFont(.headline) + + VerificationCodeInputField( + code: $verificationCode, + validations: [FormValidators.verificationCode], + maintainsValidationMessage: true + ) + .accessibilityIdentifier("sms-verification-code-field") } .padding(.horizontal) } @@ -250,17 +240,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,13 +259,14 @@ 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()) - .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) } @@ -285,7 +276,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 +302,7 @@ extension MFAResolutionView: View { VStack(alignment: .leading) { Text(hintDisplayName(for: hint)) - .font(.body) + .authFont(.body) .foregroundColor(.primary) hintSubtitle(for: hint) @@ -345,7 +336,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 af14a1c1676..7e28ea2dee1 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) @@ -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 10821e9b7c9..deed8e5799d 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) @@ -127,7 +127,7 @@ extension PhoneReauthView: View { .frame(maxWidth: .infinity) } } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(isLoading) .accessibilityIdentifier("send-verification-code-button") Button(authService.string.cancelButtonLabel) { @@ -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) @@ -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/PrivacyTOCsView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift index f5e6bfb6381..e4dc34c74f7 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 e996ccc5b36..6e6da427f6c 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 { @@ -58,7 +59,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 +71,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 +84,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 +97,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 +119,7 @@ extension SignedInView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .accessibilityIdentifier("sign-out-button") @@ -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) @@ -195,6 +196,7 @@ private struct DeleteAccountConfirmationSheet: View { onConfirm() } label: { Text("Delete Account") + .authFont(.body) .padding(.vertical, 8) .frame(maxWidth: .infinity) } @@ -208,6 +210,7 @@ private struct DeleteAccountConfirmationSheet: View { onCancel() } label: { Text("Cancel") + .authFont(.body) .padding(.vertical, 8) .frame(maxWidth: .infinity) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift index 8480beb818b..9e6e3912c1b 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 8e967113990..0bc5627269f 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift @@ -15,6 +15,8 @@ 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 @@ -68,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) @@ -100,11 +103,12 @@ public struct AuthTextField: View { TextField( label, text: $text, - prompt: Text(prompt) + prompt: Text(prompt).font(typography.resolvedFont(for: .body)) ) .frame(height: 24) } } + .authFont(.body) } .frame(maxWidth: .infinity) .keyboardType(keyboardType) @@ -131,10 +135,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 { @@ -148,9 +156,9 @@ public struct AuthTextField: View { ForEach(validations) { validator in let isValid = validator.isValid(input: text) Text(validator.message) - .font(.caption) - .strikethrough(isValid, color: .gray) - .foregroundStyle(isValid ? .gray : .red) + .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 050d3c37cea..c908927d646 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 cc226a33c32..b9971494a0d 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 @@ -91,8 +92,8 @@ public struct VerificationCodeInputField: View { if isError, let errorMessage = errorMessage { Text(errorMessage) - .font(.caption) - .foregroundColor(.red) + .authFont(.caption) + .foregroundStyle(style.errorColor ?? .red) .frame(maxWidth: .infinity, alignment: .leading) } @@ -102,9 +103,9 @@ public struct VerificationCodeInputField: View { ForEach(validations) { validator in let isValid = validator.isValid(input: code) Text(validator.message) - .font(.caption) - .strikethrough(isValid, color: .gray) - .foregroundStyle(isValid ? .gray : .red) + .authFont(.caption) + .strikethrough(isValid, color: style.secondaryColor ?? .gray) + .foregroundStyle(isValid ? (style.secondaryColor ?? .gray) : (style.errorColor ?? .red)) .fixedSize(horizontal: false, vertical: true) } } @@ -293,6 +294,8 @@ 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 @@ -310,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 { @@ -330,8 +333,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 = .systemFont(ofSize: 24, weight: .medium) textField.textAlignment = .center textField.keyboardType = .numberPad textField.textContentType = .oneTimeCode @@ -344,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) ) ) @@ -367,6 +371,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 @@ -380,6 +385,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 { @@ -394,6 +400,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 } @@ -505,7 +514,7 @@ private final class BackspaceUITextField: UITextField { return VStack(spacing: 32) { Text("Enter Verification Code") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) VerificationCodeInputField( @@ -519,7 +528,7 @@ private final class BackspaceUITextField: UITextField { ) Text("Current code: \(code)") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } .padding() @@ -530,7 +539,7 @@ private final class BackspaceUITextField: UITextField { return VStack(spacing: 32) { Text("Enter Verification Code") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) VerificationCodeInputField( @@ -546,7 +555,7 @@ private final class BackspaceUITextField: UITextField { ) Text("Current code: \(code)") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } .padding() @@ -557,7 +566,7 @@ private final class BackspaceUITextField: UITextField { return VStack(spacing: 32) { Text("Enter 4-Digit Code") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) VerificationCodeInputField( @@ -572,7 +581,7 @@ private final class BackspaceUITextField: UITextField { ) Text("Current code: \(code)") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } .padding() diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTextFieldStyle.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTextFieldStyle.swift new file mode 100644 index 00000000000..6fc0dc405c5 --- /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) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift new file mode 100644 index 00000000000..adbf3dd7095 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift @@ -0,0 +1,110 @@ +// 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. `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 fontName: String? + + public init(fontName: String? = nil) { + self.fontName = fontName + } + + public static let `default` = AuthTypography() + + /// 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 fontName { + .custom( + fontName, + size: UIFont.preferredFont( + forTextStyle: style.uiKit, + compatibleWith: UITraitCollection(preferredContentSizeCategory: .large) + ).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 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(fontName: "Poppins-Regular")) + /// ``` + func authTypography(_ typography: AuthTypography) -> some View { + environment(\.authTypography, typography) + } +} + +struct AuthFontModifier: ViewModifier { + @Environment(\.authTypography) private var typography + let style: Font.TextStyle + var weight: Font.Weight? + + 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 + } + } +} diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift index 9024a19f83d..4f117bceb58 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")