article

Wednesday, October 13, 2021

SwiftUI Firebase Booklist - Product List, Product Details and Checkout

SwiftUI Firebase Booklist - Product List, Product Details and Checkout
Learn step by step how to create a SwiftUI Firebase Booklist, SwiftUI and Combine, This is the MVVM architecture

https://github.com/firebase/firebase-ios-sdk.git

https://github.com/SDWebImage/SDWebImageSwiftUI.git

ContentView.swift
 
//
//  ContentView.swift
//  DevSwiftUI
//
//  Created by Cairocoders
//

import SwiftUI
import Firebase
import SDWebImageSwiftUI

struct ContentView: View {
     
    @StateObject var viewModel = BooksViewModel()
    @State var presentAddBookSheet = false
     
    private func bookRowView(book: Book) -> some View {
      NavigationLink(destination: BookDetailsView(book: book)) {
          HStack {

              AnimatedImage(url: URL(string: book.image)!).resizable().frame(width: 70, height: 110)
                  .aspectRatio(contentMode: .fit)
                  .clipShape(RoundedRectangle(cornerRadius: 10))
                  .shadow(color: .gray, radius: 10, x: 5, y: 5)
                  .padding()
              
              VStack(alignment: .leading) {
                  Text(book.title).font(.headline)
                  Text("by " + book.author).font(.subheadline).foregroundColor(.gray)
                  Spacer().frame(height: 15)
                  Text("$" + String(book.price)).font(.title)
              }
          }
      }
    }
     
    var body: some View {
      NavigationView {
        List {
          ForEach (viewModel.books) { book in
            bookRowView(book: book)
          }
        }
        .navigationBarTitle("Book List")
        .onAppear() {
          print("BooksListView appears. Subscribing to data updates.")
          self.viewModel.subscribe()
        }
      }
    }
  }
 
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
Book.swift
 
//
//  Book.swift
//  DevSwiftUI
//
//  Created by Cairocoders
//

import Foundation
import FirebaseFirestoreSwift
import Combine

struct Book: Identifiable, Codable {
  @DocumentID var id: String?
  var title: String
  var author: String
  var numberOfPages: Int
  var image: String
  var price: Double
  var description: String
  var isAvailable: Bool
    
  enum CodingKeys: String, CodingKey {
    case id
    case title
    case author
    case numberOfPages = "pages"
    case image
    case price
    case description
    case isAvailable
  }
}

struct Order: Identifiable, Codable {
  @DocumentID var id: String?
  var username: String
  var title: String
  var price: Double
  var image: String
}
BookViewModel.swift
 
//
//  BookViewModel.swift
//  DevSwiftUI
//
//  Created by Cairocoders
//

import Foundation
import Combine
import FirebaseFirestore

class BookViewModel: ObservableObject {
   
  @Published var book: Book
  @Published var modified = false
   
  private var cancellables = Set<AnyCancellable>()
   
    init(book: Book = Book(title: "", author: "", numberOfPages: 0, image: "", price: 0, description: "", isAvailable: false)) {
    self.book = book
     
    self.$book
      .dropFirst()
      .sink { [weak self] book in
        self?.modified = true
      }
      .store(in: &self.cancellables)
  }
}
BooksViewModel.swift
 
//
//  BooksViewModel.swift
//  DevSwiftUI
//
//  Created by Cairocoders
//

import Foundation
import Combine
import FirebaseFirestore
 
class BooksViewModel: ObservableObject {
  @Published var books = [Book]()
   
  private var db = Firestore.firestore()
  private var listenerRegistration: ListenerRegistration?
   
  deinit {
    unsubscribe()
  }
   
  func unsubscribe() {
    if listenerRegistration != nil {
      listenerRegistration?.remove()
      listenerRegistration = nil
    }
  }
   
  func subscribe() {
    if listenerRegistration == nil {
      listenerRegistration = db.collection("books").addSnapshotListener { (querySnapshot, error) in
        guard let documents = querySnapshot?.documents else {
          print("No documents")
          return
        }
         
        self.books = documents.compactMap { queryDocumentSnapshot in
          try? queryDocumentSnapshot.data(as: Book.self)
        }
      }
    }
  }
   
