From 265830c61df89c03e714856beeeac7cb01719d18 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Thu, 30 Oct 2025 16:14:16 +0000 Subject: [PATCH 1/6] refactor: handle auto upgrade anonymous improvement --- .../Sources/Views/SignInWithAppleButton.swift | 9 ++- .../Sources/Services/AuthService.swift | 22 ++++++- .../Sources/Views/AuthPickerView.swift | 58 ++++++++++++++++++- .../Sources/Views/EmailAuthView.swift | 17 +++++- .../Views/SignInWithFacebookButton.swift | 9 ++- .../Views/SignInWithGoogleButton.swift | 9 ++- .../Sources/Views/GenericOAuthButton.swift | 9 ++- .../Sources/Views/PhoneAuthButtonView.swift | 9 ++- .../Views/SignInWithTwitterButton.swift | 9 ++- .../FirebaseSwiftUIExample/ContentView.swift | 2 + 10 files changed, 142 insertions(+), 11 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift index 693491dbfe..7b06bfbdf7 100644 --- a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift @@ -19,6 +19,7 @@ import SwiftUI @MainActor public struct SignInWithAppleButton { @Environment(AuthService.self) private var authService + @Environment(\.signInWithMergeConflictHandler) private var signInHandler let provider: AuthProviderSwift public init(provider: AuthProviderSwift) { self.provider = provider @@ -29,7 +30,13 @@ extension SignInWithAppleButton: View { public var body: some View { Button(action: { Task { - try? await authService.signIn(provider) + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(provider) + } + } else { + try? await authService.signIn(provider) + } } }) { HStack { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index b3a32d38d8..1f32ec118b 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -177,6 +177,8 @@ public final class AuthService { public func signOut() async throws { do { try await auth.signOut() + // Cannot wait for auth listener to change, feedback needs to be immediate + currentUser = nil updateAuthenticationState() } catch { updateError(message: string.localizedErrorMessage(for: error)) @@ -202,7 +204,7 @@ public final class AuthService { } } - public func handleAutoUpgradeAnonymousUser(credentials: AuthCredential) async throws + private func handleAutoUpgradeAnonymousUser(credentials: AuthCredential) async throws -> SignInOutcome { if currentUser == nil { throw AuthServiceError.noCurrentUser @@ -213,11 +215,27 @@ public final class AuthService { updateAuthenticationState() return .signedIn(result) } catch let error as NSError { + // Handle credentialAlreadyInUse error + if error.code == AuthErrorCode.credentialAlreadyInUse.rawValue { + // Extract the updated credential from the error + let updatedCredential = error.userInfo["FIRAuthUpdatedCredentialKey"] as? AuthCredential + ?? credentials + + let context = AccountMergeConflictContext( + credential: updatedCredential, + underlyingError: error, + message: "Unable to merge accounts. The credential is already associated with a different account.", + uid: currentUser?.uid + ) + throw AuthServiceError.accountMergeConflict(context: context) + } + + // Handle emailAlreadyInUse error if error.code == AuthErrorCode.emailAlreadyInUse.rawValue { let context = AccountMergeConflictContext( credential: credentials, underlyingError: error, - message: "Unable to merge accounts. Use the credential in the context to resolve the conflict.", + message: "Unable to merge accounts. This email is already associated with a different account.", uid: currentUser?.uid ) throw AuthServiceError.accountMergeConflict(context: context) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 20220a0d5b..5155c3c845 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -15,6 +15,59 @@ import FirebaseCore import SwiftUI +// MARK: - Merge Conflict Handling + +/// Helper function to handle sign-in with automatic merge conflict resolution. +/// +/// This function attempts to sign in with the provided action. If a merge conflict occurs +/// (when an anonymous user is being upgraded and the credential is already associated with +/// an existing account), it automatically signs out the anonymous user and signs in with +/// the existing account's credential. +/// +/// - Parameters: +/// - authService: The AuthService instance to use for sign-in operations +/// - signInAction: An async closure that performs the sign-in operation +/// - Returns: The SignInOutcome from the successful sign-in +/// - Throws: Re-throws any errors except accountMergeConflict (which is handled internally) +@MainActor +public func signInWithMergeConflictHandling(authService: AuthService, + signInAction: () async throws + -> SignInOutcome) async throws -> SignInOutcome { + do { + return try await signInAction() + } catch let error as AuthServiceError { + if case let .accountMergeConflict(context) = error { + // The anonymous account conflicts with an existing account + // Sign out the anonymous user + try await authService.signOut() + + // Sign in with the existing account's credential + // This works because shouldHandleAnonymousUpgrade is now false after sign out + return try await authService.signIn(credentials: context.credential) + } + throw error + } +} + +// MARK: - Environment Key for Sign-In Handler + +/// Environment key for a sign-in handler that includes merge conflict resolution +private struct SignInHandlerKey: EnvironmentKey { + static let defaultValue: (@MainActor (AuthService, () async throws -> SignInOutcome) async throws + -> SignInOutcome)? = nil +} + +public extension EnvironmentValues { + /// A sign-in handler that automatically handles merge conflicts for anonymous user upgrades. + /// When set in the environment, views should use this handler to wrap their sign-in calls. + var signInWithMergeConflictHandler: (@MainActor (AuthService, + () async throws -> SignInOutcome) async throws + -> SignInOutcome)? { + get { self[SignInHandlerKey.self] } + set { self[SignInHandlerKey.self] = newValue } + } +} + @MainActor public struct AuthPickerView { @Environment(AuthService.self) private var authService @@ -67,10 +120,13 @@ extension AuthPickerView: View { .emailLoginFlowLabel : authService.string.emailSignUpFlowLabel) Divider() EmailAuthView() + .environment(\.signInWithMergeConflictHandler, signInWithMergeConflictHandling) } VStack { authService.renderButtons() - }.padding(.horizontal) + } + .padding(.horizontal) + .environment(\.signInWithMergeConflictHandler, signInWithMergeConflictHandling) if authService.emailSignInEnabled { Divider() HStack { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index c11f506f6a..0813cc599b 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -31,6 +31,7 @@ private enum FocusableField: Hashable { @MainActor public struct EmailAuthView { @Environment(AuthService.self) private var authService + @Environment(\.signInWithMergeConflictHandler) private var signInHandler @State private var email = "" @State private var password = "" @@ -49,11 +50,23 @@ public struct EmailAuthView { } private func signInWithEmailPassword() async { - try? await authService.signIn(email: email, password: password) + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(email: email, password: password) + } + } else { + try? await authService.signIn(email: email, password: password) + } } private func createUserWithEmailPassword() async { - try? await authService.createUser(email: email, password: password) + if let handler = signInHandler { + try? await handler(authService) { + try await authService.createUser(email: email, password: password) + } + } else { + try? await authService.createUser(email: email, password: password) + } } } diff --git a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift index 136157da0c..8f939c433a 100644 --- a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift +++ b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift @@ -23,6 +23,7 @@ import SwiftUI @MainActor public struct SignInWithFacebookButton { @Environment(AuthService.self) private var authService + @Environment(\.signInWithMergeConflictHandler) private var signInHandler let facebookProvider: FacebookProviderSwift @State private var showCanceledAlert = false @State private var limitedLogin = true @@ -67,7 +68,13 @@ extension SignInWithFacebookButton: View { Button(action: { Task { facebookProvider.isLimitedLogin = limitedLogin - try? await authService.signIn(facebookProvider) + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(facebookProvider) + } + } else { + try? await authService.signIn(facebookProvider) + } } }) { HStack { diff --git a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift index 881b0ffbad..da345dd14b 100644 --- a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift +++ b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift @@ -26,6 +26,7 @@ import SwiftUI @MainActor public struct SignInWithGoogleButton { @Environment(AuthService.self) private var authService + @Environment(\.signInWithMergeConflictHandler) private var signInHandler let googleProvider: AuthProviderSwift public init(googleProvider: AuthProviderSwift) { @@ -43,7 +44,13 @@ extension SignInWithGoogleButton: View { public var body: some View { GoogleSignInButton(viewModel: customViewModel) { Task { - try? await authService.signIn(googleProvider) + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(googleProvider) + } + } else { + try? await authService.signIn(googleProvider) + } } } .accessibilityIdentifier("sign-in-with-google-button") diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift index a14082b328..59d8e76d61 100644 --- a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift +++ b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift @@ -19,6 +19,7 @@ import SwiftUI @MainActor public struct GenericOAuthButton { @Environment(AuthService.self) private var authService + @Environment(\.signInWithMergeConflictHandler) private var signInHandler let provider: AuthProviderSwift public init(provider: AuthProviderSwift) { self.provider = provider @@ -36,7 +37,13 @@ extension GenericOAuthButton: View { return AnyView( Button(action: { Task { - try await authService.signIn(provider) + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(provider) + } + } else { + try? await authService.signIn(provider) + } } }) { HStack { diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift index de045e736e..13b76f9109 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift @@ -19,6 +19,7 @@ import SwiftUI @MainActor public struct PhoneAuthButtonView { @Environment(AuthService.self) private var authService + @Environment(\.signInWithMergeConflictHandler) private var signInHandler let phoneProvider: PhoneAuthProviderSwift public init(phoneProvider: PhoneAuthProviderSwift) { @@ -30,7 +31,13 @@ extension PhoneAuthButtonView: View { public var body: some View { Button(action: { Task { - try await authService.signIn(phoneProvider) + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(phoneProvider) + } + } else { + try? await authService.signIn(phoneProvider) + } } }) { Label("Sign in with Phone", systemImage: "phone.fill") diff --git a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift index d85e9052e1..1e62cefa8b 100644 --- a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift +++ b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift @@ -19,6 +19,7 @@ import SwiftUI @MainActor public struct SignInWithTwitterButton { @Environment(AuthService.self) private var authService + @Environment(\.signInWithMergeConflictHandler) private var signInHandler let provider: AuthProviderSwift public init(provider: AuthProviderSwift) { self.provider = provider @@ -29,7 +30,13 @@ extension SignInWithTwitterButton: View { public var body: some View { Button(action: { Task { - try? await authService.signIn(provider) + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(provider) + } + } else { + try? await authService.signIn(provider) + } } }) { HStack { diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift index 3b1022a430..e87a710240 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift @@ -33,6 +33,7 @@ struct ContentView: View { let authService: AuthService init() { + Auth.auth().signInAnonymously() let actionCodeSettings = ActionCodeSettings() actionCodeSettings.handleCodeInApp = true actionCodeSettings @@ -40,6 +41,7 @@ struct ContentView: View { actionCodeSettings.linkDomain = "flutterfire-e2e-tests.firebaseapp.com" actionCodeSettings.setIOSBundleID(Bundle.main.bundleIdentifier!) let configuration = AuthConfiguration( + shouldAutoUpgradeAnonymousUsers: true, tosUrl: URL(string: "https://example.com/tos"), privacyPolicyUrl: URL(string: "https://example.com/privacy"), emailLinkSignInActionCodeSettings: actionCodeSettings, From a09e6158eccb98e39ae3350cf4bb50bcb3427a55 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Thu, 30 Oct 2025 16:14:26 +0000 Subject: [PATCH 2/6] format --- FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Version.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Version.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Version.swift index f7da298cdb..3b7cd58954 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 +} From 786bdd1bb9a8c0d4346d8a9e6e8899ea0698a414 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Mon, 3 Nov 2025 15:29:59 +0000 Subject: [PATCH 3/6] refactor: phone authentication --- .../Sources/Services/AuthService.swift | 18 +- .../Sources/Views/AuthPickerView.swift | 16 -- .../Sources/Views/EnterPhoneNumberView.swift | 113 -------- .../Views/EnterVerificationCodeView.swift | 127 --------- .../Services/PhoneAuthProviderAuthUI.swift | 268 ++++++++++++++++-- .../Sources/Views/PhoneAuthButtonView.swift | 10 +- 6 files changed, 265 insertions(+), 287 deletions(-) delete mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift delete mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 507d6e1b37..1dd20f5316 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -27,9 +27,10 @@ public protocol AuthProviderUI { var provider: AuthProviderSwift { get } } -public protocol PhoneAuthProviderSwift: AuthProviderSwift { - @MainActor func verifyPhoneNumber(phoneNumber: String) async throws -> String - func setVerificationCode(verificationID: String, code: String) +public protocol PhoneAuthProviderSwift: AuthProviderSwift, AnyObject { + // Phone auth provider that presents its own UI flow in createAuthCredential() + // Internal use only: AuthService will be injected automatically by AuthService.signIn() + var authService: AuthService? { get set } } public enum AuthenticationState { @@ -50,8 +51,6 @@ public enum AuthView: Hashable { case mfaEnrollment case mfaManagement case mfaResolution - case enterPhoneNumber - case enterVerificationCode(verificationID: String, fullPhoneNumber: String) } public enum SignInOutcome: @unchecked Sendable { @@ -144,10 +143,6 @@ public final class AuthService { private var providers: [AuthProviderUI] = [] - public var currentPhoneProvider: PhoneAuthProviderSwift? { - providers.compactMap { $0.provider as? PhoneAuthProviderSwift }.first - } - public func registerProvider(providerWithButton: AuthProviderUI) { providers.append(providerWithButton) } @@ -171,6 +166,11 @@ public final class AuthService { public func signIn(_ provider: AuthProviderSwift) async throws -> SignInOutcome { do { + // Automatically inject AuthService for phone provider + if let phoneProvider = provider as? PhoneAuthProviderSwift { + phoneProvider.authService = self + } + let credential = try await provider.createAuthCredential() let result = try await signIn(credentials: credential) return result diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index a1cec0f59d..3885370322 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -107,22 +107,6 @@ extension AuthPickerView: View { 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() - } } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift deleted file mode 100644 index 757e588f26..0000000000 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift +++ /dev/null @@ -1,113 +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 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 deleted file mode 100644 index 54e4f996fb..0000000000 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift +++ /dev/null @@ -1,127 +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 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/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift index 2e4e66c8f3..7d46dec747 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift @@ -12,44 +12,270 @@ // See the License for the specific language governing permissions and // limitations under the License. +import Combine @preconcurrency import FirebaseAuth import FirebaseAuthSwiftUI +import FirebaseAuthUIComponents import SwiftUI +import UIKit public typealias VerificationID = String -public class PhoneProviderSwift: PhoneAuthProviderSwift { - private var verificationID: String? - private var verificationCode: String? +// MARK: - Phone Auth Coordinator - public init() {} +@MainActor +private class PhoneAuthCoordinator: ObservableObject { + @Published var isPresented = true + @Published var currentStep: Step = .enterPhoneNumber + @Published var phoneNumber = "" + @Published var selectedCountry: CountryData = .default + @Published var verificationID = "" + @Published var fullPhoneNumber = "" + @Published var verificationCode = "" + @Published var currentError: AlertError? + @Published var isProcessing = false + + var continuation: CheckedContinuation? + + enum Step { + case enterPhoneNumber + case enterVerificationCode + } + + func sendVerificationCode() async { + isProcessing = true + do { + fullPhoneNumber = selectedCountry.dialCode + phoneNumber + verificationID = try await withCheckedThrowingContinuation { continuation in + PhoneAuthProvider.provider() + .verifyPhoneNumber(fullPhoneNumber, uiDelegate: nil) { verificationID, error in + if let error = error { + continuation.resume(throwing: error) + return + } + continuation.resume(returning: verificationID!) + } + } + currentStep = .enterVerificationCode + currentError = nil + } catch { + currentError = AlertError(message: error.localizedDescription) + } + isProcessing = false + } + + func verifyCodeAndComplete() async { + isProcessing = true + do { + let credential = PhoneAuthProvider.provider() + .credential(withVerificationID: verificationID, verificationCode: verificationCode) + + isPresented = false + continuation?.resume(returning: credential) + continuation = nil + } catch { + currentError = AlertError(message: error.localizedDescription) + isProcessing = false + } + } + + func cancel() { + isPresented = false + continuation?.resume(throwing: AuthServiceError.signInCancelled("Phone authentication was cancelled")) + continuation = nil + } +} - @MainActor public func verifyPhoneNumber(phoneNumber: String) async throws -> VerificationID { - return try await withCheckedThrowingContinuation { continuation in - PhoneAuthProvider.provider() - .verifyPhoneNumber(phoneNumber, uiDelegate: nil) { verificationID, error in - if let error = error { - continuation.resume(throwing: error) - return +// MARK: - Phone Auth Flow View + +@MainActor +private struct PhoneAuthFlowView: View { + @StateObject var coordinator: PhoneAuthCoordinator + @Environment(AuthService.self) private var authService + + var body: some View { + NavigationStack { + Group { + switch coordinator.currentStep { + case .enterPhoneNumber: + phoneNumberView + case .enterVerificationCode: + verificationCodeView + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + coordinator.cancel() } - continuation.resume(returning: verificationID!) } + } } } + + // MARK: - Phone Number View + + var phoneNumberView: some View { + VStack(spacing: 16) { + Text(authService.string.enterPhoneNumberPlaceholder) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top) + + AuthTextField( + text: $coordinator.phoneNumber, + localizedTitle: "Phone", + prompt: authService.string.enterPhoneNumberPlaceholder, + keyboardType: .phonePad, + contentType: .telephoneNumber, + onChange: { _ in } + ) { + CountrySelector( + selectedCountry: $coordinator.selectedCountry, + enabled: !coordinator.isProcessing + ) + } + + Button(action: { + Task { + await coordinator.sendVerificationCode() + } + }) { + if coordinator.isProcessing { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.sendCodeButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(coordinator.isProcessing || coordinator.phoneNumber.isEmpty) + .padding(.top, 8) - public func setVerificationCode(verificationID: String, code: String) { - self.verificationID = verificationID - verificationCode = code + Spacer() + } + .navigationTitle(authService.string.phoneSignInTitle) + .padding(.horizontal) + .errorAlert(error: $coordinator.currentError, okButtonLabel: authService.string.okButtonLabel) } + + // MARK: - Verification Code View + + var verificationCodeView: some View { + VStack(spacing: 32) { + VStack(spacing: 16) { + VStack(spacing: 8) { + Text("We sent a code to \(coordinator.fullPhoneNumber)") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) - @MainActor public func createAuthCredential() async throws -> AuthCredential { - guard let verificationID = verificationID, - let verificationCode = verificationCode else { - throw AuthServiceError.providerAuthenticationFailed("Verification ID or code not set") + Button { + coordinator.currentStep = .enterPhoneNumber + coordinator.verificationCode = "" + } label: { + Text("Change number") + .font(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.bottom) + .frame(maxWidth: .infinity, alignment: .leading) + + VerificationCodeInputField( + code: $coordinator.verificationCode, + isError: coordinator.currentError != nil, + errorMessage: coordinator.currentError?.message + ) + + Button(action: { + Task { + await coordinator.verifyCodeAndComplete() + } + }) { + if coordinator.isProcessing { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.verifyAndSignInButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(coordinator.isProcessing || coordinator.verificationCode.count != 6) + } + + Spacer() } + .navigationTitle(authService.string.enterVerificationCodeTitle) + .navigationBarTitleDisplayMode(.inline) + .padding(.horizontal) + .errorAlert(error: $coordinator.currentError, okButtonLabel: authService.string.okButtonLabel) + } +} + +// MARK: - Phone Provider Swift - return PhoneAuthProvider.provider() - .credential(withVerificationID: verificationID, verificationCode: verificationCode) +public class PhoneProviderSwift: PhoneAuthProviderSwift { + private var cancellables = Set() + + // Internal use only: Injected automatically by AuthService.signIn() + public weak var authService: AuthService? + + public init() {} + + @MainActor public func createAuthCredential() async throws -> AuthCredential { + guard let authService = authService else { + throw AuthServiceError.providerAuthenticationFailed( + "AuthService not injected. This should be set automatically by AuthService.signIn()." + ) + } + + // Get the root view controller to present from + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.windows.first?.rootViewController else { + throw AuthServiceError.rootViewControllerNotFound( + "Root view controller not available to present phone auth flow" + ) + } + + // Find the topmost view controller + var topViewController = rootViewController + while let presented = topViewController.presentedViewController { + topViewController = presented + } + + // Create coordinator + let coordinator = PhoneAuthCoordinator() + + // Present the flow and wait for result + return try await withCheckedThrowingContinuation { continuation in + coordinator.continuation = continuation + + // Create SwiftUI view with environment + let flowView = PhoneAuthFlowView(coordinator: coordinator) + .environment(authService) + + let hostingController = UIHostingController(rootView: flowView) + + // Dismiss handler - watch for presentation state changes + coordinator.$isPresented.sink { [weak hostingController] isPresented in + if !isPresented { + hostingController?.dismiss(animated: true) + } + }.store(in: &cancellables) + + // Present modally + topViewController.present(hostingController, animated: true) + } } } diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift index 4ceac1c956..f2cd7c3ed4 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift @@ -35,7 +35,15 @@ extension PhoneAuthButtonView: View { style: .phone, accessibilityId: "sign-in-with-phone-button" ) { - authService.navigator.push(.enterPhoneNumber) + Task { + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(phoneProvider) + } + } else { + try? await authService.signIn(phoneProvider) + } + } } } } From 7820b5607a02e5ef22dda315565e84c22ddbd6aa Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Mon, 3 Nov 2025 16:14:54 +0000 Subject: [PATCH 4/6] fix: update cancel button --- .../Services/PhoneAuthProviderAuthUI.swift | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift index 7d46dec747..7e1c5f6aab 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift @@ -104,10 +104,20 @@ private struct PhoneAuthFlowView: View { } } .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - coordinator.cancel() - } + toolbar + } + } + .interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled) + } + + @ToolbarContentBuilder + var toolbar: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + if !authService.configuration.shouldHideCancelButton { + Button { + coordinator.cancel() + } label: { + Image(systemName: "xmark") } } } From b87e17e62b3f169cc9e619b0582e68798af9f503 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Mon, 3 Nov 2025 16:42:07 +0000 Subject: [PATCH 5/6] fix: silent error on cancel --- .../Sources/Services/AuthService.swift | 45 +++++++-------- .../Sources/Views/ErrorAlertView.swift | 10 +++- .../Services/PhoneAuthProviderAuthUI.swift | 55 +++++++++++-------- 3 files changed, 63 insertions(+), 47 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 1dd20f5316..55b63c2983 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -170,12 +170,13 @@ public final class AuthService { if let phoneProvider = provider as? PhoneAuthProviderSwift { phoneProvider.authService = self } - + let credential = try await provider.createAuthCredential() let result = try await signIn(credentials: credential) return result } catch { - updateError(message: string.localizedErrorMessage(for: error)) + // Always pass the underlying error - view decides what to show + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -206,8 +207,8 @@ public final class AuthService { currentError = nil } - func updateError(title: String = "Error", message: String) { - currentError = AlertError(title: title, message: message) + func updateError(title: String = "Error", message: String, underlyingError: Error? = nil) { + currentError = AlertError(title: title, message: message, underlyingError: underlyingError) } public var shouldHandleAnonymousUpgrade: Bool { @@ -221,7 +222,7 @@ public final class AuthService { currentUser = nil updateAuthenticationState() } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -239,7 +240,7 @@ public final class AuthService { updateAuthenticationState() } catch { authenticationState = .unauthenticated - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -303,7 +304,7 @@ public final class AuthService { } } else { // Don't want error modal on MFA error so we only update here - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) } throw error @@ -325,7 +326,7 @@ public final class AuthService { } } } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -344,7 +345,7 @@ public extension AuthService { try await user.delete() } } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -359,7 +360,7 @@ public extension AuthService { try await user.updatePassword(to: password) } } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -392,7 +393,7 @@ public extension AuthService { } } catch { authenticationState = .unauthenticated - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -401,7 +402,7 @@ public extension AuthService { do { try await auth.sendPasswordReset(withEmail: email) } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -418,7 +419,7 @@ public extension AuthService { actionCodeSettings: actionCodeSettings ) } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -451,7 +452,7 @@ public extension AuthService { emailLink = nil } } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -520,7 +521,7 @@ public extension AuthService { changeRequest.photoURL = url try await changeRequest.commitChanges() } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -535,7 +536,7 @@ public extension AuthService { changeRequest.displayName = name try await changeRequest.commitChanges() } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -627,7 +628,7 @@ public extension AuthService { ) } } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -679,7 +680,7 @@ public extension AuthService { return verificationID } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -758,7 +759,7 @@ public extension AuthService { } currentUser = auth.currentUser } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -847,7 +848,7 @@ public extension AuthService { return freshFactors } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -917,7 +918,7 @@ public extension AuthService { } } } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -970,7 +971,7 @@ public extension AuthService { .multiFactorAuth("Failed to resolve MFA challenge: \(error.localizedDescription)") } } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift index 806fa015b3..ab1da65f8b 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift @@ -22,7 +22,11 @@ struct ErrorAlertModifier: ViewModifier { func body(content: Content) -> some View { content .alert(isPresented: Binding( - get: { error != nil }, + get: { + // View layer decides: Don't show alert for CancellationError + guard let error = error else { return false } + return !(error.underlyingError is CancellationError) + }, set: { if !$0 { error = nil } } )) { Alert( @@ -48,9 +52,11 @@ public struct AlertError: Identifiable { public let id = UUID() public let title: String public let message: String + public let underlyingError: Error? - public init(title: String = "Error", message: String) { + public init(title: String = "Error", message: String, underlyingError: Error? = nil) { self.title = title self.message = message + self.underlyingError = underlyingError } } diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift index 7e1c5f6aab..2f5f05bd02 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift @@ -34,14 +34,14 @@ private class PhoneAuthCoordinator: ObservableObject { @Published var verificationCode = "" @Published var currentError: AlertError? @Published var isProcessing = false - + var continuation: CheckedContinuation? - + enum Step { case enterPhoneNumber case enterVerificationCode } - + func sendVerificationCode() async { isProcessing = true do { @@ -63,13 +63,13 @@ private class PhoneAuthCoordinator: ObservableObject { } isProcessing = false } - + func verifyCodeAndComplete() async { isProcessing = true do { let credential = PhoneAuthProvider.provider() .credential(withVerificationID: verificationID, verificationCode: verificationCode) - + isPresented = false continuation?.resume(returning: credential) continuation = nil @@ -78,10 +78,19 @@ private class PhoneAuthCoordinator: ObservableObject { isProcessing = false } } - + func cancel() { isPresented = false - continuation?.resume(throwing: AuthServiceError.signInCancelled("Phone authentication was cancelled")) + + // Only throw error if user has started the flow (sent verification code) + // If they cancel before entering/sending phone number, dismiss silently + if !verificationID.isEmpty { + continuation? + .resume(throwing: AuthServiceError.signInCancelled("Phone authentication was cancelled")) + } else { + continuation?.resume(throwing: CancellationError()) + } + continuation = nil } } @@ -92,7 +101,7 @@ private class PhoneAuthCoordinator: ObservableObject { private struct PhoneAuthFlowView: View { @StateObject var coordinator: PhoneAuthCoordinator @Environment(AuthService.self) private var authService - + var body: some View { NavigationStack { Group { @@ -109,7 +118,7 @@ private struct PhoneAuthFlowView: View { } .interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled) } - + @ToolbarContentBuilder var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { @@ -122,9 +131,9 @@ private struct PhoneAuthFlowView: View { } } } - + // MARK: - Phone Number View - + var phoneNumberView: some View { VStack(spacing: 16) { Text(authService.string.enterPhoneNumberPlaceholder) @@ -173,9 +182,9 @@ private struct PhoneAuthFlowView: View { .padding(.horizontal) .errorAlert(error: $coordinator.currentError, okButtonLabel: authService.string.okButtonLabel) } - + // MARK: - Verification Code View - + var verificationCodeView: some View { VStack(spacing: 32) { VStack(spacing: 16) { @@ -236,10 +245,10 @@ private struct PhoneAuthFlowView: View { public class PhoneProviderSwift: PhoneAuthProviderSwift { private var cancellables = Set() - + // Internal use only: Injected automatically by AuthService.signIn() public weak var authService: AuthService? - + public init() {} @MainActor public func createAuthCredential() async throws -> AuthCredential { @@ -248,7 +257,7 @@ public class PhoneProviderSwift: PhoneAuthProviderSwift { "AuthService not injected. This should be set automatically by AuthService.signIn()." ) } - + // Get the root view controller to present from guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootViewController = windowScene.windows.first?.rootViewController else { @@ -256,33 +265,33 @@ public class PhoneProviderSwift: PhoneAuthProviderSwift { "Root view controller not available to present phone auth flow" ) } - + // Find the topmost view controller var topViewController = rootViewController while let presented = topViewController.presentedViewController { topViewController = presented } - + // Create coordinator let coordinator = PhoneAuthCoordinator() - + // Present the flow and wait for result return try await withCheckedThrowingContinuation { continuation in coordinator.continuation = continuation - + // Create SwiftUI view with environment let flowView = PhoneAuthFlowView(coordinator: coordinator) .environment(authService) - + let hostingController = UIHostingController(rootView: flowView) - + // Dismiss handler - watch for presentation state changes coordinator.$isPresented.sink { [weak hostingController] isPresented in if !isPresented { hostingController?.dismiss(animated: true) } }.store(in: &cancellables) - + // Present modally topViewController.present(hostingController, animated: true) } From 3a71f548a4b49f12d9a614a2ad6f1e319746f43f Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Mon, 3 Nov 2025 16:42:13 +0000 Subject: [PATCH 6/6] format --- .../Sources/Views/AuthPickerView.swift | 5 ++++- .../Sources/Views/SignInWithFacebookButton.swift | 10 +++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 3885370322..b8e1cdd05e 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -154,7 +154,10 @@ extension AuthPickerView: View { .aspectRatio(contentMode: .fit) .frame(width: 100, height: 100) if authService.emailSignInEnabled { - EmailAuthView().environment(\.signInWithMergeConflictHandler, signInWithMergeConflictHandling) + EmailAuthView().environment( + \.signInWithMergeConflictHandler, + signInWithMergeConflictHandling + ) } Divider() otherSignInOptions(proxy) diff --git a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift index c898e85c06..1fd50dc472 100644 --- a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift +++ b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift @@ -39,12 +39,12 @@ extension SignInWithFacebookButton: View { ) { Task { if let handler = signInHandler { - try? await handler(authService) { - try await authService.signIn(facebookProvider) - } - } else { - try? await authService.signIn(facebookProvider) + try? await handler(authService) { + try await authService.signIn(facebookProvider) } + } else { + try? await authService.signIn(facebookProvider) + } } } }