├── SwiftfulFirebaseBootcamp ├── Assets.xcassets │ ├── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── SwiftfulFirebaseBootcamp.entitlements ├── ContentView.swift ├── Info.plist ├── Core │ ├── Products │ │ ├── Subviews │ │ │ ├── ProductCellViewBuilder.swift │ │ │ └── ProductCellView.swift │ │ ├── ProductsView.swift │ │ └── ProductsViewModel.swift │ ├── RootView.swift │ ├── Favorites │ │ ├── FavoriteView.swift │ │ └── FavoriteViewModel.swift │ ├── Authentication │ │ ├── Subviews │ │ │ ├── SignInEmailViewModel.swift │ │ │ └── SignInEmailView.swift │ │ ├── AuthenticationViewModel.swift │ │ └── AuthenticationView.swift │ ├── Tabbar │ │ └── TabbarView.swift │ ├── Settings │ │ ├── SettingsViewModel.swift │ │ └── SettingsView.swift │ └── Profile │ │ ├── ProfileViewModel.swift │ │ └── ProfileView.swift ├── Components │ └── ViewModifiers │ │ └── OnFirstAppearViewModifier.swift ├── Crashlytics │ ├── CrashManager.swift │ └── CrashView.swift ├── SwiftfulFirebaseBootcampApp.swift ├── Utilities │ ├── Utilities.swift │ └── ProductsDatabase.swift ├── GoogleService-Info.plist ├── Authentication │ ├── SignInGoogleHelper.swift │ ├── SignInAppleHelper.swift │ └── AuthenticationManager.swift ├── Analytics │ └── AnalyticsView.swift ├── SecurityRules │ └── SecurityRules.swift ├── Extensions │ └── Query+EXT.swift ├── Storage │ └── StorageManager.swift ├── Performance │ └── PerformanceView.swift └── Firestore │ ├── ProductsManager.swift │ └── UserManager.swift └── SwiftfulFirebaseBootcamp.xcodeproj ├── project.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ └── swiftpm │ └── Package.resolved ├── xcuserdata └── nicksarno.xcuserdatad │ └── xcschemes │ └── xcschememanagement.plist ├── xcshareddata └── xcschemes │ └── SwiftfulFirebaseBootcamp.xcscheme └── project.pbxproj /SwiftfulFirebaseBootcamp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/SwiftfulFirebaseBootcamp.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.applesignin 6 | 7 | Default 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/21/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ContentView: View { 11 | var body: some View { 12 | VStack { 13 | Image(systemName: "globe") 14 | .imageScale(.large) 15 | .foregroundColor(.accentColor) 16 | Text("Hello, world!") 17 | } 18 | .padding() 19 | } 20 | } 21 | 22 | struct ContentView_Previews: PreviewProvider { 23 | static var previews: some View { 24 | ContentView() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleURLTypes 6 | 7 | 8 | CFBundleTypeRole 9 | Editor 10 | CFBundleURLName 11 | Google Sign In 12 | CFBundleURLSchemes 13 | 14 | com.googleusercontent.apps.325734147798-k3d25eg7dridd3mgmr46q4vnq3bco57t 15 | 16 | 17 | 18 | GIDClientID 19 | 325734147798-k3d25eg7dridd3mgmr46q4vnq3bco57t.apps.googleusercontent.com 20 | 21 | 22 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Core/Products/Subviews/ProductCellViewBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductCellViewBuilder.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/22/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ProductCellViewBuilder: View { 11 | 12 | let productId: String 13 | @State private var product: Product? = nil 14 | 15 | var body: some View { 16 | ZStack { 17 | if let product { 18 | ProductCellView(product: product) 19 | } 20 | } 21 | .task { 22 | self.product = try? await ProductsManager.shared.getProduct(productId: productId) 23 | } 24 | } 25 | } 26 | 27 | struct ProductCellViewBuilder_Previews: PreviewProvider { 28 | static var previews: some View { 29 | ProductCellViewBuilder(productId: "1") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Components/ViewModifiers/OnFirstAppearViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnFirstAppearViewModifier.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/22/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct OnFirstAppearViewModifier: ViewModifier { 12 | 13 | @State private var didAppear: Bool = false 14 | let perform: (() -> Void)? 15 | 16 | func body(content: Content) -> some View { 17 | content 18 | .onAppear { 19 | if !didAppear { 20 | perform?() 21 | didAppear = true 22 | } 23 | } 24 | } 25 | } 26 | 27 | extension View { 28 | 29 | func onFirstAppear(perform: (() -> Void)?) -> some View { 30 | modifier(OnFirstAppearViewModifier(perform: perform)) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Core/RootView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RootView.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/21/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct RootView: View { 11 | 12 | @State private var showSignInView: Bool = false 13 | 14 | var body: some View { 15 | ZStack { 16 | if !showSignInView { 17 | TabbarView(showSignInView: $showSignInView) 18 | } 19 | } 20 | .onAppear { 21 | let authUser = try? AuthenticationManager.shared.getAuthenticatedUser() 22 | self.showSignInView = authUser == nil 23 | } 24 | .fullScreenCover(isPresented: $showSignInView) { 25 | NavigationStack { 26 | AuthenticationView(showSignInView: $showSignInView) 27 | } 28 | } 29 | } 30 | } 31 | 32 | struct RootView_Previews: PreviewProvider { 33 | static var previews: some View { 34 | RootView() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Crashlytics/CrashManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CrashManager.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/24/23. 6 | // 7 | 8 | import Foundation 9 | import FirebaseCrashlytics 10 | 11 | final class CrashManager { 12 | 13 | static let shared = CrashManager() 14 | private init() { } 15 | 16 | func setUserId(userId: String) { 17 | Crashlytics.crashlytics().setUserID(userId) 18 | } 19 | 20 | private func setValue(value: String, key: String) { 21 | Crashlytics.crashlytics().setCustomValue(value, forKey: key) 22 | } 23 | 24 | func setIsPremiumValue(isPremium: Bool) { 25 | setValue(value: isPremium.description.lowercased(), key: "user_is_premium") 26 | } 27 | 28 | func addLog(message: String) { 29 | Crashlytics.crashlytics().log(message) 30 | } 31 | 32 | func sendNonFatal(error: Error) { 33 | Crashlytics.crashlytics().record(error: error) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/SwiftfulFirebaseBootcampApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftfulFirebaseBootcampApp.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/21/23. 6 | // 7 | 8 | import SwiftUI 9 | import Firebase 10 | 11 | @main 12 | struct SwiftfulFirebaseBootcampApp: App { 13 | 14 | @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate 15 | 16 | var body: some Scene { 17 | WindowGroup { 18 | // RootView() 19 | // CrashView() 20 | // PerformanceView() 21 | AnalyticsView() 22 | } 23 | } 24 | } 25 | 26 | class AppDelegate: NSObject, UIApplicationDelegate { 27 | 28 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { 29 | FirebaseApp.configure() 30 | 31 | return true 32 | } 33 | 34 | func applicationDidBecomeActive(_ application: UIApplication) { 35 | 36 | } 37 | 38 | func applicationWillResignActive(_ application: UIApplication) { 39 | 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Core/Favorites/FavoriteView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FavoriteView.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/22/23. 6 | // 7 | import SwiftUI 8 | 9 | struct FavoriteView: View { 10 | 11 | @StateObject private var viewModel = FavoriteViewModel() 12 | 13 | var body: some View { 14 | List { 15 | ForEach(viewModel.userFavoriteProducts, id: \.id.self) { item in 16 | ProductCellViewBuilder(productId: String(item.productId)) 17 | .contextMenu { 18 | Button("Remove from favorites") { 19 | viewModel.removeFromFavorites(favoriteProductId: item.id) 20 | } 21 | } 22 | } 23 | } 24 | .navigationTitle("Favorites") 25 | .onFirstAppear { 26 | viewModel.addListenerForFavorites() 27 | } 28 | } 29 | } 30 | 31 | struct FavoriteView_Previews: PreviewProvider { 32 | static var previews: some View { 33 | NavigationStack { 34 | FavoriteView() 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Core/Authentication/Subviews/SignInEmailViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignInEmailViewModel.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/21/23. 6 | // 7 | 8 | import Foundation 9 | 10 | @MainActor 11 | final class SignInEmailViewModel: ObservableObject { 12 | 13 | @Published var email = "" 14 | @Published var password = "" 15 | 16 | func signUp() async throws { 17 | guard !email.isEmpty, !password.isEmpty else { 18 | print("No email or password found.") 19 | return 20 | } 21 | 22 | let authDataResult = try await AuthenticationManager.shared.createUser(email: email, password: password) 23 | let user = DBUser(auth: authDataResult) 24 | try await UserManager.shared.createNewUser(user: user) 25 | } 26 | 27 | func signIn() async throws { 28 | guard !email.isEmpty, !password.isEmpty else { 29 | print("No email or password found.") 30 | return 31 | } 32 | 33 | try await AuthenticationManager.shared.signInUser(email: email, password: password) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Utilities/Utilities.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Utilities.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/21/23. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | final class Utilities { 12 | 13 | static let shared = Utilities() 14 | private init() {} 15 | 16 | @MainActor 17 | func topViewController(controller: UIViewController? = nil) -> UIViewController? { 18 | let controller = controller ?? UIApplication.shared.keyWindow?.rootViewController 19 | 20 | if let navigationController = controller as? UINavigationController { 21 | return topViewController(controller: navigationController.visibleViewController) 22 | } 23 | if let tabController = controller as? UITabBarController { 24 | if let selected = tabController.selectedViewController { 25 | return topViewController(controller: selected) 26 | } 27 | } 28 | if let presented = controller?.presentedViewController { 29 | return topViewController(controller: presented) 30 | } 31 | return controller 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp.xcodeproj/xcuserdata/nicksarno.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | Promises (Playground) 1.xcscheme 8 | 9 | isShown 10 | 11 | orderHint 12 | 2 13 | 14 | Promises (Playground) 2.xcscheme 15 | 16 | isShown 17 | 18 | orderHint 19 | 3 20 | 21 | Promises (Playground).xcscheme 22 | 23 | isShown 24 | 25 | orderHint 26 | 1 27 | 28 | SwiftfulFirebaseBootcamp.xcscheme_^#shared#^_ 29 | 30 | orderHint 31 | 0 32 | 33 | 34 | SuppressBuildableAutocreation 35 | 36 | 02E2BF9C297C5C2C0051D19B 37 | 38 | primary 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Core/Tabbar/TabbarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabbarView.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/22/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TabbarView: View { 11 | 12 | @Binding var showSignInView: Bool 13 | 14 | var body: some View { 15 | TabView { 16 | NavigationStack { 17 | ProductsView() 18 | } 19 | .tabItem { 20 | Image(systemName: "cart") 21 | Text("Products") 22 | } 23 | 24 | NavigationStack { 25 | FavoriteView() 26 | } 27 | .tabItem { 28 | Image(systemName: "star.fill") 29 | Text("Favorites") 30 | } 31 | 32 | NavigationStack { 33 | ProfileView(showSignInView: $showSignInView) 34 | } 35 | .tabItem { 36 | Image(systemName: "person") 37 | Text("Profile") 38 | } 39 | } 40 | } 41 | } 42 | 43 | struct TabbarView_Previews: PreviewProvider { 44 | static var previews: some View { 45 | TabbarView(showSignInView: .constant(false)) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/GoogleService-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CLIENT_ID 6 | 325734147798-b53bob7iju00j9nletbfg7u7ureupcl6.apps.googleusercontent.com 7 | REVERSED_CLIENT_ID 8 | com.googleusercontent.apps.325734147798-b53bob7iju00j9nletbfg7u7ureupcl6 9 | API_KEY 10 | AIzaSyAvrxycahf0-z9n42Alvx1APHBl16xI6H0 11 | GCM_SENDER_ID 12 | 325734147798 13 | PLIST_VERSION 14 | 1 15 | BUNDLE_ID 16 | com.tribesocial.SwiftfulFirebaseBootcamp 17 | PROJECT_ID 18 | swiftfulfirebasebootcamp 19 | STORAGE_BUCKET 20 | swiftfulfirebasebootcamp.appspot.com 21 | IS_ADS_ENABLED 22 | 23 | IS_ANALYTICS_ENABLED 24 | 25 | IS_APPINVITE_ENABLED 26 | 27 | IS_GCM_ENABLED 28 | 29 | IS_SIGNIN_ENABLED 30 | 31 | GOOGLE_APP_ID 32 | 1:325734147798:ios:7b6a285992da211f9e8b77 33 | 34 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Authentication/SignInGoogleHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignInGoogleHelper.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/21/23. 6 | // 7 | 8 | import Foundation 9 | import GoogleSignIn 10 | import GoogleSignInSwift 11 | 12 | struct GoogleSignInResultModel { 13 | let idToken: String 14 | let accessToken: String 15 | let name: String? 16 | let email: String? 17 | } 18 | 19 | final class SignInGoogleHelper { 20 | 21 | @MainActor 22 | func signIn() async throws -> GoogleSignInResultModel { 23 | guard let topVC = Utilities.shared.topViewController() else { 24 | throw URLError(.cannotFindHost) 25 | } 26 | 27 | let gidSignInResult = try await GIDSignIn.sharedInstance.signIn(withPresenting: topVC) 28 | 29 | guard let idToken = gidSignInResult.user.idToken?.tokenString else { 30 | throw URLError(.badServerResponse) 31 | } 32 | 33 | let accessToken = gidSignInResult.user.accessToken.tokenString 34 | let name = gidSignInResult.user.profile?.name 35 | let email = gidSignInResult.user.profile?.email 36 | 37 | let tokens = GoogleSignInResultModel(idToken: idToken, accessToken: accessToken, name: name, email: email) 38 | return tokens 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Core/Authentication/AuthenticationViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthenticationViewModel.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/21/23. 6 | // 7 | 8 | import Foundation 9 | 10 | @MainActor 11 | final class AuthenticationViewModel: ObservableObject { 12 | 13 | func signInGoogle() async throws { 14 | let helper = SignInGoogleHelper() 15 | let tokens = try await helper.signIn() 16 | let authDataResult = try await AuthenticationManager.shared.signInWithGoogle(tokens: tokens) 17 | let user = DBUser(auth: authDataResult) 18 | try await UserManager.shared.createNewUser(user: user) 19 | } 20 | 21 | func signInApple() async throws { 22 | let helper = SignInAppleHelper() 23 | let tokens = try await helper.startSignInWithAppleFlow() 24 | let authDataResult = try await AuthenticationManager.shared.signInWithApple(tokens: tokens) 25 | let user = DBUser(auth: authDataResult) 26 | try await UserManager.shared.createNewUser(user: user) 27 | } 28 | 29 | func signInAnonymous() async throws { 30 | let authDataResult = try await AuthenticationManager.shared.signInAnonymous() 31 | let user = DBUser(auth: authDataResult) 32 | try await UserManager.shared.createNewUser(user: user) 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Core/Products/Subviews/ProductCellView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductCellView.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/22/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ProductCellView: View { 11 | 12 | let product: Product 13 | 14 | var body: some View { 15 | HStack(alignment: .top, spacing: 12) { 16 | 17 | AsyncImage(url: URL(string: product.thumbnail ?? "")) { image in 18 | image 19 | .resizable() 20 | .scaledToFill() 21 | .frame(width: 75, height: 75) 22 | .cornerRadius(10) 23 | } placeholder: { 24 | ProgressView() 25 | } 26 | .frame(width: 75, height: 75) 27 | .shadow(color: Color.black.opacity(0.3), radius: 4, x: 0, y: 2) 28 | 29 | 30 | VStack(alignment: .leading, spacing: 4) { 31 | Text((product.title ?? "n/a")) 32 | .font(.headline) 33 | .foregroundColor(.primary) 34 | Text("Price: $" + String(product.price ?? 0)) 35 | Text("Rating: " + String(product.rating ?? 0)) 36 | Text("Category: " + (product.category ?? "n/a")) 37 | Text("Brand: " + (product.brand ?? "n/a")) 38 | } 39 | .font(.callout) 40 | .foregroundColor(.secondary) 41 | } 42 | } 43 | } 44 | 45 | struct ProductCellView_Previews: PreviewProvider { 46 | static var previews: some View { 47 | ProductCellView(product: Product(id: 1, title: "Test", description: "test", price: 435, discountPercentage: 1345245, rating: 65231, stock: 1324, brand: "asdfasdf", category: "asdfafsd", thumbnail: "asdfafds", images: [])) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Core/Favorites/FavoriteViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FavoriteViewModel.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/22/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Combine 11 | 12 | @MainActor 13 | final class FavoriteViewModel: ObservableObject { 14 | 15 | @Published private(set) var userFavoriteProducts: [UserFavoriteProduct] = [] 16 | private var cancellables = Set() 17 | 18 | func addListenerForFavorites() { 19 | guard let authDataResult = try? AuthenticationManager.shared.getAuthenticatedUser() else { return } 20 | 21 | // UserManager.shared.addListenerForAllUserFavoriteProducts(userId: authDataResult.uid) { [weak self] products in 22 | // self?.userFavoriteProducts = products 23 | // } 24 | 25 | UserManager.shared.addListenerForAllUserFavoriteProducts(userId: authDataResult.uid) 26 | .sink { completion in 27 | 28 | } receiveValue: { [weak self] products in 29 | self?.userFavoriteProducts = products 30 | } 31 | .store(in: &cancellables) 32 | 33 | } 34 | 35 | // func getFavorites() { 36 | // Task { 37 | // let authDataResult = try AuthenticationManager.shared.getAuthenticatedUser() 38 | // self.userFavoriteProducts = try await UserManager.shared.getAllUserFavoriteProducts(userId: authDataResult.uid) 39 | // } 40 | // } 41 | 42 | func removeFromFavorites(favoriteProductId: String) { 43 | Task { 44 | let authDataResult = try AuthenticationManager.shared.getAuthenticatedUser() 45 | try? await UserManager.shared.removeUserFavoriteProduct(userId: authDataResult.uid, favoriteProductId: favoriteProductId) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Crashlytics/CrashView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CrashView.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/24/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CrashView: View { 11 | var body: some View { 12 | ZStack { 13 | Color.gray.opacity(0.3).ignoresSafeArea() 14 | 15 | VStack(spacing: 40) { 16 | 17 | Button("Click me 1") { 18 | CrashManager.shared.addLog(message: "button_1_clicked") 19 | 20 | let myString: String? = nil 21 | 22 | guard let myString else { 23 | CrashManager.shared.sendNonFatal(error: URLError(.dataNotAllowed)) 24 | return 25 | } 26 | 27 | let string2 = myString 28 | } 29 | 30 | Button("Click me 2") { 31 | CrashManager.shared.addLog(message: "button_2_clicked") 32 | 33 | fatalError("This was a fatal crash.") 34 | } 35 | 36 | Button("Click me 3") { 37 | CrashManager.shared.addLog(message: "button_3_clicked") 38 | 39 | let array: [String] = [] 40 | let item = array[0] 41 | } 42 | } 43 | } 44 | .onAppear { 45 | CrashManager.shared.setUserId(userId: "ABC123") 46 | CrashManager.shared.setIsPremiumValue(isPremium: false) 47 | CrashManager.shared.addLog(message: "crash_view_appeared") 48 | CrashManager.shared.addLog(message: "Crash view appeared on user's screen.") 49 | } 50 | } 51 | } 52 | 53 | struct CrashView_Previews: PreviewProvider { 54 | static var previews: some View { 55 | CrashView() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Analytics/AnalyticsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnalyticsView.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/26/23. 6 | // 7 | 8 | import SwiftUI 9 | import FirebaseAnalytics 10 | import FirebaseAnalyticsSwift 11 | 12 | final class AnalyticsManager { 13 | 14 | static let shared = AnalyticsManager() 15 | private init() { } 16 | 17 | func logEvent(name: String, params: [String:Any]? = nil) { 18 | Analytics.logEvent(name, parameters: params) 19 | } 20 | 21 | func setUserId(userId: String) { 22 | Analytics.setUserID(userId) 23 | } 24 | 25 | func setUserProperty(value: String?, property: String) { 26 | // AnalyticsEventAddPaymentInfo 27 | Analytics.setUserProperty(value, forName: property) 28 | } 29 | 30 | } 31 | 32 | struct AnalyticsView: View { 33 | var body: some View { 34 | VStack(spacing: 40) { 35 | Button("Click me!") { 36 | AnalyticsManager.shared.logEvent(name: "AnalyticsView_ButtonClick") 37 | } 38 | 39 | Button("Click me too!") { 40 | AnalyticsManager.shared.logEvent(name: "AnalyticsView_SecondaryButtonClick", params: [ 41 | "screen_title" : "Hello, world!" 42 | ]) 43 | } 44 | } 45 | .analyticsScreen(name: "AnalyticsView") 46 | .onAppear { 47 | AnalyticsManager.shared.logEvent(name: "AnalyticsView_Appear") 48 | } 49 | .onDisappear { 50 | AnalyticsManager.shared.logEvent(name: "AnalyticsView_Disppear") 51 | 52 | AnalyticsManager.shared.setUserId(userId: "ABC123") 53 | AnalyticsManager.shared.setUserProperty(value: true.description, property: "user_is_premium") 54 | } 55 | } 56 | } 57 | 58 | struct AnalyticsView_Previews: PreviewProvider { 59 | static var previews: some View { 60 | AnalyticsView() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/SecurityRules/SecurityRules.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SecurityRules.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/23/23. 6 | // 7 | 8 | import Foundation 9 | 10 | // https://firebase.google.com/docs/firestore/security/rules-structure 11 | // https://firebase.google.com/docs/rules/rules-language 12 | 13 | /* 14 | rules_version = '2'; 15 | service cloud.firestore { 16 | match /databases/{database}/documents { 17 | match /users/{userId} { 18 | allow read: if request.auth != null; 19 | allow write: if request.auth != null && request.auth.uid == userId; 20 | // allow write: if resource.data.user_isPremium == false; 21 | // allow write: if request.resource.data.custom_key == "1234"; 22 | //allow write: if isPublic(); 23 | } 24 | 25 | match /users/{userId}/favorite_products/{userFavoriteProductID} { 26 | allow read: if request.auth != null; 27 | allow write: if request.auth != null && request.auth.uid == userId; 28 | } 29 | 30 | match /products/{productId} { 31 | //allow read, write: if request.auth != null; 32 | //allow create: if request.auth != null; 33 | //allow read: if request.auth != null && isAdmin(request.auth.uid); 34 | allow read: if request.auth != null; 35 | allow create: if request.auth != null && isAdmin(request.auth.uid); 36 | allow update: if request.auth != null && isAdmin(request.auth.uid); 37 | allow delete: if false; 38 | } 39 | 40 | function isPublic() { 41 | return resource.data.visibility == "public"; 42 | } 43 | 44 | function isAdmin(userId) { 45 | // let adminIds = ["FLQSb7fAHzdGSeMop37sLEkif3l1", "abc"]; 46 | // return userId in adminIds; 47 | return exists(/databases/$(database)/documents/admins/$(userId)); 48 | } 49 | } 50 | } 51 | 52 | // read 53 | // get - single document reads 54 | // list - queries and collection read requests 55 | // 56 | // write 57 | // create - add document 58 | // update - edit document 59 | // delete - delete document 60 | */ 61 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Core/Authentication/Subviews/SignInEmailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignInEmailView.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/21/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SignInEmailView: View { 11 | 12 | @StateObject private var viewModel = SignInEmailViewModel() 13 | @Binding var showSignInView: Bool 14 | 15 | var body: some View { 16 | VStack { 17 | TextField("Email...", text: $viewModel.email) 18 | .padding() 19 | .background(Color.gray.opacity(0.4)) 20 | .cornerRadius(10) 21 | 22 | SecureField("Password...", text: $viewModel.password) 23 | .padding() 24 | .background(Color.gray.opacity(0.4)) 25 | .cornerRadius(10) 26 | 27 | Button { 28 | Task { 29 | do { 30 | try await viewModel.signUp() 31 | showSignInView = false 32 | return 33 | } catch { 34 | print(error) 35 | } 36 | 37 | do { 38 | try await viewModel.signIn() 39 | showSignInView = false 40 | return 41 | } catch { 42 | print(error) 43 | } 44 | } 45 | } label: { 46 | Text("Sign In") 47 | .font(.headline) 48 | .foregroundColor(.white) 49 | .frame(height: 55) 50 | .frame(maxWidth: .infinity) 51 | .background(Color.blue) 52 | .cornerRadius(10) 53 | } 54 | 55 | Spacer() 56 | } 57 | .padding() 58 | .navigationTitle("Sign In With Email") 59 | } 60 | } 61 | 62 | struct SignInEmailView_Previews: PreviewProvider { 63 | static var previews: some View { 64 | NavigationStack { 65 | SignInEmailView(showSignInView: .constant(false)) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Extensions/Query+EXT.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Query+EXT.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/22/23. 6 | // 7 | 8 | import Foundation 9 | import FirebaseFirestore 10 | import FirebaseFirestoreSwift 11 | import Combine 12 | 13 | extension Query { 14 | 15 | // func getDocuments(as type: T.Type) async throws -> [T] where T : Decodable { 16 | // let snapshot = try await self.getDocuments() 17 | // 18 | // return try snapshot.documents.map({ document in 19 | // try document.data(as: T.self) 20 | // }) 21 | // } 22 | 23 | func getDocuments(as type: T.Type) async throws -> [T] where T : Decodable { 24 | try await getDocumentsWithSnapshot(as: type).products 25 | } 26 | 27 | func getDocumentsWithSnapshot(as type: T.Type) async throws -> (products: [T], lastDocument: DocumentSnapshot?) where T : Decodable { 28 | let snapshot = try await self.getDocuments() 29 | 30 | let products = try snapshot.documents.map({ document in 31 | try document.data(as: T.self) 32 | }) 33 | 34 | return (products, snapshot.documents.last) 35 | } 36 | 37 | func startOptionally(afterDocument lastDocument: DocumentSnapshot?) -> Query { 38 | guard let lastDocument else { return self } 39 | return self.start(afterDocument: lastDocument) 40 | } 41 | 42 | func aggregateCount() async throws -> Int { 43 | let snapshot = try await self.count.getAggregation(source: .server) 44 | return Int(truncating: snapshot.count) 45 | } 46 | 47 | func addSnapshotListener(as type: T.Type) -> (AnyPublisher<[T], Error>, ListenerRegistration) where T : Decodable { 48 | let publisher = PassthroughSubject<[T], Error>() 49 | 50 | let listener = self.addSnapshotListener { querySnapshot, error in 51 | guard let documents = querySnapshot?.documents else { 52 | print("No documents") 53 | return 54 | } 55 | 56 | let products: [T] = documents.compactMap({ try? $0.data(as: T.self) }) 57 | publisher.send(products) 58 | } 59 | 60 | return (publisher.eraseToAnyPublisher(), listener) 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Core/Settings/SettingsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsViewModel.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/21/23. 6 | // 7 | 8 | import Foundation 9 | 10 | @MainActor 11 | final class SettingsViewModel: ObservableObject { 12 | 13 | @Published var authProviders: [AuthProviderOption] = [] 14 | @Published var authUser: AuthDataResultModel? = nil 15 | 16 | func loadAuthProviders() { 17 | if let providers = try? AuthenticationManager.shared.getProviders() { 18 | authProviders = providers 19 | } 20 | } 21 | 22 | func loadAuthUser() { 23 | self.authUser = try? AuthenticationManager.shared.getAuthenticatedUser() 24 | } 25 | 26 | func signOut() throws { 27 | try AuthenticationManager.shared.signOut() 28 | } 29 | 30 | func deleteAccount() async throws { 31 | try await AuthenticationManager.shared.delete() 32 | } 33 | 34 | func resetPassword() async throws { 35 | let authUser = try AuthenticationManager.shared.getAuthenticatedUser() 36 | 37 | guard let email = authUser.email else { 38 | throw URLError(.fileDoesNotExist) 39 | } 40 | 41 | try await AuthenticationManager.shared.resetPassword(email: email) 42 | } 43 | 44 | func updateEmail() async throws { 45 | let email = "hello123@gmail.com" 46 | try await AuthenticationManager.shared.updateEmail(email: email) 47 | } 48 | 49 | func updatePassword() async throws { 50 | let password = "Hello123!" 51 | try await AuthenticationManager.shared.updatePassword(password: password) 52 | } 53 | 54 | func linkGoogleAccount() async throws { 55 | let helper = SignInGoogleHelper() 56 | let tokens = try await helper.signIn() 57 | self.authUser = try await AuthenticationManager.shared.linkGoogle(tokens: tokens) 58 | } 59 | 60 | func linkAppleAccount() async throws { 61 | let helper = SignInAppleHelper() 62 | let tokens = try await helper.startSignInWithAppleFlow() 63 | self.authUser = try await AuthenticationManager.shared.linkApple(tokens: tokens) 64 | } 65 | 66 | func linkEmailAccount() async throws { 67 | let email = "anotherEmail@gmail.com" 68 | let password = "Hello123!" 69 | self.authUser = try await AuthenticationManager.shared.linkEmail(email: email, password: password) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Core/Products/ProductsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductsView.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/22/23. 6 | // 7 | import SwiftUI 8 | 9 | struct ProductsView: View { 10 | 11 | @StateObject private var viewModel = ProductsViewModel() 12 | 13 | var body: some View { 14 | List { 15 | ForEach(viewModel.products) { product in 16 | ProductCellView(product: product) 17 | .contextMenu { 18 | Button("Add to favorites") { 19 | viewModel.addUserFavoriteProduct(productId: product.id) 20 | } 21 | } 22 | 23 | if product == viewModel.products.last { 24 | ProgressView() 25 | .onAppear { 26 | viewModel.getProducts() 27 | } 28 | } 29 | } 30 | } 31 | .navigationTitle("Products") 32 | .toolbar(content: { 33 | ToolbarItem(placement: .navigationBarLeading) { 34 | Menu("Filter: \(viewModel.selectedFilter?.rawValue ?? "NONE")") { 35 | ForEach(ProductsViewModel.FilterOption.allCases, id: \.self) { option in 36 | Button(option.rawValue) { 37 | Task { 38 | try? await viewModel.filterSelected(option: option) 39 | } 40 | } 41 | } 42 | } 43 | } 44 | 45 | ToolbarItem(placement: .navigationBarTrailing) { 46 | Menu("Category: \(viewModel.selectedCategory?.rawValue ?? "NONE")") { 47 | ForEach(ProductsViewModel.CategoryOption.allCases, id: \.self) { option in 48 | Button(option.rawValue) { 49 | Task { 50 | try? await viewModel.categorySelected(option: option) 51 | } 52 | } 53 | } 54 | } 55 | } 56 | }) 57 | .onAppear { 58 | viewModel.getProducts() 59 | } 60 | } 61 | } 62 | 63 | struct ProductsView_Previews: PreviewProvider { 64 | static var previews: some View { 65 | NavigationStack { 66 | ProductsView() 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Storage/StorageManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StorageManager.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/23/23. 6 | // 7 | 8 | import Foundation 9 | import FirebaseStorage 10 | import UIKit 11 | 12 | final class StorageManager { 13 | 14 | static let shared = StorageManager() 15 | private init() { } 16 | 17 | private let storage = Storage.storage().reference() 18 | 19 | private var imagesReference: StorageReference { 20 | storage.child("images") 21 | } 22 | 23 | private func userReference(userId: String) -> StorageReference { 24 | storage.child("users").child(userId) 25 | } 26 | 27 | func getPathForImage(path: String) -> StorageReference { 28 | Storage.storage().reference(withPath: path) 29 | } 30 | 31 | func getUrlForImage(path: String) async throws -> URL { 32 | try await getPathForImage(path: path).downloadURL() 33 | } 34 | 35 | func getData(userId: String, path: String) async throws -> Data { 36 | //try await userReference(userId: userId).child(path).data(maxSize: 3 * 1024 * 1024) 37 | try await storage.child(path).data(maxSize: 3 * 1024 * 1024) 38 | } 39 | 40 | func getImage(userId: String, path: String) async throws -> UIImage { 41 | let data = try await getData(userId: userId, path: path) 42 | 43 | guard let image = UIImage(data: data) else { 44 | throw URLError(.badServerResponse) 45 | } 46 | 47 | return image 48 | } 49 | 50 | func saveImage(data: Data, userId: String) async throws -> (path: String, name: String) { 51 | let meta = StorageMetadata() 52 | meta.contentType = "image/jpeg" 53 | 54 | let path = "\(UUID().uuidString).jpeg" 55 | let returnedMetaData = try await userReference(userId: userId).child(path).putDataAsync(data, metadata: meta) 56 | 57 | guard let returnedPath = returnedMetaData.path, let returnedName = returnedMetaData.name else { 58 | throw URLError(.badServerResponse) 59 | } 60 | 61 | return (returnedPath, returnedName) 62 | } 63 | 64 | func saveImage(image: UIImage, userId: String) async throws -> (path: String, name: String) { 65 | // image.pngData() 66 | guard let data = image.jpegData(compressionQuality: 1) else { 67 | throw URLError(.backgroundSessionWasDisconnected) 68 | } 69 | 70 | return try await saveImage(data: data, userId: userId) 71 | } 72 | 73 | func deleteImage(path: String) async throws { 74 | try await getPathForImage(path: path).delete() 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Core/Authentication/AuthenticationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthenticationView.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/21/23. 6 | // 7 | 8 | import SwiftUI 9 | import GoogleSignIn 10 | import GoogleSignInSwift 11 | 12 | struct AuthenticationView: View { 13 | 14 | @StateObject private var viewModel = AuthenticationViewModel() 15 | @Binding var showSignInView: Bool 16 | 17 | var body: some View { 18 | VStack { 19 | 20 | Button(action: { 21 | Task { 22 | do { 23 | try await viewModel.signInAnonymous() 24 | showSignInView = false 25 | } catch { 26 | print(error) 27 | } 28 | } 29 | }, label: { 30 | Text("Sign In Anonymously") 31 | .font(.headline) 32 | .foregroundColor(.white) 33 | .frame(height: 55) 34 | .frame(maxWidth: .infinity) 35 | .background(Color.orange) 36 | .cornerRadius(10) 37 | }) 38 | 39 | NavigationLink { 40 | SignInEmailView(showSignInView: $showSignInView) 41 | } label: { 42 | Text("Sign In With Email") 43 | .font(.headline) 44 | .foregroundColor(.white) 45 | .frame(height: 55) 46 | .frame(maxWidth: .infinity) 47 | .background(Color.blue) 48 | .cornerRadius(10) 49 | } 50 | 51 | GoogleSignInButton(viewModel: GoogleSignInButtonViewModel(scheme: .dark, style: .wide, state: .normal)) { 52 | Task { 53 | do { 54 | try await viewModel.signInGoogle() 55 | showSignInView = false 56 | } catch { 57 | print(error) 58 | } 59 | } 60 | } 61 | 62 | Button(action: { 63 | Task { 64 | do { 65 | try await viewModel.signInApple() 66 | showSignInView = false 67 | } catch { 68 | print(error) 69 | } 70 | } 71 | }, label: { 72 | SignInWithAppleButtonViewRepresentable(type: .default, style: .black) 73 | .allowsHitTesting(false) 74 | }) 75 | .frame(height: 55) 76 | 77 | 78 | Spacer() 79 | } 80 | .padding() 81 | .navigationTitle("Sign In") 82 | } 83 | } 84 | 85 | struct AuthenticationView_Previews: PreviewProvider { 86 | static var previews: some View { 87 | NavigationStack { 88 | AuthenticationView(showSignInView: .constant(false)) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Performance/PerformanceView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PerformanceView.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/24/23. 6 | // 7 | 8 | import SwiftUI 9 | import FirebasePerformance 10 | 11 | final class PerformanceManager { 12 | 13 | static let shared = PerformanceManager() 14 | private init() { } 15 | 16 | private var traces: [String:Trace] = [:] 17 | 18 | func startTrace(name: String) { 19 | let trace = Performance.startTrace(name: name) 20 | traces[name] = trace 21 | } 22 | 23 | func setValue(name: String, value: String, forAttribute: String) { 24 | guard let trace = traces[name] else { return } 25 | trace.setValue(value, forAttribute: forAttribute) 26 | } 27 | 28 | func stopTrace(name: String) { 29 | guard let trace = traces[name] else { return } 30 | trace.stop() 31 | traces.removeValue(forKey: name) 32 | } 33 | } 34 | 35 | struct PerformanceView: View { 36 | 37 | @State private var title: String = "Some Title" 38 | 39 | var body: some View { 40 | Text("Hello, World!") 41 | .onAppear { 42 | configure() 43 | downloadProductsAndUploadToFirebase() 44 | 45 | PerformanceManager.shared.startTrace(name: "performance_screen_time") 46 | } 47 | .onDisappear { 48 | PerformanceManager.shared.stopTrace(name: "performance_screen_time") 49 | } 50 | } 51 | 52 | private func configure() { 53 | PerformanceManager.shared.startTrace(name: "performance_view_loading") 54 | 55 | Task { 56 | try? await Task.sleep(nanoseconds: 2_000_000_000) 57 | PerformanceManager.shared.setValue(name: "performance_view_loading", value: "Started downloading", forAttribute: "func_state") 58 | try? await Task.sleep(nanoseconds: 2_000_000_000) 59 | PerformanceManager.shared.setValue(name: "performance_view_loading", value: "Finished downloading", forAttribute: "func_state") 60 | 61 | PerformanceManager.shared.stopTrace(name: "performance_view_loading") 62 | } 63 | 64 | } 65 | 66 | func downloadProductsAndUploadToFirebase() { 67 | let urlString = "https://dummyjson.com/products" 68 | guard let url = URL(string: urlString), let metric = HTTPMetric(url: url, httpMethod: .get) else { return } 69 | metric.start() 70 | 71 | Task { 72 | do { 73 | let (data, response) = try await URLSession.shared.data(from: url) 74 | if let response = response as? HTTPURLResponse { 75 | metric.responseCode = response.statusCode 76 | } 77 | metric.stop() 78 | print("SUCCESS") 79 | } catch { 80 | print(error) 81 | metric.stop() 82 | } 83 | } 84 | } 85 | 86 | } 87 | 88 | struct PerformanceView_Previews: PreviewProvider { 89 | static var previews: some View { 90 | PerformanceView() 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Core/Products/ProductsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductsViewModel.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/22/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import FirebaseFirestore 11 | 12 | @MainActor 13 | final class ProductsViewModel: ObservableObject { 14 | 15 | @Published private(set) var products: [Product] = [] 16 | @Published var selectedFilter: FilterOption? = nil 17 | @Published var selectedCategory: CategoryOption? = nil 18 | private var lastDocument: DocumentSnapshot? = nil 19 | 20 | enum FilterOption: String, CaseIterable { 21 | case noFilter 22 | case priceHigh 23 | case priceLow 24 | 25 | var priceDescending: Bool? { 26 | switch self { 27 | case .noFilter: return nil 28 | case .priceHigh: return true 29 | case .priceLow: return false 30 | } 31 | } 32 | } 33 | 34 | func filterSelected(option: FilterOption) async throws { 35 | self.selectedFilter = option 36 | self.products = [] 37 | self.lastDocument = nil 38 | self.getProducts() 39 | } 40 | 41 | enum CategoryOption: String, CaseIterable { 42 | case noCategory 43 | case smartphones 44 | case laptops 45 | case fragrances 46 | 47 | var categoryKey: String? { 48 | if self == .noCategory { 49 | return nil 50 | } 51 | return self.rawValue 52 | } 53 | } 54 | 55 | func categorySelected(option: CategoryOption) async throws { 56 | self.selectedCategory = option 57 | self.products = [] 58 | self.lastDocument = nil 59 | self.getProducts() 60 | } 61 | 62 | func getProducts() { 63 | Task { 64 | let (newProducts, lastDocument) = try await ProductsManager.shared.getAllProducts(priceDescending: selectedFilter?.priceDescending, forCategory: selectedCategory?.categoryKey, count: 10, lastDocument: lastDocument) 65 | 66 | self.products.append(contentsOf: newProducts) 67 | if let lastDocument { 68 | self.lastDocument = lastDocument 69 | } 70 | } 71 | } 72 | 73 | func addUserFavoriteProduct(productId: Int) { 74 | Task { 75 | let authDataResult = try AuthenticationManager.shared.getAuthenticatedUser() 76 | try? await UserManager.shared.addUserFavoriteProduct(userId: authDataResult.uid, productId: productId) 77 | } 78 | } 79 | 80 | // func getProductsCount() { 81 | // Task { 82 | // let count = try await ProductsManager.shared.getAllProductsCount() 83 | // print("ALL PRODUCT COUNT: \(count)") 84 | // } 85 | // } 86 | 87 | // func getProductsByRating() { 88 | // Task { 89 | //// let newProducts = try await ProductsManager.shared.getProductsByRating(count: 3, lastRating: self.products.last?.rating) 90 | // 91 | // let (newProducts, lastDocument) = try await ProductsManager.shared.getProductsByRating(count: 3, lastDocument: lastDocument) 92 | // self.products.append(contentsOf: newProducts) 93 | // self.lastDocument = lastDocument 94 | // } 95 | // } 96 | } 97 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Core/Profile/ProfileViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileViewModel.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/23/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import PhotosUI 11 | 12 | @MainActor 13 | final class ProfileViewModel: ObservableObject { 14 | 15 | @Published private(set) var user: DBUser? = nil 16 | 17 | func loadCurrentUser() async throws { 18 | let authDataResult = try AuthenticationManager.shared.getAuthenticatedUser() 19 | self.user = try await UserManager.shared.getUser(userId: authDataResult.uid) 20 | } 21 | 22 | func togglePremiumStatus() { 23 | guard let user else { return } 24 | let currentValue = user.isPremium ?? false 25 | Task { 26 | try await UserManager.shared.updateUserPremiumStatus(userId: user.userId, isPremium: !currentValue) 27 | self.user = try await UserManager.shared.getUser(userId: user.userId) 28 | } 29 | } 30 | 31 | func addUserPreference(text: String) { 32 | guard let user else { return } 33 | 34 | Task { 35 | try await UserManager.shared.addUserPreference(userId: user.userId, preference: text) 36 | self.user = try await UserManager.shared.getUser(userId: user.userId) 37 | } 38 | } 39 | 40 | func removeUserPreference(text: String) { 41 | guard let user else { return } 42 | 43 | Task { 44 | try await UserManager.shared.removeUserPreference(userId: user.userId, preference: text) 45 | self.user = try await UserManager.shared.getUser(userId: user.userId) 46 | } 47 | } 48 | 49 | func addFavoriteMovie() { 50 | guard let user else { return } 51 | let movie = Movie(id: "1", title: "Avatar 2", isPopular: true) 52 | Task { 53 | try await UserManager.shared.addFavoriteMovie(userId: user.userId, movie: movie) 54 | self.user = try await UserManager.shared.getUser(userId: user.userId) 55 | } 56 | } 57 | 58 | func removeFavoriteMovie() { 59 | guard let user else { return } 60 | 61 | Task { 62 | try await UserManager.shared.removeFavoriteMovie(userId: user.userId) 63 | self.user = try await UserManager.shared.getUser(userId: user.userId) 64 | } 65 | } 66 | 67 | func saveProfileImage(item: PhotosPickerItem) { 68 | guard let user else { return } 69 | 70 | Task { 71 | guard let data = try await item.loadTransferable(type: Data.self) else { return } 72 | let (path, name) = try await StorageManager.shared.saveImage(data: data, userId: user.userId) 73 | print("SUCCESS!") 74 | print(path) 75 | print(name) 76 | let url = try await StorageManager.shared.getUrlForImage(path: path) 77 | try await UserManager.shared.updateUserProfileImagePath(userId: user.userId, path: path, url: url.absoluteString) 78 | } 79 | } 80 | 81 | func deleteProfileImage() { 82 | guard let user, let path = user.profileImagePath else { return } 83 | 84 | Task { 85 | try await StorageManager.shared.deleteImage(path: path) 86 | try await UserManager.shared.updateUserProfileImagePath(userId: user.userId, path: nil, url: nil) 87 | } 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp.xcodeproj/xcshareddata/xcschemes/SwiftfulFirebaseBootcamp.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 57 | 58 | 61 | 62 | 65 | 66 | 69 | 70 | 71 | 72 | 78 | 80 | 86 | 87 | 88 | 89 | 91 | 92 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Core/Settings/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/21/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SettingsView: View { 11 | 12 | @StateObject private var viewModel = SettingsViewModel() 13 | @Binding var showSignInView: Bool 14 | 15 | var body: some View { 16 | List { 17 | Button("Log out") { 18 | Task { 19 | do { 20 | try viewModel.signOut() 21 | showSignInView = true 22 | } catch { 23 | print(error) 24 | } 25 | } 26 | } 27 | 28 | Button(role: .destructive) { 29 | Task { 30 | do { 31 | try await viewModel.deleteAccount() 32 | showSignInView = true 33 | } catch { 34 | print(error) 35 | } 36 | } 37 | } label: { 38 | Text("Delete account") 39 | } 40 | 41 | 42 | if viewModel.authProviders.contains(.email) { 43 | emailSection 44 | } 45 | 46 | if viewModel.authUser?.isAnonymous == true { 47 | anonymousSection 48 | } 49 | } 50 | .onAppear { 51 | viewModel.loadAuthProviders() 52 | viewModel.loadAuthUser() 53 | } 54 | .navigationBarTitle("Settings") 55 | } 56 | } 57 | 58 | struct SettingsView_Previews: PreviewProvider { 59 | static var previews: some View { 60 | NavigationStack { 61 | SettingsView(showSignInView: .constant(false)) 62 | } 63 | } 64 | } 65 | 66 | extension SettingsView { 67 | 68 | private var emailSection: some View { 69 | Section { 70 | Button("Reset password") { 71 | Task { 72 | do { 73 | try await viewModel.resetPassword() 74 | print("PASSWORD RESET!") 75 | } catch { 76 | print(error) 77 | } 78 | } 79 | } 80 | 81 | Button("Update password") { 82 | Task { 83 | do { 84 | try await viewModel.updatePassword() 85 | print("PASSWORD UPDATED!") 86 | } catch { 87 | print(error) 88 | } 89 | } 90 | } 91 | 92 | Button("Update email") { 93 | Task { 94 | do { 95 | try await viewModel.updateEmail() 96 | print("EMAIL UPDATED!") 97 | } catch { 98 | print(error) 99 | } 100 | } 101 | } 102 | } header: { 103 | Text("Email functions") 104 | } 105 | } 106 | 107 | private var anonymousSection: some View { 108 | Section { 109 | Button("Link Google Account") { 110 | Task { 111 | do { 112 | try await viewModel.linkGoogleAccount() 113 | print("GOOGLE LINKED!") 114 | } catch { 115 | print(error) 116 | } 117 | } 118 | } 119 | 120 | Button("Link Apple Account") { 121 | Task { 122 | do { 123 | try await viewModel.linkAppleAccount() 124 | print("APPLE LINKED!") 125 | } catch { 126 | print(error) 127 | } 128 | } 129 | } 130 | 131 | Button("Link Email Account") { 132 | Task { 133 | do { 134 | try await viewModel.linkEmailAccount() 135 | print("EMAIL LINKED!") 136 | } catch { 137 | print(error) 138 | } 139 | } 140 | } 141 | } header: { 142 | Text("Create account") 143 | } 144 | } 145 | 146 | 147 | } 148 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Core/Profile/ProfileView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileView.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/21/23. 6 | // 7 | 8 | import SwiftUI 9 | import PhotosUI 10 | 11 | struct ProfileView: View { 12 | 13 | @StateObject private var viewModel = ProfileViewModel() 14 | @Binding var showSignInView: Bool 15 | @State private var selectedItem: PhotosPickerItem? = nil 16 | @State private var url: URL? = nil 17 | 18 | let preferenceOptions: [String] = ["Sports", "Movies", "Books"] 19 | 20 | private func preferenceIsSelected(text: String) -> Bool { 21 | viewModel.user?.preferences?.contains(text) == true 22 | } 23 | 24 | var body: some View { 25 | List { 26 | if let user = viewModel.user { 27 | Text("UserId: \(user.userId)") 28 | 29 | if let isAnonymous = user.isAnonymous { 30 | Text("Is Anonymous: \(isAnonymous.description.capitalized)") 31 | } 32 | 33 | Button { 34 | viewModel.togglePremiumStatus() 35 | } label: { 36 | Text("User is premium: \((user.isPremium ?? false).description.capitalized)") 37 | } 38 | 39 | VStack { 40 | HStack { 41 | ForEach(preferenceOptions, id: \.self) { string in 42 | Button(string) { 43 | if preferenceIsSelected(text: string) { 44 | viewModel.removeUserPreference(text: string) 45 | } else { 46 | viewModel.addUserPreference(text: string) 47 | } 48 | } 49 | .font(.headline) 50 | .buttonStyle(.borderedProminent) 51 | .tint(preferenceIsSelected(text: string) ? .green : .red) 52 | } 53 | } 54 | 55 | Text("User preferences: \((user.preferences ?? []).joined(separator: ", "))") 56 | .frame(maxWidth: .infinity, alignment: .leading) 57 | } 58 | 59 | Button { 60 | if user.favoriteMovie == nil { 61 | viewModel.addFavoriteMovie() 62 | } else { 63 | viewModel.removeFavoriteMovie() 64 | } 65 | } label: { 66 | Text("Favorite Movie: \((user.favoriteMovie?.title ?? ""))") 67 | } 68 | 69 | PhotosPicker(selection: $selectedItem, matching: .images, photoLibrary: .shared()) { 70 | Text("Select a photo") 71 | } 72 | 73 | 74 | if let urlString = viewModel.user?.profileImagePathUrl, let url = URL(string: urlString) { 75 | AsyncImage(url: url) { image in 76 | image 77 | .resizable() 78 | .scaledToFill() 79 | .frame(width: 150, height: 150) 80 | .cornerRadius(10) 81 | } placeholder: { 82 | ProgressView() 83 | .frame(width: 150, height: 150) 84 | } 85 | } 86 | 87 | if viewModel.user?.profileImagePath != nil { 88 | Button("Delete image") { 89 | viewModel.deleteProfileImage() 90 | } 91 | } 92 | } 93 | } 94 | .task { 95 | try? await viewModel.loadCurrentUser() 96 | } 97 | .onChange(of: selectedItem, perform: { newValue in 98 | if let newValue { 99 | viewModel.saveProfileImage(item: newValue) 100 | } 101 | }) 102 | .navigationTitle("Profile") 103 | .toolbar { 104 | ToolbarItem(placement: .navigationBarTrailing) { 105 | NavigationLink { 106 | SettingsView(showSignInView: $showSignInView) 107 | } label: { 108 | Image(systemName: "gear") 109 | .font(.headline) 110 | } 111 | } 112 | } 113 | } 114 | } 115 | 116 | struct ProfileView_Previews: PreviewProvider { 117 | static var previews: some View { 118 | RootView() 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "abseil-cpp-swiftpm", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/firebase/abseil-cpp-SwiftPM.git", 7 | "state" : { 8 | "revision" : "583de9bd60f66b40e78d08599cc92036c2e7e4e1", 9 | "version" : "0.20220203.2" 10 | } 11 | }, 12 | { 13 | "identity" : "appauth-ios", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/openid/AppAuth-iOS.git", 16 | "state" : { 17 | "revision" : "3d36a58a2b736f7bc499453e996a704929b25080", 18 | "version" : "1.6.0" 19 | } 20 | }, 21 | { 22 | "identity" : "boringssl-swiftpm", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/firebase/boringssl-SwiftPM.git", 25 | "state" : { 26 | "revision" : "dd3eda2b05a3f459fc3073695ad1b28659066eab", 27 | "version" : "0.9.1" 28 | } 29 | }, 30 | { 31 | "identity" : "firebase-ios-sdk", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/firebase/firebase-ios-sdk.git", 34 | "state" : { 35 | "revision" : "0df86ea17d5d281415be74f2290df8431644f156", 36 | "version" : "10.4.0" 37 | } 38 | }, 39 | { 40 | "identity" : "googleappmeasurement", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/google/GoogleAppMeasurement.git", 43 | "state" : { 44 | "revision" : "9a09ece724128e8d1e14c5133b87c0e236844ac0", 45 | "version" : "10.4.0" 46 | } 47 | }, 48 | { 49 | "identity" : "googledatatransport", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/google/GoogleDataTransport.git", 52 | "state" : { 53 | "revision" : "5056b15c5acbb90cd214fe4d6138bdf5a740e5a8", 54 | "version" : "9.2.0" 55 | } 56 | }, 57 | { 58 | "identity" : "googlesignin-ios", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/google/GoogleSignIn-iOS.git", 61 | "state" : { 62 | "revision" : "7932d33686c1dc4d7df7a919aae47361d1cdfda4", 63 | "version" : "7.0.0" 64 | } 65 | }, 66 | { 67 | "identity" : "googleutilities", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/google/GoogleUtilities.git", 70 | "state" : { 71 | "revision" : "0543562f85620b5b7c510c6bcbef75b562a5127b", 72 | "version" : "7.11.0" 73 | } 74 | }, 75 | { 76 | "identity" : "grpc-ios", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/grpc/grpc-ios.git", 79 | "state" : { 80 | "revision" : "8440b914756e0d26d4f4d054a1c1581daedfc5b6", 81 | "version" : "1.44.3-grpc" 82 | } 83 | }, 84 | { 85 | "identity" : "gtm-session-fetcher", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/google/gtm-session-fetcher.git", 88 | "state" : { 89 | "revision" : "96d7cc73a71ce950723aa3c50ce4fb275ae180b8", 90 | "version" : "3.1.0" 91 | } 92 | }, 93 | { 94 | "identity" : "gtmappauth", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/google/GTMAppAuth.git", 97 | "state" : { 98 | "revision" : "cee3c709307912d040bd1e06ca919875a92339c6", 99 | "version" : "2.0.0" 100 | } 101 | }, 102 | { 103 | "identity" : "leveldb", 104 | "kind" : "remoteSourceControl", 105 | "location" : "https://github.com/firebase/leveldb.git", 106 | "state" : { 107 | "revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b", 108 | "version" : "1.22.2" 109 | } 110 | }, 111 | { 112 | "identity" : "nanopb", 113 | "kind" : "remoteSourceControl", 114 | "location" : "https://github.com/firebase/nanopb.git", 115 | "state" : { 116 | "revision" : "819d0a2173aff699fb8c364b6fb906f7cdb1a692", 117 | "version" : "2.30909.0" 118 | } 119 | }, 120 | { 121 | "identity" : "promises", 122 | "kind" : "remoteSourceControl", 123 | "location" : "https://github.com/google/promises.git", 124 | "state" : { 125 | "revision" : "3e4e743631e86c8c70dbc6efdc7beaa6e90fd3bb", 126 | "version" : "2.1.1" 127 | } 128 | }, 129 | { 130 | "identity" : "swift-protobuf", 131 | "kind" : "remoteSourceControl", 132 | "location" : "https://github.com/apple/swift-protobuf.git", 133 | "state" : { 134 | "revision" : "ab3a58b7209a17d781c0d1dbb3e1ff3da306bae8", 135 | "version" : "1.20.3" 136 | } 137 | } 138 | ], 139 | "version" : 2 140 | } 141 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Firestore/ProductsManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductsManager.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/22/23. 6 | // 7 | 8 | import Foundation 9 | import FirebaseFirestore 10 | import FirebaseFirestoreSwift 11 | 12 | final class ProductsManager { 13 | 14 | static let shared = ProductsManager() 15 | private init() { } 16 | 17 | private let productsCollection = Firestore.firestore().collection("products") 18 | 19 | private func productDocument(productId: String) -> DocumentReference { 20 | productsCollection.document(productId) 21 | } 22 | 23 | func uploadProduct(product: Product) async throws { 24 | try productDocument(productId: String(product.id)).setData(from: product, merge: false) 25 | } 26 | 27 | func getProduct(productId: String) async throws -> Product { 28 | try await productDocument(productId: productId).getDocument(as: Product.self) 29 | } 30 | 31 | // private func getAllProducts() async throws -> [Product] { 32 | // try await productsCollection 33 | // .getDocuments(as: Product.self) 34 | // } 35 | // 36 | // private func getAllProductsSortedByPrice(descending: Bool) async throws -> [Product] { 37 | // try await productsCollection 38 | // .order(by: Product.CodingKeys.price.rawValue, descending: descending) 39 | // .getDocuments(as: Product.self) 40 | // } 41 | // 42 | // private func getAllProductsForCategory(category: String) async throws -> [Product] { 43 | // try await productsCollection 44 | // .whereField(Product.CodingKeys.category.rawValue, isEqualTo: category) 45 | // .getDocuments(as: Product.self) 46 | // } 47 | // 48 | // private func getAllProductsByPriceAndCategory(descending: Bool, category: String) async throws -> [Product] { 49 | // try await productsCollection 50 | // .whereField(Product.CodingKeys.category.rawValue, isEqualTo: category) 51 | // .order(by: Product.CodingKeys.price.rawValue, descending: descending) 52 | // .getDocuments(as: Product.self) 53 | // } 54 | private func getAllProductsQuery() -> Query { 55 | productsCollection 56 | } 57 | 58 | private func getAllProductsSortedByPriceQuery(descending: Bool) -> Query { 59 | productsCollection 60 | .order(by: Product.CodingKeys.price.rawValue, descending: descending) 61 | } 62 | 63 | private func getAllProductsForCategoryQuery(category: String) -> Query { 64 | productsCollection 65 | .whereField(Product.CodingKeys.category.rawValue, isEqualTo: category) 66 | } 67 | 68 | private func getAllProductsByPriceAndCategoryQuery(descending: Bool, category: String) -> Query { 69 | productsCollection 70 | .whereField(Product.CodingKeys.category.rawValue, isEqualTo: category) 71 | .order(by: Product.CodingKeys.price.rawValue, descending: descending) 72 | } 73 | 74 | func getAllProducts(priceDescending descending: Bool?, forCategory category: String?, count: Int, lastDocument: DocumentSnapshot?) async throws -> (products: [Product], lastDocument: DocumentSnapshot?) { 75 | var query: Query = getAllProductsQuery() 76 | 77 | if let descending, let category { 78 | query = getAllProductsByPriceAndCategoryQuery(descending: descending, category: category) 79 | } else if let descending { 80 | query = getAllProductsSortedByPriceQuery(descending: descending) 81 | } else if let category { 82 | query = getAllProductsForCategoryQuery(category: category) 83 | } 84 | 85 | return try await query 86 | .startOptionally(afterDocument: lastDocument) 87 | .getDocumentsWithSnapshot(as: Product.self) 88 | } 89 | 90 | func getProductsByRating(count: Int, lastRating: Double?) async throws -> [Product] { 91 | try await productsCollection 92 | .order(by: Product.CodingKeys.rating.rawValue, descending: true) 93 | .limit(to: count) 94 | .start(after: [lastRating ?? 9999999]) 95 | .getDocuments(as: Product.self) 96 | } 97 | 98 | func getProductsByRating(count: Int, lastDocument: DocumentSnapshot?) async throws -> (products: [Product], lastDocument: DocumentSnapshot?) { 99 | if let lastDocument { 100 | return try await productsCollection 101 | .order(by: Product.CodingKeys.rating.rawValue, descending: true) 102 | .limit(to: count) 103 | .start(afterDocument: lastDocument) 104 | .getDocumentsWithSnapshot(as: Product.self) 105 | } else { 106 | return try await productsCollection 107 | .order(by: Product.CodingKeys.rating.rawValue, descending: true) 108 | .limit(to: count) 109 | .getDocumentsWithSnapshot(as: Product.self) 110 | } 111 | } 112 | 113 | func getAllProductsCount() async throws -> Int { 114 | try await productsCollection 115 | .aggregateCount() 116 | } 117 | 118 | } 119 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Authentication/SignInAppleHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignInAppleHelper.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/21/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import AuthenticationServices 11 | import CryptoKit 12 | 13 | struct SignInWithAppleButtonViewRepresentable: UIViewRepresentable { 14 | 15 | let type: ASAuthorizationAppleIDButton.ButtonType 16 | let style: ASAuthorizationAppleIDButton.Style 17 | 18 | func makeUIView(context: Context) -> ASAuthorizationAppleIDButton { 19 | ASAuthorizationAppleIDButton(authorizationButtonType: type, authorizationButtonStyle: style) 20 | } 21 | 22 | func updateUIView(_ uiView: ASAuthorizationAppleIDButton, context: Context) { 23 | 24 | } 25 | 26 | } 27 | 28 | struct SignInWithAppleResult { 29 | let token: String 30 | let nonce: String 31 | let name: String? 32 | let email: String? 33 | } 34 | 35 | @MainActor 36 | final class SignInAppleHelper: NSObject { 37 | 38 | private var currentNonce: String? 39 | private var completionHandler: ((Result) -> Void)? = nil 40 | 41 | func startSignInWithAppleFlow() async throws -> SignInWithAppleResult { 42 | try await withCheckedThrowingContinuation { continuation in 43 | self.startSignInWithAppleFlow { result in 44 | switch result { 45 | case .success(let signInAppleResult): 46 | continuation.resume(returning: signInAppleResult) 47 | return 48 | case .failure(let error): 49 | continuation.resume(throwing: error) 50 | return 51 | } 52 | } 53 | } 54 | } 55 | 56 | func startSignInWithAppleFlow(completion: @escaping (Result) -> Void) { 57 | guard let topVC = Utilities.shared.topViewController() else { 58 | completion(.failure(URLError(.badURL))) 59 | return 60 | } 61 | 62 | let nonce = randomNonceString() 63 | currentNonce = nonce 64 | completionHandler = completion 65 | 66 | let appleIDProvider = ASAuthorizationAppleIDProvider() 67 | let request = appleIDProvider.createRequest() 68 | request.requestedScopes = [.fullName, .email] 69 | request.nonce = sha256(nonce) 70 | 71 | let authorizationController = ASAuthorizationController(authorizationRequests: [request]) 72 | authorizationController.delegate = self 73 | authorizationController.presentationContextProvider = topVC 74 | authorizationController.performRequests() 75 | } 76 | 77 | // Adapted from https://auth0.com/docs/api-auth/tutorials/nonce#generate-a-cryptographically-random-nonce 78 | private func randomNonceString(length: Int = 32) -> String { 79 | precondition(length > 0) 80 | let charset: [Character] = 81 | Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._") 82 | var result = "" 83 | var remainingLength = length 84 | 85 | while remainingLength > 0 { 86 | let randoms: [UInt8] = (0 ..< 16).map { _ in 87 | var random: UInt8 = 0 88 | let errorCode = SecRandomCopyBytes(kSecRandomDefault, 1, &random) 89 | if errorCode != errSecSuccess { 90 | fatalError( 91 | "Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)" 92 | ) 93 | } 94 | return random 95 | } 96 | 97 | randoms.forEach { random in 98 | if remainingLength == 0 { 99 | return 100 | } 101 | 102 | if random < charset.count { 103 | result.append(charset[Int(random)]) 104 | remainingLength -= 1 105 | } 106 | } 107 | } 108 | 109 | return result 110 | } 111 | 112 | @available(iOS 13, *) 113 | private func sha256(_ input: String) -> String { 114 | let inputData = Data(input.utf8) 115 | let hashedData = SHA256.hash(data: inputData) 116 | let hashString = hashedData.compactMap { 117 | String(format: "%02x", $0) 118 | }.joined() 119 | 120 | return hashString 121 | } 122 | 123 | 124 | } 125 | 126 | extension SignInAppleHelper: ASAuthorizationControllerDelegate { 127 | 128 | func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { 129 | guard 130 | let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential, 131 | let appleIDToken = appleIDCredential.identityToken, 132 | let idTokenString = String(data: appleIDToken, encoding: .utf8), 133 | let nonce = currentNonce else { 134 | completionHandler?(.failure(URLError(.badServerResponse))) 135 | return 136 | } 137 | let name = appleIDCredential.fullName?.givenName 138 | let email = appleIDCredential.email 139 | 140 | let tokens = SignInWithAppleResult(token: idTokenString, nonce: nonce, name: name, email: email) 141 | completionHandler?(.success(tokens)) 142 | } 143 | 144 | func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { 145 | print("Sign in with Apple errored: \(error)") 146 | completionHandler?(.failure(URLError(.cannotFindHost))) 147 | } 148 | 149 | } 150 | 151 | extension UIViewController: ASAuthorizationControllerPresentationContextProviding { 152 | 153 | public func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { 154 | return self.view.window! 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Authentication/AuthenticationManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthenticationManager.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/21/23. 6 | // 7 | 8 | import Foundation 9 | import FirebaseAuth 10 | 11 | struct AuthDataResultModel { 12 | let uid: String 13 | let email: String? 14 | let photoUrl: String? 15 | let isAnonymous: Bool 16 | 17 | init(user: User) { 18 | self.uid = user.uid 19 | self.email = user.email 20 | self.photoUrl = user.photoURL?.absoluteString 21 | self.isAnonymous = user.isAnonymous 22 | } 23 | } 24 | 25 | enum AuthProviderOption: String { 26 | case email = "password" 27 | case google = "google.com" 28 | case apple = "apple.com" 29 | } 30 | 31 | final class AuthenticationManager { 32 | 33 | static let shared = AuthenticationManager() 34 | private init() { } 35 | 36 | func getAuthenticatedUser() throws -> AuthDataResultModel { 37 | guard let user = Auth.auth().currentUser else { 38 | throw URLError(.badServerResponse) 39 | } 40 | 41 | return AuthDataResultModel(user: user) 42 | } 43 | 44 | func getProviders() throws -> [AuthProviderOption] { 45 | guard let providerData = Auth.auth().currentUser?.providerData else { 46 | throw URLError(.badServerResponse) 47 | } 48 | 49 | var providers: [AuthProviderOption] = [] 50 | for provider in providerData { 51 | if let option = AuthProviderOption(rawValue: provider.providerID) { 52 | providers.append(option) 53 | } else { 54 | assertionFailure("Provider option not found: \(provider.providerID)") 55 | } 56 | } 57 | print(providers) 58 | return providers 59 | } 60 | 61 | func signOut() throws { 62 | try Auth.auth().signOut() 63 | } 64 | 65 | func delete() async throws { 66 | guard let user = Auth.auth().currentUser else { 67 | throw URLError(.badURL) 68 | } 69 | 70 | try await user.delete() 71 | } 72 | } 73 | 74 | // MARK: SIGN IN EMAIL 75 | 76 | extension AuthenticationManager { 77 | 78 | @discardableResult 79 | func createUser(email: String, password: String) async throws -> AuthDataResultModel { 80 | let authDataResult = try await Auth.auth().createUser(withEmail: email, password: password) 81 | return AuthDataResultModel(user: authDataResult.user) 82 | } 83 | 84 | @discardableResult 85 | func signInUser(email: String, password: String) async throws -> AuthDataResultModel { 86 | let authDataResult = try await Auth.auth().signIn(withEmail: email, password: password) 87 | return AuthDataResultModel(user: authDataResult.user) 88 | } 89 | 90 | func resetPassword(email: String) async throws { 91 | try await Auth.auth().sendPasswordReset(withEmail: email) 92 | } 93 | 94 | func updatePassword(password: String) async throws { 95 | guard let user = Auth.auth().currentUser else { 96 | throw URLError(.badServerResponse) 97 | } 98 | 99 | try await user.updatePassword(to: password) 100 | } 101 | 102 | func updateEmail(email: String) async throws { 103 | guard let user = Auth.auth().currentUser else { 104 | throw URLError(.badServerResponse) 105 | } 106 | 107 | try await user.updateEmail(to: email) 108 | } 109 | 110 | } 111 | 112 | // MARK: SIGN IN SSO 113 | 114 | extension AuthenticationManager { 115 | 116 | @discardableResult 117 | func signInWithGoogle(tokens: GoogleSignInResultModel) async throws -> AuthDataResultModel { 118 | let credential = GoogleAuthProvider.credential(withIDToken: tokens.idToken, accessToken: tokens.accessToken) 119 | return try await signIn(credential: credential) 120 | } 121 | 122 | @discardableResult 123 | func signInWithApple(tokens: SignInWithAppleResult) async throws -> AuthDataResultModel { 124 | let credential = OAuthProvider.credential(withProviderID: AuthProviderOption.apple.rawValue, idToken: tokens.token, rawNonce: tokens.nonce) 125 | return try await signIn(credential: credential) 126 | } 127 | 128 | func signIn(credential: AuthCredential) async throws -> AuthDataResultModel { 129 | let authDataResult = try await Auth.auth().signIn(with: credential) 130 | return AuthDataResultModel(user: authDataResult.user) 131 | } 132 | } 133 | 134 | // MARK: SIGN IN ANONYMOUS 135 | 136 | extension AuthenticationManager { 137 | 138 | @discardableResult 139 | func signInAnonymous() async throws -> AuthDataResultModel { 140 | let authDataResult = try await Auth.auth().signInAnonymously() 141 | return AuthDataResultModel(user: authDataResult.user) 142 | } 143 | 144 | func linkEmail(email: String, password: String) async throws -> AuthDataResultModel { 145 | let credential = EmailAuthProvider.credential(withEmail: email, password: password) 146 | return try await linkCredential(credential: credential) 147 | } 148 | 149 | func linkGoogle(tokens: GoogleSignInResultModel) async throws -> AuthDataResultModel { 150 | let credential = GoogleAuthProvider.credential(withIDToken: tokens.idToken, accessToken: tokens.accessToken) 151 | return try await linkCredential(credential: credential) 152 | } 153 | 154 | func linkApple(tokens: SignInWithAppleResult) async throws -> AuthDataResultModel { 155 | let credential = OAuthProvider.credential(withProviderID: AuthProviderOption.apple.rawValue, idToken: tokens.token, rawNonce: tokens.nonce) 156 | return try await linkCredential(credential: credential) 157 | } 158 | 159 | private func linkCredential(credential: AuthCredential) async throws -> AuthDataResultModel { 160 | guard let user = Auth.auth().currentUser else { 161 | throw URLError(.badURL) 162 | } 163 | 164 | let authDataResult = try await user.link(with: credential) 165 | return AuthDataResultModel(user: authDataResult.user) 166 | } 167 | 168 | 169 | } 170 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Firestore/UserManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserManager.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/21/23. 6 | // 7 | 8 | import Foundation 9 | import FirebaseFirestore 10 | import FirebaseFirestoreSwift 11 | 12 | struct Movie: Codable { 13 | let id: String 14 | let title: String 15 | let isPopular: Bool 16 | } 17 | 18 | struct DBUser: Codable { 19 | let userId: String 20 | let isAnonymous: Bool? 21 | let email: String? 22 | let photoUrl: String? 23 | let dateCreated: Date? 24 | let isPremium: Bool? 25 | let preferences: [String]? 26 | let favoriteMovie: Movie? 27 | let profileImagePath: String? 28 | let profileImagePathUrl: String? 29 | 30 | init(auth: AuthDataResultModel) { 31 | self.userId = auth.uid 32 | self.isAnonymous = auth.isAnonymous 33 | self.email = auth.email 34 | self.photoUrl = auth.photoUrl 35 | self.dateCreated = Date() 36 | self.isPremium = false 37 | self.preferences = nil 38 | self.favoriteMovie = nil 39 | self.profileImagePath = nil 40 | self.profileImagePathUrl = nil 41 | } 42 | 43 | init( 44 | userId: String, 45 | isAnonymous: Bool? = nil, 46 | email: String? = nil, 47 | photoUrl: String? = nil, 48 | dateCreated: Date? = nil, 49 | isPremium: Bool? = nil, 50 | preferences: [String]? = nil, 51 | favoriteMovie: Movie? = nil, 52 | profileImagePath: String? = nil, 53 | profileImagePathUrl: String? = nil 54 | ) { 55 | self.userId = userId 56 | self.isAnonymous = isAnonymous 57 | self.email = email 58 | self.photoUrl = photoUrl 59 | self.dateCreated = dateCreated 60 | self.isPremium = isPremium 61 | self.preferences = preferences 62 | self.favoriteMovie = favoriteMovie 63 | self.profileImagePath = profileImagePath 64 | self.profileImagePathUrl = profileImagePathUrl 65 | } 66 | 67 | // func togglePremiumStatus() -> DBUser { 68 | // let currentValue = isPremium ?? false 69 | // return DBUser( 70 | // userId: userId, 71 | // isAnonymous: isAnonymous, 72 | // email: email, 73 | // photoUrl: photoUrl, 74 | // dateCreated: dateCreated, 75 | // isPremium: !currentValue) 76 | // } 77 | 78 | // mutating func togglePremiumStatus() { 79 | // let currentValue = isPremium ?? false 80 | // isPremium = !currentValue 81 | // } 82 | 83 | enum CodingKeys: String, CodingKey { 84 | case userId = "user_id" 85 | case isAnonymous = "is_anonymous" 86 | case email = "email" 87 | case photoUrl = "photo_url" 88 | case dateCreated = "date_created" 89 | case isPremium = "user_isPremium" 90 | case preferences = "preferences" 91 | case favoriteMovie = "favorite_movie" 92 | case profileImagePath = "profile_image_path" 93 | case profileImagePathUrl = "profile_image_path_url" 94 | } 95 | 96 | init(from decoder: Decoder) throws { 97 | let container = try decoder.container(keyedBy: CodingKeys.self) 98 | self.userId = try container.decode(String.self, forKey: .userId) 99 | self.isAnonymous = try container.decodeIfPresent(Bool.self, forKey: .isAnonymous) 100 | self.email = try container.decodeIfPresent(String.self, forKey: .email) 101 | self.photoUrl = try container.decodeIfPresent(String.self, forKey: .photoUrl) 102 | self.dateCreated = try container.decodeIfPresent(Date.self, forKey: .dateCreated) 103 | self.isPremium = try container.decodeIfPresent(Bool.self, forKey: .isPremium) 104 | self.preferences = try container.decodeIfPresent([String].self, forKey: .preferences) 105 | self.favoriteMovie = try container.decodeIfPresent(Movie.self, forKey: .favoriteMovie) 106 | self.profileImagePath = try container.decodeIfPresent(String.self, forKey: .profileImagePath) 107 | self.profileImagePathUrl = try container.decodeIfPresent(String.self, forKey: .profileImagePathUrl) 108 | } 109 | 110 | func encode(to encoder: Encoder) throws { 111 | var container = encoder.container(keyedBy: CodingKeys.self) 112 | try container.encode(self.userId, forKey: .userId) 113 | try container.encodeIfPresent(self.isAnonymous, forKey: .isAnonymous) 114 | try container.encodeIfPresent(self.email, forKey: .email) 115 | try container.encodeIfPresent(self.photoUrl, forKey: .photoUrl) 116 | try container.encodeIfPresent(self.dateCreated, forKey: .dateCreated) 117 | try container.encodeIfPresent(self.isPremium, forKey: .isPremium) 118 | try container.encodeIfPresent(self.preferences, forKey: .preferences) 119 | try container.encodeIfPresent(self.favoriteMovie, forKey: .favoriteMovie) 120 | try container.encodeIfPresent(self.profileImagePath, forKey: .profileImagePath) 121 | try container.encodeIfPresent(self.profileImagePathUrl, forKey: .profileImagePathUrl) 122 | } 123 | 124 | } 125 | 126 | final class UserManager { 127 | 128 | static let shared = UserManager() 129 | private init() { } 130 | 131 | private let userCollection: CollectionReference = Firestore.firestore().collection("users") 132 | 133 | private func userDocument(userId: String) -> DocumentReference { 134 | userCollection.document(userId) 135 | } 136 | 137 | private func userFavoriteProductCollection(userId: String) -> CollectionReference { 138 | userDocument(userId: userId).collection("favorite_products") 139 | } 140 | 141 | private func userFavoriteProductDocument(userId: String, favoriteProductId: String) -> DocumentReference { 142 | userFavoriteProductCollection(userId: userId).document(favoriteProductId) 143 | } 144 | 145 | private let encoder: Firestore.Encoder = { 146 | let encoder = Firestore.Encoder() 147 | // encoder.keyEncodingStrategy = .convertToSnakeCase 148 | return encoder 149 | }() 150 | 151 | private let decoder: Firestore.Decoder = { 152 | let decoder = Firestore.Decoder() 153 | // decoder.keyDecodingStrategy = .convertFromSnakeCase 154 | return decoder 155 | }() 156 | 157 | private var userFavoriteProductsListener: ListenerRegistration? = nil 158 | 159 | func createNewUser(user: DBUser) async throws { 160 | try userDocument(userId: user.userId).setData(from: user, merge: false) 161 | } 162 | 163 | // func createNewUser(auth: AuthDataResultModel) async throws { 164 | // var userData: [String:Any] = [ 165 | // "user_id" : auth.uid, 166 | // "is_anonymous" : auth.isAnonymous, 167 | // "date_created" : Timestamp(), 168 | // ] 169 | // if let email = auth.email { 170 | // userData["email"] = email 171 | // } 172 | // if let photoUrl = auth.photoUrl { 173 | // userData["photo_url"] = photoUrl 174 | // } 175 | // 176 | // try await userDocument(userId: auth.uid).setData(userData, merge: false) 177 | // } 178 | 179 | func getUser(userId: String) async throws -> DBUser { 180 | try await userDocument(userId: userId).getDocument(as: DBUser.self) 181 | } 182 | 183 | // func getUser(userId: String) async throws -> DBUser { 184 | // let snapshot = try await userDocument(userId: userId).getDocument() 185 | // 186 | // guard let data = snapshot.data(), let userId = data["user_id"] as? String else { 187 | // throw URLError(.badServerResponse) 188 | // } 189 | // 190 | // let isAnonymous = data["is_anonymous"] as? Bool 191 | // let email = data["email"] as? String 192 | // let photoUrl = data["photo_url"] as? String 193 | // let dateCreated = data["date_created"] as? Date 194 | // 195 | // return DBUser(userId: userId, isAnonymous: isAnonymous, email: email, photoUrl: photoUrl, dateCreated: dateCreated) 196 | // } 197 | 198 | // func updateUserPremiumStatus(user: DBUser) async throws { 199 | // try userDocument(userId: user.userId).setData(from: user, merge: true) 200 | // } 201 | 202 | func updateUserPremiumStatus(userId: String, isPremium: Bool) async throws { 203 | let data: [String:Any] = [ 204 | DBUser.CodingKeys.isPremium.rawValue : isPremium, 205 | ] 206 | 207 | try await userDocument(userId: userId).updateData(data) 208 | } 209 | 210 | func updateUserProfileImagePath(userId: String, path: String?, url: String?) async throws { 211 | let data: [String:Any] = [ 212 | DBUser.CodingKeys.profileImagePath.rawValue : path, 213 | DBUser.CodingKeys.profileImagePathUrl.rawValue : url, 214 | ] 215 | 216 | try await userDocument(userId: userId).updateData(data) 217 | } 218 | 219 | func addUserPreference(userId: String, preference: String) async throws { 220 | let data: [String:Any] = [ 221 | DBUser.CodingKeys.preferences.rawValue : FieldValue.arrayUnion([preference]) 222 | ] 223 | 224 | try await userDocument(userId: userId).updateData(data) 225 | } 226 | 227 | func removeUserPreference(userId: String, preference: String) async throws { 228 | let data: [String:Any] = [ 229 | DBUser.CodingKeys.preferences.rawValue : FieldValue.arrayRemove([preference]) 230 | ] 231 | 232 | try await userDocument(userId: userId).updateData(data) 233 | } 234 | 235 | func addFavoriteMovie(userId: String, movie: Movie) async throws { 236 | guard let data = try? encoder.encode(movie) else { 237 | throw URLError(.badURL) 238 | } 239 | 240 | let dict: [String:Any] = [ 241 | DBUser.CodingKeys.favoriteMovie.rawValue : data 242 | ] 243 | 244 | try await userDocument(userId: userId).updateData(dict) 245 | } 246 | 247 | func removeFavoriteMovie(userId: String) async throws { 248 | let data: [String:Any?] = [ 249 | DBUser.CodingKeys.favoriteMovie.rawValue : nil 250 | ] 251 | 252 | try await userDocument(userId: userId).updateData(data as [AnyHashable : Any]) 253 | } 254 | 255 | func addUserFavoriteProduct(userId: String, productId: Int) async throws { 256 | let document = userFavoriteProductCollection(userId: userId).document() 257 | let documentId = document.documentID 258 | 259 | let data: [String:Any] = [ 260 | UserFavoriteProduct.CodingKeys.id.rawValue : documentId, 261 | UserFavoriteProduct.CodingKeys.productId.rawValue : productId, 262 | UserFavoriteProduct.CodingKeys.dateCreated.rawValue : Timestamp() 263 | ] 264 | 265 | try await document.setData(data, merge: false) 266 | } 267 | 268 | func removeUserFavoriteProduct(userId: String, favoriteProductId: String) async throws { 269 | try await userFavoriteProductDocument(userId: userId, favoriteProductId: favoriteProductId).delete() 270 | } 271 | 272 | func getAllUserFavoriteProducts(userId: String) async throws -> [UserFavoriteProduct] { 273 | try await userFavoriteProductCollection(userId: userId).getDocuments(as: UserFavoriteProduct.self) 274 | } 275 | 276 | func removeListenerForAllUserFavoriteProducts() { 277 | self.userFavoriteProductsListener?.remove() 278 | } 279 | 280 | func addListenerForAllUserFavoriteProducts(userId: String, completion: @escaping (_ products: [UserFavoriteProduct]) -> Void) { 281 | self.userFavoriteProductsListener = userFavoriteProductCollection(userId: userId).addSnapshotListener { querySnapshot, error in 282 | guard let documents = querySnapshot?.documents else { 283 | print("No documents") 284 | return 285 | } 286 | 287 | let products: [UserFavoriteProduct] = documents.compactMap({ try? $0.data(as: UserFavoriteProduct.self) }) 288 | completion(products) 289 | 290 | querySnapshot?.documentChanges.forEach { diff in 291 | if (diff.type == .added) { 292 | print("New products: \(diff.document.data())") 293 | } 294 | if (diff.type == .modified) { 295 | print("Modified products: \(diff.document.data())") 296 | } 297 | if (diff.type == .removed) { 298 | print("Removed products: \(diff.document.data())") 299 | } 300 | } 301 | } 302 | } 303 | 304 | // func addListenerForAllUserFavoriteProducts(userId: String) -> AnyPublisher<[UserFavoriteProduct], Error> { 305 | // let publisher = PassthroughSubject<[UserFavoriteProduct], Error>() 306 | // 307 | // self.userFavoriteProductsListener = userFavoriteProductCollection(userId: userId).addSnapshotListener { querySnapshot, error in 308 | // guard let documents = querySnapshot?.documents else { 309 | // print("No documents") 310 | // return 311 | // } 312 | // 313 | // let products: [UserFavoriteProduct] = documents.compactMap({ try? $0.data(as: UserFavoriteProduct.self) }) 314 | // publisher.send(products) 315 | // } 316 | // 317 | // return publisher.eraseToAnyPublisher() 318 | // } 319 | func addListenerForAllUserFavoriteProducts(userId: String) -> AnyPublisher<[UserFavoriteProduct], Error> { 320 | let (publisher, listener) = userFavoriteProductCollection(userId: userId) 321 | .addSnapshotListener(as: UserFavoriteProduct.self) 322 | 323 | self.userFavoriteProductsListener = listener 324 | return publisher 325 | } 326 | 327 | } 328 | import Combine 329 | 330 | struct UserFavoriteProduct: Codable { 331 | let id: String 332 | let productId: Int 333 | let dateCreated: Date 334 | 335 | enum CodingKeys: String, CodingKey { 336 | case id = "id" 337 | case productId = "product_id" 338 | case dateCreated = "date_created" 339 | } 340 | 341 | init(from decoder: Decoder) throws { 342 | let container = try decoder.container(keyedBy: CodingKeys.self) 343 | self.id = try container.decode(String.self, forKey: .id) 344 | self.productId = try container.decode(Int.self, forKey: .productId) 345 | self.dateCreated = try container.decode(Date.self, forKey: .dateCreated) 346 | } 347 | 348 | func encode(to encoder: Encoder) throws { 349 | var container = encoder.container(keyedBy: CodingKeys.self) 350 | try container.encode(self.id, forKey: .id) 351 | try container.encode(self.productId, forKey: .productId) 352 | try container.encode(self.dateCreated, forKey: .dateCreated) 353 | } 354 | 355 | } 356 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp/Utilities/ProductsDatabase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductsDatabase.swift 3 | // SwiftfulFirebaseBootcamp 4 | // 5 | // Created by Nick Sarno on 1/22/23. 6 | // 7 | 8 | import Foundation 9 | 10 | struct ProductArray: Codable { 11 | let products: [Product] 12 | let total, skip, limit: Int 13 | } 14 | 15 | struct Product: Identifiable, Codable, Equatable { 16 | let id: Int 17 | let title: String? 18 | let description: String? 19 | let price: Int? 20 | let discountPercentage: Double? 21 | let rating: Double? 22 | let stock: Int? 23 | let brand, category: String? 24 | let thumbnail: String? 25 | let images: [String]? 26 | 27 | enum CodingKeys: String, CodingKey { 28 | case id 29 | case title 30 | case description 31 | case price 32 | case discountPercentage 33 | case rating 34 | case stock 35 | case brand 36 | case category 37 | case thumbnail 38 | case images 39 | } 40 | 41 | static func ==(lhs: Product, rhs: Product) -> Bool { 42 | return lhs.id == rhs.id 43 | } 44 | 45 | } 46 | 47 | // func downloadProductsAndUploadToFirebase() { 48 | // guard let url = URL(string: "https://dummyjson.com/products") else { return } 49 | // 50 | // Task { 51 | // do { 52 | // let (data, _) = try await URLSession.shared.data(from: url) 53 | // let products = try JSONDecoder().decode(ProductArray.self, from: data) 54 | // let productArray = products.products 55 | // 56 | // for product in productArray { 57 | // try? await ProductsManager.shared.uploadProduct(product: product) 58 | // } 59 | // 60 | // print("SUCCESS") 61 | // print(products.products.count) 62 | // } catch { 63 | // print(error) 64 | // } 65 | // } 66 | // } 67 | 68 | 69 | final class ProductDatabase { 70 | 71 | static let products: [Product] = [ 72 | Product(id: 1, title: Optional("iPhone 9"), description: Optional("An apple mobile which is nothing like apple"), price: Optional(549), discountPercentage: Optional(12.96), rating: Optional(4.69), stock: Optional(94), brand: Optional("Apple"), category: Optional("smartphones"), thumbnail: Optional("https://i.dummyjson.com/data/products/1/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/1/1.jpg", "https://i.dummyjson.com/data/products/1/2.jpg", "https://i.dummyjson.com/data/products/1/3.jpg", "https://i.dummyjson.com/data/products/1/4.jpg", "https://i.dummyjson.com/data/products/1/thumbnail.jpg"])), 73 | Product(id: 2, title: Optional("iPhone X"), description: Optional("SIM-Free, Model A19211 6.5-inch Super Retina HD display with OLED technology A12 Bionic chip with ..."), price: Optional(899), discountPercentage: Optional(17.94), rating: Optional(4.44), stock: Optional(34), brand: Optional("Apple"), category: Optional("smartphones"), thumbnail: Optional("https://i.dummyjson.com/data/products/2/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/2/1.jpg", "https://i.dummyjson.com/data/products/2/2.jpg", "https://i.dummyjson.com/data/products/2/3.jpg", "https://i.dummyjson.com/data/products/2/thumbnail.jpg"])), 74 | Product(id: 3, title: Optional("Samsung Universe 9"), description: Optional("Samsung\'s new variant which goes beyond Galaxy to the Universe"), price: Optional(1249), discountPercentage: Optional(15.46), rating: Optional(4.09), stock: Optional(36), brand: Optional("Samsung"), category: Optional("smartphones"), thumbnail: Optional("https://i.dummyjson.com/data/products/3/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/3/1.jpg"])), 75 | Product(id: 4, title: Optional("OPPOF19"), description: Optional("OPPO F19 is officially announced on April 2021."), price: Optional(280), discountPercentage: Optional(17.91), rating: Optional(4.3), stock: Optional(123), brand: Optional("OPPO"), category: Optional("smartphones"), thumbnail: Optional("https://i.dummyjson.com/data/products/4/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/4/1.jpg", "https://i.dummyjson.com/data/products/4/2.jpg", "https://i.dummyjson.com/data/products/4/3.jpg", "https://i.dummyjson.com/data/products/4/4.jpg", "https://i.dummyjson.com/data/products/4/thumbnail.jpg"])), 76 | Product(id: 5, title: Optional("Huawei P30"), description: Optional("Huawei’s re-badged P30 Pro New Edition was officially unveiled yesterday in Germany and now the device has made its way to the UK."), price: Optional(499), discountPercentage: Optional(10.58), rating: Optional(4.09), stock: Optional(32), brand: Optional("Huawei"), category: Optional("smartphones"), thumbnail: Optional("https://i.dummyjson.com/data/products/5/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/5/1.jpg", "https://i.dummyjson.com/data/products/5/2.jpg", "https://i.dummyjson.com/data/products/5/3.jpg"])), 77 | Product(id: 6, title: Optional("MacBook Pro"), description: Optional("MacBook Pro 2021 with mini-LED display may launch between September, November"), price: Optional(1749), discountPercentage: Optional(11.02), rating: Optional(4.57), stock: Optional(83), brand: Optional("APPle"), category: Optional("laptops"), thumbnail: Optional("https://i.dummyjson.com/data/products/6/thumbnail.png"), images: Optional(["https://i.dummyjson.com/data/products/6/1.png", "https://i.dummyjson.com/data/products/6/2.jpg", "https://i.dummyjson.com/data/products/6/3.png", "https://i.dummyjson.com/data/products/6/4.jpg"])), 78 | Product(id: 7, title: Optional("Samsung Galaxy Book"), description: Optional("Samsung Galaxy Book S (2020) Laptop With Intel Lakefield Chip, 8GB of RAM Launched"), price: Optional(1499), discountPercentage: Optional(4.15), rating: Optional(4.25), stock: Optional(50), brand: Optional("Samsung"), category: Optional("laptops"), thumbnail: Optional("https://i.dummyjson.com/data/products/7/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/7/1.jpg", "https://i.dummyjson.com/data/products/7/2.jpg", "https://i.dummyjson.com/data/products/7/3.jpg", "https://i.dummyjson.com/data/products/7/thumbnail.jpg"])), 79 | Product(id: 8, title: Optional("Microsoft Surface Laptop 4"), description: Optional("Style and speed. Stand out on HD video calls backed by Studio Mics. Capture ideas on the vibrant touchscreen."), price: Optional(1499), discountPercentage: Optional(10.23), rating: Optional(4.43), stock: Optional(68), brand: Optional("Microsoft Surface"), category: Optional("laptops"), thumbnail: Optional("https://i.dummyjson.com/data/products/8/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/8/1.jpg", "https://i.dummyjson.com/data/products/8/2.jpg", "https://i.dummyjson.com/data/products/8/3.jpg", "https://i.dummyjson.com/data/products/8/4.jpg", "https://i.dummyjson.com/data/products/8/thumbnail.jpg"])), 80 | Product(id: 9, title: Optional("Infinix INBOOK"), description: Optional("Infinix Inbook X1 Ci3 10th 8GB 256GB 14 Win10 Grey – 1 Year Warranty"), price: Optional(1099), discountPercentage: Optional(11.83), rating: Optional(4.54), stock: Optional(96), brand: Optional("Infinix"), category: Optional("laptops"), thumbnail: Optional("https://i.dummyjson.com/data/products/9/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/9/1.jpg", "https://i.dummyjson.com/data/products/9/2.png", "https://i.dummyjson.com/data/products/9/3.png", "https://i.dummyjson.com/data/products/9/4.jpg", "https://i.dummyjson.com/data/products/9/thumbnail.jpg"])), 81 | Product(id: 10, title: Optional("HP Pavilion 15-DK1056WM"), description: Optional("HP Pavilion 15-DK1056WM Gaming Laptop 10th Gen Core i5, 8GB, 256GB SSD, GTX 1650 4GB, Windows 10"), price: Optional(1099), discountPercentage: Optional(6.18), rating: Optional(4.43), stock: Optional(89), brand: Optional("HP Pavilion"), category: Optional("laptops"), thumbnail: Optional("https://i.dummyjson.com/data/products/10/thumbnail.jpeg"), images: Optional(["https://i.dummyjson.com/data/products/10/1.jpg", "https://i.dummyjson.com/data/products/10/2.jpg", "https://i.dummyjson.com/data/products/10/3.jpg", "https://i.dummyjson.com/data/products/10/thumbnail.jpeg"])), 82 | Product(id: 11, title: Optional("perfume Oil"), description: Optional("Mega Discount, Impression of Acqua Di Gio by GiorgioArmani concentrated attar perfume Oil"), price: Optional(13), discountPercentage: Optional(8.4), rating: Optional(4.26), stock: Optional(65), brand: Optional("Impression of Acqua Di Gio"), category: Optional("fragrances"), thumbnail: Optional("https://i.dummyjson.com/data/products/11/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/11/1.jpg", "https://i.dummyjson.com/data/products/11/2.jpg", "https://i.dummyjson.com/data/products/11/3.jpg", "https://i.dummyjson.com/data/products/11/thumbnail.jpg"])), 83 | Product(id: 12, title: Optional("Brown Perfume"), description: Optional("Royal_Mirage Sport Brown Perfume for Men & Women - 120ml"), price: Optional(40), discountPercentage: Optional(15.66), rating: Optional(4.0), stock: Optional(52), brand: Optional("Royal_Mirage"), category: Optional("fragrances"), thumbnail: Optional("https://i.dummyjson.com/data/products/12/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/12/1.jpg", "https://i.dummyjson.com/data/products/12/2.jpg", "https://i.dummyjson.com/data/products/12/3.png", "https://i.dummyjson.com/data/products/12/4.jpg", "https://i.dummyjson.com/data/products/12/thumbnail.jpg"])), 84 | Product(id: 13, title: Optional("Fog Scent Xpressio Perfume"), description: Optional("Product details of Best Fog Scent Xpressio Perfume 100ml For Men cool long lasting perfumes for Men"), price: Optional(13), discountPercentage: Optional(8.14), rating: Optional(4.59), stock: Optional(61), brand: Optional("Fog Scent Xpressio"), category: Optional("fragrances"), thumbnail: Optional("https://i.dummyjson.com/data/products/13/thumbnail.webp"), images: Optional(["https://i.dummyjson.com/data/products/13/1.jpg", "https://i.dummyjson.com/data/products/13/2.png", "https://i.dummyjson.com/data/products/13/3.jpg", "https://i.dummyjson.com/data/products/13/4.jpg", "https://i.dummyjson.com/data/products/13/thumbnail.webp"])), 85 | Product(id: 14, title: Optional("Non-Alcoholic Concentrated Perfume Oil"), description: Optional("Original Al Munakh® by Mahal Al Musk | Our Impression of Climate | 6ml Non-Alcoholic Concentrated Perfume Oil"), price: Optional(120), discountPercentage: Optional(15.6), rating: Optional(4.21), stock: Optional(114), brand: Optional("Al Munakh"), category: Optional("fragrances"), thumbnail: Optional("https://i.dummyjson.com/data/products/14/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/14/1.jpg", "https://i.dummyjson.com/data/products/14/2.jpg", "https://i.dummyjson.com/data/products/14/3.jpg", "https://i.dummyjson.com/data/products/14/thumbnail.jpg"])), 86 | Product(id: 15, title: Optional("Eau De Perfume Spray"), description: Optional("Genuine Al-Rehab spray perfume from UAE/Saudi Arabia/Yemen High Quality"), price: Optional(30), discountPercentage: Optional(10.99), rating: Optional(4.7), stock: Optional(105), brand: Optional("Lord - Al-Rehab"), category: Optional("fragrances"), thumbnail: Optional("https://i.dummyjson.com/data/products/15/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/15/1.jpg", "https://i.dummyjson.com/data/products/15/2.jpg", "https://i.dummyjson.com/data/products/15/3.jpg", "https://i.dummyjson.com/data/products/15/4.jpg", "https://i.dummyjson.com/data/products/15/thumbnail.jpg"])), 87 | Product(id: 16, title: Optional("Hyaluronic Acid Serum"), description: Optional("L\'Oréal Paris introduces Hyaluron Expert Replumping Serum formulated with 1.5% Hyaluronic Acid"), price: Optional(19), discountPercentage: Optional(13.31), rating: Optional(4.83), stock: Optional(110), brand: Optional("L\'Oreal Paris"), category: Optional("skincare"), thumbnail: Optional("https://i.dummyjson.com/data/products/16/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/16/1.png", "https://i.dummyjson.com/data/products/16/2.webp", "https://i.dummyjson.com/data/products/16/3.jpg", "https://i.dummyjson.com/data/products/16/4.jpg", "https://i.dummyjson.com/data/products/16/thumbnail.jpg"])), 88 | Product(id: 17, title: Optional("Tree Oil 30ml"), description: Optional("Tea tree oil contains a number of compounds, including terpinen-4-ol, that have been shown to kill certain bacteria,"), price: Optional(12), discountPercentage: Optional(4.09), rating: Optional(4.52), stock: Optional(78), brand: Optional("Hemani Tea"), category: Optional("skincare"), thumbnail: Optional("https://i.dummyjson.com/data/products/17/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/17/1.jpg", "https://i.dummyjson.com/data/products/17/2.jpg", "https://i.dummyjson.com/data/products/17/3.jpg", "https://i.dummyjson.com/data/products/17/thumbnail.jpg"])), 89 | Product(id: 18, title: Optional("Oil Free Moisturizer 100ml"), description: Optional("Dermive Oil Free Moisturizer with SPF 20 is specifically formulated with ceramides, hyaluronic acid & sunscreen."), price: Optional(40), discountPercentage: Optional(13.1), rating: Optional(4.56), stock: Optional(88), brand: Optional("Dermive"), category: Optional("skincare"), thumbnail: Optional("https://i.dummyjson.com/data/products/18/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/18/1.jpg", "https://i.dummyjson.com/data/products/18/2.jpg", "https://i.dummyjson.com/data/products/18/3.jpg", "https://i.dummyjson.com/data/products/18/4.jpg", "https://i.dummyjson.com/data/products/18/thumbnail.jpg"])), 90 | Product(id: 19, title: Optional("Skin Beauty Serum."), description: Optional("Product name: rorec collagen hyaluronic acid white face serum riceNet weight: 15 m"), price: Optional(46), discountPercentage: Optional(10.68), rating: Optional(4.42), stock: Optional(54), brand: Optional("ROREC White Rice"), category: Optional("skincare"), thumbnail: Optional("https://i.dummyjson.com/data/products/19/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/19/1.jpg", "https://i.dummyjson.com/data/products/19/2.jpg", "https://i.dummyjson.com/data/products/19/3.png", "https://i.dummyjson.com/data/products/19/thumbnail.jpg"])), 91 | Product(id: 20, title: Optional("Freckle Treatment Cream- 15gm"), description: Optional("Fair & Clear is Pakistan\'s only pure Freckle cream which helpsfade Freckles, Darkspots and pigments. Mercury level is 0%, so there are no side effects."), price: Optional(70), discountPercentage: Optional(16.99), rating: Optional(4.06), stock: Optional(140), brand: Optional("Fair & Clear"), category: Optional("skincare"), thumbnail: Optional("https://i.dummyjson.com/data/products/20/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/20/1.jpg", "https://i.dummyjson.com/data/products/20/2.jpg", "https://i.dummyjson.com/data/products/20/3.jpg", "https://i.dummyjson.com/data/products/20/4.jpg", "https://i.dummyjson.com/data/products/20/thumbnail.jpg"])), 92 | Product(id: 21, title: Optional("- Daal Masoor 500 grams"), description: Optional("Fine quality Branded Product Keep in a cool and dry place"), price: Optional(20), discountPercentage: Optional(4.81), rating: Optional(4.44), stock: Optional(133), brand: Optional("Saaf & Khaas"), category: Optional("groceries"), thumbnail: Optional("https://i.dummyjson.com/data/products/21/thumbnail.png"), images: Optional(["https://i.dummyjson.com/data/products/21/1.png", "https://i.dummyjson.com/data/products/21/2.jpg", "https://i.dummyjson.com/data/products/21/3.jpg"])), 93 | Product(id: 22, title: Optional("Elbow Macaroni - 400 gm"), description: Optional("Product details of Bake Parlor Big Elbow Macaroni - 400 gm"), price: Optional(14), discountPercentage: Optional(15.58), rating: Optional(4.57), stock: Optional(146), brand: Optional("Bake Parlor Big"), category: Optional("groceries"), thumbnail: Optional("https://i.dummyjson.com/data/products/22/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/22/1.jpg", "https://i.dummyjson.com/data/products/22/2.jpg", "https://i.dummyjson.com/data/products/22/3.jpg"])), 94 | Product(id: 23, title: Optional("Orange Essence Food Flavou"), description: Optional("Specifications of Orange Essence Food Flavour For Cakes and Baking Food Item"), price: Optional(14), discountPercentage: Optional(8.04), rating: Optional(4.85), stock: Optional(26), brand: Optional("Baking Food Items"), category: Optional("groceries"), thumbnail: Optional("https://i.dummyjson.com/data/products/23/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/23/1.jpg", "https://i.dummyjson.com/data/products/23/2.jpg", "https://i.dummyjson.com/data/products/23/3.jpg", "https://i.dummyjson.com/data/products/23/4.jpg", "https://i.dummyjson.com/data/products/23/thumbnail.jpg"])), 95 | Product(id: 24, title: Optional("cereals muesli fruit nuts"), description: Optional("original fauji cereal muesli 250gm box pack original fauji cereals muesli fruit nuts flakes breakfast cereal break fast faujicereals cerels cerel foji fouji"), price: Optional(46), discountPercentage: Optional(16.8), rating: Optional(4.94), stock: Optional(113), brand: Optional("fauji"), category: Optional("groceries"), thumbnail: Optional("https://i.dummyjson.com/data/products/24/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/24/1.jpg", "https://i.dummyjson.com/data/products/24/2.jpg", "https://i.dummyjson.com/data/products/24/3.jpg", "https://i.dummyjson.com/data/products/24/4.jpg", "https://i.dummyjson.com/data/products/24/thumbnail.jpg"])), 96 | Product(id: 25, title: Optional("Gulab Powder 50 Gram"), description: Optional("Dry Rose Flower Powder Gulab Powder 50 Gram • Treats Wounds"), price: Optional(70), discountPercentage: Optional(13.58), rating: Optional(4.87), stock: Optional(47), brand: Optional("Dry Rose"), category: Optional("groceries"), thumbnail: Optional("https://i.dummyjson.com/data/products/25/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/25/1.png", "https://i.dummyjson.com/data/products/25/2.jpg", "https://i.dummyjson.com/data/products/25/3.png", "https://i.dummyjson.com/data/products/25/4.jpg", "https://i.dummyjson.com/data/products/25/thumbnail.jpg"])), 97 | Product(id: 26, title: Optional("Plant Hanger For Home"), description: Optional("Boho Decor Plant Hanger For Home Wall Decoration Macrame Wall Hanging Shelf"), price: Optional(41), discountPercentage: Optional(17.86), rating: Optional(4.08), stock: Optional(131), brand: Optional("Boho Decor"), category: Optional("home-decoration"), thumbnail: Optional("https://i.dummyjson.com/data/products/26/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/26/1.jpg", "https://i.dummyjson.com/data/products/26/2.jpg", "https://i.dummyjson.com/data/products/26/3.jpg", "https://i.dummyjson.com/data/products/26/4.jpg", "https://i.dummyjson.com/data/products/26/5.jpg", "https://i.dummyjson.com/data/products/26/thumbnail.jpg"])), 98 | Product(id: 27, title: Optional("Flying Wooden Bird"), description: Optional("Package Include 6 Birds with Adhesive Tape Shape: 3D Shaped Wooden Birds Material: Wooden MDF, Laminated 3.5mm"), price: Optional(51), discountPercentage: Optional(15.58), rating: Optional(4.41), stock: Optional(17), brand: Optional("Flying Wooden"), category: Optional("home-decoration"), thumbnail: Optional("https://i.dummyjson.com/data/products/27/thumbnail.webp"), images: Optional(["https://i.dummyjson.com/data/products/27/1.jpg", "https://i.dummyjson.com/data/products/27/2.jpg", "https://i.dummyjson.com/data/products/27/3.jpg", "https://i.dummyjson.com/data/products/27/4.jpg", "https://i.dummyjson.com/data/products/27/thumbnail.webp"])), 99 | Product(id: 28, title: Optional("3D Embellishment Art Lamp"), description: Optional("3D led lamp sticker Wall sticker 3d wall art light on/off button cell operated (included)"), price: Optional(20), discountPercentage: Optional(16.49), rating: Optional(4.82), stock: Optional(54), brand: Optional("LED Lights"), category: Optional("home-decoration"), thumbnail: Optional("https://i.dummyjson.com/data/products/28/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/28/1.jpg", "https://i.dummyjson.com/data/products/28/2.jpg", "https://i.dummyjson.com/data/products/28/3.png", "https://i.dummyjson.com/data/products/28/4.jpg", "https://i.dummyjson.com/data/products/28/thumbnail.jpg"])), 100 | Product(id: 29, title: Optional("Handcraft Chinese style"), description: Optional("Handcraft Chinese style art luxury palace hotel villa mansion home decor ceramic vase with brass fruit plate"), price: Optional(60), discountPercentage: Optional(15.34), rating: Optional(4.44), stock: Optional(7), brand: Optional("luxury palace"), category: Optional("home-decoration"), thumbnail: Optional("https://i.dummyjson.com/data/products/29/thumbnail.webp"), images: Optional(["https://i.dummyjson.com/data/products/29/1.jpg", "https://i.dummyjson.com/data/products/29/2.jpg", "https://i.dummyjson.com/data/products/29/3.webp", "https://i.dummyjson.com/data/products/29/4.webp", "https://i.dummyjson.com/data/products/29/thumbnail.webp"])), 101 | Product(id: 30, title: Optional("Key Holder"), description: Optional("Attractive DesignMetallic materialFour key hooksReliable & DurablePremium Quality"), price: Optional(30), discountPercentage: Optional(2.92), rating: Optional(4.92), stock: Optional(54), brand: Optional("Golden"), category: Optional("home-decoration"), thumbnail: Optional("https://i.dummyjson.com/data/products/30/thumbnail.jpg"), images: Optional(["https://i.dummyjson.com/data/products/30/1.jpg", "https://i.dummyjson.com/data/products/30/2.jpg", "https://i.dummyjson.com/data/products/30/3.jpg", "https://i.dummyjson.com/data/products/30/thumbnail.jpg"])) 102 | ] 103 | 104 | } 105 | 106 | 107 | -------------------------------------------------------------------------------- /SwiftfulFirebaseBootcamp.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 024234672983674800ABFC2A /* AdSupport.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 024234662983674800ABFC2A /* AdSupport.framework */; }; 11 | 024234692983675800ABFC2A /* AnalyticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024234682983675800ABFC2A /* AnalyticsView.swift */; }; 12 | 02980083297CBD7600AEA9D6 /* SignInAppleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02980082297CBD7600AEA9D6 /* SignInAppleHelper.swift */; }; 13 | 02980085297CDDD900AEA9D6 /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 02980084297CDDD900AEA9D6 /* FirebaseFirestore */; }; 14 | 02980087297CDDD900AEA9D6 /* FirebaseFirestoreSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 02980086297CDDD900AEA9D6 /* FirebaseFirestoreSwift */; }; 15 | 0298008B297CDE9700AEA9D6 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0298008A297CDE9700AEA9D6 /* SettingsViewModel.swift */; }; 16 | 0298008E297CDEBE00AEA9D6 /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0298008D297CDEBE00AEA9D6 /* AuthenticationViewModel.swift */; }; 17 | 02980091297CDEE200AEA9D6 /* SignInEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02980090297CDEE200AEA9D6 /* SignInEmailViewModel.swift */; }; 18 | 02980094297CDF0200AEA9D6 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02980093297CDF0200AEA9D6 /* ProfileView.swift */; }; 19 | 02980097297CE50A00AEA9D6 /* UserManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02980096297CE50A00AEA9D6 /* UserManager.swift */; }; 20 | 02980099297D0AD100AEA9D6 /* ProductsDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02980098297D0AD100AEA9D6 /* ProductsDatabase.swift */; }; 21 | 0298009C297D0D0B00AEA9D6 /* ProductsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0298009B297D0D0B00AEA9D6 /* ProductsView.swift */; }; 22 | 0298009E297D0EC200AEA9D6 /* ProductsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0298009D297D0EC200AEA9D6 /* ProductsManager.swift */; }; 23 | 029800A1297D145600AEA9D6 /* ProductCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029800A0297D145600AEA9D6 /* ProductCellView.swift */; }; 24 | 029800A4297DEF0200AEA9D6 /* TabbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029800A3297DEF0200AEA9D6 /* TabbarView.swift */; }; 25 | 029800A7297DF0F400AEA9D6 /* FavoriteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029800A6297DF0F400AEA9D6 /* FavoriteView.swift */; }; 26 | 029800A9297DFC6800AEA9D6 /* ProductCellViewBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029800A8297DFC6800AEA9D6 /* ProductCellViewBuilder.swift */; }; 27 | 029800AC297E0AA100AEA9D6 /* Query+EXT.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029800AB297E0AA100AEA9D6 /* Query+EXT.swift */; }; 28 | 029800B0297E0AD400AEA9D6 /* OnFirstAppearViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029800AF297E0AD400AEA9D6 /* OnFirstAppearViewModifier.swift */; }; 29 | 029800B2297E0AF400AEA9D6 /* FavoriteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029800B1297E0AF400AEA9D6 /* FavoriteViewModel.swift */; }; 30 | 029800B4297E0B0D00AEA9D6 /* ProductsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029800B3297E0B0D00AEA9D6 /* ProductsViewModel.swift */; }; 31 | 029800B7297F696D00AEA9D6 /* SecurityRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029800B6297F696D00AEA9D6 /* SecurityRules.swift */; }; 32 | 029800B9297F6BF300AEA9D6 /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 029800B8297F6BF300AEA9D6 /* FirebaseStorage */; }; 33 | 029800BC297F6CA100AEA9D6 /* StorageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029800BB297F6CA100AEA9D6 /* StorageManager.swift */; }; 34 | 029800BE297F6EF200AEA9D6 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029800BD297F6EF200AEA9D6 /* ProfileViewModel.swift */; }; 35 | 029800C02980B79100AEA9D6 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 029800BF2980B79100AEA9D6 /* FirebaseCrashlytics */; }; 36 | 029800C42980BA1800AEA9D6 /* CrashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029800C32980BA1800AEA9D6 /* CrashView.swift */; }; 37 | 029800C62980CE0100AEA9D6 /* FirebasePerformance in Frameworks */ = {isa = PBXBuildFile; productRef = 029800C52980CE0100AEA9D6 /* FirebasePerformance */; }; 38 | 029800C92980CE2000AEA9D6 /* CrashManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029800C82980CE2000AEA9D6 /* CrashManager.swift */; }; 39 | 029800CF2980CFAE00AEA9D6 /* PerformanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029800CE2980CFAE00AEA9D6 /* PerformanceView.swift */; }; 40 | 02D0797D297C815700EAB2DE /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 02D0797C297C815700EAB2DE /* FirebaseAnalytics */; }; 41 | 02D0797F297C815700EAB2DE /* FirebaseAnalyticsSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 02D0797E297C815700EAB2DE /* FirebaseAnalyticsSwift */; }; 42 | 02D07982297C84A900EAB2DE /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 02D07981297C84A900EAB2DE /* FirebaseAuth */; }; 43 | 02D07985297C855700EAB2DE /* AuthenticationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D07984297C855700EAB2DE /* AuthenticationView.swift */; }; 44 | 02D07987297C876A00EAB2DE /* SignInEmailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D07986297C876A00EAB2DE /* SignInEmailView.swift */; }; 45 | 02D07989297C88AA00EAB2DE /* AuthenticationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D07988297C88AA00EAB2DE /* AuthenticationManager.swift */; }; 46 | 02D0798B297C8CC400EAB2DE /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D0798A297C8CC400EAB2DE /* RootView.swift */; }; 47 | 02D0798D297C8EE000EAB2DE /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D0798C297C8EE000EAB2DE /* SettingsView.swift */; }; 48 | 02D07990297C9F7B00EAB2DE /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = 02D0798F297C9F7B00EAB2DE /* GoogleSignIn */; }; 49 | 02D07992297C9F7B00EAB2DE /* GoogleSignInSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 02D07991297C9F7B00EAB2DE /* GoogleSignInSwift */; }; 50 | 02D07996297CA2FC00EAB2DE /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D07995297CA2FC00EAB2DE /* Utilities.swift */; }; 51 | 02D07998297CA8F900EAB2DE /* SignInGoogleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D07997297CA8F900EAB2DE /* SignInGoogleHelper.swift */; }; 52 | 02D0799B297CB25700EAB2DE /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 02D0799A297CB25700EAB2DE /* GoogleService-Info.plist */; }; 53 | 02E2BFA1297C5C2C0051D19B /* SwiftfulFirebaseBootcampApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E2BFA0297C5C2C0051D19B /* SwiftfulFirebaseBootcampApp.swift */; }; 54 | 02E2BFA3297C5C2C0051D19B /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E2BFA2297C5C2C0051D19B /* ContentView.swift */; }; 55 | 02E2BFA5297C5C2E0051D19B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 02E2BFA4297C5C2E0051D19B /* Assets.xcassets */; }; 56 | 02E2BFA8297C5C2E0051D19B /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 02E2BFA7297C5C2E0051D19B /* Preview Assets.xcassets */; }; 57 | /* End PBXBuildFile section */ 58 | 59 | /* Begin PBXFileReference section */ 60 | 024234662983674800ABFC2A /* AdSupport.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AdSupport.framework; path = System/Library/Frameworks/AdSupport.framework; sourceTree = SDKROOT; }; 61 | 024234682983675800ABFC2A /* AnalyticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsView.swift; sourceTree = ""; }; 62 | 02980082297CBD7600AEA9D6 /* SignInAppleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInAppleHelper.swift; sourceTree = ""; }; 63 | 0298008A297CDE9700AEA9D6 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 64 | 0298008D297CDEBE00AEA9D6 /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; 65 | 02980090297CDEE200AEA9D6 /* SignInEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInEmailViewModel.swift; sourceTree = ""; }; 66 | 02980093297CDF0200AEA9D6 /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; 67 | 02980096297CE50A00AEA9D6 /* UserManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserManager.swift; sourceTree = ""; }; 68 | 02980098297D0AD100AEA9D6 /* ProductsDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsDatabase.swift; sourceTree = ""; }; 69 | 0298009B297D0D0B00AEA9D6 /* ProductsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsView.swift; sourceTree = ""; }; 70 | 0298009D297D0EC200AEA9D6 /* ProductsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsManager.swift; sourceTree = ""; }; 71 | 029800A0297D145600AEA9D6 /* ProductCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductCellView.swift; sourceTree = ""; }; 72 | 029800A3297DEF0200AEA9D6 /* TabbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabbarView.swift; sourceTree = ""; }; 73 | 029800A6297DF0F400AEA9D6 /* FavoriteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteView.swift; sourceTree = ""; }; 74 | 029800A8297DFC6800AEA9D6 /* ProductCellViewBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductCellViewBuilder.swift; sourceTree = ""; }; 75 | 029800AB297E0AA100AEA9D6 /* Query+EXT.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Query+EXT.swift"; sourceTree = ""; }; 76 | 029800AF297E0AD400AEA9D6 /* OnFirstAppearViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnFirstAppearViewModifier.swift; sourceTree = ""; }; 77 | 029800B1297E0AF400AEA9D6 /* FavoriteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewModel.swift; sourceTree = ""; }; 78 | 029800B3297E0B0D00AEA9D6 /* ProductsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsViewModel.swift; sourceTree = ""; }; 79 | 029800B6297F696D00AEA9D6 /* SecurityRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityRules.swift; sourceTree = ""; }; 80 | 029800BB297F6CA100AEA9D6 /* StorageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageManager.swift; sourceTree = ""; }; 81 | 029800BD297F6EF200AEA9D6 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; 82 | 029800C32980BA1800AEA9D6 /* CrashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashView.swift; sourceTree = ""; }; 83 | 029800C82980CE2000AEA9D6 /* CrashManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashManager.swift; sourceTree = ""; }; 84 | 029800CE2980CFAE00AEA9D6 /* PerformanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceView.swift; sourceTree = ""; }; 85 | 02D07984297C855700EAB2DE /* AuthenticationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationView.swift; sourceTree = ""; }; 86 | 02D07986297C876A00EAB2DE /* SignInEmailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInEmailView.swift; sourceTree = ""; }; 87 | 02D07988297C88AA00EAB2DE /* AuthenticationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationManager.swift; sourceTree = ""; }; 88 | 02D0798A297C8CC400EAB2DE /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 89 | 02D0798C297C8EE000EAB2DE /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 90 | 02D07993297C9FC500EAB2DE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 91 | 02D07995297CA2FC00EAB2DE /* Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities.swift; sourceTree = ""; }; 92 | 02D07997297CA8F900EAB2DE /* SignInGoogleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInGoogleHelper.swift; sourceTree = ""; }; 93 | 02D07999297CB19C00EAB2DE /* SwiftfulFirebaseBootcamp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SwiftfulFirebaseBootcamp.entitlements; sourceTree = ""; }; 94 | 02D0799A297CB25700EAB2DE /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 95 | 02E2BF9D297C5C2C0051D19B /* SwiftfulFirebaseBootcamp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftfulFirebaseBootcamp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 96 | 02E2BFA0297C5C2C0051D19B /* SwiftfulFirebaseBootcampApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftfulFirebaseBootcampApp.swift; sourceTree = ""; }; 97 | 02E2BFA2297C5C2C0051D19B /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 98 | 02E2BFA4297C5C2E0051D19B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 99 | 02E2BFA7297C5C2E0051D19B /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 100 | /* End PBXFileReference section */ 101 | 102 | /* Begin PBXFrameworksBuildPhase section */ 103 | 02E2BF9A297C5C2C0051D19B /* Frameworks */ = { 104 | isa = PBXFrameworksBuildPhase; 105 | buildActionMask = 2147483647; 106 | files = ( 107 | 02D07992297C9F7B00EAB2DE /* GoogleSignInSwift in Frameworks */, 108 | 02D0797F297C815700EAB2DE /* FirebaseAnalyticsSwift in Frameworks */, 109 | 029800C62980CE0100AEA9D6 /* FirebasePerformance in Frameworks */, 110 | 02D07990297C9F7B00EAB2DE /* GoogleSignIn in Frameworks */, 111 | 02980087297CDDD900AEA9D6 /* FirebaseFirestoreSwift in Frameworks */, 112 | 02D07982297C84A900EAB2DE /* FirebaseAuth in Frameworks */, 113 | 029800B9297F6BF300AEA9D6 /* FirebaseStorage in Frameworks */, 114 | 024234672983674800ABFC2A /* AdSupport.framework in Frameworks */, 115 | 029800C02980B79100AEA9D6 /* FirebaseCrashlytics in Frameworks */, 116 | 02D0797D297C815700EAB2DE /* FirebaseAnalytics in Frameworks */, 117 | 02980085297CDDD900AEA9D6 /* FirebaseFirestore in Frameworks */, 118 | ); 119 | runOnlyForDeploymentPostprocessing = 0; 120 | }; 121 | /* End PBXFrameworksBuildPhase section */ 122 | 123 | /* Begin PBXGroup section */ 124 | 02423465298366FA00ABFC2A /* Analytics */ = { 125 | isa = PBXGroup; 126 | children = ( 127 | 024234682983675800ABFC2A /* AnalyticsView.swift */, 128 | ); 129 | path = Analytics; 130 | sourceTree = ""; 131 | }; 132 | 02980088297CDE6800AEA9D6 /* Core */ = { 133 | isa = PBXGroup; 134 | children = ( 135 | 02D0798A297C8CC400EAB2DE /* RootView.swift */, 136 | 0298008C297CDEA600AEA9D6 /* Authentication */, 137 | 029800A2297DEEEF00AEA9D6 /* Tabbar */, 138 | 0298009A297D0D0100AEA9D6 /* Products */, 139 | 029800A5297DF0E700AEA9D6 /* Favorites */, 140 | 02980092297CDEF100AEA9D6 /* Profile */, 141 | 02980089297CDE8C00AEA9D6 /* Settings */, 142 | ); 143 | path = Core; 144 | sourceTree = ""; 145 | }; 146 | 02980089297CDE8C00AEA9D6 /* Settings */ = { 147 | isa = PBXGroup; 148 | children = ( 149 | 02D0798C297C8EE000EAB2DE /* SettingsView.swift */, 150 | 0298008A297CDE9700AEA9D6 /* SettingsViewModel.swift */, 151 | ); 152 | path = Settings; 153 | sourceTree = ""; 154 | }; 155 | 0298008C297CDEA600AEA9D6 /* Authentication */ = { 156 | isa = PBXGroup; 157 | children = ( 158 | 02D07984297C855700EAB2DE /* AuthenticationView.swift */, 159 | 0298008D297CDEBE00AEA9D6 /* AuthenticationViewModel.swift */, 160 | 0298008F297CDECF00AEA9D6 /* Subviews */, 161 | ); 162 | path = Authentication; 163 | sourceTree = ""; 164 | }; 165 | 0298008F297CDECF00AEA9D6 /* Subviews */ = { 166 | isa = PBXGroup; 167 | children = ( 168 | 02D07986297C876A00EAB2DE /* SignInEmailView.swift */, 169 | 02980090297CDEE200AEA9D6 /* SignInEmailViewModel.swift */, 170 | ); 171 | path = Subviews; 172 | sourceTree = ""; 173 | }; 174 | 02980092297CDEF100AEA9D6 /* Profile */ = { 175 | isa = PBXGroup; 176 | children = ( 177 | 02980093297CDF0200AEA9D6 /* ProfileView.swift */, 178 | 029800BD297F6EF200AEA9D6 /* ProfileViewModel.swift */, 179 | ); 180 | path = Profile; 181 | sourceTree = ""; 182 | }; 183 | 02980095297CE4EA00AEA9D6 /* Firestore */ = { 184 | isa = PBXGroup; 185 | children = ( 186 | 02980096297CE50A00AEA9D6 /* UserManager.swift */, 187 | 0298009D297D0EC200AEA9D6 /* ProductsManager.swift */, 188 | ); 189 | path = Firestore; 190 | sourceTree = ""; 191 | }; 192 | 0298009A297D0D0100AEA9D6 /* Products */ = { 193 | isa = PBXGroup; 194 | children = ( 195 | 0298009B297D0D0B00AEA9D6 /* ProductsView.swift */, 196 | 029800B3297E0B0D00AEA9D6 /* ProductsViewModel.swift */, 197 | 0298009F297D143000AEA9D6 /* Subviews */, 198 | ); 199 | path = Products; 200 | sourceTree = ""; 201 | }; 202 | 0298009F297D143000AEA9D6 /* Subviews */ = { 203 | isa = PBXGroup; 204 | children = ( 205 | 029800A0297D145600AEA9D6 /* ProductCellView.swift */, 206 | 029800A8297DFC6800AEA9D6 /* ProductCellViewBuilder.swift */, 207 | ); 208 | path = Subviews; 209 | sourceTree = ""; 210 | }; 211 | 029800A2297DEEEF00AEA9D6 /* Tabbar */ = { 212 | isa = PBXGroup; 213 | children = ( 214 | 029800A3297DEF0200AEA9D6 /* TabbarView.swift */, 215 | ); 216 | path = Tabbar; 217 | sourceTree = ""; 218 | }; 219 | 029800A5297DF0E700AEA9D6 /* Favorites */ = { 220 | isa = PBXGroup; 221 | children = ( 222 | 029800A6297DF0F400AEA9D6 /* FavoriteView.swift */, 223 | 029800B1297E0AF400AEA9D6 /* FavoriteViewModel.swift */, 224 | ); 225 | path = Favorites; 226 | sourceTree = ""; 227 | }; 228 | 029800AA297E0A9600AEA9D6 /* Extensions */ = { 229 | isa = PBXGroup; 230 | children = ( 231 | 029800AB297E0AA100AEA9D6 /* Query+EXT.swift */, 232 | ); 233 | path = Extensions; 234 | sourceTree = ""; 235 | }; 236 | 029800AD297E0ABE00AEA9D6 /* Components */ = { 237 | isa = PBXGroup; 238 | children = ( 239 | 029800AE297E0AC400AEA9D6 /* ViewModifiers */, 240 | ); 241 | path = Components; 242 | sourceTree = ""; 243 | }; 244 | 029800AE297E0AC400AEA9D6 /* ViewModifiers */ = { 245 | isa = PBXGroup; 246 | children = ( 247 | 029800AF297E0AD400AEA9D6 /* OnFirstAppearViewModifier.swift */, 248 | ); 249 | path = ViewModifiers; 250 | sourceTree = ""; 251 | }; 252 | 029800B5297F695E00AEA9D6 /* SecurityRules */ = { 253 | isa = PBXGroup; 254 | children = ( 255 | 029800B6297F696D00AEA9D6 /* SecurityRules.swift */, 256 | ); 257 | path = SecurityRules; 258 | sourceTree = ""; 259 | }; 260 | 029800BA297F6C9000AEA9D6 /* Storage */ = { 261 | isa = PBXGroup; 262 | children = ( 263 | 029800BB297F6CA100AEA9D6 /* StorageManager.swift */, 264 | ); 265 | path = Storage; 266 | sourceTree = ""; 267 | }; 268 | 029800C22980BA0600AEA9D6 /* Crashlytics */ = { 269 | isa = PBXGroup; 270 | children = ( 271 | 029800C32980BA1800AEA9D6 /* CrashView.swift */, 272 | 029800C82980CE2000AEA9D6 /* CrashManager.swift */, 273 | ); 274 | path = Crashlytics; 275 | sourceTree = ""; 276 | }; 277 | 029800C72980CE0D00AEA9D6 /* Performance */ = { 278 | isa = PBXGroup; 279 | children = ( 280 | 029800CE2980CFAE00AEA9D6 /* PerformanceView.swift */, 281 | ); 282 | path = Performance; 283 | sourceTree = ""; 284 | }; 285 | 02D07980297C84A900EAB2DE /* Frameworks */ = { 286 | isa = PBXGroup; 287 | children = ( 288 | 024234662983674800ABFC2A /* AdSupport.framework */, 289 | ); 290 | name = Frameworks; 291 | sourceTree = ""; 292 | }; 293 | 02D07983297C853A00EAB2DE /* Authentication */ = { 294 | isa = PBXGroup; 295 | children = ( 296 | 02D07988297C88AA00EAB2DE /* AuthenticationManager.swift */, 297 | 02D07997297CA8F900EAB2DE /* SignInGoogleHelper.swift */, 298 | 02980082297CBD7600AEA9D6 /* SignInAppleHelper.swift */, 299 | ); 300 | path = Authentication; 301 | sourceTree = ""; 302 | }; 303 | 02D07994297CA2EF00EAB2DE /* Utilities */ = { 304 | isa = PBXGroup; 305 | children = ( 306 | 02D07995297CA2FC00EAB2DE /* Utilities.swift */, 307 | 02980098297D0AD100AEA9D6 /* ProductsDatabase.swift */, 308 | ); 309 | path = Utilities; 310 | sourceTree = ""; 311 | }; 312 | 02E2BF94297C5C2C0051D19B = { 313 | isa = PBXGroup; 314 | children = ( 315 | 02E2BF9F297C5C2C0051D19B /* SwiftfulFirebaseBootcamp */, 316 | 02E2BF9E297C5C2C0051D19B /* Products */, 317 | 02D07980297C84A900EAB2DE /* Frameworks */, 318 | ); 319 | sourceTree = ""; 320 | }; 321 | 02E2BF9E297C5C2C0051D19B /* Products */ = { 322 | isa = PBXGroup; 323 | children = ( 324 | 02E2BF9D297C5C2C0051D19B /* SwiftfulFirebaseBootcamp.app */, 325 | ); 326 | name = Products; 327 | sourceTree = ""; 328 | }; 329 | 02E2BF9F297C5C2C0051D19B /* SwiftfulFirebaseBootcamp */ = { 330 | isa = PBXGroup; 331 | children = ( 332 | 02D07999297CB19C00EAB2DE /* SwiftfulFirebaseBootcamp.entitlements */, 333 | 02D07993297C9FC500EAB2DE /* Info.plist */, 334 | 02E2BFA0297C5C2C0051D19B /* SwiftfulFirebaseBootcampApp.swift */, 335 | 02E2BFA2297C5C2C0051D19B /* ContentView.swift */, 336 | 029800AD297E0ABE00AEA9D6 /* Components */, 337 | 029800AA297E0A9600AEA9D6 /* Extensions */, 338 | 02980088297CDE6800AEA9D6 /* Core */, 339 | 02D07994297CA2EF00EAB2DE /* Utilities */, 340 | 02D07983297C853A00EAB2DE /* Authentication */, 341 | 02980095297CE4EA00AEA9D6 /* Firestore */, 342 | 029800B5297F695E00AEA9D6 /* SecurityRules */, 343 | 029800BA297F6C9000AEA9D6 /* Storage */, 344 | 029800C22980BA0600AEA9D6 /* Crashlytics */, 345 | 029800C72980CE0D00AEA9D6 /* Performance */, 346 | 02423465298366FA00ABFC2A /* Analytics */, 347 | 02D0799A297CB25700EAB2DE /* GoogleService-Info.plist */, 348 | 02E2BFA4297C5C2E0051D19B /* Assets.xcassets */, 349 | 02E2BFA6297C5C2E0051D19B /* Preview Content */, 350 | ); 351 | path = SwiftfulFirebaseBootcamp; 352 | sourceTree = ""; 353 | }; 354 | 02E2BFA6297C5C2E0051D19B /* Preview Content */ = { 355 | isa = PBXGroup; 356 | children = ( 357 | 02E2BFA7297C5C2E0051D19B /* Preview Assets.xcassets */, 358 | ); 359 | path = "Preview Content"; 360 | sourceTree = ""; 361 | }; 362 | /* End PBXGroup section */ 363 | 364 | /* Begin PBXNativeTarget section */ 365 | 02E2BF9C297C5C2C0051D19B /* SwiftfulFirebaseBootcamp */ = { 366 | isa = PBXNativeTarget; 367 | buildConfigurationList = 02E2BFAB297C5C2E0051D19B /* Build configuration list for PBXNativeTarget "SwiftfulFirebaseBootcamp" */; 368 | buildPhases = ( 369 | 02E2BF99297C5C2C0051D19B /* Sources */, 370 | 02E2BF9A297C5C2C0051D19B /* Frameworks */, 371 | 02E2BF9B297C5C2C0051D19B /* Resources */, 372 | 029800C12980B84600AEA9D6 /* ShellScript */, 373 | ); 374 | buildRules = ( 375 | ); 376 | dependencies = ( 377 | ); 378 | name = SwiftfulFirebaseBootcamp; 379 | packageProductDependencies = ( 380 | 02D0797C297C815700EAB2DE /* FirebaseAnalytics */, 381 | 02D0797E297C815700EAB2DE /* FirebaseAnalyticsSwift */, 382 | 02D07981297C84A900EAB2DE /* FirebaseAuth */, 383 | 02D0798F297C9F7B00EAB2DE /* GoogleSignIn */, 384 | 02D07991297C9F7B00EAB2DE /* GoogleSignInSwift */, 385 | 02980084297CDDD900AEA9D6 /* FirebaseFirestore */, 386 | 02980086297CDDD900AEA9D6 /* FirebaseFirestoreSwift */, 387 | 029800B8297F6BF300AEA9D6 /* FirebaseStorage */, 388 | 029800BF2980B79100AEA9D6 /* FirebaseCrashlytics */, 389 | 029800C52980CE0100AEA9D6 /* FirebasePerformance */, 390 | ); 391 | productName = SwiftfulFirebaseBootcamp; 392 | productReference = 02E2BF9D297C5C2C0051D19B /* SwiftfulFirebaseBootcamp.app */; 393 | productType = "com.apple.product-type.application"; 394 | }; 395 | /* End PBXNativeTarget section */ 396 | 397 | /* Begin PBXProject section */ 398 | 02E2BF95297C5C2C0051D19B /* Project object */ = { 399 | isa = PBXProject; 400 | attributes = { 401 | BuildIndependentTargetsInParallel = 1; 402 | LastSwiftUpdateCheck = 1420; 403 | LastUpgradeCheck = 1420; 404 | TargetAttributes = { 405 | 02E2BF9C297C5C2C0051D19B = { 406 | CreatedOnToolsVersion = 14.2; 407 | }; 408 | }; 409 | }; 410 | buildConfigurationList = 02E2BF98297C5C2C0051D19B /* Build configuration list for PBXProject "SwiftfulFirebaseBootcamp" */; 411 | compatibilityVersion = "Xcode 14.0"; 412 | developmentRegion = en; 413 | hasScannedForEncodings = 0; 414 | knownRegions = ( 415 | en, 416 | Base, 417 | ); 418 | mainGroup = 02E2BF94297C5C2C0051D19B; 419 | packageReferences = ( 420 | 02D0797B297C815700EAB2DE /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, 421 | 02D0798E297C9F7B00EAB2DE /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */, 422 | ); 423 | productRefGroup = 02E2BF9E297C5C2C0051D19B /* Products */; 424 | projectDirPath = ""; 425 | projectRoot = ""; 426 | targets = ( 427 | 02E2BF9C297C5C2C0051D19B /* SwiftfulFirebaseBootcamp */, 428 | ); 429 | }; 430 | /* End PBXProject section */ 431 | 432 | /* Begin PBXResourcesBuildPhase section */ 433 | 02E2BF9B297C5C2C0051D19B /* Resources */ = { 434 | isa = PBXResourcesBuildPhase; 435 | buildActionMask = 2147483647; 436 | files = ( 437 | 02D0799B297CB25700EAB2DE /* GoogleService-Info.plist in Resources */, 438 | 02E2BFA8297C5C2E0051D19B /* Preview Assets.xcassets in Resources */, 439 | 02E2BFA5297C5C2E0051D19B /* Assets.xcassets in Resources */, 440 | ); 441 | runOnlyForDeploymentPostprocessing = 0; 442 | }; 443 | /* End PBXResourcesBuildPhase section */ 444 | 445 | /* Begin PBXShellScriptBuildPhase section */ 446 | 029800C12980B84600AEA9D6 /* ShellScript */ = { 447 | isa = PBXShellScriptBuildPhase; 448 | buildActionMask = 2147483647; 449 | files = ( 450 | ); 451 | inputFileListPaths = ( 452 | ); 453 | inputPaths = ( 454 | "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}", 455 | "$(SRCROOT)/$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", 456 | ); 457 | outputFileListPaths = ( 458 | ); 459 | outputPaths = ( 460 | ); 461 | runOnlyForDeploymentPostprocessing = 0; 462 | shellPath = /bin/sh; 463 | shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\n"; 464 | }; 465 | /* End PBXShellScriptBuildPhase section */ 466 | 467 | /* Begin PBXSourcesBuildPhase section */ 468 | 02E2BF99297C5C2C0051D19B /* Sources */ = { 469 | isa = PBXSourcesBuildPhase; 470 | buildActionMask = 2147483647; 471 | files = ( 472 | 0298008E297CDEBE00AEA9D6 /* AuthenticationViewModel.swift in Sources */, 473 | 02D0798D297C8EE000EAB2DE /* SettingsView.swift in Sources */, 474 | 029800BE297F6EF200AEA9D6 /* ProfileViewModel.swift in Sources */, 475 | 029800A1297D145600AEA9D6 /* ProductCellView.swift in Sources */, 476 | 029800BC297F6CA100AEA9D6 /* StorageManager.swift in Sources */, 477 | 02E2BFA3297C5C2C0051D19B /* ContentView.swift in Sources */, 478 | 02980097297CE50A00AEA9D6 /* UserManager.swift in Sources */, 479 | 02E2BFA1297C5C2C0051D19B /* SwiftfulFirebaseBootcampApp.swift in Sources */, 480 | 02D0798B297C8CC400EAB2DE /* RootView.swift in Sources */, 481 | 029800C92980CE2000AEA9D6 /* CrashManager.swift in Sources */, 482 | 02980091297CDEE200AEA9D6 /* SignInEmailViewModel.swift in Sources */, 483 | 029800B2297E0AF400AEA9D6 /* FavoriteViewModel.swift in Sources */, 484 | 0298009E297D0EC200AEA9D6 /* ProductsManager.swift in Sources */, 485 | 02D07998297CA8F900EAB2DE /* SignInGoogleHelper.swift in Sources */, 486 | 02D07985297C855700EAB2DE /* AuthenticationView.swift in Sources */, 487 | 02D07989297C88AA00EAB2DE /* AuthenticationManager.swift in Sources */, 488 | 02980099297D0AD100AEA9D6 /* ProductsDatabase.swift in Sources */, 489 | 02D07996297CA2FC00EAB2DE /* Utilities.swift in Sources */, 490 | 0298009C297D0D0B00AEA9D6 /* ProductsView.swift in Sources */, 491 | 029800B4297E0B0D00AEA9D6 /* ProductsViewModel.swift in Sources */, 492 | 0298008B297CDE9700AEA9D6 /* SettingsViewModel.swift in Sources */, 493 | 029800C42980BA1800AEA9D6 /* CrashView.swift in Sources */, 494 | 029800A7297DF0F400AEA9D6 /* FavoriteView.swift in Sources */, 495 | 02980083297CBD7600AEA9D6 /* SignInAppleHelper.swift in Sources */, 496 | 029800B0297E0AD400AEA9D6 /* OnFirstAppearViewModifier.swift in Sources */, 497 | 029800B7297F696D00AEA9D6 /* SecurityRules.swift in Sources */, 498 | 029800A9297DFC6800AEA9D6 /* ProductCellViewBuilder.swift in Sources */, 499 | 024234692983675800ABFC2A /* AnalyticsView.swift in Sources */, 500 | 02980094297CDF0200AEA9D6 /* ProfileView.swift in Sources */, 501 | 029800A4297DEF0200AEA9D6 /* TabbarView.swift in Sources */, 502 | 029800CF2980CFAE00AEA9D6 /* PerformanceView.swift in Sources */, 503 | 02D07987297C876A00EAB2DE /* SignInEmailView.swift in Sources */, 504 | 029800AC297E0AA100AEA9D6 /* Query+EXT.swift in Sources */, 505 | ); 506 | runOnlyForDeploymentPostprocessing = 0; 507 | }; 508 | /* End PBXSourcesBuildPhase section */ 509 | 510 | /* Begin XCBuildConfiguration section */ 511 | 02E2BFA9297C5C2E0051D19B /* Debug */ = { 512 | isa = XCBuildConfiguration; 513 | buildSettings = { 514 | ALWAYS_SEARCH_USER_PATHS = NO; 515 | CLANG_ANALYZER_NONNULL = YES; 516 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 517 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 518 | CLANG_ENABLE_MODULES = YES; 519 | CLANG_ENABLE_OBJC_ARC = YES; 520 | CLANG_ENABLE_OBJC_WEAK = YES; 521 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 522 | CLANG_WARN_BOOL_CONVERSION = YES; 523 | CLANG_WARN_COMMA = YES; 524 | CLANG_WARN_CONSTANT_CONVERSION = YES; 525 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 526 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 527 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 528 | CLANG_WARN_EMPTY_BODY = YES; 529 | CLANG_WARN_ENUM_CONVERSION = YES; 530 | CLANG_WARN_INFINITE_RECURSION = YES; 531 | CLANG_WARN_INT_CONVERSION = YES; 532 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 533 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 534 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 535 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 536 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 537 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 538 | CLANG_WARN_STRICT_PROTOTYPES = YES; 539 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 540 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 541 | CLANG_WARN_UNREACHABLE_CODE = YES; 542 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 543 | COPY_PHASE_STRIP = NO; 544 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 545 | ENABLE_STRICT_OBJC_MSGSEND = YES; 546 | ENABLE_TESTABILITY = YES; 547 | GCC_C_LANGUAGE_STANDARD = gnu11; 548 | GCC_DYNAMIC_NO_PIC = NO; 549 | GCC_NO_COMMON_BLOCKS = YES; 550 | GCC_OPTIMIZATION_LEVEL = 0; 551 | GCC_PREPROCESSOR_DEFINITIONS = ( 552 | "DEBUG=1", 553 | "$(inherited)", 554 | ); 555 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 556 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 557 | GCC_WARN_UNDECLARED_SELECTOR = YES; 558 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 559 | GCC_WARN_UNUSED_FUNCTION = YES; 560 | GCC_WARN_UNUSED_VARIABLE = YES; 561 | IPHONEOS_DEPLOYMENT_TARGET = 16.2; 562 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 563 | MTL_FAST_MATH = YES; 564 | ONLY_ACTIVE_ARCH = YES; 565 | SDKROOT = iphoneos; 566 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 567 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 568 | }; 569 | name = Debug; 570 | }; 571 | 02E2BFAA297C5C2E0051D19B /* Release */ = { 572 | isa = XCBuildConfiguration; 573 | buildSettings = { 574 | ALWAYS_SEARCH_USER_PATHS = NO; 575 | CLANG_ANALYZER_NONNULL = YES; 576 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 577 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 578 | CLANG_ENABLE_MODULES = YES; 579 | CLANG_ENABLE_OBJC_ARC = YES; 580 | CLANG_ENABLE_OBJC_WEAK = YES; 581 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 582 | CLANG_WARN_BOOL_CONVERSION = YES; 583 | CLANG_WARN_COMMA = YES; 584 | CLANG_WARN_CONSTANT_CONVERSION = YES; 585 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 586 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 587 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 588 | CLANG_WARN_EMPTY_BODY = YES; 589 | CLANG_WARN_ENUM_CONVERSION = YES; 590 | CLANG_WARN_INFINITE_RECURSION = YES; 591 | CLANG_WARN_INT_CONVERSION = YES; 592 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 593 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 594 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 595 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 596 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 597 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 598 | CLANG_WARN_STRICT_PROTOTYPES = YES; 599 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 600 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 601 | CLANG_WARN_UNREACHABLE_CODE = YES; 602 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 603 | COPY_PHASE_STRIP = NO; 604 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 605 | ENABLE_NS_ASSERTIONS = NO; 606 | ENABLE_STRICT_OBJC_MSGSEND = YES; 607 | GCC_C_LANGUAGE_STANDARD = gnu11; 608 | GCC_NO_COMMON_BLOCKS = YES; 609 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 610 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 611 | GCC_WARN_UNDECLARED_SELECTOR = YES; 612 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 613 | GCC_WARN_UNUSED_FUNCTION = YES; 614 | GCC_WARN_UNUSED_VARIABLE = YES; 615 | IPHONEOS_DEPLOYMENT_TARGET = 16.2; 616 | MTL_ENABLE_DEBUG_INFO = NO; 617 | MTL_FAST_MATH = YES; 618 | SDKROOT = iphoneos; 619 | SWIFT_COMPILATION_MODE = wholemodule; 620 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 621 | VALIDATE_PRODUCT = YES; 622 | }; 623 | name = Release; 624 | }; 625 | 02E2BFAC297C5C2E0051D19B /* Debug */ = { 626 | isa = XCBuildConfiguration; 627 | buildSettings = { 628 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 629 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 630 | CODE_SIGN_ENTITLEMENTS = SwiftfulFirebaseBootcamp/SwiftfulFirebaseBootcamp.entitlements; 631 | CODE_SIGN_STYLE = Automatic; 632 | CURRENT_PROJECT_VERSION = 1; 633 | DEVELOPMENT_ASSET_PATHS = "\"SwiftfulFirebaseBootcamp/Preview Content\""; 634 | DEVELOPMENT_TEAM = 8DFK3NVZ4W; 635 | ENABLE_PREVIEWS = YES; 636 | GENERATE_INFOPLIST_FILE = YES; 637 | INFOPLIST_FILE = SwiftfulFirebaseBootcamp/Info.plist; 638 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 639 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 640 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 641 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 642 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 643 | LD_RUNPATH_SEARCH_PATHS = ( 644 | "$(inherited)", 645 | "@executable_path/Frameworks", 646 | ); 647 | MARKETING_VERSION = 1.1; 648 | PRODUCT_BUNDLE_IDENTIFIER = com.tribesocial.SwiftfulFirebaseBootcamp; 649 | PRODUCT_NAME = "$(TARGET_NAME)"; 650 | SWIFT_EMIT_LOC_STRINGS = YES; 651 | SWIFT_VERSION = 5.0; 652 | TARGETED_DEVICE_FAMILY = "1,2"; 653 | }; 654 | name = Debug; 655 | }; 656 | 02E2BFAD297C5C2E0051D19B /* Release */ = { 657 | isa = XCBuildConfiguration; 658 | buildSettings = { 659 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 660 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 661 | CODE_SIGN_ENTITLEMENTS = SwiftfulFirebaseBootcamp/SwiftfulFirebaseBootcamp.entitlements; 662 | CODE_SIGN_STYLE = Automatic; 663 | CURRENT_PROJECT_VERSION = 1; 664 | DEVELOPMENT_ASSET_PATHS = "\"SwiftfulFirebaseBootcamp/Preview Content\""; 665 | DEVELOPMENT_TEAM = 8DFK3NVZ4W; 666 | ENABLE_PREVIEWS = YES; 667 | GENERATE_INFOPLIST_FILE = YES; 668 | INFOPLIST_FILE = SwiftfulFirebaseBootcamp/Info.plist; 669 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 670 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 671 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 672 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 673 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 674 | LD_RUNPATH_SEARCH_PATHS = ( 675 | "$(inherited)", 676 | "@executable_path/Frameworks", 677 | ); 678 | MARKETING_VERSION = 1.1; 679 | PRODUCT_BUNDLE_IDENTIFIER = com.tribesocial.SwiftfulFirebaseBootcamp; 680 | PRODUCT_NAME = "$(TARGET_NAME)"; 681 | SWIFT_EMIT_LOC_STRINGS = YES; 682 | SWIFT_VERSION = 5.0; 683 | TARGETED_DEVICE_FAMILY = "1,2"; 684 | }; 685 | name = Release; 686 | }; 687 | /* End XCBuildConfiguration section */ 688 | 689 | /* Begin XCConfigurationList section */ 690 | 02E2BF98297C5C2C0051D19B /* Build configuration list for PBXProject "SwiftfulFirebaseBootcamp" */ = { 691 | isa = XCConfigurationList; 692 | buildConfigurations = ( 693 | 02E2BFA9297C5C2E0051D19B /* Debug */, 694 | 02E2BFAA297C5C2E0051D19B /* Release */, 695 | ); 696 | defaultConfigurationIsVisible = 0; 697 | defaultConfigurationName = Release; 698 | }; 699 | 02E2BFAB297C5C2E0051D19B /* Build configuration list for PBXNativeTarget "SwiftfulFirebaseBootcamp" */ = { 700 | isa = XCConfigurationList; 701 | buildConfigurations = ( 702 | 02E2BFAC297C5C2E0051D19B /* Debug */, 703 | 02E2BFAD297C5C2E0051D19B /* Release */, 704 | ); 705 | defaultConfigurationIsVisible = 0; 706 | defaultConfigurationName = Release; 707 | }; 708 | /* End XCConfigurationList section */ 709 | 710 | /* Begin XCRemoteSwiftPackageReference section */ 711 | 02D0797B297C815700EAB2DE /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { 712 | isa = XCRemoteSwiftPackageReference; 713 | repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; 714 | requirement = { 715 | kind = exactVersion; 716 | version = 10.4.0; 717 | }; 718 | }; 719 | 02D0798E297C9F7B00EAB2DE /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = { 720 | isa = XCRemoteSwiftPackageReference; 721 | repositoryURL = "https://github.com/google/GoogleSignIn-iOS.git"; 722 | requirement = { 723 | kind = upToNextMajorVersion; 724 | minimumVersion = 7.0.0; 725 | }; 726 | }; 727 | /* End XCRemoteSwiftPackageReference section */ 728 | 729 | /* Begin XCSwiftPackageProductDependency section */ 730 | 02980084297CDDD900AEA9D6 /* FirebaseFirestore */ = { 731 | isa = XCSwiftPackageProductDependency; 732 | package = 02D0797B297C815700EAB2DE /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; 733 | productName = FirebaseFirestore; 734 | }; 735 | 02980086297CDDD900AEA9D6 /* FirebaseFirestoreSwift */ = { 736 | isa = XCSwiftPackageProductDependency; 737 | package = 02D0797B297C815700EAB2DE /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; 738 | productName = FirebaseFirestoreSwift; 739 | }; 740 | 029800B8297F6BF300AEA9D6 /* FirebaseStorage */ = { 741 | isa = XCSwiftPackageProductDependency; 742 | package = 02D0797B297C815700EAB2DE /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; 743 | productName = FirebaseStorage; 744 | }; 745 | 029800BF2980B79100AEA9D6 /* FirebaseCrashlytics */ = { 746 | isa = XCSwiftPackageProductDependency; 747 | package = 02D0797B297C815700EAB2DE /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; 748 | productName = FirebaseCrashlytics; 749 | }; 750 | 029800C52980CE0100AEA9D6 /* FirebasePerformance */ = { 751 | isa = XCSwiftPackageProductDependency; 752 | package = 02D0797B297C815700EAB2DE /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; 753 | productName = FirebasePerformance; 754 | }; 755 | 02D0797C297C815700EAB2DE /* FirebaseAnalytics */ = { 756 | isa = XCSwiftPackageProductDependency; 757 | package = 02D0797B297C815700EAB2DE /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; 758 | productName = FirebaseAnalytics; 759 | }; 760 | 02D0797E297C815700EAB2DE /* FirebaseAnalyticsSwift */ = { 761 | isa = XCSwiftPackageProductDependency; 762 | package = 02D0797B297C815700EAB2DE /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; 763 | productName = FirebaseAnalyticsSwift; 764 | }; 765 | 02D07981297C84A900EAB2DE /* FirebaseAuth */ = { 766 | isa = XCSwiftPackageProductDependency; 767 | package = 02D0797B297C815700EAB2DE /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; 768 | productName = FirebaseAuth; 769 | }; 770 | 02D0798F297C9F7B00EAB2DE /* GoogleSignIn */ = { 771 | isa = XCSwiftPackageProductDependency; 772 | package = 02D0798E297C9F7B00EAB2DE /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; 773 | productName = GoogleSignIn; 774 | }; 775 | 02D07991297C9F7B00EAB2DE /* GoogleSignInSwift */ = { 776 | isa = XCSwiftPackageProductDependency; 777 | package = 02D0798E297C9F7B00EAB2DE /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; 778 | productName = GoogleSignInSwift; 779 | }; 780 | /* End XCSwiftPackageProductDependency section */ 781 | }; 782 | rootObject = 02E2BF95297C5C2C0051D19B /* Project object */; 783 | } 784 | --------------------------------------------------------------------------------