diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Configuration/AuthProvider.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Configuration/AuthProvider.swift new file mode 100644 index 0000000000..c5a9c9a6c2 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Configuration/AuthProvider.swift @@ -0,0 +1,147 @@ +// 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 FirebaseAuth + +enum AuthProvider: CaseIterable { + case google + case facebook + case twitter + case github + case email + case phone + case anonymous + case microsoft + case yahoo + case apple + + var id: String { + switch self { + case .google: return GoogleAuthProvider.id + case .facebook: return FacebookAuthProvider.id + case .twitter: return TwitterAuthProvider.id + case .github: return GitHubAuthProvider.id + case .email: return EmailAuthProvider.id + case .phone: return PhoneAuthProvider.id + case .anonymous: return "anonymous" + case .microsoft: return "microsoft.com" + case .yahoo: return "yahoo.com" + case .apple: return "apple.com" + } + } + + var buttonTitle: String { + switch self { + case .google: + return "Sign in with Google" + case .facebook: + return "Sign in with Facebook" + case .twitter: + return "Sign in with Twitter" + case .github: + return "Sign in with GitHub" + case .email: + return "Sign in with Email" + case .phone: + return "Sign in with Phone" + case .anonymous: + return "Sign in Anonymously" + case .microsoft: + return "Sign in with Microsoft" + case .yahoo: + return "Sign in with Yahoo" + case .apple: + return "Sign in with Apple" + } + } + + var isSocialProvider: Bool { + switch self { + case .google, .facebook, .twitter, .github: + return true + default: + return false + } + } + + static func from(id: String) -> AuthProvider? { + Self.allCases.first { $0.id == id } + } + + var providerStyle: ProviderStyle { + switch self { + case .google: + return ProviderStyle( + icon: .fuiIcGoogleg, + backgroundColor: Color(hex: 0xFFFFFF), + contentColor: Color(hex: 0x757575) + ) + case .facebook: + return ProviderStyle( + icon: .fuiIcFacebook, + backgroundColor: Color(hex: 0x3B5998), + contentColor: Color(hex: 0xFFFFFF) + ) + case .twitter: + return ProviderStyle( + icon: .fuiIcTwitterBird, + backgroundColor: Color(hex: 0x5BAAF4), + contentColor: Color(hex: 0xFFFFFF) + ) + case .github: + return ProviderStyle( + icon: .fuiIcGithub, + backgroundColor: Color(hex: 0x24292E), + contentColor: Color(hex: 0xFFFFFF) + ) + case .email: + return ProviderStyle( + icon: .fuiIcMail, + backgroundColor: Color(hex: 0xD0021B), + contentColor: Color(hex: 0xFFFFFF) + ) + case .phone: + return ProviderStyle( + icon: .fuiIcPhone, + backgroundColor: Color(hex: 0x43C5A5), + contentColor: Color(hex: 0xFFFFFF) + ) + case .anonymous: + return ProviderStyle( + icon: .fuiIcAnonymous, + backgroundColor: Color(hex: 0xF4B400), + contentColor: Color(hex: 0xFFFFFF) + ) + case .microsoft: + return ProviderStyle( + icon: .fuiIcMicrosoft, + backgroundColor: Color(hex: 0x2F2F2F), + contentColor: Color(hex: 0xFFFFFF) + ) + case .yahoo: + return ProviderStyle( + icon: .fuiIcYahoo, + backgroundColor: Color(hex: 0x720E9E), + contentColor: Color(hex: 0xFFFFFF) + ) + case .apple: + return ProviderStyle( + icon: .fuiIcApple, + backgroundColor: Color(hex: 0x000000), + contentColor: Color(hex: 0xFFFFFF) + ) + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Extensions/Color+Hex.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Extensions/Color+Hex.swift new file mode 100644 index 0000000000..afcc90b692 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Extensions/Color+Hex.swift @@ -0,0 +1,24 @@ +// 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: Int, opacity: Double = 1.0) { + let red = Double((hex & 0xFF0000) >> 16) / 255.0 + let green = Double((hex & 0xFF00) >> 8) / 255.0 + let blue = Double((hex & 0xFF) >> 0) / 255.0 + self.init(.sRGB, red: red, green: green, blue: blue, opacity: opacity) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000000..eb87897008 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..2305880107 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/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/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/Contents.json b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/firebase-auth-logo.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/firebase-auth-logo.imageset/Contents.json new file mode 100644 index 0000000000..d41340246f --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/firebase-auth-logo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "firebase_auth_120dp 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "firebase_auth_120dp 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "firebase_auth_120dp.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/firebase-auth-logo.imageset/firebase_auth_120dp 1.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/firebase-auth-logo.imageset/firebase_auth_120dp 1.png new file mode 100644 index 0000000000..6a6374e30e Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/firebase-auth-logo.imageset/firebase_auth_120dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/firebase-auth-logo.imageset/firebase_auth_120dp 2.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/firebase-auth-logo.imageset/firebase_auth_120dp 2.png new file mode 100644 index 0000000000..351fa59fe5 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/firebase-auth-logo.imageset/firebase_auth_120dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/firebase-auth-logo.imageset/firebase_auth_120dp.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/firebase-auth-logo.imageset/firebase_auth_120dp.png new file mode 100644 index 0000000000..9b68e8ade0 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/firebase-auth-logo.imageset/firebase_auth_120dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-anonymous.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-anonymous.imageset/Contents.json new file mode 100644 index 0000000000..482a49b90f --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-anonymous.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui_ic_anonymous_white_24dp 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui_ic_anonymous_white_24dp 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui_ic_anonymous_white_24dp.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp 1.png new file mode 100644 index 0000000000..4867274485 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp 2.png new file mode 100644 index 0000000000..5c2f2bcd90 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp.png new file mode 100644 index 0000000000..9d57c10f7e Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-apple.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-apple.imageset/Contents.json new file mode 100644 index 0000000000..b8005dda54 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-apple.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "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" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp 1.png new file mode 100644 index 0000000000..d251bbd78f Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp 2.png new file mode 100644 index 0000000000..7c239197b3 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp.png new file mode 100644 index 0000000000..0914e18323 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-facebook.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-facebook.imageset/Contents.json new file mode 100644 index 0000000000..a220841ee5 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-facebook.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui_ic_facebook_white_22dp 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui_ic_facebook_white_22dp 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui_ic_facebook_white_22dp.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_white_22dp 1.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_white_22dp 1.png new file mode 100644 index 0000000000..fb5d39e09b Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_white_22dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_white_22dp 2.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_white_22dp 2.png new file mode 100644 index 0000000000..6cc6fcf3c9 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_white_22dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_white_22dp.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_white_22dp.png new file mode 100644 index 0000000000..717ca815a1 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_white_22dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-github.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-github.imageset/Contents.json new file mode 100644 index 0000000000..6acf81f95a --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/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/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp 1.png new file mode 100644 index 0000000000..bad7f150f9 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp 2.png new file mode 100644 index 0000000000..aa84b536ca Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp.png new file mode 100644 index 0000000000..437f627122 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-googleg.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-googleg.imageset/Contents.json new file mode 100644 index 0000000000..f6ede1b0b3 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/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/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp 1.png new file mode 100644 index 0000000000..c9f49bd31f Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp 2.png new file mode 100644 index 0000000000..a3c7bf97ca Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp.png new file mode 100644 index 0000000000..9df17f75fe Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-mail.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-mail.imageset/Contents.json new file mode 100644 index 0000000000..2401fa19fa --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/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/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp 1.png new file mode 100644 index 0000000000..b8f42d5d78 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp 2.png new file mode 100644 index 0000000000..937721e2eb Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp.png new file mode 100644 index 0000000000..273756411a Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-microsoft.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-microsoft.imageset/Contents.json new file mode 100644 index 0000000000..123e877f0a --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/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/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp 1.png new file mode 100644 index 0000000000..b43f424a5d Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp 2.png new file mode 100644 index 0000000000..5455ead2cf Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp.png new file mode 100644 index 0000000000..98ca3614c5 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-phone.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-phone.imageset/Contents.json new file mode 100644 index 0000000000..14af6b8003 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/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/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp 1.png new file mode 100644 index 0000000000..e040bdf1a9 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp 2.png new file mode 100644 index 0000000000..70579d4aa6 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp.png new file mode 100644 index 0000000000..27a6b5438c Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-twitter-bird.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-twitter-bird.imageset/Contents.json new file mode 100644 index 0000000000..f9efe37c15 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-twitter-bird.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui_ic_twitter_bird_white_24dp 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui_ic_twitter_bird_white_24dp 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui_ic_twitter_bird_white_24dp.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-twitter-bird.imageset/fui_ic_twitter_bird_white_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-twitter-bird.imageset/fui_ic_twitter_bird_white_24dp 1.png new file mode 100644 index 0000000000..4c99510bc3 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-twitter-bird.imageset/fui_ic_twitter_bird_white_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-twitter-bird.imageset/fui_ic_twitter_bird_white_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-twitter-bird.imageset/fui_ic_twitter_bird_white_24dp 2.png new file mode 100644 index 0000000000..2546331cec Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-twitter-bird.imageset/fui_ic_twitter_bird_white_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-twitter-bird.imageset/fui_ic_twitter_bird_white_24dp.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-twitter-bird.imageset/fui_ic_twitter_bird_white_24dp.png new file mode 100644 index 0000000000..1414814317 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-twitter-bird.imageset/fui_ic_twitter_bird_white_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-yahoo.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-yahoo.imageset/Contents.json new file mode 100644 index 0000000000..9ae684f1b1 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/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/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp 1.png new file mode 100644 index 0000000000..0b733b01ae Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp 2.png new file mode 100644 index 0000000000..be4fe60ce5 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp.png b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp.png new file mode 100644 index 0000000000..9f6b1ec58b Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Theme/ProviderStyle.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Theme/ProviderStyle.swift new file mode 100644 index 0000000000..207d3355e8 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Theme/ProviderStyle.swift @@ -0,0 +1,36 @@ +// 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 + +struct ProviderStyle { + let icon: ImageResource? + let backgroundColor: Color + let contentColor: Color + var iconTint: Color? = nil + let shape: AnyShape = AnyShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + let elevation: CGFloat = 2 + + static let empty = ProviderStyle( + icon: nil, + backgroundColor: .white, + contentColor: .black + ) + + static var `default`: [String: ProviderStyle] { + Dictionary(uniqueKeysWithValues: AuthProvider.allCases.map { provider in + (provider.id, provider.providerStyle) + }) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Theme/Theme.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Theme/Theme.swift new file mode 100644 index 0000000000..d0e8d78724 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Theme/Theme.swift @@ -0,0 +1,14 @@ +// 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. + diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/AuthMethodPickerListView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/AuthMethodPickerListView.swift new file mode 100644 index 0000000000..24d5035596 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/AuthMethodPickerListView.swift @@ -0,0 +1,73 @@ +// 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 + +struct AuthMethodPickerListView: View { + var onProviderSelected: (AuthProvider) -> Void + + var body: some View { + GeometryReader { proxy in + ScrollView { + VStack(spacing: 16) { + AuthProviderButton( + provider: .apple, + onClick: onProviderSelected + ) + AuthProviderButton( + provider: .anonymous, + onClick: onProviderSelected + ) + AuthProviderButton( + provider: .email, + onClick: onProviderSelected + ) + AuthProviderButton( + provider: .phone, + onClick: onProviderSelected + ) + AuthProviderButton( + provider: .google, + onClick: onProviderSelected + ) + AuthProviderButton( + provider: .facebook, + onClick: onProviderSelected + ) + AuthProviderButton( + provider: .twitter, + onClick: onProviderSelected + ) + AuthProviderButton( + provider: .github, + onClick: onProviderSelected + ) + AuthProviderButton( + provider: .microsoft, + onClick: onProviderSelected + ) + AuthProviderButton( + provider: .yahoo, + onClick: onProviderSelected + ) + } + .padding(.horizontal, proxy.size.width * 0.18) + } + } + } +} + +#Preview { + AuthMethodPickerListView { selectedProvider in } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Components/AnnotatedString.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Components/AnnotatedString.swift new file mode 100644 index 0000000000..fdec24b9a2 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Components/AnnotatedString.swift @@ -0,0 +1,56 @@ +// 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 + +struct AnnotatedString: View { + let fullText: String + let links: [(label: String, url: String)] + + init( + fullText: String, + links: [(String, String)], + ) { + self.fullText = fullText + self.links = links + } + + var body: some View { + let text = makeAttributedText() + Text(text) + .multilineTextAlignment(.center) + .tint(.accentColor) // Use theme color + .onOpenURL { url in + // Handle URL tap (optional custom handling) + UIApplication.shared.open(url) + } + } + + private func makeAttributedText() -> AttributedString { + let template = fullText + var attributed = AttributedString(template) + + for (label, urlString) in links { + guard let range = attributed.range(of: label), + let url = URL(string: urlString) + else { continue } + + attributed[range].link = url + attributed[range].foregroundColor = UIColor.tintColor + attributed[range].underlineStyle = Text.LineStyle.single + } + + return attributed + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Components/AuthProviderButton.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Components/AuthProviderButton.swift new file mode 100644 index 0000000000..d28601badd --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Components/AuthProviderButton.swift @@ -0,0 +1,83 @@ +// 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 + +struct AuthProviderButton: View { + let provider: AuthProvider + let onClick: (AuthProvider) -> Void + var enabled: Bool = true + var style: ProviderStyle? = nil + + private var resolvedStyle: ProviderStyle { + style ?? provider.providerStyle + } + + var body: some View { + let providerStyle = resolvedStyle + Button { + onClick(provider) + } label: { + HStack(spacing: 12) { + if let iconResource = providerStyle.icon { + providerIcon(for: iconResource, tint: providerStyle.iconTint) + } + Text(provider.buttonTitle) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(providerStyle.contentColor) + } + } + .buttonStyle(ProviderButtonStyle(style: providerStyle)) + .disabled(!enabled) + } + + @ViewBuilder + private func providerIcon(for resource: ImageResource, tint: Color?) -> some View { + if let tint { + Image(resource) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundStyle(tint) + } else { + Image(resource) + .renderingMode(.original) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + } +} + +private struct ProviderButtonStyle: PrimitiveButtonStyle { + let style: ProviderStyle + + func makeBody(configuration: Configuration) -> some View { + Button(action: configuration.trigger) { + configuration.label + .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 + ) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Components/AuthTextField.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Components/AuthTextField.swift new file mode 100644 index 0000000000..29b4f936d2 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Components/AuthTextField.swift @@ -0,0 +1,169 @@ +// 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 + +struct FieldValidation: Identifiable, Equatable { + let id = UUID() + let message: String + var valid: Bool = false +} + +struct AuthTextField: View { + //@Environment(Theme.self) private var theme + @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? + + 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() } + ) { + self._text = text + self.localizedTitle = localizedTitle + self.prompt = prompt + self.textAlignment = textAlignment + self.keyboardType = keyboardType + self.contentType = contentType + self.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 } + } + + 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) { oldValue, 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) { oldValue, newValue in + formState?(newValue) + if !newValue { + withAnimation(.easeInOut(duration: 0.08).repeatCount(4)) { + invalidInput = true + } completion: { + invalidInput = false + } + } + } + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Email/EmailResetPasswordView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Email/EmailResetPasswordView.swift new file mode 100644 index 0000000000..b0361db1a8 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Email/EmailResetPasswordView.swift @@ -0,0 +1,102 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SwiftUI + +struct EmailResetPasswordView: View { + let state: EmailAuthContentState + + var body: some View { + VStack(spacing: 32) { + VStack(spacing: 16) { + if state.resetLinkSent { + VStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 48)) + .foregroundStyle(.green) + + Text("Password reset link sent!") + .font(.headline) + + Text("Check your email at \(state.email.wrappedValue) for a link to reset your password.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + } else { + VStack(spacing: 16) { + Text("Enter your email address and we'll send you a link to reset your password.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + AuthTextField( + text: state.email, + localizedTitle: "Email", + prompt: "Enter your email", + keyboardType: .emailAddress, + contentType: .emailAddress + ) + + Button { + state.onSendResetLinkClick() + } label: { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text("Send Reset Link") + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(state.isLoading) + + if let error = state.error { + Text(error) + .foregroundStyle(.red) + .font(.caption) + } + } + } + } + } + .navigationTitle("Reset Password") + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } +} + +#Preview { + NavigationStack { + EmailResetPasswordView(state: EmailAuthContentState( + isLoading: false, + error: nil, + email: .constant(""), + password: .constant(""), + confirmPassword: .constant(""), + displayName: .constant(""), + resetLinkSent: false, + onSignInClick: {}, + onSignUpClick: {}, + onSendResetLinkClick: {}, + onGoToSignUp: {}, + onGoToSignIn: {}, + onGoToResetPassword: {} + )) + .safeAreaPadding() + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Email/EmailSignInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Email/EmailSignInView.swift new file mode 100644 index 0000000000..3157d2ca5f --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Email/EmailSignInView.swift @@ -0,0 +1,107 @@ +// 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 FirebaseAuthSwiftUI + +struct EmailSignInView: View { + let authService: AuthService + let state: EmailAuthContentState + + var body: some View { + VStack(spacing: 32) { + VStack(spacing: 16) { + Group { + AuthTextField( + text: state.email, + localizedTitle: "Email", + prompt: "Enter your email", + keyboardType: .emailAddress, + contentType: .emailAddress + ) + AuthTextField( + text: state.password, + localizedTitle: "Password", + prompt: "Enter your password", + contentType: .password, + sensitive: true + ) + } + + Button { + state.onGoToResetPassword() + } label: { + Text("Forgot password?") + .frame(maxWidth: .infinity, alignment: .trailing) + } + + Button { + state.onSignInClick() + } label: { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text("Sign in") + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(state.isLoading) + + if let error = state.error { + Text(error) + .foregroundStyle(.red) + .font(.caption) + } + } + + Button { + state.onGoToSignUp() + } label: { + Text("Create an Account") + .frame(maxWidth: .infinity) + } + .disabled(state.isLoading) + } + .navigationTitle("Sign in with email") + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } +} + +#Preview { + NavigationStack { + EmailSignInView( + authService: AuthService(), + state: EmailAuthContentState( + isLoading: false, + error: nil, + email: .constant(""), + password: .constant(""), + confirmPassword: .constant(""), + displayName: .constant(""), + resetLinkSent: false, + onSignInClick: {}, + onSignUpClick: {}, + onSendResetLinkClick: {}, + onGoToSignUp: {}, + onGoToSignIn: {}, + onGoToResetPassword: {} + ) + ) + .safeAreaPadding() + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Email/EmailSignUpView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Email/EmailSignUpView.swift new file mode 100644 index 0000000000..51e1413a09 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Email/EmailSignUpView.swift @@ -0,0 +1,103 @@ +// 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 + +struct EmailSignUpView: View { + let state: EmailAuthContentState + + var body: some View { + VStack(spacing: 32) { + VStack(spacing: 16) { + Group { + AuthTextField( + text: state.displayName, + localizedTitle: "Display Name", + prompt: "Enter your name", + contentType: .name + ) + + AuthTextField( + text: state.email, + localizedTitle: "Email", + prompt: "Enter your email", + keyboardType: .emailAddress, + contentType: .emailAddress + ) + + AuthTextField( + text: state.password, + localizedTitle: "Password", + prompt: "Enter your password", + contentType: .newPassword, + sensitive: true + ) + + AuthTextField( + text: state.confirmPassword, + localizedTitle: "Confirm Password", + prompt: "Re-enter your password", + contentType: .newPassword, + sensitive: true + ) + } + + Button { + state.onSignUpClick() + } label: { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text("Create Account") + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(state.isLoading) + + if let error = state.error { + Text(error) + .foregroundStyle(.red) + .font(.caption) + } + } + } + .navigationTitle("Create an account") + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } +} + +#Preview { + NavigationStack { + EmailSignUpView(state: EmailAuthContentState( + isLoading: false, + error: nil, + email: .constant(""), + password: .constant(""), + confirmPassword: .constant(""), + displayName: .constant(""), + resetLinkSent: false, + onSignInClick: {}, + onSignUpClick: {}, + onSendResetLinkClick: {}, + onGoToSignUp: {}, + onGoToSignIn: {}, + onGoToResetPassword: {} + )) + .safeAreaPadding() + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/FirebaseAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/FirebaseAuthView.swift new file mode 100644 index 0000000000..2c353095b2 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/FirebaseAuthView.swift @@ -0,0 +1,45 @@ +// 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 FirebaseAuthView: View { + public init( + authService: AuthService, + isPresented: Binding = .constant(false), + interactiveDismissDisabled: Bool = true, + @ViewBuilder content: @escaping () -> Content = { EmptyView() } + ) { + self.authService = authService + self.isPresented = isPresented + self.interactiveDismissDisabled = interactiveDismissDisabled + self.content = content + } + + private var authService: AuthService + private var isPresented: Binding + private var interactiveDismissDisabled: Bool + private let content: () -> Content? + + + public var body: some View { + content() + .sheet(isPresented: isPresented) { + FirebaseAuthViewInternal( + authService: authService, + interactiveDismissDisabled: interactiveDismissDisabled + ) + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/FirebaseAuthViewInternal.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/FirebaseAuthViewInternal.swift new file mode 100644 index 0000000000..b62d3900ab --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/FirebaseAuthViewInternal.swift @@ -0,0 +1,321 @@ +// 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 + +struct StringError: LocalizedError { + let message: String + + var errorDescription: String? { message } +} + +enum Route: Hashable { + case emailAuth(EmailAuthMode) + case phoneAuth(PhoneAuthStep) + + @ViewBuilder + @MainActor + func destination( + authService: AuthService, + emailAuthState: EmailAuthContentState, + phoneAuthState: PhoneAuthContentState + ) -> some View { + switch self { + case .emailAuth(let mode): + switch mode { + case .signIn: + EmailSignInView( + authService: authService, + state: emailAuthState + ) + .safeAreaPadding() + case .signUp: + EmailSignUpView(state: emailAuthState) + .safeAreaPadding() + case .resetPassword: + EmailResetPasswordView(state: emailAuthState) + .safeAreaPadding() + } + case .phoneAuth(let step): + switch step { + case .enterPhoneNumber: + EnterPhoneNumberView(state: phoneAuthState) + .safeAreaPadding() + case .enterVerificationCode: + EnterVerificationCodeView(state: phoneAuthState) + .safeAreaPadding() + } + } + } +} + +enum EmailAuthMode { + case signIn + case signUp + case resetPassword +} + +enum PhoneAuthStep { + case enterPhoneNumber + case enterVerificationCode +} + +struct CountryData { + let name: String + let dialCode: String + let code: String + + var flag: String { + // Convert country code to flag emoji + let base: UInt32 = 127397 + var emoji = "" + for scalar in code.unicodeScalars { + if let unicodeScalar = UnicodeScalar(base + scalar.value) { + emoji.append(String(unicodeScalar)) + } + } + return emoji + } + + static let `default` = CountryData(name: "United States", dialCode: "+1", code: "US") +} + +struct EmailAuthContentState { + var isLoading: Bool + var error: String? + var email: Binding + var password: Binding + var confirmPassword: Binding + var displayName: Binding + var resetLinkSent: Bool + var onSignInClick: () -> Void + var onSignUpClick: () -> Void + var onSendResetLinkClick: () -> Void + var onGoToSignUp: () -> Void + var onGoToSignIn: () -> Void + var onGoToResetPassword: () -> Void +} + +struct PhoneAuthContentState { + var isLoading: Bool + var error: String? + var phoneNumber: Binding + var selectedCountry: Binding + var verificationCode: Binding + var fullPhoneNumber: String + var resendTimer: Int + var onSendCodeClick: () -> Void + var onVerifyCodeClick: () -> Void + var onResendCodeClick: () -> Void + var onChangeNumberClick: () -> Void +} + +@Observable +class Navigator { + var routes: [Route] = [] + + func push(_ route: Route) { + routes.append(route) + } + + @discardableResult + func pop() -> Route? { + routes.popLast() + } +} + +struct FirebaseAuthViewInternal: View { + init( + authService: AuthService, + interactiveDismissDisabled: Bool = true + ) { + self.authService = authService + self.interactiveDismissDisabled = interactiveDismissDisabled + } + + private var authService: AuthService + private var interactiveDismissDisabled: Bool + @State private var navigator = Navigator() + + // Email Auth State + @State private var email = "" + @State private var password = "" + @State private var confirmPassword = "" + @State private var displayName = "" + @State private var emailError: String? + @State private var resetLinkSent = false + + // Phone Auth State + @State private var phoneNumber = "" + @State private var verificationCode = "" + @State private var selectedCountry: CountryData = .default + @State private var phoneIsLoading = false + @State private var phoneError: String? + @State private var resendTimer = 0 + + @State private var isShowingErrorAlert = false + + var body: some View { + NavigationStack(path: $navigator.routes) { + authMethodPicker + .navigationTitle("Authentication") + .navigationBarTitleDisplayMode(.large) + .navigationDestination(for: Route.self) { route in + route.destination( + authService: authService, + emailAuthState: createEmailAuthState(), + phoneAuthState: createPhoneAuthState() + ) + } + } + .alert( + isPresented: $isShowingErrorAlert, + error: StringError(message: authService.currentError?.message ?? "") + ) { + Button("OK") { + isShowingErrorAlert = false + } + } + .onChange(of: authService.currentError?.message ?? "") { _, newValue in + debugPrint("onChange: \(newValue)") + isShowingErrorAlert = !newValue.isEmpty + } + .interactiveDismissDisabled(interactiveDismissDisabled) + } + + @ViewBuilder + var authMethodPicker: some View { + VStack { + Image(.firebaseAuthLogo) + AuthMethodPickerListView { selectedProvider in + switch selectedProvider { + case .email: + navigator.push(.emailAuth(.signIn)) + case .phone: + navigator.push(.phoneAuth(.enterPhoneNumber)) + default: + break + } + } + .padding(.vertical, 16) + tosAndPPFooter + .padding(.horizontal, 16) + } + .padding(.top, 24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + var tosAndPPFooter: some View { + AnnotatedString( + fullText: "By continuing, you accept our Terms of Service and Privacy Policy.", + links: [ + ("Terms of Service", "https://example.com/terms"), + ("Privacy Policy", "https://example.com/privacy") + ] + ) + } + + // MARK: - State Creation + + private func createEmailAuthState() -> EmailAuthContentState { + EmailAuthContentState( + isLoading: authService.authenticationState == .authenticating, + error: emailError, + email: $email, + password: $password, + confirmPassword: $confirmPassword, + displayName: $displayName, + resetLinkSent: resetLinkSent, + onSignInClick: handleEmailSignIn, + onSignUpClick: handleEmailSignUp, + onSendResetLinkClick: handleSendResetLink, + onGoToSignUp: { + handleEmailAuthNavigation(route: .emailAuth(.signUp)) + }, + onGoToSignIn: { + handleEmailAuthNavigation(route: .emailAuth(.signIn)) + }, + onGoToResetPassword: { + handleEmailAuthNavigation(route: .emailAuth(.resetPassword)) + } + ) + } + + private func createPhoneAuthState() -> PhoneAuthContentState { + PhoneAuthContentState( + isLoading: authService.authenticationState == .authenticating, + error: phoneError, + phoneNumber: $phoneNumber, + selectedCountry: $selectedCountry, + verificationCode: $verificationCode, + fullPhoneNumber: "\(selectedCountry.dialCode)\(phoneNumber)", + resendTimer: resendTimer, + onSendCodeClick: handleSendCode, + onVerifyCodeClick: handleVerifyCode, + onResendCodeClick: handleResendCode, + onChangeNumberClick: { + verificationCode = "" + navigator.pop() + } + ) + } + + // MARK: - Email Auth Handlers + + private func handleEmailAuthNavigation(route: Route) { + email = "" + password = "" + confirmPassword = "" + displayName = "" + navigator.push(route) + } + + private func handleEmailSignIn() { + Task { + try? await authService.signIn(email: email, password: password) + } + } + + private func handleEmailSignUp() { + Task { + try? await authService.createUser(email: email, password: password) + } + } + + private func handleSendResetLink() { + Task { + try? await authService.sendPasswordRecoveryEmail(email: email) + } + } + + // MARK: - Phone Auth Handlers + + private func handleSendCode() { + // TODO: Implement send code logic + navigator.push(.phoneAuth(.enterVerificationCode)) + } + + private func handleVerifyCode() { + // TODO: Implement verify code logic + } + + private func handleResendCode() { + // TODO: Implement resend code logic + } +} + +#Preview { + FirebaseAuthViewInternal(authService: AuthService()) +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Phone/CountrySelector.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Phone/CountrySelector.swift new file mode 100644 index 0000000000..77a00f5f63 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Phone/CountrySelector.swift @@ -0,0 +1,77 @@ +// 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 + +struct CountrySelector: View { + @Binding var selectedCountry: CountryData + var enabled: Bool = true + var allowedCountries: Set? = nil + + // 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 + } + + var body: some View { + Menu { + ForEach(filteredCountries, id: \.code) { country in + Button { + selectedCountry = country + } label: { + Text("\(country.flag) \(country.name) (\(country.dialCode))") + } + } + } label: { + HStack(spacing: 4) { + Text(selectedCountry.flag) + .font(.title3) + Text(selectedCountry.dialCode) + .font(.body) + .foregroundStyle(.primary) + Image(systemName: "chevron.down") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .disabled(!enabled) + } +} + +#Preview { + CountrySelector( + selectedCountry: .constant(.default) + ) +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Phone/EnterPhoneNumberView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Phone/EnterPhoneNumberView.swift new file mode 100644 index 0000000000..98a300a154 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Phone/EnterPhoneNumberView.swift @@ -0,0 +1,87 @@ +// 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 + +struct EnterPhoneNumberView: View { + let state: PhoneAuthContentState + + var body: some View { + VStack(spacing: 16) { + Text("Enter your phone number to get started") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + + // Phone number input with country selector + AuthTextField( + text: state.phoneNumber, + localizedTitle: "Phone Number", + prompt: "Enter your phone number", + keyboardType: .phonePad, + contentType: .telephoneNumber, + onChange: { _ in } + ) { + CountrySelector( + selectedCountry: state.selectedCountry, + enabled: !state.isLoading + ) + } + + Button { + state.onSendCodeClick() + } label: { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text("Send Code") + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(state.isLoading || state.phoneNumber.wrappedValue.isEmpty) + + if let error = state.error { + Text(error) + .foregroundStyle(.red) + .font(.caption) + } + } + .navigationTitle("Sign in with phone") + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } +} + +#Preview { + NavigationStack { + EnterPhoneNumberView(state: PhoneAuthContentState( + isLoading: false, + error: nil, + phoneNumber: .constant(""), + selectedCountry: .constant(.default), + verificationCode: .constant(""), + fullPhoneNumber: "+1 ", + resendTimer: 0, + onSendCodeClick: {}, + onVerifyCodeClick: {}, + onResendCodeClick: {}, + onChangeNumberClick: {} + )) + .safeAreaPadding() + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Phone/EnterVerificationCodeView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Phone/EnterVerificationCodeView.swift new file mode 100644 index 0000000000..59d20290a7 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Phone/EnterVerificationCodeView.swift @@ -0,0 +1,103 @@ +// 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 + +struct EnterVerificationCodeView: View { + let state: PhoneAuthContentState + + var body: some View { + VStack(spacing: 32) { + VStack(spacing: 16) { + VStack(spacing: 8) { + Text("We sent a code to \(state.fullPhoneNumber)") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + Button { + state.onChangeNumberClick() + } label: { + Text("Change number") + .font(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.bottom) + .frame(maxWidth: .infinity, alignment: .leading) + + // Verification code input + VerificationCodeInputField( + code: state.verificationCode, + isError: state.error != nil, + errorMessage: state.error + ) + + Button { + state.onVerifyCodeClick() + } label: { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text("Verify Code") + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(state.isLoading || state.verificationCode.wrappedValue.count != 6) + } + + // Resend code section + VStack(spacing: 8) { + if state.resendTimer > 0 { + Text("Resend code in \(state.resendTimer)s") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Button { + state.onResendCodeClick() + } label: { + Text("Resend Code") + .font(.caption) + } + .disabled(state.isLoading) + } + } + } + .navigationTitle("Verify Phone Number") + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } +} + +#Preview { + NavigationStack { + EnterVerificationCodeView(state: PhoneAuthContentState( + isLoading: false, + error: nil, + phoneNumber: .constant(""), + selectedCountry: .constant(.default), + verificationCode: .constant(""), + fullPhoneNumber: "+1 5551234567", + resendTimer: 0, + onSendCodeClick: {}, + onVerifyCodeClick: {}, + onResendCodeClick: {}, + onChangeNumberClick: {} + )) + .safeAreaPadding() + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Phone/VerificationCodeInputField.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Phone/VerificationCodeInputField.swift new file mode 100644 index 0000000000..ae795e4018 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/FirebaseAuthView/Views/Phone/VerificationCodeInputField.swift @@ -0,0 +1,555 @@ +// 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 + +struct VerificationCodeInputField: View { + init( + code: Binding, + codeLength: Int = 6, + isError: Bool = false, + errorMessage: String? = nil, + onCodeComplete: @escaping (String) -> Void = { _ in }, + onCodeChange: @escaping (String) -> Void = { _ in } + ) { + self._code = code + self.codeLength = codeLength + self.isError = isError + self.errorMessage = errorMessage + self.onCodeComplete = onCodeComplete + self.onCodeChange = onCodeChange + self._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 + + var body: some View { + VStack(spacing: 8) { + HStack(spacing: 8) { + ForEach(0.. 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).. 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/FirebaseAuthSwiftUI/Sources/Strings/Localizable.xcstrings b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Strings/Localizable.xcstrings index a9b077b883..ef52e93e9f 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Strings/Localizable.xcstrings +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Strings/Localizable.xcstrings @@ -3,6 +3,22 @@ "strings" : { "%@" : { + }, + "%@ %@ (%@)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ %2$@ (%3$@)" + } + } + } + }, + "••••••%@" : { + + }, + "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 +43,12 @@ } } } + }, + "Add an extra layer of security to your account" : { + + }, + "Add Another Method" : { + }, "AddPasswordAlertMessage" : { "comment" : "Alert message shown when adding account password.", @@ -162,6 +184,18 @@ } } } + }, + "as:" : { + + }, + "Authentication" : { + + }, + "Authentication Method" : { + + }, + "Authenticator App" : { + }, "AuthPickerTitle" : { "comment" : "Title for auth picker screen.", @@ -213,6 +247,18 @@ } } } + }, + "Change number" : { + + }, + "Check your email at %@ for a link to reset your password." : { + + }, + "Choose Authentication Method" : { + + }, + "Choose verification method:" : { + }, "ChoosePassword" : { "comment" : "Placeholder for the password text field in a sign up form.", @@ -237,6 +283,15 @@ } } } + }, + "Complete Setup" : { + + }, + "Complete Sign-In" : { + + }, + "Complete sign-in with your second factor" : { + }, "ConfirmEmail" : { "comment" : "Title of confirm email label.", @@ -261,6 +316,18 @@ } } } + }, + "Copied to clipboard!" : { + + }, + "Create Account" : { + + }, + "Create an account" : { + + }, + "Create an Account" : { + }, "Delete" : { "comment" : "Text of Delete action button.", @@ -321,6 +388,19 @@ } } } + }, + "Digit %lld of %lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Digit %1$lld of %2$lld" + } + } + } + }, + "Display Name" : { + }, "Don't have an account yet?" : { "localizations" : { @@ -439,6 +519,36 @@ } } } + }, + "Enrolled Methods" : { + + }, + "Enrolled: %@" : { + + }, + "Enter 6-digit code" : { + + }, + "Enter Code from App" : { + + }, + "Enter the 6-digit code from your authenticator app" : { + + }, + "Enter Verification Code" : { + + }, + "Enter verification code digit" : { + + }, + "Enter your email address and we'll send you a link to reset your password." : { + + }, + "Enter Your Phone Number" : { + + }, + "Enter your phone number to get started" : { + }, "EnterYourEmail" : { "comment" : "Title for email entry screen, email text field placeholder. Use short/abbreviated translation for 'email' which is less than 15 chars.", @@ -499,6 +609,9 @@ } } } + }, + "Forgot password?" : { + }, "ForgotPassword" : { "comment" : "Button text for 'Forgot Password' action.", @@ -523,6 +636,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 +673,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 +712,15 @@ } } } + }, + "No Authentication Methods" : { + + }, + "No Authentication Methods Available" : { + + }, + "No MFA methods are configured as allowed. Please contact your administrator." : { + }, "OK" : { "comment" : "OK button title.", @@ -593,6 +733,9 @@ } } } + }, + "Password reset link sent!" : { + }, "PasswordRecoveryEmailSentMessage" : { "comment" : "Message displayed when the email for password recovery has been sent.", @@ -653,6 +796,9 @@ } } } + }, + "Phone Number" : { + }, "PlaceholderChosePassword" : { "comment" : "Placeholder of secret input cell when user changes password.", @@ -797,6 +943,9 @@ } } } + }, + "Remove" : { + }, "Resend" : { "comment" : "Resend button title.", @@ -809,6 +958,15 @@ } } } + }, + "Resend Code" : { + + }, + "Resend code in %llds" : { + + }, + "Reset Password" : { + }, "Save" : { "comment" : "Save button title.", @@ -821,6 +979,12 @@ } } } + }, + "Scan QR Code" : { + + }, + "Scan with your authenticator app or tap to open directly" : { + }, "Send" : { "comment" : "Send button title.", @@ -833,6 +997,12 @@ } } } + }, + "Send Code" : { + + }, + "Send Reset Link" : { + }, "SendEmailSignInLinkButtonLabel" : { "comment" : "Button label for sending email sign-in link", @@ -845,6 +1015,21 @@ } } } + }, + "Set Up Two-Factor Authentication" : { + + }, + "Set up two-factor authentication to add an extra layer of security to your account." : { + + }, + "Sign in" : { + + }, + "Sign in with email" : { + + }, + "Sign in with phone" : { + }, "Sign up" : { @@ -944,6 +1129,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.", @@ -992,6 +1189,12 @@ } } } + }, + "Two-Factor Authentication" : { + + }, + "Unable to generate QR Code" : { + }, "UnlinkAction" : { "comment" : "Button title for unlinking account action.", @@ -1087,6 +1290,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 +1305,12 @@ } } } + }, + "Verification Code" : { + + }, + "Verify Code" : { + }, "Verify email address?" : { "comment" : "Label for sending email verification to user.", @@ -1110,6 +1322,9 @@ } } } + }, + "Verify Phone Number" : { + }, "VerifyItsYou" : { "comment" : "Alert message title show for re-authorization.", @@ -1122,6 +1337,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/FirebaseFacebookSwiftUI/Sources/Services/FacebookProviderAuthUI.swift b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/FacebookProviderAuthUI.swift index be4194e49e..92a69cb58c 100644 --- a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/FacebookProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/FacebookProviderAuthUI.swift @@ -105,7 +105,7 @@ public class FacebookProviderSwift: AuthProviderSwift, DeleteUserSwift { "`rawNonce` has not been generated for Facebook limited login" ) } - let credential = OAuthProvider.credential(withProviderID: providerId, + let credential = OAuthProvider.credential(providerID: .facebook, idToken: idToken.tokenString, rawNonce: nonce) return credential @@ -132,6 +132,6 @@ public class FacebookProviderAuthUI: AuthProviderUI { } @MainActor public func authButton() -> AnyView { - AnyView(SignInWithFacebookButton(facebookProvider: provider as! FacebookProviderSwift)) + AnyView(SignInWithFacebookButton(facebookProvider: provider)) } } diff --git a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift index 136157da0c..10e74c0339 100644 --- a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift +++ b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift @@ -23,14 +23,14 @@ import SwiftUI @MainActor public struct SignInWithFacebookButton { @Environment(AuthService.self) private var authService - let facebookProvider: FacebookProviderSwift + let facebookProvider: AuthProviderSwift @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) { + public init(facebookProvider: AuthProviderSwift) { self.facebookProvider = facebookProvider _trackingAuthorizationStatus = State(initialValue: ATTrackingManager .trackingAuthorizationStatus) @@ -66,7 +66,6 @@ extension SignInWithFacebookButton: View { VStack { Button(action: { Task { - facebookProvider.isLimitedLogin = limitedLogin try? await authService.signIn(facebookProvider) } }) { diff --git a/samples/swift/FirebaseUI-demo-swift.xcodeproj/project.pbxproj b/samples/swift/FirebaseUI-demo-swift.xcodeproj/project.pbxproj index dabd26609d..d236027bae 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/ContentViewSheetExample.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentViewSheetExample.swift new file mode 100644 index 0000000000..791305d831 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentViewSheetExample.swift @@ -0,0 +1,81 @@ +// +// ContentViewSheetExample.swift +// FirebaseUI +// +// Created by Ademola Fadumo on 20/10/2025. +// + +import SwiftUI +import FirebaseAuth +import FirebaseAuthSwiftUI +import FirebaseGoogleSwiftUI +import FirebaseFacebookSwiftUI +import FirebasePhoneAuthSwiftUI + +struct ContentViewSheetExample: View { + init() { + let actionCodeSettings = ActionCodeSettings() + actionCodeSettings.handleCodeInApp = true + actionCodeSettings + .url = URL(string: "https://flutterfire-e2e-tests.firebaseapp.com") + actionCodeSettings.linkDomain = "flutterfire-e2e-tests.firebaseapp.com" + actionCodeSettings.setIOSBundleID(Bundle.main.bundleIdentifier!) + let configuration = AuthConfiguration( + tosUrl: URL(string: "https://example.com/tos"), + privacyPolicyUrl: URL(string: "https://example.com/privacy"), + emailLinkSignInActionCodeSettings: actionCodeSettings, + mfaEnabled: true + ) + + authService = AuthService( + configuration: configuration + ) + .withGoogleSignIn() + .withPhoneSignIn() + .withTwitterSignIn() + .withFacebookSignIn() + .withEmailSignIn() + } + + @State private var authService: AuthService + @State private var isPresented: Bool = false + + var body: some View { + FirebaseAuthView( + authService: authService, + isPresented: $isPresented + ) { + NavigationStack { + VStack { + if authService.authenticationState == .unauthenticated { + Text("Not Authenticated") + } else { + Text("Authenticated - \(authService.currentUser?.email ?? "")") + Button { + Task { + try? await authService.signOut() + } + } label: { + Text("Sign Out") + } + .buttonStyle(.borderedProminent) + } + } + .navigationTitle("Firebase UI Demo") + } + .onAppear { + isPresented = authService.authenticationState == .unauthenticated + } + .onChange(of: authService.authenticationState) { oldValue, newValue in + debugPrint("authService.authenticationState - \(newValue)") + if newValue != .authenticating { + isPresented = newValue == .unauthenticated + } + } + } + } +} + +#Preview { + ContentViewSheetExample() +} diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExampleApp.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExampleApp.swift index 735bbbb0f5..00a72d02a0 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExampleApp.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExampleApp.swift @@ -79,11 +79,12 @@ struct FirebaseSwiftUIExampleApp: App { var body: some Scene { WindowGroup { - if testRunner { - TestView() - } else { - ContentView() - } + ContentViewSheetExample() +// if testRunner { +// TestView() +// } else { +// ContentView() +// } } } } diff --git a/samples/swiftui/FirebaseSwiftUIExample/GoogleService-Info.plist b/samples/swiftui/FirebaseSwiftUIExample/GoogleService-Info.plist index f325ead98d..457edc00d3 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/GoogleService-Info.plist +++ b/samples/swiftui/FirebaseSwiftUIExample/GoogleService-Info.plist @@ -3,23 +3,23 @@ CLIENT_ID - 406099696497-134k3722m01rtrsklhf3b7k8sqa5r7in.apps.googleusercontent.com + 771411398215-et7qhn5mcivjv33j0qk9pib3vq4q4dgf.apps.googleusercontent.com REVERSED_CLIENT_ID - com.googleusercontent.apps.406099696497-134k3722m01rtrsklhf3b7k8sqa5r7in + com.googleusercontent.apps.771411398215-et7qhn5mcivjv33j0qk9pib3vq4q4dgf ANDROID_CLIENT_ID - 406099696497-17qn06u8a0dc717u8ul7s49ampk13lul.apps.googleusercontent.com + 771411398215-0oisrss5dnie4sl79qmaeme606t0ac57.apps.googleusercontent.com API_KEY - AIzaSyDooSUGSf63Ghq02_iIhtnmwMDs4HlWS6c + AIzaSyA1U0l0OXAbR2zIZ0WTXjz0s-2oKbRma6s GCM_SENDER_ID - 406099696497 + 771411398215 PLIST_VERSION 1 BUNDLE_ID - io.flutter.plugins.firebase.auth.example + com.example.AuthSwiftUIExample PROJECT_ID - flutterfire-e2e-tests + temp-test-aa342 STORAGE_BUCKET - flutterfire-e2e-tests.appspot.com + temp-test-aa342.firebasestorage.app IS_ADS_ENABLED IS_ANALYTICS_ENABLED @@ -31,8 +31,6 @@ IS_SIGNIN_ENABLED GOOGLE_APP_ID - 1:406099696497:ios:58cbc26aca8e5cf83574d0 - DATABASE_URL - https://flutterfire-e2e-tests-default-rtdb.europe-west1.firebasedatabase.app + 1:771411398215:ios:3286f98b5460a08e2e6b62 \ No newline at end of file