-
-
Notifications
You must be signed in to change notification settings - Fork 111
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
Showing
47 changed files
with
1,892 additions
and
240 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
Config.plist |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
80
Examples/ProductSample/Data/AuthenticationRepository.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
39
Examples/ProductSample/Data/ProductImageStorageRepository.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} | ||
} |
Oops, something went wrong.