From 8016dfe3d10677151d25c2dafd745aafee71b26b Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 19 Oct 2023 09:15:41 -0300 Subject: [PATCH 01/14] Create Product Sample app --- Examples/Examples.xcodeproj/project.pbxproj | 218 +++++++++++++++++- Examples/ProductSample/.gitignore | 1 + Examples/ProductSample/AddProductView.swift | 18 ++ Examples/ProductSample/AppView.swift | 62 +++++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 ++ .../Assets.xcassets/Contents.json | 6 + .../AuthenticationRepository.swift | 31 +++ Examples/ProductSample/Config.swift | 24 ++ Examples/ProductSample/Domain/UseCase.swift | 100 ++++++++ .../Preview Assets.xcassets/Contents.json | 6 + Examples/ProductSample/Product.swift | 15 ++ .../ProductSample/ProductDetailsView.swift | 32 +++ .../ProductDetailsViewModel.swift | 78 +++++++ Examples/ProductSample/ProductListView.swift | 49 ++++ .../ProductSample/ProductListViewModel.swift | 46 ++++ .../ProductSample/ProductRepository.swift | 60 +++++ Examples/ProductSample/ProductSampleApp.swift | 23 ++ Sources/Storage/FileOptions.swift | 7 - Sources/Storage/SearchOptions.swift | 26 --- Sources/Storage/SortBy.swift | 9 - Sources/Storage/StorageApi.swift | 16 +- Sources/Storage/StorageFileApi.swift | 69 ++++-- Sources/Storage/Types.swift | 62 +++++ Tests/StorageTests/SupabaseStorageTests.swift | 13 +- 25 files changed, 918 insertions(+), 77 deletions(-) create mode 100644 Examples/ProductSample/.gitignore create mode 100644 Examples/ProductSample/AddProductView.swift create mode 100644 Examples/ProductSample/AppView.swift create mode 100644 Examples/ProductSample/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Examples/ProductSample/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/ProductSample/Assets.xcassets/Contents.json create mode 100644 Examples/ProductSample/AuthenticationRepository.swift create mode 100644 Examples/ProductSample/Config.swift create mode 100644 Examples/ProductSample/Domain/UseCase.swift create mode 100644 Examples/ProductSample/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 Examples/ProductSample/Product.swift create mode 100644 Examples/ProductSample/ProductDetailsView.swift create mode 100644 Examples/ProductSample/ProductDetailsViewModel.swift create mode 100644 Examples/ProductSample/ProductListView.swift create mode 100644 Examples/ProductSample/ProductListViewModel.swift create mode 100644 Examples/ProductSample/ProductRepository.swift create mode 100644 Examples/ProductSample/ProductSampleApp.swift delete mode 100644 Sources/Storage/FileOptions.swift delete mode 100644 Sources/Storage/SearchOptions.swift delete mode 100644 Sources/Storage/SortBy.swift create mode 100644 Sources/Storage/Types.swift diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 27e3c925..715c5074 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -22,6 +22,22 @@ 7956406D2955B3500088A06F /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = 7956406C2955B3500088A06F /* SwiftUINavigation */; }; 795640702955B5190088A06F /* IdentifiedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 7956406F2955B5190088A06F /* IdentifiedCollections */; }; 79719ECE2ADF26C400737804 /* Supabase in Frameworks */ = {isa = PBXBuildFile; productRef = 79719ECD2ADF26C400737804 /* Supabase */; }; + 79C591DC2AE0880F0088A9C8 /* ProductSampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591DB2AE0880F0088A9C8 /* ProductSampleApp.swift */; }; + 79C591DE2AE0880F0088A9C8 /* AppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591DD2AE0880F0088A9C8 /* AppView.swift */; }; + 79C591E02AE088110088A9C8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 79C591DF2AE088110088A9C8 /* Assets.xcassets */; }; + 79C591E32AE088110088A9C8 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 79C591E22AE088110088A9C8 /* Preview Assets.xcassets */; }; + 79C591E82AE088250088A9C8 /* Supabase in Frameworks */ = {isa = PBXBuildFile; productRef = 79C591E72AE088250088A9C8 /* Supabase */; }; + 79C591EA2AE089230088A9C8 /* Product.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591E92AE089230088A9C8 /* Product.swift */; }; + 79C591EC2AE089510088A9C8 /* ProductRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591EB2AE089510088A9C8 /* ProductRepository.swift */; }; + 79C591EE2AE1258B0088A9C8 /* AuthenticationRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591ED2AE1258B0088A9C8 /* AuthenticationRepository.swift */; }; + 79C591F02AE126120088A9C8 /* ProductListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591EF2AE126120088A9C8 /* ProductListViewModel.swift */; }; + 79C591F22AE127180088A9C8 /* ProductListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591F12AE127180088A9C8 /* ProductListView.swift */; }; + 79C591F42AE12A0D0088A9C8 /* AddProductView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591F32AE12A0D0088A9C8 /* AddProductView.swift */; }; + 79C591F62AE12AAC0088A9C8 /* ProductDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591F52AE12AAC0088A9C8 /* ProductDetailsViewModel.swift */; }; + 79C591F82AE12B850088A9C8 /* ProductDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591F72AE12B850088A9C8 /* ProductDetailsView.swift */; }; + 79C591FB2AE12FDE0088A9C8 /* UseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591FA2AE12FDE0088A9C8 /* UseCase.swift */; }; + 79C591FD2AE152590088A9C8 /* Config.plist in Resources */ = {isa = PBXBuildFile; fileRef = 79C591FC2AE152590088A9C8 /* Config.plist */; }; + 79C591FF2AE1527C0088A9C8 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591FE2AE1527C0088A9C8 /* Config.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -39,6 +55,22 @@ 795640652955AE9C0088A06F /* TodoListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodoListView.swift; sourceTree = ""; }; 795640672955AEB30088A06F /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; 795640692955AFBD0088A06F /* ErrorText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorText.swift; sourceTree = ""; }; + 79C591D92AE0880F0088A9C8 /* ProductSample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ProductSample.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 79C591DB2AE0880F0088A9C8 /* ProductSampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductSampleApp.swift; sourceTree = ""; }; + 79C591DD2AE0880F0088A9C8 /* AppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppView.swift; sourceTree = ""; }; + 79C591DF2AE088110088A9C8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 79C591E22AE088110088A9C8 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 79C591E92AE089230088A9C8 /* Product.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Product.swift; sourceTree = ""; }; + 79C591EB2AE089510088A9C8 /* ProductRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductRepository.swift; sourceTree = ""; }; + 79C591ED2AE1258B0088A9C8 /* AuthenticationRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationRepository.swift; sourceTree = ""; }; + 79C591EF2AE126120088A9C8 /* ProductListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListViewModel.swift; sourceTree = ""; }; + 79C591F12AE127180088A9C8 /* ProductListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListView.swift; sourceTree = ""; }; + 79C591F32AE12A0D0088A9C8 /* AddProductView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProductView.swift; sourceTree = ""; }; + 79C591F52AE12AAC0088A9C8 /* ProductDetailsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailsViewModel.swift; sourceTree = ""; }; + 79C591F72AE12B850088A9C8 /* ProductDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailsView.swift; sourceTree = ""; }; + 79C591FA2AE12FDE0088A9C8 /* UseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UseCase.swift; sourceTree = ""; }; + 79C591FC2AE152590088A9C8 /* Config.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Config.plist; sourceTree = ""; }; + 79C591FE2AE1527C0088A9C8 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -52,6 +84,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 79C591D62AE0880F0088A9C8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 79C591E82AE088250088A9C8 /* Supabase in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -59,6 +99,7 @@ isa = PBXGroup; children = ( 793895C82954ABFF0044F2B8 /* Examples */, + 79C591DA2AE0880F0088A9C8 /* ProductSample */, 793895C72954ABFF0044F2B8 /* Products */, 7956405A2954AC3E0088A06F /* Frameworks */, ); @@ -68,6 +109,7 @@ isa = PBXGroup; children = ( 793895C62954ABFF0044F2B8 /* Examples.app */, + 79C591D92AE0880F0088A9C8 /* ProductSample.app */, ); name = Products; sourceTree = ""; @@ -107,6 +149,44 @@ name = Frameworks; sourceTree = ""; }; + 79C591DA2AE0880F0088A9C8 /* ProductSample */ = { + isa = PBXGroup; + children = ( + 79C591F92AE12FD10088A9C8 /* Domain */, + 79C591DB2AE0880F0088A9C8 /* ProductSampleApp.swift */, + 79C591DD2AE0880F0088A9C8 /* AppView.swift */, + 79C591DF2AE088110088A9C8 /* Assets.xcassets */, + 79C591E12AE088110088A9C8 /* Preview Content */, + 79C591E92AE089230088A9C8 /* Product.swift */, + 79C591EB2AE089510088A9C8 /* ProductRepository.swift */, + 79C591ED2AE1258B0088A9C8 /* AuthenticationRepository.swift */, + 79C591EF2AE126120088A9C8 /* ProductListViewModel.swift */, + 79C591F12AE127180088A9C8 /* ProductListView.swift */, + 79C591F32AE12A0D0088A9C8 /* AddProductView.swift */, + 79C591F52AE12AAC0088A9C8 /* ProductDetailsViewModel.swift */, + 79C591F72AE12B850088A9C8 /* ProductDetailsView.swift */, + 79C591FC2AE152590088A9C8 /* Config.plist */, + 79C591FE2AE1527C0088A9C8 /* Config.swift */, + ); + path = ProductSample; + sourceTree = ""; + }; + 79C591E12AE088110088A9C8 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 79C591E22AE088110088A9C8 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 79C591F92AE12FD10088A9C8 /* Domain */ = { + isa = PBXGroup; + children = ( + 79C591FA2AE12FDE0088A9C8 /* UseCase.swift */, + ); + path = Domain; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -132,6 +212,26 @@ productReference = 793895C62954ABFF0044F2B8 /* Examples.app */; productType = "com.apple.product-type.application"; }; + 79C591D82AE0880F0088A9C8 /* ProductSample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 79C591E62AE088110088A9C8 /* Build configuration list for PBXNativeTarget "ProductSample" */; + buildPhases = ( + 79C591D52AE0880F0088A9C8 /* Sources */, + 79C591D62AE0880F0088A9C8 /* Frameworks */, + 79C591D72AE0880F0088A9C8 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ProductSample; + packageProductDependencies = ( + 79C591E72AE088250088A9C8 /* Supabase */, + ); + productName = ProductSample; + productReference = 79C591D92AE0880F0088A9C8 /* ProductSample.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -139,12 +239,15 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1410; + LastSwiftUpdateCheck = 1500; LastUpgradeCheck = 1500; TargetAttributes = { 793895C52954ABFF0044F2B8 = { CreatedOnToolsVersion = 14.1; }; + 79C591D82AE0880F0088A9C8 = { + CreatedOnToolsVersion = 15.0.1; + }; }; }; buildConfigurationList = 793895C12954ABFF0044F2B8 /* Build configuration list for PBXProject "Examples" */; @@ -165,6 +268,7 @@ projectRoot = ""; targets = ( 793895C52954ABFF0044F2B8 /* Examples */, + 79C591D82AE0880F0088A9C8 /* ProductSample */, ); }; /* End PBXProject section */ @@ -179,6 +283,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 79C591D72AE0880F0088A9C8 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 79C591E32AE088110088A9C8 /* Preview Assets.xcassets in Resources */, + 79C591FD2AE152590088A9C8 /* Config.plist in Resources */, + 79C591E02AE088110088A9C8 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -199,6 +313,25 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 79C591D52AE0880F0088A9C8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 79C591DE2AE0880F0088A9C8 /* AppView.swift in Sources */, + 79C591DC2AE0880F0088A9C8 /* ProductSampleApp.swift in Sources */, + 79C591F62AE12AAC0088A9C8 /* ProductDetailsViewModel.swift in Sources */, + 79C591F22AE127180088A9C8 /* ProductListView.swift in Sources */, + 79C591F02AE126120088A9C8 /* ProductListViewModel.swift in Sources */, + 79C591FF2AE1527C0088A9C8 /* Config.swift in Sources */, + 79C591FB2AE12FDE0088A9C8 /* UseCase.swift in Sources */, + 79C591F42AE12A0D0088A9C8 /* AddProductView.swift in Sources */, + 79C591EA2AE089230088A9C8 /* Product.swift in Sources */, + 79C591EE2AE1258B0088A9C8 /* AuthenticationRepository.swift in Sources */, + 79C591EC2AE089510088A9C8 /* ProductRepository.swift in Sources */, + 79C591F82AE12B850088A9C8 /* ProductDetailsView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ @@ -393,6 +526,76 @@ }; name = Release; }; + 79C591E42AE088110088A9C8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ProductSample/Preview Content\""; + DEVELOPMENT_TEAM = ELTTE7K8TT; + ENABLE_PREVIEWS = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.grds.ProductSample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 79C591E52AE088110088A9C8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ProductSample/Preview Content\""; + DEVELOPMENT_TEAM = ELTTE7K8TT; + ENABLE_PREVIEWS = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.grds.ProductSample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -414,6 +617,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 79C591E62AE088110088A9C8 /* Build configuration list for PBXNativeTarget "ProductSample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 79C591E42AE088110088A9C8 /* Debug */, + 79C591E52AE088110088A9C8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -450,6 +662,10 @@ isa = XCSwiftPackageProductDependency; productName = Supabase; }; + 79C591E72AE088250088A9C8 /* Supabase */ = { + isa = XCSwiftPackageProductDependency; + productName = Supabase; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 793895BE2954ABFF0044F2B8 /* Project object */; diff --git a/Examples/ProductSample/.gitignore b/Examples/ProductSample/.gitignore new file mode 100644 index 00000000..805ba5e2 --- /dev/null +++ b/Examples/ProductSample/.gitignore @@ -0,0 +1 @@ +Config.plist diff --git a/Examples/ProductSample/AddProductView.swift b/Examples/ProductSample/AddProductView.swift new file mode 100644 index 00000000..36e1ea32 --- /dev/null +++ b/Examples/ProductSample/AddProductView.swift @@ -0,0 +1,18 @@ +// +// AddProductView.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import SwiftUI + +struct AddProductView: View { + var body: some View { + Text("Hello, World!") + } +} + +#Preview { + AddProductView() +} diff --git a/Examples/ProductSample/AppView.swift b/Examples/ProductSample/AppView.swift new file mode 100644 index 00000000..0f1a3346 --- /dev/null +++ b/Examples/ProductSample/AppView.swift @@ -0,0 +1,62 @@ +// +// AppView.swift +// ProductSample +// +// Created by Guilherme Souza on 18/10/23. +// + +import SwiftUI + +struct ProductDetailRoute: Hashable { + let productId: String +} + +struct AddProductRoute: Identifiable, Hashable { + var id: AnyHashable { self } +} + +@MainActor +final class AppViewModel: ObservableObject { + let productListModel = ProductListViewModel() + + @Published var addProductRoute: AddProductRoute? + + func productDetailViewModel(with productId: String?) -> ProductDetailsViewModel { + ProductDetailsViewModel(productId: productId) { [weak self] updated in + Task { + await self?.productListModel.loadProducts() + } + } + } +} + +struct AppView: View { + @StateObject var model = AppViewModel() + + var body: some View { + NavigationStack { + ProductListView() + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + model.addProductRoute = .init() + } label: { + Label("Add", systemImage: "plus") + } + } + } + .navigationDestination(for: ProductDetailRoute.self) { route in + ProductDetailsView(model: model.productDetailViewModel(with: route.productId)) + } + } + .sheet(item: $model.addProductRoute) { _ in + NavigationStack { + ProductDetailsView(model: model.productDetailViewModel(with: nil)) + } + } + } +} + +#Preview { + AppView() +} diff --git a/Examples/ProductSample/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/ProductSample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/Examples/ProductSample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/ProductSample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/ProductSample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..13613e3e --- /dev/null +++ b/Examples/ProductSample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/ProductSample/Assets.xcassets/Contents.json b/Examples/ProductSample/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Examples/ProductSample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/ProductSample/AuthenticationRepository.swift b/Examples/ProductSample/AuthenticationRepository.swift new file mode 100644 index 00000000..044b6cf6 --- /dev/null +++ b/Examples/ProductSample/AuthenticationRepository.swift @@ -0,0 +1,31 @@ +// +// AuthenticationRepository.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import Foundation +import Supabase + +protocol AuthenticationRepository { + func signIn(email: String, password: String) async throws + func signUp(email: String, password: String) async throws + func signInWithApple() async throws +} + +struct AuthenticationRepositoryImpl: AuthenticationRepository { + let client: GoTrueClient + + func signIn(email: String, password: String) async throws { + try await client.signIn(email: email, password: password) + } + + func signUp(email: String, password: String) async throws { + try await client.signUp(email: email, password: password) + } + + func signInWithApple() async throws { + fatalError("\(#function) unimplemented") + } +} diff --git a/Examples/ProductSample/Config.swift b/Examples/ProductSample/Config.swift new file mode 100644 index 00000000..780794cf --- /dev/null +++ b/Examples/ProductSample/Config.swift @@ -0,0 +1,24 @@ +// +// Config.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import Foundation + +enum Config { + static let SUPABASE_URL = load(key: "SUPABASE_URL") ?? "" + static let SUPABASE_ANON_KEY = load(key: "SUPABASE_ANON_KEY") ?? "" + + private static func load(key: String) -> T? { + guard + let configURL = Bundle.main.url(forResource: "Config", withExtension: "plist"), + let config = try? PropertyListSerialization.propertyList(from: Data(contentsOf: configURL), format: nil) as? [String: Any] + else { + return nil + } + + return config[key] as? T + } +} diff --git a/Examples/ProductSample/Domain/UseCase.swift b/Examples/ProductSample/Domain/UseCase.swift new file mode 100644 index 00000000..7c7d221b --- /dev/null +++ b/Examples/ProductSample/Domain/UseCase.swift @@ -0,0 +1,100 @@ +// +// UseCase.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import Foundation +import Storage + +protocol UseCase { + associatedtype Input + associatedtype Output + + func execute(input: Input) async -> Output +} + +struct CreateProductParams: Encodable { + let name: String + let price: Double + let image: String? +} + +protocol CreateProductUseCase: UseCase> {} + +struct CreateProductUseCaseImpl: CreateProductUseCase { + let repository: ProductRepository + + func execute(input: CreateProductParams) async -> Result<(), Error> { + do { + try await repository.createProduct(input) + return .success(()) + } catch { + return .failure(error) + } + } +} + +struct UpdateProductParams { + var id: String + var name: String? + var price: Double? + + var imageName: String? + var imageFile: Data? +} + +protocol UpdateProductUseCase: UseCase> {} + +struct UpdateProductUseCaseImpl: UpdateProductUseCase { + let repository: ProductRepository + + // TODO: Abstract storage access + let storage: SupabaseStorageClient + + func execute(input: UpdateProductParams) async -> Result<(), Error> { + do { + var image: String? + + if let imageName = input.imageName, let imageFile = input.imageFile, !imageFile.isEmpty { + let filePath = "\(imageName).png" + let imageFilePath = try await storage.from(id: "product-images") + .upload( + path: filePath, + file: File( + name: filePath, data: imageFile, fileName: filePath, contentType: "image/png"), + fileOptions: FileOptions(contentType: "image/png", upsert: true) + ) + + image = buildImageURL(imageFilePath: imageFilePath) + } + + try await repository.updateProduct( + id: input.id, name: input.name, price: input.price, image: image) + return .success(()) + } catch { + return .failure(error) + } + } + + private func buildImageURL(imageFilePath: String) -> String { + supabase.storage.configuration.url.appendingPathComponent("object/public/\(imageFilePath)") + .absoluteString + } +} + +protocol GetProductUseCase: UseCase> {} + +struct GetProductUseCaseImpl: GetProductUseCase { + let repository: ProductRepository + + func execute(input: Product.ID) async -> Result { + do { + let product = try await repository.getProduct(id: input) + return .success(product) + } catch { + return .failure(error) + } + } +} diff --git a/Examples/ProductSample/Preview Content/Preview Assets.xcassets/Contents.json b/Examples/ProductSample/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Examples/ProductSample/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/ProductSample/Product.swift b/Examples/ProductSample/Product.swift new file mode 100644 index 00000000..69b77d8e --- /dev/null +++ b/Examples/ProductSample/Product.swift @@ -0,0 +1,15 @@ +// +// Product.swift +// ProductSample +// +// Created by Guilherme Souza on 18/10/23. +// + +import Foundation + +struct Product: Identifiable, Decodable { + let id: String + let name: String + let price: Double + let image: String? +} diff --git a/Examples/ProductSample/ProductDetailsView.swift b/Examples/ProductSample/ProductDetailsView.swift new file mode 100644 index 00000000..bd76a6a8 --- /dev/null +++ b/Examples/ProductSample/ProductDetailsView.swift @@ -0,0 +1,32 @@ +// +// ProductDetailsView.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import SwiftUI + +struct ProductDetailsView: View { + @ObservedObject var model: ProductDetailsViewModel + + var body: some View { + Form { + Section { + TextField("Product Name", text: $model.name) + TextField("Product Price", value: $model.price, formatter: NumberFormatter()) + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Save") { + Task { await model.saveButtonTapped() } + } + } + } + } +} + +#Preview { + ProductDetailsView(model: ProductDetailsViewModel(productId: nil) { _ in }) +} diff --git a/Examples/ProductSample/ProductDetailsViewModel.swift b/Examples/ProductSample/ProductDetailsViewModel.swift new file mode 100644 index 00000000..a4dd06b6 --- /dev/null +++ b/Examples/ProductSample/ProductDetailsViewModel.swift @@ -0,0 +1,78 @@ +// +// ProductDetailsViewModel.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import SwiftUI + +final class ProductDetailsViewModel: ObservableObject { + private let productId: Product.ID? + + private let updateProductUseCase: any UpdateProductUseCase + private let createProductUseCase: any CreateProductUseCase + private let getProductUseCase: any GetProductUseCase + + @Published var name: String = "" + @Published var price: Double = 0 + + let onCompletion: (Bool) -> Void + + init( + updateProductUseCase: any UpdateProductUseCase = UpdateProductUseCaseImpl( + repository: ProductRepositoryImpl(supabase: supabase), storage: supabase.storage), + createProductUseCase: any CreateProductUseCase = CreateProductUseCaseImpl( + repository: ProductRepositoryImpl(supabase: supabase)), + getProductUseCase: any GetProductUseCase = GetProductUseCaseImpl( + repository: ProductRepositoryImpl(supabase: supabase)), + productId: Product.ID?, + onCompletion: @escaping (Bool) -> Void + ) { + self.updateProductUseCase = updateProductUseCase + self.createProductUseCase = createProductUseCase + self.getProductUseCase = getProductUseCase + self.productId = productId + self.onCompletion = onCompletion + } + + func loadProductIfNeeded() async { + guard let productId else { return } + + switch await getProductUseCase.execute(input: productId) { + case .success(let product): + name = product.name + price = product.price + case .failure(let error): + dump(error) + } + } + + func saveButtonTapped() async { + let result: Result + + if let productId { + result = await updateProductUseCase.execute( + input: UpdateProductParams( + id: productId, + name: name, + price: price, + imageName: nil, + imageFile: nil + ) + ) + } else { + result = await createProductUseCase.execute( + input: CreateProductParams(name: name, price: price, image: nil) + ) + } + + switch result { + case .failure(let error): + dump(error) + onCompletion(false) + case .success: + onCompletion(true) + } + } +} diff --git a/Examples/ProductSample/ProductListView.swift b/Examples/ProductSample/ProductListView.swift new file mode 100644 index 00000000..6cd59313 --- /dev/null +++ b/Examples/ProductSample/ProductListView.swift @@ -0,0 +1,49 @@ +// +// ProductListView.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import SwiftUI + +struct ProductListView: View { + @StateObject var model = ProductListViewModel() + + var body: some View { + List { + if let error = model.error { + Text(error.localizedDescription) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color.red.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding() + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowSeparator(.hidden) + } + + ForEach(model.products) { product in + NavigationLink(value: ProductDetailRoute(productId: product.id)) { + Text(product.name) + } + } + } + .listStyle(.plain) + .overlay { + if model.products.isEmpty { + Text("Product list empty.") + } + } + .task { + await model.loadProducts() + } + .refreshable { + await model.loadProducts() + } + } +} + +#Preview { + ProductListView() +} diff --git a/Examples/ProductSample/ProductListViewModel.swift b/Examples/ProductSample/ProductListViewModel.swift new file mode 100644 index 00000000..6de5fc2b --- /dev/null +++ b/Examples/ProductSample/ProductListViewModel.swift @@ -0,0 +1,46 @@ +// +// ProductListViewModel.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import SwiftUI + +@MainActor +final class ProductListViewModel: ObservableObject { + let productRepository: ProductRepository + + @Published var products: [Product] = [] + @Published var isLoading = false + @Published var error: Error? + + init(productRepository: ProductRepository = ProductRepositoryImpl(supabase: supabase)) { + self.productRepository = productRepository + } + + func loadProducts() async { + isLoading = true + defer { isLoading = false } + + do { + products = try await productRepository.getProducts() + self.error = nil + } catch { + self.error = error + } + } + + func removeItem(product: Product) async { + self.products.removeAll { $0.id == product.id } + + do { + try await productRepository.deleteProduct(id: product.id) + self.error = nil + } catch { + self.error = error + } + + await loadProducts() + } +} diff --git a/Examples/ProductSample/ProductRepository.swift b/Examples/ProductSample/ProductRepository.swift new file mode 100644 index 00000000..9441037e --- /dev/null +++ b/Examples/ProductSample/ProductRepository.swift @@ -0,0 +1,60 @@ +// +// ProductRepository.swift +// ProductSample +// +// Created by Guilherme Souza on 18/10/23. +// + +import Foundation +import Supabase + +protocol ProductRepository { + func createProduct(_ product: CreateProductParams) 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: CreateProductParams) 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) + } + + try await supabase.database.from("products") + .update(values: params) + .eq(column: "id", value: id) + .execute() + } +} diff --git a/Examples/ProductSample/ProductSampleApp.swift b/Examples/ProductSample/ProductSampleApp.swift new file mode 100644 index 00000000..f1032d6a --- /dev/null +++ b/Examples/ProductSample/ProductSampleApp.swift @@ -0,0 +1,23 @@ +// +// 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() + } + } +} + +let supabase = SupabaseClient( + supabaseURL: URL(string: Config.SUPABASE_URL)!, + supabaseKey: Config.SUPABASE_ANON_KEY +) diff --git a/Sources/Storage/FileOptions.swift b/Sources/Storage/FileOptions.swift deleted file mode 100644 index bbc51b1e..00000000 --- a/Sources/Storage/FileOptions.swift +++ /dev/null @@ -1,7 +0,0 @@ -public struct FileOptions { - public var cacheControl: String - - public init(cacheControl: String) { - self.cacheControl = cacheControl - } -} diff --git a/Sources/Storage/SearchOptions.swift b/Sources/Storage/SearchOptions.swift deleted file mode 100644 index d222932e..00000000 --- a/Sources/Storage/SearchOptions.swift +++ /dev/null @@ -1,26 +0,0 @@ -public struct SearchOptions: Encodable { - public let prefix: String - - /// The number of files you want to be returned. - public var limit: Int? - - /// The starting position. - public var offset: Int? - - /// The column to sort by. Can be any column inside a ``FileObject``. - public var sortBy: SortBy? - - /// The search string to filter files by. - public var search: String? - - public init( - prefix: String = "", limit: Int? = nil, offset: Int? = nil, sortBy: SortBy? = nil, - search: String? = nil - ) { - self.prefix = prefix - self.limit = limit - self.offset = offset - self.sortBy = sortBy - self.search = search - } -} diff --git a/Sources/Storage/SortBy.swift b/Sources/Storage/SortBy.swift deleted file mode 100644 index e65a19b0..00000000 --- a/Sources/Storage/SortBy.swift +++ /dev/null @@ -1,9 +0,0 @@ -public struct SortBy: Encodable { - public var column: String? - public var order: String? - - public init(column: String? = nil, order: String? = nil) { - self.column = column - self.order = order - } -} diff --git a/Sources/Storage/StorageApi.swift b/Sources/Storage/StorageApi.swift index fd1190ce..da556c8a 100644 --- a/Sources/Storage/StorageApi.swift +++ b/Sources/Storage/StorageApi.swift @@ -6,7 +6,7 @@ import Foundation #endif public class StorageApi { - var configuration: StorageClientConfiguration + public let configuration: StorageClientConfiguration public init(configuration: StorageClientConfiguration) { self.configuration = configuration @@ -33,9 +33,17 @@ public class StorageApi { } extension Request { - init(path: String, method: String, formData: FormData, options: FileOptions?) { - var headers = ["Content-Type": formData.contentType] - headers["Cache-Control"] = options?.cacheControl + init( + path: String, method: String, formData: FormData, options: FileOptions, + headers: [String: String] = [:] + ) { + var headers = headers + if headers["Content-Type"] == nil { + headers["Content-Type"] = formData.contentType + } + if headers["Cache-Control"] == nil { + headers["Cache-Control"] = "max-age=\(options.cacheControl)" + } self.init( path: path, method: method, diff --git a/Sources/Storage/StorageFileApi.swift b/Sources/Storage/StorageFileApi.swift index fe9cd270..bc5dde65 100644 --- a/Sources/Storage/StorageFileApi.swift +++ b/Sources/Storage/StorageFileApi.swift @@ -24,24 +24,53 @@ public class StorageFileApi: StorageApi { super.init(configuration: configuration) } + struct UploadResponse: Decodable { + let Key: String + } + + func uploadOrUpdate( + method: String, + path: String, + file: Data, + fileOptions: FileOptions + ) async throws -> String { + let contentType = fileOptions.contentType + var headers = [ + "x-upsert": "\(fileOptions.upsert)" + ] + + headers["duplex"] = fileOptions.duplex + + let fileName = fileName(fromPath: path) + + let form = FormData() + form.append( + file: File(name: fileName, data: file, fileName: fileName, contentType: contentType) + ) + + return try await execute( + Request( + path: "/object/\(bucketId)/\(path)", + method: method, + formData: form, + options: fileOptions, + headers: headers + ) + ) + .decoded(as: UploadResponse.self, decoder: configuration.decoder).Key + } + /// Uploads a file to an existing bucket. /// - Parameters: /// - path: The relative file path. Should be of the format `folder/subfolder/filename.png`. The /// bucket must already exist before attempting to upload. /// - file: The File object to be stored in the bucket. /// - fileOptions: HTTP headers. For example `cacheControl` - public func upload(path: String, file: File, fileOptions: FileOptions?) async throws { - let formData = FormData() - formData.append(file: file) - - try await execute( - Request( - path: "/object/\(bucketId)/\(path)", - method: "POST", - formData: formData, - options: fileOptions - ) - ) + @discardableResult + public func upload(path: String, file: File, fileOptions: FileOptions = FileOptions()) + async throws -> String + { + try await uploadOrUpdate(method: "POST", path: path, file: file.data, fileOptions: fileOptions) } /// Replaces an existing file at the specified path with a new one. @@ -50,18 +79,10 @@ public class StorageFileApi: StorageApi { /// already exist before attempting to upload. /// - file: The file object to be stored in the bucket. /// - fileOptions: HTTP headers. For example `cacheControl` - public func update(path: String, file: File, fileOptions: FileOptions?) async throws { - let formData = FormData() - formData.append(file: file) - - try await execute( - Request( - path: "/object/\(bucketId)/\(path)", - method: "PUT", - formData: formData, - options: fileOptions - ) - ) + public func update(path: String, file: File, fileOptions: FileOptions = FileOptions()) + async throws -> String + { + try await uploadOrUpdate(method: "PUT", path: path, file: file.data, fileOptions: fileOptions) } /// Moves an existing file, optionally renaming it at the same time. diff --git a/Sources/Storage/Types.swift b/Sources/Storage/Types.swift new file mode 100644 index 00000000..2d4f40a9 --- /dev/null +++ b/Sources/Storage/Types.swift @@ -0,0 +1,62 @@ +public struct SearchOptions: Encodable { + public let prefix: String + + /// The number of files you want to be returned. + public var limit: Int? + + /// The starting position. + public var offset: Int? + + /// The column to sort by. Can be any column inside a ``FileObject``. + public var sortBy: SortBy? + + /// The search string to filter files by. + public var search: String? + + public init( + prefix: String = "", limit: Int? = nil, offset: Int? = nil, sortBy: SortBy? = nil, + search: String? = nil + ) { + self.prefix = prefix + self.limit = limit + self.offset = offset + self.sortBy = sortBy + self.search = search + } +} + +public struct SortBy: Encodable { + public var column: String? + public var order: String? + + public init(column: String? = nil, order: String? = nil) { + self.column = column + self.order = order + } +} + +public struct FileOptions { + /// The number of seconds the asset is cached in the browser and in the Supabase CDN. This is set in the `Cache-Control: max-age=` header. Defaults to 3600 seconds. + public var cacheControl: String + + /// The `Content-Type` header value. Should be specified if using a `fileBody` that is neither `Blob` nor `File` nor `FormData`, otherwise will default to `text/plain;charset=UTF-8`. + public var contentType: String + + /// When upsert is set to true, the file is overwritten if it exists. When set to false, an error is thrown if the object already exists. Defaults to false. + public var upsert: Bool + + /// The duplex option is a string parameter that enables or disables duplex streaming, allowing for both reading and writing data in the same stream. It can be passed as an option to the fetch() method. + public var duplex: String? + + public init( + cacheControl: String = "3600", + contentType: String = "text/plain;charset=UTF-8", + upsert: Bool = false, + duplex: String? = nil + ) { + self.cacheControl = cacheControl + self.contentType = contentType + self.upsert = upsert + self.duplex = duplex + } +} diff --git a/Tests/StorageTests/SupabaseStorageTests.swift b/Tests/StorageTests/SupabaseStorageTests.swift index 14bea8e7..a0ff4aaf 100644 --- a/Tests/StorageTests/SupabaseStorageTests.swift +++ b/Tests/StorageTests/SupabaseStorageTests.swift @@ -28,7 +28,7 @@ final class SupabaseStorageTests: XCTestCase { ) ) - let uploadData = try! Data( + let uploadData = try? Data( contentsOf: URL( string: "https://raw.githubusercontent.com/supabase-community/storage-swift/main/README.md" )! @@ -37,10 +37,10 @@ final class SupabaseStorageTests: XCTestCase { override func setUp() async throws { try await super.setUp() - try XCTSkipUnless( - ProcessInfo.processInfo.environment["INTEGRATION_TESTS"] != nil, - "INTEGRATION_TESTS not defined." - ) + // try XCTSkipUnless( + // ProcessInfo.processInfo.environment["INTEGRATION_TESTS"] != nil, + // "INTEGRATION_TESTS not defined." + // ) _ = try? await storage.emptyBucket(id: bucket) _ = try? await storage.deleteBucket(id: bucket) @@ -96,7 +96,8 @@ final class SupabaseStorageTests: XCTestCase { private func uploadTestData() async throws { let file = File( - name: "README.md", data: uploadData, fileName: "README.md", contentType: "text/html") + name: "README.md", data: uploadData ?? Data(), fileName: "README.md", contentType: "text/html" + ) _ = try await storage.from(id: bucket).upload( path: "README.md", file: file, fileOptions: FileOptions(cacheControl: "3600") ) From cc27b90ba6dc53d1d8f3907e790cf5185b4219e6 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 19 Oct 2023 09:32:49 -0300 Subject: [PATCH 02/14] Use dependency container --- Examples/Examples.xcodeproj/project.pbxproj | 26 +++++++++- Examples/ProductSample/Config.swift | 3 +- Examples/ProductSample/Dependencies.swift | 27 ++++++++++ .../UseCases/CreateProductUseCase.swift | 29 +++++++++++ .../Domain/UseCases/GetProductUseCase.swift | 23 +++++++++ .../UpdateProductUseCase.swift} | 49 ++----------------- .../Domain/UseCases/UseCase.swift | 16 ++++++ .../ProductSample/ProductDetailsView.swift | 15 +++++- .../ProductDetailsViewModel.swift | 19 ++++--- Examples/ProductSample/ProductListView.swift | 2 +- .../ProductSample/ProductListViewModel.swift | 4 +- Examples/ProductSample/ProductSampleApp.swift | 5 -- Sources/Storage/StorageFileApi.swift | 4 ++ 13 files changed, 157 insertions(+), 65 deletions(-) create mode 100644 Examples/ProductSample/Dependencies.swift create mode 100644 Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift create mode 100644 Examples/ProductSample/Domain/UseCases/GetProductUseCase.swift rename Examples/ProductSample/Domain/{UseCase.swift => UseCases/UpdateProductUseCase.swift} (55%) create mode 100644 Examples/ProductSample/Domain/UseCases/UseCase.swift diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 715c5074..2bf359f2 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -38,6 +38,10 @@ 79C591FB2AE12FDE0088A9C8 /* UseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591FA2AE12FDE0088A9C8 /* UseCase.swift */; }; 79C591FD2AE152590088A9C8 /* Config.plist in Resources */ = {isa = PBXBuildFile; fileRef = 79C591FC2AE152590088A9C8 /* Config.plist */; }; 79C591FF2AE1527C0088A9C8 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591FE2AE1527C0088A9C8 /* Config.swift */; }; + 79C592012AE1561D0088A9C8 /* Dependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C592002AE1561D0088A9C8 /* Dependencies.swift */; }; + 79C592042AE159130088A9C8 /* CreateProductUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C592032AE159130088A9C8 /* CreateProductUseCase.swift */; }; + 79C592062AE159250088A9C8 /* UpdateProductUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C592052AE159250088A9C8 /* UpdateProductUseCase.swift */; }; + 79C592082AE159390088A9C8 /* GetProductUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C592072AE159390088A9C8 /* GetProductUseCase.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -71,6 +75,10 @@ 79C591FA2AE12FDE0088A9C8 /* UseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UseCase.swift; sourceTree = ""; }; 79C591FC2AE152590088A9C8 /* Config.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Config.plist; sourceTree = ""; }; 79C591FE2AE1527C0088A9C8 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; + 79C592002AE1561D0088A9C8 /* Dependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dependencies.swift; sourceTree = ""; }; + 79C592032AE159130088A9C8 /* CreateProductUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateProductUseCase.swift; sourceTree = ""; }; + 79C592052AE159250088A9C8 /* UpdateProductUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateProductUseCase.swift; sourceTree = ""; }; + 79C592072AE159390088A9C8 /* GetProductUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProductUseCase.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -167,6 +175,7 @@ 79C591F72AE12B850088A9C8 /* ProductDetailsView.swift */, 79C591FC2AE152590088A9C8 /* Config.plist */, 79C591FE2AE1527C0088A9C8 /* Config.swift */, + 79C592002AE1561D0088A9C8 /* Dependencies.swift */, ); path = ProductSample; sourceTree = ""; @@ -182,11 +191,22 @@ 79C591F92AE12FD10088A9C8 /* Domain */ = { isa = PBXGroup; children = ( - 79C591FA2AE12FDE0088A9C8 /* UseCase.swift */, + 79C592022AE159070088A9C8 /* UseCases */, ); path = Domain; sourceTree = ""; }; + 79C592022AE159070088A9C8 /* UseCases */ = { + isa = PBXGroup; + children = ( + 79C591FA2AE12FDE0088A9C8 /* UseCase.swift */, + 79C592032AE159130088A9C8 /* CreateProductUseCase.swift */, + 79C592052AE159250088A9C8 /* UpdateProductUseCase.swift */, + 79C592072AE159390088A9C8 /* GetProductUseCase.swift */, + ); + path = UseCases; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -320,12 +340,16 @@ 79C591DE2AE0880F0088A9C8 /* AppView.swift in Sources */, 79C591DC2AE0880F0088A9C8 /* ProductSampleApp.swift in Sources */, 79C591F62AE12AAC0088A9C8 /* ProductDetailsViewModel.swift in Sources */, + 79C592042AE159130088A9C8 /* CreateProductUseCase.swift in Sources */, + 79C592012AE1561D0088A9C8 /* Dependencies.swift in Sources */, 79C591F22AE127180088A9C8 /* ProductListView.swift in Sources */, 79C591F02AE126120088A9C8 /* ProductListViewModel.swift in Sources */, 79C591FF2AE1527C0088A9C8 /* Config.swift in Sources */, 79C591FB2AE12FDE0088A9C8 /* UseCase.swift in Sources */, 79C591F42AE12A0D0088A9C8 /* AddProductView.swift in Sources */, + 79C592062AE159250088A9C8 /* UpdateProductUseCase.swift in Sources */, 79C591EA2AE089230088A9C8 /* Product.swift in Sources */, + 79C592082AE159390088A9C8 /* GetProductUseCase.swift in Sources */, 79C591EE2AE1258B0088A9C8 /* AuthenticationRepository.swift in Sources */, 79C591EC2AE089510088A9C8 /* ProductRepository.swift in Sources */, 79C591F82AE12B850088A9C8 /* ProductDetailsView.swift in Sources */, diff --git a/Examples/ProductSample/Config.swift b/Examples/ProductSample/Config.swift index 780794cf..fd69fb86 100644 --- a/Examples/ProductSample/Config.swift +++ b/Examples/ProductSample/Config.swift @@ -14,7 +14,8 @@ enum Config { private static func load(key: String) -> T? { guard let configURL = Bundle.main.url(forResource: "Config", withExtension: "plist"), - let config = try? PropertyListSerialization.propertyList(from: Data(contentsOf: configURL), format: nil) as? [String: Any] + let config = try? PropertyListSerialization.propertyList( + from: Data(contentsOf: configURL), format: nil) as? [String: Any] else { return nil } diff --git a/Examples/ProductSample/Dependencies.swift b/Examples/ProductSample/Dependencies.swift new file mode 100644 index 00000000..8862b10f --- /dev/null +++ b/Examples/ProductSample/Dependencies.swift @@ -0,0 +1,27 @@ +// +// 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) + + // MARK: Use Cases + static let updateProductUseCase: any UpdateProductUseCase = UpdateProductUseCaseImpl( + repository: productRepository, storage: supabase.storage) + static let createProductUseCase: any CreateProductUseCase = CreateProductUseCaseImpl( + repository: productRepository) + static let getProductUseCase: any GetProductUseCase = GetProductUseCaseImpl( + repository: productRepository) +} diff --git a/Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift b/Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift new file mode 100644 index 00000000..5365f34f --- /dev/null +++ b/Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift @@ -0,0 +1,29 @@ +// +// CreateProductUseCase.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import Foundation + +struct CreateProductParams: Encodable { + let name: String + let price: Double + let image: String? +} + +protocol CreateProductUseCase: UseCase> {} + +struct CreateProductUseCaseImpl: CreateProductUseCase { + let repository: ProductRepository + + func execute(input: CreateProductParams) async -> Result<(), Error> { + do { + try await repository.createProduct(input) + return .success(()) + } catch { + return .failure(error) + } + } +} diff --git a/Examples/ProductSample/Domain/UseCases/GetProductUseCase.swift b/Examples/ProductSample/Domain/UseCases/GetProductUseCase.swift new file mode 100644 index 00000000..54090844 --- /dev/null +++ b/Examples/ProductSample/Domain/UseCases/GetProductUseCase.swift @@ -0,0 +1,23 @@ +// +// GetProductUseCase.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import Foundation + +protocol GetProductUseCase: UseCase> {} + +struct GetProductUseCaseImpl: GetProductUseCase { + let repository: ProductRepository + + func execute(input: Product.ID) async -> Result { + do { + let product = try await repository.getProduct(id: input) + return .success(product) + } catch { + return .failure(error) + } + } +} diff --git a/Examples/ProductSample/Domain/UseCase.swift b/Examples/ProductSample/Domain/UseCases/UpdateProductUseCase.swift similarity index 55% rename from Examples/ProductSample/Domain/UseCase.swift rename to Examples/ProductSample/Domain/UseCases/UpdateProductUseCase.swift index 7c7d221b..d499c411 100644 --- a/Examples/ProductSample/Domain/UseCase.swift +++ b/Examples/ProductSample/Domain/UseCases/UpdateProductUseCase.swift @@ -1,40 +1,12 @@ // -// UseCase.swift +// UpdateProductUseCase.swift // ProductSample // // Created by Guilherme Souza on 19/10/23. // import Foundation -import Storage - -protocol UseCase { - associatedtype Input - associatedtype Output - - func execute(input: Input) async -> Output -} - -struct CreateProductParams: Encodable { - let name: String - let price: Double - let image: String? -} - -protocol CreateProductUseCase: UseCase> {} - -struct CreateProductUseCaseImpl: CreateProductUseCase { - let repository: ProductRepository - - func execute(input: CreateProductParams) async -> Result<(), Error> { - do { - try await repository.createProduct(input) - return .success(()) - } catch { - return .failure(error) - } - } -} +import Supabase struct UpdateProductParams { var id: String @@ -79,22 +51,7 @@ struct UpdateProductUseCaseImpl: UpdateProductUseCase { } private func buildImageURL(imageFilePath: String) -> String { - supabase.storage.configuration.url.appendingPathComponent("object/public/\(imageFilePath)") + storage.configuration.url.appendingPathComponent("object/public/\(imageFilePath)") .absoluteString } } - -protocol GetProductUseCase: UseCase> {} - -struct GetProductUseCaseImpl: GetProductUseCase { - let repository: ProductRepository - - func execute(input: Product.ID) async -> Result { - do { - let product = try await repository.getProduct(id: input) - return .success(product) - } catch { - return .failure(error) - } - } -} diff --git a/Examples/ProductSample/Domain/UseCases/UseCase.swift b/Examples/ProductSample/Domain/UseCases/UseCase.swift new file mode 100644 index 00000000..7e6b0940 --- /dev/null +++ b/Examples/ProductSample/Domain/UseCases/UseCase.swift @@ -0,0 +1,16 @@ +// +// UseCase.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import Foundation +import Storage + +protocol UseCase { + associatedtype Input + associatedtype Output + + func execute(input: Input) async -> Output +} diff --git a/Examples/ProductSample/ProductDetailsView.swift b/Examples/ProductSample/ProductDetailsView.swift index bd76a6a8..747717f2 100644 --- a/Examples/ProductSample/ProductDetailsView.swift +++ b/Examples/ProductSample/ProductDetailsView.swift @@ -10,6 +10,8 @@ import SwiftUI struct ProductDetailsView: View { @ObservedObject var model: ProductDetailsViewModel + @Environment(\.dismiss) private var dismiss + var body: some View { Form { Section { @@ -17,10 +19,19 @@ struct ProductDetailsView: View { TextField("Product Price", value: $model.price, formatter: NumberFormatter()) } } + .task { await model.loadProductIfNeeded() } .toolbar { ToolbarItem(placement: .primaryAction) { - Button("Save") { - Task { await model.saveButtonTapped() } + if model.isSavingProduct { + ProgressView() + } else { + Button("Save") { + Task { + if await model.saveButtonTapped() { + dismiss() + } + } + } } } } diff --git a/Examples/ProductSample/ProductDetailsViewModel.swift b/Examples/ProductSample/ProductDetailsViewModel.swift index a4dd06b6..28c685f0 100644 --- a/Examples/ProductSample/ProductDetailsViewModel.swift +++ b/Examples/ProductSample/ProductDetailsViewModel.swift @@ -7,6 +7,7 @@ import SwiftUI +@MainActor final class ProductDetailsViewModel: ObservableObject { private let productId: Product.ID? @@ -17,15 +18,14 @@ final class ProductDetailsViewModel: ObservableObject { @Published var name: String = "" @Published var price: Double = 0 + @Published var isSavingProduct = false + let onCompletion: (Bool) -> Void init( - updateProductUseCase: any UpdateProductUseCase = UpdateProductUseCaseImpl( - repository: ProductRepositoryImpl(supabase: supabase), storage: supabase.storage), - createProductUseCase: any CreateProductUseCase = CreateProductUseCaseImpl( - repository: ProductRepositoryImpl(supabase: supabase)), - getProductUseCase: any GetProductUseCase = GetProductUseCaseImpl( - repository: ProductRepositoryImpl(supabase: supabase)), + updateProductUseCase: any UpdateProductUseCase = Dependencies.updateProductUseCase, + createProductUseCase: any CreateProductUseCase = Dependencies.createProductUseCase, + getProductUseCase: any GetProductUseCase = Dependencies.getProductUseCase, productId: Product.ID?, onCompletion: @escaping (Bool) -> Void ) { @@ -48,7 +48,10 @@ final class ProductDetailsViewModel: ObservableObject { } } - func saveButtonTapped() async { + func saveButtonTapped() async -> Bool { + isSavingProduct = true + defer { isSavingProduct = false } + let result: Result if let productId { @@ -71,8 +74,10 @@ final class ProductDetailsViewModel: ObservableObject { case .failure(let error): dump(error) onCompletion(false) + return false case .success: onCompletion(true) + return true } } } diff --git a/Examples/ProductSample/ProductListView.swift b/Examples/ProductSample/ProductListView.swift index 6cd59313..7b17f7ee 100644 --- a/Examples/ProductSample/ProductListView.swift +++ b/Examples/ProductSample/ProductListView.swift @@ -25,7 +25,7 @@ struct ProductListView: View { ForEach(model.products) { product in NavigationLink(value: ProductDetailRoute(productId: product.id)) { - Text(product.name) + LabeledContent(product.name, value: product.price.formatted(.currency(code: "USD"))) } } } diff --git a/Examples/ProductSample/ProductListViewModel.swift b/Examples/ProductSample/ProductListViewModel.swift index 6de5fc2b..85827331 100644 --- a/Examples/ProductSample/ProductListViewModel.swift +++ b/Examples/ProductSample/ProductListViewModel.swift @@ -9,13 +9,13 @@ import SwiftUI @MainActor final class ProductListViewModel: ObservableObject { - let productRepository: ProductRepository + private let productRepository: ProductRepository @Published var products: [Product] = [] @Published var isLoading = false @Published var error: Error? - init(productRepository: ProductRepository = ProductRepositoryImpl(supabase: supabase)) { + init(productRepository: ProductRepository = Dependencies.productRepository) { self.productRepository = productRepository } diff --git a/Examples/ProductSample/ProductSampleApp.swift b/Examples/ProductSample/ProductSampleApp.swift index f1032d6a..3b45dab7 100644 --- a/Examples/ProductSample/ProductSampleApp.swift +++ b/Examples/ProductSample/ProductSampleApp.swift @@ -16,8 +16,3 @@ struct ProductSampleApp: App { } } } - -let supabase = SupabaseClient( - supabaseURL: URL(string: Config.SUPABASE_URL)!, - supabaseKey: Config.SUPABASE_ANON_KEY -) diff --git a/Sources/Storage/StorageFileApi.swift b/Sources/Storage/StorageFileApi.swift index bc5dde65..0322a900 100644 --- a/Sources/Storage/StorageFileApi.swift +++ b/Sources/Storage/StorageFileApi.swift @@ -209,3 +209,7 @@ public class StorageFileApi: StorageApi { return generatedUrl } } + +private func fileName(fromPath path: String) -> String { + (path as NSString).lastPathComponent +} From 51108d7afb6ba9ea015a19cae04b84d56af1c6b4 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 19 Oct 2023 09:39:13 -0300 Subject: [PATCH 03/14] Add logger --- Examples/Examples.xcodeproj/project.pbxproj | 4 ++++ Examples/ProductSample/Logger.swift | 15 +++++++++++++++ .../ProductSample/ProductDetailsViewModel.swift | 8 +++++++- Examples/ProductSample/ProductListViewModel.swift | 4 ++++ 4 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 Examples/ProductSample/Logger.swift diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 2bf359f2..01d5258e 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ 79C592042AE159130088A9C8 /* CreateProductUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C592032AE159130088A9C8 /* CreateProductUseCase.swift */; }; 79C592062AE159250088A9C8 /* UpdateProductUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C592052AE159250088A9C8 /* UpdateProductUseCase.swift */; }; 79C592082AE159390088A9C8 /* GetProductUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C592072AE159390088A9C8 /* GetProductUseCase.swift */; }; + 79C5920A2AE159E20088A9C8 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C592092AE159E20088A9C8 /* Logger.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -79,6 +80,7 @@ 79C592032AE159130088A9C8 /* CreateProductUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateProductUseCase.swift; sourceTree = ""; }; 79C592052AE159250088A9C8 /* UpdateProductUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateProductUseCase.swift; sourceTree = ""; }; 79C592072AE159390088A9C8 /* GetProductUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProductUseCase.swift; sourceTree = ""; }; + 79C592092AE159E20088A9C8 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -176,6 +178,7 @@ 79C591FC2AE152590088A9C8 /* Config.plist */, 79C591FE2AE1527C0088A9C8 /* Config.swift */, 79C592002AE1561D0088A9C8 /* Dependencies.swift */, + 79C592092AE159E20088A9C8 /* Logger.swift */, ); path = ProductSample; sourceTree = ""; @@ -345,6 +348,7 @@ 79C591F22AE127180088A9C8 /* ProductListView.swift in Sources */, 79C591F02AE126120088A9C8 /* ProductListViewModel.swift in Sources */, 79C591FF2AE1527C0088A9C8 /* Config.swift in Sources */, + 79C5920A2AE159E20088A9C8 /* Logger.swift in Sources */, 79C591FB2AE12FDE0088A9C8 /* UseCase.swift in Sources */, 79C591F42AE12A0D0088A9C8 /* AddProductView.swift in Sources */, 79C592062AE159250088A9C8 /* UpdateProductUseCase.swift in Sources */, diff --git a/Examples/ProductSample/Logger.swift b/Examples/ProductSample/Logger.swift new file mode 100644 index 00000000..1f6cfffa --- /dev/null +++ b/Examples/ProductSample/Logger.swift @@ -0,0 +1,15 @@ +// +// Logger.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import Foundation +import OSLog + +extension Logger { + static func make(category: String) -> Logger { + Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: category) + } +} diff --git a/Examples/ProductSample/ProductDetailsViewModel.swift b/Examples/ProductSample/ProductDetailsViewModel.swift index 28c685f0..50663450 100644 --- a/Examples/ProductSample/ProductDetailsViewModel.swift +++ b/Examples/ProductSample/ProductDetailsViewModel.swift @@ -5,10 +5,13 @@ // Created by Guilherme Souza on 19/10/23. // +import OSLog import SwiftUI @MainActor final class ProductDetailsViewModel: ObservableObject { + private let logger = Logger.make(category: "ProductDetailsViewModel") + private let productId: Product.ID? private let updateProductUseCase: any UpdateProductUseCase @@ -55,6 +58,7 @@ final class ProductDetailsViewModel: ObservableObject { let result: Result if let productId { + logger.info("Will update product: \(productId)") result = await updateProductUseCase.execute( input: UpdateProductParams( id: productId, @@ -65,6 +69,7 @@ final class ProductDetailsViewModel: ObservableObject { ) ) } else { + logger.info("Will add product") result = await createProductUseCase.execute( input: CreateProductParams(name: name, price: price, image: nil) ) @@ -72,10 +77,11 @@ final class ProductDetailsViewModel: ObservableObject { switch result { case .failure(let error): - dump(error) + logger.error("Save failed: \(error)") onCompletion(false) return false case .success: + logger.error("Save succeeded") onCompletion(true) return true } diff --git a/Examples/ProductSample/ProductListViewModel.swift b/Examples/ProductSample/ProductListViewModel.swift index 85827331..4bda3bb5 100644 --- a/Examples/ProductSample/ProductListViewModel.swift +++ b/Examples/ProductSample/ProductListViewModel.swift @@ -5,10 +5,12 @@ // Created by Guilherme Souza on 19/10/23. // +import OSLog import SwiftUI @MainActor final class ProductListViewModel: ObservableObject { + private let logger = Logger.make(category: "ProductListViewModel") private let productRepository: ProductRepository @Published var products: [Product] = [] @@ -25,8 +27,10 @@ final class ProductListViewModel: ObservableObject { do { products = try await productRepository.getProducts() + logger.info("Products loaded.") self.error = nil } catch { + logger.error("Error loading products: \(error)") self.error = error } } From 7c34f8ed673ccff2e49d46734c0cab41b21e4ae9 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 19 Oct 2023 17:29:23 -0300 Subject: [PATCH 04/14] Working on image upload --- Examples/Examples.xcodeproj/project.pbxproj | 8 ++ Examples/ProductSample/Dependencies.swift | 19 ++- .../UseCases/CreateProductUseCase.swift | 25 ++-- .../Domain/UseCases/GetProductUseCase.swift | 11 +- .../Domain/UseCases/ImageUploadUseCase.swift | 30 ++++ .../UseCases/UpdateProductUseCase.swift | 46 +++---- .../Domain/UseCases/UseCase.swift | 2 +- .../ProductSample/ProductDetailsView.swift | 24 ++++ .../ProductDetailsViewModel.swift | 130 ++++++++++++++---- .../ProductSample/ProductRepository.swift | 10 +- Examples/ProductSample/Result.swift | 17 +++ 11 files changed, 244 insertions(+), 78 deletions(-) create mode 100644 Examples/ProductSample/Domain/UseCases/ImageUploadUseCase.swift create mode 100644 Examples/ProductSample/Result.swift diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 01d5258e..104e32a7 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -43,6 +43,8 @@ 79C592062AE159250088A9C8 /* UpdateProductUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C592052AE159250088A9C8 /* UpdateProductUseCase.swift */; }; 79C592082AE159390088A9C8 /* GetProductUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C592072AE159390088A9C8 /* GetProductUseCase.swift */; }; 79C5920A2AE159E20088A9C8 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C592092AE159E20088A9C8 /* Logger.swift */; }; + 79C5920C2AE1B8820088A9C8 /* Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C5920B2AE1B8820088A9C8 /* Result.swift */; }; + 79C5920E2AE1C56D0088A9C8 /* ImageUploadUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C5920D2AE1C56D0088A9C8 /* ImageUploadUseCase.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -81,6 +83,8 @@ 79C592052AE159250088A9C8 /* UpdateProductUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateProductUseCase.swift; sourceTree = ""; }; 79C592072AE159390088A9C8 /* GetProductUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProductUseCase.swift; sourceTree = ""; }; 79C592092AE159E20088A9C8 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + 79C5920B2AE1B8820088A9C8 /* Result.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Result.swift; sourceTree = ""; }; + 79C5920D2AE1C56D0088A9C8 /* ImageUploadUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageUploadUseCase.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -179,6 +183,7 @@ 79C591FE2AE1527C0088A9C8 /* Config.swift */, 79C592002AE1561D0088A9C8 /* Dependencies.swift */, 79C592092AE159E20088A9C8 /* Logger.swift */, + 79C5920B2AE1B8820088A9C8 /* Result.swift */, ); path = ProductSample; sourceTree = ""; @@ -206,6 +211,7 @@ 79C592032AE159130088A9C8 /* CreateProductUseCase.swift */, 79C592052AE159250088A9C8 /* UpdateProductUseCase.swift */, 79C592072AE159390088A9C8 /* GetProductUseCase.swift */, + 79C5920D2AE1C56D0088A9C8 /* ImageUploadUseCase.swift */, ); path = UseCases; sourceTree = ""; @@ -346,7 +352,9 @@ 79C592042AE159130088A9C8 /* CreateProductUseCase.swift in Sources */, 79C592012AE1561D0088A9C8 /* Dependencies.swift in Sources */, 79C591F22AE127180088A9C8 /* ProductListView.swift in Sources */, + 79C5920E2AE1C56D0088A9C8 /* ImageUploadUseCase.swift in Sources */, 79C591F02AE126120088A9C8 /* ProductListViewModel.swift in Sources */, + 79C5920C2AE1B8820088A9C8 /* Result.swift in Sources */, 79C591FF2AE1527C0088A9C8 /* Config.swift in Sources */, 79C5920A2AE159E20088A9C8 /* Logger.swift in Sources */, 79C591FB2AE12FDE0088A9C8 /* UseCase.swift in Sources */, diff --git a/Examples/ProductSample/Dependencies.swift b/Examples/ProductSample/Dependencies.swift index 8862b10f..5c313d28 100644 --- a/Examples/ProductSample/Dependencies.swift +++ b/Examples/ProductSample/Dependencies.swift @@ -15,13 +15,26 @@ enum Dependencies { ) // MARK: Repositories + static let productRepository: ProductRepository = ProductRepositoryImpl(supabase: supabase) // MARK: Use Cases + + static let imageUploadUseCase: any ImageUploadUseCase = ImageUploadUseCaseImpl( + storage: supabase.storage + ) + static let updateProductUseCase: any UpdateProductUseCase = UpdateProductUseCaseImpl( - repository: productRepository, storage: supabase.storage) + repository: productRepository, + imageUploadUseCase: imageUploadUseCase + ) + static let createProductUseCase: any CreateProductUseCase = CreateProductUseCaseImpl( - repository: productRepository) + repository: productRepository, + imageUploadUseCase: imageUploadUseCase + ) + static let getProductUseCase: any GetProductUseCase = GetProductUseCaseImpl( - repository: productRepository) + repository: productRepository + ) } diff --git a/Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift b/Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift index 5365f34f..db0fd802 100644 --- a/Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift +++ b/Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift @@ -6,24 +6,31 @@ // import Foundation +import Supabase -struct CreateProductParams: Encodable { +struct CreateProductParams { let name: String let price: Double - let image: String? + let image: ImageUploadParams? } -protocol CreateProductUseCase: UseCase> {} +protocol CreateProductUseCase: UseCase> {} struct CreateProductUseCaseImpl: CreateProductUseCase { let repository: ProductRepository + let imageUploadUseCase: any ImageUploadUseCase - func execute(input: CreateProductParams) async -> Result<(), Error> { - do { - try await repository.createProduct(input) - return .success(()) - } catch { - return .failure(error) + func execute(input: CreateProductParams) -> Task<(), Error> { + Task { + var imageFilePath: String? + + if let image = input.image { + imageFilePath = try await imageUploadUseCase.execute(input: image).value + } + + try await repository.createProduct( + InsertProductDto(name: input.name, price: input.price, image: imageFilePath) + ) } } } diff --git a/Examples/ProductSample/Domain/UseCases/GetProductUseCase.swift b/Examples/ProductSample/Domain/UseCases/GetProductUseCase.swift index 54090844..fa94dc7c 100644 --- a/Examples/ProductSample/Domain/UseCases/GetProductUseCase.swift +++ b/Examples/ProductSample/Domain/UseCases/GetProductUseCase.swift @@ -7,17 +7,14 @@ import Foundation -protocol GetProductUseCase: UseCase> {} +protocol GetProductUseCase: UseCase> {} struct GetProductUseCaseImpl: GetProductUseCase { let repository: ProductRepository - func execute(input: Product.ID) async -> Result { - do { - let product = try await repository.getProduct(id: input) - return .success(product) - } catch { - return .failure(error) + func execute(input: Product.ID) -> Task { + Task { + try await repository.getProduct(id: input) } } } diff --git a/Examples/ProductSample/Domain/UseCases/ImageUploadUseCase.swift b/Examples/ProductSample/Domain/UseCases/ImageUploadUseCase.swift new file mode 100644 index 00000000..ba109d02 --- /dev/null +++ b/Examples/ProductSample/Domain/UseCases/ImageUploadUseCase.swift @@ -0,0 +1,30 @@ +// +// ImageUploadUseCase.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import Foundation +import Storage + +protocol ImageUploadUseCase: UseCase> {} + +struct ImageUploadUseCaseImpl: ImageUploadUseCase { + let storage: SupabaseStorageClient + + func execute(input: ImageUploadParams) -> Task { + Task { + let fileName = "\(input.fileName).\(input.fileExtension ?? "png")" + let contentType = input.mimeType ?? "image/png" + let imagePath = try await storage.from(id: "product-images") + .upload( + path: fileName, + file: File( + name: fileName, data: input.data, fileName: fileName, contentType: contentType), + fileOptions: FileOptions(contentType: contentType, upsert: true) + ) + return imagePath + } + } +} diff --git a/Examples/ProductSample/Domain/UseCases/UpdateProductUseCase.swift b/Examples/ProductSample/Domain/UseCases/UpdateProductUseCase.swift index d499c411..ae516371 100644 --- a/Examples/ProductSample/Domain/UseCases/UpdateProductUseCase.swift +++ b/Examples/ProductSample/Domain/UseCases/UpdateProductUseCase.swift @@ -8,50 +8,36 @@ import Foundation import Supabase +struct ImageUploadParams { + let fileName: String + let fileExtension: String? + let mimeType: String? + let data: Data +} + struct UpdateProductParams { var id: String var name: String? var price: Double? - - var imageName: String? - var imageFile: Data? + var image: ImageUploadParams? } -protocol UpdateProductUseCase: UseCase> {} +protocol UpdateProductUseCase: UseCase> {} struct UpdateProductUseCaseImpl: UpdateProductUseCase { let repository: ProductRepository + let imageUploadUseCase: any ImageUploadUseCase - // TODO: Abstract storage access - let storage: SupabaseStorageClient - - func execute(input: UpdateProductParams) async -> Result<(), Error> { - do { - var image: String? + func execute(input: UpdateProductParams) -> Task<(), Error> { + Task { + var imageFilePath: String? - if let imageName = input.imageName, let imageFile = input.imageFile, !imageFile.isEmpty { - let filePath = "\(imageName).png" - let imageFilePath = try await storage.from(id: "product-images") - .upload( - path: filePath, - file: File( - name: filePath, data: imageFile, fileName: filePath, contentType: "image/png"), - fileOptions: FileOptions(contentType: "image/png", upsert: true) - ) - - image = buildImageURL(imageFilePath: imageFilePath) + if let image = input.image { + imageFilePath = try await imageUploadUseCase.execute(input: image).value } try await repository.updateProduct( - id: input.id, name: input.name, price: input.price, image: image) - return .success(()) - } catch { - return .failure(error) + id: input.id, name: input.name, price: input.price, image: imageFilePath) } } - - private func buildImageURL(imageFilePath: String) -> String { - storage.configuration.url.appendingPathComponent("object/public/\(imageFilePath)") - .absoluteString - } } diff --git a/Examples/ProductSample/Domain/UseCases/UseCase.swift b/Examples/ProductSample/Domain/UseCases/UseCase.swift index 7e6b0940..90db779a 100644 --- a/Examples/ProductSample/Domain/UseCases/UseCase.swift +++ b/Examples/ProductSample/Domain/UseCases/UseCase.swift @@ -12,5 +12,5 @@ protocol UseCase { associatedtype Input associatedtype Output - func execute(input: Input) async -> Output + func execute(input: Input) -> Output } diff --git a/Examples/ProductSample/ProductDetailsView.swift b/Examples/ProductSample/ProductDetailsView.swift index 747717f2..4ac8efb1 100644 --- a/Examples/ProductSample/ProductDetailsView.swift +++ b/Examples/ProductSample/ProductDetailsView.swift @@ -5,6 +5,7 @@ // Created by Guilherme Souza on 19/10/23. // +import PhotosUI import SwiftUI struct ProductDetailsView: View { @@ -14,6 +15,29 @@ struct ProductDetailsView: View { var body: some View { Form { + Section { + Group { + switch model.imageSource { + case .remote(let url): + AsyncImage(url: url) + case .local(let productImage): + productImage.image + .resizable() + case .none: + Color.clear + } + } + .scaledToFit() + .frame(width: 80) + .overlay { + PhotosPicker(selection: $model.imageSelection, matching: .images) { + Image(systemName: "pencil.circle.fill") + .symbolRenderingMode(.multicolor) + .font(.system(size: 30)) + .foregroundColor(.accentColor) + } + } + } Section { TextField("Product Name", text: $model.name) TextField("Product Price", value: $model.price, formatter: NumberFormatter()) diff --git a/Examples/ProductSample/ProductDetailsViewModel.swift b/Examples/ProductSample/ProductDetailsViewModel.swift index 50663450..6bc1035b 100644 --- a/Examples/ProductSample/ProductDetailsViewModel.swift +++ b/Examples/ProductSample/ProductDetailsViewModel.swift @@ -6,6 +6,7 @@ // import OSLog +import PhotosUI import SwiftUI @MainActor @@ -20,7 +21,36 @@ final class ProductDetailsViewModel: ObservableObject { @Published var name: String = "" @Published var price: Double = 0 + @Published private var imageURL: URL? + enum ImageSource { + case remote(URL) + case local(ProductImage) + } + + var imageSource: ImageSource? { + if case let .success(image) = self.image { + return .local(image) + } + + if let imageURL { + return .remote(imageURL) + } + + return nil + } + + @Published var imageSelection: PhotosPickerItem? { + didSet { + if let imageSelection { + Task { + await loadTransferable(from: imageSelection) + } + } + } + } + + @Published private var image: Result? @Published var isSavingProduct = false let onCompletion: (Bool) -> Void @@ -42,11 +72,20 @@ final class ProductDetailsViewModel: ObservableObject { func loadProductIfNeeded() async { guard let productId else { return } - switch await getProductUseCase.execute(input: productId) { - case .success(let product): + do { + let product = try await getProductUseCase.execute(input: productId).value name = product.name price = product.price - case .failure(let error): + + if let image = product.image, + let signedPath = try? await Dependencies.supabase.storage.from(id: "product-images") + .createSignedURL(path: image, expiresIn: 3600).signedURL + { + + imageURL = Dependencies.supabase.storage.configuration.url.appendingPathComponent( + signedPath.path) + } + } catch { dump(error) } } @@ -55,35 +94,74 @@ final class ProductDetailsViewModel: ObservableObject { isSavingProduct = true defer { isSavingProduct = false } - let result: Result - - if let productId { - logger.info("Will update product: \(productId)") - result = await updateProductUseCase.execute( - input: UpdateProductParams( - id: productId, - name: name, - price: price, - imageName: nil, - imageFile: nil - ) - ) - } else { - logger.info("Will add product") - result = await createProductUseCase.execute( - input: CreateProductParams(name: name, price: price, image: nil) + let imageUploadParams = image?.value.map { image in + ImageUploadParams( + fileName: UUID().uuidString, + fileExtension: imageSelection?.supportedContentTypes.first?.preferredFilenameExtension, + mimeType: imageSelection?.supportedContentTypes.first?.preferredMIMEType, + data: image.data ) } - switch result { - case .failure(let error): - logger.error("Save failed: \(error)") - onCompletion(false) - return false - case .success: + do { + if let productId { + logger.info("Will update product: \(productId)") + + try await updateProductUseCase.execute( + input: UpdateProductParams( + id: productId, + name: name, + price: price, + image: imageUploadParams + ) + ).value + } else { + logger.info("Will add product") + try await createProductUseCase.execute( + input: CreateProductParams( + name: name, + price: price, + image: imageUploadParams + ) + ).value + } + logger.error("Save succeeded") onCompletion(true) return true + } catch { + logger.error("Save failed: \(error)") + onCompletion(false) + return false + } + } + + private func loadTransferable(from imageSelection: PhotosPickerItem) async { + do { + let image = try await imageSelection.loadTransferable(type: ProductImage.self) + self.image = image.map(Result.success) + } catch { + self.image = .failure(error) } } } + +struct ProductImage: Transferable { + let image: Image + let data: Data + + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(importedContentType: .image) { data in + guard let uiImage = UIImage(data: data) else { + throw TransferError.importFailed + } + + let image = Image(uiImage: uiImage) + return ProductImage(image: image, data: data) + } + } +} + +enum TransferError: Error { + case importFailed +} diff --git a/Examples/ProductSample/ProductRepository.swift b/Examples/ProductSample/ProductRepository.swift index 9441037e..b503b213 100644 --- a/Examples/ProductSample/ProductRepository.swift +++ b/Examples/ProductSample/ProductRepository.swift @@ -8,8 +8,14 @@ import Foundation import Supabase +struct InsertProductDto: Encodable { + let name: String + let price: Double + let image: String? +} + protocol ProductRepository { - func createProduct(_ product: CreateProductParams) async throws + 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 @@ -19,7 +25,7 @@ protocol ProductRepository { struct ProductRepositoryImpl: ProductRepository { let supabase: SupabaseClient - func createProduct(_ product: CreateProductParams) async throws { + func createProduct(_ product: InsertProductDto) async throws { try await supabase.database.from("products").insert(values: product).execute() } diff --git a/Examples/ProductSample/Result.swift b/Examples/ProductSample/Result.swift new file mode 100644 index 00000000..acda04c1 --- /dev/null +++ b/Examples/ProductSample/Result.swift @@ -0,0 +1,17 @@ +// +// File.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import Foundation + +extension Result { + var value: Success? { + if case .success(let success) = self { + return success + } + return nil + } +} From 8a942f3b5097776d45e1287f572042c000cf229b Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 19 Oct 2023 18:13:11 -0300 Subject: [PATCH 05/14] Fix image download --- Examples/Examples.xcodeproj/project.pbxproj | 22 ++++-- .../{ => Data}/AuthenticationRepository.swift | 0 .../Data/ProductImageStorageRepository.swift | 39 ++++++++++ .../{ => Data}/ProductRepository.swift | 5 ++ Examples/ProductSample/Dependencies.swift | 16 ++-- .../ProductSample/{ => Domain}/Product.swift | 6 +- .../UseCases/CreateProductUseCase.swift | 8 +- .../Domain/UseCases/GetProductUseCase.swift | 4 +- .../Domain/UseCases/ImageUploadUseCase.swift | 30 -------- .../UseCases/UpdateProductUseCase.swift | 8 +- .../ProductSample/ProductDetailsView.swift | 7 +- .../ProductDetailsViewModel.swift | 74 ++++++++++--------- Sources/Storage/StorageFileApi.swift | 1 + 13 files changed, 122 insertions(+), 98 deletions(-) rename Examples/ProductSample/{ => Data}/AuthenticationRepository.swift (100%) create mode 100644 Examples/ProductSample/Data/ProductImageStorageRepository.swift rename Examples/ProductSample/{ => Data}/ProductRepository.swift (95%) rename Examples/ProductSample/{ => Domain}/Product.swift (68%) delete mode 100644 Examples/ProductSample/Domain/UseCases/ImageUploadUseCase.swift diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 104e32a7..e73c7477 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -44,7 +44,7 @@ 79C592082AE159390088A9C8 /* GetProductUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C592072AE159390088A9C8 /* GetProductUseCase.swift */; }; 79C5920A2AE159E20088A9C8 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C592092AE159E20088A9C8 /* Logger.swift */; }; 79C5920C2AE1B8820088A9C8 /* Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C5920B2AE1B8820088A9C8 /* Result.swift */; }; - 79C5920E2AE1C56D0088A9C8 /* ImageUploadUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C5920D2AE1C56D0088A9C8 /* ImageUploadUseCase.swift */; }; + 79C592112AE1CD040088A9C8 /* ProductImageStorageRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C592102AE1CD040088A9C8 /* ProductImageStorageRepository.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -84,7 +84,7 @@ 79C592072AE159390088A9C8 /* GetProductUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProductUseCase.swift; sourceTree = ""; }; 79C592092AE159E20088A9C8 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; 79C5920B2AE1B8820088A9C8 /* Result.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Result.swift; sourceTree = ""; }; - 79C5920D2AE1C56D0088A9C8 /* ImageUploadUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageUploadUseCase.swift; sourceTree = ""; }; + 79C592102AE1CD040088A9C8 /* ProductImageStorageRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductImageStorageRepository.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -166,14 +166,12 @@ 79C591DA2AE0880F0088A9C8 /* ProductSample */ = { isa = PBXGroup; children = ( + 79C5920F2AE1CCE80088A9C8 /* Data */, 79C591F92AE12FD10088A9C8 /* Domain */, 79C591DB2AE0880F0088A9C8 /* ProductSampleApp.swift */, 79C591DD2AE0880F0088A9C8 /* AppView.swift */, 79C591DF2AE088110088A9C8 /* Assets.xcassets */, 79C591E12AE088110088A9C8 /* Preview Content */, - 79C591E92AE089230088A9C8 /* Product.swift */, - 79C591EB2AE089510088A9C8 /* ProductRepository.swift */, - 79C591ED2AE1258B0088A9C8 /* AuthenticationRepository.swift */, 79C591EF2AE126120088A9C8 /* ProductListViewModel.swift */, 79C591F12AE127180088A9C8 /* ProductListView.swift */, 79C591F32AE12A0D0088A9C8 /* AddProductView.swift */, @@ -199,6 +197,7 @@ 79C591F92AE12FD10088A9C8 /* Domain */ = { isa = PBXGroup; children = ( + 79C591E92AE089230088A9C8 /* Product.swift */, 79C592022AE159070088A9C8 /* UseCases */, ); path = Domain; @@ -211,11 +210,20 @@ 79C592032AE159130088A9C8 /* CreateProductUseCase.swift */, 79C592052AE159250088A9C8 /* UpdateProductUseCase.swift */, 79C592072AE159390088A9C8 /* GetProductUseCase.swift */, - 79C5920D2AE1C56D0088A9C8 /* ImageUploadUseCase.swift */, ); path = UseCases; sourceTree = ""; }; + 79C5920F2AE1CCE80088A9C8 /* Data */ = { + isa = PBXGroup; + children = ( + 79C591EB2AE089510088A9C8 /* ProductRepository.swift */, + 79C591ED2AE1258B0088A9C8 /* AuthenticationRepository.swift */, + 79C592102AE1CD040088A9C8 /* ProductImageStorageRepository.swift */, + ); + path = Data; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -352,7 +360,7 @@ 79C592042AE159130088A9C8 /* CreateProductUseCase.swift in Sources */, 79C592012AE1561D0088A9C8 /* Dependencies.swift in Sources */, 79C591F22AE127180088A9C8 /* ProductListView.swift in Sources */, - 79C5920E2AE1C56D0088A9C8 /* ImageUploadUseCase.swift in Sources */, + 79C592112AE1CD040088A9C8 /* ProductImageStorageRepository.swift in Sources */, 79C591F02AE126120088A9C8 /* ProductListViewModel.swift in Sources */, 79C5920C2AE1B8820088A9C8 /* Result.swift in Sources */, 79C591FF2AE1527C0088A9C8 /* Config.swift in Sources */, diff --git a/Examples/ProductSample/AuthenticationRepository.swift b/Examples/ProductSample/Data/AuthenticationRepository.swift similarity index 100% rename from Examples/ProductSample/AuthenticationRepository.swift rename to Examples/ProductSample/Data/AuthenticationRepository.swift diff --git a/Examples/ProductSample/Data/ProductImageStorageRepository.swift b/Examples/ProductSample/Data/ProductImageStorageRepository.swift new file mode 100644 index 00000000..2a51d9ac --- /dev/null +++ b/Examples/ProductSample/Data/ProductImageStorageRepository.swift @@ -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) + } +} diff --git a/Examples/ProductSample/ProductRepository.swift b/Examples/ProductSample/Data/ProductRepository.swift similarity index 95% rename from Examples/ProductSample/ProductRepository.swift rename to Examples/ProductSample/Data/ProductRepository.swift index b503b213..7e73a5c4 100644 --- a/Examples/ProductSample/ProductRepository.swift +++ b/Examples/ProductSample/Data/ProductRepository.swift @@ -58,6 +58,11 @@ struct ProductRepositoryImpl: ProductRepository { 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) diff --git a/Examples/ProductSample/Dependencies.swift b/Examples/ProductSample/Dependencies.swift index 5c313d28..a1b457f0 100644 --- a/Examples/ProductSample/Dependencies.swift +++ b/Examples/ProductSample/Dependencies.swift @@ -17,24 +17,22 @@ enum Dependencies { // MARK: Repositories static let productRepository: ProductRepository = ProductRepositoryImpl(supabase: supabase) + static let productImageStorageRepository: ProductImageStorageRepository = + ProductImageStorageRepositoryImpl(storage: supabase.storage) // MARK: Use Cases - static let imageUploadUseCase: any ImageUploadUseCase = ImageUploadUseCaseImpl( - storage: supabase.storage - ) - static let updateProductUseCase: any UpdateProductUseCase = UpdateProductUseCaseImpl( - repository: productRepository, - imageUploadUseCase: imageUploadUseCase + productRepository: productRepository, + productImageStorageRepository: productImageStorageRepository ) static let createProductUseCase: any CreateProductUseCase = CreateProductUseCaseImpl( - repository: productRepository, - imageUploadUseCase: imageUploadUseCase + productRepository: productRepository, + productImageStorageRepository: productImageStorageRepository ) static let getProductUseCase: any GetProductUseCase = GetProductUseCaseImpl( - repository: productRepository + productRepository: productRepository ) } diff --git a/Examples/ProductSample/Product.swift b/Examples/ProductSample/Domain/Product.swift similarity index 68% rename from Examples/ProductSample/Product.swift rename to Examples/ProductSample/Domain/Product.swift index 69b77d8e..44ffb8ab 100644 --- a/Examples/ProductSample/Product.swift +++ b/Examples/ProductSample/Domain/Product.swift @@ -11,5 +11,9 @@ struct Product: Identifiable, Decodable { let id: String let name: String let price: Double - let image: String? + let image: ImageKey? +} + +struct ImageKey: RawRepresentable, Decodable { + var rawValue: String } diff --git a/Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift b/Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift index db0fd802..747d6a3d 100644 --- a/Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift +++ b/Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift @@ -17,18 +17,18 @@ struct CreateProductParams { protocol CreateProductUseCase: UseCase> {} struct CreateProductUseCaseImpl: CreateProductUseCase { - let repository: ProductRepository - let imageUploadUseCase: any ImageUploadUseCase + let productRepository: ProductRepository + let productImageStorageRepository: ProductImageStorageRepository func execute(input: CreateProductParams) -> Task<(), Error> { Task { var imageFilePath: String? if let image = input.image { - imageFilePath = try await imageUploadUseCase.execute(input: image).value + imageFilePath = try await productImageStorageRepository.uploadImage(image) } - try await repository.createProduct( + try await productRepository.createProduct( InsertProductDto(name: input.name, price: input.price, image: imageFilePath) ) } diff --git a/Examples/ProductSample/Domain/UseCases/GetProductUseCase.swift b/Examples/ProductSample/Domain/UseCases/GetProductUseCase.swift index fa94dc7c..039d98cf 100644 --- a/Examples/ProductSample/Domain/UseCases/GetProductUseCase.swift +++ b/Examples/ProductSample/Domain/UseCases/GetProductUseCase.swift @@ -10,11 +10,11 @@ import Foundation protocol GetProductUseCase: UseCase> {} struct GetProductUseCaseImpl: GetProductUseCase { - let repository: ProductRepository + let productRepository: ProductRepository func execute(input: Product.ID) -> Task { Task { - try await repository.getProduct(id: input) + try await productRepository.getProduct(id: input) } } } diff --git a/Examples/ProductSample/Domain/UseCases/ImageUploadUseCase.swift b/Examples/ProductSample/Domain/UseCases/ImageUploadUseCase.swift deleted file mode 100644 index ba109d02..00000000 --- a/Examples/ProductSample/Domain/UseCases/ImageUploadUseCase.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// ImageUploadUseCase.swift -// ProductSample -// -// Created by Guilherme Souza on 19/10/23. -// - -import Foundation -import Storage - -protocol ImageUploadUseCase: UseCase> {} - -struct ImageUploadUseCaseImpl: ImageUploadUseCase { - let storage: SupabaseStorageClient - - func execute(input: ImageUploadParams) -> Task { - Task { - let fileName = "\(input.fileName).\(input.fileExtension ?? "png")" - let contentType = input.mimeType ?? "image/png" - let imagePath = try await storage.from(id: "product-images") - .upload( - path: fileName, - file: File( - name: fileName, data: input.data, fileName: fileName, contentType: contentType), - fileOptions: FileOptions(contentType: contentType, upsert: true) - ) - return imagePath - } - } -} diff --git a/Examples/ProductSample/Domain/UseCases/UpdateProductUseCase.swift b/Examples/ProductSample/Domain/UseCases/UpdateProductUseCase.swift index ae516371..8e3411e3 100644 --- a/Examples/ProductSample/Domain/UseCases/UpdateProductUseCase.swift +++ b/Examples/ProductSample/Domain/UseCases/UpdateProductUseCase.swift @@ -25,18 +25,18 @@ struct UpdateProductParams { protocol UpdateProductUseCase: UseCase> {} struct UpdateProductUseCaseImpl: UpdateProductUseCase { - let repository: ProductRepository - let imageUploadUseCase: any ImageUploadUseCase + let productRepository: ProductRepository + let productImageStorageRepository: any ProductImageStorageRepository func execute(input: UpdateProductParams) -> Task<(), Error> { Task { var imageFilePath: String? if let image = input.image { - imageFilePath = try await imageUploadUseCase.execute(input: image).value + imageFilePath = try await productImageStorageRepository.uploadImage(image) } - try await repository.updateProduct( + try await productRepository.updateProduct( id: input.id, name: input.name, price: input.price, image: imageFilePath) } } diff --git a/Examples/ProductSample/ProductDetailsView.swift b/Examples/ProductSample/ProductDetailsView.swift index 4ac8efb1..4c0bafb1 100644 --- a/Examples/ProductSample/ProductDetailsView.swift +++ b/Examples/ProductSample/ProductDetailsView.swift @@ -17,13 +17,10 @@ struct ProductDetailsView: View { Form { Section { Group { - switch model.imageSource { - case .remote(let url): - AsyncImage(url: url) - case .local(let productImage): + if let productImage = model.imageSource?.productImage { productImage.image .resizable() - case .none: + } else { Color.clear } } diff --git a/Examples/ProductSample/ProductDetailsViewModel.swift b/Examples/ProductSample/ProductDetailsViewModel.swift index 6bc1035b..b7cae005 100644 --- a/Examples/ProductSample/ProductDetailsViewModel.swift +++ b/Examples/ProductSample/ProductDetailsViewModel.swift @@ -18,26 +18,20 @@ final class ProductDetailsViewModel: ObservableObject { private let updateProductUseCase: any UpdateProductUseCase private let createProductUseCase: any CreateProductUseCase private let getProductUseCase: any GetProductUseCase + private let productImageStorage: ProductImageStorageRepository @Published var name: String = "" @Published var price: Double = 0 - @Published private var imageURL: URL? enum ImageSource { - case remote(URL) + case remote(ProductImage) case local(ProductImage) - } - - var imageSource: ImageSource? { - if case let .success(image) = self.image { - return .local(image) - } - if let imageURL { - return .remote(imageURL) + var productImage: ProductImage { + switch self { + case .remote(let image), .local(let image): image + } } - - return nil } @Published var imageSelection: PhotosPickerItem? { @@ -50,7 +44,7 @@ final class ProductDetailsViewModel: ObservableObject { } } - @Published private var image: Result? + @Published var imageSource: ImageSource? @Published var isSavingProduct = false let onCompletion: (Bool) -> Void @@ -59,12 +53,14 @@ final class ProductDetailsViewModel: ObservableObject { updateProductUseCase: any UpdateProductUseCase = Dependencies.updateProductUseCase, createProductUseCase: any CreateProductUseCase = Dependencies.createProductUseCase, getProductUseCase: any GetProductUseCase = Dependencies.getProductUseCase, + productImageStorage: ProductImageStorageRepository = Dependencies.productImageStorageRepository, productId: Product.ID?, onCompletion: @escaping (Bool) -> Void ) { self.updateProductUseCase = updateProductUseCase self.createProductUseCase = createProductUseCase self.getProductUseCase = getProductUseCase + self.productImageStorage = productImageStorage self.productId = productId self.onCompletion = onCompletion } @@ -77,13 +73,9 @@ final class ProductDetailsViewModel: ObservableObject { name = product.name price = product.price - if let image = product.image, - let signedPath = try? await Dependencies.supabase.storage.from(id: "product-images") - .createSignedURL(path: image, expiresIn: 3600).signedURL - { - - imageURL = Dependencies.supabase.storage.configuration.url.appendingPathComponent( - signedPath.path) + if let image = product.image { + let data = try await productImageStorage.downloadImage(image) + imageSource = ProductImage(data: data).map(ImageSource.remote) } } catch { dump(error) @@ -94,14 +86,17 @@ final class ProductDetailsViewModel: ObservableObject { isSavingProduct = true defer { isSavingProduct = false } - let imageUploadParams = image?.value.map { image in - ImageUploadParams( - fileName: UUID().uuidString, - fileExtension: imageSelection?.supportedContentTypes.first?.preferredFilenameExtension, - mimeType: imageSelection?.supportedContentTypes.first?.preferredMIMEType, - data: image.data - ) - } + let imageUploadParams = + if case let .local(image) = imageSource { + ImageUploadParams( + fileName: UUID().uuidString, + fileExtension: imageSelection?.supportedContentTypes.first?.preferredFilenameExtension, + mimeType: imageSelection?.supportedContentTypes.first?.preferredMIMEType, + data: image.data + ) + } else { + ImageUploadParams?.none + } do { if let productId { @@ -137,11 +132,8 @@ final class ProductDetailsViewModel: ObservableObject { } private func loadTransferable(from imageSelection: PhotosPickerItem) async { - do { - let image = try await imageSelection.loadTransferable(type: ProductImage.self) - self.image = image.map(Result.success) - } catch { - self.image = .failure(error) + if let image = try? await imageSelection.loadTransferable(type: ProductImage.self) { + self.imageSource = .local(image) } } } @@ -152,13 +144,23 @@ struct ProductImage: Transferable { static var transferRepresentation: some TransferRepresentation { DataRepresentation(importedContentType: .image) { data in - guard let uiImage = UIImage(data: data) else { + guard let image = ProductImage(data: data) else { throw TransferError.importFailed } - let image = Image(uiImage: uiImage) - return ProductImage(image: image, data: data) + return image + } + } +} + +extension ProductImage { + init?(data: Data) { + guard let uiImage = UIImage(data: data) else { + return nil } + + let image = Image(uiImage: uiImage) + self.init(image: image, data: data) } } diff --git a/Sources/Storage/StorageFileApi.swift b/Sources/Storage/StorageFileApi.swift index 0322a900..cba6a10a 100644 --- a/Sources/Storage/StorageFileApi.swift +++ b/Sources/Storage/StorageFileApi.swift @@ -164,6 +164,7 @@ public class StorageFileApi: StorageApi { /// `folder/image.png`. @discardableResult public func download(path: String) async throws -> Data { + // TODO: implement missing functionality from https://github.com/supabase/storage-js/blob/main/src/packages/StorageFileApi.ts#L466 try await execute( Request(path: "/object/\(bucketId)/\(path)", method: "GET") ) From 4735d7b61a86d37f25c6341c41d9ebfe301f0d2c Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 19 Oct 2023 18:27:10 -0300 Subject: [PATCH 06/14] Swipe to delete --- Examples/ProductSample/ProductDetailsViewModel.swift | 2 +- Examples/ProductSample/ProductListView.swift | 5 +++++ Examples/ProductSample/ProductListViewModel.swift | 10 +++++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Examples/ProductSample/ProductDetailsViewModel.swift b/Examples/ProductSample/ProductDetailsViewModel.swift index b7cae005..146f180e 100644 --- a/Examples/ProductSample/ProductDetailsViewModel.swift +++ b/Examples/ProductSample/ProductDetailsViewModel.swift @@ -78,7 +78,7 @@ final class ProductDetailsViewModel: ObservableObject { imageSource = ProductImage(data: data).map(ImageSource.remote) } } catch { - dump(error) + logger.error("Error loading product: \(error)") } } diff --git a/Examples/ProductSample/ProductListView.swift b/Examples/ProductSample/ProductListView.swift index 7b17f7ee..1b1fb601 100644 --- a/Examples/ProductSample/ProductListView.swift +++ b/Examples/ProductSample/ProductListView.swift @@ -28,6 +28,11 @@ struct ProductListView: View { LabeledContent(product.name, value: product.price.formatted(.currency(code: "USD"))) } } + .onDelete { indexSet in + Task { + await model.didSwipeToDelete(indexSet) + } + } } .listStyle(.plain) .overlay { diff --git a/Examples/ProductSample/ProductListViewModel.swift b/Examples/ProductSample/ProductListViewModel.swift index 4bda3bb5..cc3e17c2 100644 --- a/Examples/ProductSample/ProductListViewModel.swift +++ b/Examples/ProductSample/ProductListViewModel.swift @@ -35,13 +35,21 @@ final class ProductListViewModel: ObservableObject { } } - func removeItem(product: Product) async { + func didSwipeToDelete(_ indexes: IndexSet) async { + for index in indexes { + let product = products[index] + await removeItem(product: product) + } + } + + private func removeItem(product: Product) async { self.products.removeAll { $0.id == product.id } do { try await productRepository.deleteProduct(id: product.id) self.error = nil } catch { + logger.error("Failed to remove product: \(product.id) error: \(error)") self.error = error } From 1fabe2535b00dee51ac95095585f65af1c3923c6 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 19 Oct 2023 18:37:41 -0300 Subject: [PATCH 07/14] Add missing use cases --- Examples/Examples.xcodeproj/project.pbxproj | 18 ++++++++++++++++- Examples/ProductSample/Dependencies.swift | 8 ++++++++ .../Domain/{ => Models}/Product.swift | 0 .../UseCases/DeleteProductUseCase.swift | 20 +++++++++++++++++++ .../Domain/UseCases/GetProductsUseCase.swift | 20 +++++++++++++++++++ .../Domain/UseCases/UseCase.swift | 6 ++++++ .../ProductSample/ProductListViewModel.swift | 16 ++++++++++----- 7 files changed, 82 insertions(+), 6 deletions(-) rename Examples/ProductSample/Domain/{ => Models}/Product.swift (100%) create mode 100644 Examples/ProductSample/Domain/UseCases/DeleteProductUseCase.swift create mode 100644 Examples/ProductSample/Domain/UseCases/GetProductsUseCase.swift diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index e73c7477..890c199b 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 79018ACF2AE1D6CF006EA669 /* DeleteProductUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79018ACE2AE1D6CF006EA669 /* DeleteProductUseCase.swift */; }; + 79018AD12AE1D775006EA669 /* GetProductsUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79018AD02AE1D775006EA669 /* GetProductsUseCase.swift */; }; 793895CA2954ABFF0044F2B8 /* ExamplesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 793895C92954ABFF0044F2B8 /* ExamplesApp.swift */; }; 793895CC2954ABFF0044F2B8 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 793895CB2954ABFF0044F2B8 /* RootView.swift */; }; 793895CE2954AC000044F2B8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 793895CD2954AC000044F2B8 /* Assets.xcassets */; }; @@ -48,6 +50,8 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 79018ACE2AE1D6CF006EA669 /* DeleteProductUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteProductUseCase.swift; sourceTree = ""; }; + 79018AD02AE1D775006EA669 /* GetProductsUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProductsUseCase.swift; sourceTree = ""; }; 793895C62954ABFF0044F2B8 /* Examples.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Examples.app; sourceTree = BUILT_PRODUCTS_DIR; }; 793895C92954ABFF0044F2B8 /* ExamplesApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExamplesApp.swift; sourceTree = ""; }; 793895CB2954ABFF0044F2B8 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; @@ -109,6 +113,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 79018AD22AE1D7DE006EA669 /* Models */ = { + isa = PBXGroup; + children = ( + 79C591E92AE089230088A9C8 /* Product.swift */, + ); + path = Models; + sourceTree = ""; + }; 793895BD2954ABFF0044F2B8 = { isa = PBXGroup; children = ( @@ -197,7 +209,7 @@ 79C591F92AE12FD10088A9C8 /* Domain */ = { isa = PBXGroup; children = ( - 79C591E92AE089230088A9C8 /* Product.swift */, + 79018AD22AE1D7DE006EA669 /* Models */, 79C592022AE159070088A9C8 /* UseCases */, ); path = Domain; @@ -210,6 +222,8 @@ 79C592032AE159130088A9C8 /* CreateProductUseCase.swift */, 79C592052AE159250088A9C8 /* UpdateProductUseCase.swift */, 79C592072AE159390088A9C8 /* GetProductUseCase.swift */, + 79018ACE2AE1D6CF006EA669 /* DeleteProductUseCase.swift */, + 79018AD02AE1D775006EA669 /* GetProductsUseCase.swift */, ); path = UseCases; sourceTree = ""; @@ -357,6 +371,7 @@ 79C591DE2AE0880F0088A9C8 /* AppView.swift in Sources */, 79C591DC2AE0880F0088A9C8 /* ProductSampleApp.swift in Sources */, 79C591F62AE12AAC0088A9C8 /* ProductDetailsViewModel.swift in Sources */, + 79018AD12AE1D775006EA669 /* GetProductsUseCase.swift in Sources */, 79C592042AE159130088A9C8 /* CreateProductUseCase.swift in Sources */, 79C592012AE1561D0088A9C8 /* Dependencies.swift in Sources */, 79C591F22AE127180088A9C8 /* ProductListView.swift in Sources */, @@ -364,6 +379,7 @@ 79C591F02AE126120088A9C8 /* ProductListViewModel.swift in Sources */, 79C5920C2AE1B8820088A9C8 /* Result.swift in Sources */, 79C591FF2AE1527C0088A9C8 /* Config.swift in Sources */, + 79018ACF2AE1D6CF006EA669 /* DeleteProductUseCase.swift in Sources */, 79C5920A2AE159E20088A9C8 /* Logger.swift in Sources */, 79C591FB2AE12FDE0088A9C8 /* UseCase.swift in Sources */, 79C591F42AE12A0D0088A9C8 /* AddProductView.swift in Sources */, diff --git a/Examples/ProductSample/Dependencies.swift b/Examples/ProductSample/Dependencies.swift index a1b457f0..e1a7182c 100644 --- a/Examples/ProductSample/Dependencies.swift +++ b/Examples/ProductSample/Dependencies.swift @@ -35,4 +35,12 @@ enum Dependencies { static let getProductUseCase: any GetProductUseCase = GetProductUseCaseImpl( productRepository: productRepository ) + + static let deleteProductUseCase: any DeleteProductUseCase = DeleteProductUseCaseImpl( + repository: productRepository + ) + + static let getProductsUseCase: any GetProductsUseCase = GetProductsUseCaseImpl( + repository: productRepository + ) } diff --git a/Examples/ProductSample/Domain/Product.swift b/Examples/ProductSample/Domain/Models/Product.swift similarity index 100% rename from Examples/ProductSample/Domain/Product.swift rename to Examples/ProductSample/Domain/Models/Product.swift diff --git a/Examples/ProductSample/Domain/UseCases/DeleteProductUseCase.swift b/Examples/ProductSample/Domain/UseCases/DeleteProductUseCase.swift new file mode 100644 index 00000000..b030a119 --- /dev/null +++ b/Examples/ProductSample/Domain/UseCases/DeleteProductUseCase.swift @@ -0,0 +1,20 @@ +// +// DeleteProductUseCase.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import Foundation + +protocol DeleteProductUseCase: UseCase> {} + +struct DeleteProductUseCaseImpl: DeleteProductUseCase { + let repository: ProductRepository + + func execute(input: Product.ID) -> Task<(), Error> { + Task { + try await repository.deleteProduct(id: input) + } + } +} diff --git a/Examples/ProductSample/Domain/UseCases/GetProductsUseCase.swift b/Examples/ProductSample/Domain/UseCases/GetProductsUseCase.swift new file mode 100644 index 00000000..7b585996 --- /dev/null +++ b/Examples/ProductSample/Domain/UseCases/GetProductsUseCase.swift @@ -0,0 +1,20 @@ +// +// GetProductsUseCase.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import Foundation + +protocol GetProductsUseCase: UseCase> {} + +struct GetProductsUseCaseImpl: GetProductsUseCase { + let repository: any ProductRepository + + func execute(input: ()) -> Task<[Product], Error> { + Task { + try await repository.getProducts() + } + } +} diff --git a/Examples/ProductSample/Domain/UseCases/UseCase.swift b/Examples/ProductSample/Domain/UseCases/UseCase.swift index 90db779a..706434da 100644 --- a/Examples/ProductSample/Domain/UseCases/UseCase.swift +++ b/Examples/ProductSample/Domain/UseCases/UseCase.swift @@ -14,3 +14,9 @@ protocol UseCase { func execute(input: Input) -> Output } + +extension UseCase where Input == Void { + func execute() -> Output { + self.execute(input: ()) + } +} diff --git a/Examples/ProductSample/ProductListViewModel.swift b/Examples/ProductSample/ProductListViewModel.swift index cc3e17c2..0bbb9f61 100644 --- a/Examples/ProductSample/ProductListViewModel.swift +++ b/Examples/ProductSample/ProductListViewModel.swift @@ -11,14 +11,20 @@ import SwiftUI @MainActor final class ProductListViewModel: ObservableObject { private let logger = Logger.make(category: "ProductListViewModel") - private let productRepository: ProductRepository + + private let deleteProductUseCase: any DeleteProductUseCase + private let getProductsUseCase: any GetProductsUseCase @Published var products: [Product] = [] @Published var isLoading = false @Published var error: Error? - init(productRepository: ProductRepository = Dependencies.productRepository) { - self.productRepository = productRepository + init( + deleteProductUseCase: any DeleteProductUseCase = Dependencies.deleteProductUseCase, + getProductsUseCase: any GetProductsUseCase = Dependencies.getProductsUseCase + ) { + self.deleteProductUseCase = deleteProductUseCase + self.getProductsUseCase = getProductsUseCase } func loadProducts() async { @@ -26,7 +32,7 @@ final class ProductListViewModel: ObservableObject { defer { isLoading = false } do { - products = try await productRepository.getProducts() + products = try await getProductsUseCase.execute().value logger.info("Products loaded.") self.error = nil } catch { @@ -46,7 +52,7 @@ final class ProductListViewModel: ObservableObject { self.products.removeAll { $0.id == product.id } do { - try await productRepository.deleteProduct(id: product.id) + try await deleteProductUseCase.execute(input: product.id).value self.error = nil } catch { logger.error("Failed to remove product: \(product.id) error: \(error)") From b15d93a798336484d9df4dd781c71d9b2a92cc25 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 19 Oct 2023 18:41:10 -0300 Subject: [PATCH 08/14] Build ProductSample app on build-example job --- Makefile | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 95c47a69..08015fd6 100644 --- a/Makefile +++ b/Makefile @@ -13,10 +13,12 @@ test-library: done; build-example: - xcodebuild build \ - -workspace supabase-swift.xcworkspace \ - -scheme Examples \ - -destination platform="$(PLATFORM_IOS)" || exit 1; + for example in "ProductSample"; do \ + xcodebuild build \ + -workspace supabase-swift.xcworkspace \ + -scheme "$$example" \ + -destination platform="$(PLATFORM_IOS)" || exit 1; \ + done; format: @swift format -i -r . From c113667e67bbd4e552183dd91998b05b06f9efe0 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 19 Oct 2023 18:44:09 -0300 Subject: [PATCH 09/14] Organize sample app --- Examples/Examples.xcodeproj/project.pbxproj | 82 ++++++++++++++----- Examples/ProductSample/AddProductView.swift | 18 ---- .../{ => Application}/AppView.swift | 0 .../{ => Application}/Dependencies.swift | 0 .../{ => Application}/ProductSampleApp.swift | 0 .../ProductDetails}/ProductDetailsView.swift | 0 .../ProductDetailsViewModel.swift | 0 .../ProductList}/ProductListView.swift | 0 .../ProductList}/ProductListViewModel.swift | 0 .../ProductSample/{ => Helpers}/Config.swift | 0 .../ProductSample/{ => Helpers}/Logger.swift | 0 .../ProductSample/{ => Helpers}/Result.swift | 0 .../AccentColor.colorset/Contents.json | 0 .../AppIcon.appiconset/Contents.json | 0 .../Assets.xcassets/Contents.json | 0 .../Preview Assets.xcassets/Contents.json | 0 16 files changed, 63 insertions(+), 37 deletions(-) delete mode 100644 Examples/ProductSample/AddProductView.swift rename Examples/ProductSample/{ => Application}/AppView.swift (100%) rename Examples/ProductSample/{ => Application}/Dependencies.swift (100%) rename Examples/ProductSample/{ => Application}/ProductSampleApp.swift (100%) rename Examples/ProductSample/{ => Features/ProductDetails}/ProductDetailsView.swift (100%) rename Examples/ProductSample/{ => Features/ProductDetails}/ProductDetailsViewModel.swift (100%) rename Examples/ProductSample/{ => Features/ProductList}/ProductListView.swift (100%) rename Examples/ProductSample/{ => Features/ProductList}/ProductListViewModel.swift (100%) rename Examples/ProductSample/{ => Helpers}/Config.swift (100%) rename Examples/ProductSample/{ => Helpers}/Logger.swift (100%) rename Examples/ProductSample/{ => Helpers}/Result.swift (100%) rename Examples/ProductSample/{ => SupportFiles}/Assets.xcassets/AccentColor.colorset/Contents.json (100%) rename Examples/ProductSample/{ => SupportFiles}/Assets.xcassets/AppIcon.appiconset/Contents.json (100%) rename Examples/ProductSample/{ => SupportFiles}/Assets.xcassets/Contents.json (100%) rename Examples/ProductSample/{ => SupportFiles}/Preview Content/Preview Assets.xcassets/Contents.json (100%) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 890c199b..3b396de6 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -34,7 +34,6 @@ 79C591EE2AE1258B0088A9C8 /* AuthenticationRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591ED2AE1258B0088A9C8 /* AuthenticationRepository.swift */; }; 79C591F02AE126120088A9C8 /* ProductListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591EF2AE126120088A9C8 /* ProductListViewModel.swift */; }; 79C591F22AE127180088A9C8 /* ProductListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591F12AE127180088A9C8 /* ProductListView.swift */; }; - 79C591F42AE12A0D0088A9C8 /* AddProductView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591F32AE12A0D0088A9C8 /* AddProductView.swift */; }; 79C591F62AE12AAC0088A9C8 /* ProductDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591F52AE12AAC0088A9C8 /* ProductDetailsViewModel.swift */; }; 79C591F82AE12B850088A9C8 /* ProductDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591F72AE12B850088A9C8 /* ProductDetailsView.swift */; }; 79C591FB2AE12FDE0088A9C8 /* UseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591FA2AE12FDE0088A9C8 /* UseCase.swift */; }; @@ -76,7 +75,6 @@ 79C591ED2AE1258B0088A9C8 /* AuthenticationRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationRepository.swift; sourceTree = ""; }; 79C591EF2AE126120088A9C8 /* ProductListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListViewModel.swift; sourceTree = ""; }; 79C591F12AE127180088A9C8 /* ProductListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListView.swift; sourceTree = ""; }; - 79C591F32AE12A0D0088A9C8 /* AddProductView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProductView.swift; sourceTree = ""; }; 79C591F52AE12AAC0088A9C8 /* ProductDetailsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailsViewModel.swift; sourceTree = ""; }; 79C591F72AE12B850088A9C8 /* ProductDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailsView.swift; sourceTree = ""; }; 79C591FA2AE12FDE0088A9C8 /* UseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UseCase.swift; sourceTree = ""; }; @@ -121,6 +119,63 @@ path = Models; sourceTree = ""; }; + 79018AD32AE1DA1B006EA669 /* ProductList */ = { + isa = PBXGroup; + children = ( + 79C591EF2AE126120088A9C8 /* ProductListViewModel.swift */, + 79C591F12AE127180088A9C8 /* ProductListView.swift */, + ); + path = ProductList; + sourceTree = ""; + }; + 79018AD42AE1DA25006EA669 /* ProductDetails */ = { + isa = PBXGroup; + children = ( + 79C591F52AE12AAC0088A9C8 /* ProductDetailsViewModel.swift */, + 79C591F72AE12B850088A9C8 /* ProductDetailsView.swift */, + ); + path = ProductDetails; + sourceTree = ""; + }; + 79018AD52AE1DA31006EA669 /* Features */ = { + isa = PBXGroup; + children = ( + 79018AD42AE1DA25006EA669 /* ProductDetails */, + 79018AD32AE1DA1B006EA669 /* ProductList */, + ); + path = Features; + sourceTree = ""; + }; + 79018AD62AE1DA4C006EA669 /* Application */ = { + isa = PBXGroup; + children = ( + 79C591DD2AE0880F0088A9C8 /* AppView.swift */, + 79C592002AE1561D0088A9C8 /* Dependencies.swift */, + 79C591DB2AE0880F0088A9C8 /* ProductSampleApp.swift */, + ); + path = Application; + sourceTree = ""; + }; + 79018AD72AE1DA64006EA669 /* Helpers */ = { + isa = PBXGroup; + children = ( + 79C591FE2AE1527C0088A9C8 /* Config.swift */, + 79C592092AE159E20088A9C8 /* Logger.swift */, + 79C5920B2AE1B8820088A9C8 /* Result.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 79018AD92AE1DA75006EA669 /* SupportFiles */ = { + isa = PBXGroup; + children = ( + 79C591DF2AE088110088A9C8 /* Assets.xcassets */, + 79C591E12AE088110088A9C8 /* Preview Content */, + 79C591FC2AE152590088A9C8 /* Config.plist */, + ); + path = SupportFiles; + sourceTree = ""; + }; 793895BD2954ABFF0044F2B8 = { isa = PBXGroup; children = ( @@ -178,22 +233,12 @@ 79C591DA2AE0880F0088A9C8 /* ProductSample */ = { isa = PBXGroup; children = ( + 79018AD62AE1DA4C006EA669 /* Application */, 79C5920F2AE1CCE80088A9C8 /* Data */, 79C591F92AE12FD10088A9C8 /* Domain */, - 79C591DB2AE0880F0088A9C8 /* ProductSampleApp.swift */, - 79C591DD2AE0880F0088A9C8 /* AppView.swift */, - 79C591DF2AE088110088A9C8 /* Assets.xcassets */, - 79C591E12AE088110088A9C8 /* Preview Content */, - 79C591EF2AE126120088A9C8 /* ProductListViewModel.swift */, - 79C591F12AE127180088A9C8 /* ProductListView.swift */, - 79C591F32AE12A0D0088A9C8 /* AddProductView.swift */, - 79C591F52AE12AAC0088A9C8 /* ProductDetailsViewModel.swift */, - 79C591F72AE12B850088A9C8 /* ProductDetailsView.swift */, - 79C591FC2AE152590088A9C8 /* Config.plist */, - 79C591FE2AE1527C0088A9C8 /* Config.swift */, - 79C592002AE1561D0088A9C8 /* Dependencies.swift */, - 79C592092AE159E20088A9C8 /* Logger.swift */, - 79C5920B2AE1B8820088A9C8 /* Result.swift */, + 79018AD52AE1DA31006EA669 /* Features */, + 79018AD72AE1DA64006EA669 /* Helpers */, + 79018AD92AE1DA75006EA669 /* SupportFiles */, ); path = ProductSample; sourceTree = ""; @@ -382,7 +427,6 @@ 79018ACF2AE1D6CF006EA669 /* DeleteProductUseCase.swift in Sources */, 79C5920A2AE159E20088A9C8 /* Logger.swift in Sources */, 79C591FB2AE12FDE0088A9C8 /* UseCase.swift in Sources */, - 79C591F42AE12A0D0088A9C8 /* AddProductView.swift in Sources */, 79C592062AE159250088A9C8 /* UpdateProductUseCase.swift in Sources */, 79C591EA2AE089230088A9C8 /* Product.swift in Sources */, 79C592082AE159390088A9C8 /* GetProductUseCase.swift in Sources */, @@ -594,7 +638,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"ProductSample/Preview Content\""; + DEVELOPMENT_ASSET_PATHS = "\"ProductSample/SupportFiles/Preview Content\""; DEVELOPMENT_TEAM = ELTTE7K8TT; ENABLE_PREVIEWS = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -629,7 +673,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"ProductSample/Preview Content\""; + DEVELOPMENT_ASSET_PATHS = "\"ProductSample/SupportFiles/Preview Content\""; DEVELOPMENT_TEAM = ELTTE7K8TT; ENABLE_PREVIEWS = YES; GCC_C_LANGUAGE_STANDARD = gnu17; diff --git a/Examples/ProductSample/AddProductView.swift b/Examples/ProductSample/AddProductView.swift deleted file mode 100644 index 36e1ea32..00000000 --- a/Examples/ProductSample/AddProductView.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// AddProductView.swift -// ProductSample -// -// Created by Guilherme Souza on 19/10/23. -// - -import SwiftUI - -struct AddProductView: View { - var body: some View { - Text("Hello, World!") - } -} - -#Preview { - AddProductView() -} diff --git a/Examples/ProductSample/AppView.swift b/Examples/ProductSample/Application/AppView.swift similarity index 100% rename from Examples/ProductSample/AppView.swift rename to Examples/ProductSample/Application/AppView.swift diff --git a/Examples/ProductSample/Dependencies.swift b/Examples/ProductSample/Application/Dependencies.swift similarity index 100% rename from Examples/ProductSample/Dependencies.swift rename to Examples/ProductSample/Application/Dependencies.swift diff --git a/Examples/ProductSample/ProductSampleApp.swift b/Examples/ProductSample/Application/ProductSampleApp.swift similarity index 100% rename from Examples/ProductSample/ProductSampleApp.swift rename to Examples/ProductSample/Application/ProductSampleApp.swift diff --git a/Examples/ProductSample/ProductDetailsView.swift b/Examples/ProductSample/Features/ProductDetails/ProductDetailsView.swift similarity index 100% rename from Examples/ProductSample/ProductDetailsView.swift rename to Examples/ProductSample/Features/ProductDetails/ProductDetailsView.swift diff --git a/Examples/ProductSample/ProductDetailsViewModel.swift b/Examples/ProductSample/Features/ProductDetails/ProductDetailsViewModel.swift similarity index 100% rename from Examples/ProductSample/ProductDetailsViewModel.swift rename to Examples/ProductSample/Features/ProductDetails/ProductDetailsViewModel.swift diff --git a/Examples/ProductSample/ProductListView.swift b/Examples/ProductSample/Features/ProductList/ProductListView.swift similarity index 100% rename from Examples/ProductSample/ProductListView.swift rename to Examples/ProductSample/Features/ProductList/ProductListView.swift diff --git a/Examples/ProductSample/ProductListViewModel.swift b/Examples/ProductSample/Features/ProductList/ProductListViewModel.swift similarity index 100% rename from Examples/ProductSample/ProductListViewModel.swift rename to Examples/ProductSample/Features/ProductList/ProductListViewModel.swift diff --git a/Examples/ProductSample/Config.swift b/Examples/ProductSample/Helpers/Config.swift similarity index 100% rename from Examples/ProductSample/Config.swift rename to Examples/ProductSample/Helpers/Config.swift diff --git a/Examples/ProductSample/Logger.swift b/Examples/ProductSample/Helpers/Logger.swift similarity index 100% rename from Examples/ProductSample/Logger.swift rename to Examples/ProductSample/Helpers/Logger.swift diff --git a/Examples/ProductSample/Result.swift b/Examples/ProductSample/Helpers/Result.swift similarity index 100% rename from Examples/ProductSample/Result.swift rename to Examples/ProductSample/Helpers/Result.swift diff --git a/Examples/ProductSample/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/ProductSample/SupportFiles/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Examples/ProductSample/Assets.xcassets/AccentColor.colorset/Contents.json rename to Examples/ProductSample/SupportFiles/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Examples/ProductSample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/ProductSample/SupportFiles/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from Examples/ProductSample/Assets.xcassets/AppIcon.appiconset/Contents.json rename to Examples/ProductSample/SupportFiles/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/Examples/ProductSample/Assets.xcassets/Contents.json b/Examples/ProductSample/SupportFiles/Assets.xcassets/Contents.json similarity index 100% rename from Examples/ProductSample/Assets.xcassets/Contents.json rename to Examples/ProductSample/SupportFiles/Assets.xcassets/Contents.json diff --git a/Examples/ProductSample/Preview Content/Preview Assets.xcassets/Contents.json b/Examples/ProductSample/SupportFiles/Preview Content/Preview Assets.xcassets/Contents.json similarity index 100% rename from Examples/ProductSample/Preview Content/Preview Assets.xcassets/Contents.json rename to Examples/ProductSample/SupportFiles/Preview Content/Preview Assets.xcassets/Contents.json From 018dfcbe28f18c56d10b8f834b57d8a4144c3918 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 19 Oct 2023 18:49:37 -0300 Subject: [PATCH 10/14] Start adding auth --- Examples/Examples.xcodeproj/project.pbxproj | 16 +++++++++++++ .../ProductSample/Application/AppView.swift | 23 +++++++++++++++++++ .../Features/Auth/AuthView.swift | 20 ++++++++++++++++ .../Features/Auth/AuthViewModel.swift | 13 +++++++++++ Makefile | 2 +- 5 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 Examples/ProductSample/Features/Auth/AuthView.swift create mode 100644 Examples/ProductSample/Features/Auth/AuthViewModel.swift diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 3b396de6..9fe8a833 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ 79018ACF2AE1D6CF006EA669 /* DeleteProductUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79018ACE2AE1D6CF006EA669 /* DeleteProductUseCase.swift */; }; 79018AD12AE1D775006EA669 /* GetProductsUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79018AD02AE1D775006EA669 /* GetProductsUseCase.swift */; }; + 79018ADB2AE1DAF2006EA669 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79018ADA2AE1DAF2006EA669 /* AuthView.swift */; }; + 79018ADE2AE1DB03006EA669 /* AuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79018ADD2AE1DB03006EA669 /* AuthViewModel.swift */; }; 793895CA2954ABFF0044F2B8 /* ExamplesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 793895C92954ABFF0044F2B8 /* ExamplesApp.swift */; }; 793895CC2954ABFF0044F2B8 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 793895CB2954ABFF0044F2B8 /* RootView.swift */; }; 793895CE2954AC000044F2B8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 793895CD2954AC000044F2B8 /* Assets.xcassets */; }; @@ -51,6 +53,8 @@ /* Begin PBXFileReference section */ 79018ACE2AE1D6CF006EA669 /* DeleteProductUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteProductUseCase.swift; sourceTree = ""; }; 79018AD02AE1D775006EA669 /* GetProductsUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProductsUseCase.swift; sourceTree = ""; }; + 79018ADA2AE1DAF2006EA669 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; + 79018ADD2AE1DB03006EA669 /* AuthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthViewModel.swift; sourceTree = ""; }; 793895C62954ABFF0044F2B8 /* Examples.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Examples.app; sourceTree = BUILT_PRODUCTS_DIR; }; 793895C92954ABFF0044F2B8 /* ExamplesApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExamplesApp.swift; sourceTree = ""; }; 793895CB2954ABFF0044F2B8 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; @@ -140,6 +144,7 @@ 79018AD52AE1DA31006EA669 /* Features */ = { isa = PBXGroup; children = ( + 79018ADC2AE1DAF6006EA669 /* Auth */, 79018AD42AE1DA25006EA669 /* ProductDetails */, 79018AD32AE1DA1B006EA669 /* ProductList */, ); @@ -176,6 +181,15 @@ path = SupportFiles; sourceTree = ""; }; + 79018ADC2AE1DAF6006EA669 /* Auth */ = { + isa = PBXGroup; + children = ( + 79018ADA2AE1DAF2006EA669 /* AuthView.swift */, + 79018ADD2AE1DB03006EA669 /* AuthViewModel.swift */, + ); + path = Auth; + sourceTree = ""; + }; 793895BD2954ABFF0044F2B8 = { isa = PBXGroup; children = ( @@ -427,9 +441,11 @@ 79018ACF2AE1D6CF006EA669 /* DeleteProductUseCase.swift in Sources */, 79C5920A2AE159E20088A9C8 /* Logger.swift in Sources */, 79C591FB2AE12FDE0088A9C8 /* UseCase.swift in Sources */, + 79018ADB2AE1DAF2006EA669 /* AuthView.swift in Sources */, 79C592062AE159250088A9C8 /* UpdateProductUseCase.swift in Sources */, 79C591EA2AE089230088A9C8 /* Product.swift in Sources */, 79C592082AE159390088A9C8 /* GetProductUseCase.swift in Sources */, + 79018ADE2AE1DB03006EA669 /* AuthViewModel.swift in Sources */, 79C591EE2AE1258B0088A9C8 /* AuthenticationRepository.swift in Sources */, 79C591EC2AE089510088A9C8 /* ProductRepository.swift in Sources */, 79C591F82AE12B850088A9C8 /* ProductDetailsView.swift in Sources */, diff --git a/Examples/ProductSample/Application/AppView.swift b/Examples/ProductSample/Application/AppView.swift index 0f1a3346..c8c91662 100644 --- a/Examples/ProductSample/Application/AppView.swift +++ b/Examples/ProductSample/Application/AppView.swift @@ -18,8 +18,15 @@ struct AddProductRoute: Identifiable, Hashable { @MainActor final class AppViewModel: ObservableObject { let productListModel = ProductListViewModel() + let authViewModel = AuthViewModel() + + enum AuthState { + case authenticated + case notAuthenticated + } @Published var addProductRoute: AddProductRoute? + @Published var authState: AuthState? func productDetailViewModel(with productId: String?) -> ProductDetailsViewModel { ProductDetailsViewModel(productId: productId) { [weak self] updated in @@ -34,6 +41,18 @@ struct AppView: View { @StateObject var model = AppViewModel() var body: some View { + switch model.authState { + case .authenticated: + authenticatedView + case .notAuthenticated: + notAuthenticatedView + case .none: + ProgressView() + } + + } + + var authenticatedView: some View { NavigationStack { ProductListView() .toolbar { @@ -55,6 +74,10 @@ struct AppView: View { } } } + + var notAuthenticatedView: some View { + AuthView(model: model.authViewModel) + } } #Preview { diff --git a/Examples/ProductSample/Features/Auth/AuthView.swift b/Examples/ProductSample/Features/Auth/AuthView.swift new file mode 100644 index 00000000..b7b910d7 --- /dev/null +++ b/Examples/ProductSample/Features/Auth/AuthView.swift @@ -0,0 +1,20 @@ +// +// AuthView.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import SwiftUI + +struct AuthView: View { + @ObservedObject var model: AuthViewModel + + var body: some View { + Text("Hello, World!") + } +} + +#Preview { + AuthView(model: AuthViewModel()) +} diff --git a/Examples/ProductSample/Features/Auth/AuthViewModel.swift b/Examples/ProductSample/Features/Auth/AuthViewModel.swift new file mode 100644 index 00000000..6be573be --- /dev/null +++ b/Examples/ProductSample/Features/Auth/AuthViewModel.swift @@ -0,0 +1,13 @@ +// +// AuthViewModel.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import Foundation + +@MainActor +final class AuthViewModel: ObservableObject { + +} diff --git a/Makefile b/Makefile index 08015fd6..fc36553a 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ test-library: done; build-example: - for example in "ProductSample"; do \ + for example in "Examples" "ProductSample"; do \ xcodebuild build \ -workspace supabase-swift.xcworkspace \ -scheme "$$example" \ From 2e3d075d1a89436a5ffa0e72d2fd354d85b2f9c8 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Sat, 21 Oct 2023 08:45:05 -0300 Subject: [PATCH 11/14] Fix GoTrueClient memory leaks, fix listening for auth changes --- Examples/Examples.xcodeproj/project.pbxproj | 8 + .../ProductSample/Application/AppView.swift | 74 ++++++--- .../Application/Dependencies.swift | 11 ++ .../Data/AuthenticationRepository.swift | 57 ++++++- .../Domain/UseCases/SignInUseCase.swift | 36 +++++ .../Features/Auth/AuthView.swift | 49 +++++- .../Features/Auth/AuthViewModel.swift | 66 ++++++++ .../ProductList/ProductListView.swift | 4 +- Examples/ProductSample/Info.plist | 17 ++ Package.swift | 1 + Sources/GoTrue/GoTrueClient.swift | 151 ++++++++++++------ Sources/GoTrue/Internal/SessionManager.swift | 29 ++-- Sources/GoTrue/Internal/ShareReplay.swift | 96 ----------- .../_Helpers}/LockIsolated.swift | 9 +- .../PostgRESTTests/BuildURLRequestTests.swift | 1 + 15 files changed, 426 insertions(+), 183 deletions(-) create mode 100644 Examples/ProductSample/Domain/UseCases/SignInUseCase.swift create mode 100644 Examples/ProductSample/Info.plist delete mode 100644 Sources/GoTrue/Internal/ShareReplay.swift rename {Tests/PostgRESTTests/Helpers => Sources/_Helpers}/LockIsolated.swift (71%) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 9fe8a833..8fe9317c 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 79018AD12AE1D775006EA669 /* GetProductsUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79018AD02AE1D775006EA669 /* GetProductsUseCase.swift */; }; 79018ADB2AE1DAF2006EA669 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79018ADA2AE1DAF2006EA669 /* AuthView.swift */; }; 79018ADE2AE1DB03006EA669 /* AuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79018ADD2AE1DB03006EA669 /* AuthViewModel.swift */; }; + 79018AE02AE309EE006EA669 /* SignInUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79018ADF2AE309EE006EA669 /* SignInUseCase.swift */; }; 793895CA2954ABFF0044F2B8 /* ExamplesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 793895C92954ABFF0044F2B8 /* ExamplesApp.swift */; }; 793895CC2954ABFF0044F2B8 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 793895CB2954ABFF0044F2B8 /* RootView.swift */; }; 793895CE2954AC000044F2B8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 793895CD2954AC000044F2B8 /* Assets.xcassets */; }; @@ -55,6 +56,8 @@ 79018AD02AE1D775006EA669 /* GetProductsUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProductsUseCase.swift; sourceTree = ""; }; 79018ADA2AE1DAF2006EA669 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; 79018ADD2AE1DB03006EA669 /* AuthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthViewModel.swift; sourceTree = ""; }; + 79018ADF2AE309EE006EA669 /* SignInUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInUseCase.swift; sourceTree = ""; }; + 79018AE12AE3D0E3006EA669 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 793895C62954ABFF0044F2B8 /* Examples.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Examples.app; sourceTree = BUILT_PRODUCTS_DIR; }; 793895C92954ABFF0044F2B8 /* ExamplesApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExamplesApp.swift; sourceTree = ""; }; 793895CB2954ABFF0044F2B8 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; @@ -247,6 +250,7 @@ 79C591DA2AE0880F0088A9C8 /* ProductSample */ = { isa = PBXGroup; children = ( + 79018AE12AE3D0E3006EA669 /* Info.plist */, 79018AD62AE1DA4C006EA669 /* Application */, 79C5920F2AE1CCE80088A9C8 /* Data */, 79C591F92AE12FD10088A9C8 /* Domain */, @@ -283,6 +287,7 @@ 79C592072AE159390088A9C8 /* GetProductUseCase.swift */, 79018ACE2AE1D6CF006EA669 /* DeleteProductUseCase.swift */, 79018AD02AE1D775006EA669 /* GetProductsUseCase.swift */, + 79018ADF2AE309EE006EA669 /* SignInUseCase.swift */, ); path = UseCases; sourceTree = ""; @@ -436,6 +441,7 @@ 79C591F22AE127180088A9C8 /* ProductListView.swift in Sources */, 79C592112AE1CD040088A9C8 /* ProductImageStorageRepository.swift in Sources */, 79C591F02AE126120088A9C8 /* ProductListViewModel.swift in Sources */, + 79018AE02AE309EE006EA669 /* SignInUseCase.swift in Sources */, 79C5920C2AE1B8820088A9C8 /* Result.swift in Sources */, 79C591FF2AE1527C0088A9C8 /* Config.swift in Sources */, 79018ACF2AE1D6CF006EA669 /* DeleteProductUseCase.swift in Sources */, @@ -659,6 +665,7 @@ ENABLE_PREVIEWS = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ProductSample/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -694,6 +701,7 @@ ENABLE_PREVIEWS = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ProductSample/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/Examples/ProductSample/Application/AppView.swift b/Examples/ProductSample/Application/AppView.swift index c8c91662..4d9e8952 100644 --- a/Examples/ProductSample/Application/AppView.swift +++ b/Examples/ProductSample/Application/AppView.swift @@ -5,6 +5,7 @@ // Created by Guilherme Souza on 18/10/23. // +import OSLog import SwiftUI struct ProductDetailRoute: Hashable { @@ -17,24 +18,57 @@ struct AddProductRoute: Identifiable, Hashable { @MainActor final class AppViewModel: ObservableObject { - let productListModel = ProductListViewModel() - let authViewModel = AuthViewModel() + private let logger = Logger.make(category: "AppViewModel") + private let authenticationRepository: AuthenticationRepository enum AuthState { - case authenticated - case notAuthenticated + case authenticated(ProductListViewModel) + case notAuthenticated(AuthViewModel) } @Published var addProductRoute: AddProductRoute? @Published var authState: AuthState? + private var authStateListenerTask: Task? + + 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 { - await self?.productListModel.loadProducts() + if case let .authenticated(model) = self?.authState { + await model.loadProducts() + } } } } + + func signOutButtonTapped() async { + await authenticationRepository.signOut() + } } struct AppView: View { @@ -42,41 +76,45 @@ struct AppView: View { var body: some View { switch model.authState { - case .authenticated: - authenticatedView - case .notAuthenticated: - notAuthenticatedView + case .authenticated(let model): + authenticatedView(model: model) + case .notAuthenticated(let model): + notAuthenticatedView(model: model) case .none: ProgressView() } - } - var authenticatedView: some View { + func authenticatedView(model: ProductListViewModel) -> some View { NavigationStack { - ProductListView() + ProductListView(model: model) .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Sign out") { + Task { await self.model.signOutButtonTapped() } + } + } ToolbarItem(placement: .primaryAction) { Button { - model.addProductRoute = .init() + self.model.addProductRoute = .init() } label: { Label("Add", systemImage: "plus") } } } .navigationDestination(for: ProductDetailRoute.self) { route in - ProductDetailsView(model: model.productDetailViewModel(with: route.productId)) + ProductDetailsView(model: self.model.productDetailViewModel(with: route.productId)) } } - .sheet(item: $model.addProductRoute) { _ in + .sheet(item: self.$model.addProductRoute) { _ in NavigationStack { - ProductDetailsView(model: model.productDetailViewModel(with: nil)) + ProductDetailsView(model: self.model.productDetailViewModel(with: nil)) } } } - var notAuthenticatedView: some View { - AuthView(model: model.authViewModel) + func notAuthenticatedView(model: AuthViewModel) -> some View { + AuthView(model: model) } } diff --git a/Examples/ProductSample/Application/Dependencies.swift b/Examples/ProductSample/Application/Dependencies.swift index e1a7182c..add52b18 100644 --- a/Examples/ProductSample/Application/Dependencies.swift +++ b/Examples/ProductSample/Application/Dependencies.swift @@ -19,6 +19,9 @@ enum Dependencies { 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 @@ -43,4 +46,12 @@ enum Dependencies { static let getProductsUseCase: any GetProductsUseCase = GetProductsUseCaseImpl( repository: productRepository ) + + static let signInUseCase: any SignInUseCase = SignInUseCaseImpl( + repository: authenticationRepository + ) + + static let signUpUseCase: any SignUpUseCase = SignUpUseCaseImpl( + repository: authenticationRepository + ) } diff --git a/Examples/ProductSample/Data/AuthenticationRepository.swift b/Examples/ProductSample/Data/AuthenticationRepository.swift index 044b6cf6..d3d6c5df 100644 --- a/Examples/ProductSample/Data/AuthenticationRepository.swift +++ b/Examples/ProductSample/Data/AuthenticationRepository.swift @@ -8,24 +8,75 @@ import Foundation import Supabase +enum AuthenticationState { + case signedIn + case signedOut +} + +enum SignUpResult { + case success + case requiresConfirmation +} + protocol AuthenticationRepository { + var authStateListener: AsyncStream { get } + func signIn(email: String, password: String) async throws - func signUp(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 + func signIn(email: String, password: String) async throws { try await client.signIn(email: email, password: password) } - func signUp(email: String, password: String) async throws { - try await client.signUp(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() + } } diff --git a/Examples/ProductSample/Domain/UseCases/SignInUseCase.swift b/Examples/ProductSample/Domain/UseCases/SignInUseCase.swift new file mode 100644 index 00000000..b7ec5b45 --- /dev/null +++ b/Examples/ProductSample/Domain/UseCases/SignInUseCase.swift @@ -0,0 +1,36 @@ +// +// SignInUseCase.swift +// ProductSample +// +// Created by Guilherme Souza on 20/10/23. +// + +import Foundation + +struct Credentials { + let email, password: String +} + +protocol SignInUseCase: UseCase> {} + +struct SignInUseCaseImpl: SignInUseCase { + let repository: AuthenticationRepository + + func execute(input: Credentials) -> Task<(), Error> { + Task { + try await repository.signIn(email: input.email, password: input.password) + } + } +} + +protocol SignUpUseCase: UseCase> {} + +struct SignUpUseCaseImpl: SignUpUseCase { + let repository: AuthenticationRepository + + func execute(input: Credentials) -> Task { + Task { + try await repository.signUp(email: input.email, password: input.password) + } + } +} diff --git a/Examples/ProductSample/Features/Auth/AuthView.swift b/Examples/ProductSample/Features/Auth/AuthView.swift index b7b910d7..9d60cc10 100644 --- a/Examples/ProductSample/Features/Auth/AuthView.swift +++ b/Examples/ProductSample/Features/Auth/AuthView.swift @@ -11,7 +11,54 @@ struct AuthView: View { @ObservedObject var model: AuthViewModel var body: some View { - Text("Hello, World!") + Form { + Section { + TextField("Email", text: $model.email) + .keyboardType(.emailAddress) + .textContentType(.emailAddress) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + SecureField("Password", text: $model.password) + .textContentType(.password) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + + Section { + Button("Sign in") { + Task { + await model.signInButtonTapped() + } + } + Button("Sign up") { + Task { + await model.signUpButtonTapped() + } + } + Button("Sign in with Apple") { + Task { + await model.signInWithAppleButtonTapped() + } + } + } + + if let status = model.status { + switch status { + case .error(let error): + Text(error.localizedDescription).font(.callout).foregroundStyle(.red) + case .requiresConfirmation: + Text( + "Account created, but it requires confirmation, click the verification link sent to the registered email." + ) + .font(.callout) + case .loading: + ProgressView() + } + } + } + .onOpenURL { url in + Task { await model.onOpenURL(url) } + } } } diff --git a/Examples/ProductSample/Features/Auth/AuthViewModel.swift b/Examples/ProductSample/Features/Auth/AuthViewModel.swift index 6be573be..1e14028b 100644 --- a/Examples/ProductSample/Features/Auth/AuthViewModel.swift +++ b/Examples/ProductSample/Features/Auth/AuthViewModel.swift @@ -6,8 +6,74 @@ // import Foundation +import OSLog @MainActor final class AuthViewModel: ObservableObject { + private let logger = Logger.make(category: "AuthViewModel") + private let signInUseCase: any SignInUseCase + private let signUpUseCase: any SignUpUseCase + + @Published var email = "" + @Published var password = "" + + enum Status { + case loading + case requiresConfirmation + case error(Error) + } + + @Published var status: Status? + + init( + signInUseCase: any SignInUseCase = Dependencies.signInUseCase, + signUpUseCase: any SignUpUseCase = Dependencies.signUpUseCase + ) { + self.signInUseCase = signInUseCase + self.signUpUseCase = signUpUseCase + } + + func signInButtonTapped() async { + status = .loading + do { + try await signInUseCase.execute(input: .init(email: email, password: password)).value + status = nil + } catch { + status = .error(error) + logger.error("Error signing in: \(error)") + } + } + + func signUpButtonTapped() async { + status = .loading + do { + let result = try await signUpUseCase.execute(input: .init(email: email, password: password)) + .value + if result == .requiresConfirmation { + status = .requiresConfirmation + } else { + status = nil + } + } catch { + status = .error(error) + logger.error("Error signing up: \(error)") + } + } + + func signInWithAppleButtonTapped() async {} + + func onOpenURL(_ url: URL) async { + status = .loading + + do { + logger.debug("Retrieve session from url: \(url)") + try await Dependencies.supabase.auth.session(from: url) + await signInButtonTapped() + status = nil + } catch { + status = .error(error) + logger.error("Error creating session from url: \(error)") + } + } } diff --git a/Examples/ProductSample/Features/ProductList/ProductListView.swift b/Examples/ProductSample/Features/ProductList/ProductListView.swift index 1b1fb601..9d90501f 100644 --- a/Examples/ProductSample/Features/ProductList/ProductListView.swift +++ b/Examples/ProductSample/Features/ProductList/ProductListView.swift @@ -8,7 +8,7 @@ import SwiftUI struct ProductListView: View { - @StateObject var model = ProductListViewModel() + @ObservedObject var model: ProductListViewModel var body: some View { List { @@ -50,5 +50,5 @@ struct ProductListView: View { } #Preview { - ProductListView() + ProductListView(model: ProductListViewModel()) } diff --git a/Examples/ProductSample/Info.plist b/Examples/ProductSample/Info.plist new file mode 100644 index 00000000..a8e3f4cb --- /dev/null +++ b/Examples/ProductSample/Info.plist @@ -0,0 +1,17 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + dev.grds.ProductSample + + + + + diff --git a/Package.swift b/Package.swift index b2ab78e5..92826a03 100644 --- a/Package.swift +++ b/Package.swift @@ -51,6 +51,7 @@ var package = Package( name: "PostgRESTTests", dependencies: [ "PostgREST", + "_Helpers", .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), ], exclude: ["__Snapshots__"] diff --git a/Sources/GoTrue/GoTrueClient.swift b/Sources/GoTrue/GoTrueClient.swift index 71c371d9..50b7d5e9 100644 --- a/Sources/GoTrue/GoTrueClient.swift +++ b/Sources/GoTrue/GoTrueClient.swift @@ -1,4 +1,3 @@ -import Combine import Foundation @_spi(Internal) import _Helpers @@ -8,6 +7,14 @@ public typealias AnyJSON = _Helpers.AnyJSON import FoundationNetworking #endif +public final class AuthStateListenerHandle { + let handler: (AuthChangeEvent) -> Void + + init(handler: @escaping (AuthChangeEvent) -> Void) { + self.handler = handler + } +} + public final class GoTrueClient { public typealias FetchHandler = @Sendable (_ request: URLRequest) async throws -> ( Data, @@ -45,16 +52,8 @@ public final class GoTrueClient { } private let configuration: Configuration - private lazy var sessionManager = SessionManager( - localStorage: self.configuration.localStorage, - sessionRefresher: { try await self.refreshSession(refreshToken: $0) } - ) - - private let authEventChangeSubject = PassthroughSubject() - /// Asynchronous sequence of authentication change events emitted during life of `GoTrueClient`. - public var authEventChange: AnyPublisher { - authEventChangeSubject.shareReplay(1).eraseToAnyPublisher() - } + private let sessionManager: SessionManager + private var initializationTask: Task? /// Returns the session, refreshing it if necessary. public var session: Session { @@ -63,6 +62,8 @@ public final class GoTrueClient { } } + private var authChangeListeners = LockIsolated([ObjectIdentifier: AuthStateListenerHandle]()) + public convenience init( url: URL, headers: [String: String] = [:], @@ -79,24 +80,58 @@ public final class GoTrueClient { encoder: encoder, decoder: decoder, fetch: fetch - )) + ) + ) } + public init(configuration: Configuration) { var configuration = configuration configuration.headers["X-Client-Info"] = "gotrue-swift/\(version)" self.configuration = configuration + sessionManager = SessionManager(localStorage: configuration.localStorage) + + initializationTask = Task(priority: .userInitiated) { [weak self] in + await self?.sessionManager.setSessionRefresher(self) - Task { do { - _ = try await sessionManager.session() - authEventChangeSubject.send(.signedIn) + _ = try await self?.sessionManager.session() + self?.emitAuthChangeEvent(.signedIn) + } catch is CancellationError { + // no-op } catch { - authEventChangeSubject.send(.signedOut) + self?.emitAuthChangeEvent(.signedOut) } } } + deinit { + initializationTask?.cancel() + } + + /// Listen for ``AuthChangeEvent`` events. + /// - Parameter onChange: Closure to call when a new event is triggered. + /// - Returns: A handle that can be used to unsubscribe from changes. + public func addAuthStateChangeListener(onChange: @escaping (AuthChangeEvent) -> Void) + -> AuthStateListenerHandle + { + let handle = AuthStateListenerHandle(handler: onChange) + + authChangeListeners.withValue { + $0[ObjectIdentifier(handle)] = handle + } + + return handle + } + + /// Unsubscribe from changes. + /// - Parameter handle: The handle to unsubscribe. + public func removeAuthStateChangeListener(_ handle: AuthStateListenerHandle) { + authChangeListeners.withValue { + $0[ObjectIdentifier(handle)] = nil + } + } + /// Creates a new user. /// - Parameters: /// - email: User's email address. @@ -166,7 +201,7 @@ public final class GoTrueClient { if let session = response.session { try await sessionManager.update(session) - authEventChangeSubject.send(.signedIn) + emitAuthChangeEvent(.signedIn) } return response @@ -226,7 +261,7 @@ public final class GoTrueClient { if session.user.emailConfirmedAt != nil || session.user.confirmedAt != nil { try await sessionManager.update(session) - authEventChangeSubject.send(.signedIn) + emitAuthChangeEvent(.signedIn) } return session @@ -335,32 +370,6 @@ public final class GoTrueClient { return url } - @discardableResult - public func refreshSession(refreshToken: String) async throws -> Session { - do { - let session = try await execute( - .init( - path: "/token", - method: "POST", - query: [URLQueryItem(name: "grant_type", value: "refresh_token")], - body: configuration.encoder.encode(UserCredentials(refreshToken: refreshToken)) - ) - ).decoded(as: Session.self, decoder: configuration.decoder) - - if session.user.phoneConfirmedAt != nil || session.user.emailConfirmedAt != nil - || session - .user.confirmedAt != nil - { - try await sessionManager.update(session) - authEventChangeSubject.send(.signedIn) - } - - return session - } catch { - throw error - } - } - /// Gets the session data from a OAuth2 callback URL. @discardableResult public func session(from url: URL, storeSession: Bool = true) async throws -> Session { @@ -406,10 +415,10 @@ public final class GoTrueClient { if storeSession { try await sessionManager.update(session) - authEventChangeSubject.send(.signedIn) + emitAuthChangeEvent(.signedIn) if let type = params.first(where: { $0.name == "type" })?.value, type == "recovery" { - authEventChangeSubject.send(.passwordRecovery) + emitAuthChangeEvent(.passwordRecovery) } } @@ -459,13 +468,13 @@ public final class GoTrueClient { } try await sessionManager.update(session) - authEventChangeSubject.send(.tokenRefreshed) + emitAuthChangeEvent(.tokenRefreshed) return session } /// Signs out the current user, if there is a logged in user. public func signOut() async throws { - defer { authEventChangeSubject.send(.signedOut) } + defer { emitAuthChangeEvent(.signedOut) } let session = try? await sessionManager.session() if session != nil { @@ -541,7 +550,7 @@ public final class GoTrueClient { if let session = response.session { try await sessionManager.update(session) - authEventChangeSubject.send(.signedIn) + emitAuthChangeEvent(.signedIn) } return response @@ -556,7 +565,7 @@ public final class GoTrueClient { ).decoded(as: User.self, decoder: configuration.decoder) session.user = user try await sessionManager.update(session) - authEventChangeSubject.send(.userUpdated) + emitAuthChangeEvent(.userUpdated) return user } @@ -595,6 +604,8 @@ public final class GoTrueClient { @discardableResult private func execute(_ request: Request) async throws -> Response { + await initializationTask?.value + var request = request request.headers.merge(configuration.headers) { r, _ in r } let urlRequest = try request.urlRequest(withBaseURL: configuration.url) @@ -611,4 +622,44 @@ public final class GoTrueClient { return Response(data: data, response: httpResponse) } + + private func emitAuthChangeEvent(_ event: AuthChangeEvent) { + let listeners = authChangeListeners.value.values + for listener in listeners { + listener.handler(event) + } + } +} + +extension GoTrueClient: SessionRefresher { + @discardableResult + public func refreshSession(refreshToken: String) async throws -> Session { + do { + let session = try await execute( + .init( + path: "/token", + method: "POST", + query: [URLQueryItem(name: "grant_type", value: "refresh_token")], + body: configuration.encoder.encode(UserCredentials(refreshToken: refreshToken)) + ) + ).decoded(as: Session.self, decoder: configuration.decoder) + + if session.user.phoneConfirmedAt != nil || session.user.emailConfirmedAt != nil + || session + .user.confirmedAt != nil + { + try await sessionManager.update(session) + emitAuthChangeEvent(.signedIn) + } + + return session + } catch { + throw error + } + } +} + +extension GoTrueClient { + public static let didChangeAuthStateNotification = Notification.Name( + "DID_CHANGE_AUTH_STATE_NOTIFICATION") } diff --git a/Sources/GoTrue/Internal/SessionManager.swift b/Sources/GoTrue/Internal/SessionManager.swift index 66f46424..b8ad2d79 100644 --- a/Sources/GoTrue/Internal/SessionManager.swift +++ b/Sources/GoTrue/Internal/SessionManager.swift @@ -15,16 +15,22 @@ struct StoredSession: Codable { } } -actor SessionManager { - typealias SessionRefresher = @Sendable (_ refreshToken: String) async throws -> Session +protocol SessionRefresher: AnyObject { + func refreshSession(refreshToken: String) async throws -> Session +} +actor SessionManager { private var task: Task? private let localStorage: GoTrueLocalStorage - private let sessionRefresher: SessionRefresher - init(localStorage: GoTrueLocalStorage, sessionRefresher: @escaping SessionRefresher) { + private weak var sessionRefresher: SessionRefresher? + + init(localStorage: GoTrueLocalStorage) { self.localStorage = localStorage - self.sessionRefresher = sessionRefresher + } + + func setSessionRefresher(_ refresher: SessionRefresher?) { + sessionRefresher = refresher } func session() async throws -> Session { @@ -41,11 +47,16 @@ actor SessionManager { } task = Task { - defer { self.task = nil } + defer { task = nil } - let session = try await sessionRefresher(currentSession.session.refreshToken) - try update(session) - return session + if let session = try await sessionRefresher?.refreshSession( + refreshToken: currentSession.session.refreshToken) + { + try update(session) + return session + } + + throw GoTrueError.sessionNotFound } return try await task!.value diff --git a/Sources/GoTrue/Internal/ShareReplay.swift b/Sources/GoTrue/Internal/ShareReplay.swift deleted file mode 100644 index a935c21f..00000000 --- a/Sources/GoTrue/Internal/ShareReplay.swift +++ /dev/null @@ -1,96 +0,0 @@ -import Combine -import Foundation - -extension Publisher { - /// Provides a subject that shares a single subscription to the upstream publisher and - /// replays at most `bufferSize` items emitted by that publisher - /// - Parameter bufferSize: limits the number of items that can be replayed - func shareReplay(_ bufferSize: Int) -> AnyPublisher { - multicast(subject: ReplaySubject(bufferSize)).autoconnect().eraseToAnyPublisher() - } -} - -final class ReplaySubject: Subject { - private var buffer = [Output]() - private let bufferSize: Int - private let lock = NSRecursiveLock() - - init(_ bufferSize: Int = 0) { - self.bufferSize = bufferSize - } - - private var subscriptions = [ReplaySubjectSubscription]() - private var completion: Subscribers.Completion? - - func receive(subscriber: Downstream) - where Downstream.Failure == Failure, Downstream.Input == Output { - lock.lock() - defer { lock.unlock() } - let subscription = ReplaySubjectSubscription( - downstream: AnySubscriber(subscriber)) - subscriber.receive(subscription: subscription) - subscriptions.append(subscription) - subscription.replay(buffer, completion: completion) - } - - /// Establishes demand for a new upstream subscriptions - func send(subscription: Subscription) { - lock.lock() - defer { lock.unlock() } - subscription.request(.unlimited) - } - - /// Sends a value to the subscriber. - func send(_ value: Output) { - lock.lock() - defer { lock.unlock() } - buffer.append(value) - buffer = buffer.suffix(bufferSize) - subscriptions.forEach { $0.receive(value) } - } - - /// Sends a completion event to the subscriber. - func send(completion: Subscribers.Completion) { - lock.lock() - defer { lock.unlock() } - self.completion = completion - subscriptions.forEach { subscription in subscription.receive(completion: completion) } - } -} - -final class ReplaySubjectSubscription: Subscription { - private let downstream: AnySubscriber - private var isCompleted = false - private var demand: Subscribers.Demand = .none - - init(downstream: AnySubscriber) { - self.downstream = downstream - } - - func request(_ newDemand: Subscribers.Demand) { - demand += newDemand - } - - func cancel() { - isCompleted = true - } - - func receive(_ value: Output) { - guard !isCompleted, demand > 0 else { return } - - demand += downstream.receive(value) - demand -= 1 - } - - func receive(completion: Subscribers.Completion) { - guard !isCompleted else { return } - isCompleted = true - downstream.receive(completion: completion) - } - - func replay(_ values: [Output], completion: Subscribers.Completion?) { - guard !isCompleted else { return } - values.forEach { value in receive(value) } - if let completion = completion { receive(completion: completion) } - } -} diff --git a/Tests/PostgRESTTests/Helpers/LockIsolated.swift b/Sources/_Helpers/LockIsolated.swift similarity index 71% rename from Tests/PostgRESTTests/Helpers/LockIsolated.swift rename to Sources/_Helpers/LockIsolated.swift index 7afd7aa3..552da2ea 100644 --- a/Tests/PostgRESTTests/Helpers/LockIsolated.swift +++ b/Sources/_Helpers/LockIsolated.swift @@ -7,16 +7,17 @@ import Foundation -final class LockIsolated: @unchecked Sendable { +@_spi(Internal) +public final class LockIsolated: @unchecked Sendable { private let lock = NSRecursiveLock() private var _value: Value - init(_ value: Value) { + public init(_ value: Value) { self._value = value } @discardableResult - func withValue(_ block: (inout Value) throws -> T) rethrows -> T { + public func withValue(_ block: (inout Value) throws -> T) rethrows -> T { try lock.sync { var value = self._value defer { self._value = value } @@ -24,7 +25,7 @@ final class LockIsolated: @unchecked Sendable { } } - var value: Value { + public var value: Value { lock.sync { self._value } } } diff --git a/Tests/PostgRESTTests/BuildURLRequestTests.swift b/Tests/PostgRESTTests/BuildURLRequestTests.swift index 9d3aa855..14fa2b42 100644 --- a/Tests/PostgRESTTests/BuildURLRequestTests.swift +++ b/Tests/PostgRESTTests/BuildURLRequestTests.swift @@ -1,6 +1,7 @@ import Foundation import SnapshotTesting import XCTest +@_spi(Internal) import _Helpers @testable import PostgREST From 66a6f69a9ffecf397103c7e64ba6e423f7e56531 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Sat, 21 Oct 2023 08:50:03 -0300 Subject: [PATCH 12/14] Move models to specific files --- Examples/Examples.xcodeproj/project.pbxproj | 20 +++++++++++++++ .../ProductSample/Application/AppView.swift | 8 ------ .../Data/AuthenticationRepository.swift | 10 -------- .../ProductSample/Domain/Models/Auth.swift | 17 +++++++++++++ .../ProductSample/Domain/Models/Product.swift | 20 +++++++++++++++ .../UseCases/CreateProductUseCase.swift | 6 ----- .../Domain/UseCases/SignInUseCase.swift | 16 ------------ .../Domain/UseCases/SignUpUseCase.swift | 25 +++++++++++++++++++ .../UseCases/UpdateProductUseCase.swift | 14 ----------- Examples/ProductSample/Routes/Routes.swift | 16 ++++++++++++ Sources/GoTrue/GoTrueClient.swift | 5 ++-- 11 files changed, 100 insertions(+), 57 deletions(-) create mode 100644 Examples/ProductSample/Domain/Models/Auth.swift create mode 100644 Examples/ProductSample/Domain/UseCases/SignUpUseCase.swift create mode 100644 Examples/ProductSample/Routes/Routes.swift diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 8fe9317c..3c7e7bc8 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -12,6 +12,9 @@ 79018ADB2AE1DAF2006EA669 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79018ADA2AE1DAF2006EA669 /* AuthView.swift */; }; 79018ADE2AE1DB03006EA669 /* AuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79018ADD2AE1DB03006EA669 /* AuthViewModel.swift */; }; 79018AE02AE309EE006EA669 /* SignInUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79018ADF2AE309EE006EA669 /* SignInUseCase.swift */; }; + 79018AE42AE3F185006EA669 /* Routes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79018AE32AE3F185006EA669 /* Routes.swift */; }; + 79018AE62AE3F1E4006EA669 /* Auth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79018AE52AE3F1E4006EA669 /* Auth.swift */; }; + 79018AE82AE3F1F3006EA669 /* SignUpUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79018AE72AE3F1F3006EA669 /* SignUpUseCase.swift */; }; 793895CA2954ABFF0044F2B8 /* ExamplesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 793895C92954ABFF0044F2B8 /* ExamplesApp.swift */; }; 793895CC2954ABFF0044F2B8 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 793895CB2954ABFF0044F2B8 /* RootView.swift */; }; 793895CE2954AC000044F2B8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 793895CD2954AC000044F2B8 /* Assets.xcassets */; }; @@ -58,6 +61,9 @@ 79018ADD2AE1DB03006EA669 /* AuthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthViewModel.swift; sourceTree = ""; }; 79018ADF2AE309EE006EA669 /* SignInUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInUseCase.swift; sourceTree = ""; }; 79018AE12AE3D0E3006EA669 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 79018AE32AE3F185006EA669 /* Routes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Routes.swift; sourceTree = ""; }; + 79018AE52AE3F1E4006EA669 /* Auth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Auth.swift; sourceTree = ""; }; + 79018AE72AE3F1F3006EA669 /* SignUpUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpUseCase.swift; sourceTree = ""; }; 793895C62954ABFF0044F2B8 /* Examples.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Examples.app; sourceTree = BUILT_PRODUCTS_DIR; }; 793895C92954ABFF0044F2B8 /* ExamplesApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExamplesApp.swift; sourceTree = ""; }; 793895CB2954ABFF0044F2B8 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; @@ -122,6 +128,7 @@ isa = PBXGroup; children = ( 79C591E92AE089230088A9C8 /* Product.swift */, + 79018AE52AE3F1E4006EA669 /* Auth.swift */, ); path = Models; sourceTree = ""; @@ -193,6 +200,14 @@ path = Auth; sourceTree = ""; }; + 79018AE22AE3F17D006EA669 /* Routes */ = { + isa = PBXGroup; + children = ( + 79018AE32AE3F185006EA669 /* Routes.swift */, + ); + path = Routes; + sourceTree = ""; + }; 793895BD2954ABFF0044F2B8 = { isa = PBXGroup; children = ( @@ -250,6 +265,7 @@ 79C591DA2AE0880F0088A9C8 /* ProductSample */ = { isa = PBXGroup; children = ( + 79018AE22AE3F17D006EA669 /* Routes */, 79018AE12AE3D0E3006EA669 /* Info.plist */, 79018AD62AE1DA4C006EA669 /* Application */, 79C5920F2AE1CCE80088A9C8 /* Data */, @@ -288,6 +304,7 @@ 79018ACE2AE1D6CF006EA669 /* DeleteProductUseCase.swift */, 79018AD02AE1D775006EA669 /* GetProductsUseCase.swift */, 79018ADF2AE309EE006EA669 /* SignInUseCase.swift */, + 79018AE72AE3F1F3006EA669 /* SignUpUseCase.swift */, ); path = UseCases; sourceTree = ""; @@ -433,6 +450,8 @@ buildActionMask = 2147483647; files = ( 79C591DE2AE0880F0088A9C8 /* AppView.swift in Sources */, + 79018AE42AE3F185006EA669 /* Routes.swift in Sources */, + 79018AE62AE3F1E4006EA669 /* Auth.swift in Sources */, 79C591DC2AE0880F0088A9C8 /* ProductSampleApp.swift in Sources */, 79C591F62AE12AAC0088A9C8 /* ProductDetailsViewModel.swift in Sources */, 79018AD12AE1D775006EA669 /* GetProductsUseCase.swift in Sources */, @@ -447,6 +466,7 @@ 79018ACF2AE1D6CF006EA669 /* DeleteProductUseCase.swift in Sources */, 79C5920A2AE159E20088A9C8 /* Logger.swift in Sources */, 79C591FB2AE12FDE0088A9C8 /* UseCase.swift in Sources */, + 79018AE82AE3F1F3006EA669 /* SignUpUseCase.swift in Sources */, 79018ADB2AE1DAF2006EA669 /* AuthView.swift in Sources */, 79C592062AE159250088A9C8 /* UpdateProductUseCase.swift in Sources */, 79C591EA2AE089230088A9C8 /* Product.swift in Sources */, diff --git a/Examples/ProductSample/Application/AppView.swift b/Examples/ProductSample/Application/AppView.swift index 4d9e8952..1f3acbd7 100644 --- a/Examples/ProductSample/Application/AppView.swift +++ b/Examples/ProductSample/Application/AppView.swift @@ -8,14 +8,6 @@ import OSLog import SwiftUI -struct ProductDetailRoute: Hashable { - let productId: String -} - -struct AddProductRoute: Identifiable, Hashable { - var id: AnyHashable { self } -} - @MainActor final class AppViewModel: ObservableObject { private let logger = Logger.make(category: "AppViewModel") diff --git a/Examples/ProductSample/Data/AuthenticationRepository.swift b/Examples/ProductSample/Data/AuthenticationRepository.swift index d3d6c5df..4190d1b7 100644 --- a/Examples/ProductSample/Data/AuthenticationRepository.swift +++ b/Examples/ProductSample/Data/AuthenticationRepository.swift @@ -8,16 +8,6 @@ import Foundation import Supabase -enum AuthenticationState { - case signedIn - case signedOut -} - -enum SignUpResult { - case success - case requiresConfirmation -} - protocol AuthenticationRepository { var authStateListener: AsyncStream { get } diff --git a/Examples/ProductSample/Domain/Models/Auth.swift b/Examples/ProductSample/Domain/Models/Auth.swift new file mode 100644 index 00000000..4fcf3306 --- /dev/null +++ b/Examples/ProductSample/Domain/Models/Auth.swift @@ -0,0 +1,17 @@ +// +// Credentials.swift +// ProductSample +// +// Created by Guilherme Souza on 21/10/23. +// + +import Foundation + +struct Credentials { + let email, password: String +} + +enum AuthenticationState { + case signedIn + case signedOut +} diff --git a/Examples/ProductSample/Domain/Models/Product.swift b/Examples/ProductSample/Domain/Models/Product.swift index 44ffb8ab..3da4f32f 100644 --- a/Examples/ProductSample/Domain/Models/Product.swift +++ b/Examples/ProductSample/Domain/Models/Product.swift @@ -17,3 +17,23 @@ struct Product: Identifiable, Decodable { struct ImageKey: RawRepresentable, Decodable { var rawValue: String } + +struct CreateProductParams { + let name: String + let price: Double + let image: ImageUploadParams? +} + +struct ImageUploadParams { + let fileName: String + let fileExtension: String? + let mimeType: String? + let data: Data +} + +struct UpdateProductParams { + var id: String + var name: String? + var price: Double? + var image: ImageUploadParams? +} diff --git a/Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift b/Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift index 747d6a3d..e672ab80 100644 --- a/Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift +++ b/Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift @@ -8,12 +8,6 @@ import Foundation import Supabase -struct CreateProductParams { - let name: String - let price: Double - let image: ImageUploadParams? -} - protocol CreateProductUseCase: UseCase> {} struct CreateProductUseCaseImpl: CreateProductUseCase { diff --git a/Examples/ProductSample/Domain/UseCases/SignInUseCase.swift b/Examples/ProductSample/Domain/UseCases/SignInUseCase.swift index b7ec5b45..0e325926 100644 --- a/Examples/ProductSample/Domain/UseCases/SignInUseCase.swift +++ b/Examples/ProductSample/Domain/UseCases/SignInUseCase.swift @@ -7,10 +7,6 @@ import Foundation -struct Credentials { - let email, password: String -} - protocol SignInUseCase: UseCase> {} struct SignInUseCaseImpl: SignInUseCase { @@ -22,15 +18,3 @@ struct SignInUseCaseImpl: SignInUseCase { } } } - -protocol SignUpUseCase: UseCase> {} - -struct SignUpUseCaseImpl: SignUpUseCase { - let repository: AuthenticationRepository - - func execute(input: Credentials) -> Task { - Task { - try await repository.signUp(email: input.email, password: input.password) - } - } -} diff --git a/Examples/ProductSample/Domain/UseCases/SignUpUseCase.swift b/Examples/ProductSample/Domain/UseCases/SignUpUseCase.swift new file mode 100644 index 00000000..a8a25e1d --- /dev/null +++ b/Examples/ProductSample/Domain/UseCases/SignUpUseCase.swift @@ -0,0 +1,25 @@ +// +// SignUpUseCase.swift +// ProductSample +// +// Created by Guilherme Souza on 21/10/23. +// + +import Foundation + +enum SignUpResult { + case success + case requiresConfirmation +} + +protocol SignUpUseCase: UseCase> {} + +struct SignUpUseCaseImpl: SignUpUseCase { + let repository: AuthenticationRepository + + func execute(input: Credentials) -> Task { + Task { + try await repository.signUp(email: input.email, password: input.password) + } + } +} diff --git a/Examples/ProductSample/Domain/UseCases/UpdateProductUseCase.swift b/Examples/ProductSample/Domain/UseCases/UpdateProductUseCase.swift index 8e3411e3..90d1fde1 100644 --- a/Examples/ProductSample/Domain/UseCases/UpdateProductUseCase.swift +++ b/Examples/ProductSample/Domain/UseCases/UpdateProductUseCase.swift @@ -8,20 +8,6 @@ import Foundation import Supabase -struct ImageUploadParams { - let fileName: String - let fileExtension: String? - let mimeType: String? - let data: Data -} - -struct UpdateProductParams { - var id: String - var name: String? - var price: Double? - var image: ImageUploadParams? -} - protocol UpdateProductUseCase: UseCase> {} struct UpdateProductUseCaseImpl: UpdateProductUseCase { diff --git a/Examples/ProductSample/Routes/Routes.swift b/Examples/ProductSample/Routes/Routes.swift new file mode 100644 index 00000000..8fb642e5 --- /dev/null +++ b/Examples/ProductSample/Routes/Routes.swift @@ -0,0 +1,16 @@ +// +// Routes.swift +// ProductSample +// +// Created by Guilherme Souza on 21/10/23. +// + +import Foundation + +struct ProductDetailRoute: Hashable { + let productId: Product.ID +} + +struct AddProductRoute: Identifiable, Hashable { + var id: AnyHashable { self } +} diff --git a/Sources/GoTrue/GoTrueClient.swift b/Sources/GoTrue/GoTrueClient.swift index 50b7d5e9..dc6f9105 100644 --- a/Sources/GoTrue/GoTrueClient.swift +++ b/Sources/GoTrue/GoTrueClient.swift @@ -84,7 +84,6 @@ public final class GoTrueClient { ) } - public init(configuration: Configuration) { var configuration = configuration configuration.headers["X-Client-Info"] = "gotrue-swift/\(version)" @@ -108,7 +107,7 @@ public final class GoTrueClient { deinit { initializationTask?.cancel() } - + /// Listen for ``AuthChangeEvent`` events. /// - Parameter onChange: Closure to call when a new event is triggered. /// - Returns: A handle that can be used to unsubscribe from changes. @@ -123,7 +122,7 @@ public final class GoTrueClient { return handle } - + /// Unsubscribe from changes. /// - Parameter handle: The handle to unsubscribe. public func removeAuthStateChangeListener(_ handle: AuthStateListenerHandle) { From 0153ef441783730c6dcaf745ed3d9909642d5ada Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Sat, 21 Oct 2023 09:06:36 -0300 Subject: [PATCH 13/14] Send owner_id when creating product --- Examples/ProductSample/Application/Dependencies.swift | 3 ++- .../ProductSample/Data/AuthenticationRepository.swift | 8 ++++++++ Examples/ProductSample/Data/ProductRepository.swift | 8 ++++++++ Examples/ProductSample/Domain/Models/Product.swift | 4 ++++ .../Domain/UseCases/CreateProductUseCase.swift | 6 +++++- 5 files changed, 27 insertions(+), 2 deletions(-) diff --git a/Examples/ProductSample/Application/Dependencies.swift b/Examples/ProductSample/Application/Dependencies.swift index add52b18..897bb7a3 100644 --- a/Examples/ProductSample/Application/Dependencies.swift +++ b/Examples/ProductSample/Application/Dependencies.swift @@ -32,7 +32,8 @@ enum Dependencies { static let createProductUseCase: any CreateProductUseCase = CreateProductUseCaseImpl( productRepository: productRepository, - productImageStorageRepository: productImageStorageRepository + productImageStorageRepository: productImageStorageRepository, + authenticationRepository: authenticationRepository ) static let getProductUseCase: any GetProductUseCase = GetProductUseCaseImpl( diff --git a/Examples/ProductSample/Data/AuthenticationRepository.swift b/Examples/ProductSample/Data/AuthenticationRepository.swift index 4190d1b7..4c5b1b9b 100644 --- a/Examples/ProductSample/Data/AuthenticationRepository.swift +++ b/Examples/ProductSample/Data/AuthenticationRepository.swift @@ -11,6 +11,8 @@ import Supabase protocol AuthenticationRepository { var authStateListener: AsyncStream { 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 @@ -46,6 +48,12 @@ struct AuthenticationRepositoryImpl: AuthenticationRepository { let authStateListener: AsyncStream + 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) } diff --git a/Examples/ProductSample/Data/ProductRepository.swift b/Examples/ProductSample/Data/ProductRepository.swift index 7e73a5c4..e15f879e 100644 --- a/Examples/ProductSample/Data/ProductRepository.swift +++ b/Examples/ProductSample/Data/ProductRepository.swift @@ -12,6 +12,14 @@ 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 { diff --git a/Examples/ProductSample/Domain/Models/Product.swift b/Examples/ProductSample/Domain/Models/Product.swift index 3da4f32f..0cbbcd67 100644 --- a/Examples/ProductSample/Domain/Models/Product.swift +++ b/Examples/ProductSample/Domain/Models/Product.swift @@ -7,6 +7,10 @@ import Foundation +import struct GoTrue.User + +typealias UserID = User.ID + struct Product: Identifiable, Decodable { let id: String let name: String diff --git a/Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift b/Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift index e672ab80..77ef0f86 100644 --- a/Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift +++ b/Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift @@ -13,9 +13,12 @@ protocol CreateProductUseCase: UseCase> { struct CreateProductUseCaseImpl: CreateProductUseCase { let productRepository: ProductRepository let productImageStorageRepository: ProductImageStorageRepository + let authenticationRepository: AuthenticationRepository func execute(input: CreateProductParams) -> Task<(), Error> { Task { + let ownerID = try await authenticationRepository.currentUserID + var imageFilePath: String? if let image = input.image { @@ -23,7 +26,8 @@ struct CreateProductUseCaseImpl: CreateProductUseCase { } try await productRepository.createProduct( - InsertProductDto(name: input.name, price: input.price, image: imageFilePath) + InsertProductDto( + name: input.name, price: input.price, image: imageFilePath, ownerID: ownerID) ) } } From 3b5ca63ead54cbeb321f110028c9abebf6f4f60f Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 23 Oct 2023 05:36:57 -0300 Subject: [PATCH 14/14] Move Info.plist file --- Examples/Examples.xcodeproj/project.pbxproj | 10 +++++----- Examples/ProductSample/{ => SupportFiles}/Info.plist | 0 2 files changed, 5 insertions(+), 5 deletions(-) rename Examples/ProductSample/{ => SupportFiles}/Info.plist (100%) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 3c7e7bc8..456a71e9 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -60,7 +60,7 @@ 79018ADA2AE1DAF2006EA669 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; 79018ADD2AE1DB03006EA669 /* AuthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthViewModel.swift; sourceTree = ""; }; 79018ADF2AE309EE006EA669 /* SignInUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInUseCase.swift; sourceTree = ""; }; - 79018AE12AE3D0E3006EA669 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 79018AE12AE3D0E3006EA669 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 79018AE32AE3F185006EA669 /* Routes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Routes.swift; sourceTree = ""; }; 79018AE52AE3F1E4006EA669 /* Auth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Auth.swift; sourceTree = ""; }; 79018AE72AE3F1F3006EA669 /* SignUpUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpUseCase.swift; sourceTree = ""; }; @@ -184,6 +184,7 @@ 79018AD92AE1DA75006EA669 /* SupportFiles */ = { isa = PBXGroup; children = ( + 79018AE12AE3D0E3006EA669 /* Info.plist */, 79C591DF2AE088110088A9C8 /* Assets.xcassets */, 79C591E12AE088110088A9C8 /* Preview Content */, 79C591FC2AE152590088A9C8 /* Config.plist */, @@ -265,13 +266,12 @@ 79C591DA2AE0880F0088A9C8 /* ProductSample */ = { isa = PBXGroup; children = ( - 79018AE22AE3F17D006EA669 /* Routes */, - 79018AE12AE3D0E3006EA669 /* Info.plist */, 79018AD62AE1DA4C006EA669 /* Application */, 79C5920F2AE1CCE80088A9C8 /* Data */, 79C591F92AE12FD10088A9C8 /* Domain */, 79018AD52AE1DA31006EA669 /* Features */, 79018AD72AE1DA64006EA669 /* Helpers */, + 79018AE22AE3F17D006EA669 /* Routes */, 79018AD92AE1DA75006EA669 /* SupportFiles */, ); path = ProductSample; @@ -685,7 +685,7 @@ ENABLE_PREVIEWS = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = ProductSample/Info.plist; + INFOPLIST_FILE = ProductSample/SupportFIles/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -721,7 +721,7 @@ ENABLE_PREVIEWS = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = ProductSample/Info.plist; + INFOPLIST_FILE = ProductSample/SupportFIles/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/Examples/ProductSample/Info.plist b/Examples/ProductSample/SupportFiles/Info.plist similarity index 100% rename from Examples/ProductSample/Info.plist rename to Examples/ProductSample/SupportFiles/Info.plist