diff --git a/Healthy/Classes/Entities/Recipe.swift b/Domain/Sources/Domain/Entities/Recipe.swift similarity index 63% rename from Healthy/Classes/Entities/Recipe.swift rename to Domain/Sources/Domain/Entities/Recipe.swift index 02687230..52990ae7 100644 --- a/Healthy/Classes/Entities/Recipe.swift +++ b/Domain/Sources/Domain/Entities/Recipe.swift @@ -2,45 +2,51 @@ /// /// This model contains properties for the recipe title, recipe image URL, rating (out of 5 stars), /// user image URL, user name, and preparation time. -struct Recipe { +public struct Recipe { // MARK: - Properties + /// Recipe ID + public let recipeId: String + /// The title of the recipe - let title: String? + public let title: String? /// A URL string representing the recipe image - let recipeImageUrl: String? + public let recipeImageUrl: String? /// The rating of the recipe in the form of a star rating (out of 5 stars) - let rating: Int? + public let rating: Int? /// A URL string representing the user who posted the recipe - let userImageUrl: String? + public let userImageUrl: String? /// The name of the user who posted the recipe - let userName: String? + public let userName: String? /// The preparation time for the recipe in seconds - let preparationTime: Int? + public let preparationTime: Int? // MARK: - Initializers /// Initializes a new instance of `Recipe` with the given parameters. /// /// - Parameters: + /// - recipeId: The recipe Id /// - title: The title of the recipe. /// - recipeImageUrl: A URL string representing the recipe image. /// - rating: The star rating of the recipe (out of 5 stars). /// - userImageUrl: A URL string representing the user who posted the recipe. /// - userName: The name of the user who posted the recipe. /// - preparationTime: The preparation time for the recipe in minutes. - init(title: String = "", - recipeImageUrl: String = "", - rating: Int = 0, - userImageUrl: String = "", - userName: String = "", - preparationTime: Int = 0) { + public init(recipeId: String, + title: String = "", + recipeImageUrl: String = "", + rating: Int = 0, + userImageUrl: String = "", + userName: String = "", + preparationTime: Int = 0) { + self.recipeId = recipeId self.title = title self.recipeImageUrl = recipeImageUrl self.rating = rating @@ -48,15 +54,4 @@ struct Recipe { self.userName = userName self.preparationTime = preparationTime } - - // MARK: - Methods - - /// Formats the preparation time as a string in the "Minutes:Seconds" format. - /// - /// - Returns: A string representing the preparation time formatted as "Minutes:Seconds". - func formattedPreparationTime() -> String { - let minutes = (preparationTime ?? 0) / 60 - let seconds = (preparationTime ?? 0) % 60 - return String(format: "%02d:%02d", minutes, seconds) - } } diff --git a/Domain/Sources/Domain/UseCases/FavoriteRecipe.swift b/Domain/Sources/Domain/UseCases/FavoriteRecipe.swift new file mode 100644 index 00000000..0c49d228 --- /dev/null +++ b/Domain/Sources/Domain/UseCases/FavoriteRecipe.swift @@ -0,0 +1,5 @@ +public protocol FavoriteRecipeUseCase { + func favoriteRecipe(_ recipe: Recipe) + func unfavoriteRecipe(_ recipe: Recipe) + func allFavoriteRecipes() -> [Recipe] +} diff --git a/Healthy.xcodeproj/project.pbxproj b/Healthy.xcodeproj/project.pbxproj index 900ca6de..ba7f3e61 100644 --- a/Healthy.xcodeproj/project.pbxproj +++ b/Healthy.xcodeproj/project.pbxproj @@ -12,6 +12,11 @@ 0229242A2A33A137009290A8 /* UIView+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022924292A33A137009290A8 /* UIView+Helpers.swift */; }; 0229242F2A33A170009290A8 /* Strings.Generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0229242C2A33A170009290A8 /* Strings.Generated.swift */; }; 022924302A33A170009290A8 /* UIColors.Generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0229242D2A33A170009290A8 /* UIColors.Generated.swift */; }; + 02373E5B2A7CED3F0095DD03 /* Storage in Frameworks */ = {isa = PBXBuildFile; productRef = 02373E5A2A7CED3F0095DD03 /* Storage */; }; + 0246FE842A7D1A640092B237 /* FavoriteRecipeUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0246FE832A7D1A640092B237 /* FavoriteRecipeUseCase.swift */; }; + 0246FE872A7D1AF80092B237 /* UseCase+Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0246FE862A7D1AF80092B237 /* UseCase+Container.swift */; }; + 0246FE8A2A7D1BFF0092B237 /* Storage+Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0246FE892A7D1BFF0092B237 /* Storage+Container.swift */; }; + 0246FE8C2A7D26470092B237 /* KeyValueWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0246FE8B2A7D26470092B237 /* KeyValueWrapper.swift */; }; 025511922A3C394300295B91 /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 20376AA82A2663F8004BF0BF /* Colors.xcassets */; }; 025511962A3C3F6400295B91 /* Poppins-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 025511942A3C3F6400295B91 /* Poppins-Regular.ttf */; }; 025511972A3C3F6400295B91 /* Poppins-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 025511952A3C3F6400295B91 /* Poppins-Bold.ttf */; }; @@ -28,8 +33,6 @@ 025511B32A3C5F8900295B91 /* FilterSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025511AF2A3C5F8900295B91 /* FilterSearchViewModel.swift */; }; 025511B42A3C5F8900295B91 /* FilterSearchViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 025511B02A3C5F8900295B91 /* FilterSearchViewController.xib */; }; 025511B72A3C623000295B91 /* NewRelic in Frameworks */ = {isa = PBXBuildFile; productRef = 025511B62A3C623000295B91 /* NewRelic */; }; - 025511BB2A3C655200295B91 /* SavedRecipe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025511BA2A3C655200295B91 /* SavedRecipe.swift */; }; - 025511BD2A3C656300295B91 /* Recipe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025511BC2A3C656300295B91 /* Recipe.swift */; }; 025511BF2A3C8E9300295B91 /* OnboardingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025511BE2A3C8E9300295B91 /* OnboardingCoordinator.swift */; }; 025511C22A3D049500295B91 /* Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025511C12A3D049500295B91 /* Coordinator.swift */; }; 025511C42A3D058800295B91 /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025511C32A3D058800295B91 /* AppCoordinator.swift */; }; @@ -66,7 +69,7 @@ 027DDA422A0E6A680052818C /* HealthyUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027DDA412A0E6A680052818C /* HealthyUITestsLaunchTests.swift */; }; 0286F2862A5EEB1800F20478 /* RandomMealEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0286F2852A5EEB1800F20478 /* RandomMealEntity.swift */; }; 02892EC82A58575C001A3DB4 /* Networking in Frameworks */ = {isa = PBXBuildFile; productRef = 02892EC72A58575C001A3DB4 /* Networking */; }; - 02892ECA2A599CBC001A3DB4 /* Container+Networking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02892EC92A599CBC001A3DB4 /* Container+Networking.swift */; }; + 02892ECA2A599CBC001A3DB4 /* Services+Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02892EC92A599CBC001A3DB4 /* Services+Container.swift */; }; 0296F7932A4342B500DBC86A /* FacebookCore in Frameworks */ = {isa = PBXBuildFile; productRef = 0296F7922A4342B500DBC86A /* FacebookCore */; }; 0296F7952A4342B500DBC86A /* FacebookLogin in Frameworks */ = {isa = PBXBuildFile; productRef = 0296F7942A4342B500DBC86A /* FacebookLogin */; }; 0296F7972A43491B00DBC86A /* FacebookAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0296F7962A43491B00DBC86A /* FacebookAuthenticator.swift */; }; @@ -95,7 +98,6 @@ 27C7C7F92A4341A300FECE25 /* LogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C7C7F52A4341A300FECE25 /* LogLevel.swift */; }; 27C7C7FB2A4341D100FECE25 /* NewRelicLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C7C7FA2A4341D100FECE25 /* NewRelicLogger.swift */; }; 6D2145472A44C9EB0085C519 /* SearchFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2145462A44C9EB0085C519 /* SearchFilter.swift */; }; - 6D3B31712A4BBC0000BA0CA8 /* HomeHeaderSkeletonView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 6D94975E2A4BB45B004A135C /* HomeHeaderSkeletonView.xib */; }; 6D42077B2A5CD14400DAB9A5 /* Area.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D42077A2A5CD14400DAB9A5 /* Area.swift */; }; 6D42077D2A5CD20A00DAB9A5 /* Category.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D42077C2A5CD20A00DAB9A5 /* Category.swift */; }; 6D42077F2A5CD21500DAB9A5 /* Ingrediant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D42077E2A5CD21500DAB9A5 /* Ingrediant.swift */; }; @@ -176,6 +178,11 @@ 022924292A33A137009290A8 /* UIView+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+Helpers.swift"; sourceTree = ""; }; 0229242C2A33A170009290A8 /* Strings.Generated.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Strings.Generated.swift; sourceTree = ""; }; 0229242D2A33A170009290A8 /* UIColors.Generated.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIColors.Generated.swift; sourceTree = ""; }; + 02373E592A7CE6560095DD03 /* Storage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Storage; sourceTree = ""; }; + 0246FE832A7D1A640092B237 /* FavoriteRecipeUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteRecipeUseCase.swift; sourceTree = ""; }; + 0246FE862A7D1AF80092B237 /* UseCase+Container.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UseCase+Container.swift"; sourceTree = ""; }; + 0246FE892A7D1BFF0092B237 /* Storage+Container.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Container.swift"; sourceTree = ""; }; + 0246FE8B2A7D26470092B237 /* KeyValueWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyValueWrapper.swift; sourceTree = ""; }; 025511942A3C3F6400295B91 /* Poppins-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Poppins-Regular.ttf"; sourceTree = ""; }; 025511952A3C3F6400295B91 /* Poppins-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Poppins-Bold.ttf"; sourceTree = ""; }; 025511992A3C5D7300295B91 /* SearchViewModelType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModelType.swift; sourceTree = ""; }; @@ -190,8 +197,6 @@ 025511AE2A3C5F8900295B91 /* FilterSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterSearchViewController.swift; sourceTree = ""; }; 025511AF2A3C5F8900295B91 /* FilterSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterSearchViewModel.swift; sourceTree = ""; }; 025511B02A3C5F8900295B91 /* FilterSearchViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FilterSearchViewController.xib; sourceTree = ""; }; - 025511BA2A3C655200295B91 /* SavedRecipe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedRecipe.swift; sourceTree = ""; }; - 025511BC2A3C656300295B91 /* Recipe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Recipe.swift; sourceTree = ""; }; 025511BE2A3C8E9300295B91 /* OnboardingCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingCoordinator.swift; sourceTree = ""; }; 025511C12A3D049500295B91 /* Coordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Coordinator.swift; sourceTree = ""; }; 025511C32A3D058800295B91 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; @@ -231,7 +236,7 @@ 027DDA412A0E6A680052818C /* HealthyUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthyUITestsLaunchTests.swift; sourceTree = ""; }; 0286F2852A5EEB1800F20478 /* RandomMealEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomMealEntity.swift; sourceTree = ""; }; 02892EC52A585558001A3DB4 /* Networking */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Networking; sourceTree = ""; }; - 02892EC92A599CBC001A3DB4 /* Container+Networking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Container+Networking.swift"; sourceTree = ""; }; + 02892EC92A599CBC001A3DB4 /* Services+Container.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Services+Container.swift"; sourceTree = ""; }; 0296F7962A43491B00DBC86A /* FacebookAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FacebookAuthenticator.swift; sourceTree = ""; }; 029C89112A71E78000AF380B /* NSLayoutConstraint+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSLayoutConstraint+Helpers.swift"; sourceTree = ""; }; 029C89132A71E79A00AF380B /* NewRecipesCollectionViewLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewRecipesCollectionViewLayout.swift; sourceTree = ""; }; @@ -258,12 +263,10 @@ 27C7C7F52A4341A300FECE25 /* LogLevel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogLevel.swift; sourceTree = ""; }; 27C7C7FA2A4341D100FECE25 /* NewRelicLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewRelicLogger.swift; sourceTree = ""; }; 6D2145462A44C9EB0085C519 /* SearchFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFilter.swift; sourceTree = ""; }; - 6D6B89AC2A5DE99F00E52F4C /* FilterByArea.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterByArea.swift; sourceTree = ""; }; 6D42077A2A5CD14400DAB9A5 /* Area.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Area.swift; sourceTree = ""; }; 6D42077C2A5CD20A00DAB9A5 /* Category.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Category.swift; sourceTree = ""; }; 6D42077E2A5CD21500DAB9A5 /* Ingrediant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ingrediant.swift; sourceTree = ""; }; - 6D94975C2A4BB456004A135C /* HomeHeaderSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeHeaderSkeletonView.swift; sourceTree = ""; }; - 6D94975E2A4BB45B004A135C /* HomeHeaderSkeletonView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HomeHeaderSkeletonView.xib; sourceTree = ""; }; + 6D6B89AC2A5DE99F00E52F4C /* FilterByArea.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterByArea.swift; sourceTree = ""; }; 6D9D97A42A4B020F00BB3589 /* PublisherSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublisherSpy.swift; sourceTree = ""; }; 6D9D97A62A4B188400BB3589 /* PublisherMultibleValueSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublisherMultibleValueSpy.swift; sourceTree = ""; }; 6DA228FA2A431FF30011E43E /* SearchViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModelTests.swift; sourceTree = ""; }; @@ -328,6 +331,7 @@ 025511B72A3C623000295B91 /* NewRelic in Frameworks */, 6DDA138D2A1109E8004390D4 /* Factory in Frameworks */, 02557D732A697E8E0022756A /* Domain in Frameworks */, + 02373E5B2A7CED3F0095DD03 /* Storage in Frameworks */, 0296F7952A4342B500DBC86A /* FacebookLogin in Frameworks */, 6D611B9D2A299E3600A1FC65 /* GoogleSignIn in Frameworks */, ); @@ -418,8 +422,6 @@ 025511B92A3C654700295B91 /* Entities */ = { isa = PBXGroup; children = ( - 025511BC2A3C656300295B91 /* Recipe.swift */, - 025511BA2A3C655200295B91 /* SavedRecipe.swift */, 798B59B12A5D15E600E4DCFF /* MealCategories.swift */, C972DB2E2A5F6F91000041D1 /* FilterByMainIngredient.swift */, 6D6B89AC2A5DE99F00E52F4C /* FilterByArea.swift */, @@ -467,7 +469,9 @@ 02557D6F2A697D8E0022756A /* UseCases */ = { isa = PBXGroup; children = ( + 0246FE832A7D1A640092B237 /* FavoriteRecipeUseCase.swift */, 02557D702A697DF90022756A /* LoginUseCase.swift */, + 0246FE862A7D1AF80092B237 /* UseCase+Container.swift */, ); path = UseCases; sourceTree = ""; @@ -539,6 +543,7 @@ isa = PBXGroup; children = ( 02557D6E2A697C3D0022756A /* Domain */, + 02373E592A7CE6560095DD03 /* Storage */, 02892EC52A585558001A3DB4 /* Networking */, 027DDA1D2A0E6A660052818C /* Healthy */, 027DDA342A0E6A680052818C /* HealthyTests */, @@ -609,11 +614,11 @@ 027DDA542A0E76C50052818C /* ReusableViews */, 027DDA552A0E76DC0052818C /* Services */, 025A47832A337337008BF85A /* System */, + 02557D6F2A697D8E0022756A /* UseCases */, 027DDA532A0E76A00052818C /* Utilities */, 025511C32A3D058800295B91 /* AppCoordinator.swift */, 027DDA1E2A0E6A660052818C /* AppDelegate.swift */, 027DDA202A0E6A660052818C /* SceneDelegate.swift */, - 02557D6F2A697D8E0022756A /* UseCases */, ); path = Classes; sourceTree = ""; @@ -658,6 +663,7 @@ C96520C82A2B72B800FACCC0 /* Logger */, 025A47702A337320008BF85A /* Validators */, 025511C12A3D049500295B91 /* Coordinator.swift */, + 0246FE8B2A7D26470092B237 /* KeyValueWrapper.swift */, ); path = Utilities; sourceTree = ""; @@ -677,19 +683,12 @@ 027DDA552A0E76DC0052818C /* Services */ = { isa = PBXGroup; children = ( - 027DDA572A0E76E90052818C /* Storage */, - 02892EC92A599CBC001A3DB4 /* Container+Networking.swift */, + 02892EC92A599CBC001A3DB4 /* Services+Container.swift */, + 0246FE892A7D1BFF0092B237 /* Storage+Container.swift */, ); path = Services; sourceTree = ""; }; - 027DDA572A0E76E90052818C /* Storage */ = { - isa = PBXGroup; - children = ( - ); - path = Storage; - sourceTree = ""; - }; 02892EC62A58575C001A3DB4 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -1094,6 +1093,7 @@ 0296F7942A4342B500DBC86A /* FacebookLogin */, 02892EC72A58575C001A3DB4 /* Networking */, 02557D722A697E8E0022756A /* Domain */, + 02373E5A2A7CED3F0095DD03 /* Storage */, ); productName = Healthy; productReference = 027DDA1B2A0E6A660052818C /* Healthy.app */; @@ -1304,6 +1304,7 @@ 0286F2862A5EEB1800F20478 /* RandomMealEntity.swift in Sources */, 0266E7E62A56372500D5ABC4 /* UINavigationBar+Appearance.swift in Sources */, C2FE12122A412904001A7BE3 /* AnimatableView.swift in Sources */, + 0246FE872A7D1AF80092B237 /* UseCase+Container.swift in Sources */, 025511C42A3D058800295B91 /* AppCoordinator.swift in Sources */, 209F93D12A40C1E700B007BA /* NewRecipeCollectionViewCell.swift in Sources */, 6D6B89AD2A5DE99F00E52F4C /* FilterByArea.swift in Sources */, @@ -1329,7 +1330,9 @@ 025511A92A3C5DAA00295B91 /* SavedRecipesViewController.swift in Sources */, 27C7C7F82A4341A300FECE25 /* FileSystemLogger.swift in Sources */, 025A477F2A337320008BF85A /* ValidationRule.swift in Sources */, + 0246FE8A2A7D1BFF0092B237 /* Storage+Container.swift in Sources */, B24160892A45B4DA000DF5BA /* FoodTagsView.swift in Sources */, + 0246FE842A7D1A640092B237 /* FavoriteRecipeUseCase.swift in Sources */, B2F799E92A344B9A002F1894 /* MainTabBarController.swift in Sources */, C21F76E42A4AFBF700E0609C /* Collection+Helpers.swift in Sources */, 025511B22A3C5F8900295B91 /* FilterSearchViewController.swift in Sources */, @@ -1341,13 +1344,11 @@ 025511AA2A3C5DAA00295B91 /* SavedRecipesViewModel.swift in Sources */, B241608B2A45B4DA000DF5BA /* FoodTagCollectionViewCell.swift in Sources */, 6D42077D2A5CD20A00DAB9A5 /* Category.swift in Sources */, - 025511BB2A3C655200295B91 /* SavedRecipe.swift in Sources */, 025511A82A3C5DAA00295B91 /* SavedRecipesViewModelType.swift in Sources */, - 02892ECA2A599CBC001A3DB4 /* Container+Networking.swift in Sources */, + 02892ECA2A599CBC001A3DB4 /* Services+Container.swift in Sources */, 025511D02A3D0A8B00295B91 /* SplashViewModel.swift in Sources */, 0296F7972A43491B00DBC86A /* FacebookAuthenticator.swift in Sources */, 025511B12A3C5F8900295B91 /* FilterSearchViewModelType.swift in Sources */, - 025511BD2A3C656300295B91 /* Recipe.swift in Sources */, 0255119D2A3C5D7300295B91 /* SearchViewModelType.swift in Sources */, 6DF5250C2A1E855F0027502C /* LoginViewModelType.swift in Sources */, 27C7C7F72A4341A300FECE25 /* Logging.swift in Sources */, @@ -1381,6 +1382,7 @@ 027DDA212A0E6A660052818C /* SceneDelegate.swift in Sources */, 025A47532A336B05008BF85A /* DashboardViewModelType.swift in Sources */, C972DB2F2A5F6F91000041D1 /* FilterByMainIngredient.swift in Sources */, + 0246FE8C2A7D26470092B237 /* KeyValueWrapper.swift in Sources */, 6DF5250A2A1E854B0027502C /* LoginViewModel.swift in Sources */, 025A477B2A337320008BF85A /* EmailValidator.swift in Sources */, 020CF0B72A3FA8F600D81BC8 /* UIImage.Generated.swift in Sources */, @@ -1790,6 +1792,10 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 02373E5A2A7CED3F0095DD03 /* Storage */ = { + isa = XCSwiftPackageProductDependency; + productName = Storage; + }; 025511B62A3C623000295B91 /* NewRelic */ = { isa = XCSwiftPackageProductDependency; package = 025511B52A3C623000295B91 /* XCRemoteSwiftPackageReference "newrelic-ios-agent-spm" */; diff --git a/Healthy/Classes/AppCoordinator.swift b/Healthy/Classes/AppCoordinator.swift index 2ae37000..a485e9e8 100644 --- a/Healthy/Classes/AppCoordinator.swift +++ b/Healthy/Classes/AppCoordinator.swift @@ -4,7 +4,7 @@ import UIKit final class AppCoordinator { private let window: UIWindow private var children: [Coordinator] = [] - private var isLoggedIn = false // TODO: [HL-74] Replace with actual implentation + private var isLoggedIn = true // TODO: [HL-74] Replace with actual implentation /// Initializes a new `AppCoordinator` object with the specified window. /// diff --git a/Healthy/Classes/Entities/SavedRecipe.swift b/Healthy/Classes/Entities/SavedRecipe.swift deleted file mode 100644 index 08f1b4a9..00000000 --- a/Healthy/Classes/Entities/SavedRecipe.swift +++ /dev/null @@ -1,38 +0,0 @@ -import UIKit - -struct SavedRecipe: Hashable, Equatable { - - // MARK: - Properties - - let id = UUID() - - /// The title of the recipe - let title: String? - - /// The image of the recipe - let recipeImage: UIImage? - - /// The rating of the recipe - let rating: Double? - - /// The chef who posted the recipe - let chefName: String? - - /// The cooking Time of The recipe - let cookingTime: Int? - - var toggleBookmark: () -> Void - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - - static func == (lhs: SavedRecipe, rhs: SavedRecipe) -> Bool { - return lhs.title == rhs.title && - lhs.rating == rhs.rating && - lhs.chefName == rhs.chefName && - lhs.cookingTime == rhs.cookingTime && - lhs.recipeImage == rhs.recipeImage - } - -} diff --git a/Healthy/Classes/Modules/Dashboard/DashboardViewModel.swift b/Healthy/Classes/Modules/Dashboard/DashboardViewModel.swift index bf6ebade..97b8e047 100644 --- a/Healthy/Classes/Modules/Dashboard/DashboardViewModel.swift +++ b/Healthy/Classes/Modules/Dashboard/DashboardViewModel.swift @@ -1,9 +1,13 @@ import Combine +import Domain import Foundation +import Factory // MARK: DashboardViewModel final class DashboardViewModel { + @Injected(\.favoriteRecipeUseCase) private var favoriteRecipeUseCase + @Published private var newRecipes: [NewRecipeViewModel] = [] init() { @@ -31,31 +35,27 @@ extension DashboardViewModel: DashboardViewModelOutput { private extension DashboardViewModel { private func loadPlaceholderNewRecipes() { - newRecipes = [ - NewRecipeViewModel( - recipeName: "", - userName: "", - preparationTimeInMinutes: "", - recipeImageUrl: "", - rating: .zero, - userImageUrl: nil - ), - NewRecipeViewModel( - recipeName: "", - userName: "", - preparationTimeInMinutes: "", - recipeImageUrl: "", - rating: .zero, - userImageUrl: nil - ), + let recipes = [ + Recipe(recipeId: "first-recipe", + title: "First Recipe"), + Recipe(recipeId: "second-recipe", + title: "Second Recipe"), + Recipe(recipeId: "third-recipe", + title: "Third Recipe") + ] + + newRecipes = recipes.map { recipe in NewRecipeViewModel( - recipeName: "", - userName: "", - preparationTimeInMinutes: "", - recipeImageUrl: "", - rating: .zero, - userImageUrl: nil + recipeName: recipe.title ?? String(), + userName: recipe.userName ?? String(), + preparationTimeInMinutes: recipe.preparationTime?.description ?? String(), + recipeImageUrl: recipe.recipeImageUrl, + rating: recipe.rating, + userImageUrl: recipe.userImageUrl, + onTap: { [weak self] in + self?.favoriteRecipeUseCase.favoriteRecipe(recipe) + } ) - ] + } } } diff --git a/Healthy/Classes/Modules/Dashboard/Views/NewRecipesView/CollectionViewCell/NewRecipeCollectionViewCell.swift b/Healthy/Classes/Modules/Dashboard/Views/NewRecipesView/CollectionViewCell/NewRecipeCollectionViewCell.swift index 8c78996d..4ad07515 100644 --- a/Healthy/Classes/Modules/Dashboard/Views/NewRecipesView/CollectionViewCell/NewRecipeCollectionViewCell.swift +++ b/Healthy/Classes/Modules/Dashboard/Views/NewRecipesView/CollectionViewCell/NewRecipeCollectionViewCell.swift @@ -12,11 +12,16 @@ final class NewRecipeCollectionViewCell: UICollectionViewCell { @IBOutlet weak private(set) var preparationTimeHorizontalStackView: UIStackView! @IBOutlet weak private(set) var cardView: UIView! + // MARK: - Properties + + private var onTap: () -> Void = { } + // MARK: - Lifecycle override func awakeFromNib() { super.awakeFromNib() configureAppearance() + configureOnTapGesture() } override func prepareForReuse() { @@ -33,6 +38,12 @@ final class NewRecipeCollectionViewCell: UICollectionViewCell { cardView.applyDefaultCardShadow(cornerRadius: Constants.cornerRadius) } + private func configureOnTapGesture() { + addGestureRecognizer( + UITapGestureRecognizer(target: self, action: #selector(viewWasTapped)) + ) + } + /// Sets placeholder images for testing purposes /// /// Use this till a `ViewModel` is passed and configures the view properly @@ -41,6 +52,11 @@ final class NewRecipeCollectionViewCell: UICollectionViewCell { recipeImage.image = UIImage.imageRecipePlaceholder } + /// Called when the cell is tapped + @objc private func viewWasTapped() { + onTap() + } + /// Configures the cell view and binds the data from the provided view model. /// /// - Parameter viewModel: The view model containing the data to be displayed in the cell view. @@ -49,6 +65,7 @@ final class NewRecipeCollectionViewCell: UICollectionViewCell { userNameLabel.text = viewModel.userName preparationTimeInMinutesLabel.text = viewModel.preparationTimeInMinutes + onTap = viewModel.onTap // TODO: [HL-52] To be Implemented after implementing binding imageUrl To UIImageView } } @@ -70,7 +87,6 @@ extension NewRecipeCollectionViewCell { /// The view model used to configure the `NewRecipeCollectionViewCell`. struct ViewModel: Equatable { - /// The name of the recipe. let recipeName: String @@ -88,5 +104,13 @@ extension NewRecipeCollectionViewCell { /// The image urlrepresenting the user photo. let userImageUrl: String? + + /// On cell tap action + let onTap: () -> Void + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.recipeName == rhs.recipeName + && lhs.userName == rhs.userName + } } } diff --git a/Healthy/Classes/Modules/SavedRecipes/Cell/SavedRecipesTableViewCell.swift b/Healthy/Classes/Modules/SavedRecipes/Cell/SavedRecipesTableViewCell.swift index 393de93c..8ac1f0fe 100644 --- a/Healthy/Classes/Modules/SavedRecipes/Cell/SavedRecipesTableViewCell.swift +++ b/Healthy/Classes/Modules/SavedRecipes/Cell/SavedRecipesTableViewCell.swift @@ -32,11 +32,7 @@ extension SavedRecipesTableViewCell { } static func == (lhs: SavedRecipesTableViewCell.ViewModel, rhs: SavedRecipesTableViewCell.ViewModel) -> Bool { - return lhs.title == rhs.title && - lhs.rating == rhs.rating && - lhs.chefName == rhs.chefName && - lhs.cookingTime == rhs.cookingTime && - lhs.recipeImage == rhs.recipeImage + return lhs.id == rhs.id } } } diff --git a/Healthy/Classes/Modules/SavedRecipes/SavedRecipesViewController.swift b/Healthy/Classes/Modules/SavedRecipes/SavedRecipesViewController.swift index a2da6a85..a1a51f40 100644 --- a/Healthy/Classes/Modules/SavedRecipes/SavedRecipesViewController.swift +++ b/Healthy/Classes/Modules/SavedRecipes/SavedRecipesViewController.swift @@ -43,6 +43,12 @@ final class SavedRecipesViewController: UIViewController { configureTableViewDataSource() setupBindings() } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + viewModel.viewWillAppear() + } } // MARK: - Actions diff --git a/Healthy/Classes/Modules/SavedRecipes/SavedRecipesViewModel.swift b/Healthy/Classes/Modules/SavedRecipes/SavedRecipesViewModel.swift index 60657951..b48af133 100644 --- a/Healthy/Classes/Modules/SavedRecipes/SavedRecipesViewModel.swift +++ b/Healthy/Classes/Modules/SavedRecipes/SavedRecipesViewModel.swift @@ -1,24 +1,18 @@ import Combine import UIKit +import Factory // MARK: SavedRecipesViewModel final class SavedRecipesViewModel { + typealias CellViewModel = SavedRecipesTableViewCell.ViewModel + + @Injected(\.favoriteRecipeUseCase) private var favoriteRecipeUseCase + // MARK: - Properties - @Published private (set) var savedRecipes: [SavedRecipesTableViewCell.ViewModel] = [ - SavedRecipesTableViewCell.ViewModel(title: "Traditional spare ribs baked ", - recipeImage: UIImage.iconFood, - rating: 4.5, - chefName: "By Chef John", - cookingTime: 15, toggleBookmark: {}), - SavedRecipesTableViewCell.ViewModel(title: "spice roasted chicken with flavored rice", - recipeImage: UIImage.iconFood, - rating: 5.0, - chefName: "By Mark Kelvin", - cookingTime: 20, toggleBookmark: {}) - ] + @Published private(set) var savedRecipes: [CellViewModel] = [] } // MARK: SavedRecipesViewModel @@ -29,6 +23,10 @@ extension SavedRecipesViewModel: SavedRecipesViewModelInput { savedRecipes.remove(at: index) } } + + func viewWillAppear() { + reloadSavedRecipes() + } } // MARK: SavedRecipesViewModelOutput @@ -41,4 +39,18 @@ extension SavedRecipesViewModel: SavedRecipesViewModelOutput { // MARK: Private Handlers -private extension SavedRecipesViewModel {} +private extension SavedRecipesViewModel { + private func reloadSavedRecipes() { + let recipes = favoriteRecipeUseCase.allFavoriteRecipes() + savedRecipes = recipes.map { recipe in + CellViewModel(title: recipe.title, + recipeImage: UIImage.iconFood, + rating: Double(recipe.rating ?? .zero), + chefName: recipe.userName, + cookingTime: recipe.preparationTime) { [weak self] in + self?.favoriteRecipeUseCase.unfavoriteRecipe(recipe) + self?.reloadSavedRecipes() + } + } + } +} diff --git a/Healthy/Classes/Modules/SavedRecipes/SavedRecipesViewModelType.swift b/Healthy/Classes/Modules/SavedRecipes/SavedRecipesViewModelType.swift index 502d4cf9..98aab124 100644 --- a/Healthy/Classes/Modules/SavedRecipes/SavedRecipesViewModelType.swift +++ b/Healthy/Classes/Modules/SavedRecipes/SavedRecipesViewModelType.swift @@ -7,6 +7,7 @@ typealias SavedRecipesViewModelType = SavedRecipesViewModelInput & SavedRecipesV /// protocol SavedRecipesViewModelInput { func removeSavedRecipe(_ recipe: SavedRecipesTableViewCell.ViewModel) + func viewWillAppear() } /// SavedRecipes ViewModel Output diff --git a/Healthy/Classes/Modules/Search/Search/SearchViewModel.swift b/Healthy/Classes/Modules/Search/Search/SearchViewModel.swift index 5c6ce7cc..0c7deb8f 100644 --- a/Healthy/Classes/Modules/Search/Search/SearchViewModel.swift +++ b/Healthy/Classes/Modules/Search/Search/SearchViewModel.swift @@ -1,5 +1,6 @@ import Foundation import Combine +import Domain protocol SearchDataSource { func loadRecipes() async throws -> [Recipe] @@ -129,3 +130,15 @@ private extension SearchViewModel { } } } + +// MARK: - Recipe Helpers +private extension Recipe { + /// Formats the preparation time as a string in the "Minutes:Seconds" format. + /// + /// - Returns: A string representing the preparation time formatted as "Minutes:Seconds". + func formattedPreparationTime() -> String { + let minutes = (preparationTime ?? 0) / 60 + let seconds = (preparationTime ?? 0) % 60 + return String(format: "%02d:%02d", minutes, seconds) + } +} diff --git a/Healthy/Classes/Modules/Search/Search/SearchViewModelType.swift b/Healthy/Classes/Modules/Search/Search/SearchViewModelType.swift index 63c8b81b..d4b73045 100644 --- a/Healthy/Classes/Modules/Search/Search/SearchViewModelType.swift +++ b/Healthy/Classes/Modules/Search/Search/SearchViewModelType.swift @@ -1,4 +1,5 @@ import Combine +import Domain /// Search Input & Output /// diff --git a/Healthy/Classes/Services/Container+Networking.swift b/Healthy/Classes/Services/Container+Networking.swift deleted file mode 100644 index 90d08646..00000000 --- a/Healthy/Classes/Services/Container+Networking.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Factory -import Networking - -extension Container { - var networking: Factory { - Factory(self) { - DefaultNetworkDispatcher() - } - } -} diff --git a/Healthy/Classes/Services/Services+Container.swift b/Healthy/Classes/Services/Services+Container.swift new file mode 100644 index 00000000..3bc322e7 --- /dev/null +++ b/Healthy/Classes/Services/Services+Container.swift @@ -0,0 +1,20 @@ +import Factory +import Foundation +import Storage +import Networking + +// MARK: - Services Container + +extension Container { + var networking: Factory { + Factory(self) { DefaultNetworkDispatcher() } + } + + var coreDataWrapper: Factory { + Factory(self) { CoreDataWrapper(modelName: "CoreDataModel") } + } + + var keyValueWrapper: Factory { + Factory(self) { UserDefaults.standard } + } +} diff --git a/Healthy/Classes/Services/Storage+Container.swift b/Healthy/Classes/Services/Storage+Container.swift new file mode 100644 index 00000000..7435a74c --- /dev/null +++ b/Healthy/Classes/Services/Storage+Container.swift @@ -0,0 +1,12 @@ +import Factory +import Storage + +// MARK: Storage Container + +extension Container { + var favoriteRecipeStorage: Factory { + Factory(self) { + DefaultFavoriteRecipeStorage(coreDataWrapper: self.coreDataWrapper()) + } + } +} diff --git a/Healthy/Classes/Services/Storage/.gitkeep b/Healthy/Classes/Services/Storage/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Healthy/Classes/UseCases/FavoriteRecipeUseCase.swift b/Healthy/Classes/UseCases/FavoriteRecipeUseCase.swift new file mode 100644 index 00000000..4498358b --- /dev/null +++ b/Healthy/Classes/UseCases/FavoriteRecipeUseCase.swift @@ -0,0 +1,22 @@ +import Domain +import Factory + +final class DefaultFavoriteRecipeUseCase: FavoriteRecipeUseCase { + @Injected(\.favoriteRecipeStorage) private var favoriteRecipeStorage + + func favoriteRecipe(_ recipe: Recipe) { + guard favoriteRecipeStorage.isFavoriteRecipe(recipe) == false else { + return + } + + favoriteRecipeStorage.favoriteRecipe(recipe) + } + + func unfavoriteRecipe(_ recipe: Recipe) { + favoriteRecipeStorage.unfavoriteRecipe(recipe) + } + + func allFavoriteRecipes() -> [Recipe] { + favoriteRecipeStorage.fetchFavoriteRecipes() + } +} diff --git a/Healthy/Classes/UseCases/LoginUseCase.swift b/Healthy/Classes/UseCases/LoginUseCase.swift index 97df6879..cc7dbae6 100644 --- a/Healthy/Classes/UseCases/LoginUseCase.swift +++ b/Healthy/Classes/UseCases/LoginUseCase.swift @@ -3,14 +3,6 @@ import Networking import Factory import Foundation -extension Container { - var loginUseCase: Factory { - Factory(self) { - DefaultLoginUseCase() - } - } -} - final class DefaultLoginUseCase: LoginUseCase { @Injected(\.networking) private var networking diff --git a/Healthy/Classes/UseCases/UseCase+Container.swift b/Healthy/Classes/UseCases/UseCase+Container.swift new file mode 100644 index 00000000..6fcaa12e --- /dev/null +++ b/Healthy/Classes/UseCases/UseCase+Container.swift @@ -0,0 +1,12 @@ +import Factory +import Domain + +extension Container { + var loginUseCase: Factory { + Factory(self) { DefaultLoginUseCase() } + } + + var favoriteRecipeUseCase: Factory { + Factory(self) { DefaultFavoriteRecipeUseCase() } + } +} diff --git a/Healthy/Classes/Utilities/KeyValueWrapper.swift b/Healthy/Classes/Utilities/KeyValueWrapper.swift new file mode 100644 index 00000000..33072748 --- /dev/null +++ b/Healthy/Classes/Utilities/KeyValueWrapper.swift @@ -0,0 +1,55 @@ +import Foundation + +protocol StorageKey { + var rawValue: String { get } +} + +enum OnboardingStorageKey: String, StorageKey { + case userDidAcknowledgeOnboarding +} + +enum AuthenticationStorageKey: String, StorageKey { + case userDidAcknowledgeOnboarding +} + +protocol KeyValueWrapper { + func setObject(_ object: T, forKey key: StorageKey) + func object(forKey key: StorageKey) -> T? + + func setCodableObject(_ object: T, forKey key: StorageKey) + func codableObject(forKey key: StorageKey) -> T? +} + +extension UserDefaults: KeyValueWrapper { + func setObject(_ object: T, forKey key: StorageKey) { + setValue(object, forKey: key.rawValue) + } + + func object(forKey key: StorageKey) -> T? { + value(forKey: key.rawValue) as? T + } + + func setCodableObject(_ object: T, forKey key: StorageKey) { + do { + let data = try JSONEncoder().encode(object) + setObject(data, forKey: key) + } catch { + // Log the error + print((error as NSError).debugDescription) + } + } + + func codableObject(forKey key: StorageKey) -> T? { + guard let data: Data = object(forKey: key) else { + return nil + } + + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + // Log the error + print((error as NSError).debugDescription) + return nil + } + } +} diff --git a/Storage/Package.swift b/Storage/Package.swift new file mode 100644 index 00000000..77308653 --- /dev/null +++ b/Storage/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Storage", + platforms: [.iOS(.v13)], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "Storage", + targets: ["Storage"]) + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + .package(name: "Domain", path: "Domain") + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "Storage", + dependencies: ["Domain"]), + .testTarget( + name: "StorageTests", + dependencies: ["Storage", "Domain"]) + ] +) diff --git a/Storage/Sources/Storage/CoreDataModel.xcdatamodeld/Model.xcdatamodel/contents b/Storage/Sources/Storage/CoreDataModel.xcdatamodeld/Model.xcdatamodel/contents new file mode 100644 index 00000000..8b47a2fd --- /dev/null +++ b/Storage/Sources/Storage/CoreDataModel.xcdatamodeld/Model.xcdatamodel/contents @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/Storage/Sources/Storage/Emtities/FavoriteRecipeEntity.swift b/Storage/Sources/Storage/Emtities/FavoriteRecipeEntity.swift new file mode 100644 index 00000000..dda223bd --- /dev/null +++ b/Storage/Sources/Storage/Emtities/FavoriteRecipeEntity.swift @@ -0,0 +1,16 @@ +import CoreData + +@objc(FavoriteRecipeEntity) +final class FavoriteRecipeEntity: NSManagedObject, Object { + public static func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "FavoriteRecipeEntity") + } + + @NSManaged var recipeId: String + @NSManaged var title: String? + @NSManaged var imageUrl: String? + @NSManaged var rating: Int64 + @NSManaged var userImageUrl: String? + @NSManaged var userName: String? + @NSManaged var preparationTime: Int64 +} diff --git a/Storage/Sources/Storage/Storage/FavoriteRecipeStorage.swift b/Storage/Sources/Storage/Storage/FavoriteRecipeStorage.swift new file mode 100644 index 00000000..dac657c3 --- /dev/null +++ b/Storage/Sources/Storage/Storage/FavoriteRecipeStorage.swift @@ -0,0 +1,66 @@ +import Domain + +public protocol FavoriteRecipeStorage { + func favoriteRecipe(_ recipe: Recipe) + func unfavoriteRecipe(_ recipe: Recipe) + func isFavoriteRecipe(_ recipe: Recipe) -> Bool + func fetchFavoriteRecipes() -> [Recipe] +} + +public final class DefaultFavoriteRecipeStorage: FavoriteRecipeStorage { + private let coreDataWrapper: CoreDataWrapping + + public init(coreDataWrapper: CoreDataWrapping) { + self.coreDataWrapper = coreDataWrapper + } + + public func favoriteRecipe(_ recipe: Recipe) { + let object = coreDataWrapper.createObject(ofType: FavoriteRecipeEntity.self) + object.recipeId = recipe.recipeId + object.title = recipe.title + object.imageUrl = recipe.recipeImageUrl + object.rating = Int64(recipe.rating ?? .zero) + object.userImageUrl = recipe.userImageUrl + object.userName = recipe.userName + object.preparationTime = Int64(recipe.preparationTime ?? .zero) + coreDataWrapper.saveContext() + } + + public func unfavoriteRecipe(_ recipe: Recipe) { + let predicate = \FavoriteRecipeEntity.recipeId == recipe.recipeId + let object = coreDataWrapper.firstObject( + ofType: FavoriteRecipeEntity.self, + matching: predicate + ) + + guard let object else { + return + } + + coreDataWrapper.deleteObject(object) + } + + public func isFavoriteRecipe(_ recipe: Recipe) -> Bool { + let predicate = \FavoriteRecipeEntity.recipeId == recipe.recipeId + let object = coreDataWrapper.firstObject( + ofType: FavoriteRecipeEntity.self, + matching: predicate + ) + + return object != nil + } + + public func fetchFavoriteRecipes() -> [Recipe] { + let objects = coreDataWrapper.fetchObjects(ofType: FavoriteRecipeEntity.self) + return objects.map { + Recipe(recipeId: $0.recipeId, + title: $0.title ?? String(), + recipeImageUrl: $0.imageUrl ?? String(), + rating: Int($0.rating), + userImageUrl: $0.userImageUrl ?? String(), + userName: $0.userName ?? String(), + preparationTime: Int($0.preparationTime) + ) + } + } +} diff --git a/Storage/Sources/Storage/Tools/CoreDataWrapper.swift b/Storage/Sources/Storage/Tools/CoreDataWrapper.swift new file mode 100644 index 00000000..4d1f4489 --- /dev/null +++ b/Storage/Sources/Storage/Tools/CoreDataWrapper.swift @@ -0,0 +1,100 @@ +import CoreData + +public protocol Object: NSManagedObject { + static func fetchRequest() -> NSFetchRequest +} + +public protocol CoreDataWrapping { + func saveContext() + func createObject(ofType type: T.Type) -> T + func fetchObjects(ofType type: T.Type, + predicate: NSPredicate?, + sortDescriptors: [NSSortDescriptor]?) -> [T] + func firstObject(ofType type: T.Type, + matching predicate: NSPredicate?) -> T? + func deleteObject(_ object: T) +} + +public extension CoreDataWrapping { + func fetchObjects(ofType type: T.Type, + predicate: NSPredicate? = nil, + sortDescriptors: [NSSortDescriptor]? = nil) -> [T] { + fetchObjects(ofType: type, + predicate: predicate, + sortDescriptors: sortDescriptors) + } + + func firstObject(ofType type: T.Type, + matching predicate: NSPredicate? = nil + ) -> T? { + firstObject(ofType: type, matching: predicate) + } +} + +public final class CoreDataWrapper: CoreDataWrapping { + private let persistentContainer: NSPersistentContainer + + public init(modelName: String) { + guard let modelURL = Bundle.module.url(forResource: modelName, withExtension: "momd"), + let model = NSManagedObjectModel(contentsOf: modelURL) else { + fatalError("Unable to create model with name: \(modelName) in module bundle") + } + + persistentContainer = NSPersistentContainer(name: modelName, managedObjectModel: model) + persistentContainer.loadPersistentStores { (_, error) in + if let error = error as NSError? { + fatalError("Unresolved error \(error), \(error.userInfo)") + } + } + } + + public func saveContext() { + let context = persistentContainer.viewContext + if context.hasChanges { + do { + try context.save() + } catch { + let nsError = error as NSError + fatalError("Unresolved error \(nsError), \(nsError.userInfo)") + } + } + } + + public func createObject(ofType type: T.Type) -> T { + return T(context: persistentContainer.viewContext) + } + + public func fetchObjects(ofType type: T.Type, + predicate: NSPredicate? = nil, + sortDescriptors: [NSSortDescriptor]? = nil) -> [T] { + let fetchRequest: NSFetchRequest = T.fetchRequest() + fetchRequest.predicate = predicate + fetchRequest.sortDescriptors = sortDescriptors + + do { + return try persistentContainer.viewContext.fetch(fetchRequest) + } catch { + print("Fetch error: \(error)") + return [] + } + } + + public func firstObject(ofType type: T.Type, + matching predicate: NSPredicate? = nil) -> T? { + let fetchRequest: NSFetchRequest = T.fetchRequest() + fetchRequest.predicate = predicate + fetchRequest.fetchLimit = 1 + + do { + return try persistentContainer.viewContext.fetch(fetchRequest).first + } catch { + print("Fetch error: \(error)") + return nil + } + } + + public func deleteObject(_ object: T) { + persistentContainer.viewContext.delete(object) + saveContext() + } +} diff --git a/Storage/Sources/Storage/Tools/TypedPredicate.swift b/Storage/Sources/Storage/Tools/TypedPredicate.swift new file mode 100644 index 00000000..98d5e7f8 --- /dev/null +++ b/Storage/Sources/Storage/Tools/TypedPredicate.swift @@ -0,0 +1,58 @@ +// swiftlint:disable all +import Foundation + +// MARK: - typed predicate types +public protocol TypedPredicateProtocol: NSPredicate { associatedtype Root } +public final class CompoundPredicate: NSCompoundPredicate, TypedPredicateProtocol {} +public final class ComparisonPredicate: NSComparisonPredicate, TypedPredicateProtocol {} + +// MARK: - compound operators +public func && (p1: TP1, p2: TP2) -> CompoundPredicate where TP1.Root == TP2.Root { + CompoundPredicate(type: .and, subpredicates: [p1, p2]) +} + +public func || (p1: TP1, p2: TP2) -> CompoundPredicate where TP1.Root == TP2.Root { + CompoundPredicate(type: .or, subpredicates: [p1, p2]) +} + +public prefix func ! (p: TP) -> CompoundPredicate { + CompoundPredicate(type: .not, subpredicates: [p]) +} + +// MARK: - comparison operators +public func == >(kp: K, value: E) -> ComparisonPredicate { + ComparisonPredicate(kp, .equalTo, value) +} + +public func != >(kp: K, value: E) -> ComparisonPredicate { + ComparisonPredicate(kp, .notEqualTo, value) +} + +public func > >(kp: K, value: C) -> ComparisonPredicate { + ComparisonPredicate(kp, .greaterThan, value) +} + +public func < >(kp: K, value: C) -> ComparisonPredicate { + ComparisonPredicate(kp, .lessThan, value) +} + +public func <= >(kp: K, value: C) -> ComparisonPredicate { + ComparisonPredicate(kp, .lessThanOrEqualTo, value) +} + +public func >= >(kp: K, value: C) -> ComparisonPredicate { + ComparisonPredicate(kp, .greaterThanOrEqualTo, value) +} + +public func === >(kp: K, values: S) -> ComparisonPredicate where S.Element: Equatable { + ComparisonPredicate(kp, .in, values) +} + +// MARK: - internal +extension ComparisonPredicate { + convenience init(_ kp: KeyPath, _ op: NSComparisonPredicate.Operator, _ value: Any?) { + let ex1 = \Root.self == kp ? NSExpression.expressionForEvaluatedObject() : NSExpression(forKeyPath: kp) + let ex2 = NSExpression(forConstantValue: value) + self.init(leftExpression: ex1, rightExpression: ex2, modifier: .direct, type: op) + } +} diff --git a/Storage/Tests/StorageTests/Empty.swift b/Storage/Tests/StorageTests/Empty.swift new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/Storage/Tests/StorageTests/Empty.swift @@ -0,0 +1 @@ +