Skip to content

Commit

Permalink
Create Product Sample app (#121)
Browse files Browse the repository at this point in the history
* Create Product Sample app

* Use dependency container

* Add logger

* Working on image upload

* Fix image download

* Swipe to delete

* Add missing use cases

* Build ProductSample app on build-example job

* Organize sample app

* Start adding auth

* Fix GoTrueClient memory leaks, fix listening for auth changes

* Move models to specific files

* Send owner_id when creating product

* Move Info.plist file
  • Loading branch information
grdsdev authored Oct 23, 2023
1 parent 7085cd9 commit 54b2a1c
Show file tree
Hide file tree
Showing 47 changed files with 1,892 additions and 240 deletions.
366 changes: 365 additions & 1 deletion Examples/Examples.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Examples/ProductSample/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Config.plist
115 changes: 115 additions & 0 deletions Examples/ProductSample/Application/AppView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//
// AppView.swift
// ProductSample
//
// Created by Guilherme Souza on 18/10/23.
//

import OSLog
import SwiftUI

@MainActor
final class AppViewModel: ObservableObject {
private let logger = Logger.make(category: "AppViewModel")
private let authenticationRepository: AuthenticationRepository

enum AuthState {
case authenticated(ProductListViewModel)
case notAuthenticated(AuthViewModel)
}

@Published var addProductRoute: AddProductRoute?
@Published var authState: AuthState?

private var authStateListenerTask: Task<Void, Never>?

init(authenticationRepository: AuthenticationRepository = Dependencies.authenticationRepository) {
self.authenticationRepository = authenticationRepository

authStateListenerTask = Task {
for await state in authenticationRepository.authStateListener {
logger.debug("auth state changed: \(String(describing: state))")

if Task.isCancelled {
logger.debug("auth state task cancelled, returning.")
return
}

self.authState =
switch state {
case .signedIn: .authenticated(.init())
case .signedOut: .notAuthenticated(.init())
}
}
}
}

deinit {
authStateListenerTask?.cancel()
}

func productDetailViewModel(with productId: String?) -> ProductDetailsViewModel {
ProductDetailsViewModel(productId: productId) { [weak self] updated in
Task {
if case let .authenticated(model) = self?.authState {
await model.loadProducts()
}
}
}
}

func signOutButtonTapped() async {
await authenticationRepository.signOut()
}
}

struct AppView: View {
@StateObject var model = AppViewModel()

var body: some View {
switch model.authState {
case .authenticated(let model):
authenticatedView(model: model)
case .notAuthenticated(let model):
notAuthenticatedView(model: model)
case .none:
ProgressView()
}
}

func authenticatedView(model: ProductListViewModel) -> some View {
NavigationStack {
ProductListView(model: model)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Sign out") {
Task { await self.model.signOutButtonTapped() }
}
}
ToolbarItem(placement: .primaryAction) {
Button {
self.model.addProductRoute = .init()
} label: {
Label("Add", systemImage: "plus")
}
}
}
.navigationDestination(for: ProductDetailRoute.self) { route in
ProductDetailsView(model: self.model.productDetailViewModel(with: route.productId))
}
}
.sheet(item: self.$model.addProductRoute) { _ in
NavigationStack {
ProductDetailsView(model: self.model.productDetailViewModel(with: nil))
}
}
}

func notAuthenticatedView(model: AuthViewModel) -> some View {
AuthView(model: model)
}
}

#Preview {
AppView()
}
58 changes: 58 additions & 0 deletions Examples/ProductSample/Application/Dependencies.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// Dependencies.swift
// ProductSample
//
// Created by Guilherme Souza on 19/10/23.
//

import Foundation
import Supabase

enum Dependencies {
static let supabase = SupabaseClient(
supabaseURL: URL(string: Config.SUPABASE_URL)!,
supabaseKey: Config.SUPABASE_ANON_KEY
)

// MARK: Repositories

static let productRepository: ProductRepository = ProductRepositoryImpl(supabase: supabase)
static let productImageStorageRepository: ProductImageStorageRepository =
ProductImageStorageRepositoryImpl(storage: supabase.storage)
static let authenticationRepository: AuthenticationRepository = AuthenticationRepositoryImpl(
client: supabase.auth
)

// MARK: Use Cases

static let updateProductUseCase: any UpdateProductUseCase = UpdateProductUseCaseImpl(
productRepository: productRepository,
productImageStorageRepository: productImageStorageRepository
)

static let createProductUseCase: any CreateProductUseCase = CreateProductUseCaseImpl(
productRepository: productRepository,
productImageStorageRepository: productImageStorageRepository,
authenticationRepository: authenticationRepository
)

static let getProductUseCase: any GetProductUseCase = GetProductUseCaseImpl(
productRepository: productRepository
)

static let deleteProductUseCase: any DeleteProductUseCase = DeleteProductUseCaseImpl(
repository: productRepository
)

static let getProductsUseCase: any GetProductsUseCase = GetProductsUseCaseImpl(
repository: productRepository
)

static let signInUseCase: any SignInUseCase = SignInUseCaseImpl(
repository: authenticationRepository
)

static let signUpUseCase: any SignUpUseCase = SignUpUseCaseImpl(
repository: authenticationRepository
)
}
18 changes: 18 additions & 0 deletions Examples/ProductSample/Application/ProductSampleApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// ProductSampleApp.swift
// ProductSample
//
// Created by Guilherme Souza on 18/10/23.
//