  func removeBooks(atOffsets indexSet: IndexSet) {
    let books = indexSet.lazy.map { self.books[$0] }
    books.forEach { book in
      if let documentId = book.id {
        db.collection("books").document(documentId).delete { error in
          if let error = error {
            print("Unable to remove document: \(error.localizedDescription)")
          }
        }
      }
    }
  }
 
   
}
BookDetailsView.swift
 
//
//  BookDetailsView.swift
//  DevSwiftUI
//
//  Created by Cairocoders
//

import SwiftUI
import SDWebImageSwiftUI
 
struct BookDetailsView: View {
   
  @ObservedObject var viewModel = BookViewModel()
  @ObservedObject var viewModelOrder = OrderViewModel()
    
  @State private var showModal = false
  @State private var showAlert = false
  @State private var showModalbookreading = false
    
  var book: Book
   
  var body: some View {
      VStack {

          AnimatedImage(url: URL(string: book.image)!)
              .resizable()
              .frame(width: 180, height: 280)
              .clipShape(RoundedRectangle(cornerRadius: 10))
              .shadow(color: .gray, radius: 10, x: 5, y: 5)
          
          Spacer()
              .frame(height: 30)

          Text(book.author)
              .foregroundColor(.gray)

          Text(book.title)
              .font(.system(size: 24, weight: .semibold))
              .padding([.leading, .trailing], 20)


          Spacer()
              .frame(height: 20)

          Text(book.description)
              .lineLimit(4)
              .padding([.leading, .trailing], 20)
              .lineSpacing(6)
              .foregroundColor(.gray)

          Spacer()
              .frame(height: 20)
          

          Divider()
            .padding(.bottom, 30)
            .padding([.leading, .trailing], 20)
          
          if book.isAvailable {
              // Read button
              Button(action: { self.showModalbookreading = true }) {
                  HStack {
                      Text("Read")
                          .fontWeight(.semibold)
                  }
                  .frame(width: 200)
                  .padding()
                  .foregroundColor(.white)
                  .background(Color.green)
                  .cornerRadius(40)
              }
              .sheet(isPresented: self.$showModalbookreading) {
                  BookReadingView(titleBook: book.title)
              }
          }else{
              // Add button
              Button(action: {
                  self.showAlert = true
                  self.handleOrderTapped()
              }) {
                  HStack {
                      Text("Buy for " + String(book.price) + "$")
                          .fontWeight(.semibold)
                  }
                  .frame(width: 200)
                  .padding()
                  .foregroundColor(.white)
                  .background(Color.black)
                  .cornerRadius(40)
              }
              .alert(isPresented: $showAlert) {
                  Alert(title: Text("Book added to cart"), message: Text("You're ready to proceed to checkout and complete your order."), dismissButton: .default(Text("Done")))
              }
          }
      }
      // NavBar item - Checkout button
      .navigationBarItems(trailing:
          Button(action: {
              self.showModal = true
          }) {
              CartButtonView(numberOfItems: total())
          }.sheet(isPresented: self.$showModal, onDismiss: {  }) {
              CartView(showModal: self.$showModal)
          })
      .navigationBarTitle(Text(""), displayMode: .inline)
      .onAppear() {
        print("BookDetailsView.onAppear() for \(self.book.title)")
      }
      .onDisappear() {
        print("BookDetailsView.onDisappear()")
      }
  }
   
    func handleOrderTapped() {
        self.viewModelOrder.handleOrderTapped(username: "Cairocoders", title: book.title, price: book.price, image: book.image)
    }
    func total() -> Int {
        let totalprice = 3
        return totalprice
    }
}
 
struct BookDetailsView_Previews: PreviewProvider {
  static var previews: some View {
      let book = Book(title: "Coder", author: "Cairocoders", numberOfPages: 23, image: "photo1", price: 12.5, description: "Book Description", isAvailable: false)
    return
      NavigationView {
        BookDetailsView(book: book)
      }
  }
}
CartButtonView.swift
 
//
//  CartButtonView.swift
//  DevSwiftUI
//
//  Created by Cairocoders
//

import SwiftUI

struct CartButtonView: View {
    var numberOfItems: Int

    var body: some View {
        VStack {
            Image(systemName: "cart")
                .resizable()
                .scaledToFit()
                .frame(width: 32, height: 32, alignment: .center)
                .foregroundColor(.green)
                .overlay(ImageOverlay(numberOfItems: numberOfItems), alignment: .center)
            Spacer()
        }
    }

    struct ImageOverlay: View {
        var numberOfItems: Int

