diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift index 693491dbfed..d3c5c2151aa 100644 --- a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift @@ -13,6 +13,7 @@ // limitations under the License. import FirebaseAuthSwiftUI +import FirebaseAuthUIComponents import SwiftUI /// A button for signing in with Apple @@ -27,27 +28,14 @@ public struct SignInWithAppleButton { extension SignInWithAppleButton: View { public var body: some View { - Button(action: { + AuthProviderButton( + label: "Sign in with Apple", + style: .apple, + accessibilityId: "sign-in-with-apple-button" + ) { Task { try? await authService.signIn(provider) } - }) { - HStack { - Image(systemName: "apple.logo") - .resizable() - .renderingMode(.template) - .scaledToFit() - .frame(width: 24, height: 24) - .foregroundColor(.white) - Text("Sign in with Apple") - .fontWeight(.semibold) - .foregroundColor(.white) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .background(Color.black) - .cornerRadius(8) } - .accessibilityIdentifier("sign-in-with-apple-button") } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index b3a32d38d8d..8d2a75f75d6 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -13,6 +13,7 @@ // limitations under the License. @preconcurrency import FirebaseAuth +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -28,6 +29,7 @@ public protocol AuthProviderUI { public protocol PhoneAuthProviderSwift: AuthProviderSwift { @MainActor func verifyPhoneNumber(phoneNumber: String) async throws -> String + func setVerificationCode(verificationID: String, code: String) } public enum AuthenticationState { @@ -41,14 +43,15 @@ public enum AuthenticationFlow { case signUp } -public enum AuthView { - case authPicker +public enum AuthView: Hashable { case passwordRecovery case emailLink case updatePassword case mfaEnrollment case mfaManagement case mfaResolution + case enterPhoneNumber + case enterVerificationCode(verificationID: String, fullPhoneNumber: String) } public enum SignInOutcome: @unchecked Sendable { @@ -82,6 +85,24 @@ private final class AuthListenerManager { } } +@Observable +public class Navigator { + var routes: [AuthView] = [] + + public func push(_ route: AuthView) { + routes.append(route) + } + + @discardableResult + public func pop() -> AuthView? { + routes.popLast() + } + + public func clear() { + routes.removeAll() + } +} + @MainActor @Observable public final class AuthService { @@ -96,7 +117,16 @@ public final class AuthService { @ObservationIgnored @AppStorage("email-link") public var emailLink: String? public let configuration: AuthConfiguration public let auth: Auth - public var authView: AuthView = .authPicker + public var isPresented: Bool = false + public private(set) var navigator = Navigator() + public var authView: AuthView? { + navigator.routes.last + } + + var authViewRoutes: [AuthView] { + navigator.routes + } + public let string: StringUtils public var currentUser: User? public var authenticationState: AuthenticationState = .unauthenticated @@ -105,16 +135,19 @@ public final class AuthService { public let passwordPrompt: PasswordPromptCoordinator = .init() public var currentMFARequired: MFARequired? private var currentMFAResolver: MultiFactorResolver? - private var pendingMFACredential: AuthCredential? // MARK: - Provider APIs private var listenerManager: AuthListenerManager? - public var signedInCredential: AuthCredential? var emailSignInEnabled = false private var providers: [AuthProviderUI] = [] + + public var currentPhoneProvider: PhoneAuthProviderSwift? { + providers.compactMap { $0.provider as? PhoneAuthProviderSwift }.first + } + public func registerProvider(providerWithButton: AuthProviderUI) { providers.append(providerWithButton) } @@ -122,6 +155,13 @@ public final class AuthService { public func renderButtons(spacing: CGFloat = 16) -> AnyView { AnyView( VStack(spacing: spacing) { + AuthProviderButton( + label: string.signInWithEmailLinkViewTitle, + style: .email, + accessibilityId: "sign-in-with-email-link-button" + ) { + self.navigator.push(.emailLink) + } ForEach(providers, id: \.id) { provider in provider.authButton() } @@ -209,7 +249,6 @@ public final class AuthService { } do { let result = try await currentUser?.link(with: credentials) - signedInCredential = credentials updateAuthenticationState() return .signedIn(result) } catch let error as NSError { @@ -233,7 +272,6 @@ public final class AuthService { return try await handleAutoUpgradeAnonymousUser(credentials: credentials) } else { let result = try await auth.signIn(with: credentials) - signedInCredential = result.credential ?? credentials updateAuthenticationState() return .signedIn(result) } @@ -243,8 +281,6 @@ public final class AuthService { if error.code == AuthErrorCode.secondFactorRequired.rawValue { if let resolver = error .userInfo[AuthErrorUserInfoMultiFactorResolverKey] as? MultiFactorResolver { - // Preserve the original credential for use after MFA resolution - pendingMFACredential = credentials return handleMFARequiredError(resolver: resolver) } } else { @@ -333,7 +369,6 @@ public extension AuthService { return try await handleAutoUpgradeAnonymousUser(credentials: credential) } else { let result = try await auth.createUser(withEmail: email, password: password) - signedInCredential = result.credential updateAuthenticationState() return .signedIn(result) } @@ -710,12 +745,41 @@ public extension AuthService { } } - func reauthenticateCurrentUser(on user: User) async throws { - guard let providerId = signedInCredential?.provider else { - throw AuthServiceError - .reauthenticationRequired("Recent login required to perform this operation.") + /// Gets the provider ID that was used for the current sign-in session + private func getCurrentSignInProvider() async throws -> String { + guard let user = currentUser else { + throw AuthServiceError.noCurrentUser } + // Get the ID token result which contains the signInProvider claim + let tokenResult = try await user.getIDTokenResult(forcingRefresh: false) + + // The signInProvider property tells us which provider was used for this session + let signInProvider = tokenResult.signInProvider + + // If signInProvider is not empty, use it + if !signInProvider.isEmpty { + return signInProvider + } + + // Fallback: if signInProvider is empty, try to infer from providerData + // Prefer non-password providers as they're more specific + let providerId = user.providerData.first(where: { $0.providerID != "password" })?.providerID + ?? user.providerData.first?.providerID + + guard let providerId = providerId else { + throw AuthServiceError.reauthenticationRequired( + "Unable to determine sign-in provider for reauthentication" + ) + } + + return providerId + } + + func reauthenticateCurrentUser(on user: User) async throws { + // Get the provider from the token instead of stored credential + let providerId = try await getCurrentSignInProvider() + if providerId == EmailAuthProviderID { guard let email = user.email else { throw AuthServiceError.invalidCredentials("User does not have an email address") @@ -797,7 +861,7 @@ public extension AuthService { let hints = extractMFAHints(from: resolver) currentMFARequired = MFARequired(hints: hints) currentMFAResolver = resolver - authView = .mfaResolution + navigator.push(.mfaResolution) return .mfaRequired(MFARequired(hints: hints)) } @@ -877,16 +941,11 @@ public extension AuthService { do { let result = try await resolver.resolveSignIn(with: assertion) - - // After MFA resolution, result.credential is nil, so restore the original credential - // that was used before MFA was triggered - signedInCredential = result.credential ?? pendingMFACredential updateAuthenticationState() // Clear MFA resolution state currentMFARequired = nil currentMFAResolver = nil - pendingMFACredential = nil } catch { throw AuthServiceError diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Strings/Localizable.xcstrings b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Strings/Localizable.xcstrings index a9b077b8839..7d686924a67 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Strings/Localizable.xcstrings +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Strings/Localizable.xcstrings @@ -3,6 +3,12 @@ "strings" : { "%@" : { + }, + "••••••%@" : { + + }, + "Account: %@" : { + }, "AccountDisabledError" : { "comment" : "Error message displayed when the account is disabled. Use short/abbreviated translation for 'email' which is less than 15 chars.", @@ -27,6 +33,12 @@ } } } + }, + "Add an extra layer of security to your account" : { + + }, + "Add Another Method" : { + }, "AddPasswordAlertMessage" : { "comment" : "Alert message shown when adding account password.", @@ -162,6 +174,12 @@ } } } + }, + "Authentication Method" : { + + }, + "Authenticator App" : { + }, "AuthPickerTitle" : { "comment" : "Title for auth picker screen.", @@ -170,7 +188,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Welcome" + "value" : "Sign in with Firebase" } } } @@ -213,6 +231,15 @@ } } } + }, + "Change number" : { + + }, + "Choose Authentication Method" : { + + }, + "Choose verification method:" : { + }, "ChoosePassword" : { "comment" : "Placeholder for the password text field in a sign up form.", @@ -237,6 +264,15 @@ } } } + }, + "Complete Setup" : { + + }, + "Complete Sign-In" : { + + }, + "Complete sign-in with your second factor" : { + }, "ConfirmEmail" : { "comment" : "Title of confirm email label.", @@ -261,6 +297,9 @@ } } } + }, + "Copied to clipboard!" : { + }, "Delete" : { "comment" : "Text of Delete action button.", @@ -273,6 +312,12 @@ } } } + }, + "Delete Account" : { + + }, + "Delete Account?" : { + }, "DeleteAccountBody" : { "comment" : "Alert message body shown to confirm account deletion action.", @@ -321,6 +366,9 @@ } } } + }, + "Display Name" : { + }, "Don't have an account yet?" : { "localizations" : { @@ -439,6 +487,24 @@ } } } + }, + "Enrolled Methods" : { + + }, + "Enrolled: %@" : { + + }, + "Enter 6-digit code" : { + + }, + "Enter the 6-digit code from your authenticator app" : { + + }, + "Enter Verification Code" : { + + }, + "Enter Your Phone Number" : { + }, "EnterYourEmail" : { "comment" : "Title for email entry screen, email text field placeholder. Use short/abbreviated translation for 'email' which is less than 15 chars.", @@ -507,7 +573,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Forgot password?" + "value" : "Send password recovery email" } } } @@ -523,6 +589,9 @@ } } } + }, + "Get Started" : { + }, "InvalidEmailError" : { "comment" : "Error message displayed when user enters an invalid email address. Use short/abbreviated translation for 'email' which is less than 15 chars.", @@ -557,6 +626,21 @@ } } } + }, + "Manage Two-Factor Authentication" : { + + }, + "Manage your authentication methods" : { + + }, + "Manual Entry Key:" : { + + }, + "MFA is not enabled in the current configuration. Please contact your administrator." : { + + }, + "Multi-Factor Authentication Disabled" : { + }, "Name" : { "comment" : "Label next to a name text field.", @@ -581,6 +665,15 @@ } } } + }, + "No Authentication Methods" : { + + }, + "No Authentication Methods Available" : { + + }, + "No MFA methods are configured as allowed. Please contact your administrator." : { + }, "OK" : { "comment" : "OK button title.", @@ -653,6 +746,9 @@ } } } + }, + "Phone Number" : { + }, "PlaceholderChosePassword" : { "comment" : "Placeholder of secret input cell when user changes password.", @@ -797,6 +893,9 @@ } } } + }, + "Remove" : { + }, "Resend" : { "comment" : "Resend button title.", @@ -809,6 +908,9 @@ } } } + }, + "Resend Code" : { + }, "Save" : { "comment" : "Save button title.", @@ -821,6 +923,12 @@ } } } + }, + "Scan QR Code" : { + + }, + "Scan with your authenticator app or tap to open directly" : { + }, "Send" : { "comment" : "Send button title.", @@ -833,6 +941,9 @@ } } } + }, + "Send Code" : { + }, "SendEmailSignInLinkButtonLabel" : { "comment" : "Button label for sending email sign-in link", @@ -845,6 +956,12 @@ } } } + }, + "Set Up Two-Factor Authentication" : { + + }, + "Set up two-factor authentication to add an extra layer of security to your account." : { + }, "Sign up" : { @@ -944,6 +1061,18 @@ } } } + }, + "SMS Authentication" : { + + }, + "SMS Verification" : { + + }, + "SMS: %@" : { + + }, + "Tap to open in authenticator app" : { + }, "TermsOfService" : { "comment" : "Text linked to a web page with the Terms of Service content.", @@ -968,6 +1097,9 @@ } } } + }, + "This action cannot be undone. All your data will be permanently deleted. You may need to reauthenticate to complete this action." : { + }, "TroubleGettingEmailMessage" : { "comment" : "Alert message displayed when user having trouble getting email.", @@ -992,6 +1124,12 @@ } } } + }, + "Two-Factor Authentication" : { + + }, + "Unable to generate QR Code" : { + }, "UnlinkAction" : { "comment" : "Button title for unlinking account action.", @@ -1087,6 +1225,9 @@ } } } + }, + "Use an authenticator app like Google Authenticator or Authy to generate verification codes." : { + }, "UserNotFoundError" : { "comment" : "Error message displayed when there's no account matching the email address. Use short/abbreviated translation for 'email' which is less than 15 chars.", @@ -1099,6 +1240,9 @@ } } } + }, + "Verification Code" : { + }, "Verify email address?" : { "comment" : "Label for sending email verification to user.", @@ -1122,6 +1266,21 @@ } } } + }, + "We sent a code to %@" : { + + }, + "We'll send a code to ••••••%@" : { + + }, + "We'll send a verification code to this number" : { + + }, + "We'll send a verification code to your phone" : { + + }, + "We'll send a verification code to your phone number each time you sign in." : { + }, "WeakPasswordError" : { "comment" : "Error message displayed when the password is too weak.", diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Utils/PhoneUtils.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/PhoneUtils.swift similarity index 100% rename from FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Utils/PhoneUtils.swift rename to FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/PhoneUtils.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/StringUtils.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/StringUtils.swift index f3038550969..8b26f1f34c5 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/StringUtils.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/StringUtils.swift @@ -217,14 +217,14 @@ public class StringUtils { /// Account settings - send email verification label /// found in: - /// VerifyEmailView + /// SignedInView public var sendEmailVerificationButtonLabel: String { return localizedString(for: "Verify email address?") } /// Account settings - verify email sheet message /// found in: - /// VerifyEmailView + /// SignedInView public var verifyEmailSheetMessage: String { return localizedString(for: "Verification email sent") } @@ -323,6 +323,55 @@ public class StringUtils { return localizedString(for: "Enter phone number") } + /// Phone provider + /// found in: + /// - PhoneAuthView + public var phoneSignInTitle: String { + return localizedString(for: "Sign in with Phone") + } + + /// Phone provider + /// found in: + /// - PhoneAuthView + public var enterPhoneNumberPlaceholder: String { + return localizedString(for: "Enter phone number") + } + + /// Phone provider + /// found in: + /// - PhoneAuthView + public var sendCodeButtonLabel: String { + return localizedString(for: "Send Code") + } + + /// Phone provider + /// found in: + /// - PhoneAuthView + public var processingLabel: String { + return localizedString(for: "Processing...") + } + + /// Phone provider + /// found in: + /// - PhoneAuthView + public var enterVerificationCodeTitle: String { + return localizedString(for: "Enter Verification Code") + } + + /// Phone provider + /// found in: + /// - PhoneAuthView + public var verificationCodePlaceholder: String { + return localizedString(for: "Verification Code") + } + + /// Phone provider + /// found in: + /// - PhoneAuthView + public var verifyAndSignInButtonLabel: String { + return localizedString(for: "Verify and Sign In") + } + /// Phone provider /// found in: /// - PhoneAuthButtonView diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Version.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Version.swift index f7da298cdbc..3b7cd58954f 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Version.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Version.swift @@ -16,4 +16,4 @@ public enum FirebaseAuthSwiftUIVersion { // Use the release-swift.sh script to bump this version number, commit and push a new tag. public static let version = "15.1.0" -} \ No newline at end of file +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 20220a0d5b4..e8266cc1362 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -12,95 +12,127 @@ // See the License for the specific language governing permissions and // limitations under the License. +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor -public struct AuthPickerView { - @Environment(AuthService.self) private var authService - - public init() {} - - private func switchFlow() { - authService.authenticationFlow = authService - .authenticationFlow == .signIn ? .signUp : .signIn +public struct AuthPickerView { + public init(@ViewBuilder content: @escaping () -> Content = { EmptyView() }) { + self.content = content } - @ViewBuilder - private var authPickerTitleView: some View { - if authService.authView == .authPicker { - Text(authService.string.authPickerTitle) - .font(.largeTitle) - .fontWeight(.bold) - .padding() - } - } + @Environment(AuthService.self) private var authService + private let content: () -> Content } extension AuthPickerView: View { public var body: some View { - ScrollView { - VStack { - authPickerTitleView - if authService.authenticationState == .authenticated { - switch authService.authView { - case .mfaEnrollment: - MFAEnrolmentView() - case .mfaManagement: - MFAManagementView() - default: - SignedInView() - } - } else { - switch authService.authView { - case .passwordRecovery: - PasswordRecoveryView() - case .emailLink: - EmailLinkView() - case .mfaEnrollment: - MFAEnrolmentView() - case .mfaResolution: - MFAResolutionView() - case .authPicker: - if authService.emailSignInEnabled { - Text(authService.authenticationFlow == .signIn ? authService.string - .emailLoginFlowLabel : authService.string.emailSignUpFlowLabel) - Divider() - EmailAuthView() + @Bindable var authService = authService + content() + .sheet(isPresented: $authService.isPresented) { + @Bindable var navigator = authService.navigator + NavigationStack(path: $navigator.routes) { + authPickerViewInternal + .navigationTitle(authService.authenticationState == .unauthenticated ? authService + .string.authPickerTitle : "") + .navigationBarTitleDisplayMode(.large) + .toolbar { + toolbar } - VStack { - authService.renderButtons() - }.padding(.horizontal) - if authService.emailSignInEnabled { - Divider() - HStack { - Text(authService - .authenticationFlow == .signIn ? authService.string.dontHaveAnAccountYetLabel : - authService.string.alreadyHaveAnAccountLabel) - Button(action: { - withAnimation { - switchFlow() - } - }) { - Text(authService.authenticationFlow == .signUp ? authService.string - .emailLoginFlowLabel : authService.string.emailSignUpFlowLabel) - .fontWeight(.semibold) - .foregroundColor(.blue) - }.accessibilityIdentifier("switch-auth-flow") + .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 AuthView.mfaResolution: + MFAResolutionView() + case AuthView.enterPhoneNumber: + if let phoneProvider = authService.currentPhoneProvider { + EnterPhoneNumberView(phoneProvider: phoneProvider) + } else { + EmptyView() + } + case let .enterVerificationCode(verificationID, fullPhoneNumber): + if let phoneProvider = authService.currentPhoneProvider { + EnterVerificationCodeView( + verificationID: verificationID, + fullPhoneNumber: fullPhoneNumber, + phoneProvider: phoneProvider + ) + } else { + EmptyView() + } } } - PrivacyTOCsView(displayMode: .footer) - default: - // TODO: - possibly refactor this, see: https://github.com/firebase/FirebaseUI-iOS/pull/1259#discussion_r2105473437 - EmptyView() + } + .interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled) + } + } + + @ToolbarContentBuilder + var toolbar: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + if !authService.configuration.shouldHideCancelButton { + Button { + authService.isPresented = false + } label: { + Image(systemName: "xmark") + } + } + } + } + + @ViewBuilder + var authPickerViewInternal: some View { + @Bindable var authService = authService + VStack { + if authService.authenticationState == .unauthenticated { + authMethodPicker + .safeAreaPadding() + } else { + SignedInView() + } + } + .errorAlert( + error: $authService.currentError, + okButtonLabel: authService.string.okButtonLabel + ) + } + + @ViewBuilder + var authMethodPicker: some View { + GeometryReader { proxy in + ScrollView { + VStack(spacing: 24) { + Image(Assets.firebaseAuthLogo) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 100, height: 100) + if authService.emailSignInEnabled { + EmailAuthView() } + Divider() + otherSignInOptions(proxy) + PrivacyTOCsView(displayMode: .full) } } } - .errorAlert(error: Binding( - get: { authService.currentError }, - set: { authService.currentError = $0 } - ), okButtonLabel: authService.string.okButtonLabel) + } + + @ViewBuilder + func otherSignInOptions(_ proxy: GeometryProxy) -> some View { + VStack { + authService.renderButtons() + } + .padding(.horizontal, proxy.size.width * 0.18) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index c11f506f6a7..58c88e89de9 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -19,6 +19,7 @@ // Created by Russell Wheatley on 20/03/2025. // import FirebaseAuth +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -59,79 +60,85 @@ public struct EmailAuthView { extension EmailAuthView: View { public var body: some View { - VStack { - LabeledContent { - TextField(authService.string.emailInputLabel, text: $email) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .focused($focus, equals: .email) - .submitLabel(.next) - .onSubmit { - self.focus = .password - } - } label: { - Image(systemName: "at") - } - .padding(.vertical, 6) - .background(Divider(), alignment: .bottom) - .padding(.bottom, 4) + VStack(spacing: 16) { + AuthTextField( + text: $email, + localizedTitle: "Email", + prompt: authService.string.emailInputLabel, + keyboardType: .emailAddress, + contentType: .emailAddress, + onSubmit: { _ in + self.focus = .password + }, + leading: { + Image(systemName: "at") + } + ) + .focused($focus, equals: .email) .accessibilityIdentifier("email-field") - - LabeledContent { - SecureField(authService.string.passwordInputLabel, text: $password) - .focused($focus, equals: .password) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .submitLabel(.go) - .onSubmit { - Task { await signInWithEmailPassword() } - } - } label: { - Image(systemName: "lock") - } - .padding(.vertical, 6) - .background(Divider(), alignment: .bottom) - .padding(.bottom, 8) + AuthTextField( + text: $password, + localizedTitle: "Password", + prompt: authService.string.passwordInputLabel, + contentType: .password, + sensitive: true, + onSubmit: { _ in + Task { await signInWithEmailPassword() } + }, + leading: { + Image(systemName: "lock") + } + ) + .submitLabel(.go) + .focused($focus, equals: .password) .accessibilityIdentifier("password-field") - if authService.authenticationFlow == .signIn { - Button(action: { - authService.authView = .passwordRecovery - }) { + Button { + authService.navigator.push(.passwordRecovery) + } label: { Text(authService.string.passwordButtonLabel) - }.accessibilityIdentifier("password-recovery-button") + .frame(maxWidth: .infinity, alignment: .trailing) + } + .accessibilityIdentifier("password-recovery-button") } if authService.authenticationFlow == .signUp { - LabeledContent { - SecureField(authService.string.confirmPasswordInputLabel, text: $confirmPassword) - .focused($focus, equals: .confirmPassword) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .submitLabel(.go) - .onSubmit { - Task { await createUserWithEmailPassword() } - } - } label: { - Image(systemName: "lock") - } - .padding(.vertical, 6) - .background(Divider(), alignment: .bottom) - .padding(.bottom, 8) + AuthTextField( + text: $confirmPassword, + localizedTitle: "Confirm Password", + prompt: authService.string.confirmPasswordInputLabel, + contentType: .password, + sensitive: true, + onSubmit: { _ in + Task { await createUserWithEmailPassword() } + }, + leading: { + Image(systemName: "lock") + } + ) + .submitLabel(.go) + .focused($focus, equals: .confirmPassword) .accessibilityIdentifier("confirm-password-field") } Button(action: { Task { - if authService.authenticationFlow == .signIn { await signInWithEmailPassword() } - else { await createUserWithEmailPassword() } + if authService.authenticationFlow == .signIn { + await signInWithEmailPassword() + } else { + await createUserWithEmailPassword() + } } }) { if authService.authenticationState != .authenticating { - Text(authService.authenticationFlow == .signIn ? authService.string - .signInWithEmailButtonLabel : authService.string.signUpWithEmailButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) + Text( + authService.authenticationFlow == .signIn + ? authService.string + .signInWithEmailButtonLabel + : authService.string.signUpWithEmailButtonLabel + ) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) } else { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: .white)) @@ -140,15 +147,35 @@ extension EmailAuthView: View { } } .disabled(!isValid) - .padding([.top, .bottom, .horizontal], 8) + .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .buttonStyle(.borderedProminent) .accessibilityIdentifier("sign-in-button") + } + HStack { + Text( + authService + .authenticationFlow == .signIn + ? authService.string.dontHaveAnAccountYetLabel + : authService.string.alreadyHaveAnAccountLabel + ) Button(action: { - authService.authView = .emailLink + withAnimation { + authService.authenticationFlow = + authService + .authenticationFlow == .signIn ? .signUp : .signIn + } }) { - Text(authService.string.signUpWithEmailLinkButtonLabel) - }.accessibilityIdentifier("sign-in-with-email-link-button") + Text( + authService.authenticationFlow == .signUp + ? authService.string + .emailLoginFlowLabel + : authService.string.emailSignUpFlowLabel + ) + .fontWeight(.semibold) + .foregroundColor(.blue) + } + .accessibilityIdentifier("switch-auth-flow") } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift index 87b761696d7..c86c33d64f8 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift @@ -13,6 +13,7 @@ // limitations under the License. import FirebaseAuth +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -35,63 +36,53 @@ public struct EmailLinkView { extension EmailLinkView: View { public var body: some View { - VStack { - HStack { - Button(action: { - authService.authView = .authPicker - }) { - HStack(spacing: 4) { - Image(systemName: "chevron.left") - .font(.system(size: 17, weight: .medium)) - Text(authService.string.backButtonLabel) - .font(.system(size: 17)) - } - .foregroundColor(.blue) + VStack(spacing: 24) { + AuthTextField( + text: $email, + localizedTitle: "Send a sign-in link to your email", + prompt: authService.string.emailInputLabel, + keyboardType: .emailAddress, + contentType: .emailAddress, + leading: { + Image(systemName: "at") } - .accessibilityIdentifier("email-link-back-button") - - Spacer() - } - .padding(.horizontal) - .padding(.top, 8) - Text(authService.string.signInWithEmailLinkViewTitle) - .accessibilityIdentifier("email-link-title-text") - LabeledContent { - TextField(authService.string.emailInputLabel, text: $email) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .submitLabel(.next) - } label: { - Image(systemName: "at") - }.padding(.vertical, 6) - .background(Divider(), alignment: .bottom) - .padding(.bottom, 4) - Button(action: { + ) + Button { Task { await sendEmailLink() authService.emailLink = email } - }) { + } label: { Text(authService.string.sendEmailLinkButtonLabel) .padding(.vertical, 8) .frame(maxWidth: .infinity) } + .buttonStyle(.borderedProminent) .disabled(!CommonUtils.isValidEmail(email)) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) - Spacer() } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle(authService.string.signInWithEmailLinkViewTitle) + .safeAreaPadding() .sheet(isPresented: $showModal) { - VStack { + VStack(spacing: 24) { Text(authService.string.signInWithEmailLinkViewMessage) - .padding() - Button(authService.string.okButtonLabel) { + .font(.headline) + Button { showModal = false + } label: { + Text(authService.string.okButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) } - .padding() + .buttonStyle(.borderedProminent) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) } - .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .safeAreaPadding() + .presentationDetents([.medium]) } .onOpenURL { url in Task { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift new file mode 100644 index 00000000000..757e588f260 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift @@ -0,0 +1,113 @@ +// 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 + +@MainActor +struct EnterPhoneNumberView: View { + @Environment(AuthService.self) private var authService + @State private var phoneNumber: String = "" + @State private var selectedCountry: CountryData = .default + @State private var currentError: AlertError? = nil + @State private var isProcessing: Bool = false + + let phoneProvider: PhoneAuthProviderSwift + + var body: some View { + VStack(spacing: 16) { + Text(authService.string.enterPhoneNumberPlaceholder) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top) + + AuthTextField( + text: $phoneNumber, + localizedTitle: "Phone", + prompt: authService.string.enterPhoneNumberPlaceholder, + keyboardType: .phonePad, + contentType: .telephoneNumber, + onChange: { _ in } + ) { + CountrySelector( + selectedCountry: $selectedCountry, + enabled: !isProcessing + ) + } + + Button(action: { + Task { + isProcessing = true + do { + let fullPhoneNumber = selectedCountry.dialCode + phoneNumber + let id = try await phoneProvider.verifyPhoneNumber(phoneNumber: fullPhoneNumber) + authService.navigator.push(.enterVerificationCode( + verificationID: id, + fullPhoneNumber: fullPhoneNumber + )) + currentError = nil + } catch { + currentError = AlertError(message: error.localizedDescription) + } + isProcessing = false + } + }) { + if isProcessing { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.sendCodeButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(isProcessing || phoneNumber.isEmpty) + .padding(.top, 8) + + Spacer() + } + .navigationTitle(authService.string.phoneSignInTitle) + .padding(.horizontal) + .errorAlert(error: $currentError, okButtonLabel: authService.string.okButtonLabel) + } +} + +#Preview { + FirebaseOptions.dummyConfigurationForPreview() + + class MockPhoneProvider: PhoneAuthProviderSwift { + var id: String = "phone" + + func verifyPhoneNumber(phoneNumber _: String) async throws -> String { + return "mock-verification-id" + } + + func setVerificationCode(verificationID _: String, code _: String) { + // Mock implementation + } + + func createAuthCredential() async throws -> AuthCredential { + fatalError("Not implemented in preview") + } + } + + return EnterPhoneNumberView(phoneProvider: MockPhoneProvider()) + .environment(AuthService()) +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift new file mode 100644 index 00000000000..54e4f996fb9 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift @@ -0,0 +1,127 @@ +// 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 + +@MainActor +struct EnterVerificationCodeView: View { + @Environment(AuthService.self) private var authService + @Environment(\.dismiss) private var dismiss + @State private var verificationCode: String = "" + @State private var currentError: AlertError? = nil + @State private var isProcessing: Bool = false + + let verificationID: String + let fullPhoneNumber: String + let phoneProvider: PhoneAuthProviderSwift + + var body: some View { + VStack(spacing: 32) { + VStack(spacing: 16) { + VStack(spacing: 8) { + Text("We sent a code to \(fullPhoneNumber)") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + authService.navigator.pop() + } label: { + Text("Change number") + .font(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.bottom) + .frame(maxWidth: .infinity, alignment: .leading) + + VerificationCodeInputField( + code: $verificationCode, + isError: currentError != nil, + errorMessage: currentError?.message + ) + + Button(action: { + Task { + isProcessing = true + do { + phoneProvider.setVerificationCode( + verificationID: verificationID, + code: verificationCode + ) + let credential = try await phoneProvider.createAuthCredential() + + _ = try await authService.signIn(credentials: credential) + dismiss() + } catch { + currentError = AlertError(message: error.localizedDescription) + isProcessing = false + } + } + }) { + if isProcessing { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.verifyAndSignInButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(isProcessing || verificationCode.count != 6) + } + + Spacer() + } + .navigationTitle(authService.string.enterVerificationCodeTitle) + .navigationBarTitleDisplayMode(.inline) + .padding(.horizontal) + .errorAlert(error: $currentError, okButtonLabel: authService.string.okButtonLabel) + } +} + +#Preview { + FirebaseOptions.dummyConfigurationForPreview() + + class MockPhoneProvider: PhoneAuthProviderSwift { + var id: String = "phone" + + func verifyPhoneNumber(phoneNumber _: String) async throws -> String { + return "mock-verification-id" + } + + func setVerificationCode(verificationID _: String, code _: String) { + // Mock implementation + } + + func createAuthCredential() async throws -> AuthCredential { + fatalError("Not implemented in preview") + } + } + + return NavigationStack { + EnterVerificationCodeView( + verificationID: "mock-id", + fullPhoneNumber: "+1 5551234567", + phoneProvider: MockPhoneProvider(), + ) + .environment(AuthService()) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift index 0f547564255..f53da9da728 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift @@ -13,6 +13,7 @@ // limitations under the License. import FirebaseAuth +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -28,6 +29,7 @@ public struct MFAEnrolmentView { @State private var selectedFactorType: SecondFactorType = .sms @State private var phoneNumber = "" + @State private var selectedCountry: CountryData = .default @State private var verificationCode = "" @State private var totpCode = "" @State private var currentSession: EnrollmentSession? @@ -95,9 +97,10 @@ public struct MFAEnrolmentView { isLoading = true defer { isLoading = false } + let fullPhoneNumber = selectedCountry.dialCode + phoneNumber let verificationId = try await authService.sendSmsVerificationForEnrollment( session: session, - phoneNumber: phoneNumber + phoneNumber: fullPhoneNumber ) // Update session status currentSession = EnrollmentSession( @@ -105,7 +108,7 @@ public struct MFAEnrolmentView { type: session.type, session: session.session, totpInfo: session.totpInfo, - phoneNumber: phoneNumber, + phoneNumber: fullPhoneNumber, verificationId: verificationId, status: .verificationSent, createdAt: session.createdAt, @@ -132,24 +135,20 @@ public struct MFAEnrolmentView { // Reset form state on success resetForm() - authService.authView = .authPicker + authService.navigator.clear() } } private func resetForm() { currentSession = nil phoneNumber = "" + selectedCountry = .default verificationCode = "" totpCode = "" displayName = "" focus = nil } - private func cancelEnrollment() { - resetForm() - authService.authView = .authPicker - } - private func copyToClipboard(_ text: String) { UIPasteboard.general.string = text @@ -187,38 +186,21 @@ public struct MFAEnrolmentView { extension MFAEnrolmentView: View { public var body: some View { - VStack(spacing: 16) { - // Back button - HStack { - Button(action: { - cancelEnrollment() - }) { - HStack(spacing: 4) { - Image(systemName: "chevron.left") - .font(.system(size: 17, weight: .medium)) - Text("Back") - .font(.system(size: 17)) - } - .foregroundColor(.blue) - } - .accessibilityIdentifier("mfa-back-button") - Spacer() - } - .padding(.horizontal) - - // Header - VStack { - Text("Set Up Two-Factor Authentication") - .font(.largeTitle) - .fontWeight(.bold) - .multilineTextAlignment(.center) + VStack(spacing: 24) { + // Header (only shown when no session is active) + if currentSession == nil { + VStack(spacing: 8) { + Text("Set Up Two-Factor Authentication") + .font(.largeTitle) + .fontWeight(.bold) + .multilineTextAlignment(.center) - Text("Add an extra layer of security to your account") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) + Text("Add an extra layer of security to your account") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } } - .padding() // Factor Type Selection (only if no session started) if currentSession == nil { @@ -239,7 +221,6 @@ extension MFAEnrolmentView: View { .foregroundColor(.secondary) .multilineTextAlignment(.center) } - .padding(.horizontal) .accessibilityIdentifier("mfa-disabled-message") } else if allowedFactorTypes.isEmpty { VStack(spacing: 12) { @@ -256,7 +237,6 @@ extension MFAEnrolmentView: View { .foregroundColor(.secondary) .multilineTextAlignment(.center) } - .padding(.horizontal) .accessibilityIdentifier("no-factors-message") } else { VStack(alignment: .leading, spacing: 12) { @@ -276,7 +256,6 @@ extension MFAEnrolmentView: View { .pickerStyle(.segmented) .accessibilityIdentifier("factor-type-picker") } - .padding(.horizontal) } } @@ -287,8 +266,9 @@ extension MFAEnrolmentView: View { initialContent } } - .padding(.horizontal, 16) - .padding(.vertical, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .safeAreaPadding() + .navigationTitle("Two-Factor Authentication") .onAppear { // Initialize selected factor type to first allowed type if !allowedFactorTypes.contains(selectedFactorType), @@ -300,46 +280,45 @@ extension MFAEnrolmentView: View { @ViewBuilder private var initialContent: some View { - VStack(spacing: 12) { + VStack(spacing: 24) { // Description based on selected type - Group { - if selectedFactorType == .sms { - VStack(spacing: 8) { - Image(systemName: "message.circle") - .font(.system(size: 40)) - .foregroundColor(.blue) - - Text("SMS Authentication") - .font(.title2) - .fontWeight(.semibold) - - Text("We'll send a verification code to your phone number each time you sign in.") - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - } else { - VStack(spacing: 8) { - Image(systemName: "qrcode") - .font(.system(size: 40)) - .foregroundColor(.green) + if selectedFactorType == .sms { + VStack(spacing: 8) { + Image(systemName: "message.circle") + .font(.system(size: 40)) + .foregroundColor(.blue) - Text("Authenticator App") - .font(.title2) - .fontWeight(.semibold) + Text("SMS Authentication") + .font(.title2) + .fontWeight(.semibold) - Text( - "Use an authenticator app like Google Authenticator or Authy to generate verification codes." - ) + Text("We'll send a verification code to your phone number each time you sign in.") .font(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) - } + } + } else { + VStack(spacing: 8) { + Image(systemName: "qrcode") + .font(.system(size: 40)) + .foregroundColor(.green) + + Text("Authenticator App") + .font(.title2) + .fontWeight(.semibold) + + Text( + "Use an authenticator app like Google Authenticator or Authy to generate verification codes." + ) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) } } - .padding(.horizontal) - Button(action: startEnrollment) { + Button { + startEnrollment() + } label: { HStack { if isLoading { ProgressView() @@ -347,14 +326,13 @@ extension MFAEnrolmentView: View { } Text("Get Started") } + .padding(.vertical, 8) .frame(maxWidth: .infinity) - .padding() - .background(canStartEnrollment ? Color.blue : Color.gray) - .foregroundColor(.white) - .cornerRadius(8) } + .buttonStyle(.borderedProminent) .disabled(!canStartEnrollment) - .padding(.horizontal) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) .accessibilityIdentifier("start-enrollment-button") } } @@ -371,37 +349,54 @@ extension MFAEnrolmentView: View { @ViewBuilder private func smsEnrollmentContent(session: EnrollmentSession) -> some View { - VStack(spacing: 20) { + VStack(spacing: 24) { // SMS enrollment steps if session.status == .initiated { - VStack(spacing: 16) { - Image(systemName: "phone") - .font(.system(size: 48)) - .foregroundColor(.blue) - - Text("Enter Your Phone Number") - .font(.title2) - .fontWeight(.semibold) + VStack(spacing: 24) { + VStack(spacing: 8) { + Image(systemName: "phone") + .font(.system(size: 48)) + .foregroundColor(.blue) - Text("We'll send a verification code to this number") - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) + Text("Enter Your Phone Number") + .font(.title2) + .fontWeight(.semibold) - TextField("Phone Number", text: $phoneNumber) - .textFieldStyle(.roundedBorder) - .keyboardType(.phonePad) - .focused($focus, equals: .phoneNumber) - .accessibilityIdentifier("phone-number-field") - .padding(.horizontal) + Text("We'll send a verification code to this number") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } - TextField("Display Name", text: $displayName) - .textFieldStyle(.roundedBorder) - .focused($focus, equals: nil) - .accessibilityIdentifier("display-name-field") - .padding(.horizontal) + AuthTextField( + text: $phoneNumber, + localizedTitle: "Phone Number", + prompt: "Enter phone number", + keyboardType: .phonePad, + contentType: .telephoneNumber, + onChange: { _ in } + ) { + CountrySelector( + selectedCountry: $selectedCountry, + enabled: !isLoading + ) + } + .focused($focus, equals: .phoneNumber) + .accessibilityIdentifier("phone-number-field") + + AuthTextField( + text: $displayName, + localizedTitle: "Display Name", + prompt: "Enter display name for this device", + leading: { + Image(systemName: "person") + } + ) + .accessibilityIdentifier("display-name-field") - Button(action: sendSMSVerification) { + Button { + sendSMSVerification() + } label: { HStack { if isLoading { ProgressView() @@ -409,39 +404,48 @@ extension MFAEnrolmentView: View { } Text("Send Code") } + .padding(.vertical, 8) .frame(maxWidth: .infinity) - .padding() - .background(canSendSMSVerification ? Color.blue : Color.gray) - .foregroundColor(.white) - .cornerRadius(8) } + .buttonStyle(.borderedProminent) .disabled(!canSendSMSVerification) - .padding(.horizontal) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) .accessibilityIdentifier("send-sms-button") } } else if session.status == .verificationSent { - VStack(spacing: 16) { - Image(systemName: "checkmark.message") - .font(.system(size: 48)) - .foregroundColor(.green) + VStack(spacing: 24) { + VStack(spacing: 8) { + Image(systemName: "checkmark.message") + .font(.system(size: 48)) + .foregroundColor(.green) - Text("Enter Verification Code") - .font(.title2) - .fontWeight(.semibold) + Text("Enter Verification Code") + .font(.title2) + .fontWeight(.semibold) - Text("We sent a code to \(session.phoneNumber ?? "your phone")") - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) + Text("We sent a code to \(session.phoneNumber ?? "your phone")") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } - TextField("Verification Code", text: $verificationCode) - .textFieldStyle(.roundedBorder) - .keyboardType(.numberPad) - .focused($focus, equals: .verificationCode) - .accessibilityIdentifier("verification-code-field") - .padding(.horizontal) + AuthTextField( + text: $verificationCode, + localizedTitle: "Verification Code", + prompt: "Enter 6-digit code", + keyboardType: .numberPad, + contentType: .oneTimeCode, + leading: { + Image(systemName: "number") + } + ) + .focused($focus, equals: .verificationCode) + .accessibilityIdentifier("verification-code-field") - Button(action: completeEnrollment) { + Button { + completeEnrollment() + } label: { HStack { if isLoading { ProgressView() @@ -449,20 +453,25 @@ extension MFAEnrolmentView: View { } Text("Complete Setup") } + .padding(.vertical, 8) .frame(maxWidth: .infinity) - .padding() - .background(canCompleteEnrollment ? Color.blue : Color.gray) - .foregroundColor(.white) - .cornerRadius(8) } + .buttonStyle(.borderedProminent) .disabled(!canCompleteEnrollment) - .padding(.horizontal) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) .accessibilityIdentifier("complete-enrollment-button") - Button("Resend Code") { + Button { sendSMSVerification() + } label: { + Text("Resend Code") + .padding(.vertical, 8) + .frame(maxWidth: .infinity) } - .foregroundColor(.blue) + .buttonStyle(.bordered) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) .accessibilityIdentifier("resend-code-button") } } @@ -471,21 +480,25 @@ extension MFAEnrolmentView: View { @ViewBuilder private func totpEnrollmentContent(session: EnrollmentSession) -> some View { - VStack(spacing: 20) { + VStack(spacing: 16) { if let totpInfo = session.totpInfo { VStack(spacing: 16) { - Image(systemName: "qrcode") - .font(.system(size: 48)) - .foregroundColor(.green) + VStack(spacing: 6) { + Image(systemName: "qrcode") + .font(.system(size: 40)) + .foregroundColor(.green) - Text("Scan QR Code") - .font(.title2) - .fontWeight(.semibold) + Text("Scan QR Code") + .font(.title2) + .fontWeight(.semibold) - Text("Scan with your authenticator app or tap to open directly") - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) + Text("Scan with your authenticator app or tap to open directly") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .lineLimit(nil) + .padding(.horizontal) + } // QR Code generated from the otpauth:// URI if let qrURL = totpInfo.qrCodeURL, @@ -493,12 +506,12 @@ extension MFAEnrolmentView: View { Button(action: { UIApplication.shared.open(qrURL) }) { - VStack(spacing: 12) { + VStack(spacing: 8) { Image(uiImage: qrImage) .interpolation(.none) .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 200, height: 200) + .frame(width: 180, height: 180) .accessibilityIdentifier("qr-code-image") HStack(spacing: 6) { @@ -516,7 +529,7 @@ extension MFAEnrolmentView: View { } else { RoundedRectangle(cornerRadius: 8) .fill(Color.gray.opacity(0.3)) - .frame(width: 200, height: 200) + .frame(width: 180, height: 180) .overlay( VStack { Image(systemName: "exclamationmark.triangle") @@ -528,10 +541,10 @@ extension MFAEnrolmentView: View { ) } - Text("Manual Entry Key:") - .font(.headline) + VStack(spacing: 6) { + Text("Manual Entry Key:") + .font(.headline) - VStack(spacing: 8) { Button(action: { copyToClipboard(totpInfo.sharedSecretKey) }) { @@ -562,19 +575,32 @@ extension MFAEnrolmentView: View { } .animation(.easeInOut(duration: 0.2), value: showCopiedFeedback) - TextField("Display Name", text: $displayName) - .textFieldStyle(.roundedBorder) - .accessibilityIdentifier("display-name-field") - .padding(.horizontal) - - TextField("Enter Code from App", text: $totpCode) - .textFieldStyle(.roundedBorder) - .keyboardType(.numberPad) - .focused($focus, equals: .totpCode) - .accessibilityIdentifier("totp-code-field") - .padding(.horizontal) + AuthTextField( + text: $displayName, + localizedTitle: "Display Name", + prompt: "Enter display name for this authenticator", + leading: { + Image(systemName: "person") + } + ) + .accessibilityIdentifier("display-name-field") + + AuthTextField( + text: $totpCode, + localizedTitle: "Verification Code", + prompt: "Enter code from app", + keyboardType: .numberPad, + contentType: .oneTimeCode, + leading: { + Image(systemName: "number") + } + ) + .focused($focus, equals: .totpCode) + .accessibilityIdentifier("totp-code-field") - Button(action: completeEnrollment) { + Button { + completeEnrollment() + } label: { HStack { if isLoading { ProgressView() @@ -582,14 +608,13 @@ extension MFAEnrolmentView: View { } Text("Complete Setup") } + .padding(.vertical, 8) .frame(maxWidth: .infinity) - .padding() - .background(canCompleteEnrollment ? Color.blue : Color.gray) - .foregroundColor(.white) - .cornerRadius(8) } + .buttonStyle(.borderedProminent) .disabled(!canCompleteEnrollment) - .padding(.horizontal) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) .accessibilityIdentifier("complete-enrollment-button") } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift index ab3cc74b94b..fcbed901a87 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift @@ -13,6 +13,7 @@ // limitations under the License. import FirebaseAuth +import FirebaseCore import SwiftUI extension MultiFactorInfo: Identifiable { @@ -26,14 +27,6 @@ public struct MFAManagementView { @State private var enrolledFactors: [MultiFactorInfo] = [] @State private var isLoading = false - // Present password prompt when required for reauthentication - private var isShowingPasswordPrompt: Binding { - Binding( - get: { authService.passwordPrompt.isPromptingPassword }, - set: { authService.passwordPrompt.isPromptingPassword = $0 } - ) - } - public init() {} private func loadEnrolledFactors() { @@ -56,36 +49,14 @@ public struct MFAManagementView { } private func navigateToEnrollment() { - authService.authView = .mfaEnrollment - } - - private func goBack() { - authService.authView = .authPicker + authService.navigator.push(.mfaEnrollment) } } extension MFAManagementView: View { public var body: some View { + @Bindable var passwordPrompt = authService.passwordPrompt VStack(spacing: 20) { - // Header with manual back button - HStack { - Button(action: { - authService.authView = .authPicker - }) { - HStack(spacing: 4) { - Image(systemName: "chevron.left") - .font(.system(size: 17, weight: .medium)) - Text(authService.string.backButtonLabel) - .font(.system(size: 17)) - } - .foregroundColor(.blue) - } - .accessibilityIdentifier("back-button") - - Spacer() - } - .padding(.horizontal) - // Title section VStack { Text("Two-Factor Authentication") @@ -119,10 +90,16 @@ extension MFAManagementView: View { .multilineTextAlignment(.center) .padding(.horizontal) - Button("Set Up Two-Factor Authentication") { + Button { navigateToEnrollment() + } label: { + Text("Set Up Two-Factor Authentication") + .padding(.vertical, 8) + .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) .accessibilityIdentifier("setup-mfa-button") } } else { @@ -142,18 +119,22 @@ extension MFAManagementView: View { Button("Add Another Method") { navigateToEnrollment() } - .buttonStyle(.bordered) - .padding(.horizontal) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .buttonStyle(.borderedProminent) .accessibilityIdentifier("add-mfa-method-button") } } Spacer() } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .safeAreaPadding() .onAppear { loadEnrolledFactors() } - .sheet(isPresented: isShowingPasswordPrompt) { + // Present password prompt when required for reauthentication + .sheet(isPresented: $passwordPrompt.isPromptingPassword) { PasswordPromptSheet(coordinator: authService.passwordPrompt) } } @@ -222,6 +203,9 @@ private extension DateFormatter { } #Preview { - MFAManagementView() - .environment(AuthService()) + FirebaseOptions.dummyConfigurationForPreview() + return NavigationStack { + MFAManagementView() + .environment(AuthService()) + } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift index 800a7c5131d..a5efb326401 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift @@ -90,7 +90,7 @@ public struct MFAResolutionView { ) // On success, the AuthService will update the authentication state // and we should navigate back to the main app - authService.authView = .authPicker + authService.navigator.clear() isLoading = false } catch { isLoading = false @@ -99,7 +99,7 @@ public struct MFAResolutionView { } private func cancelResolution() { - authService.authView = .authPicker + authService.navigator.clear() } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift index b258e464f5a..5c8296ed1a7 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.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 @@ -36,45 +37,17 @@ public struct PasswordRecoveryView { extension PasswordRecoveryView: View { public var body: some View { - VStack { - HStack { - Button(action: { - authService.authView = .authPicker - }) { - HStack(spacing: 4) { - Image(systemName: "chevron.left") - .font(.system(size: 17, weight: .medium)) - Text(authService.string.backButtonLabel) - .font(.system(size: 17)) - } - .foregroundColor(.blue) + VStack(spacing: 24) { + AuthTextField( + text: $email, + localizedTitle: "Send a password recovery link to your email", + prompt: authService.string.emailInputLabel, + keyboardType: .emailAddress, + contentType: .emailAddress, + leading: { + Image(systemName: "at") } - .accessibilityIdentifier("password-recovery-back-button") - - Spacer() - } - .padding(.horizontal) - .padding(.top, 8) - Text(authService.string.passwordRecoveryTitle) - .font(.largeTitle) - .fontWeight(.bold) - .padding() - .accessibilityIdentifier("password-recovery-text") - - Divider() - - LabeledContent { - TextField(authService.string.emailInputLabel, text: $email) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .submitLabel(.next) - } label: { - Image(systemName: "at") - } - .padding(.vertical, 6) - .background(Divider(), alignment: .bottom) - .padding(.bottom, 4) - + ) Button(action: { Task { await sendPasswordRecoveryEmail() @@ -85,10 +58,12 @@ extension PasswordRecoveryView: View { .frame(maxWidth: .infinity) } .disabled(!CommonUtils.isValidEmail(email)) - .padding([.top, .bottom, .horizontal], 8) .frame(maxWidth: .infinity) .buttonStyle(.borderedProminent) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle(authService.string.passwordRecoveryTitle) + .safeAreaPadding() .sheet(isPresented: $showSuccessSheet) { successSheet } @@ -115,7 +90,7 @@ extension PasswordRecoveryView: View { Button(authService.string.okButtonLabel) { showSuccessSheet = false email = "" - authService.authView = .authPicker + authService.navigator.clear() } .padding() } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift index db7c1294bde..d8a4da1be16 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift @@ -66,9 +66,7 @@ extension PrivacyTOCsView: View { if let tosURL = authService.configuration.tosUrl, let privacyURL = authService.configuration.privacyPolicyUrl { Text(attributedMessage(tosURL: tosURL, privacyURL: privacyURL)) - .multilineTextAlignment(displayMode == .full ? .leading : .trailing) - .font(.footnote) - .foregroundColor(.primary) + .multilineTextAlignment(displayMode == .full ? .center : .trailing) .padding() } else { EmptyView() diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift index 68f4f90de7c..2ac346b3929 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift @@ -18,59 +18,190 @@ import SwiftUI @MainActor public struct SignedInView { @Environment(AuthService.self) private var authService -} + @State private var showDeleteConfirmation = false + @State private var showEmailVerificationSent = false -extension SignedInView: View { - private var isShowingPasswordPrompt: Binding { - Binding( - get: { authService.passwordPrompt.isPromptingPassword }, - set: { authService.passwordPrompt.isPromptingPassword = $0 } - ) + private func sendEmailVerification() async { + do { + try await authService.sendEmailVerification() + showEmailVerificationSent = true + } catch { + // Error already displayed via modal by AuthService + } } +} +extension SignedInView: View { public var body: some View { - if authService.authView == .updatePassword { - UpdatePasswordView() - } else { - VStack { - Text(authService.string.signedInTitle) - .font(.largeTitle) - .fontWeight(.bold) - .padding() - .accessibilityIdentifier("signed-in-text") - Text("as:") - Text( - "\(authService.currentUser?.email ?? authService.currentUser?.displayName ?? "Unknown")" - ) - if authService.currentUser?.isEmailVerified == false { - VerifyEmailView() - } - Divider() - Button(authService.string.updatePasswordButtonLabel) { - authService.authView = .updatePassword - } - Divider() - Button("Manage Two-Factor Authentication") { - authService.authView = .mfaManagement - } - .accessibilityIdentifier("mfa-management-button") - Divider() - Button(authService.string.signOutButtonLabel) { + @Bindable var passwordPrompt = authService.passwordPrompt + VStack { + Text(authService.string.signedInTitle) + .font(.largeTitle) + .fontWeight(.bold) + .padding() + .accessibilityIdentifier("signed-in-text") + Text( + "\(authService.currentUser?.email ?? authService.currentUser?.displayName ?? "Unknown")" + ) + if authService.currentUser?.isEmailVerified == false { + Button { Task { - try? await authService.signOut() + await sendEmailVerification() } - }.accessibilityIdentifier("sign-out-button") - Divider() - Button(authService.string.deleteAccountButtonLabel) { + } label: { + Text(authService.string.sendEmailVerificationButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("verify-email-button") + } + Button { + authService.navigator.push(.updatePassword) + } label: { + Text(authService.string.updatePasswordButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("update-password-button") + + Button { + authService.navigator.push(.mfaManagement) + } label: { + Text("Manage Two-Factor Authentication") + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("mfa-management-button") + + Button { + showDeleteConfirmation = true + } label: { + Text(authService.string.deleteAccountButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("delete-account-button") + + Button { + Task { + try? await authService.signOut() + } + } label: { + Text(authService.string.signOutButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("sign-out-button") + } + .safeAreaPadding() + .sheet(isPresented: $showDeleteConfirmation) { + DeleteAccountConfirmationSheet( + onConfirm: { + showDeleteConfirmation = false Task { try? await authService.deleteUser() } + }, + onCancel: { + showDeleteConfirmation = false } + ) + .presentationDetents([.medium]) + } + .sheet(isPresented: $passwordPrompt.isPromptingPassword) { + PasswordPromptSheet(coordinator: authService.passwordPrompt) + } + .sheet(isPresented: $showEmailVerificationSent) { + VStack(spacing: 24) { + Text(authService.string.verifyEmailSheetMessage) + .font(.headline) + Button { + showEmailVerificationSent = false + } label: { + Text(authService.string.okButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .safeAreaPadding() + .presentationDetents([.medium]) + } + } +} + +private struct DeleteAccountConfirmationSheet: View { + @Environment(AuthService.self) private var authService + let onConfirm: () -> Void + let onCancel: () -> Void + + var body: some View { + VStack(spacing: 24) { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 60)) + .foregroundColor(.red) + + Text("Delete Account?") + .font(.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) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) } - .sheet(isPresented: isShowingPasswordPrompt) { - PasswordPromptSheet(coordinator: authService.passwordPrompt) + + VStack(spacing: 12) { + Button { + onConfirm() + } label: { + Text("Delete Account") + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.red) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("confirm-delete-button") + + Button { + onCancel() + } label: { + Text("Cancel") + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("cancel-delete-button") } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .safeAreaPadding() } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePassword.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift similarity index 52% rename from FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePassword.swift rename to FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift index 7eb5bd9b889..b2e79217972 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePassword.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift @@ -18,7 +18,7 @@ // // Created by Russell Wheatley on 24/04/2025. // - +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -40,63 +40,39 @@ public struct UpdatePasswordView { } extension UpdatePasswordView: View { - private var isShowingPasswordPrompt: Binding { - Binding( - get: { authService.passwordPrompt.isPromptingPassword }, - set: { authService.passwordPrompt.isPromptingPassword = $0 } - ) - } - public var body: some View { - VStack { - HStack { - Button(action: { - authService.authView = .authPicker - }) { - HStack(spacing: 4) { - Image(systemName: "chevron.left") - .font(.system(size: 17, weight: .medium)) - Text(authService.string.backButtonLabel) - .font(.system(size: 17)) - } - .foregroundColor(.blue) + @Bindable var passwordPrompt = authService.passwordPrompt + VStack(spacing: 24) { + AuthTextField( + text: $password, + localizedTitle: "Type new password", + prompt: authService.string.passwordInputLabel, + contentType: .password, + sensitive: true, + leading: { + Image(systemName: "lock") } - .accessibilityIdentifier("update-password-back-button") - - Spacer() - } - .padding(.horizontal) - .padding(.top, 8) - LabeledContent { - SecureField(authService.string.passwordInputLabel, text: $password) - .focused($focus, equals: .password) - .submitLabel(.go) - } label: { - Image(systemName: "lock") - } - .padding(.vertical, 6) - .background(Divider(), alignment: .bottom) - .padding(.bottom, 8) - - Divider() + ) + .submitLabel(.go) + .focused($focus, equals: .password) - LabeledContent { - SecureField(authService.string.confirmPasswordInputLabel, text: $confirmPassword) - .focused($focus, equals: .confirmPassword) - .submitLabel(.go) - } label: { - Image(systemName: "lock") - } - .padding(.vertical, 6) - .background(Divider(), alignment: .bottom) - .padding(.bottom, 8) - - Divider() + AuthTextField( + text: $confirmPassword, + localizedTitle: "Retype new password", + prompt: authService.string.confirmPasswordInputLabel, + contentType: .password, + sensitive: true, + leading: { + Image(systemName: "lock") + } + ) + .submitLabel(.go) + .focused($focus, equals: .confirmPassword) Button(action: { Task { try await authService.updatePassword(to: confirmPassword) - authService.authView = .authPicker + authService.navigator.clear() } }, label: { Text(authService.string.updatePasswordButtonLabel) @@ -108,7 +84,11 @@ extension UpdatePasswordView: View { .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .buttonStyle(.borderedProminent) - }.sheet(isPresented: isShowingPasswordPrompt) { + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .safeAreaPadding() + .navigationTitle(authService.string.passwordRecoveryTitle) + .sheet(isPresented: $passwordPrompt.isPromptingPassword) { PasswordPromptSheet(coordinator: authService.passwordPrompt) } } @@ -116,6 +96,8 @@ extension UpdatePasswordView: View { #Preview { FirebaseOptions.dummyConfigurationForPreview() - return UpdatePasswordView() - .environment(AuthService()) + return NavigationStack { + UpdatePasswordView() + .environment(AuthService()) + } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/VerifyEmailView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/VerifyEmailView.swift deleted file mode 100644 index 724d1480f3f..00000000000 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/VerifyEmailView.swift +++ /dev/null @@ -1,65 +0,0 @@ -// 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 FirebaseCore -import SwiftUI - -public struct VerifyEmailView { - @Environment(AuthService.self) private var authService - @State private var showModal = false - - private func sendEmailVerification() async { - do { - try await authService.sendEmailVerification() - showModal = true - } catch { - // Error already displayed via modal by AuthService - } - } -} - -extension VerifyEmailView: View { - public var body: some View { - VStack { - Button(action: { - Task { - await sendEmailVerification() - } - }) { - Text(authService.string.sendEmailVerificationButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .padding([.top, .bottom, .horizontal], 8) - .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) - }.sheet(isPresented: $showModal) { - VStack { - Text(authService.string.verifyEmailSheetMessage) - .font(.headline) - Button(authService.string.okButtonLabel) { - showModal = false - } - .padding() - } - .padding() - } - } -} - -#Preview { - FirebaseOptions.dummyConfigurationForPreview() - return VerifyEmailView() - .environment(AuthService()) -} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Assets.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Assets.swift new file mode 100644 index 00000000000..3c551537570 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Assets.swift @@ -0,0 +1,20 @@ +// 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 + +@MainActor +public struct Assets { + public static var firebaseAuthLogo: ImageResource = .firebaseAuthLogo +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift new file mode 100644 index 00000000000..f8df18d14df --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift @@ -0,0 +1,81 @@ +// 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 + +/// A styled button component for authentication providers +/// Used by all provider packages to maintain consistent UI +public struct AuthProviderButton: View { + let label: String + let style: ProviderStyle + let action: () -> Void + var enabled: Bool + var accessibilityId: String? + + public init(label: String, + style: ProviderStyle, + enabled: Bool = true, + accessibilityId: String? = nil, + action: @escaping () -> Void) { + self.label = label + self.style = style + self.enabled = enabled + self.accessibilityId = accessibilityId + self.action = action + } + + public var body: some View { + Button(action: action) { + HStack(spacing: 12) { + if let icon = style.icon { + providerIcon(for: icon, tint: style.iconTint) + } + Text(label) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(style.contentColor) + } + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.borderedProminent) + .tint(style.backgroundColor) + .shadow( + color: Color.black.opacity(0.12), + radius: Double(style.elevation), + x: 0, + y: style.elevation > 0 ? 1 : 0 + ) + .disabled(!enabled) + .accessibilityIdentifier(accessibilityId ?? "auth-provider-button") + } + + @ViewBuilder + private func providerIcon(for image: Image, tint: Color?) -> some View { + if let tint { + image + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundStyle(tint) + } else { + image + .renderingMode(.original) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift new file mode 100644 index 00000000000..9c3247c6c7d --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift @@ -0,0 +1,171 @@ +// 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 + +public struct FieldValidation: Identifiable, Equatable { + public let id = UUID() + public let message: String + public var valid: Bool = false + + public init(message: String, valid: Bool = false) { + self.message = message + self.valid = valid + } +} + +public struct AuthTextField: View { + @FocusState private var isFocused: Bool + @State var invalidInput: Bool = false + @State var obscured: Bool = true + + @Binding var text: String + let localizedTitle: String + let prompt: String + var textAlignment: TextAlignment = .leading + var keyboardType: UIKeyboardType = .default + var contentType: UITextContentType? = nil + var isSecureTextField: Bool = false + var validations: [FieldValidation] = [] + var formState: ((Bool) -> Void)? = nil + var onSubmit: ((String) -> Void)? = nil + var onChange: ((String) -> Void)? = nil + private let leading: () -> Leading? + + public init(text: Binding, + localizedTitle: String, + prompt: String, + textAlignment: TextAlignment = .leading, + keyboardType: UIKeyboardType = .default, + contentType: UITextContentType? = nil, + sensitive: Bool = false, + validations: [FieldValidation] = [], + formState: ((Bool) -> Void)? = nil, + onSubmit: ((String) -> Void)? = nil, + onChange: ((String) -> Void)? = nil, + @ViewBuilder leading: @escaping () -> Leading? = { EmptyView() }) { + _text = text + self.localizedTitle = localizedTitle + self.prompt = prompt + self.textAlignment = textAlignment + self.keyboardType = keyboardType + self.contentType = contentType + isSecureTextField = sensitive + self.validations = validations + self.formState = formState + self.onSubmit = onSubmit + self.onChange = onChange + self.leading = leading + } + + var allRequirementsMet: Bool { + validations.allSatisfy { $0.valid == true } + } + + public var body: some View { + VStack(alignment: .leading) { + Text(localizedTitle) + HStack(spacing: 8) { + leading() + Group { + if isSecureTextField { + ZStack(alignment: .trailing) { + SecureField(localizedTitle, text: $text, prompt: Text(prompt)) + .opacity(obscured ? 1 : 0) + .focused($isFocused) + .frame(height: 24) + TextField(localizedTitle, text: $text, prompt: Text(prompt)) + .opacity(obscured ? 0 : 1) + .focused($isFocused) + .frame(height: 24) + if !text.isEmpty { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + obscured.toggle() + } + // Reapply focus after toggling + DispatchQueue.main.async { + isFocused = true + } + } label: { + Image(systemName: obscured ? "eye" : "eye.slash") + } + .buttonStyle(.plain) + } + } + } else { + TextField( + localizedTitle, + text: $text, + prompt: Text(prompt) + ) + .frame(height: 24) + } + } + } + .frame(maxWidth: .infinity) + .keyboardType(keyboardType) + .textContentType(contentType) + .autocapitalization(.none) + .disableAutocorrection(true) + .focused($isFocused) + .onSubmit { + onSubmit?(text) + } + .onChange(of: text) { _, newValue in + onChange?(newValue) + } + .multilineTextAlignment(textAlignment) + .textFieldStyle(.plain) + .padding(.vertical, 12) + .padding(.horizontal, 12) + .background { + RoundedRectangle(cornerRadius: 8) + .fill(Color.accentColor.opacity(0.05)) + .strokeBorder(lineWidth: isFocused ? 3 : 1) + .foregroundStyle(isFocused ? Color.accentColor : Color(.systemFill)) + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation { + isFocused = true + } + } + if !validations.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(validations) { validation in + HStack { + Image(systemName: isSecureTextField ? "lock.open" : "x.square") + .foregroundStyle(validation.valid ? .gray : .red) + Text(validation.message) + .strikethrough(validation.valid, color: .gray) + .foregroundStyle(.gray) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .onChange(of: allRequirementsMet) { _, newValue in + formState?(newValue) + if !newValue { + withAnimation(.easeInOut(duration: 0.08).repeatCount(4)) { + invalidInput = true + } completion: { + invalidInput = false + } + } + } + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift new file mode 100644 index 00000000000..49d94e9fe1e --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.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 SwiftUI + +public struct CountryData: Equatable { + public let name: String + public let dialCode: String + public let code: String + + public init(name: String, dialCode: String, code: String) { + self.name = name + self.dialCode = dialCode + self.code = code + } + + public var flag: String { + let base: UInt32 = 127_397 + var s = "" + for v in code.unicodeScalars { + s.unicodeScalars.append(UnicodeScalar(base + v.value)!) + } + return String(s) + } + + @MainActor public static let `default` = CountryData( + name: "United States", + dialCode: "+1", + code: "US" + ) +} + +public struct CountrySelector: View { + @Binding var selectedCountry: CountryData + var enabled: Bool = true + var allowedCountries: Set? = nil + + public init(selectedCountry: Binding, + enabled: Bool = true, + allowedCountries: Set? = nil) { + _selectedCountry = selectedCountry + self.enabled = enabled + self.allowedCountries = allowedCountries + } + + // Common countries list + private let allCountries: [CountryData] = [ + CountryData(name: "United States", dialCode: "+1", code: "US"), + CountryData(name: "United Kingdom", dialCode: "+44", code: "GB"), + CountryData(name: "Canada", dialCode: "+1", code: "CA"), + CountryData(name: "Australia", dialCode: "+61", code: "AU"), + CountryData(name: "Germany", dialCode: "+49", code: "DE"), + CountryData(name: "France", dialCode: "+33", code: "FR"), + CountryData(name: "India", dialCode: "+91", code: "IN"), + CountryData(name: "Nigeria", dialCode: "+234", code: "NG"), + CountryData(name: "South Africa", dialCode: "+27", code: "ZA"), + CountryData(name: "Japan", dialCode: "+81", code: "JP"), + CountryData(name: "China", dialCode: "+86", code: "CN"), + CountryData(name: "Brazil", dialCode: "+55", code: "BR"), + CountryData(name: "Mexico", dialCode: "+52", code: "MX"), + CountryData(name: "Spain", dialCode: "+34", code: "ES"), + CountryData(name: "Italy", dialCode: "+39", code: "IT"), + ] + + private var filteredCountries: [CountryData] { + if let allowedCountries = allowedCountries { + return allCountries.filter { allowedCountries.contains($0.code) } + } + return allCountries + } + + public var body: some View { + Menu { + ForEach(filteredCountries, id: \.code) { country in + Button { + selectedCountry = country + } label: { + Text("\(country.flag) \(country.name) (\(country.dialCode))") + } + .accessibilityIdentifier("country-option-\(country.code)") + } + } label: { + HStack(spacing: 4) { + Text(selectedCountry.flag) + .font(.title3) + Text(selectedCountry.dialCode) + .font(.body) + .foregroundStyle(.primary) + Image(systemName: "chevron.down") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .accessibilityIdentifier("country-selector") + .disabled(!enabled) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift new file mode 100644 index 00000000000..fc99208c020 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift @@ -0,0 +1,551 @@ +// 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 +import UIKit + +public struct VerificationCodeInputField: View { + public init(code: Binding, + codeLength: Int = 6, + isError: Bool = false, + errorMessage: String? = nil, + onCodeComplete: @escaping (String) -> Void = { _ in }, + onCodeChange: @escaping (String) -> Void = { _ in }) { + _code = code + self.codeLength = codeLength + self.isError = isError + self.errorMessage = errorMessage + self.onCodeComplete = onCodeComplete + self.onCodeChange = onCodeChange + _digitFields = State(initialValue: Array(repeating: "", count: codeLength)) + } + + @Binding var code: String + let codeLength: Int + let isError: Bool + let errorMessage: String? + let onCodeComplete: (String) -> Void + let onCodeChange: (String) -> Void + + @State private var digitFields: [String] = [] + @State private var focusedIndex: Int? = nil + @State private var pendingInternalCodeUpdates = 0 + + public var body: some View { + VStack(spacing: 8) { + HStack(spacing: 8) { + ForEach(0 ..< codeLength, id: \.self) { index in + SingleDigitField( + digit: $digitFields[index], + isError: isError, + isFocused: focusedIndex == index, + maxDigits: codeLength - index, + position: index + 1, + totalDigits: codeLength, + onDigitChanged: { newDigit in + handleDigitChanged(at: index, newDigit: newDigit) + }, + onBackspace: { + handleBackspace(at: index) + }, + onFocusChanged: { isFocused in + DispatchQueue.main.async { + if isFocused { + if focusedIndex != index { + withAnimation(.easeInOut(duration: 0.2)) { + focusedIndex = index + } + } + } else if focusedIndex == index { + withAnimation(.easeInOut(duration: 0.2)) { + focusedIndex = nil + } + } + } + } + ) + } + } + + if isError, let errorMessage = errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .onAppear { + // Initialize digit fields from the code binding + updateDigitFieldsFromCode(shouldUpdateFocus: true, forceFocus: true) + } + .onChange(of: code) { _, _ in + if pendingInternalCodeUpdates > 0 { + pendingInternalCodeUpdates -= 1 + return + } + updateDigitFieldsFromCode(shouldUpdateFocus: true) + } + } + + private func updateDigitFieldsFromCode(shouldUpdateFocus: Bool, forceFocus: Bool = false) { + let sanitized = code.filter { $0.isNumber } + let truncated = String(sanitized.prefix(codeLength)) + var newFields = Array(repeating: "", count: codeLength) + + for (offset, character) in truncated.enumerated() { + newFields[offset] = String(character) + } + + let fieldsChanged = newFields != digitFields + if fieldsChanged { + digitFields = newFields + } + + if code != truncated { + commitCodeChange(truncated) + } + + if shouldUpdateFocus && (fieldsChanged || forceFocus) { + let newFocus = truncated.count < codeLength ? truncated.count : nil + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.2)) { + focusedIndex = newFocus + } + } + } + + if fieldsChanged && truncated.count == codeLength { + DispatchQueue.main.async { + onCodeComplete(truncated) + } + } + } + + private func commitCodeChange(_ newCode: String) { + if code != newCode { + pendingInternalCodeUpdates += 1 + code = newCode + } + } + + private func handleDigitChanged(at index: Int, newDigit: String) { + let sanitized = newDigit.filter { $0.isNumber } + + guard !sanitized.isEmpty else { + processSingleDigitInput(at: index, digit: "") + return + } + + let firstDigit = String(sanitized.prefix(1)) + processSingleDigitInput(at: index, digit: firstDigit) + + let remainder = String(sanitized.dropFirst()) + let availableSlots = max(codeLength - (index + 1), 0) + if availableSlots > 0 { + let trimmedRemainder = String(remainder.prefix(availableSlots)) + if !trimmedRemainder.isEmpty { + applyBulkInput(startingAt: index + 1, digits: trimmedRemainder) + } + } + } + + private func processSingleDigitInput(at index: Int, digit: String) { + if digitFields[index] != digit { + digitFields[index] = digit + } + + let newCode = digitFields.joined() + commitCodeChange(newCode) + onCodeChange(newCode) + + if !digit.isEmpty, + let nextIndex = findNextEmptyField(startingFrom: index) { + DispatchQueue.main.async { + if focusedIndex != nextIndex { + withAnimation(.easeInOut(duration: 0.2)) { + focusedIndex = nextIndex + } + } + } + } + + if newCode.count == codeLength { + DispatchQueue.main.async { + onCodeComplete(newCode) + } + } + } + + private func handleBackspace(at index: Int) { + // If current field is empty, move to previous field and clear it + if digitFields[index].isEmpty && index > 0 { + digitFields[index - 1] = "" + DispatchQueue.main.async { + let previousIndex = index - 1 + if focusedIndex != previousIndex { + withAnimation(.easeInOut(duration: 0.2)) { + focusedIndex = previousIndex + } + } + } + } else { + // Clear current field + digitFields[index] = "" + } + + // Update the main code string + let newCode = digitFields.joined() + commitCodeChange(newCode) + onCodeChange(newCode) + } + + private func applyBulkInput(startingAt index: Int, digits: String) { + guard !digits.isEmpty, index < codeLength else { return } + + var updatedFields = digitFields + var currentIndex = index + + for digit in digits where currentIndex < codeLength { + updatedFields[currentIndex] = String(digit) + currentIndex += 1 + } + + if digitFields != updatedFields { + digitFields = updatedFields + } + + let newCode = updatedFields.joined() + commitCodeChange(newCode) + onCodeChange(newCode) + + if newCode.count == codeLength { + DispatchQueue.main.async { + onCodeComplete(newCode) + } + } else { + let clampedIndex = max(min(currentIndex - 1, codeLength - 1), 0) + if let nextIndex = findNextEmptyField(startingFrom: clampedIndex) { + DispatchQueue.main.async { + if focusedIndex != nextIndex { + withAnimation(.easeInOut(duration: 0.2)) { + focusedIndex = nextIndex + } + } + } + } + } + } + + private func findNextEmptyField(startingFrom index: Int) -> Int? { + // Look for the next empty field after the current index + for i in (index + 1) ..< codeLength { + if digitFields[i].isEmpty { + return i + } + } + // If no empty field found after current index, look from the beginning + for i in 0 ..< index { + if digitFields[i].isEmpty { + return i + } + } + return nil + } +} + +private struct SingleDigitField: View { + @Binding var digit: String + let isError: Bool + let isFocused: Bool + let maxDigits: Int + let position: Int + let totalDigits: Int + let onDigitChanged: (String) -> Void + let onBackspace: () -> Void + let onFocusChanged: (Bool) -> Void + + private var borderWidth: CGFloat { + if isError { return 2 } + if isFocused || !digit.isEmpty { return 3 } + return 1 + } + + private var borderColor: Color { + if isError { return .red } + if isFocused || !digit.isEmpty { return .accentColor } + return Color(.systemFill) + } + + var body: some View { + BackspaceAwareTextField( + text: $digit, + isFirstResponder: isFocused, + onDeleteBackwardWhenEmpty: { + if digit.isEmpty { + onBackspace() + } else { + digit = "" + } + }, + onFocusChanged: { isFocused in + onFocusChanged(isFocused) + }, + maxCharacters: maxDigits, + configuration: { textField in + textField.font = .systemFont(ofSize: 24, weight: .medium) + textField.textAlignment = .center + textField.keyboardType = .numberPad + textField.textContentType = .oneTimeCode + textField.autocapitalizationType = .none + textField.autocorrectionType = .no + }, + onTextChange: { newValue in + onDigitChanged(newValue) + } + ) + .frame(width: 48, height: 48) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.accentColor.opacity(0.05)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(borderColor, lineWidth: borderWidth) + ) + ) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Digit \(position) of \(totalDigits)") + .accessibilityValue(digit.isEmpty ? "Empty" : digit) + .accessibilityHint("Enter verification code digit") + .animation(.easeInOut(duration: 0.2), value: isFocused) + .animation(.easeInOut(duration: 0.2), value: digit) + } +} + +private struct BackspaceAwareTextField: UIViewRepresentable { + @Binding var text: String + var isFirstResponder: Bool + let onDeleteBackwardWhenEmpty: () -> Void + let onFocusChanged: (Bool) -> Void + let maxCharacters: Int + let configuration: (UITextField) -> Void + let onTextChange: (String) -> Void + + func makeUIView(context: Context) -> BackspaceUITextField { + context.coordinator.parent = self + let textField = BackspaceUITextField() + textField.delegate = context.coordinator + textField.addTarget( + context.coordinator, + action: #selector(Coordinator.editingChanged(_:)), + for: .editingChanged + ) + configuration(textField) + textField.onDeleteBackward = { [weak textField] in + guard let textField else { return } + if (textField.text ?? "").isEmpty { + onDeleteBackwardWhenEmpty() + } + } + return textField + } + + func updateUIView(_ uiView: BackspaceUITextField, context: Context) { + context.coordinator.parent = self + if uiView.text != text { + uiView.text = text + } + + uiView.onDeleteBackward = { [weak uiView] in + guard let uiView else { return } + if (uiView.text ?? "").isEmpty { + onDeleteBackwardWhenEmpty() + } + } + + if isFirstResponder { + if !context.coordinator.isFirstResponder { + context.coordinator.isFirstResponder = true + DispatchQueue.main.async { [weak uiView] in + guard let uiView, !uiView.isFirstResponder else { return } + uiView.becomeFirstResponder() + } + } + } else if context.coordinator.isFirstResponder { + context.coordinator.isFirstResponder = false + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + final class Coordinator: NSObject, UITextFieldDelegate { + var parent: BackspaceAwareTextField + var isFirstResponder = false + + init(parent: BackspaceAwareTextField) { + self.parent = parent + } + + @objc func editingChanged(_ sender: UITextField) { + let updatedText = sender.text ?? "" + parent.text = updatedText + parent.onTextChange(updatedText) + } + + func textFieldDidBeginEditing(_ textField: UITextField) { + isFirstResponder = true + animateFocusChange(for: textField, focused: true) + parent.onFocusChanged(true) + } + + func textFieldDidEndEditing(_ textField: UITextField) { + isFirstResponder = false + animateFocusChange(for: textField, focused: false) + parent.onFocusChanged(false) + } + + private func animateFocusChange(for textField: UITextField, focused: Bool) { + let targetTransform: CGAffineTransform = focused ? CGAffineTransform(scaleX: 1.05, y: 1.05) : + .identity + UIView.animate( + withDuration: 0.2, + delay: 0, + options: [.curveEaseInOut, .allowUserInteraction] + ) { + textField.transform = targetTransform + } + } + + func textField(_ textField: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String) -> Bool { + if string.isEmpty { + return true + } + + let digitsOnly = string.filter { $0.isNumber } + guard !digitsOnly.isEmpty else { + return false + } + + let currentText = textField.text ?? "" + let nsCurrent = currentText as NSString + + if digitsOnly.count > 1 || string.count > 1 { + let limit = max(parent.maxCharacters, 1) + let truncated = String(digitsOnly.prefix(limit)) + let proposed = nsCurrent.replacingCharacters(in: range, with: truncated) + parent.onTextChange(String(proposed.prefix(limit))) + return false + } + + let updated = nsCurrent.replacingCharacters(in: range, with: digitsOnly) + return updated.count <= 1 + } + } +} + +private final class BackspaceUITextField: UITextField { + var onDeleteBackward: (() -> Void)? + + override func deleteBackward() { + let wasEmpty = (text ?? "").isEmpty + super.deleteBackward() + if wasEmpty { + onDeleteBackward?() + } + } +} + +// MARK: - Preview + +#Preview("Normal State") { + @Previewable @State var code = "" + + return VStack(spacing: 32) { + Text("Enter Verification Code") + .font(.title2) + .fontWeight(.semibold) + + VerificationCodeInputField( + code: $code, + onCodeComplete: { completedCode in + print("Code completed: \(completedCode)") + }, + onCodeChange: { newCode in + print("Code changed: \(newCode)") + } + ) + + Text("Current code: \(code)") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() +} + +#Preview("Error State") { + @Previewable @State var code = "12345" + + return VStack(spacing: 32) { + Text("Enter Verification Code") + .font(.title2) + .fontWeight(.semibold) + + VerificationCodeInputField( + code: $code, + isError: true, + errorMessage: "Invalid verification code", + onCodeComplete: { completedCode in + print("Code completed: \(completedCode)") + }, + onCodeChange: { newCode in + print("Code changed: \(newCode)") + } + ) + + Text("Current code: \(code)") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() +} + +#Preview("Custom Length") { + @Previewable @State var code = "" + + return VStack(spacing: 32) { + Text("Enter 4-Digit Code") + .font(.title2) + .fontWeight(.semibold) + + VerificationCodeInputField( + code: $code, + codeLength: 4, + onCodeComplete: { completedCode in + print("Code completed: \(completedCode)") + }, + onCodeChange: { newCode in + print("Code changed: \(newCode)") + } + ) + + Text("Current code: \(code)") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Extensions/Color+Hex.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Extensions/Color+Hex.swift new file mode 100644 index 00000000000..1066edba52a --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Extensions/Color+Hex.swift @@ -0,0 +1,25 @@ +// 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 + +extension Color { + init(hex: UInt, alpha: Double = 1.0) { + let red = Double((hex >> 16) & 0xFF) / 255.0 + let green = Double((hex >> 8) & 0xFF) / 255.0 + let blue = Double(hex & 0xFF) / 255.0 + + self.init(.sRGB, red: red, green: green, blue: blue, opacity: alpha) + } +} diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 51% rename from FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/Contents.json rename to FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/AccentColor.colorset/Contents.json index 6cc12269b33..eb878970081 100644 --- a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/Contents.json +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,7 +1,11 @@ { + "colors" : [ + { + "idiom" : "universal" + } + ], "info" : { "author" : "xcode", "version" : 1 } } - diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000000..2305880107d --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Resources/Media.xcassets/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/Contents.json similarity index 98% rename from FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Resources/Media.xcassets/Contents.json rename to FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/Contents.json index 6cc12269b33..73c00596a7f 100644 --- a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Resources/Media.xcassets/Contents.json +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/Contents.json @@ -4,4 +4,3 @@ "version" : 1 } } - diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/yahoo_logo.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Contents.json similarity index 54% rename from FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/yahoo_logo.imageset/Contents.json rename to FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Contents.json index 7504763768c..ec9b5e4a368 100644 --- a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/yahoo_logo.imageset/Contents.json +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "ic_yahoo.png", + "filename" : "Product_Logomark_Authentication_Full_Color 1.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "ic_yahoo@2x.png", + "filename" : "Product_Logomark_Authentication_Full_Color 1 (1).png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "ic_yahoo@3x.png", + "filename" : "Product_Logomark_Authentication_Full_Color 1 (2).png", "idiom" : "universal", "scale" : "3x" } @@ -19,10 +19,5 @@ "info" : { "author" : "xcode", "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true, - "template-rendering-intent" : "template" } } - diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Product_Logomark_Authentication_Full_Color 1 (1).png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Product_Logomark_Authentication_Full_Color 1 (1).png new file mode 100644 index 00000000000..fecbcb6dd41 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Product_Logomark_Authentication_Full_Color 1 (1).png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Product_Logomark_Authentication_Full_Color 1 (2).png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Product_Logomark_Authentication_Full_Color 1 (2).png new file mode 100644 index 00000000000..9df93f97a38 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Product_Logomark_Authentication_Full_Color 1 (2).png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Product_Logomark_Authentication_Full_Color 1.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Product_Logomark_Authentication_Full_Color 1.png new file mode 100644 index 00000000000..bc9af3cc0c1 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Product_Logomark_Authentication_Full_Color 1.png differ diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/github_logo.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/Contents.json similarity index 54% rename from FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/github_logo.imageset/Contents.json rename to FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/Contents.json index ac6bbbb149b..482a49b90ff 100644 --- a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/github_logo.imageset/Contents.json +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "ic_github.png", + "filename" : "fui_ic_anonymous_white_24dp 2.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "ic_github@2x.png", + "filename" : "fui_ic_anonymous_white_24dp 1.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "ic_github@3x.png", + "filename" : "fui_ic_anonymous_white_24dp.png", "idiom" : "universal", "scale" : "3x" } @@ -19,10 +19,5 @@ "info" : { "author" : "xcode", "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true, - "template-rendering-intent" : "template" } } - diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp 1.png new file mode 100644 index 00000000000..4867274485c Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp 2.png new file mode 100644 index 00000000000..5c2f2bcd903 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp.png new file mode 100644 index 00000000000..9d57c10f7e0 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Resources/Media.xcassets/twitter_logo.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/Contents.json similarity index 63% rename from FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Resources/Media.xcassets/twitter_logo.imageset/Contents.json rename to FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/Contents.json index bd4799c6c36..b8005dda548 100644 --- a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Resources/Media.xcassets/twitter_logo.imageset/Contents.json +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/Contents.json @@ -1,15 +1,17 @@ { "images" : [ { - "filename" : "ic_twitter-white.png", + "filename" : "fui_ic_apple_white_24dp 2.png", "idiom" : "universal", "scale" : "1x" }, { + "filename" : "fui_ic_apple_white_24dp 1.png", "idiom" : "universal", "scale" : "2x" }, { + "filename" : "fui_ic_apple_white_24dp.png", "idiom" : "universal", "scale" : "3x" } @@ -17,10 +19,5 @@ "info" : { "author" : "xcode", "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true, - "template-rendering-intent" : "template" } } - diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp 1.png new file mode 100644 index 00000000000..d251bbd78f5 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp 2.png new file mode 100644 index 00000000000..7c239197b30 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp.png new file mode 100644 index 00000000000..0914e183233 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/microsoft_logo.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/Contents.json similarity index 53% rename from FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/microsoft_logo.imageset/Contents.json rename to FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/Contents.json index 60fe21a8d5b..daff1371565 100644 --- a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/microsoft_logo.imageset/Contents.json +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "ic_microsoft.png", + "filename" : "fui_ic_facebook_icon.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "ic_microsoft@2x.png", + "filename" : "fui_ic_facebook_icon_x2.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "ic_microsoft@3x.png", + "filename" : "fui_ic_facebook_icon_x3.png", "idiom" : "universal", "scale" : "3x" } @@ -19,10 +19,5 @@ "info" : { "author" : "xcode", "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true, - "template-rendering-intent" : "template" } } - diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_icon.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_icon.png new file mode 100644 index 00000000000..b8562f4939f Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_icon.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_icon_x2.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_icon_x2.png new file mode 100644 index 00000000000..deb223d3f1e Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_icon_x2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_icon_x3.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_icon_x3.png new file mode 100644 index 00000000000..054ffec0ad7 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_icon_x3.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/Contents.json new file mode 100644 index 00000000000..6acf81f95a4 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui_ic_github_white_24dp 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui_ic_github_white_24dp 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui_ic_github_white_24dp.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp 1.png new file mode 100644 index 00000000000..bad7f150f9a Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp 2.png new file mode 100644 index 00000000000..aa84b536ca2 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp.png new file mode 100644 index 00000000000..437f627122a Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/Contents.json new file mode 100644 index 00000000000..f6ede1b0b3b --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui_ic_googleg_color_24dp 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui_ic_googleg_color_24dp 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui_ic_googleg_color_24dp.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp 1.png new file mode 100644 index 00000000000..c9f49bd31f5 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp 2.png new file mode 100644 index 00000000000..a3c7bf97cad Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp.png new file mode 100644 index 00000000000..9df17f75fe8 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/Contents.json new file mode 100644 index 00000000000..2401fa19fad --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui_ic_mail_white_24dp 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui_ic_mail_white_24dp 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui_ic_mail_white_24dp.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp 1.png new file mode 100644 index 00000000000..b8f42d5d781 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp 2.png new file mode 100644 index 00000000000..937721e2eb5 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp.png new file mode 100644 index 00000000000..273756411a4 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/Contents.json new file mode 100644 index 00000000000..123e877f0a4 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui_ic_microsoft_24dp 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui_ic_microsoft_24dp 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui_ic_microsoft_24dp.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp 1.png new file mode 100644 index 00000000000..b43f424a5de Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp 2.png new file mode 100644 index 00000000000..5455ead2cfe Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp.png new file mode 100644 index 00000000000..98ca3614c57 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/Contents.json new file mode 100644 index 00000000000..14af6b80034 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui_ic_phone_white_24dp 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui_ic_phone_white_24dp 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui_ic_phone_white_24dp.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp 1.png new file mode 100644 index 00000000000..e040bdf1a94 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp 2.png new file mode 100644 index 00000000000..70579d4aa65 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp.png new file mode 100644 index 00000000000..27a6b5438cc Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/Contents.json new file mode 100644 index 00000000000..24174d49ac4 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui_ic_twitter_x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui_ic_twitter_x_x2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui_ic_twitter_x_x3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/fui_ic_twitter_x.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/fui_ic_twitter_x.png new file mode 100644 index 00000000000..803b7a5e689 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/fui_ic_twitter_x.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/fui_ic_twitter_x_x2.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/fui_ic_twitter_x_x2.png new file mode 100644 index 00000000000..dd26fab7baa Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/fui_ic_twitter_x_x2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/fui_ic_twitter_x_x3.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/fui_ic_twitter_x_x3.png new file mode 100644 index 00000000000..dc5ad745643 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/fui_ic_twitter_x_x3.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/Contents.json new file mode 100644 index 00000000000..9ae684f1b1a --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui_ic_yahoo_24dp 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui_ic_yahoo_24dp 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui_ic_yahoo_24dp.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp 1.png new file mode 100644 index 00000000000..0b733b01ae6 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp 2.png new file mode 100644 index 00000000000..be4fe60ce54 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp.png new file mode 100644 index 00000000000..9f6b1ec58b2 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/ProviderStyle.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/ProviderStyle.swift new file mode 100644 index 00000000000..a26a42b9c35 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/ProviderStyle.swift @@ -0,0 +1,125 @@ +// 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 authentication provider buttons +public struct ProviderStyle: Sendable { + public init(icon: Image? = nil, + backgroundColor: Color, + contentColor: Color, + iconTint: Color? = nil, + elevation: CGFloat = 2) { + self.icon = icon + self.backgroundColor = backgroundColor + self.contentColor = contentColor + self.iconTint = iconTint + self.elevation = elevation + } + + public let icon: Image? + public let backgroundColor: Color + public let contentColor: Color + public var iconTint: Color? = nil + public let shape: AnyShape = .init(RoundedRectangle(cornerRadius: 4, style: .continuous)) + public let elevation: CGFloat + + public static let empty = ProviderStyle( + icon: nil, + backgroundColor: .white, + contentColor: .black + ) + + // MARK: - Predefined Styles + + public static var google: ProviderStyle { + ProviderStyle( + icon: Image(.fuiIcGoogleg), + backgroundColor: Color(hex: 0xFFFFFF), + contentColor: Color(hex: 0x757575) + ) + } + + public static var facebook: ProviderStyle { + ProviderStyle( + icon: Image(.fuiIcFacebook), + backgroundColor: Color(hex: 0x1877F2), + contentColor: Color(hex: 0xFFFFFF) + ) + } + + public static var twitter: ProviderStyle { + ProviderStyle( + icon: Image(.fuiIcTwitterX), + backgroundColor: Color.black, + contentColor: Color(hex: 0xFFFFFF) + ) + } + + public static var apple: ProviderStyle { + ProviderStyle( + icon: Image(.fuiIcApple), + backgroundColor: Color(hex: 0x000000), + contentColor: Color(hex: 0xFFFFFF) + ) + } + + public static var phone: ProviderStyle { + ProviderStyle( + icon: Image(.fuiIcPhone), + backgroundColor: Color(hex: 0x43C5A5), + contentColor: Color(hex: 0xFFFFFF) + ) + } + + public static var github: ProviderStyle { + ProviderStyle( + icon: Image(.fuiIcGithub), + backgroundColor: Color(hex: 0x24292E), + contentColor: Color(hex: 0xFFFFFF) + ) + } + + public static var microsoft: ProviderStyle { + ProviderStyle( + icon: Image(.fuiIcMicrosoft), + backgroundColor: Color(hex: 0x2F2F2F), + contentColor: Color(hex: 0xFFFFFF) + ) + } + + public static var yahoo: ProviderStyle { + ProviderStyle( + icon: Image(.fuiIcYahoo), + backgroundColor: Color(hex: 0x720E9E), + contentColor: Color(hex: 0xFFFFFF) + ) + } + + public static var anonymous: ProviderStyle { + ProviderStyle( + icon: Image(.fuiIcAnonymous), + backgroundColor: Color(hex: 0xF4B400), + contentColor: Color(hex: 0xFFFFFF) + ) + } + + public static var email: ProviderStyle { + ProviderStyle( + icon: Image(.fuiIcMail), + backgroundColor: Color(hex: 0xD0021B), + contentColor: Color(hex: 0xFFFFFF) + ) + } +} diff --git a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/FacebookProviderAuthUI.swift b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/FacebookProviderAuthUI.swift index f069551fbb1..9e69109e5fb 100644 --- a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/FacebookProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/FacebookProviderAuthUI.swift @@ -26,11 +26,11 @@ public class FacebookProviderSwift: AuthProviderSwift { private var rawNonce: String? private var shaNonce: String? // Needed for reauthentication - var isLimitedLogin: Bool = true + private var isLimitedLogin: Bool = true - public init(scopes: [String] = ["email", "public_profile"], isLimitedLogin: Bool = true) { + public init(scopes: [String] = ["email", "public_profile"]) { self.scopes = scopes - self.isLimitedLogin = isLimitedLogin + isLimitedLogin = ATTrackingManager.trackingAuthorizationStatus != .authorized } @MainActor public func createAuthCredential() async throws -> AuthCredential { diff --git a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift index 136157da0c6..a20738bf4f4 100644 --- a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift +++ b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift @@ -12,104 +12,33 @@ // See the License for the specific language governing permissions and // limitations under the License. -import AppTrackingTransparency -import FacebookCore -import FacebookLogin import FirebaseAuth import FirebaseAuthSwiftUI +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI +/// A button for signing in with Facebook @MainActor public struct SignInWithFacebookButton { @Environment(AuthService.self) private var authService let facebookProvider: FacebookProviderSwift - @State private var showCanceledAlert = false - @State private var limitedLogin = true - @State private var showUserTrackingAlert = false - @State private var trackingAuthorizationStatus: ATTrackingManager - .AuthorizationStatus = .notDetermined public init(facebookProvider: FacebookProviderSwift) { self.facebookProvider = facebookProvider - _trackingAuthorizationStatus = State(initialValue: ATTrackingManager - .trackingAuthorizationStatus) - } - - private var limitedLoginBinding: Binding { - Binding( - get: { self.limitedLogin }, - set: { newValue in - if trackingAuthorizationStatus == .authorized { - self.limitedLogin = newValue - } else { - self.limitedLogin = true - } - } - ) - } - - func requestTrackingPermission() { - ATTrackingManager.requestTrackingAuthorization { status in - Task { @MainActor in - trackingAuthorizationStatus = status - if status != .authorized { - showUserTrackingAlert = true - } - } - } } } extension SignInWithFacebookButton: View { public var body: some View { - VStack { - Button(action: { - Task { - facebookProvider.isLimitedLogin = limitedLogin - try? await authService.signIn(facebookProvider) - } - }) { - HStack { - Image(systemName: "f.circle.fill") - .font(.title2) - .foregroundColor(.white) - Text(authService.string.facebookLoginButtonLabel) - .fontWeight(.semibold) - .foregroundColor(.white) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .frame(maxWidth: .infinity) - .background(Color.blue) - .cornerRadius(8) + AuthProviderButton( + label: authService.string.facebookLoginButtonLabel, + style: .facebook, + accessibilityId: "sign-in-with-facebook-button" + ) { + Task { + try? await authService.signIn(facebookProvider) } - .accessibilityIdentifier("sign-in-with-facebook-button") - - HStack { - Text(authService.string.authorizeUserTrackingLabel) - .font(.footnote) - .foregroundColor(.blue) - .underline() - .onTapGesture { - requestTrackingPermission() - } - Toggle(isOn: limitedLoginBinding) { - HStack { - Spacer() // This will push the text to the left of the toggle - Text(authService.string.facebookLimitedLoginLabel) - .foregroundColor(.blue) - } - } - .toggleStyle(SwitchToggleStyle(tint: .green)) - } - } - .alert(isPresented: $showUserTrackingAlert) { - Alert( - title: Text(authService.string.authorizeUserTrackingLabel), - message: Text(authService.string.facebookAuthorizeUserTrackingMessage), - dismissButton: .default(Text(authService.string.okButtonLabel)) - ) } } } diff --git a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift index 881b0ffbad2..dffb56123d5 100644 --- a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift +++ b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift @@ -19,8 +19,8 @@ // Created by Russell Wheatley on 22/05/2025. // import FirebaseAuthSwiftUI +import FirebaseAuthUIComponents import FirebaseCore -import GoogleSignInSwift import SwiftUI @MainActor @@ -31,22 +31,19 @@ public struct SignInWithGoogleButton { public init(googleProvider: AuthProviderSwift) { self.googleProvider = googleProvider } - - let customViewModel = GoogleSignInButtonViewModel( - scheme: .light, - style: .wide, - state: .normal - ) } extension SignInWithGoogleButton: View { public var body: some View { - GoogleSignInButton(viewModel: customViewModel) { + AuthProviderButton( + label: "Sign in with Google", + style: .google, + accessibilityId: "sign-in-with-google-button" + ) { Task { try? await authService.signIn(googleProvider) } } - .accessibilityIdentifier("sign-in-with-google-button") } } diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/github_logo.imageset/ic_github.png b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/github_logo.imageset/ic_github.png deleted file mode 100644 index cae7a5ddfc2..00000000000 Binary files a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/github_logo.imageset/ic_github.png and /dev/null differ diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/github_logo.imageset/ic_github@2x.png b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/github_logo.imageset/ic_github@2x.png deleted file mode 100644 index acd642bc19f..00000000000 Binary files a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/github_logo.imageset/ic_github@2x.png and /dev/null differ diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/github_logo.imageset/ic_github@3x.png b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/github_logo.imageset/ic_github@3x.png deleted file mode 100644 index ffcfd198a44..00000000000 Binary files a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/github_logo.imageset/ic_github@3x.png and /dev/null differ diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/microsoft_logo.imageset/ic_microsoft.png b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/microsoft_logo.imageset/ic_microsoft.png deleted file mode 100644 index 3a57a3e967a..00000000000 Binary files a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/microsoft_logo.imageset/ic_microsoft.png and /dev/null differ diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/microsoft_logo.imageset/ic_microsoft@2x.png b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/microsoft_logo.imageset/ic_microsoft@2x.png deleted file mode 100644 index baf89791056..00000000000 Binary files a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/microsoft_logo.imageset/ic_microsoft@2x.png and /dev/null differ diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/microsoft_logo.imageset/ic_microsoft@3x.png b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/microsoft_logo.imageset/ic_microsoft@3x.png deleted file mode 100644 index ca78fa69de5..00000000000 Binary files a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/microsoft_logo.imageset/ic_microsoft@3x.png and /dev/null differ diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/yahoo_logo.imageset/ic_yahoo.png b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/yahoo_logo.imageset/ic_yahoo.png deleted file mode 100644 index 3d0060050d2..00000000000 Binary files a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/yahoo_logo.imageset/ic_yahoo.png and /dev/null differ diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/yahoo_logo.imageset/ic_yahoo@2x.png b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/yahoo_logo.imageset/ic_yahoo@2x.png deleted file mode 100644 index 0deffb137a2..00000000000 Binary files a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/yahoo_logo.imageset/ic_yahoo@2x.png and /dev/null differ diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/yahoo_logo.imageset/ic_yahoo@3x.png b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/yahoo_logo.imageset/ic_yahoo@3x.png deleted file mode 100644 index f3bf49ed073..00000000000 Binary files a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Resources/Media.xcassets/yahoo_logo.imageset/ic_yahoo@3x.png and /dev/null differ diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift+Presets.swift b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift+Presets.swift index 080a575b504..f87f3b1a34d 100644 --- a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift+Presets.swift +++ b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift+Presets.swift @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import FirebaseAuthUIComponents import SwiftUI /// Preset configurations for common OAuth providers @@ -25,9 +26,9 @@ public extension OAuthProviderSwift { providerId: "github.com", scopes: scopes, displayName: "Sign in with GitHub", - buttonIcon: Image("github_logo", bundle: .module), - buttonBackgroundColor: .black, - buttonForegroundColor: .white + buttonIcon: ProviderStyle.github.icon!, + buttonBackgroundColor: ProviderStyle.github.backgroundColor, + buttonForegroundColor: ProviderStyle.github.contentColor ) } @@ -41,9 +42,9 @@ public extension OAuthProviderSwift { scopes: scopes, customParameters: ["prompt": "consent"], displayName: "Sign in with Microsoft", - buttonIcon: Image("microsoft_logo", bundle: .module), - buttonBackgroundColor: Color(red: 0 / 255, green: 120 / 255, blue: 212 / 255), - buttonForegroundColor: .white + buttonIcon: ProviderStyle.microsoft.icon!, + buttonBackgroundColor: ProviderStyle.microsoft.backgroundColor, + buttonForegroundColor: ProviderStyle.microsoft.contentColor ) } @@ -57,9 +58,9 @@ public extension OAuthProviderSwift { scopes: scopes, customParameters: ["prompt": "consent"], displayName: "Sign in with Yahoo", - buttonIcon: Image("yahoo_logo", bundle: .module), - buttonBackgroundColor: Color(red: 80 / 255, green: 0 / 255, blue: 155 / 255), - buttonForegroundColor: .white + buttonIcon: ProviderStyle.yahoo.icon!, + buttonBackgroundColor: ProviderStyle.yahoo.backgroundColor, + buttonForegroundColor: ProviderStyle.yahoo.contentColor ) } } diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift index a14082b328a..b7af99e5e78 100644 --- a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift +++ b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift @@ -13,6 +13,7 @@ // limitations under the License. import FirebaseAuthSwiftUI +import FirebaseAuthUIComponents import SwiftUI /// A generic OAuth sign-in button that adapts to any provider's configuration @@ -33,29 +34,26 @@ extension GenericOAuthButton: View { .foregroundColor(.red) ) } + + // Create custom style from provider configuration + var resolvedStyle: ProviderStyle { + ProviderStyle( + icon: oauthProvider.buttonIcon, + backgroundColor: oauthProvider.buttonBackgroundColor, + contentColor: oauthProvider.buttonForegroundColor + ) + } + return AnyView( - Button(action: { + AuthProviderButton( + label: oauthProvider.displayName, + style: resolvedStyle, + accessibilityId: "sign-in-with-\(oauthProvider.providerId)-button" + ) { Task { try await authService.signIn(provider) } - }) { - HStack { - oauthProvider.buttonIcon - .resizable() - .renderingMode(.template) - .scaledToFit() - .frame(width: 24, height: 24) - .foregroundColor(oauthProvider.buttonForegroundColor) - Text(oauthProvider.displayName) - .fontWeight(.semibold) - .foregroundColor(oauthProvider.buttonForegroundColor) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .background(oauthProvider.buttonBackgroundColor) - .cornerRadius(8) } - .accessibilityIdentifier("sign-in-with-\(oauthProvider.providerId)-button") ) } } diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift index bf054c2f9cb..2e4e66c8f3c 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift @@ -19,6 +19,9 @@ import SwiftUI public typealias VerificationID = String public class PhoneProviderSwift: PhoneAuthProviderSwift { + private var verificationID: String? + private var verificationCode: String? + public init() {} @MainActor public func verifyPhoneNumber(phoneNumber: String) async throws -> VerificationID { @@ -34,34 +37,19 @@ public class PhoneProviderSwift: PhoneAuthProviderSwift { } } - // Present phone auth UI and wait for user to complete the flow + public func setVerificationCode(verificationID: String, code: String) { + self.verificationID = verificationID + verificationCode = code + } + @MainActor public func createAuthCredential() async throws -> AuthCredential { - guard let presentingViewController = await (UIApplication.shared.connectedScenes - .first as? UIWindowScene)?.windows.first?.rootViewController else { - throw AuthServiceError - .rootViewControllerNotFound( - "Root View controller is not available to present Phone auth View." - ) + guard let verificationID = verificationID, + let verificationCode = verificationCode else { + throw AuthServiceError.providerAuthenticationFailed("Verification ID or code not set") } - return try await withCheckedThrowingContinuation { continuation in - let phoneAuthView = PhoneAuthView(phoneProvider: self) { result in - switch result { - case let .success(verificationID, verificationCode): - // Create the credential here - let credential = PhoneAuthProvider.provider() - .credential(withVerificationID: verificationID, verificationCode: verificationCode) - continuation.resume(returning: credential) - case let .failure(error): - continuation.resume(throwing: error) - } - } - - let hostingController = UIHostingController(rootView: phoneAuthView) - hostingController.modalPresentationStyle = .formSheet - - presentingViewController.present(hostingController, animated: true) - } + return PhoneAuthProvider.provider() + .credential(withVerificationID: verificationID, verificationCode: verificationCode) } } diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift index de045e736e6..8af609241e7 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift @@ -13,6 +13,7 @@ // limitations under the License. import FirebaseAuthSwiftUI +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -28,19 +29,13 @@ public struct PhoneAuthButtonView { extension PhoneAuthButtonView: View { public var body: some View { - Button(action: { - Task { - try await authService.signIn(phoneProvider) - } - }) { - Label("Sign in with Phone", systemImage: "phone.fill") - .foregroundColor(.white) - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.green.opacity(0.8)) // Light green - .cornerRadius(8) + AuthProviderButton( + label: "Sign in with Phone", + style: .phone, + accessibilityId: "sign-in-with-phone-button" + ) { + authService.navigator.push(.enterPhoneNumber) } - .accessibilityIdentifier("sign-in-with-phone-button") } } diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthView.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthView.swift deleted file mode 100644 index 539130415f6..00000000000 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthView.swift +++ /dev/null @@ -1,185 +0,0 @@ -// 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. - -// -// PhoneAuthView.swift -// FirebaseUI -// -// Created by Russell Wheatley on 14/05/2025. -// - -import FirebaseAuth -import FirebaseAuthSwiftUI -import FirebaseCore -import SwiftUI - -@MainActor -public struct PhoneAuthView { - @Environment(\.dismiss) private var dismiss - @State private var currentError: AlertError? - @State private var phoneNumber = "" - @State private var showVerificationCodeInput = false - @State private var verificationCode = "" - @State private var verificationID = "" - @State private var isProcessing = false - let phoneProvider: PhoneAuthProviderSwift - let completion: (Result<(String, String), Error>) -> Void - - public init(phoneProvider: PhoneAuthProviderSwift, - completion: @escaping (Result<(String, String), Error>) -> Void) { - self.phoneProvider = phoneProvider - self.completion = completion - } -} - -extension PhoneAuthView: View { - public var body: some View { - ZStack { - VStack(spacing: 16) { - // Header with cancel button - HStack { - Spacer() - Button(action: { - completion(.failure(AuthServiceError - .signInCancelled("User cancelled sign-in for Phone"))) - dismiss() - }) { - Image(systemName: "xmark.circle.fill") - .font(.title2) - .foregroundColor(.gray) - } - } - .padding(.horizontal) - .padding(.top, 8) - - if !isProcessing { - Text("Sign in with Phone") - .font(.title2) - .bold() - - LabeledContent { - TextField("Enter phone number", text: $phoneNumber) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .submitLabel(.next) - .keyboardType(.phonePad) - } label: { - Image(systemName: "phone.fill") - } - .padding(.vertical, 6) - .background(Divider(), alignment: .bottom) - .padding(.bottom, 4) - .padding(.horizontal) - - Button(action: { - Task { - isProcessing = true - do { - let id = try await phoneProvider.verifyPhoneNumber(phoneNumber: phoneNumber) - verificationID = id - showVerificationCodeInput = true - currentError = nil - } catch { - currentError = AlertError(message: error.localizedDescription) - } - isProcessing = false - } - }) { - Text("Send Code") - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .disabled(!PhoneUtils.isValidPhoneNumber(phoneNumber) || isProcessing) - .padding([.top, .bottom], 8) - .padding(.horizontal) - .buttonStyle(.borderedProminent) - - Spacer() - } else { - Spacer() - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) - .padding() - Text("Processing...") - .foregroundColor(.secondary) - Spacer() - } - } - .sheet(isPresented: $showVerificationCodeInput) { - VStack(spacing: 16) { - // Header with cancel button - HStack { - Spacer() - Button(action: { - showVerificationCodeInput = false - }) { - Image(systemName: "xmark.circle.fill") - .font(.title2) - .foregroundColor(.gray) - } - } - .padding(.horizontal) - .padding(.top, 8) - - Text("Enter Verification Code") - .font(.title2) - .bold() - - TextField("Verification Code", text: $verificationCode) - .keyboardType(.numberPad) - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - .padding(.horizontal) - - Button(action: { - Task { - isProcessing = true - // Return the verification details to createAuthCredential - completion(.success((verificationID, verificationCode))) - showVerificationCodeInput = false - dismiss() - } - }) { - Text("Verify and Sign In") - .foregroundColor(.white) - .padding() - .frame(maxWidth: .infinity) - .background(Color.green) - .cornerRadius(8) - .padding(.horizontal) - } - .disabled(verificationCode.isEmpty || isProcessing) - - Spacer() - } - .padding(.vertical) - } - } - .errorAlert(error: $currentError, okButtonLabel: "OK") - } -} - -#Preview { - FirebaseOptions.dummyConfigurationForPreview() - let phoneProvider = PhoneProviderSwift() - return PhoneAuthView(phoneProvider: phoneProvider) { result in - switch result { - case let .success(verificationID, verificationCode): - print("Preview: Got verification - ID: \(verificationID), Code: \(verificationCode)") - case let .failure(error): - print("Preview: Phone auth failed with error: \(error)") - } - } -} diff --git a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Resources/Media.xcassets/twitter_logo.imageset/ic_twitter-white.png b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Resources/Media.xcassets/twitter_logo.imageset/ic_twitter-white.png deleted file mode 100644 index 2609e580066..00000000000 Binary files a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Resources/Media.xcassets/twitter_logo.imageset/ic_twitter-white.png and /dev/null differ diff --git a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift index d85e9052e11..a874819f021 100644 --- a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift +++ b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift @@ -13,6 +13,7 @@ // limitations under the License. import FirebaseAuthSwiftUI +import FirebaseAuthUIComponents import SwiftUI /// A button for signing in with Twitter/X @@ -27,27 +28,14 @@ public struct SignInWithTwitterButton { extension SignInWithTwitterButton: View { public var body: some View { - Button(action: { + AuthProviderButton( + label: "Sign in with X", + style: .twitter, + accessibilityId: "sign-in-with-twitter-button" + ) { Task { try? await authService.signIn(provider) } - }) { - HStack { - Image("twitter_logo", bundle: .module) - .resizable() - .renderingMode(.template) - .scaledToFit() - .frame(width: 24, height: 24) - .foregroundColor(.white) - Text("Sign in with X") - .fontWeight(.semibold) - .foregroundColor(.white) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .background(Color.black) - .cornerRadius(8) } - .accessibilityIdentifier("sign-in-with-twitter-button") } } diff --git a/Package.swift b/Package.swift index 488b32e7d24..d91de108fa5 100644 --- a/Package.swift +++ b/Package.swift @@ -264,9 +264,18 @@ let package = Package( .headerSearchPath("../../"), ] ), + .target( + name: "FirebaseAuthUIComponents", + dependencies: [], + path: "FirebaseSwiftUI/FirebaseAuthUIComponents/Sources", + resources: [ + .process("Resources"), + ] + ), .target( name: "FirebaseAuthSwiftUI", dependencies: [ + "FirebaseAuthUIComponents", .product(name: "FirebaseAuth", package: "firebase-ios-sdk"), ], path: "FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources", @@ -274,7 +283,7 @@ let package = Package( .process("Strings"), ], swiftSettings: [ - .swiftLanguageVersion(.v6), + .swiftLanguageMode(.v6), ] ), .testTarget( @@ -282,19 +291,20 @@ let package = Package( dependencies: ["FirebaseAuthSwiftUI"], path: "FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/", swiftSettings: [ - .swiftLanguageVersion(.v6), + .swiftLanguageMode(.v6), ] ), .target( name: "FirebaseGoogleSwiftUI", dependencies: [ "FirebaseAuthSwiftUI", + "FirebaseAuthUIComponents", .product(name: "GoogleSignIn", package: "GoogleSignIn-iOS"), .product(name: "GoogleSignInSwift", package: "GoogleSignIn-iOS"), ], path: "FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources", swiftSettings: [ - .swiftLanguageVersion(.v6), + .swiftLanguageMode(.v6), ] ), .testTarget( @@ -302,19 +312,20 @@ let package = Package( dependencies: ["FirebaseGoogleSwiftUI"], path: "FirebaseSwiftUI/FirebaseGoogleSwiftUI/Tests/", swiftSettings: [ - .swiftLanguageVersion(.v6), + .swiftLanguageMode(.v6), ] ), .target( name: "FirebaseFacebookSwiftUI", dependencies: [ "FirebaseAuthSwiftUI", + "FirebaseAuthUIComponents", .product(name: "FacebookLogin", package: "facebook-ios-sdk"), .product(name: "FacebookCore", package: "facebook-ios-sdk"), ], path: "FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources", swiftSettings: [ - .swiftLanguageVersion(.v6), + .swiftLanguageMode(.v6), ] ), .testTarget( @@ -322,17 +333,18 @@ let package = Package( dependencies: ["FirebaseFacebookSwiftUI"], path: "FirebaseSwiftUI/FirebaseFacebookSwiftUI/Tests/", swiftSettings: [ - .swiftLanguageVersion(.v6), + .swiftLanguageMode(.v6), ] ), .target( name: "FirebasePhoneAuthSwiftUI", dependencies: [ "FirebaseAuthSwiftUI", + "FirebaseAuthUIComponents", ], path: "FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources", swiftSettings: [ - .swiftLanguageVersion(.v6), + .swiftLanguageMode(.v6), ] ), .testTarget( @@ -340,20 +352,18 @@ let package = Package( dependencies: ["FirebasePhoneAuthSwiftUI"], path: "FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Tests/", swiftSettings: [ - .swiftLanguageVersion(.v6), + .swiftLanguageMode(.v6), ] ), .target( name: "FirebaseTwitterSwiftUI", dependencies: [ "FirebaseAuthSwiftUI", + "FirebaseAuthUIComponents", ], path: "FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources", - resources: [ - .process("Resources") - ], swiftSettings: [ - .swiftLanguageVersion(.v6), + .swiftLanguageMode(.v6), ] ), .testTarget( @@ -361,17 +371,18 @@ let package = Package( dependencies: ["FirebaseTwitterSwiftUI"], path: "FirebaseSwiftUI/FirebaseTwitterSwiftUI/Tests/", swiftSettings: [ - .swiftLanguageVersion(.v6), + .swiftLanguageMode(.v6), ] ), .target( name: "FirebaseAppleSwiftUI", dependencies: [ "FirebaseAuthSwiftUI", + "FirebaseAuthUIComponents", ], path: "FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources", swiftSettings: [ - .swiftLanguageVersion(.v6), + .swiftLanguageMode(.v6), ] ), .testTarget( @@ -379,20 +390,18 @@ let package = Package( dependencies: ["FirebaseAppleSwiftUI"], path: "FirebaseSwiftUI/FirebaseAppleSwiftUI/Tests/", swiftSettings: [ - .swiftLanguageVersion(.v6), + .swiftLanguageMode(.v6), ] ), .target( name: "FirebaseOAuthSwiftUI", dependencies: [ "FirebaseAuthSwiftUI", + "FirebaseAuthUIComponents", ], path: "FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources", - resources: [ - .process("Resources") - ], swiftSettings: [ - .swiftLanguageVersion(.v6), + .swiftLanguageMode(.v6), ] ), .testTarget( @@ -400,7 +409,7 @@ let package = Package( dependencies: ["FirebaseOAuthSwiftUI"], path: "FirebaseSwiftUI/FirebaseOAuthSwiftUI/Tests/", swiftSettings: [ - .swiftLanguageVersion(.v6), + .swiftLanguageMode(.v6), ] ), ] diff --git a/samples/swift/FirebaseUI-demo-swift.xcodeproj/project.pbxproj b/samples/swift/FirebaseUI-demo-swift.xcodeproj/project.pbxproj index dabd26609d7..d236027bae3 100644 --- a/samples/swift/FirebaseUI-demo-swift.xcodeproj/project.pbxproj +++ b/samples/swift/FirebaseUI-demo-swift.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 4617B75BF5701E48387F35F6 /* Pods_FirebaseUI_demo_swift.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 25F197CFBB06559F4B537E37 /* Pods_FirebaseUI_demo_swift.framework */; }; + 6096FEF87E5B53C0792BC146 /* Pods_FirebaseUI_demo_swiftTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 637019691D9C301621749DE1 /* Pods_FirebaseUI_demo_swiftTests.framework */; }; 89B2924722568B1C00CEF7D7 /* twtrsymbol.png in Resources */ = {isa = PBXBuildFile; fileRef = 89B2924622568B1C00CEF7D7 /* twtrsymbol.png */; }; 8D5F93B01D9B192D00D5A2E4 /* StorageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D5F93AF1D9B192D00D5A2E4 /* StorageViewController.swift */; }; 8DABC9891D3D82D600453807 /* FUIAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DABC9881D3D82D600453807 /* FUIAppDelegate.swift */; }; @@ -49,6 +51,9 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 25F197CFBB06559F4B537E37 /* Pods_FirebaseUI_demo_swift.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_FirebaseUI_demo_swift.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 637019691D9C301621749DE1 /* Pods_FirebaseUI_demo_swiftTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_FirebaseUI_demo_swiftTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 6F5993EFB11CBA0003C0DE94 /* Pods-FirebaseUI-demo-swiftTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FirebaseUI-demo-swiftTests.release.xcconfig"; path = "Target Support Files/Pods-FirebaseUI-demo-swiftTests/Pods-FirebaseUI-demo-swiftTests.release.xcconfig"; sourceTree = ""; }; 89B2924622568B1C00CEF7D7 /* twtrsymbol.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = twtrsymbol.png; sourceTree = ""; }; 8D5F93AF1D9B192D00D5A2E4 /* StorageViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StorageViewController.swift; sourceTree = ""; }; 8DABC9851D3D82D600453807 /* FirebaseUI-demo-swift.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FirebaseUI-demo-swift.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -59,6 +64,8 @@ 8DABC99D1D3D82D600453807 /* FirebaseUI-demo-swiftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FirebaseUI-demo-swiftTests.swift"; sourceTree = ""; }; 8DABC99F1D3D82D600453807 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 8DD51E361D873B0D00E2CA51 /* UIStoryboardExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIStoryboardExtension.swift; sourceTree = ""; }; + 8E009A2D4461F77B9CEB0C4D /* Pods-FirebaseUI-demo-swiftTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FirebaseUI-demo-swiftTests.debug.xcconfig"; path = "Target Support Files/Pods-FirebaseUI-demo-swiftTests/Pods-FirebaseUI-demo-swiftTests.debug.xcconfig"; sourceTree = ""; }; + A885F4D8D84B72ADACBE725B /* Pods-FirebaseUI-demo-swift.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FirebaseUI-demo-swift.release.xcconfig"; path = "Target Support Files/Pods-FirebaseUI-demo-swift/Pods-FirebaseUI-demo-swift.release.xcconfig"; sourceTree = ""; }; C302C1D51D91CC7B00ADBD41 /* FUIAuthViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FUIAuthViewController.swift; sourceTree = ""; }; C302C1D71D91CC7B00ADBD41 /* ChatCollectionViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatCollectionViewCell.swift; sourceTree = ""; }; C302C1D81D91CC7B00ADBD41 /* ChatViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatViewController.swift; sourceTree = ""; }; @@ -166,6 +173,7 @@ C39BC04F1DB812330060F6AF /* FUICustomPasswordVerificationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FUICustomPasswordVerificationViewController.swift; sourceTree = ""; }; C39BC0501DB812330060F6AF /* FUICustomPasswordVerificationViewController.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = FUICustomPasswordVerificationViewController.xib; sourceTree = ""; }; C3F23ECC1D80F3300020509F /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + DB206ACE5B8C8DC3A2E47E00 /* Pods-FirebaseUI-demo-swift.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FirebaseUI-demo-swift.debug.xcconfig"; path = "Target Support Files/Pods-FirebaseUI-demo-swift/Pods-FirebaseUI-demo-swift.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -173,6 +181,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 4617B75BF5701E48387F35F6 /* Pods_FirebaseUI_demo_swift.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -180,6 +189,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 6096FEF87E5B53C0792BC146 /* Pods_FirebaseUI_demo_swiftTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -202,6 +212,7 @@ 8DABC99C1D3D82D600453807 /* FirebaseUI-demo-swiftTests */, 8DABC9861D3D82D600453807 /* Products */, 9C43BF8CA810E7C909775084 /* Pods */, + C129AF2D5B3F8906D7A96042 /* Frameworks */, ); sourceTree = ""; }; @@ -240,10 +251,23 @@ 9C43BF8CA810E7C909775084 /* Pods */ = { isa = PBXGroup; children = ( + DB206ACE5B8C8DC3A2E47E00 /* Pods-FirebaseUI-demo-swift.debug.xcconfig */, + A885F4D8D84B72ADACBE725B /* Pods-FirebaseUI-demo-swift.release.xcconfig */, + 8E009A2D4461F77B9CEB0C4D /* Pods-FirebaseUI-demo-swiftTests.debug.xcconfig */, + 6F5993EFB11CBA0003C0DE94 /* Pods-FirebaseUI-demo-swiftTests.release.xcconfig */, ); path = Pods; sourceTree = ""; }; + C129AF2D5B3F8906D7A96042 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 25F197CFBB06559F4B537E37 /* Pods_FirebaseUI_demo_swift.framework */, + 637019691D9C301621749DE1 /* Pods_FirebaseUI_demo_swiftTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; C302C1D31D91CC7B00ADBD41 /* Samples */ = { isa = PBXGroup; children = ( @@ -305,9 +329,11 @@ isa = PBXNativeTarget; buildConfigurationList = 8DABC9A21D3D82D600453807 /* Build configuration list for PBXNativeTarget "FirebaseUI-demo-swift" */; buildPhases = ( + 3D86CE81C1F8711347A14B72 /* [CP] Check Pods Manifest.lock */, 8DABC9811D3D82D600453807 /* Sources */, 8DABC9821D3D82D600453807 /* Frameworks */, 8DABC9831D3D82D600453807 /* Resources */, + 04D211F7D3B42A6D19A9E000 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -322,6 +348,7 @@ isa = PBXNativeTarget; buildConfigurationList = 8DABC9A51D3D82D600453807 /* Build configuration list for PBXNativeTarget "FirebaseUI-demo-swiftTests" */; buildPhases = ( + 94F892B9CDD1C2428D7F724B /* [CP] Check Pods Manifest.lock */, 8DABC9951D3D82D600453807 /* Sources */, 8DABC9961D3D82D600453807 /* Frameworks */, 8DABC9971D3D82D600453807 /* Resources */, @@ -348,7 +375,6 @@ TargetAttributes = { 8DABC9841D3D82D600453807 = { CreatedOnToolsVersion = 7.3.1; - DevelopmentTeam = YYX2P3XVJ7; LastSwiftMigration = 1020; SystemCapabilities = { com.apple.BackgroundModes = { @@ -497,6 +523,149 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 04D211F7D3B42A6D19A9E000 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-FirebaseUI-demo-swift/Pods-FirebaseUI-demo-swift-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/AppAuth/AppAuth.framework", + "${BUILT_PRODUCTS_DIR}/AppCheckCore/AppCheckCore.framework", + "${BUILT_PRODUCTS_DIR}/BoringSSL-GRPC/openssl_grpc.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseAnonymousAuthUI/FirebaseAnonymousAuthUI.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseAppCheckInterop/FirebaseAppCheckInterop.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseAuth/FirebaseAuth.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseAuthInterop/FirebaseAuthInterop.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseAuthUI/FirebaseAuthUI.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseCore/FirebaseCore.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseCoreExtension/FirebaseCoreExtension.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseCoreInternal/FirebaseCoreInternal.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseDatabase/FirebaseDatabase.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseDatabaseUI/FirebaseDatabaseUI.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseEmailAuthUI/FirebaseEmailAuthUI.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseFacebookAuthUI/FirebaseFacebookAuthUI.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseFirestore/FirebaseFirestore.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseFirestoreInternal/FirebaseFirestoreInternal.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseFirestoreUI/FirebaseFirestoreUI.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseGoogleAuthUI/FirebaseGoogleAuthUI.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseOAuthUI/FirebaseOAuthUI.framework", + "${BUILT_PRODUCTS_DIR}/FirebasePhoneAuthUI/FirebasePhoneAuthUI.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseSharedSwift/FirebaseSharedSwift.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseStorage/FirebaseStorage.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseStorageUI/FirebaseStorageUI.framework", + "${BUILT_PRODUCTS_DIR}/GTMAppAuth/GTMAppAuth.framework", + "${BUILT_PRODUCTS_DIR}/GTMSessionFetcher/GTMSessionFetcher.framework", + "${BUILT_PRODUCTS_DIR}/GoogleSignIn/GoogleSignIn.framework", + "${BUILT_PRODUCTS_DIR}/GoogleUtilities/GoogleUtilities.framework", + "${BUILT_PRODUCTS_DIR}/PromisesObjC/FBLPromises.framework", + "${BUILT_PRODUCTS_DIR}/RecaptchaInterop/RecaptchaInterop.framework", + "${BUILT_PRODUCTS_DIR}/SDWebImage/SDWebImage.framework", + "${BUILT_PRODUCTS_DIR}/abseil/absl.framework", + "${BUILT_PRODUCTS_DIR}/gRPC-C++/grpcpp.framework", + "${BUILT_PRODUCTS_DIR}/gRPC-Core/grpc.framework", + "${BUILT_PRODUCTS_DIR}/leveldb-library/leveldb.framework", + "${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/FBAEMKit/FBAEMKit.framework/FBAEMKit", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/FBSDKCoreKit/FBSDKCoreKit.framework/FBSDKCoreKit", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/FBSDKCoreKit_Basics/FBSDKCoreKit_Basics.framework/FBSDKCoreKit_Basics", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/FBSDKLoginKit/FBSDKLoginKit.framework/FBSDKLoginKit", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/AppAuth.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/AppCheckCore.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/openssl_grpc.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseAnonymousAuthUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseAppCheckInterop.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseAuth.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseAuthInterop.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseAuthUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseCore.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseCoreExtension.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseCoreInternal.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseDatabase.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseDatabaseUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseEmailAuthUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseFacebookAuthUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseFirestore.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseFirestoreInternal.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseFirestoreUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseGoogleAuthUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseOAuthUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebasePhoneAuthUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseSharedSwift.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseStorage.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseStorageUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GTMAppAuth.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GTMSessionFetcher.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleSignIn.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleUtilities.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FBLPromises.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RecaptchaInterop.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImage.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/absl.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/grpcpp.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/grpc.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/leveldb.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FBAEMKit.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FBSDKCoreKit.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FBSDKCoreKit_Basics.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FBSDKLoginKit.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-FirebaseUI-demo-swift/Pods-FirebaseUI-demo-swift-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 3D86CE81C1F8711347A14B72 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-FirebaseUI-demo-swift-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 94F892B9CDD1C2428D7F724B /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-FirebaseUI-demo-swiftTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 8DABC9811D3D82D600453807 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -748,12 +917,13 @@ }; 8DABC9A31D3D82D600453807 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = DB206ACE5B8C8DC3A2E47E00 /* Pods-FirebaseUI-demo-swift.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; BITCODE_GENERATION_MODE = ""; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "FirebaseUI-demo-swift/FirebaseUI-demo-swift.entitlements"; - DEVELOPMENT_TEAM = YYX2P3XVJ7; + DEVELOPMENT_TEAM = ""; HEADER_SEARCH_PATHS = ( "$(inherited)", "${PODS_ROOT}/Firebase/Core/Sources", @@ -792,7 +962,7 @@ BITCODE_GENERATION_MODE = ""; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "FirebaseUI-demo-swift/FirebaseUI-demo-swift.entitlements"; - DEVELOPMENT_TEAM = YYX2P3XVJ7; + DEVELOPMENT_TEAM = ""; HEADER_SEARCH_PATHS = ( "$(inherited)", "${PODS_ROOT}/Firebase/Core/Sources", diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/project.pbxproj b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/project.pbxproj index abec964899c..7b274a794df 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/project.pbxproj +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/project.pbxproj @@ -462,7 +462,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"FirebaseSwiftUIExample/Preview Content\""; - DEVELOPMENT_TEAM = YYX2P3XVJ7; + DEVELOPMENT_TEAM = 3G33A99C47; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = FirebaseSwiftUIExample/Info.plist; @@ -478,7 +478,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.firebase.auth.example; + PRODUCT_BUNDLE_IDENTIFIER = aob.flutter.plugins.firebase.auth.example; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; @@ -495,7 +495,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"FirebaseSwiftUIExample/Preview Content\""; - DEVELOPMENT_TEAM = YYX2P3XVJ7; + DEVELOPMENT_TEAM = 3G33A99C47; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = FirebaseSwiftUIExample/Info.plist; @@ -511,7 +511,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.firebase.auth.example; + PRODUCT_BUNDLE_IDENTIFIER = aob.flutter.plugins.firebase.auth.example; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/xcshareddata/xcschemes/FirebaseSwiftUIExample.xcscheme b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/xcshareddata/xcschemes/FirebaseSwiftUIExample.xcscheme index 30faacec200..bbe3e1cad50 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/xcshareddata/xcschemes/FirebaseSwiftUIExample.xcscheme +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/xcshareddata/xcschemes/FirebaseSwiftUIExample.xcscheme @@ -31,7 +31,7 @@ shouldAutocreateTestPlan = "YES"> 16dbbdf0cfb309034a6ad98ac2a21688 FacebookDisplayName Firebase Swift UI App + FirebaseAppDelegateProxyEnabled + LSApplicationQueriesSchemes fbapi @@ -39,7 +41,5 @@ fetch remote-notification - FirebaseAppDelegateProxyEnabled - diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/TestView.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/TestView.swift index ea1399cd029..f5314193014 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/TestView.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/TestView.swift @@ -19,14 +19,14 @@ // Created by Russell Wheatley on 23/04/2025. // +import FirebaseAppleSwiftUI import FirebaseAuth import FirebaseAuthSwiftUI import FirebaseFacebookSwiftUI import FirebaseGoogleSwiftUI +import FirebaseOAuthSwiftUI import FirebasePhoneAuthSwiftUI -import FirebaseAppleSwiftUI import FirebaseTwitterSwiftUI -import FirebaseOAuthSwiftUI import SwiftUI struct TestView: View { @@ -66,9 +66,13 @@ struct TestView: View { .withOAuthSignIn(OAuthProviderSwift.yahoo()) .withFacebookSignIn() .withEmailSignIn() + authService.isPresented = true } var body: some View { - AuthPickerView().environment(authService) + AuthPickerView { + Text("Hello, world!") + } + .environment(authService) } } diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/UITestUtils.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/UITestUtils.swift index 6d9586d2488..0f2f3713ee3 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/UITestUtils.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/UITestUtils.swift @@ -13,4 +13,3 @@ public let testRunner = CommandLine.arguments.contains("--test-view-enabled") func signOut() throws { try Auth.auth().signOut() } - diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift index 4bf18e9345e..408576e12a8 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift @@ -101,9 +101,8 @@ struct FirebaseSwiftUIExampleTests { let service = try await prepareFreshAuthService() #expect(service.authenticationState == .unauthenticated) - #expect(service.authView == .authPicker) + #expect(service.authView == nil) #expect(service.currentError == nil) - #expect(service.signedInCredential == nil) #expect(service.currentUser == nil) try await service.createUser(email: createEmail(), password: kPassword) @@ -116,7 +115,7 @@ struct FirebaseSwiftUIExampleTests { service.currentUser != nil } #expect(service.currentUser != nil) - #expect(service.authView == .authPicker) + #expect(service.authView == nil) #expect(service.currentError == nil) } @@ -137,9 +136,8 @@ struct FirebaseSwiftUIExampleTests { service.currentUser == nil } #expect(service.currentUser == nil) - #expect(service.authView == .authPicker) + #expect(service.authView == nil) #expect(service.currentError == nil) - #expect(service.signedInCredential == nil) try await service.signIn(email: email, password: kPassword) @@ -152,11 +150,7 @@ struct FirebaseSwiftUIExampleTests { service.currentUser != nil } #expect(service.currentUser != nil) - try await waitForStateChange { - service.signedInCredential != nil - } - #expect(service.signedInCredential != nil) - #expect(service.authView == .authPicker) + #expect(service.authView == nil) #expect(service.currentError == nil) } } diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift index b134e5fb346..b824d7d1bc3 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift @@ -47,11 +47,11 @@ final class FirebaseSwiftUIExampleUITests: XCTestCase { func testSignInDisplaysSignedInView() async throws { let email = createEmail() let password = "123456" - + // Create user in test runner BEFORE launching app // User will exist in emulator, but app starts unauthenticated try await createTestUser(email: email, password: password) - + // Now launch the app - it connects to emulator but isn't signed in let app = createTestApp() app.launch() @@ -94,13 +94,14 @@ final class FirebaseSwiftUIExampleUITests: XCTestCase { let passwordRecoveryButton = app.buttons["password-recovery-button"] XCTAssertTrue(passwordRecoveryButton.exists, "Password recovery button should exist") passwordRecoveryButton.tap() - let passwordRecoveryText = app.staticTexts["password-recovery-text"] + let passwordRecoveryText = app.staticTexts["Send a password recovery link to your email"] + .firstMatch XCTAssertTrue( passwordRecoveryText.waitForExistence(timeout: 10), "Password recovery text should exist after routing to PasswordRecoveryView" ) - let passwordRecoveryBackButton = app.buttons["password-recovery-back-button"] + let passwordRecoveryBackButton = app.navigationBars.buttons.element(boundBy: 0) XCTAssertTrue(passwordRecoveryBackButton.exists, "Password back button should exist") passwordRecoveryBackButton.tap() @@ -114,14 +115,14 @@ final class FirebaseSwiftUIExampleUITests: XCTestCase { XCTAssertTrue(emailLinkSignInButton.exists, "Email link sign-in button should exist") emailLinkSignInButton.tap() - let emailLinkText = app.staticTexts["email-link-title-text"] + let emailLinkText = app.staticTexts["Send a sign-in link to your email"].firstMatch XCTAssertTrue( emailLinkText.waitForExistence(timeout: 10), "Email link text should exist after pressing email link button in AuthPickerView" ) - let emailLinkBackButton = app.buttons["email-link-back-button"] + let emailLinkBackButton = app.navigationBars.buttons.element(boundBy: 0) XCTAssertTrue(emailLinkBackButton.exists, "Email link back button should exist") emailLinkBackButton.tap() @@ -176,10 +177,12 @@ final class FirebaseSwiftUIExampleUITests: XCTestCase { // Wait for the auth screen to disappear (email field should no longer exist) let emailFieldDisappeared = NSPredicate(format: "exists == false") - let expectation = XCTNSPredicateExpectation(predicate: emailFieldDisappeared, object: emailField) + let expectation = XCTNSPredicateExpectation( + predicate: emailFieldDisappeared, + object: emailField + ) let result = XCTWaiter().wait(for: [expectation], timeout: 10.0) XCTAssertEqual(result, .completed, "Email field should disappear after sign-up") - // Wait for user creation and signed-in view to appear let signedInText = app.staticTexts["signed-in-text"] diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift index 931ace293ef..1b8c981f036 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift @@ -31,10 +31,10 @@ final class MFAEnrollmentUITests: XCTestCase { @MainActor func testMFAManagementButtonExistsAndIsTappable() async throws { let email = createEmail() - + // Create user in test runner before launching app try await createTestUser(email: email) - + let app = createTestApp(mfaEnabled: true) app.launch() @@ -63,10 +63,10 @@ final class MFAEnrollmentUITests: XCTestCase { @MainActor func testMFAEnrollmentNavigationFromManagement() async throws { let email = createEmail() - + // Create user in test runner before launching app try await createTestUser(email: email) - + let app = createTestApp(mfaEnabled: true) app.launch() @@ -98,10 +98,10 @@ final class MFAEnrollmentUITests: XCTestCase { @MainActor func testFactorTypePickerExistsAndWorks() async throws { let email = createEmail() - + // Create user in test runner before launching app try await createTestUser(email: email) - + let app = createTestApp(mfaEnabled: true) app.launch() @@ -127,10 +127,10 @@ final class MFAEnrollmentUITests: XCTestCase { @MainActor func testStartEnrollmentButtonExistsAndWorks() async throws { let email = createEmail() - + // Create user in test runner before launching app try await createTestUser(email: email) - + let app = createTestApp(mfaEnabled: true) app.launch() @@ -167,7 +167,7 @@ final class MFAEnrollmentUITests: XCTestCase { // 1) Create user in test runner before launching app (with email verification) let email = createEmail() try await createTestUser(email: email, verifyEmail: true) - + let app = createTestApp(mfaEnabled: true) app.launch() @@ -193,11 +193,25 @@ final class MFAEnrollmentUITests: XCTestCase { XCTAssertTrue(startButton.waitForExistence(timeout: 10)) startButton.tap() - // 6) Enter phone number and display name, then press "Send Code" + // 6) Select UK country code and enter phone number (without dial code) + // Find and tap the country selector - try multiple approaches since it's embedded in the + // TextField + let countrySelector = app.buttons["🇺🇸 +1"] + XCTAssertTrue(countrySelector.waitForExistence(timeout: 5)) + + countrySelector.tap() + + // Select United Kingdom (+44) - try multiple element types + let ukOption = app.buttons["country-option-GB"] + XCTAssertTrue(ukOption.waitForExistence(timeout: 5)) + + ukOption.tap() + + // Enter phone number (without dial code) let phoneField = app.textFields["phone-number-field"] XCTAssertTrue(phoneField.waitForExistence(timeout: 10)) - let phoneNumber = "+447444555666" - UIPasteboard.general.string = phoneNumber + let phoneNumberWithoutDialCode = "7444555666" + UIPasteboard.general.string = phoneNumberWithoutDialCode phoneField.tap() phoneField.press(forDuration: 1.2) app.menuItems["Paste"].tap() @@ -219,13 +233,15 @@ final class MFAEnrollmentUITests: XCTestCase { XCTAssertTrue(verificationCodeField.waitForExistence(timeout: 15)) // Fetch the latest SMS verification code generated by the emulator for this phone number - let code = try await getLastSmsCode(specificPhone: phoneNumber) + // The emulator stores the full phone number with dial code + let fullPhoneNumber = "+44\(phoneNumberWithoutDialCode)" + let code = try await getLastSmsCode(specificPhone: fullPhoneNumber) UIPasteboard.general.string = code verificationCodeField.tap() verificationCodeField.press(forDuration: 1.2) app.menuItems["Paste"].tap() - + // Test resend code button exists let resendButton = app.buttons["resend-code-button"] XCTAssertTrue(resendButton.exists, "Resend code button should exist") @@ -247,7 +263,10 @@ final class MFAEnrollmentUITests: XCTestCase { XCTAssertTrue(enrolledMethodsHeader.waitForExistence(timeout: 10)) // Find a "Remove" button for any enrolled factor (identifier starts with "remove-factor-") - let removeButton = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH %@", "remove-factor-")).firstMatch + let removeButton = app.buttons.matching(NSPredicate( + format: "identifier BEGINSWITH %@", + "remove-factor-" + )).firstMatch XCTAssertTrue(removeButton.waitForExistence(timeout: 10)) // 10) Remove the enrolled SMS factor and verify we're back to setup state @@ -257,16 +276,15 @@ final class MFAEnrollmentUITests: XCTestCase { XCTAssertTrue(setupButton.waitForExistence(timeout: 15)) } - // MARK: - TOTP Enrollment Flow Tests @MainActor func testTOTPEnrollmentFlowUI() async throws { let email = createEmail() - + // Create user in test runner before launching app (with email verification) try await createTestUser(email: email, verifyEmail: true) - + let app = createTestApp(mfaEnabled: true) app.launch() @@ -322,10 +340,10 @@ final class MFAEnrollmentUITests: XCTestCase { @MainActor func testErrorMessageDisplay() async throws { let email = createEmail() - + // Create user in test runner before launching app try await createTestUser(email: email) - + let app = createTestApp(mfaEnabled: true) app.launch() @@ -342,10 +360,10 @@ final class MFAEnrollmentUITests: XCTestCase { @MainActor func testBackButtonNavigation() async throws { let email = createEmail() - + // Create user in test runner before launching app try await createTestUser(email: email) - + let app = createTestApp(mfaEnabled: true) app.launch() @@ -354,52 +372,22 @@ final class MFAEnrollmentUITests: XCTestCase { try navigateToMFAEnrollment(app: app) // Test back button exists - let cancelButton = app.buttons["mfa-back-button"] - XCTAssertTrue(cancelButton.exists, "Back button should exist") - XCTAssertTrue(cancelButton.isEnabled, "Back button should be enabled") + let backButton = app.navigationBars.buttons.element(boundBy: 0) + XCTAssertTrue(backButton.exists, "Back button should exist") // Tap cancel button - cancelButton.tap() - - // Should navigate back to signed in view - let signedInText = app.staticTexts["signed-in-text"] - XCTAssertTrue( - signedInText.waitForExistence(timeout: 5), - "Should navigate back to signed in view" - ) - } - - @MainActor - func testBackButtonFromMFAManagement() async throws { - let email = createEmail() - - // Create user in test runner before launching app - try await createTestUser(email: email) - - let app = createTestApp(mfaEnabled: true) - app.launch() - - // Sign in and navigate to MFA management - try signInToApp(app: app, email: email) - app.buttons["mfa-management-button"].tap() - - // Test back button exists - let backButton = app.buttons["back-button"] - XCTAssertTrue(backButton.waitForExistence(timeout: 5), "Back button should exist") - XCTAssertTrue(backButton.isEnabled, "Back button should be enabled") - - // Tap back button backButton.tap() - // Should navigate back to signed in view - let signedInText = app.staticTexts["signed-in-text"] + // Should navigate back to manage MFA View + let signedInText = app.buttons["setup-mfa-button"] XCTAssertTrue( signedInText.waitForExistence(timeout: 5), - "Should navigate back to signed in view" + "Should navigate back to setup MFA view" ) } // MARK: - Helper Methods + @MainActor private func signInToApp(app: XCUIApplication, email: String) throws { let password = "123456" @@ -431,11 +419,15 @@ final class MFAEnrollmentUITests: XCTestCase { } // Wait for signed-in state - // Wait for signed-in state + // Wait for signed-in state let signedInText = app.staticTexts["signed-in-text"] - XCTAssertTrue(signedInText.waitForExistence(timeout: 30), "SignedInView should be visible after login") + XCTAssertTrue( + signedInText.waitForExistence(timeout: 30), + "SignedInView should be visible after login" + ) XCTAssertTrue(signedInText.exists, "SignedInView should be visible after login") } + @MainActor private func navigateToMFAEnrollment(app: XCUIApplication) throws { // Navigate to MFA management @@ -458,12 +450,12 @@ final class MFAEnrollmentUITests: XCTestCase { } struct VerificationCodesResponse: Codable { - let verificationCodes: [VerificationCode]? + let verificationCodes: [VerificationCode]? } struct VerificationCode: Codable { - let phoneNumber: String - let code: String + let phoneNumber: String + let code: String } /// Retrieves the last SMS verification code from Firebase Auth Emulator @@ -471,37 +463,64 @@ struct VerificationCode: Codable { /// - Returns: The verification code as a String /// - Throws: Error if unable to retrieve codes private func getLastSmsCode(specificPhone: String? = nil) async throws -> String { - let getSmsCodesUrl = "http://127.0.0.1:9099/emulator/v1/projects/flutterfire-e2e-tests/verificationCodes" + let getSmsCodesUrl = + "http://127.0.0.1:9099/emulator/v1/projects/flutterfire-e2e-tests/verificationCodes" guard let url = URL(string: getSmsCodesUrl) else { - throw NSError(domain: "getLastSmsCode", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to create URL for SMS codes endpoint"]) + throw NSError( + domain: "getLastSmsCode", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to create URL for SMS codes endpoint"] + ) } do { - let (data, _) = try await URLSession.shared.data(from: url) + let (data, _) = try await URLSession.shared.data(from: url) - let decoder = JSONDecoder() - let codesResponse = try decoder.decode(VerificationCodesResponse.self, from: data) + let decoder = JSONDecoder() + let codesResponse = try decoder.decode(VerificationCodesResponse.self, from: data) - guard let codes = codesResponse.verificationCodes, !codes.isEmpty else { - throw NSError(domain: "getLastSmsCode", code: -1, userInfo: [NSLocalizedDescriptionKey: "No SMS verification codes found in emulator"]) - } + guard let codes = codesResponse.verificationCodes, !codes.isEmpty else { + throw NSError( + domain: "getLastSmsCode", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "No SMS verification codes found in emulator"] + ) + } - if let specificPhone = specificPhone { - // Search backwards through codes for the specific phone number - for code in codes.reversed() { - if code.phoneNumber == specificPhone { - return code.code - } - } - throw NSError(domain: "getLastSmsCode", code: -1, userInfo: [NSLocalizedDescriptionKey: "No SMS verification code found for phone number: \(specificPhone)"]) - } else { - // Return the last code in the array - return codes.last!.code + if let specificPhone = specificPhone { + // Search backwards through codes for the specific phone number + for code in codes.reversed() { + if code.phoneNumber == specificPhone { + return code.code + } } + throw NSError( + domain: "getLastSmsCode", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: "No SMS verification code found for phone number: \(specificPhone)", + ] + ) + } else { + // Return the last code in the array + return codes.last!.code + } } catch let error as DecodingError { - throw NSError(domain: "getLastSmsCode", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse SMS codes response: \(error.localizedDescription)"]) + throw NSError( + domain: "getLastSmsCode", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: "Failed to parse SMS codes response: \(error.localizedDescription)", + ] + ) } catch { - throw NSError(domain: "getLastSmsCode", code: -1, userInfo: [NSLocalizedDescriptionKey: "Network request failed: \(error.localizedDescription)"]) + throw NSError( + domain: "getLastSmsCode", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: "Network request failed: \(error.localizedDescription)", + ] + ) } } diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift index 93eff325eec..e1026b2663a 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift @@ -38,81 +38,82 @@ final class MFAResolutionUITests: XCTestCase { let email = createEmail() let password = "12345678" let phoneNumber = "+15551234567" - + // Sign up the user try await signUpUser(email: email, password: password) - + // Get ID token and enable MFA via API guard let idToken = await getIDTokenFromEmulator(email: email, password: password) else { XCTFail("Failed to get ID token from emulator") return } - + try await verifyEmailInEmulator(email: email, idToken: idToken) - + let mfaEnabled = await enableSMSMFAViaEmulator( idToken: idToken, phoneNumber: phoneNumber, displayName: "Test Phone" ) - + XCTAssertTrue(mfaEnabled, "MFA should be enabled successfully via API") - // Wait for sign out to complete let emailField = app.textFields["email-field"] XCTAssertTrue(emailField.waitForExistence(timeout: 10), "Should return to auth picker") - + try signInUser(app: app, email: email, password: password) - - + let mfaResolutionTitle = app.staticTexts["mfa-resolution-title"] XCTAssertTrue( mfaResolutionTitle.waitForExistence(timeout: 10), "MFA resolution view should appear" ) - + let smsButton = app.buttons["sms-method-button"] if smsButton.exists && smsButton.isEnabled { smsButton.tap() } dismissAlert(app: app) - // Wait for SMS to be sent try await Task.sleep(nanoseconds: 2_000_000_000) - + let sendSMSButton = app.buttons["send-sms-button"] - + sendSMSButton.tap() - + try await Task.sleep(nanoseconds: 3_000_000_000) - - guard let verificationCode = await getSMSVerificationCode(for: phoneNumber, codeType: "verification") else { + + guard let verificationCode = await getSMSVerificationCode( + for: phoneNumber, + codeType: "verification" + ) else { XCTFail("Failed to retrieve SMS verification code from emulator") return } - + let codeField = app.textFields["sms-verification-code-field"] XCTAssertTrue(codeField.waitForExistence(timeout: 10), "Code field should exist") codeField.tap() codeField.typeText(verificationCode) - + let completeButton = app.buttons["complete-resolution-button"] XCTAssertTrue(completeButton.exists, "Complete button should exist") completeButton.tap() - + // Wait for sign-in to complete - // Resolution always fails due to ERROR_MULTI_FACTOR_INFO_NOT_FOUND exception. See below issue for more information. + // Resolution always fails due to ERROR_MULTI_FACTOR_INFO_NOT_FOUND exception. See below issue + // for more information. // TODO(russellwheatley): uncomment below when this firebase-ios-sdk issue has been resolved: https://github.com/firebase/firebase-ios-sdk/issues/11079 - + // let signedInText = app.staticTexts["signed-in-text"] // XCTAssertTrue( // signedInText.waitForExistence(timeout: 10), // "User should be signed in after MFA resolution" // ) } - + // MARK: - Helper Methods /// Programmatically enables SMS MFA for a user via the Auth emulator REST API @@ -122,53 +123,51 @@ final class MFAResolutionUITests: XCTestCase { /// - displayName: Optional display name for the MFA factor /// - Returns: True if MFA was successfully enabled, false otherwise @MainActor - private func enableSMSMFAViaEmulator( - idToken: String, - phoneNumber: String, - displayName: String = "Test Phone" - ) async -> Bool { - let emulatorUrl = "http://127.0.0.1:9099/identitytoolkit.googleapis.com/v2/accounts/mfaEnrollment:start?key=fake-api-key" - + private func enableSMSMFAViaEmulator(idToken: String, + phoneNumber: String, + displayName: String = "Test Phone") async -> Bool { + let emulatorUrl = + "http://127.0.0.1:9099/identitytoolkit.googleapis.com/v2/accounts/mfaEnrollment:start?key=fake-api-key" + guard let url = URL(string: emulatorUrl) else { XCTFail("Invalid emulator URL") return false } - + var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - + let requestBody: [String: Any] = [ "idToken": idToken, "phoneEnrollmentInfo": [ "phoneNumber": phoneNumber, - "recaptchaToken": "fake-recaptcha-token" - ] + "recaptchaToken": "fake-recaptcha-token", + ], ] - + guard let httpBody = try? JSONSerialization.data(withJSONObject: requestBody) else { XCTFail("Failed to serialize request body") return false } - + request.httpBody = httpBody - + // Step 1: Start MFA enrollment do { let (data, _) = try await URLSession.shared.data(for: request) - - + // Step 1: Parse JSON guard let jsonObject = try? JSONSerialization.jsonObject(with: data) else { print("❌ Failed to parse JSON from response data") return false } - + guard let json = jsonObject as? [String: Any] else { print("❌ JSON is not a dictionary. Type: \(type(of: jsonObject))") return false } - + // Step 2: Extract phoneSessionInfo guard let info = json["phoneSessionInfo"] as? [String: Any] else { print("❌ Failed to extract 'phoneSessionInfo' from JSON") @@ -178,7 +177,7 @@ final class MFAResolutionUITests: XCTestCase { } return false } - + // Step 3: Extract sessionInfo guard let sessionInfo = info["sessionInfo"] as? String else { print("❌ Failed to extract 'sessionInfo' from phoneSessionInfo") @@ -188,70 +187,71 @@ final class MFAResolutionUITests: XCTestCase { } return false } - + // Step 2: Get verification code from emulator try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second guard let verificationCode = await getSMSVerificationCode(for: phoneNumber) else { XCTFail("Failed to retrieve SMS verification code") return false } - + // Step 3: Finalize MFA enrollment - let finalizeUrl = "http://127.0.0.1:9099/identitytoolkit.googleapis.com/v2/accounts/mfaEnrollment:finalize?key=fake-api-key" + let finalizeUrl = + "http://127.0.0.1:9099/identitytoolkit.googleapis.com/v2/accounts/mfaEnrollment:finalize?key=fake-api-key" guard let finalizeURL = URL(string: finalizeUrl) else { return false } - + var finalizeRequest = URLRequest(url: finalizeURL) finalizeRequest.httpMethod = "POST" finalizeRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") - + let finalizeBody: [String: Any] = [ "idToken": idToken, "phoneVerificationInfo": [ "sessionInfo": sessionInfo, - "code": verificationCode + "code": verificationCode, ], - "displayName": displayName + "displayName": displayName, ] - + guard let finalizeHttpBody = try? JSONSerialization.data(withJSONObject: finalizeBody) else { return false } - + finalizeRequest.httpBody = finalizeHttpBody - + let (finalizeData, finalizeResponse) = try await URLSession.shared.data(for: finalizeRequest) - + // Check HTTP status if let httpResponse = finalizeResponse as? HTTPURLResponse { print("📡 Finalize HTTP Status: \(httpResponse.statusCode)") } - - - guard let json = try? JSONSerialization.jsonObject(with: finalizeData) as? [String: Any] else { + + guard let json = try? JSONSerialization.jsonObject(with: finalizeData) as? [String: Any] + else { print("❌ Failed to parse finalize response as JSON") return false } - + // Check if we have the new idToken and MFA info guard let newIdToken = json["idToken"] as? String else { print("❌ Missing 'idToken' in finalize response") return false } - + // Check if refreshToken is present if let refreshToken = json["refreshToken"] as? String { print("✅ Got refreshToken: \(refreshToken.prefix(20))...") } - + // Check for MFA info in response if let mfaInfo = json["mfaInfo"] { print("✅ MFA info in response: \(mfaInfo)") } - + return true - + } catch { print("Failed to enable MFA: \(error.localizedDescription)") return false @@ -261,37 +261,39 @@ final class MFAResolutionUITests: XCTestCase { /// Retrieves SMS verification codes from the Firebase Auth emulator /// - Parameters: /// - phoneNumber: The phone number to retrieve the code for - /// - codeType: The type of code - "enrollment" for MFA enrollment, "verification" for phone verification during resolution + /// - codeType: The type of code - "enrollment" for MFA enrollment, "verification" for phone + /// verification during resolution @MainActor - private func getSMSVerificationCode(for phoneNumber: String, codeType: String = "enrollment") async -> String? { - let emulatorUrl = "http://127.0.0.1:9099/emulator/v1/projects/flutterfire-e2e-tests/verificationCodes" - + private func getSMSVerificationCode(for phoneNumber: String, + codeType: String = "enrollment") async -> String? { + let emulatorUrl = + "http://127.0.0.1:9099/emulator/v1/projects/flutterfire-e2e-tests/verificationCodes" + guard let url = URL(string: emulatorUrl) else { return nil } - + do { let (data, _) = try await URLSession.shared.data(from: url) - - + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let codes = json["verificationCodes"] as? [[String: Any]] else { print("❌ Failed to parse verification codes") return nil } - + // Filter codes by phone number and type, then get the most recent one let matchingCodes = codes.filter { codeInfo in guard let phone = codeInfo["phoneNumber"] as? String else { print("❌ Code missing phoneNumber field") return false } - + // The key difference between enrollment and verification codes: // - Enrollment codes have full phone numbers (e.g., "+15551234567") // - Verification codes have masked phone numbers (e.g., "+*******4567") let isMasked = phone.contains("*") - + // Match phone number let phoneMatches: Bool if isMasked { @@ -303,7 +305,7 @@ final class MFAResolutionUITests: XCTestCase { // Full phone number match phoneMatches = phone == phoneNumber } - + guard phoneMatches else { return false } @@ -316,16 +318,16 @@ final class MFAResolutionUITests: XCTestCase { return isMasked } } - + // Get the last matching code (most recent) if let lastCode = matchingCodes.last, let code = lastCode["code"] as? String { return code } - + print("❌ No matching code found") return nil - + } catch { print("Failed to fetch verification codes: \(error.localizedDescription)") return nil @@ -340,42 +342,43 @@ final class MFAResolutionUITests: XCTestCase { /// - Returns: The user's ID token, or nil if the sign-in failed @MainActor private func getIDTokenFromEmulator(email: String, password: String = "123456") async -> String? { - let signInUrl = "http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=fake-api-key" - + let signInUrl = + "http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=fake-api-key" + guard let url = URL(string: signInUrl) else { print("Invalid emulator URL") return nil } - + var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - + let requestBody: [String: Any] = [ "email": email, "password": password, - "returnSecureToken": true + "returnSecureToken": true, ] - + guard let httpBody = try? JSONSerialization.data(withJSONObject: requestBody) else { print("Failed to serialize sign-in request body") return nil } - + request.httpBody = httpBody - + do { let (data, _) = try await URLSession.shared.data(for: request) - + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let idToken = json["idToken"] as? String else { print("Failed to parse sign-in response") return nil } - + print("Successfully got ID token from emulator: \(idToken.prefix(20))...") return idToken - + } catch { print("Failed to get ID token from emulator: \(error.localizedDescription)") return nil @@ -385,33 +388,37 @@ final class MFAResolutionUITests: XCTestCase { @MainActor private func signUpUser(email: String, password: String = "12345678") async throws { // Create user via Auth Emulator REST API - let url = URL(string: "http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake-api-key")! + let url = + URL( + string: "http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake-api-key" + )! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - + let body: [String: Any] = [ "email": email, "password": password, - "returnSecureToken": true + "returnSecureToken": true, ] request.httpBody = try JSONSerialization.data(withJSONObject: body) - + let (data, response) = try await URLSession.shared.data(for: request) - + guard let httpResponse = response as? HTTPURLResponse else { XCTFail("Invalid response") return } - - guard (200...299).contains(httpResponse.statusCode) else { + + guard (200 ... 299).contains(httpResponse.statusCode) else { let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" XCTFail("Failed to create user. Status: \(httpResponse.statusCode), Error: \(errorMessage)") return } } - private func signInUser(app: XCUIApplication, email: String, password: String = "123456") throws { + @MainActor private func signInUser(app: XCUIApplication, email: String, + password: String = "123456") throws { // Ensure we're in sign in flow let switchFlowButton = app.buttons["switch-auth-flow"] if switchFlowButton.exists && switchFlowButton.label.contains("Sign In") { @@ -434,7 +441,7 @@ final class MFAResolutionUITests: XCTestCase { signInButton.tap() } - private func enrollSMSMFA(app: XCUIApplication) throws { + @MainActor private func enrollSMSMFA(app: XCUIApplication) throws { // Navigate to MFA management let mfaManagementButton = app.buttons["mfa-management-button"] XCTAssertTrue(mfaManagementButton.waitForExistence(timeout: 5)) diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/ProviderUITests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/ProviderUITests.swift index 8ae3b6ace3e..cda57f0c29f 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/ProviderUITests.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/ProviderUITests.swift @@ -32,9 +32,9 @@ final class ProviderUITests: XCTestCase { func testProviderButtons() throws { let app = createTestApp() app.launch() - + // MARK: - Check existence of provider buttons - + // Check for Twitter/X sign-in button let twitterButton = app.buttons["sign-in-with-twitter-button"] XCTAssertTrue( @@ -55,35 +55,35 @@ final class ProviderUITests: XCTestCase { githubButton.waitForExistence(timeout: 5), "Github sign-in button should exist" ) - + // Check for Microsoft sign-in button let microsoftButton = app.buttons["sign-in-with-microsoft.com-button"] XCTAssertTrue( microsoftButton.waitForExistence(timeout: 5), "Microsoft sign-in button should exist" ) - + // Check for Yahoo sign-in button let yahooButton = app.buttons["sign-in-with-yahoo.com-button"] XCTAssertTrue( yahooButton.waitForExistence(timeout: 5), "Yahoo sign-in button should exist" ) - + // Check for Google sign-in button let googleButton = app.buttons["sign-in-with-google-button"] XCTAssertTrue( googleButton.waitForExistence(timeout: 5), "Google sign-in button should exist" ) - + // Check for Facebook sign-in button let facebookButton = app.buttons["sign-in-with-facebook-button"] XCTAssertTrue( facebookButton.waitForExistence(timeout: 5), "Facebook sign-in button should exist" ) - + // Check for Phone sign-in button let phoneButton = app.buttons["sign-in-with-phone-button"] XCTAssertTrue( @@ -96,7 +96,8 @@ final class ProviderUITests: XCTestCase { func testErrorModal() throws { let app = createTestApp() app.launch() - // Just test email + external provider for error modal on failure to ensure provider button sign-in flow fails along with failures within AuthPickerView + // Just test email + external provider for error modal on failure to ensure provider button + // sign-in flow fails along with failures within AuthPickerView let emailField = app.textFields["email-field"] XCTAssertTrue(emailField.waitForExistence(timeout: 6), "Email field should exist") emailField.tap() @@ -110,26 +111,25 @@ final class ProviderUITests: XCTestCase { let signInButton = app.buttons["sign-in-button"] XCTAssertTrue(signInButton.exists, "Sign-In button should exist") signInButton.tap() - + // Wait for the alert to appear let alert1 = app.alerts.firstMatch XCTAssertTrue( alert1.waitForExistence(timeout: 5), "Alert should appear after canceling Facebook sign-in" ) - + alert1.buttons["OK"].firstMatch.tap() - - + let facebookButton = app.buttons["sign-in-with-facebook-button"] XCTAssertTrue( facebookButton.waitForExistence(timeout: 5), "Facebook sign-in button should exist" ) - + facebookButton.tap() - // Wait for Facebook modal to appear and tap Cancel + // Wait for Facebook modal to appear and tap Cancel // The Facebook SDK modal is presented by the system/Safari let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") @@ -140,7 +140,7 @@ final class ProviderUITests: XCTestCase { "Cancel button should appear in Springboard authentication modal" ) cancelButton.tap() - + // Wait for the alert to appear let alert2 = app.alerts.firstMatch XCTAssertTrue( diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift index 7625f256233..bc5bc0313fd 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift @@ -12,7 +12,7 @@ func createEmail() -> String { // MARK: - App Configuration /// Creates and configures an XCUIApplication with default test launch arguments -func createTestApp(mfaEnabled: Bool = false) -> XCUIApplication { +@MainActor func createTestApp(mfaEnabled: Bool = false) -> XCUIApplication { let app = XCUIApplication() app.launchArguments.append("--test-view-enabled") if mfaEnabled { @@ -23,7 +23,7 @@ func createTestApp(mfaEnabled: Bool = false) -> XCUIApplication { // MARK: - Alert Handling -func dismissAlert(app: XCUIApplication) { +@MainActor func dismissAlert(app: XCUIApplication) { if app.scrollViews.otherElements.buttons["Not Now"].waitForExistence(timeout: 2) { app.scrollViews.otherElements.buttons["Not Now"].tap() } @@ -32,36 +32,38 @@ func dismissAlert(app: XCUIApplication) { // MARK: - User Creation /// Helper to create a test user in the emulator via REST API (avoids keychain issues) -func createTestUser(email: String, password: String = "123456", verifyEmail: Bool = false) async throws { +@MainActor func createTestUser(email: String, password: String = "123456", + verifyEmail: Bool = false) async throws { // Use Firebase Auth emulator REST API directly to avoid keychain access issues in UI tests - let signUpUrl = "http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake-api-key" - + let signUpUrl = + "http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake-api-key" + guard let url = URL(string: signUpUrl) else { throw NSError(domain: "TestError", code: 1, - userInfo: [NSLocalizedDescriptionKey: "Invalid emulator URL"]) + userInfo: [NSLocalizedDescriptionKey: "Invalid emulator URL"]) } - + var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - + let body: [String: Any] = [ "email": email, "password": password, - "returnSecureToken": true + "returnSecureToken": true, ] - + request.httpBody = try JSONSerialization.data(withJSONObject: body) - + let (data, response) = try await URLSession.shared.data(for: request) - + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error" throw NSError(domain: "TestError", code: 2, - userInfo: [NSLocalizedDescriptionKey: "Failed to create user: \(errorBody)"]) + userInfo: [NSLocalizedDescriptionKey: "Failed to create user: \(errorBody)"]) } - + // If email verification is requested, verify the email if verifyEmail { // Parse the response to get the idToken @@ -75,32 +77,31 @@ func createTestUser(email: String, password: String = "123456", verifyEmail: Boo // MARK: - Email Verification /// Verifies an email address in the emulator using the OOB code mechanism -func verifyEmailInEmulator(email: String, +@MainActor func verifyEmailInEmulator(email: String, idToken: String, projectID: String = "flutterfire-e2e-tests", emulatorHost: String = "127.0.0.1:9099") async throws { let base = "http://\(emulatorHost)" - // Step 1: Trigger email verification (creates OOB code in emulator) var sendReq = URLRequest( - url: URL(string: "\(base)/identitytoolkit.googleapis.com/v1/accounts:sendOobCode?key=fake-api-key")! + url: URL( + string: "\(base)/identitytoolkit.googleapis.com/v1/accounts:sendOobCode?key=fake-api-key" + )! ) sendReq.httpMethod = "POST" sendReq.setValue("application/json", forHTTPHeaderField: "Content-Type") sendReq.httpBody = try JSONSerialization.data(withJSONObject: [ "requestType": "VERIFY_EMAIL", - "idToken": idToken + "idToken": idToken, ]) - let (_, sendResp) = try await URLSession.shared.data(for: sendReq) guard let http = sendResp as? HTTPURLResponse, http.statusCode == 200 else { throw NSError(domain: "EmulatorError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to send verification email"]) } - // Step 2: Fetch OOB codes from emulator let oobURL = URL(string: "\(base)/emulator/v1/projects/\(projectID)/oobCodes")! let (oobData, oobResp) = try await URLSession.shared.data(from: oobURL) @@ -109,7 +110,6 @@ func verifyEmailInEmulator(email: String, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch OOB codes"]) } - struct OobEnvelope: Decodable { let oobCodes: [OobItem] } struct OobItem: Decodable { let oobCode: String @@ -118,10 +118,8 @@ func verifyEmailInEmulator(email: String, let creationTime: String? } - let envelope = try JSONDecoder().decode(OobEnvelope.self, from: oobData) - // Step 3: Find most recent VERIFY_EMAIL code for this email let iso = ISO8601DateFormatter() let codeItem = envelope.oobCodes @@ -135,18 +133,19 @@ func verifyEmailInEmulator(email: String, } .first - guard let oobCode = codeItem?.oobCode else { throw NSError(domain: "EmulatorError", code: 3, - userInfo: [NSLocalizedDescriptionKey: "No VERIFY_EMAIL OOB code found for \(email)"]) + userInfo: [ + NSLocalizedDescriptionKey: "No VERIFY_EMAIL OOB code found for \(email)", + ]) } - // Step 4: Apply the OOB code (simulate clicking verification link) - let verifyURL = URL(string: "\(base)/emulator/action?mode=verifyEmail&oobCode=\(oobCode)&apiKey=fake-api-key")! + let verifyURL = + URL(string: "\(base)/emulator/action?mode=verifyEmail&oobCode=\(oobCode)&apiKey=fake-api-key")! let (_, verifyResp) = try await URLSession.shared.data(from: verifyURL) guard (verifyResp as? HTTPURLResponse)?.statusCode == 200 else { throw NSError(domain: "EmulatorError", code: 4, userInfo: [NSLocalizedDescriptionKey: "Failed to apply OOB code"]) } -} \ No newline at end of file +}