├── 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 |
--------------------------------------------------------------------------------