        var body: some View {
            ZStack {
                Text(String(numberOfItems))
                    .font(.system(size: 22))
                    .fontWeight(.bold)
                    .foregroundColor(.black)
                    .padding(5)
            }
        }
    }
}

struct CartButtonView_Previews: PreviewProvider {
    static var previews: some View {
        CartButtonView(numberOfItems: 3)
    }
}
CartView.swift
 
//
//  CartView.swift
//  DevSwiftUI
//
//  Created by Cairocoders
//

import SwiftUI

struct CartView: View {
    
    @Binding var showModal: Bool

    @State private var showingAlert = false
    
    @StateObject var viewModel = OrdersViewModel()
    var body: some View {
        ScrollView(.vertical) {
            VStack {
                // Dismiss button
                HStack() {
                    Image(systemName: "multiply") //Close
                    .resizable()
                    .frame(width: 25, height: 25)
                    .padding(20)

                    Spacer()
                }
                
                // Title
                VStack {
                    Text("Your bag")
                        .font(.system(size: 34))
                        .fontWeight(.bold)
                    Text(String(viewModel.orders.count) + " items")
                        .font(.system(size: 18))
                        .foregroundColor(.gray)
                }

                // Item list
                VStack(alignment: .leading) {
                    ForEach (viewModel.orders) { items in
                        CartRow(title: items.title , image: items.image, price: Int(items.price))
                    }
                }
                .onAppear() {
                  print("Order appears.")
                  self.viewModel.subscribe()
                }

                Spacer().frame(height: 20)

                // Summary
                HStack {
                    VStack {
                        Image(systemName: "shippingbox")
                            .resizable()
                            .frame(width: 32, height: 32)
                            .padding(.bottom, -8)
                        Text("FREE")
                            .font(.system(size: 16))
                            .fontWeight(.bold)
                            .padding(.bottom, 5)
                    }.frame(width: 64, height: 64)
                        .background(Color.gray.opacity(0.4))
                        .cornerRadius(15)

                    Spacer().frame(width: 40)

                    VStack(alignment: .leading) {
                        Text("Total:")
                            .font(.system(size: 18))
                            .foregroundColor(.gray)
                        Text("$" + "25")
                            .font(.system(size: 34))
                            .fontWeight(.bold)
                    }

                    Spacer().frame(width: 80)
                }

                // Checkout button
                Divider().padding()

                Button(action: {
                    self.showingAlert = true
                }) {
                    HStack {
                        Text("Checkout")
                            .font(.system(size: 18))
                            .fontWeight(.bold)
                    }
                    .frame(width: 200)
                    .padding()
                    .foregroundColor(.white)
                    .background(Color.yellow)
                    .cornerRadius(40)
                }
                .alert(isPresented: $showingAlert) {
                    Alert(title: Text("Order confirmed"),
                          message: Text("Thank you for your purchase."),
                          dismissButton: .default(Text("Done")) {
                            self.showModal.toggle()
                    })
                }
            }
        }
    }
}
CartRow.swift
 
//
//  CartRow.swift
//  DevSwiftUI
//
//  Created by Cairocoders
//

import SwiftUI
import SDWebImageSwiftUI

struct CartRow: View {
    
    var id = ""
    var title = ""
    var image = ""
    var price = 0
    @State var presentActionSheet = false
    
    var body: some View {
        HStack {

            AnimatedImage(url: URL(string: image)!)
                .resizable()
                .frame(width: 80, height: 100)
                .clipShape(RoundedRectangle(cornerRadius: 10))
                .shadow(color: .gray, radius: 10, x: 5, y: 5)
                .padding()
            
            VStack(alignment: .leading) {
                Text(title)
                Spacer().frame(height: 15)
                Text("$" + String(price)).font(.system(size: 18)).bold()
            }.padding([.top, .bottom])
                .frame(width: 150)
            
            Text("x" + String("1"))
                .fontWeight(.semibold)
                .padding(10)
                .overlay(
                    RoundedRectangle(cornerRadius: 20)
                        .stroke(Color.gray, lineWidth: 1)
                )
        
                .padding(.leading, 20)
            
        }
        .actionSheet(isPresented: $presentActionSheet) {
          ActionSheet(title: Text("Are you sure?"),
                      buttons: [
                        .destructive(Text("Delete book"),
                                     action: {  }),
                        .cancel()
                      ])
        }
    }
}