import Supabase
import SwiftUI

@main
struct ProductSampleApp: App {
var body: some Scene {
WindowGroup {
AppView()
}
}
}
80 changes: 80 additions & 0 deletions Examples/ProductSample/Data/AuthenticationRepository.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//
// AuthenticationRepository.swift
// ProductSample
//
// Created by Guilherme Souza on 19/10/23.
//

import Foundation
import Supabase

protocol AuthenticationRepository {
var authStateListener: AsyncStream<AuthenticationState> { get }

var currentUserID: UUID { get async throws }

func signIn(email: String, password: String) async throws
func signUp(email: String, password: String) async throws -> SignUpResult
func signInWithApple() async throws
func signOut() async
}

struct AuthenticationRepositoryImpl: AuthenticationRepository {
let client: GoTrueClient

init(client: GoTrueClient) {
self.client = client

let (stream, continuation) = AsyncStream.makeStream(of: AuthenticationState.self)
let handle = client.addAuthStateChangeListener { event in
let state: AuthenticationState? =
switch event {
case .signedIn: AuthenticationState.signedIn
case .signedOut: AuthenticationState.signedOut
case .passwordRecovery, .tokenRefreshed, .userUpdated, .userDeleted: nil
}

if let state {
continuation.yield(state)
}
}

continuation.onTermination = { _ in
client.removeAuthStateChangeListener(handle)
}

self.authStateListener = stream
}

let authStateListener: AsyncStream<AuthenticationState>

var currentUserID: UUID {
get async throws {
try await client.session.user.id
}
}

func signIn(email: String, password: String) async throws {
try await client.signIn(email: email, password: password)
}

func signUp(email: String, password: String) async throws -> SignUpResult {
let response = try await client.signUp(
email: email,
password: password,
redirectTo: URL(string: "dev.grds.ProductSample://")
)
if case .session = response {
return .success
}
return .requiresConfirmation
}

func signInWithApple() async throws {
fatalError("\(#function) unimplemented")
}

func signOut() async {
try? await client.signOut()
}
}
39 changes: 39 additions & 0 deletions Examples/ProductSample/Data/ProductImageStorageRepository.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//
// ProductImageStorageRepository.swift
// ProductSample
//
// Created by Guilherme Souza on 19/10/23.
//

import Foundation
import Storage

protocol ProductImageStorageRepository {
func uploadImage(_ params: ImageUploadParams) async throws -> String
func downloadImage(_ key: ImageKey) async throws -> Data
}

struct ProductImageStorageRepositoryImpl: ProductImageStorageRepository {
let storage: SupabaseStorageClient

func uploadImage(_ params: ImageUploadParams) async throws -> String {
let fileName = "\(params.fileName).\(params.fileExtension ?? "png")"
let contentType = params.mimeType ?? "image/png"
let imagePath = try await storage.from(id: "product-images")
.upload(
path: fileName,
file: File(
name: fileName, data: params.data, fileName: fileName, contentType: contentType),
fileOptions: FileOptions(contentType: contentType, upsert: true)
)
return imagePath
}

func downloadImage(_ key: ImageKey) async throws -> Data {
// we save product images in the format "bucket-id/image.png", but SupabaseStorage prefixes
// the path with the bucket-id already so we must provide only the file name to the download
// call, this is what lastPathComponent is doing below.
let fileName = (key.rawValue as NSString).lastPathComponent
return try await storage.from(id: "product-images").download(path: fileName)
}
}
79 changes: 79 additions & 0 deletions Examples/ProductSample/Data/ProductRepository.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//
// ProductRepository.swift
// ProductSample
//
// Created by Guilherme Souza on 18/10/23.
//

import Foundation
import Supabase

struct InsertProductDto: Encodable {
let name: String
let price: Double
let image: String?
let ownerID: UserID

enum CodingKeys: String, CodingKey {
case name
case price
case image
case ownerID = "owner_id"
}
}

protocol ProductRepository {
func createProduct(_ product: InsertProductDto) async throws
func getProducts() async throws -> [Product]
func getProduct(id: Product.ID) async throws -> Product
func deleteProduct(id: Product.ID) async throws
func updateProduct(id: String, name: String?, price: Double?, image: String?) async throws
}

struct ProductRepositoryImpl: ProductRepository {
let supabase: SupabaseClient

func createProduct(_ product: InsertProductDto) async throws {
try await supabase.database.from("products").insert(values: product).execute()
}

func getProducts() async throws -> [Product] {
try await supabase.database.from("products").select().execute().value
}

func getProduct(id: Product.ID) async throws -> Product {
try await supabase.database.from("products").select().eq(column: "id", value: id).single()
.execute().value
}

func deleteProduct(id: Product.ID) async throws {
try await supabase.database.from("products").delete().eq(column: "id", value: id).execute()
.value
}

func updateProduct(id: String, name: String?, price: Double?, image: String?) async throws {
var params: [String: AnyJSON] = [:]

if let name {
params["name"] = .string(name)
}

if let price {
params["price"] = .number(price)
}

if let image {
params["image"] = .string(image)
}

if params.isEmpty {
// nothing to update, just return.
return
}

try await supabase.database.from("products")
.update(values: params)
.eq(column: "id", value: id)
.execute()
}
}
Loading

0 comments on commit 54b2a1c

Please sign in to comment.