diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 11339f4ca0..ae3ad3b173 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -83,6 +83,45 @@ public final class AuthService { public var authenticationFlow: AuthenticationFlow = .login public var errorMessage = "" public let passwordPrompt: PasswordPromptCoordinator = .init() + + // MARK: - AuthPickerView Modal APIs + + public var isShowingAuthModal = false + + public enum AuthModalContentType { + case phoneAuth + } + + public var currentModal: AuthModalContentType? + + public var authModalViewBuilderRegistry: [AuthModalContentType: () -> AnyView] = [:] + + public func registerModalView(for type: AuthModalContentType, + @ViewBuilder builder: @escaping () -> AnyView) { + authModalViewBuilderRegistry[type] = builder + } + + public func viewForCurrentModal() -> AnyView? { + guard let type = currentModal, + let builder = authModalViewBuilderRegistry[type] else { + return nil + } + return builder() + } + + public func presentModal(for type: AuthModalContentType) { + currentModal = type + isShowingAuthModal = true + } + + public func dismissModal() { + isShowingAuthModal = false + } + + // MARK: - End AuthPickerView Modal APIs + + // MARK: - Provider APIs + private var unsafeGoogleProvider: (any GoogleProviderAuthUIProtocol)? private var unsafeFacebookProvider: (any FacebookProviderAuthUIProtocol)? private var unsafePhoneAuthProvider: (any PhoneAuthProviderAuthUIProtocol)? @@ -146,6 +185,8 @@ public final class AuthService { } } + // MARK: - End Provider APIs + private func safeActionCodeSettings() throws -> ActionCodeSettings { // email sign-in requires action code settings guard let actionCodeSettings = configuration diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/StringUtils.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/StringUtils.swift index 1f84a883ce..a1c11e3155 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/StringUtils.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/StringUtils.swift @@ -271,7 +271,7 @@ public class StringUtils { /// found in: /// - SignInWithFacebookButton public var facebookLoginButtonLabel: String { - return localizedString(for: "Continue with Facebook") + return localizedString(for: "Sign in with Facebook") } /// Facebook provider @@ -319,8 +319,8 @@ public class StringUtils { /// Phone provider /// found in: /// - PhoneAuthButtonView - public var smsCodeSentLabel: String { - return localizedString(for: "SMS code sent") + public var smsCodeSendButtonLabel: String { + return localizedString(for: "Send SMS code") } /// Phone provider diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index e4c85834ea..9345290ddc 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -12,6 +12,13 @@ public struct AuthPickerView { .authenticationFlow == .login ? .signUp : .login } + private var isAuthModalPresented: Binding { + Binding( + get: { authService.isShowingAuthModal }, + set: { authService.isShowingAuthModal = $0 } + ) + } + @ViewBuilder private var authPickerTitleView: some View { if authService.authView == .authPicker { @@ -71,6 +78,33 @@ extension AuthPickerView: View { EmptyView() } } + }.sheet(isPresented: isAuthModalPresented) { + VStack(spacing: 0) { + HStack { + Button(action: { + authService.dismissModal() + }) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 17, weight: .medium)) + Text(authService.string.backButtonLabel) + .font(.system(size: 17)) + } + .foregroundColor(.blue) + } + Spacer() + } + .padding() + .background(Color(.systemBackground)) + + Divider() + + if let view = authService.viewForCurrentModal() { + view + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + } } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index de26519813..7e7c1719f3 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -123,7 +123,7 @@ extension EmailAuthView: View { } } .disabled(!isValid) - .padding([.top, .bottom], 8) + .padding([.top, .bottom, .horizontal], 8) .frame(maxWidth: .infinity) .buttonStyle(.borderedProminent) Button(action: { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/VerifyEmailView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/VerifyEmailView.swift index ca95ee1387..fd4b0e1deb 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/VerifyEmailView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/VerifyEmailView.swift @@ -25,7 +25,7 @@ extension VerifyEmailView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .padding([.top, .bottom], 8) + .padding([.top, .bottom, .horizontal], 8) .frame(maxWidth: .infinity) .buttonStyle(.borderedProminent) }.sheet(isPresented: $showModal) { diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift index 5b71b156bc..f97a21d1d7 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift @@ -5,90 +5,25 @@ import SwiftUI @MainActor public struct PhoneAuthButtonView { @Environment(AuthService.self) private var authService - @State private var errorMessage = "" - @State private var phoneNumber = "" - @State private var showVerificationCodeInput = false - @State private var verificationCode = "" - @State private var verificationID = "" public init() {} } extension PhoneAuthButtonView: View { public var body: some View { - if authService.authenticationState != .authenticating { - VStack { - LabeledContent { - TextField(authService.string.enterPhoneNumberLabel, text: $phoneNumber) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .submitLabel(.next) - } label: { - Image(systemName: "at") - }.padding(.vertical, 6) - .background(Divider(), alignment: .bottom) - .padding(.bottom, 4) - Button(action: { - Task { - do { - let id = try await authService.verifyPhoneNumber(phoneNumber: phoneNumber) - verificationID = id - showVerificationCodeInput = true - } catch { - errorMessage = authService.string.localizedErrorMessage( - for: error - ) - } - } - }) { - Text(authService.string.smsCodeSentLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .disabled(!PhoneUtils.isValidPhoneNumber(phoneNumber)) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) - Text(errorMessage).foregroundColor(.red) - }.sheet(isPresented: $showVerificationCodeInput) { - TextField(authService.string.phoneNumberVerificationCodeLabel, text: $verificationCode) - .keyboardType(.numberPad) - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - .padding(.horizontal) - - Button(action: { - Task { - do { - try await authService.signInWithPhoneNumber( - verificationID: verificationID, - verificationCode: verificationCode - ) - } catch { - errorMessage = authService.string.localizedErrorMessage(for: error) - } - showVerificationCodeInput = false - } - }) { - Text(authService.string.verifyPhoneNumberAndSignInLabel) - .foregroundColor(.white) - .padding() - .frame(maxWidth: .infinity) - .background(Color.green) - .cornerRadius(8) - .padding(.horizontal) - } - }.onOpenURL { url in - authService.auth.canHandle(url) + Button(action: { + authService.registerModalView(for: .phoneAuth) { + AnyView(PhoneAuthView().environment(authService)) } - } else { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) + authService.presentModal(for: .phoneAuth) + }) { + 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) } - Text(errorMessage).foregroundColor(.red) } } diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthView.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthView.swift new file mode 100644 index 0000000000..afec742650 --- /dev/null +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthView.swift @@ -0,0 +1,106 @@ +// +// PhoneAuthView.swift +// FirebaseUI +// +// Created by Russell Wheatley on 14/05/2025. +// + +import FirebaseAuthSwiftUI +import FirebaseCore +import SwiftUI + +@MainActor +public struct PhoneAuthView { + @Environment(AuthService.self) private var authService + @State private var errorMessage = "" + @State private var phoneNumber = "" + @State private var showVerificationCodeInput = false + @State private var verificationCode = "" + @State private var verificationID = "" + + public init() {} +} + +extension PhoneAuthView: View { + public var body: some View { + if authService.authenticationState != .authenticating { + VStack { + LabeledContent { + TextField(authService.string.enterPhoneNumberLabel, text: $phoneNumber) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .submitLabel(.next) + } label: { + Image(systemName: "at") + }.padding(.vertical, 6) + .background(Divider(), alignment: .bottom) + .padding(.bottom, 4) + Button(action: { + Task { + do { + let id = try await authService.verifyPhoneNumber(phoneNumber: phoneNumber) + verificationID = id + showVerificationCodeInput = true + } catch { + errorMessage = authService.string.localizedErrorMessage( + for: error + ) + } + } + }) { + Text(authService.string.smsCodeSendButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .disabled(!PhoneUtils.isValidPhoneNumber(phoneNumber)) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .buttonStyle(.borderedProminent) + Text(errorMessage).foregroundColor(.red) + }.sheet(isPresented: $showVerificationCodeInput) { + TextField(authService.string.phoneNumberVerificationCodeLabel, text: $verificationCode) + .keyboardType(.numberPad) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + .padding(.horizontal) + + Button(action: { + Task { + do { + try await authService.signInWithPhoneNumber( + verificationID: verificationID, + verificationCode: verificationCode + ) + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + } + showVerificationCodeInput = false + authService.dismissModal() + } + }) { + Text(authService.string.verifyPhoneNumberAndSignInLabel) + .foregroundColor(.white) + .padding() + .frame(maxWidth: .infinity) + .background(Color.green) + .cornerRadius(8) + .padding(.horizontal) + } + }.onOpenURL { url in + authService.auth.canHandle(url) + } + } else { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + } +} + +#Preview { + FirebaseOptions.dummyConfigurationForPreview() + return PhoneAuthView() + .environment(AuthService()) +} diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift index f53772ae5e..a9dc95f902 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift @@ -34,8 +34,8 @@ struct ContentView: View { configuration: configuration ) .withGoogleSignIn() - .withFacebookSignIn() .withPhoneSignIn() + .withFacebookSignIn() .withEmailSignIn() }