struct CartRow_Previews: PreviewProvider {
    static var previews: some View {
        CartRow(title: "Cairocoders", image: "hello", price: 55)
    }
}
OrderModel.swift
 
//
//  OrderModel.swift
//  DevSwiftUI
//
//  Created by Cairocoders
//

import Foundation
import Combine
import FirebaseFirestore

class OrderViewModel: ObservableObject {
   
  @Published var order: Order
  @Published var modified = false
   
  private var cancellables = Set<AnyCancellable>()
   
  init(order: Order = Order(username: "", title: "", price: 0, image: "")) {
    self.order = order
     
    self.$order
      .dropFirst()
      .sink { [weak self] order in
        self?.modified = true
      }
      .store(in: &self.cancellables)
  }
    
    private var db = Firestore.firestore()
     
    func handleOrderTapped(username: String, title: String, price: Double, image: String) {
          db.collection("order").addDocument(data: ["username": username, "title":title, "price":price, "image": image]) { (err) in
            if err != nil {
                print((err?.localizedDescription) ?? "Error")
                return
            }
        }
    }
    
}
BookReadingView.swift
//
//  BookReadingView.swift
//  DevSwiftUI
//
//  Created by Cairocoders
//

import SwiftUI

struct BookReadingView: View {
    var titleBook: String

    var body: some View {
        VStack {
            Text(titleBook)
            .font(.largeTitle)

            Divider()
                .padding(.top, 1)
                .padding([.leading, .trailing], 20)
                .padding(.bottom, 20)

            Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent gravida elit vitae quam consequat ullamcorper. Vestibulum turpis est, congue ut posuere et, sollicitudin ac neque. Fusce nec tellus arcu. Nunc consequat lacus et dui vestibulum maximus. In lobortis nibh a facilisis aliquam. Proin eget ornare urna. Aliquam at malesuada massa, vitae accumsan eros. Vestibulum nec dui scelerisque enim rhoncus suscipit. Fusce a magna odio. Vestibulum egestas in tellus a porttitor. Nunc pellentesque, augue eu dignissim ullamcorper, magna eros semper nulla, at rutrum lorem est ut lectus. Nunc id libero lectus. Nunc egestas velit eu quam finibus, et convallis ipsum volutpat. Praesent sodales ultricies nunc, vel molestie quam ornare in. Pellentesque nec commodo est, in commodo nulla. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.")
            .padding(30)
        }
    }
}

struct BookReadingView_Previews: PreviewProvider {
    static var previews: some View {
        BookReadingView(titleBook:"The two towers")
    }
}
OrderViewModel.swift
//
//  OrderViewModel.swift
//  DevSwiftUI
//
//  Created by Cairocoders
//

import Foundation
import Combine
import FirebaseFirestore
 
class OrdersViewModel: ObservableObject {
  @Published var orders = [Order]()
   
  private var db = Firestore.firestore()
  private var listenerRegistration: ListenerRegistration?
   
  deinit {
    unsubscribe()
  }
   
  func unsubscribe() {
    if listenerRegistration != nil {
      listenerRegistration?.remove()
      listenerRegistration = nil
    }
  }
   
  func subscribe() {
    if listenerRegistration == nil {
      listenerRegistration = db.collection("order").addSnapshotListener { (querySnapshot, error) in
        guard let documents = querySnapshot?.documents else {
          print("No documents")
          return
        }
         
        self.orders = documents.compactMap { queryDocumentSnapshot in
          try? queryDocumentSnapshot.data(as: Order.self)
        }
      }
    }
  }
   
  func removeOrders(atOffsets indexSet: IndexSet) {
    let orders = indexSet.lazy.map { self.orders[$0] }
    orders.forEach { order in
      if let documentId = order.id {
        db.collection("order").document(documentId).delete { error in
          if let error = error {
            print("Unable to remove document: \(error.localizedDescription)")
          }
        }
      }
    }
  }
   
}
DevSwiftUIApp.swift
//
//  DevSwiftUIApp.swift
//  DevSwiftUI
//
//  Created by Cairocoders
//

import SwiftUI
import Firebase

@main
struct DevSwiftUIApp: App {
    
    @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
     
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
 
class AppDelegate: NSObject,UIApplicationDelegate{
     
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
         
        FirebaseApp.configure()
        return true
    }
}

Related Post