diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 27e3c925..456a71e9 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -7,6 +7,14 @@ 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 */; }; + 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 */; }; @@ -22,9 +30,40 @@ 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 */; }; + 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 */; }; + 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 */; }; + 79C5920A2AE159E20088A9C8 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C592092AE159E20088A9C8 /* Logger.swift */; }; + 79C5920C2AE1B8820088A9C8 /* Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C5920B2AE1B8820088A9C8 /* Result.swift */; }; + 79C592112AE1CD040088A9C8 /* ProductImageStorageRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C592102AE1CD040088A9C8 /* ProductImageStorageRepository.swift */; }; /* 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 = ""; }; + 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.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 = ""; }; 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 = ""; }; @@ -39,6 +78,28 @@ 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 79C592092AE159E20088A9C8 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + 79C5920B2AE1B8820088A9C8 /* Result.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Result.swift; sourceTree = ""; }; + 79C592102AE1CD040088A9C8 /* ProductImageStorageRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductImageStorageRepository.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -52,13 +113,107 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 79C591D62AE0880F0088A9C8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 79C591E82AE088250088A9C8 /* Supabase in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 79018AD22AE1D7DE006EA669 /* Models */ = { + isa = PBXGroup; + children = ( + 79C591E92AE089230088A9C8 /* Product.swift */, + 79018AE52AE3F1E4006EA669 /* Auth.swift */, + ); + 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 = ( + 79018ADC2AE1DAF6006EA669 /* Auth */, + 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 = ( + 79018AE12AE3D0E3006EA669 /* Info.plist */, + 79C591DF2AE088110088A9C8 /* Assets.xcassets */, + 79C591E12AE088110088A9C8 /* Preview Content */, + 79C591FC2AE152590088A9C8 /* Config.plist */, + ); + path = SupportFiles; + sourceTree = ""; + }; + 79018ADC2AE1DAF6006EA669 /* Auth */ = { + isa = PBXGroup; + children = ( + 79018ADA2AE1DAF2006EA669 /* AuthView.swift */, + 79018ADD2AE1DB03006EA669 /* AuthViewModel.swift */, + ); + path = Auth; + sourceTree = ""; + }; + 79018AE22AE3F17D006EA669 /* Routes */ = { + isa = PBXGroup; + children = ( + 79018AE32AE3F185006EA669 /* Routes.swift */, + ); + path = Routes; + sourceTree = ""; + }; 793895BD2954ABFF0044F2B8 = { isa = PBXGroup; children = ( 793895C82954ABFF0044F2B8 /* Examples */, + 79C591DA2AE0880F0088A9C8 /* ProductSample */, 793895C72954ABFF0044F2B8 /* Products */, 7956405A2954AC3E0088A06F /* Frameworks */, ); @@ -68,6 +223,7 @@ isa = PBXGroup; children = ( 793895C62954ABFF0044F2B8 /* Examples.app */, + 79C591D92AE0880F0088A9C8 /* ProductSample.app */, ); name = Products; sourceTree = ""; @@ -107,6 +263,62 @@ name = Frameworks; sourceTree = ""; }; + 79C591DA2AE0880F0088A9C8 /* ProductSample */ = { + isa = PBXGroup; + children = ( + 79018AD62AE1DA4C006EA669 /* Application */, + 79C5920F2AE1CCE80088A9C8 /* Data */, + 79C591F92AE12FD10088A9C8 /* Domain */, + 79018AD52AE1DA31006EA669 /* Features */, + 79018AD72AE1DA64006EA669 /* Helpers */, + 79018AE22AE3F17D006EA669 /* Routes */, + 79018AD92AE1DA75006EA669 /* SupportFiles */, + ); + path = ProductSample; + sourceTree = ""; + }; + 79C591E12AE088110088A9C8 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 79C591E22AE088110088A9C8 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 79C591F92AE12FD10088A9C8 /* Domain */ = { + isa = PBXGroup; + children = ( + 79018AD22AE1D7DE006EA669 /* Models */, + 79C592022AE159070088A9C8 /* UseCases */, + ); + path = Domain; + sourceTree = ""; + }; + 79C592022AE159070088A9C8 /* UseCases */ = { + isa = PBXGroup; + children = ( + 79C591FA2AE12FDE0088A9C8 /* UseCase.swift */, + 79C592032AE159130088A9C8 /* CreateProductUseCase.swift */, + 79C592052AE159250088A9C8 /* UpdateProductUseCase.swift */, + 79C592072AE159390088A9C8 /* GetProductUseCase.swift */, + 79018ACE2AE1D6CF006EA669 /* DeleteProductUseCase.swift */, + 79018AD02AE1D775006EA669 /* GetProductsUseCase.swift */, + 79018ADF2AE309EE006EA669 /* SignInUseCase.swift */, + 79018AE72AE3F1F3006EA669 /* SignUpUseCase.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 */ @@ -132,6 +344,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 +371,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 +400,7 @@ projectRoot = ""; targets = ( 793895C52954ABFF0044F2B8 /* Examples */, + 79C591D82AE0880F0088A9C8 /* ProductSample */, ); }; /* End PBXProject section */ @@ -179,6 +415,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 +445,39 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 79C591D52AE0880F0088A9C8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + 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 */, + 79C592042AE159130088A9C8 /* CreateProductUseCase.swift in Sources */, + 79C592012AE1561D0088A9C8 /* Dependencies.swift in Sources */, + 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 */, + 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 */, + 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 */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ @@ -393,6 +672,78 @@ }; 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/SupportFiles/Preview Content\""; + DEVELOPMENT_TEAM = ELTTE7K8TT; + ENABLE_PREVIEWS = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ProductSample/SupportFIles/Info.plist; + 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/SupportFiles/Preview Content\""; + DEVELOPMENT_TEAM = ELTTE7K8TT; + ENABLE_PREVIEWS = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ProductSample/SupportFIles/Info.plist; + 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 +765,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 +810,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/Application/AppView.swift b/Examples/ProductSample/Application/AppView.swift new file mode 100644 index 00000000..1f3acbd7 --- /dev/null +++ b/Examples/ProductSample/Application/AppView.swift @@ -0,0 +1,115 @@ +// +// AppView.swift +// ProductSample +// +// Created by Guilherme Souza on 18/10/23. +// + +import OSLog +import SwiftUI + +@MainActor +final class AppViewModel: ObservableObject { + private let logger = Logger.make(category: "AppViewModel") + private let authenticationRepository: AuthenticationRepository + + enum AuthState { + case authenticated(ProductListViewModel) + case notAuthenticated(AuthViewModel) + } + + @Published var addProductRoute: AddProductRoute? + @Published var authState: AuthState? + + private var authStateListenerTask: Task? + + init(authenticationRepository: AuthenticationRepository = Dependencies.authenticationRepository) { + self.authenticationRepository = authenticationRepository + + authStateListenerTask = Task { + for await state in authenticationRepository.authStateListener { + logger.debug("auth state changed: \(String(describing: state))") + + if Task.isCancelled { + logger.debug("auth state task cancelled, returning.") + return + } + + self.authState = + switch state { + case .signedIn: .authenticated(.init()) + case .signedOut: .notAuthenticated(.init()) + } + } + } + } + + deinit { + authStateListenerTask?.cancel() + } + + func productDetailViewModel(with productId: String?) -> ProductDetailsViewModel { + ProductDetailsViewModel(productId: productId) { [weak self] updated in + Task { + if case let .authenticated(model) = self?.authState { + await model.loadProducts() + } + } + } + } + + func signOutButtonTapped() async { + await authenticationRepository.signOut() + } +} + +struct AppView: View { + @StateObject var model = AppViewModel() + + var body: some View { + switch model.authState { + case .authenticated(let model): + authenticatedView(model: model) + case .notAuthenticated(let model): + notAuthenticatedView(model: model) + case .none: + ProgressView() + } + } + + func authenticatedView(model: ProductListViewModel) -> some View { + NavigationStack { + ProductListView(model: model) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Sign out") { + Task { await self.model.signOutButtonTapped() } + } + } + ToolbarItem(placement: .primaryAction) { + Button { + self.model.addProductRoute = .init() + } label: { + Label("Add", systemImage: "plus") + } + } + } + .navigationDestination(for: ProductDetailRoute.self) { route in + ProductDetailsView(model: self.model.productDetailViewModel(with: route.productId)) + } + } + .sheet(item: self.$model.addProductRoute) { _ in + NavigationStack { + ProductDetailsView(model: self.model.productDetailViewModel(with: nil)) + } + } + } + + func notAuthenticatedView(model: AuthViewModel) -> some View { + AuthView(model: model) + } +} + +#Preview { + AppView() +} diff --git a/Examples/ProductSample/Application/Dependencies.swift b/Examples/ProductSample/Application/Dependencies.swift new file mode 100644 index 00000000..897bb7a3 --- /dev/null +++ b/Examples/ProductSample/Application/Dependencies.swift @@ -0,0 +1,58 @@ +// +// Dependencies.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import Foundation +import Supabase + +enum Dependencies { + static let supabase = SupabaseClient( + supabaseURL: URL(string: Config.SUPABASE_URL)!, + supabaseKey: Config.SUPABASE_ANON_KEY + ) + + // MARK: Repositories + + static let productRepository: ProductRepository = ProductRepositoryImpl(supabase: supabase) + static let productImageStorageRepository: ProductImageStorageRepository = + ProductImageStorageRepositoryImpl(storage: supabase.storage) + static let authenticationRepository: AuthenticationRepository = AuthenticationRepositoryImpl( + client: supabase.auth + ) + + // MARK: Use Cases + + static let updateProductUseCase: any UpdateProductUseCase = UpdateProductUseCaseImpl( + productRepository: productRepository, + productImageStorageRepository: productImageStorageRepository + ) + + static let createProductUseCase: any CreateProductUseCase = CreateProductUseCaseImpl( + productRepository: productRepository, + productImageStorageRepository: productImageStorageRepository, + authenticationRepository: authenticationRepository + ) + + static let getProductUseCase: any GetProductUseCase = GetProductUseCaseImpl( + productRepository: productRepository + ) + + static let deleteProductUseCase: any DeleteProductUseCase = DeleteProductUseCaseImpl( + repository: productRepository + ) + + static let getProductsUseCase: any GetProductsUseCase = GetProductsUseCaseImpl( + repository: productRepository + ) + + static let signInUseCase: any SignInUseCase = SignInUseCaseImpl( + repository: authenticationRepository + ) + + static let signUpUseCase: any SignUpUseCase = SignUpUseCaseImpl( + repository: authenticationRepository + ) +} diff --git a/Examples/ProductSample/Application/ProductSampleApp.swift b/Examples/ProductSample/Application/ProductSampleApp.swift new file mode 100644 index 00000000..3b45dab7 --- /dev/null +++ b/Examples/ProductSample/Application/ProductSampleApp.swift @@ -0,0 +1,18 @@ +// +// ProductSampleApp.swift +// ProductSample +// +// Created by Guilherme Souza on 18/10/23. +// + +import Supabase +import SwiftUI + +@main +struct ProductSampleApp: App { + var body: some Scene { + WindowGroup { + AppView() + } + } +} diff --git a/Examples/ProductSample/Data/AuthenticationRepository.swift b/Examples/ProductSample/Data/AuthenticationRepository.swift new file mode 100644 index 00000000..4c5b1b9b --- /dev/null +++ b/Examples/ProductSample/Data/AuthenticationRepository.swift @@ -0,0 +1,80 @@ +// +// AuthenticationRepository.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import Foundation +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 + 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 + + var currentUserID: UUID { + get async throws { + try await client.session.user.id + } + } + + func signIn(email: String, password: String) async throws { + try await client.signIn(email: email, password: password) + } + + func signUp(email: String, password: String) async throws -> SignUpResult { + let response = try await client.signUp( + email: email, + password: password, + redirectTo: URL(string: "dev.grds.ProductSample://") + ) + if case .session = response { + return .success + } + return .requiresConfirmation + } + + func signInWithApple() async throws { + fatalError("\(#function) unimplemented") + } + + func signOut() async { + try? await client.signOut() + } +} 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/Data/ProductRepository.swift b/Examples/ProductSample/Data/ProductRepository.swift new file mode 100644 index 00000000..e15f879e --- /dev/null +++ b/Examples/ProductSample/Data/ProductRepository.swift @@ -0,0 +1,79 @@ +// +// ProductRepository.swift +// ProductSample +// +// Created by Guilherme Souza on 18/10/23. +// + +import Foundation +import Supabase + +struct InsertProductDto: Encodable { + let name: String + let price: Double + let image: String? + let ownerID: UserID + + enum CodingKeys: String, CodingKey { + case name + case price + case image + case ownerID = "owner_id" + } +} + +protocol ProductRepository { + func createProduct(_ product: InsertProductDto) async throws + func getProducts() async throws -> [Product] + func getProduct(id: Product.ID) async throws -> Product + func deleteProduct(id: Product.ID) async throws + func updateProduct(id: String, name: String?, price: Double?, image: String?) async throws +} + +struct ProductRepositoryImpl: ProductRepository { + let supabase: SupabaseClient + + func createProduct(_ product: InsertProductDto) async throws { + try await supabase.database.from("products").insert(values: product).execute() + } + + func getProducts() async throws -> [Product] { + try await supabase.database.from("products").select().execute().value + } + + func getProduct(id: Product.ID) async throws -> Product { + try await supabase.database.from("products").select().eq(column: "id", value: id).single() + .execute().value + } + + func deleteProduct(id: Product.ID) async throws { + try await supabase.database.from("products").delete().eq(column: "id", value: id).execute() + .value + } + + func updateProduct(id: String, name: String?, price: Double?, image: String?) async throws { + var params: [String: AnyJSON] = [:] + + if let name { + params["name"] = .string(name) + } + + if let price { + params["price"] = .number(price) + } + + if let image { + params["image"] = .string(image) + } + + if params.isEmpty { + // nothing to update, just return. + return + } + + try await supabase.database.from("products") + .update(values: params) + .eq(column: "id", value: id) + .execute() + } +} 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 new file mode 100644 index 00000000..0cbbcd67 --- /dev/null +++ b/Examples/ProductSample/Domain/Models/Product.swift @@ -0,0 +1,43 @@ +// +// Product.swift +// ProductSample +// +// Created by Guilherme Souza on 18/10/23. +// + +import Foundation + +import struct GoTrue.User + +typealias UserID = User.ID + +struct Product: Identifiable, Decodable { + let id: String + let name: String + let price: Double + let image: ImageKey? +} + +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 new file mode 100644 index 00000000..77ef0f86 --- /dev/null +++ b/Examples/ProductSample/Domain/UseCases/CreateProductUseCase.swift @@ -0,0 +1,34 @@ +// +// CreateProductUseCase.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import Foundation +import Supabase + +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 { + imageFilePath = try await productImageStorageRepository.uploadImage(image) + } + + try await productRepository.createProduct( + InsertProductDto( + name: input.name, price: input.price, image: imageFilePath, ownerID: ownerID) + ) + } + } +} 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/GetProductUseCase.swift b/Examples/ProductSample/Domain/UseCases/GetProductUseCase.swift new file mode 100644 index 00000000..039d98cf --- /dev/null +++ b/Examples/ProductSample/Domain/UseCases/GetProductUseCase.swift @@ -0,0 +1,20 @@ +// +// GetProductUseCase.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import Foundation + +protocol GetProductUseCase: UseCase> {} + +struct GetProductUseCaseImpl: GetProductUseCase { + let productRepository: ProductRepository + + func execute(input: Product.ID) -> Task { + Task { + try await productRepository.getProduct(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/SignInUseCase.swift b/Examples/ProductSample/Domain/UseCases/SignInUseCase.swift new file mode 100644 index 00000000..0e325926 --- /dev/null +++ b/Examples/ProductSample/Domain/UseCases/SignInUseCase.swift @@ -0,0 +1,20 @@ +// +// SignInUseCase.swift +// ProductSample +// +// Created by Guilherme Souza on 20/10/23. +// + +import Foundation + +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) + } + } +} 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 new file mode 100644 index 00000000..90d1fde1 --- /dev/null +++ b/Examples/ProductSample/Domain/UseCases/UpdateProductUseCase.swift @@ -0,0 +1,29 @@ +// +// UpdateProductUseCase.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import Foundation +import Supabase + +protocol UpdateProductUseCase: UseCase> {} + +struct UpdateProductUseCaseImpl: UpdateProductUseCase { + 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 productImageStorageRepository.uploadImage(image) + } + + try await productRepository.updateProduct( + id: input.id, name: input.name, price: input.price, image: imageFilePath) + } + } +} diff --git a/Examples/ProductSample/Domain/UseCases/UseCase.swift b/Examples/ProductSample/Domain/UseCases/UseCase.swift new file mode 100644 index 00000000..706434da --- /dev/null +++ b/Examples/ProductSample/Domain/UseCases/UseCase.swift @@ -0,0 +1,22 @@ +// +// 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) -> Output +} + +extension UseCase where Input == Void { + func execute() -> Output { + self.execute(input: ()) + } +} diff --git a/Examples/ProductSample/Features/Auth/AuthView.swift b/Examples/ProductSample/Features/Auth/AuthView.swift new file mode 100644 index 00000000..9d60cc10 --- /dev/null +++ b/Examples/ProductSample/Features/Auth/AuthView.swift @@ -0,0 +1,67 @@ +// +// AuthView.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import SwiftUI + +struct AuthView: View { + @ObservedObject var model: AuthViewModel + + var body: some View { + 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) } + } + } +} + +#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..1e14028b --- /dev/null +++ b/Examples/ProductSample/Features/Auth/AuthViewModel.swift @@ -0,0 +1,79 @@ +// +// AuthViewModel.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +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/ProductDetails/ProductDetailsView.swift b/Examples/ProductSample/Features/ProductDetails/ProductDetailsView.swift new file mode 100644 index 00000000..4c0bafb1 --- /dev/null +++ b/Examples/ProductSample/Features/ProductDetails/ProductDetailsView.swift @@ -0,0 +1,64 @@ +// +// ProductDetailsView.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import PhotosUI +import SwiftUI + +struct ProductDetailsView: View { + @ObservedObject var model: ProductDetailsViewModel + + @Environment(\.dismiss) private var dismiss + + var body: some View { + Form { + Section { + Group { + if let productImage = model.imageSource?.productImage { + productImage.image + .resizable() + } else { + 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()) + } + } + .task { await model.loadProductIfNeeded() } + .toolbar { + ToolbarItem(placement: .primaryAction) { + if model.isSavingProduct { + ProgressView() + } else { + Button("Save") { + Task { + if await model.saveButtonTapped() { + dismiss() + } + } + } + } + } + } + } +} + +#Preview { + ProductDetailsView(model: ProductDetailsViewModel(productId: nil) { _ in }) +} diff --git a/Examples/ProductSample/Features/ProductDetails/ProductDetailsViewModel.swift b/Examples/ProductSample/Features/ProductDetails/ProductDetailsViewModel.swift new file mode 100644 index 00000000..146f180e --- /dev/null +++ b/Examples/ProductSample/Features/ProductDetails/ProductDetailsViewModel.swift @@ -0,0 +1,169 @@ +// +// ProductDetailsViewModel.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import OSLog +import PhotosUI +import SwiftUI + +@MainActor +final class ProductDetailsViewModel: ObservableObject { + private let logger = Logger.make(category: "ProductDetailsViewModel") + + private let productId: Product.ID? + + 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 + + enum ImageSource { + case remote(ProductImage) + case local(ProductImage) + + var productImage: ProductImage { + switch self { + case .remote(let image), .local(let image): image + } + } + } + + @Published var imageSelection: PhotosPickerItem? { + didSet { + if let imageSelection { + Task { + await loadTransferable(from: imageSelection) + } + } + } + } + + @Published var imageSource: ImageSource? + @Published var isSavingProduct = false + + let onCompletion: (Bool) -> Void + + init( + 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 + } + + func loadProductIfNeeded() async { + guard let productId else { return } + + do { + let product = try await getProductUseCase.execute(input: productId).value + name = product.name + price = product.price + + if let image = product.image { + let data = try await productImageStorage.downloadImage(image) + imageSource = ProductImage(data: data).map(ImageSource.remote) + } + } catch { + logger.error("Error loading product: \(error)") + } + } + + func saveButtonTapped() async -> Bool { + isSavingProduct = true + defer { isSavingProduct = false } + + 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 { + 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 { + if let image = try? await imageSelection.loadTransferable(type: ProductImage.self) { + self.imageSource = .local(image) + } + } +} + +struct ProductImage: Transferable { + let image: Image + let data: Data + + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(importedContentType: .image) { data in + guard let image = ProductImage(data: data) else { + throw TransferError.importFailed + } + + 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) + } +} + +enum TransferError: Error { + case importFailed +} diff --git a/Examples/ProductSample/Features/ProductList/ProductListView.swift b/Examples/ProductSample/Features/ProductList/ProductListView.swift new file mode 100644 index 00000000..9d90501f --- /dev/null +++ b/Examples/ProductSample/Features/ProductList/ProductListView.swift @@ -0,0 +1,54 @@ +// +// ProductListView.swift +// ProductSample +// +// Created by Guilherme Souza on 19/10/23. +// + +import SwiftUI + +struct ProductListView: View { + @ObservedObject 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)) { + LabeledContent(product.name, value: product.price.formatted(.currency(code: "USD"))) + } + } + .onDelete { indexSet in + Task { + await model.didSwipeToDelete(indexSet) + } + } + } + .listStyle(.plain) + .overlay { + if model.products.isEmpty { + Text("Product list empty.") + } + } + .task { + await model.loadProducts() + } + .refreshable { + await model.loadProducts() + } + } +} + +#Preview { + ProductListView(model: ProductListViewModel()) +} diff --git a/Examples/ProductSample/Features/ProductList/ProductListViewModel.swift b/Examples/ProductSample/Features/ProductList/ProductListViewModel.swift new file mode 100644 index 00000000..0bbb9f61 --- /dev/null +++ b/Examples/ProductSample/Features/ProductList/ProductListViewModel.swift @@ -0,0 +1,64 @@ +// +// ProductListViewModel.swift +// ProductSample +// +// 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 deleteProductUseCase: any DeleteProductUseCase + private let getProductsUseCase: any GetProductsUseCase + + @Published var products: [Product] = [] + @Published var isLoading = false + @Published var error: Error? + + init( + deleteProductUseCase: any DeleteProductUseCase = Dependencies.deleteProductUseCase, + getProductsUseCase: any GetProductsUseCase = Dependencies.getProductsUseCase + ) { + self.deleteProductUseCase = deleteProductUseCase + self.getProductsUseCase = getProductsUseCase + } + + func loadProducts() async { + isLoading = true + defer { isLoading = false } + + do { + products = try await getProductsUseCase.execute().value + logger.info("Products loaded.") + self.error = nil + } catch { + logger.error("Error loading products: \(error)") + self.error = error + } + } + + 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 deleteProductUseCase.execute(input: product.id).value + self.error = nil + } catch { + logger.error("Failed to remove product: \(product.id) error: \(error)") + self.error = error + } + + await loadProducts() + } +} diff --git a/Examples/ProductSample/Helpers/Config.swift b/Examples/ProductSample/Helpers/Config.swift new file mode 100644 index 00000000..fd69fb86 --- /dev/null +++ b/Examples/ProductSample/Helpers/Config.swift @@ -0,0 +1,25 @@ +// +// 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/Helpers/Logger.swift b/Examples/ProductSample/Helpers/Logger.swift new file mode 100644 index 00000000..1f6cfffa --- /dev/null +++ b/Examples/ProductSample/Helpers/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/Helpers/Result.swift b/Examples/ProductSample/Helpers/Result.swift new file mode 100644 index 00000000..acda04c1 --- /dev/null +++ b/Examples/ProductSample/Helpers/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 + } +} 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/Examples/ProductSample/SupportFiles/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/ProductSample/SupportFiles/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/Examples/ProductSample/SupportFiles/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/ProductSample/SupportFiles/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/ProductSample/SupportFiles/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..13613e3e --- /dev/null +++ b/Examples/ProductSample/SupportFiles/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/SupportFiles/Assets.xcassets/Contents.json b/Examples/ProductSample/SupportFiles/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Examples/ProductSample/SupportFiles/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/ProductSample/SupportFiles/Info.plist b/Examples/ProductSample/SupportFiles/Info.plist new file mode 100644 index 00000000..a8e3f4cb --- /dev/null +++ b/Examples/ProductSample/SupportFiles/Info.plist @@ -0,0 +1,17 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + dev.grds.ProductSample + + + + + diff --git a/Examples/ProductSample/SupportFiles/Preview Content/Preview Assets.xcassets/Contents.json b/Examples/ProductSample/SupportFiles/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Examples/ProductSample/SupportFiles/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Makefile b/Makefile index 95c47a69..fc36553a 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 "Examples" "ProductSample"; do \ + xcodebuild build \ + -workspace supabase-swift.xcworkspace \ + -scheme "$$example" \ + -destination platform="$(PLATFORM_IOS)" || exit 1; \ + done; format: @swift format -i -r . 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..dc6f9105 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,57 @@ 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 +200,7 @@ public final class GoTrueClient { if let session = response.session { try await sessionManager.update(session) - authEventChangeSubject.send(.signedIn) + emitAuthChangeEvent(.signedIn) } return response @@ -226,7 +260,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 +369,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 +414,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 +467,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 +549,7 @@ public final class GoTrueClient { if let session = response.session { try await sessionManager.update(session) - authEventChangeSubject.send(.signedIn) + emitAuthChangeEvent(.signedIn) } return response @@ -556,7 +564,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 +603,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 +621,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/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..cba6a10a 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. @@ -143,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") ) @@ -188,3 +210,7 @@ public class StorageFileApi: StorageApi { return generatedUrl } } + +private func fileName(fromPath path: String) -> String { + (path as NSString).lastPathComponent +} 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/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 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") )