Skip to content

Create Product Sample app #121

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Oct 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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