├── Healthy ├── Classes │ ├── Extensions │ │ ├── .gitkeep │ │ ├── UIView+ReuseIdentifier.swift │ │ ├── UINavigationBar+Appearance.swift │ │ ├── UIView+SkeletonView.swift │ │ ├── Collection+Helpers.swift │ │ ├── UICollectionView+Helpers.swift │ │ ├── UITextField+Style.swift │ │ ├── UITableView+RegisterNib.swift │ │ ├── UIView+Style.swift │ │ ├── NSLayoutConstraint+Helpers.swift │ │ ├── UIButton+Style.swift │ │ ├── UIButton+AnimatableView.swift │ │ └── UIFont+Style.swift │ ├── ReusableViews │ │ ├── .gitkeep │ │ ├── AnimatableView │ │ │ └── AnimatableView.swift │ │ ├── SignButtonWithSocialMediaView │ │ │ └── SignButtonWithSocialMediaView.swift │ │ └── CheckboxButton.swift │ ├── Utilities │ │ ├── .gitkeep │ │ ├── Authentication │ │ │ ├── Authentication.swift │ │ │ ├── AuthenticatedUser.swift │ │ │ └── Login │ │ │ │ └── GoogleLoginAuthenticator.swift │ │ ├── Validators │ │ │ ├── ValidationError.swift │ │ │ ├── EmailValidator.swift │ │ │ ├── PasswordValidator.swift │ │ │ └── Validator.swift │ │ ├── Logger │ │ │ ├── Logging.swift │ │ │ ├── LogLevel.swift │ │ │ ├── NewRelicLogger.swift │ │ │ └── FileSystemLogger.swift │ │ └── Coordinator.swift │ ├── Services │ │ ├── Storage │ │ │ └── .gitkeep │ │ └── Container+Networking.swift │ ├── testViewController.swift │ ├── Entities │ │ ├── Area.swift │ │ ├── Category.swift │ │ ├── FilterByArea.swift │ │ ├── FilterByMainIngredient.swift │ │ ├── Ingrediant.swift │ │ ├── MealCategories.swift │ │ ├── RandomMealEntity.swift │ │ ├── SavedRecipe.swift │ │ └── Recipe.swift │ ├── Modules │ │ ├── Search │ │ │ ├── Search │ │ │ │ ├── SearchFilter.swift │ │ │ │ ├── SearchViewModelType.swift │ │ │ │ ├── SearchViewController.swift │ │ │ │ ├── SearchViewController.xib │ │ │ │ └── Cell │ │ │ │ │ └── SearchCollectionViewCell.swift │ │ │ └── FilterSearch │ │ │ │ ├── FilterSearchViewModelType.swift │ │ │ │ ├── FilterSearchViewModel.swift │ │ │ │ ├── FilterSearchViewController.swift │ │ │ │ └── FilterSearchViewController.xib │ │ ├── Onboarding │ │ │ ├── Splash │ │ │ │ ├── SplashViewModelType.swift │ │ │ │ ├── SplashViewModel.swift │ │ │ │ └── SplashViewController.swift │ │ │ ├── CreateAccount │ │ │ │ ├── CreateAccountviewModelType.swift │ │ │ │ └── CreateAccountViewModel.swift │ │ │ ├── Login │ │ │ │ └── LoginViewModelType.swift │ │ │ └── OnboardingCoordinator.swift │ │ ├── SavedRecipes │ │ │ ├── SavedRecipesViewModelType.swift │ │ │ ├── SavedRecipesViewModel.swift │ │ │ └── SavedRecipesViewController.swift │ │ └── Dashboard │ │ │ ├── DashboardViewModelType.swift │ │ │ ├── Views │ │ │ ├── HomeHeaderSkeletonView │ │ │ │ └── HomeHeaderSkeletonView.swift │ │ │ ├── FoodTagsView │ │ │ │ └── FoodTagCollectionViewCell │ │ │ │ │ └── FoodTagCollectionViewCell.swift │ │ │ ├── HomeHeaderView │ │ │ │ └── HomeHeaderView.swift │ │ │ ├── NewRecipesView │ │ │ │ └── NewRecipesCollectionViewLayout.swift │ │ │ └── SliderDishesView │ │ │ │ └── SliderCollectionViewCell │ │ │ │ └── SliderCollectionViewCell.swift │ │ │ └── DashboardViewModel.swift │ ├── System │ │ └── Constants.swift │ ├── Generated │ │ └── UIImage.Generated.swift │ ├── UseCases │ │ └── LoginUseCase.swift │ └── AppCoordinator.swift └── Resources │ ├── Fonts │ ├── Poppins-Bold.ttf │ └── Poppins-Regular.ttf │ ├── Assets.xcassets │ ├── Contents.json │ ├── icon-google.imageset │ │ ├── google.png │ │ └── Contents.json │ ├── icon-food.imageset │ │ ├── icon-food.pdf │ │ └── Contents.json │ ├── icon-splash.imageset │ │ ├── image 11.pdf │ │ └── Contents.json │ ├── icon-facebook.imageset │ │ ├── facebook.png │ │ └── Contents.json │ ├── pattern-food.imageset │ │ ├── pattern-food.pdf │ │ └── Contents.json │ ├── preview-dishes-1.imageset │ │ ├── img-dishes1.pdf │ │ └── Contents.json │ ├── preview-dishes-2.imageset │ │ ├── img-dishe2.pdf │ │ └── Contents.json │ ├── background-splash.imageset │ │ ├── Rectangle 6.pdf │ │ └── Contents.json │ ├── icon_checkbox_selected.imageset │ │ ├── icon_checkbox_selected.pdf │ │ └── Contents.json │ ├── image-recipe-placeholder.imageset │ │ ├── image-recipe-placeholder.pdf │ │ └── Contents.json │ ├── icon_checkbox_not_selected.imageset │ │ ├── icon_checkbox_not_selected.pdf │ │ └── Contents.json │ ├── image-user-recipe-placeholder.imageset │ │ ├── image-user-recipe-placeholder.pdf │ │ └── Contents.json │ ├── star.imageset │ │ ├── Contents.json │ │ └── star.svg │ ├── icon-home.imageset │ │ └── Contents.json │ ├── icon-union.imageset │ │ └── Contents.json │ ├── icon-profile.imageset │ │ └── Contents.json │ ├── icon-notification.imageset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── image-recipe-placeholder 1.imageset │ │ └── Contents.json │ ├── icon-bookmark.imageset │ │ └── Contents.json │ ├── icon-timer.imageset │ │ ├── Contents.json │ │ └── icon-timer.pdf │ └── icon-no-data.imageset │ │ └── Contents.json │ ├── Colors.xcassets │ ├── Contents.json │ ├── Black 100.colorset │ │ └── Contents.json │ ├── Black 20.colorset │ │ └── Contents.json │ ├── Black 40.colorset │ │ └── Contents.json │ ├── Black 60.colorset │ │ └── Contents.json │ ├── Black 80.colorset │ │ └── Contents.json │ ├── Success.colorset │ │ └── Contents.json │ ├── Warning.colorset │ │ └── Contents.json │ ├── black.colorset │ │ └── Contents.json │ ├── gray 1.colorset │ │ └── Contents.json │ ├── gray 2.colorset │ │ └── Contents.json │ ├── gray 3.colorset │ │ └── Contents.json │ ├── gray 4.colorset │ │ └── Contents.json │ ├── rating.colorset │ │ └── Contents.json │ ├── Warning light.colorset │ │ └── Contents.json │ ├── primary 100.colorset │ │ └── Contents.json │ ├── primary 20.colorset │ │ └── Contents.json │ ├── primary 40.colorset │ │ └── Contents.json │ ├── primary 60.colorset │ │ └── Contents.json │ ├── primary 80.colorset │ │ └── Contents.json │ ├── secondary 100.colorset │ │ └── Contents.json │ ├── secondary 20.colorset │ │ └── Contents.json │ ├── secondary 40.colorset │ │ └── Contents.json │ ├── secondary 60.colorset │ │ └── Contents.json │ └── secondary 80.colorset │ │ └── Contents.json │ ├── Localization │ └── Localizable.strings │ ├── Info.plist │ └── Generated │ ├── Strings.Generated.swift │ ├── UIImage.Generated.swift │ └── UIColors.Generated.swift ├── Screenshots └── cover.png ├── Vendors └── SwiftGen │ ├── bin │ ├── swiftgen │ └── SwiftGen_SwiftGenCLI.bundle │ │ └── Contents │ │ ├── Info.plist │ │ └── Resources │ │ └── templates │ │ ├── colors │ │ ├── literals-swift4.stencil │ │ └── literals-swift5.stencil │ │ ├── strings │ │ └── objc-h.stencil │ │ └── ib │ │ ├── segues-swift4.stencil │ │ └── segues-swift5.stencil │ └── LICENCE ├── Networking ├── Sources │ └── Networking │ │ ├── Networking │ │ ├── NetworkError.swift │ │ ├── Request.swift │ │ ├── ResponseDecoder.swift │ │ ├── TargetType.swift │ │ └── URLRequestConvertible.swift │ │ ├── Dispatcher │ │ ├── NetworkDispatcher.swift │ │ ├── URLSessionProtocol.swift │ │ └── DefaultNetworkDispatcher.swift │ │ ├── Constants.swift │ │ └── Requests │ │ ├── ACIListRequests │ │ ├── AreaListRequest.swift │ │ ├── CategoriesListRequest.swift │ │ └── IngredientsListRequest.swift │ │ ├── MealCategoriesRequest.swift │ │ ├── MealSearchRequest.swift │ │ ├── RandomMealsRequest.swift │ │ ├── FilterByAreaRequest.swift │ │ ├── LoginRequest.swift │ │ ├── FilterByMainIngredientAPIRequest.swift │ │ └── RegisterRequest.swift ├── Package.swift └── Tests │ └── NetworkingTests │ ├── Requests │ ├── ACIListRequestsTests │ │ ├── AreaListRequestTests.swift │ │ ├── CategoriesListRequestTests.swift │ │ └── IngredientsListRequestTests.swift │ ├── RegisterRequestTests.swift │ ├── RandomMealsRequestTests.swift │ ├── FilterByAreaRequestTests.swift │ ├── MealCategoriesRequestTests.swift │ ├── MealSearchRequestTests.swift │ └── FilterByMainIngredientAPIRequestTests.swift │ └── Dispatcher │ └── LoginRequestTests.swift ├── Templates ├── Templates │ └── MVVM.xctemplate │ │ ├── TemplateIcon.png │ │ ├── TemplateIcon@2x.png │ │ ├── WithXIB │ │ ├── ___FILEBASENAME___ViewModelType.swift │ │ ├── ___FILEBASENAME___ViewModel.swift │ │ ├── ___FILEBASENAME___ViewController.swift │ │ └── ___FILEBASENAME___ViewController.xib │ │ ├── WithoutXIB │ │ ├── ___FILEBASENAME___ViewModelType.swift │ │ ├── ___FILEBASENAME___ViewModel.swift │ │ └── ___FILEBASENAME___ViewController.swift │ │ └── TemplateInfo.plist ├── README.md └── Makefile ├── Domain ├── Sources │ └── Domain │ │ ├── UseCases │ │ └── LoginUseCase.swift │ │ └── Entities │ │ └── User.swift ├── README.md ├── Tests │ └── DomainTests │ │ └── DomainTests.swift └── Package.swift ├── Healthy.xcodeproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ ├── WorkspaceSettings.xcsettings │ └── IDEWorkspaceChecks.plist ├── HealthyTests ├── Classes │ ├── Utilities │ │ ├── Publishers │ │ │ ├── PublisherSpy.swift │ │ │ └── PublisherMultibleValueSpy.swift │ │ └── Validators │ │ │ ├── EmailValidatorsTests.swift │ │ │ └── PasswordValidatorTests.swift │ ├── Modules │ │ ├── Search │ │ │ └── Mocks │ │ │ │ └── SearchDataSourceMock.swift │ │ ├── LoginUseCase │ │ │ └── DefaultLoginUseCaseTests.swift │ │ ├── SavedRecipes │ │ │ ├── Mocks │ │ │ │ └── SavedRecipesViewModelMock.swift │ │ │ └── Cell │ │ │ │ └── SavedRecipesTableViewCellTests.swift │ │ └── Onboarding │ │ │ ├── Splash │ │ │ └── SplashViewControllerTest.swift │ │ │ ├── CreateAccount │ │ │ ├── Mocks │ │ │ │ └── CreateViewModelMock.swift │ │ │ └── CreateAccountViewControllerTests.swift │ │ │ └── Login │ │ │ ├── Mocks │ │ │ └── LoginViewModelMock.swift │ │ │ └── LoginViewModelTest.swift │ ├── Mocks │ │ └── UITableViewMock.swift │ └── Extensions │ │ └── UILabel+Style │ │ └── UILabelStyleTests.swift └── UILabelStyleTests.swift ├── SwiftGen-Templates ├── colors.stencil └── images.stencil ├── swiftgen.yml ├── HealthyUITests ├── HealthyUITestsLaunchTests.swift └── HealthyUITests.swift ├── .github ├── pull_request_template.md └── workflows │ └── ios.yml ├── README.md ├── .swiftlint.yml └── .gitignore /Healthy/Classes/Extensions/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Healthy/Classes/ReusableViews/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Healthy/Classes/Utilities/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Healthy/Classes/Services/Storage/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Healthy/Classes/testViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | -------------------------------------------------------------------------------- /Screenshots/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoon-eg/healthy/HEAD/Screenshots/cover.png -------------------------------------------------------------------------------- /Healthy/Classes/Entities/Area.swift: -------------------------------------------------------------------------------- 1 | // MARK: - Area 2 | struct Area { 3 | let title: String 4 | } 5 | -------------------------------------------------------------------------------- /Vendors/SwiftGen/bin/swiftgen: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoon-eg/healthy/HEAD/Vendors/SwiftGen/bin/swiftgen -------------------------------------------------------------------------------- /Healthy/Classes/Entities/Category.swift: -------------------------------------------------------------------------------- 1 | // MARK: - Category 2 | struct Category { 3 | let title: String 4 | } 5 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Search/Search/SearchFilter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct SearchFilter: Equatable {} 4 | -------------------------------------------------------------------------------- /Healthy/Resources/Fonts/Poppins-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoon-eg/healthy/HEAD/Healthy/Resources/Fonts/Poppins-Bold.ttf -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Healthy/Resources/Fonts/Poppins-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoon-eg/healthy/HEAD/Healthy/Resources/Fonts/Poppins-Regular.ttf -------------------------------------------------------------------------------- /Networking/Sources/Networking/Networking/NetworkError.swift: -------------------------------------------------------------------------------- 1 | enum NetworkError: Error { 2 | case invalidURL 3 | case invalidResponse 4 | } 5 | -------------------------------------------------------------------------------- /Templates/Templates/MVVM.xctemplate/TemplateIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoon-eg/healthy/HEAD/Templates/Templates/MVVM.xctemplate/TemplateIcon.png -------------------------------------------------------------------------------- /Domain/Sources/Domain/UseCases/LoginUseCase.swift: -------------------------------------------------------------------------------- 1 | public protocol LoginUseCase { 2 | func login(email: String, password: String) async throws -> User 3 | } 4 | -------------------------------------------------------------------------------- /Healthy/Classes/ReusableViews/AnimatableView/AnimatableView.swift: -------------------------------------------------------------------------------- 1 | protocol AnimatableView { 2 | func startAnimating() 3 | func stopAnimating() 4 | } 5 | -------------------------------------------------------------------------------- /Templates/Templates/MVVM.xctemplate/TemplateIcon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoon-eg/healthy/HEAD/Templates/Templates/MVVM.xctemplate/TemplateIcon@2x.png -------------------------------------------------------------------------------- /Healthy/Classes/Utilities/Authentication/Authentication.swift: -------------------------------------------------------------------------------- 1 | public protocol Authentication { 2 | func performLogin() async throws -> AuthenticatedUser 3 | } 4 | -------------------------------------------------------------------------------- /Healthy/Classes/Entities/FilterByArea.swift: -------------------------------------------------------------------------------- 1 | // MARK: - Meal 2 | public struct Meal { 3 | let id: String 4 | let meal: String 5 | let mealThumb: String 6 | } 7 | -------------------------------------------------------------------------------- /Healthy/Classes/Utilities/Validators/ValidationError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct ValidationError: LocalizedError { 4 | let errorDescription: String? 5 | } 6 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/icon-google.imageset/google.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoon-eg/healthy/HEAD/Healthy/Resources/Assets.xcassets/icon-google.imageset/google.png -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/icon-food.imageset/icon-food.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoon-eg/healthy/HEAD/Healthy/Resources/Assets.xcassets/icon-food.imageset/icon-food.pdf -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/icon-splash.imageset/image 11.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoon-eg/healthy/HEAD/Healthy/Resources/Assets.xcassets/icon-splash.imageset/image 11.pdf -------------------------------------------------------------------------------- /Healthy/Classes/Entities/FilterByMainIngredient.swift: -------------------------------------------------------------------------------- 1 | // MARK: - Meal 2 | public struct MealIng { 3 | let id: String 4 | let meal: String 5 | let thumbnailImageUrl: String 6 | } 7 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/icon-facebook.imageset/facebook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoon-eg/healthy/HEAD/Healthy/Resources/Assets.xcassets/icon-facebook.imageset/facebook.png -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/pattern-food.imageset/pattern-food.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoon-eg/healthy/HEAD/Healthy/Resources/Assets.xcassets/pattern-food.imageset/pattern-food.pdf -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/preview-dishes-1.imageset/img-dishes1.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoon-eg/healthy/HEAD/Healthy/Resources/Assets.xcassets/preview-dishes-1.imageset/img-dishes1.pdf -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/preview-dishes-2.imageset/img-dishe2.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoon-eg/healthy/HEAD/Healthy/Resources/Assets.xcassets/preview-dishes-2.imageset/img-dishe2.pdf -------------------------------------------------------------------------------- /Networking/Sources/Networking/Networking/Request.swift: -------------------------------------------------------------------------------- 1 | /// A typealias that combines the `TargetType` and `ResponseDecoder` protocols. 2 | public typealias RequestType = TargetType & ResponseDecoder 3 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/background-splash.imageset/Rectangle 6.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoon-eg/healthy/HEAD/Healthy/Resources/Assets.xcassets/background-splash.imageset/Rectangle 6.pdf -------------------------------------------------------------------------------- /Healthy/Classes/Entities/Ingrediant.swift: -------------------------------------------------------------------------------- 1 | // MARK: - Meal 2 | public struct MealIngrediant { 3 | let id: String 4 | let ingrediant: String 5 | let description: String? 6 | let type: String? 7 | } 8 | -------------------------------------------------------------------------------- /Healthy.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Healthy/Classes/Utilities/Authentication/AuthenticatedUser.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct AuthenticatedUser { 4 | let id: String 5 | let name: String 6 | let email: String 7 | let imageURL: URL? 8 | } 9 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/icon_checkbox_selected.imageset/icon_checkbox_selected.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoon-eg/healthy/HEAD/Healthy/Resources/Assets.xcassets/icon_checkbox_selected.imageset/icon_checkbox_selected.pdf -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/image-recipe-placeholder.imageset/image-recipe-placeholder.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoon-eg/healthy/HEAD/Healthy/Resources/Assets.xcassets/image-recipe-placeholder.imageset/image-recipe-placeholder.pdf -------------------------------------------------------------------------------- /Healthy/Classes/Entities/MealCategories.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - MealCategory 4 | struct MealCategory { 5 | let categoryId, categoryTitle: String 6 | let categoryThumb: String 7 | let categoryDescription: String 8 | } 9 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/icon_checkbox_not_selected.imageset/icon_checkbox_not_selected.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoon-eg/healthy/HEAD/Healthy/Resources/Assets.xcassets/icon_checkbox_not_selected.imageset/icon_checkbox_not_selected.pdf -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/image-user-recipe-placeholder.imageset/image-user-recipe-placeholder.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoon-eg/healthy/HEAD/Healthy/Resources/Assets.xcassets/image-user-recipe-placeholder.imageset/image-user-recipe-placeholder.pdf -------------------------------------------------------------------------------- /Templates/README.md: -------------------------------------------------------------------------------- 1 | # MVVM Template 2 | 3 | # Installation 4 | 5 | To install MVVM template to Xcode, run in the terminal: 6 | ``` 7 | make install 8 | ``` 9 | To uninstall MVVM template from Xcode, run in the terminal: 10 | ``` 11 | make uninstall 12 | ``` 13 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/star.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "star.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Domain/README.md: -------------------------------------------------------------------------------- 1 | # Domain 2 | 3 | The Domain Layer is a crucial component of a software architecture that represents the 4 | core business logic and rules of the application. It acts as the heart of the system, 5 | encapsulating all the domain-specific logic and entities. 6 | -------------------------------------------------------------------------------- /Healthy.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/icon-home.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Home.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/icon-union.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "union.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Domain/Sources/Domain/Entities/User.swift: -------------------------------------------------------------------------------- 1 | public struct User { 2 | public let email: String 3 | public let tokenID: String 4 | 5 | public init(email: String, tokenID: String) { 6 | self.email = email 7 | self.tokenID = tokenID 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Healthy/Classes/Services/Container+Networking.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | import Networking 3 | 4 | extension Container { 5 | var networking: Factory { 6 | Factory(self) { 7 | DefaultNetworkDispatcher() 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/icon-profile.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "profile.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/icon-splash.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "image 11.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/background-splash.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Rectangle 6.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/icon-notification.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "notification.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/preview-dishes-1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "img-dishes1.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/preview-dishes-2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "img-dishe2.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/image-recipe-placeholder 1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "image-recipe-placeholder.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Healthy/Classes/Utilities/Validators/EmailValidator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct EmailValidator: Validator { 4 | let validationRules: [ValidationRule] = [ 5 | RegexValidationRule(field: L10n.Common.email, 6 | regex: "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}") 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /Healthy/Resources/Localization/Localizable.strings: -------------------------------------------------------------------------------- 1 | "app.title" = "Healthy"; 2 | "signin.buttonTitle" = "Sign In"; 3 | "signup.buttonTitle" = "Sign Up"; 4 | 5 | // MARK: Common Localizations 6 | "common.email" = "Email"; 7 | "common.password" = "Password"; 8 | 9 | // MARK: Home 10 | "home.new_recipes.header" = "New Recipes"; 11 | -------------------------------------------------------------------------------- /Healthy.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Healthy/Classes/Entities/RandomMealEntity.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - RandomMealEntity 4 | struct RandomMealEntity { 5 | let id: String 6 | let name: String 7 | let category: String 8 | let area: String 9 | let thumbnailImageUrl: String 10 | let tags: String 11 | let youtubeLink: String 12 | } 13 | -------------------------------------------------------------------------------- /Healthy/Classes/System/Constants.swift: -------------------------------------------------------------------------------- 1 | enum Constants { 2 | // TODO: [HL-29] Update the app client id 3 | static let googleClientId = "500241227951-jfe9f5o8li3l753c2146hqfru8aaa7o5.apps.googleusercontent.com" 4 | 5 | /// API Key for NewRelic 6 | static let newRelicAPIKey = "eu01xxd1e0131edfbcd7bb48440b03c791a96c5a7b-NRMA" 7 | } 8 | -------------------------------------------------------------------------------- /Healthy/Classes/Extensions/UIView+ReuseIdentifier.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | // MARK: UIView+ReuseIdentifier 4 | 5 | extension UIView { 6 | /// Returns a String value, which is the class name of the UIView subclass that it is called on. 7 | static var reuseIdentifier: String { 8 | String(describing: Self.self) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/icon-bookmark.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "bookmark.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "original" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Domain/Tests/DomainTests/DomainTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Domain 3 | 4 | final class DomainTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/pattern-food.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "pattern-food.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "original" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Healthy/Classes/Extensions/UINavigationBar+Appearance.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UINavigationBar { 4 | 5 | /// Apply the default navigation bar appearance 6 | /// 7 | class func applyDefaultAppearance() { 8 | UINavigationBar.appearance().titleTextAttributes = [ 9 | .font: UIFont.mediumRegular 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /HealthyTests/Classes/Utilities/Publishers/PublisherSpy.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | final class PublisherSpy { 4 | private(set) var value: T! 5 | private var cancellable: Cancellable? 6 | init(_ publisher: any Publisher) { 7 | cancellable = publisher.sink { [weak self] value in 8 | self?.value = value 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Healthy/Classes/Utilities/Logger/Logging.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// The Logging protocol defines a standardized way of logging messages in an application or system. 4 | public protocol Logging { 5 | func log(_ message: String, 6 | level: LogLevel, 7 | file: StaticString, 8 | function: StaticString, 9 | line: UInt) 10 | } 11 | -------------------------------------------------------------------------------- /HealthyTests/Classes/Modules/Search/Mocks/SearchDataSourceMock.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | @testable import Healthy 4 | 5 | final class SearchDataSourceMock: SearchDataSource { 6 | var loadRecipesCallBack: () async throws -> [Recipe] = { [] } 7 | func loadRecipes() async throws -> [Healthy.Recipe] { 8 | try await loadRecipesCallBack() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Search/FilterSearch/FilterSearchViewModelType.swift: -------------------------------------------------------------------------------- 1 | /// FilterSearch Input & Output 2 | /// 3 | typealias FilterSearchViewModelType = FilterSearchViewModelInput & FilterSearchViewModelOutput 4 | 5 | /// FilterSearch ViewModel Input 6 | /// 7 | protocol FilterSearchViewModelInput {} 8 | 9 | /// FilterSearch ViewModel Output 10 | /// 11 | protocol FilterSearchViewModelOutput {} 12 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Onboarding/Splash/SplashViewModelType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Splash Input & Output 4 | /// 5 | typealias SplashViewModelType = SplashViewModelInput & SplashViewModelOutput 6 | 7 | /// Splash ViewModel Input 8 | /// 9 | protocol SplashViewModelInput { 10 | func startCooking() 11 | } 12 | 13 | /// Splash ViewModel Output 14 | /// 15 | protocol SplashViewModelOutput {} 16 | -------------------------------------------------------------------------------- /Healthy/Classes/Extensions/UIView+SkeletonView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SkeletonView 3 | 4 | public extension UIView { 5 | func startSkeletonView() { 6 | isSkeletonable = true 7 | showAnimatedGradientSkeleton() 8 | } 9 | 10 | func stopSkeletonView() { 11 | isSkeletonable = false 12 | hideSkeleton(reloadDataAfter: true, transition: .crossDissolve(0.25)) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /HealthyTests/Classes/Utilities/Publishers/PublisherMultibleValueSpy.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | final class PublisherMultibleValueSpy { 4 | private(set) var values: [T] = [] 5 | private var cancellable: Cancellable? 6 | init(_ publisher: any Publisher) { 7 | cancellable = publisher.sink { [weak self] value in 8 | self?.values.append(value) 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /SwiftGen-Templates/colors.stencil: -------------------------------------------------------------------------------- 1 | import UIKit.UIColor 2 | 3 | // this is automatic generated file please don't edit it 🗡️ 4 | // MARK: - Colors 5 | 6 | extension UIColor { 7 | {% for color in catalogs.first.assets %} 8 | 9 | static var {{color.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}}: UIColor { 10 | UIColor(named: "{{ color.name }}")! 11 | } 12 | {% endfor %} 13 | } 14 | -------------------------------------------------------------------------------- /Healthy/Classes/Utilities/Validators/PasswordValidator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct PasswordValidator: Validator { 4 | let validationRules: [ValidationRule] = [ 5 | RegexValidationRule(field: L10n.Common.password, 6 | regex: "(?=^(?:(?!%).)*$)(?=.*[a-zA-Z])(?=.*[0-9])[a-zA-Z0-9\\p{Graph}]+$"), 7 | CharacterCountValidationRule(minCount: 7, maxCount: 20) 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/icon-google.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "google.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Search/FilterSearch/FilterSearchViewModel.swift: -------------------------------------------------------------------------------- 1 | // MARK: FilterSearchViewModel 2 | 3 | class FilterSearchViewModel {} 4 | 5 | // MARK: FilterSearchViewModel 6 | 7 | extension FilterSearchViewModel: FilterSearchViewModelInput {} 8 | 9 | // MARK: FilterSearchViewModelOutput 10 | 11 | extension FilterSearchViewModel: FilterSearchViewModelOutput {} 12 | 13 | // MARK: Private Handlers 14 | 15 | private extension FilterSearchViewModel {} 16 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/icon-facebook.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "facebook.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/icon-food.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon-food.pdf", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/icon-timer.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon-timer.pdf", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/icon-no-data.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon-no-data.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/Black 100.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x00", 9 | "green" : "0x00", 10 | "red" : "0x00" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/Black 20.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xD9", 9 | "green" : "0xD9", 10 | "red" : "0xD9" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/Black 40.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xA9", 9 | "green" : "0xA9", 10 | "red" : "0xA9" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/Black 60.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x79", 9 | "green" : "0x79", 10 | "red" : "0x79" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/Black 80.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x48", 9 | "green" : "0x48", 10 | "red" : "0x48" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/Success.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x57", 9 | "green" : "0xB0", 10 | "red" : "0x31" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/Warning.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x54", 9 | "green" : "0x36", 10 | "red" : "0xFD" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/black.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x00", 9 | "green" : "0x00", 10 | "red" : "0x00" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/gray 1.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x48", 9 | "green" : "0x48", 10 | "red" : "0x48" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/gray 2.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x79", 9 | "green" : "0x79", 10 | "red" : "0x79" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/gray 3.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xA9", 9 | "green" : "0xA9", 10 | "red" : "0xA9" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/gray 4.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xD9", 9 | "green" : "0xD9", 10 | "red" : "0xD9" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/rating.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x30", 9 | "green" : "0xAD", 10 | "red" : "0xFF" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Networking/Sources/Networking/Dispatcher/NetworkDispatcher.swift: -------------------------------------------------------------------------------- 1 | /// A protocol for dispatching network requests. 2 | public protocol NetworkDispatcher { 3 | /// Dispatches a network request and returns the response asynchronously. 4 | /// - Parameters: 5 | /// - request: The network request to dispatch. 6 | /// - Returns: The response from the network request. 7 | func dispatch(_ request: Request) async throws -> Request.ResponseType 8 | } 9 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/Warning light.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xE7", 9 | "green" : "0xE1", 10 | "red" : "0xFF" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/primary 100.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x75", 9 | "green" : "0x95", 10 | "red" : "0x12" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/primary 20.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xF9", 9 | "green" : "0xFA", 10 | "red" : "0xF6" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/primary 40.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xE7", 9 | "green" : "0xEB", 10 | "red" : "0xDB" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/primary 60.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xCA", 9 | "green" : "0xD3", 10 | "red" : "0xAF" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/primary 80.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xA1", 9 | "green" : "0xB1", 10 | "red" : "0x71" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/secondary 100.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x00", 9 | "green" : "0x9C", 10 | "red" : "0xFF" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/secondary 20.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xB3", 9 | "green" : "0xE1", 10 | "red" : "0xFF" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/secondary 40.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x80", 9 | "green" : "0xCE", 10 | "red" : "0xFF" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/secondary 60.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x4D", 9 | "green" : "0xBA", 10 | "red" : "0xFF" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Resources/Colors.xcassets/secondary 80.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x1A", 9 | "green" : "0xA6", 10 | "red" : "0xFF" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /SwiftGen-Templates/images.stencil: -------------------------------------------------------------------------------- 1 | import UIKit.UIImage 2 | 3 | // This file is generated automatically, Don't ever try to change it 🔫 4 | // MARK: - Images 5 | 6 | // swiftlint:disable force_unwrapping 7 | extension UIImage { 8 | {% for image in catalogs.first.assets %} 9 | 10 | static var {{image.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}}: UIImage { 11 | UIImage(named: "{{ image.name }}")! 12 | } 13 | {% endfor %} 14 | 15 | } 16 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/icon_checkbox_selected.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_checkbox_selected.pdf", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/icon_checkbox_not_selected.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_checkbox_not_selected.pdf", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/image-recipe-placeholder.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "image-recipe-placeholder.pdf", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Healthy/Classes/Extensions/Collection+Helpers.swift: -------------------------------------------------------------------------------- 1 | extension Collection { 2 | 3 | /// Safe protects the array from out of bounds by use of optional. 4 | /// 5 | /// let arr = [1, 2, 3, 4, 5] 6 | /// arr[safe: 1] -> 2 7 | /// arr[safe: 10] -> nil 8 | /// 9 | /// - Parameter index: index of element to access element. 10 | subscript(safe index: Index) -> Element? { 11 | return indices.contains(index) ? self[index] : nil 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/image-user-recipe-placeholder.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "image-user-recipe-placeholder.pdf", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Templates/Templates/MVVM.xctemplate/WithXIB/___FILEBASENAME___ViewModelType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// ___VARIABLE_productName___ Input & Output 4 | /// 5 | typealias ___FILEBASENAMEASIDENTIFIER___ = ___VARIABLE_productName___ViewModelInput & ___VARIABLE_productName___ViewModelOutput 6 | 7 | /// ___VARIABLE_productName___ ViewModel Input 8 | /// 9 | protocol ___VARIABLE_productName___ViewModelInput {} 10 | 11 | /// ___VARIABLE_productName___ ViewModel Output 12 | /// 13 | protocol ___VARIABLE_productName___ViewModelOutput {} 14 | -------------------------------------------------------------------------------- /Templates/Templates/MVVM.xctemplate/WithoutXIB/___FILEBASENAME___ViewModelType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// ___VARIABLE_productName___ Input & Output 4 | /// 5 | typealias ___FILEBASENAMEASIDENTIFIER___ = ___VARIABLE_productName___ViewModelInput & ___VARIABLE_productName___ViewModelOutput 6 | 7 | /// ___VARIABLE_productName___ ViewModel Input 8 | /// 9 | protocol ___VARIABLE_productName___ViewModelInput {} 10 | 11 | /// ___VARIABLE_productName___ ViewModel Output 12 | /// 13 | protocol ___VARIABLE_productName___ViewModelOutput {} 14 | -------------------------------------------------------------------------------- /HealthyTests/Classes/Mocks/UITableViewMock.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | @testable import Healthy 3 | 4 | final class UITableViewMock: UITableView, UITableViewDelegate, UITableViewDataSource { 5 | 6 | // MARK: Configure table view 7 | 8 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 9 | return 1 10 | } 11 | 12 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 13 | tableView.dequeueReusableCell(for: indexPath) as Cell 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Templates/Templates/MVVM.xctemplate/WithXIB/___FILEBASENAME___ViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: ___FILEBASENAMEASIDENTIFIER___ 4 | 5 | class ___FILEBASENAMEASIDENTIFIER___ {} 6 | 7 | // MARK: ___FILEBASENAMEASIDENTIFIER___ 8 | 9 | extension ___FILEBASENAMEASIDENTIFIER___: ___VARIABLE_productName___ViewModelInput {} 10 | 11 | // MARK: ___FILEBASENAMEASIDENTIFIER___Output 12 | 13 | extension ___FILEBASENAMEASIDENTIFIER___: ___VARIABLE_productName___ViewModelOutput {} 14 | 15 | // MARK: Private Handlers 16 | 17 | private extension ___FILEBASENAMEASIDENTIFIER___ {} 18 | -------------------------------------------------------------------------------- /Templates/Templates/MVVM.xctemplate/WithoutXIB/___FILEBASENAME___ViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: ___FILEBASENAMEASIDENTIFIER___ 4 | 5 | class ___FILEBASENAMEASIDENTIFIER___ {} 6 | 7 | // MARK: ___FILEBASENAMEASIDENTIFIER___ 8 | 9 | extension ___FILEBASENAMEASIDENTIFIER___: ___VARIABLE_productName___ViewModelInput {} 10 | 11 | // MARK: ___FILEBASENAMEASIDENTIFIER___Output 12 | 13 | extension ___FILEBASENAMEASIDENTIFIER___: ___VARIABLE_productName___ViewModelOutput {} 14 | 15 | // MARK: Private Handlers 16 | 17 | private extension ___FILEBASENAMEASIDENTIFIER___ {} 18 | -------------------------------------------------------------------------------- /Healthy/Classes/Generated/UIImage.Generated.swift: -------------------------------------------------------------------------------- 1 | import UIKit.UIImage 2 | 3 | // This file is generated automatically, Don't ever try to change it 🔫 4 | // MARK: - Images 5 | 6 | // swiftlint:disable force_unwrapping 7 | extension UIImage { 8 | 9 | static var accentColor: UIImage { 10 | UIImage(named: "AccentColor")! 11 | } 12 | 13 | static var backgroundSplash: UIImage { 14 | UIImage(named: "background-splash")! 15 | } 16 | 17 | static var iconSplash: UIImage { 18 | UIImage(named: "icon-splash")! 19 | } 20 | } 21 | // swiftlint:enable force_unwrapping 22 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/SavedRecipes/SavedRecipesViewModelType.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | /// SavedRecipes Input & Output 3 | /// 4 | typealias SavedRecipesViewModelType = SavedRecipesViewModelInput & SavedRecipesViewModelOutput 5 | 6 | /// SavedRecipes ViewModel Input 7 | /// 8 | protocol SavedRecipesViewModelInput { 9 | func removeSavedRecipe(_ recipe: SavedRecipesTableViewCell.ViewModel) 10 | } 11 | 12 | /// SavedRecipes ViewModel Output 13 | /// 14 | protocol SavedRecipesViewModelOutput { 15 | var recipesPublisher: any Publisher<[SavedRecipesTableViewCell.ViewModel], Never> { get } 16 | } 17 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Dashboard/DashboardViewModelType.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | /// Dashboard Input & Output 4 | /// 5 | typealias DashboardViewModelType = DashboardViewModelInput & DashboardViewModelOutput 6 | 7 | /// Dashboard ViewModel Input 8 | /// 9 | protocol DashboardViewModelInput {} 10 | 11 | /// Dashboard ViewModel Output 12 | /// 13 | protocol DashboardViewModelOutput { 14 | typealias NewRecipeViewModel = NewRecipeCollectionViewCell.ViewModel 15 | 16 | var header: HomeHeaderView.ViewModel { get } 17 | var newRecipesPublisher: any Publisher<[NewRecipeViewModel], Never> { get } 18 | } 19 | -------------------------------------------------------------------------------- /swiftgen.yml: -------------------------------------------------------------------------------- 1 | input_dir: Healthy/ 2 | output_dir: Healthy/Resources/Generated/ 3 | 4 | xcassets: 5 | - inputs: 6 | - Resources/ 7 | outputs: 8 | - templatePath: SwiftGen-Templates/images.stencil 9 | output: UIImage.Generated.swift 10 | 11 | - inputs: 12 | - Resources/Colors.xcassets 13 | outputs: 14 | - templatePath: SwiftGen-Templates/Colors.stencil 15 | output: UIColors.Generated.swift 16 | 17 | strings: 18 | inputs: 19 | - Resources/Localization/Localizable.strings 20 | outputs: 21 | - templateName: structured-swift5 22 | output: Strings.Generated.swift 23 | 24 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Onboarding/CreateAccount/CreateAccountviewModelType.swift: -------------------------------------------------------------------------------- 1 | typealias CreateAccountViewModelType = CreateAccountViewModelInput & CreateAccountViewModelOutput 2 | 3 | // MARK: CreateAccountViewModelInput 4 | protocol CreateAccountViewModelInput { 5 | func updateUsername(_ text: String) 6 | func updateEmail(_ text: String) 7 | func updatePassword(_ text: String) 8 | func updateConfirmPassword(_ text: String) 9 | func updateAcceptTermsAndConditions(_ isChecked: Bool) 10 | } 11 | 12 | // MARK: CreateAccountViewModelOutput 13 | protocol CreateAccountViewModelOutput { 14 | func configureButtonEnabled(onEnabled: @escaping (Bool) -> Void) 15 | } 16 | -------------------------------------------------------------------------------- /Templates/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile 2 | 3 | # Define color codes 4 | GREEN = \033[0;32m 5 | CRIMSON = \033[0;31m 6 | RESET = \033[0m 7 | 8 | install: 9 | @$(MAKE) install_templates 10 | 11 | XCODE_USER_TEMPLATES_DIR=~/Library/Developer/Xcode/Templates/File\ Templates 12 | TEMPLATES_DIR=Templates 13 | 14 | install_templates: 15 | @mkdir -p $(XCODE_USER_TEMPLATES_DIR) 16 | @rm -fR $(XCODE_USER_TEMPLATES_DIR)/$(TEMPLATES_DIR) 17 | @cp -R $(TEMPLATES_DIR) $(XCODE_USER_TEMPLATES_DIR) 18 | @printf "\r$(GREEN)Template is installed successfully ✅\n" 19 | 20 | 21 | uninstall: 22 | @rm -fR $(XCODE_USER_TEMPLATES_DIR)/$(TEMPLATES_DIR) 23 | @echo "$(CRIMSON)Uninstalling is done 😢" 24 | 25 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Onboarding/Splash/SplashViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: SplashViewModel 4 | 5 | final class SplashViewModel { 6 | private unowned let coordinator: OnboardingCoordinator 7 | 8 | init(coordinator: OnboardingCoordinator) { 9 | self.coordinator = coordinator 10 | } 11 | } 12 | 13 | // MARK: SplashViewModel 14 | 15 | extension SplashViewModel: SplashViewModelInput { 16 | func startCooking() { 17 | coordinator.didStartCooking() 18 | } 19 | } 20 | 21 | // MARK: SplashViewModel Output 22 | 23 | extension SplashViewModel: SplashViewModelOutput {} 24 | 25 | // MARK: Private Handlers 26 | 27 | private extension SplashViewModel {} 28 | -------------------------------------------------------------------------------- /Networking/Sources/Networking/Dispatcher/URLSessionProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A protocol representing a URLSession with async data fetching capabilities. 4 | public protocol URLSessionProtocol { 5 | /// Fetches the data and URL response for the given request. 6 | /// - Parameters: 7 | /// - request: The URL request to be sent. 8 | /// - Returns: A tuple containing the retrieved data and the URL response. 9 | /// - Throws: An error if an error occurs during the network request. 10 | func data(for request: URLRequest) async throws -> (Data, URLResponse) 11 | } 12 | 13 | /// Extension to make URLSession conform to URLSessionProtocol. 14 | extension URLSession: URLSessionProtocol {} 15 | -------------------------------------------------------------------------------- /Healthy/Classes/Extensions/UICollectionView+Helpers.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UICollectionView { 4 | 5 | func register(_: T.Type, bundle: Bundle? = nil) { 6 | let bundle = bundle ?? Bundle(for: T.self) 7 | let nib = UINib(nibName: T.reuseIdentifier, bundle: bundle) 8 | register(nib, forCellWithReuseIdentifier: T.reuseIdentifier) 9 | } 10 | 11 | func dequeueReusableCell(for indexPath: IndexPath) -> T { 12 | guard let cell = dequeueReusableCell(withReuseIdentifier: T.reuseIdentifier, for: indexPath) as? T else { 13 | fatalError("Could not dequeue cell with identifier: \(T.reuseIdentifier)") 14 | } 15 | 16 | return cell 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Onboarding/Login/LoginViewModelType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | typealias LoginViewModelType = LoginViewModelInput & LoginViewModelOutput 5 | 6 | protocol LoginViewModelInput { 7 | func updateEmail(_ text: String) 8 | func updatePassword(_ text: String) 9 | func performSignIn() 10 | func performSignUp() 11 | func performForgetPassword() 12 | func performSocialMediaSignIn(_ authentication: Authentication) 13 | } 14 | 15 | protocol LoginViewModelOutput { 16 | var isLoadingIndicatorPublisher: AnyPublisher { get } 17 | var errorPublisher: AnyPublisher { get } 18 | var isLoginEnabledPublisher: AnyPublisher { get } 19 | var isLoginStatusPublisher: AnyPublisher { get } 20 | } 21 | -------------------------------------------------------------------------------- /Healthy/Classes/Utilities/Logger/LogLevel.swift: -------------------------------------------------------------------------------- 1 | /// A level of logging severity. 2 | /// 3 | /// Use the `LogLevel` enum to specify the severity of a log message. The available log levels are: 4 | public enum LogLevel { 5 | /// Detailed information that can help with debugging. 6 | case verbose 7 | 8 | /// Information that is useful for debugging. 9 | case debug 10 | 11 | /// General information about the application's state or functionality. 12 | case info 13 | 14 | /// An indication that something unexpected or undesirable happened, but the application can continue to function. 15 | case warn 16 | 17 | /// An indication that something unexpected or undesirable happened and the application cannot continue to 18 | /// function as intended. 19 | case error 20 | } 21 | -------------------------------------------------------------------------------- /Networking/Sources/Networking/Networking/ResponseDecoder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol ResponseDecoder { 4 | /// The type of the response expected from the request. 5 | associatedtype ResponseType: Decodable 6 | 7 | /// The closure that handles the decoding of the response data. 8 | var responseDecoder: (Data) throws -> ResponseType { get } 9 | } 10 | 11 | extension ResponseDecoder { 12 | /// Default implementation of responseDecoder that uses JSONDecoder to decode the response data. 13 | public var responseDecoder: (Data) throws -> ResponseType { 14 | { data in 15 | let decoder = JSONDecoder() 16 | decoder.keyDecodingStrategy = .convertFromSnakeCase 17 | return try decoder.decode(ResponseType.self, from: data) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /HealthyUITests/HealthyUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | final class HealthyUITestsLaunchTests: XCTestCase { 4 | 5 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 6 | true 7 | } 8 | 9 | override func setUpWithError() throws { 10 | continueAfterFailure = false 11 | } 12 | 13 | func testLaunch() throws { 14 | let app = XCUIApplication() 15 | app.launch() 16 | 17 | // Insert steps here to perform after app launch but before taking a screenshot, 18 | // such as logging into a test account or navigating somewhere in the app 19 | 20 | let attachment = XCTAttachment(screenshot: app.screenshot()) 21 | attachment.name = "Launch Screen" 22 | attachment.lifetime = .keepAlways 23 | add(attachment) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /HealthyTests/Classes/Modules/LoginUseCase/DefaultLoginUseCaseTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Domain 3 | @testable import Networking 4 | @testable import Healthy 5 | 6 | final class DefaultLoginUseCaseTests: XCTestCase { 7 | var loginUseCase: DefaultLoginUseCase! 8 | 9 | override func setUp() { 10 | super.setUp() 11 | loginUseCase = DefaultLoginUseCase() 12 | } 13 | 14 | override func tearDown() { 15 | loginUseCase = nil 16 | super.tearDown() 17 | } 18 | 19 | func testLogin() async throws { 20 | let email = "test@example.com" 21 | let password = "password" 22 | 23 | let user = try await loginUseCase.login(email: email, password: password) 24 | 25 | XCTAssertEqual(user.email, "ahmdmhasn@gmail.com") 26 | XCTAssertEqual(user.tokenID, "12345678") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Search/Search/SearchViewModelType.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | /// Search Input & Output 4 | /// 5 | typealias SearchViewModelType = SearchViewModelInput & SearchViewModelOutput 6 | 7 | /// Search ViewModel Input 8 | /// 9 | protocol SearchViewModelInput { 10 | func updateSearchKeyword(_ keyword: String) 11 | func updateSearchFilter(_ filter: SearchFilter) 12 | } 13 | 14 | /// Search ViewModel Output 15 | /// 16 | protocol SearchViewModelOutput { 17 | var recipesPublisher: any Publisher<[Recipe], Never> { get } 18 | var errorPublisher: any Publisher { get } 19 | var isEmptyPublisher: any Publisher { get } 20 | var isLoadingPublisher: any Publisher { get } 21 | var isLoadingMorePublisher: any Publisher { get } 22 | var isLoadedPublisher: any Publisher { get } 23 | } 24 | -------------------------------------------------------------------------------- /HealthyTests/Classes/Modules/SavedRecipes/Mocks/SavedRecipesViewModelMock.swift: -------------------------------------------------------------------------------- 1 | @testable import Healthy 2 | import Combine 3 | import UIKit 4 | 5 | final class SavedRecipesViewModelMock: SavedRecipesViewModelType { 6 | 7 | // MARK: - Properties 8 | private let recipesSubject = PassthroughSubject<[SavedRecipesTableViewCell.ViewModel], Never>() 9 | 10 | // MARK: - Methods 11 | private(set) var removeRecipesCallCount: Int = .zero 12 | func removeSavedRecipe(_ recipe: Healthy.SavedRecipesTableViewCell.ViewModel) { 13 | removeRecipesCallCount += 1 14 | } 15 | 16 | var recipesPublisher: any Publisher<[Healthy.SavedRecipesTableViewCell.ViewModel], Never> { 17 | recipesSubject.eraseToAnyPublisher() 18 | } 19 | 20 | func sendRecipes(_ recipes: [SavedRecipesTableViewCell.ViewModel]) { 21 | recipesSubject.send(recipes) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Healthy/Classes/Utilities/Coordinator.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// A coordinator is responsible for managing the flow of navigation within an app's user interface. 4 | /// It typically has a navigationController property for managing the navigation stack and a start method for 5 | /// starting the navigation flow. 6 | /// 7 | /// Inspired by. https://khanlou.com/2015/01/the-coordinator 8 | protocol Coordinator { 9 | 10 | /// The navigation controller used for managing the navigation stack. 11 | var navigationController: UINavigationController { get } 12 | 13 | /// Starts the navigation flow managed by the coordinator. 14 | /// 15 | /// This method is called to initiate the navigation flow managed by the coordinator. It typically involves 16 | /// pushing / presenting one or more view controllers onto the navigation stack of the `navigationController`. 17 | func start() 18 | } 19 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Dashboard/Views/HomeHeaderSkeletonView/HomeHeaderSkeletonView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | private class HomeHeaderSkeletonView: UIView { 4 | 5 | // MARK: IBOutlets 6 | 7 | @IBOutlet private(set) weak var titleLabel: UILabel! 8 | @IBOutlet private(set) weak var subtitleLabel: UILabel! 9 | @IBOutlet private(set) weak var userImageView: UIImageView! 10 | 11 | // MARK: Init 12 | 13 | override init(frame: CGRect) { 14 | super.init(frame: frame) 15 | configureSkeletonView() 16 | } 17 | 18 | required init?(coder: NSCoder) { 19 | super.init(coder: coder) 20 | configureSkeletonView() 21 | } 22 | 23 | // MARK: Configurations 24 | 25 | private func configureSkeletonView() { 26 | titleLabel.startSkeletonView() 27 | subtitleLabel.startSkeletonView() 28 | userImageView.startSkeletonView() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Networking/Sources/Networking/Constants.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum Constants { 4 | static let mockyBaseUrl: URL = { 5 | guard let url = URL(string: "https://run.mocky.io/v3/") else { 6 | preconditionFailure("Invalid URL") 7 | } 8 | 9 | return url 10 | }() 11 | 12 | static let theMealDB: URL = { 13 | guard let url = URL(string: "https://www.themealdb.com/api/json/v1/1/") else { 14 | preconditionFailure("Invalid URL") 15 | } 16 | 17 | return url 18 | }() 19 | 20 | static let firebaseAuth: URL = { 21 | guard let url = URL(string: "https://identitytoolkit.googleapis.com/v1") else { 22 | preconditionFailure("Invalid URL") 23 | } 24 | 25 | return url 26 | }() 27 | 28 | static let firebaseKey: String = { 29 | return "AIzaSyB0UczrurqM1STyI8tvx4QZVTyQVw4UJ7Q" 30 | }() 31 | } 32 | -------------------------------------------------------------------------------- /Healthy/Classes/UseCases/LoginUseCase.swift: -------------------------------------------------------------------------------- 1 | import Domain 2 | import Networking 3 | import Factory 4 | import Foundation 5 | 6 | extension Container { 7 | var loginUseCase: Factory { 8 | Factory(self) { 9 | DefaultLoginUseCase() 10 | } 11 | } 12 | } 13 | 14 | final class DefaultLoginUseCase: LoginUseCase { 15 | 16 | @Injected(\.networking) private var networking 17 | 18 | func login(email: String, password: String) async throws -> User { 19 | // TODO: replace mock Request with Actual Request 20 | let request = LoginRequest(email: email, password: password) 21 | do { 22 | _ = try await networking.dispatch(request) 23 | return User(email: "ahmdmhasn@gmail.com", tokenID: "12345678") 24 | 25 | } catch { 26 | // If Error 27 | throw NSError(domain: "Some Error", code: -1) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Networking/Sources/Networking/Dispatcher/DefaultNetworkDispatcher.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// The default implementation of the NetworkDispatcher protocol. 4 | public final class DefaultNetworkDispatcher: NetworkDispatcher { 5 | private let session: URLSessionProtocol 6 | 7 | /// Initializes the DefaultNetworkDispatcher with an optional URLSession instance. 8 | /// - Parameter session: The URLSession instance to use for network requests. If not provided, 9 | /// a shared URLSession will be used. 10 | public init(session: URLSessionProtocol = URLSession.shared) { 11 | self.session = session 12 | } 13 | 14 | public func dispatch(_ request: Request) async throws -> Request.ResponseType { 15 | let urlRequest = try request.asURLRequest() 16 | let (data, _) = try await session.data(for: urlRequest) 17 | return try request.responseDecoder(data) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Networking/Sources/Networking/Requests/ACIListRequests/AreaListRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - AreaListResponse 4 | public struct AreaListResponse: Decodable { 5 | let meals: [MealArea] 6 | } 7 | 8 | // MARK: - MealArea 9 | struct MealArea: Decodable { 10 | let area: String 11 | 12 | private enum CodingKeys: String, CodingKey { 13 | case area = "strArea" 14 | } 15 | } 16 | 17 | // MARK: - AreaListRequest 18 | public struct AreaListRequest: RequestType { 19 | 20 | public init() {} 21 | 22 | public var baseUrl: URL { Constants.theMealDB } 23 | public var path: String { "list.php" } 24 | public var method: String { "GET" } 25 | public var queryParameters: [String: String] { 26 | ["a": "list"] 27 | } 28 | 29 | public let responseDecoder: (Data) throws -> AreaListResponse = { data in 30 | try JSONDecoder().decode(ResponseType.self, from: data) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Search/Search/SearchViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class SearchViewController: UIViewController { 4 | 5 | // MARK: Outlets 6 | 7 | // MARK: Properties 8 | 9 | private let viewModel: SearchViewModelType 10 | 11 | // MARK: Init 12 | 13 | init(viewModel: SearchViewModelType) { 14 | self.viewModel = viewModel 15 | super.init(nibName: nil, bundle: nil) 16 | } 17 | 18 | @available(*, unavailable) 19 | required init?(coder: NSCoder) { 20 | fatalError("init(coder:) has not been implemented") 21 | } 22 | 23 | // MARK: Lifecycle 24 | 25 | override func viewDidLoad() { 26 | super.viewDidLoad() 27 | } 28 | } 29 | 30 | // MARK: - Actions 31 | 32 | extension SearchViewController {} 33 | 34 | // MARK: - Configurations 35 | 36 | extension SearchViewController {} 37 | 38 | // MARK: - Private Handlers 39 | 40 | private extension SearchViewController {} 41 | -------------------------------------------------------------------------------- /Healthy/Classes/Utilities/Validators/Validator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol Validator { 4 | typealias ValueType = String 5 | /// An array of validation rules that apply to the data being validated. 6 | var validationRules: [ValidationRule] { get } 7 | /// Validates a given value and throw error if any 8 | func validate(_ value: ValueType) throws 9 | /// Validates whether a given value is considered valid according to the validation rules. 10 | func hasValidValue(_ value: ValueType) -> Bool 11 | } 12 | 13 | extension Validator { 14 | func validate(_ value: ValueType) throws { 15 | for validationRule in validationRules { 16 | try validationRule.validate(value) 17 | } 18 | } 19 | func hasValidValue(_ value: ValueType) -> Bool { 20 | do { 21 | try validate(value) 22 | return true 23 | } catch { 24 | return false 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Dashboard/Views/FoodTagsView/FoodTagCollectionViewCell/FoodTagCollectionViewCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class FoodTagCollectionViewCell: UICollectionViewCell { 4 | 5 | // MARK: Outlets 6 | 7 | @IBOutlet private weak var foodCategoryName: UILabel! 8 | 9 | // MARK: - Lifecycle Methods 10 | 11 | override func awakeFromNib() { 12 | super.awakeFromNib() 13 | contentView.layer.cornerRadius = 10 14 | } 15 | 16 | func updateView(viewModel: ViewModel) { 17 | foodCategoryName.text = viewModel.foodCategoryName 18 | } 19 | 20 | func setSelection(_ selected: Bool) { 21 | contentView.backgroundColor = selected ? .primary100 : .clear 22 | foodCategoryName.textColor = selected ? .white : .primary100 23 | } 24 | } 25 | 26 | // MARK: ViewModel 27 | 28 | extension FoodTagCollectionViewCell { 29 | struct ViewModel: Equatable { 30 | let foodCategoryName: String 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Healthy/Classes/Extensions/UITextField+Style.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | // MARK: TextField style 4 | 5 | extension UITextField { 6 | enum TextFieldStyle { 7 | case primary 8 | } 9 | } 10 | 11 | // MARK: Apply textfield style 12 | 13 | extension UITextField { 14 | func applyTextFieldStyle(_ style: TextFieldStyle) { 15 | NSLayoutConstraint.activate([ 16 | heightAnchor.constraint(equalToConstant: Constants.defaultHeight) 17 | ]) 18 | 19 | layer.cornerRadius = Constants.defaultCornerRadius 20 | layer.borderWidth = Constants.defaultBorderWidth 21 | layer.borderColor = UIColor.gray4.cgColor 22 | borderStyle = .none 23 | } 24 | } 25 | 26 | // MARK: Constants 27 | 28 | private extension UITextField { 29 | enum Constants { 30 | static let defaultCornerRadius: CGFloat = 10.0 31 | static let defaultHeight: CGFloat = 55.0 32 | static let defaultBorderWidth: CGFloat = 1.5 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Healthy/Classes/Entities/SavedRecipe.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | struct SavedRecipe: Hashable, Equatable { 4 | 5 | // MARK: - Properties 6 | 7 | let id = UUID() 8 | 9 | /// The title of the recipe 10 | let title: String? 11 | 12 | /// The image of the recipe 13 | let recipeImage: UIImage? 14 | 15 | /// The rating of the recipe 16 | let rating: Double? 17 | 18 | /// The chef who posted the recipe 19 | let chefName: String? 20 | 21 | /// The cooking Time of The recipe 22 | let cookingTime: Int? 23 | 24 | var toggleBookmark: () -> Void 25 | 26 | func hash(into hasher: inout Hasher) { 27 | hasher.combine(id) 28 | } 29 | 30 | static func == (lhs: SavedRecipe, rhs: SavedRecipe) -> Bool { 31 | return lhs.title == rhs.title && 32 | lhs.rating == rhs.rating && 33 | lhs.chefName == rhs.chefName && 34 | lhs.cookingTime == rhs.cookingTime && 35 | lhs.recipeImage == rhs.recipeImage 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /Healthy/Classes/Extensions/UITableView+RegisterNib.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UITableView { 4 | 5 | /// Generic function for register table view cell 6 | /// 7 | /// - Usage: 8 | /// - tableview.registerNib(cell: 'your cell'.self) 9 | func registerNib(cell: Cell.Type) { 10 | let nibName = String(describing: Cell.self) 11 | register(UINib(nibName: nibName, bundle: nil), forCellReuseIdentifier: nibName) 12 | } 13 | 14 | /// Generic function for dequeue table view cell 15 | /// 16 | /// - Usage: 17 | /// - let cell = tableview.dequeue() as 'your cell' 18 | func dequeueReusableCell(for indexPath: IndexPath) -> Cell { 19 | let identifier = String(describing: Cell.self) 20 | 21 | guard let cell = self.dequeueReusableCell(withIdentifier: identifier, for: indexPath) as? Cell else { 22 | fatalError("Not found cell") 23 | } 24 | 25 | return cell 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Search/FilterSearch/FilterSearchViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class FilterSearchViewController: UIViewController { 4 | 5 | // MARK: Outlets 6 | 7 | // MARK: Properties 8 | 9 | private let viewModel: FilterSearchViewModelType 10 | 11 | // MARK: Init 12 | 13 | init(viewModel: FilterSearchViewModelType) { 14 | self.viewModel = viewModel 15 | super.init(nibName: nil, bundle: nil) 16 | } 17 | 18 | @available(*, unavailable) 19 | required init?(coder: NSCoder) { 20 | fatalError("init(coder:) has not been implemented") 21 | } 22 | 23 | // MARK: Lifecycle 24 | 25 | override func viewDidLoad() { 26 | super.viewDidLoad() 27 | } 28 | } 29 | 30 | // MARK: - Actions 31 | 32 | extension FilterSearchViewController {} 33 | 34 | // MARK: - Configurations 35 | 36 | extension FilterSearchViewController {} 37 | 38 | // MARK: - Private Handlers 39 | 40 | private extension FilterSearchViewController {} 41 | -------------------------------------------------------------------------------- /Healthy/Classes/Extensions/UIView+Style.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | // MARK: Apply primary gradient to UIView. 4 | 5 | extension UIView { 6 | func applyPrimaryGradient() { 7 | 8 | // Create a new gradient layer. 9 | let gradientLayer = CAGradientLayer() 10 | 11 | // Set the colors and locations for the gradient layer. 12 | gradientLayer.colors = [ 13 | UIColor(red: 0, green: 0, blue: 0, alpha: 0).cgColor, 14 | UIColor(red: 0, green: 0, blue: 0, alpha: 1).cgColor 15 | ] 16 | 17 | gradientLayer.locations = [0.0, 1] 18 | 19 | // Set the start and end points for the gradient layer. 20 | gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.0) 21 | gradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0) 22 | 23 | // Set the frame to the layer. 24 | gradientLayer.frame = frame 25 | 26 | // Add the gradient layer as a sublayer to the background view. 27 | layer.insertSublayer(gradientLayer, at: 0) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Healthy/Classes/ReusableViews/SignButtonWithSocialMediaView/SignButtonWithSocialMediaView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class SignButtonWithSocialMediaView: UIView { 4 | 5 | // MARK: - IBOutlet 6 | 7 | @IBOutlet weak private var contentView: UIView! 8 | @IBOutlet weak private var signinButton: UIButton! 9 | @IBOutlet weak private var googleButton: UIButton! 10 | @IBOutlet weak private var facebookButton: UIButton! 11 | @IBOutlet private var mediaBackgroundViews: [UIView]! 12 | 13 | // MARK: - Init 14 | 15 | override init(frame: CGRect) { 16 | super.init(frame: frame) 17 | initView() 18 | } 19 | 20 | required init?(coder: NSCoder) { 21 | super.init(coder: coder) 22 | initView() 23 | } 24 | 25 | // MARK: - Configure Layout 26 | 27 | private func initView() { 28 | loadViewFromNib() 29 | configureLayout() 30 | } 31 | 32 | private func configureLayout() { 33 | signinButton.applyButtonStyle(.primary) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /HealthyTests/Classes/Extensions/UILabel+Style/UILabelStyleTests.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import XCTest 3 | @testable import Healthy 4 | 5 | final class UILabelStyleTests: XCTestCase { 6 | 7 | // MARK: - Test Title style 8 | 9 | func test_applyStyle_onTitleLabelStyle() { 10 | // Given 11 | let label = UILabel() 12 | 13 | // When 14 | label.applyTitleBoldStyle() 15 | 16 | // Then 17 | XCTAssertEqual(label.textColor, .black100) 18 | XCTAssertEqual(label.font, .titleBold) 19 | XCTAssertEqual(label.numberOfLines, 0) 20 | } 21 | 22 | // MARK: - Test Subtyle style 23 | 24 | func test_applyStyle_onSubtitleLabelStyle() { 25 | // Given 26 | let label = UILabel() 27 | 28 | // When 29 | label.applySubtitleLabelStyle() 30 | 31 | // Then 32 | XCTAssertEqual(label.textColor, .black20) 33 | XCTAssertEqual(label.font, UIFont.mediumRegular) 34 | XCTAssertEqual(label.numberOfLines, 0) 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Networking/Sources/Networking/Requests/MealCategoriesRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - MealCategoriesResponse 4 | public struct MealCategoriesResponse: Decodable { 5 | let categories: [Category] 6 | } 7 | 8 | // MARK: - Category 9 | public struct Category: Decodable { 10 | let categoryId, categoryTitle: String 11 | let categoryThumb: String 12 | let categoryDescription: String 13 | 14 | enum CodingKeys: String, CodingKey { 15 | case categoryId = "idCategory" 16 | case categoryTitle = "strCategory" 17 | case categoryThumb = "strCategoryThumb" 18 | case categoryDescription = "strCategoryDescription" 19 | } 20 | } 21 | 22 | // MARK: - MealCategoriesRequest 23 | public struct MealCategoriesRequest: RequestType { 24 | 25 | public typealias ResponseType = MealCategoriesResponse 26 | 27 | public init() {} 28 | 29 | public var baseUrl: URL { Constants.theMealDB } 30 | public var path: String { "categories.php" } 31 | public var method: String { "GET" } 32 | } 33 | -------------------------------------------------------------------------------- /Templates/Templates/MVVM.xctemplate/WithXIB/___FILEBASENAME___ViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class ___FILEBASENAMEASIDENTIFIER___: UIViewController { 4 | 5 | // MARK: Outlets 6 | 7 | // MARK: Properties 8 | 9 | private let viewModel: ___VARIABLE_productName___ViewModelType 10 | 11 | // MARK: Init 12 | 13 | init(viewModel: ___VARIABLE_productName___ViewModelType) { 14 | self.viewModel = viewModel 15 | super.init(nibName: nil, bundle: nil) 16 | } 17 | 18 | @available(*, unavailable) 19 | required init?(coder: NSCoder) { 20 | fatalError("init(coder:) has not been implemented") 21 | } 22 | 23 | // MARK: Lifecycle 24 | 25 | override func viewDidLoad() { 26 | super.viewDidLoad() 27 | } 28 | } 29 | 30 | // MARK: - Actions 31 | 32 | extension ___FILEBASENAMEASIDENTIFIER___ {} 33 | 34 | // MARK: - Configurations 35 | 36 | extension ___FILEBASENAMEASIDENTIFIER___ {} 37 | 38 | // MARK: - Private Handlers 39 | 40 | private extension ___FILEBASENAMEASIDENTIFIER___ {} 41 | -------------------------------------------------------------------------------- /Networking/Sources/Networking/Requests/ACIListRequests/CategoriesListRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - CategoriesListResponse 4 | public struct CategoriesListResponse: Decodable { 5 | let meals: [MealCategory] 6 | } 7 | 8 | // MARK: - MealCategory 9 | struct MealCategory: Decodable { 10 | let category: String 11 | 12 | enum CodingKeys: String, CodingKey { 13 | case category = "strCategory" 14 | } 15 | } 16 | 17 | // MARK: - CategoriesListRequest 18 | public struct CategoriesListRequest: RequestType { 19 | public typealias ResponseType = CategoriesListResponse 20 | 21 | public init() {} 22 | 23 | public var baseUrl: URL { Constants.theMealDB } 24 | public var path: String { "list.php" } 25 | public var method: String { "GET" } 26 | public var queryParameters: [String: String] { 27 | ["c": "list"] 28 | } 29 | 30 | public let responseDecoder: (Data) throws -> CategoriesListResponse = { data in 31 | try JSONDecoder().decode(ResponseType.self, from: data) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Templates/Templates/MVVM.xctemplate/WithoutXIB/___FILEBASENAME___ViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class ___FILEBASENAMEASIDENTIFIER___: UIViewController { 4 | 5 | // MARK: Outlets 6 | 7 | // MARK: Properties 8 | 9 | private let viewModel: ___VARIABLE_productName___ViewModelType 10 | 11 | // MARK: Init 12 | 13 | init(viewModel: ___VARIABLE_productName___ViewModelType) { 14 | self.viewModel = viewModel 15 | super.init(nibName: nil, bundle: nil) 16 | } 17 | 18 | @available(*, unavailable) 19 | required init?(coder: NSCoder) { 20 | fatalError("init(coder:) has not been implemented") 21 | } 22 | 23 | // MARK: Lifecycle 24 | 25 | override func viewDidLoad() { 26 | super.viewDidLoad() 27 | } 28 | } 29 | 30 | // MARK: - Actions 31 | 32 | extension ___FILEBASENAMEASIDENTIFIER___ {} 33 | 34 | // MARK: - Configurations 35 | 36 | extension ___FILEBASENAMEASIDENTIFIER___ {} 37 | 38 | // MARK: - Private Handlers 39 | 40 | private extension ___FILEBASENAMEASIDENTIFIER___ {} 41 | -------------------------------------------------------------------------------- /Healthy/Classes/Extensions/NSLayoutConstraint+Helpers.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension NSLayoutDimension { 4 | /// Creates a constraint that sets the dimension's constant value to the provided CGFloat. 5 | /// - Parameter value: The constant value to set for the dimension. 6 | /// - Returns: The created constraint, which is activated automatically. 7 | @discardableResult 8 | func equalTo(_ value: CGFloat) -> NSLayoutConstraint { 9 | let constraint = self.constraint(equalToConstant: value) 10 | constraint.isActive = true 11 | return constraint 12 | } 13 | } 14 | 15 | extension NSLayoutConstraint { 16 | /// Sets the priority of the constraint. 17 | /// - Parameter priority: The UILayoutPriority to set for the constraint. 18 | /// - Returns: The modified constraint, allowing for chaining with other methods. 19 | @discardableResult 20 | func priority(_ priority: UILayoutPriority) -> NSLayoutConstraint { 21 | self.priority = priority 22 | isActive = true 23 | return self 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /HealthyTests/UILabelStyleTests.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import XCTest 3 | @testable import Healthy 4 | 5 | final class UILabelStyleTests: XCTestCase { 6 | 7 | // MARK: - Test Title style 8 | 9 | func test_applyStyle_onTitleLabelStyle() { 10 | // Given 11 | let style = TitleLabelStyle() 12 | let sut = UILabel() 13 | 14 | // When 15 | style.applyStyle(for: sut) 16 | 17 | // Then 18 | XCTAssertEqual(sut.textColor, .black20) 19 | XCTAssertEqual(sut.font, UIFont.poppinsbold(20.0)) 20 | XCTAssertEqual(sut.numberOfLines, 0) 21 | } 22 | 23 | // MARK: - Test Subtyle style 24 | 25 | func test_applyStyle_onSubtitleLabelStyle() { 26 | // Given 27 | let style = SubtitleLabelStyle() 28 | let sut = UILabel() 29 | 30 | // When 31 | style.applyStyle(for: sut) 32 | 33 | // Then 34 | XCTAssertEqual(sut.textColor, .black20) 35 | XCTAssertEqual(sut.font, UIFont.poppinsbold(18.0)) 36 | XCTAssertEqual(sut.numberOfLines, 0) 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /Healthy/Classes/Utilities/Logger/NewRelicLogger.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NewRelic 3 | 4 | final class NewRelicLogger: Logging { 5 | private func convertToNewRelicLoggerLevel(form level: 6 | LogLevel) -> UInt32 { 7 | switch level { 8 | case .debug: 9 | return 0 10 | case .error: 11 | return 1 << 0 12 | case .warn: 13 | return 1 << 1 14 | case .info: 15 | return 1 << 2 16 | case .verbose: 17 | return 1 << 3 18 | } 19 | } 20 | 21 | func log(_ message: String, 22 | level: LogLevel, 23 | file: StaticString, 24 | function: StaticString, 25 | line: UInt) { 26 | NRLogger.log(convertToNewRelicLoggerLevel(form: level), 27 | inFile: String(describing: file), 28 | atLine: UInt32(line), 29 | inMethod: String(describing: function), 30 | withMessage: String(message)) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | #### Proposed Changes 2 | 3 | Describe the changes proposed in this pull request. 4 | 5 | #### Related Issues 6 | 7 | List any related issues or pull requests. 8 | 9 | #### Additional Information 10 | 11 | Provide any additional information or context that may be relevant. 12 | 13 | #### Screenshot 14 | 15 | Required for any UI changes 16 | 17 | #### Checklist 18 | 19 | - [ ] I have tested these changes locally. 20 | - [ ] I have added appropriate documentation or updated existing documentation. 21 | - [ ] I have added appropriate test coverage or updated existing test coverage. 22 | - [ ] I have updated the changelog (if applicable). 23 | - [ ] I have followed the project's code style and formatting guidelines. 24 | - [ ] I have reviewed and adhered to the project's contributing guidelines. 25 | 26 | 41 | -------------------------------------------------------------------------------- /Networking/Sources/Networking/Networking/TargetType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A protocol representing a network request. 4 | public protocol TargetType: URLRequestConvertible { 5 | /// The base URL of the request. 6 | var baseUrl: URL { get } 7 | 8 | /// The path component of the request URL. 9 | var path: String { get } 10 | 11 | /// The HTTP method of the request. 12 | var method: String { get } 13 | 14 | /// The query parameters of the request. 15 | var queryParameters: [String: String] { get } 16 | 17 | /// The body parameters of the request. 18 | var bodyParameters: [String: Any] { get } 19 | 20 | /// The headers of the request. 21 | var headers: [String: String] { get } 22 | } 23 | 24 | extension TargetType { 25 | /// Default implementation of headers. 26 | public var headers: [String: String] { [:] } 27 | 28 | /// Default implementation of query parameters. 29 | public var queryParameters: [String: String] { [:] } 30 | 31 | /// Default implementation of body parameters. 32 | public var bodyParameters: [String: Any] { [:] } 33 | } 34 | -------------------------------------------------------------------------------- /Domain/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Domain", 8 | platforms: [.iOS(.v13)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, and make them visible to other packages. 11 | .library( 12 | name: "Domain", 13 | targets: ["Domain"]) 14 | ], 15 | dependencies: [ 16 | // Dependencies declare other packages that this package depends on. 17 | // .package(url: /* package url */, from: "1.0.0"), 18 | ], 19 | targets: [ 20 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 21 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 22 | .target( 23 | name: "Domain", 24 | dependencies: []), 25 | .testTarget( 26 | name: "DomainTests", 27 | dependencies: ["Domain"]) 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /Vendors/SwiftGen/LICENCE: -------------------------------------------------------------------------------- 1 | MIT Licence 2 | 3 | Copyright (c) 2022 SwiftGen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /HealthyTests/Classes/Modules/Onboarding/Splash/SplashViewControllerTest.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Healthy 3 | 4 | final class SplashViewControllerTest: XCTestCase { 5 | 6 | // MARK: Properties 7 | 8 | private var viewController: SplashViewController! 9 | private var viewModelMock: SplashViewModelMock! 10 | 11 | // MARK: Lifecycle 12 | 13 | override func setUp() { 14 | super.setUp() 15 | viewModelMock = SplashViewModelMock() 16 | viewController = SplashViewController(viewModel: viewModelMock) 17 | viewController.loadViewIfNeeded() 18 | } 19 | 20 | // MARK: Tests 21 | 22 | func test_didTapStartCooking_shouldCallViewModelSplash() { 23 | // When 24 | viewController.didTapStartCooking(UIButton()) 25 | 26 | // Then 27 | XCTAssertEqual(viewModelMock.performStartCookingCallCount, 1) 28 | } 29 | } 30 | 31 | // MARK: SplashViewModelMock 32 | 33 | private final class SplashViewModelMock: SplashViewModelType { 34 | private(set)var performStartCookingCallCount: Int = .zero 35 | func startCooking() { 36 | performStartCookingCallCount += 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Networking/Sources/Networking/Requests/MealSearchRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - SearchMealResponse 4 | public struct SearchMealResponse: Decodable { 5 | let meals: [SearchMeal] 6 | } 7 | 8 | // MARK: - SearchMeal 9 | public struct SearchMeal: Decodable { 10 | let id: String 11 | let name: String 12 | let category: String 13 | let area: String 14 | let thumbnailURL: URL? 15 | 16 | private enum CodingKeys: String, CodingKey { 17 | case id = "idMeal" 18 | case name = "strMeal" 19 | case category = "strCategory" 20 | case area = "strArea" 21 | case thumbnailURL = "strMealThumb" 22 | } 23 | } 24 | 25 | // MARK: - MealSearchRequest 26 | public struct MealSearchRequest: RequestType { 27 | public typealias ResponseType = SearchMealResponse 28 | 29 | private let searchKey: String 30 | 31 | public init(_ searchKey: String) { 32 | self.searchKey = searchKey 33 | } 34 | 35 | public var baseUrl: URL {Constants.theMealDB } 36 | public var path: String {"search.php"} 37 | public var method: String {"GET"} 38 | public var queryParameters: [String: String] { [ "s": searchKey ] } 39 | } 40 | -------------------------------------------------------------------------------- /Networking/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Networking", 8 | platforms: [ 9 | .iOS(.v13) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "Networking", 15 | targets: ["Networking"]) 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 24 | .target( 25 | name: "Networking", 26 | dependencies: []), 27 | .testTarget( 28 | name: "NetworkingTests", 29 | dependencies: ["Networking"]) 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /Networking/Sources/Networking/Requests/RandomMealsRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - RandomMealsResponse 4 | public struct RandomMealsResponse: Codable { 5 | public let meals: [RandomMeal] 6 | } 7 | 8 | // MARK: - RandomMeal 9 | public struct RandomMeal: Codable { 10 | public let id: String 11 | public let name: String 12 | public let category: String 13 | public let area: String 14 | public let thumbnailImageUrl: String 15 | public let tags: String 16 | public let youtubeLink: String 17 | 18 | private enum CodingKeys: String, CodingKey { 19 | case id = "idMeal" 20 | case name = "strMeal" 21 | case category = "strCategory" 22 | case area = "strArea" 23 | case thumbnailImageUrl = "strMealThumb" 24 | case tags = "strTags" 25 | case youtubeLink = "strYoutube" 26 | } 27 | } 28 | 29 | // MARK: - RandomMealsRequest 30 | public struct RandomMealsRequest: RequestType { 31 | public typealias ResponseType = RandomMealsResponse 32 | 33 | public init() {} 34 | 35 | public var baseUrl: URL { Constants.theMealDB } 36 | public var path: String { "random.php" } 37 | public var method: String { "GET" } 38 | } 39 | -------------------------------------------------------------------------------- /Networking/Sources/Networking/Requests/FilterByAreaRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - FilterByAreaResponse 4 | public struct FilterByAreaResponse: Decodable { 5 | public let meals: [Meal] 6 | } 7 | 8 | // MARK: - Meal 9 | public struct Meal: Decodable { 10 | public let id: String 11 | public let meal: String 12 | public let mealThumb: String 13 | 14 | private enum CodingKeys: String, CodingKey { 15 | case id = "idMeal" 16 | case meal = "strMeal" 17 | case mealThumb = "strMealThumb" 18 | } 19 | } 20 | 21 | // MARK: - FilterByAreaRequest 22 | public struct FilterByAreaRequest: RequestType { 23 | public typealias ResponseType = FilterByAreaResponse 24 | 25 | private let area: String 26 | 27 | public init(_ area: String) { 28 | self.area = area 29 | } 30 | 31 | public var baseUrl: URL { Constants.theMealDB } 32 | public var path: String { "filter.php" } 33 | public var method: String = "GET" 34 | public var queryParameters: [String: String] { [ "a": area ] } 35 | 36 | public let responseDecoder: (Data) throws -> FilterByAreaResponse = { data in 37 | try JSONDecoder().decode(ResponseType.self, from: data) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Networking/Sources/Networking/Requests/ACIListRequests/IngredientsListRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - IngrediantsListResponse 4 | public struct IngrediantsListResponse: Decodable { 5 | let meals: [MealIngreidiant] 6 | } 7 | 8 | // MARK: - Meal 9 | public struct MealIngreidiant: Decodable { 10 | let id: String 11 | let ingrediant: String 12 | let description: String? 13 | let type: String? 14 | 15 | enum CodingKeys: String, CodingKey { 16 | case id = "idIngredient" 17 | case ingrediant = "strIngredient" 18 | case description = "strDescription" 19 | case type = "strType" 20 | } 21 | } 22 | 23 | // MARK: - IngrediantsListRequest 24 | public struct IngrediantsListRequest: RequestType { 25 | public typealias ResponseType = IngrediantsListResponse 26 | 27 | public init() {} 28 | 29 | public var baseUrl: URL { Constants.theMealDB } 30 | public var path: String { "list.php" } 31 | public var method: String { "GET" } 32 | public var queryParameters: [String: String] { 33 | ["i": "list"] 34 | } 35 | 36 | public let responseDecoder: (Data) throws -> IngrediantsListResponse = { data in 37 | try JSONDecoder().decode(ResponseType.self, from: data) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Networking/Sources/Networking/Requests/LoginRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - LoginResponse 4 | public struct LoginResponse: Codable { 5 | let accessToken: String 6 | let expiresIn: Int 7 | let refreshToken: String 8 | 9 | enum CodingKeys: String, CodingKey { 10 | case accessToken = "access_token" 11 | case expiresIn = "expires_in" 12 | case refreshToken = "refresh_token" 13 | } 14 | } 15 | 16 | // MARK: - LoginRequest 17 | public struct LoginRequest: RequestType { 18 | public typealias ResponseType = LoginResponse 19 | 20 | private let email: String 21 | private let password: String 22 | 23 | public init(email: String, password: String) { 24 | self.email = email 25 | self.password = password 26 | } 27 | 28 | public var baseUrl: URL { Constants.mockyBaseUrl } 29 | public var path: String { "ba2feb33-cc78-4f94-908e-a85fb1a1d262" } 30 | public var method: String = "GET" 31 | public var queryParameters: [String: String] { 32 | [ 33 | "email": email, 34 | "password": password 35 | ] 36 | } 37 | 38 | public let responseDecoder: (Data) throws -> LoginResponse = { data in 39 | try JSONDecoder().decode(ResponseType.self, from: data) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Networking/Sources/Networking/Networking/URLRequestConvertible.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol URLRequestConvertible { 4 | func asURLRequest() throws -> URLRequest 5 | } 6 | 7 | extension URLRequestConvertible where Self: TargetType { 8 | public func asURLRequest() throws -> URLRequest { 9 | let fullPath = baseUrl.appendingPathComponent(path) 10 | var urlComponents = URLComponents(url: fullPath, resolvingAgainstBaseURL: true) 11 | 12 | // Set query parameters if any 13 | if !queryParameters.isEmpty { 14 | urlComponents?.queryItems = queryParameters.map { key, value in 15 | URLQueryItem(name: key, value: value) 16 | } 17 | } 18 | 19 | guard let url = urlComponents?.url else { 20 | throw NetworkError.invalidURL 21 | } 22 | 23 | // Set body data if any 24 | var httpBody: Data? 25 | if !bodyParameters.isEmpty { 26 | httpBody = try JSONSerialization.data(withJSONObject: bodyParameters, options: []) 27 | } 28 | 29 | var urlRequest = URLRequest(url: url) 30 | urlRequest.httpMethod = method 31 | urlRequest.allHTTPHeaderFields = headers 32 | urlRequest.httpBody = httpBody 33 | 34 | return urlRequest 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /HealthyTests/Classes/Modules/Onboarding/CreateAccount/Mocks/CreateViewModelMock.swift: -------------------------------------------------------------------------------- 1 | @testable import Healthy 2 | 3 | final class CreateAccountViewModelMock: CreateAccountViewModelType { 4 | // MARK: - Methods 5 | private(set) var updateUsernameCallCount: Int = .zero 6 | func updateUsername(_ username: String) { 7 | updateUsernameCallCount += 1 8 | } 9 | 10 | private(set) var updateEmailCallCount: Int = .zero 11 | func updateEmail(_ email: String) { 12 | updateEmailCallCount += 1 13 | } 14 | 15 | private(set) var updatePasswordCallCount: Int = .zero 16 | func updatePassword(_ password: String) { 17 | updatePasswordCallCount += 1 18 | } 19 | 20 | private(set) var updateConfirmPasswordCallCount: Int = .zero 21 | func updateConfirmPassword(_ confirmPassword: String) { 22 | updateConfirmPasswordCallCount += 1 23 | } 24 | 25 | private(set) var configureButtonEnabledCallCount: Int = .zero 26 | func configureButtonEnabled(onEnabled: @escaping (Bool) -> Void) { 27 | configureButtonEnabledCallCount += 1 28 | } 29 | 30 | private(set) var updateAcceptTermsAndConditionsCallCount: Int = .zero 31 | func updateAcceptTermsAndConditions(_ accepted: Bool) { 32 | updateAcceptTermsAndConditionsCallCount += 1 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/star.imageset/star.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /HealthyTests/Classes/Modules/SavedRecipes/Cell/SavedRecipesTableViewCellTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Healthy 3 | 4 | final class SavedRecipesTableViewCellTests: XCTestCase { 5 | 6 | // MARK: Properties 7 | 8 | private var sut: SavedRecipesTableViewCell! 9 | private var mockTableView: UITableViewMock! 10 | 11 | // MARK: Lifecycle 12 | 13 | override func setUp() { 14 | sut = SavedRecipesTableViewCell() 15 | mockTableView = UITableViewMock(frame: CGRect(x: 0, y: 0, width: 200, height: 400), style: .plain) 16 | mockTableView.registerNib(cell: SavedRecipesTableViewCell.self) 17 | } 18 | 19 | // MARK: Tests 20 | 21 | func test_SavedRecipesCellIsFound() { 22 | // Given 23 | let indexPath = IndexPath(row: 0, section: 0) 24 | 25 | // When 26 | let itemCell = mockTableView.tableView(mockTableView, cellForRowAt: indexPath) 27 | 28 | // Then 29 | XCTAssertNotNil(itemCell) 30 | } 31 | 32 | func test_ViewOfSavedRecipesCellIsFound() { 33 | // Given 34 | let indexPath = IndexPath(row: 0, section: 0) 35 | 36 | // When 37 | let view = mockTableView.tableView(mockTableView, cellForRowAt: indexPath) 38 | 39 | // Then 40 | XCTAssertNotNil(view) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Templates/Templates/MVVM.xctemplate/TemplateInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Kind 6 | Xcode.IDEKit.TextSubstitutionFileTemplateKind 7 | Platforms 8 | 9 | com.apple.platform.iphoneos 10 | 11 | Options 12 | 13 | 14 | Identifier 15 | productName 16 | Required 17 | 18 | Name 19 | Your module name: 20 | Description 21 | The name of the MVVM-AC to create 22 | Type 23 | text 24 | 25 | 26 | Identifier 27 | viewType 28 | Required 29 | 30 | Name 31 | View type: 32 | Description 33 | The type of view to use 34 | Type 35 | popup 36 | Default 37 | WithXIB 38 | Values 39 | 40 | WithXIB 41 | WithoutXIB 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /Networking/Tests/NetworkingTests/Requests/ACIListRequestsTests/AreaListRequestTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Networking 3 | 4 | final class AreaListRequestTests: XCTestCase { 5 | 6 | // MARK: Properties 7 | 8 | private var sut: AreaListRequest! 9 | 10 | // MARK: Life cycle 11 | 12 | override func setUp() { 13 | sut = AreaListRequest() 14 | } 15 | 16 | // MARK: Tests 17 | 18 | func testAreasListRequestProperties() { 19 | // Then 20 | XCTAssertEqual(sut.baseUrl, Constants.theMealDB) 21 | XCTAssertEqual(sut.path, "list.php") 22 | XCTAssertEqual(sut.method, "GET") 23 | XCTAssertEqual(sut.queryParameters, ["a": "list"]) 24 | } 25 | 26 | func testAreaListRequestResponseDecoder() throws { 27 | // Given 28 | let areaListResponseAsString = """ 29 | {"meals":[{"strArea":"American"},{"strArea":"British"},{"strArea":"Canadian"}]} 30 | """ 31 | 32 | // When 33 | let areaListResponseData = try XCTUnwrap(areaListResponseAsString.data(using: .utf8)) 34 | let areaListResponse = try? sut.responseDecoder(areaListResponseData) 35 | 36 | // Then 37 | XCTAssertNotNil(areaListResponse) 38 | XCTAssertEqual(areaListResponse?.meals.count, 3) 39 | XCTAssertEqual(areaListResponse?.meals[0].area, "American") 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Templates/Templates/MVVM.xctemplate/WithXIB/___FILEBASENAME___ViewController.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Networking/Sources/Networking/Requests/FilterByMainIngredientAPIRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - FilterByMainIngredientAPIResponse 4 | // private enum CodingKeys: String, CodingKey {} 5 | public struct FilterByMainIngredientAPIResponse: Codable { 6 | public let meals: [MealIng] 7 | } 8 | 9 | // MARK: - Meal 10 | public struct MealIng: Codable { 11 | public let id: String 12 | public let meal: String 13 | public let thumbnailImageUrl: String 14 | 15 | enum CodingKeys: String, CodingKey { 16 | case id = "idMeal" 17 | case meal = "strMeal" 18 | case thumbnailImageUrl = "strMealThumb" 19 | } 20 | } 21 | 22 | // MARK: - FilterByMainIngredientAPIRequest 23 | public struct FilterByMainIngredientAPIRequest: RequestType { 24 | public typealias ResponseType = FilterByMainIngredientAPIResponse 25 | private var ingredient: String 26 | 27 | public init(ingredient: String) {self.ingredient = ingredient} 28 | 29 | public var baseUrl: URL { Constants.theMealDB } 30 | public var path: String { "filter.php" } 31 | public var method: String = "GET" 32 | public var queryParameters: [String: String] { 33 | ["ingredient": ingredient] 34 | } 35 | 36 | public let responseDecoder: (Data) throws -> FilterByMainIngredientAPIResponse = { data in 37 | try JSONDecoder().decode(ResponseType.self, from: data) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Vendors/SwiftGen/bin/SwiftGen_SwiftGenCLI.bundle/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildMachineOSBuild 6 | 21F79 7 | CFBundleDevelopmentRegion 8 | en 9 | CFBundleExecutable 10 | SwiftGen_SwiftGenCLI 11 | CFBundleIdentifier 12 | SwiftGen.SwiftGenCLI.resources 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | SwiftGen_SwiftGenCLI 17 | CFBundlePackageType 18 | BNDL 19 | CFBundleSupportedPlatforms 20 | 21 | MacOSX 22 | 23 | DTCompiler 24 | com.apple.compilers.llvm.clang.1_0 25 | DTPlatformBuild 26 | 13F100 27 | DTPlatformName 28 | macosx 29 | DTPlatformVersion 30 | 12.3 31 | DTSDKBuild 32 | 21E226 33 | DTSDKName 34 | macosx12.3 35 | DTXcode 36 | 1341 37 | DTXcodeBuild 38 | 13F100 39 | LSMinimumSystemVersion 40 | 10.11 41 | 42 | 43 | -------------------------------------------------------------------------------- /Networking/Tests/NetworkingTests/Requests/ACIListRequestsTests/CategoriesListRequestTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Networking 3 | 4 | final class CategoriesListRequestTests: XCTestCase { 5 | 6 | // MARK: Properties 7 | 8 | private var sut: CategoriesListRequest! 9 | 10 | // MARK: Life cycle 11 | 12 | override func setUp() { 13 | sut = CategoriesListRequest() 14 | } 15 | 16 | // MARK: Tests 17 | 18 | func testCategoriesListRequestProperties() { 19 | // Then 20 | XCTAssertEqual(sut.baseUrl, Constants.theMealDB) 21 | XCTAssertEqual(sut.path, "list.php") 22 | XCTAssertEqual(sut.method, "GET") 23 | XCTAssertEqual(sut.queryParameters, ["c": "list"]) 24 | } 25 | 26 | func testAreaListRequestResponseDecoder() throws { 27 | // Given 28 | let categoryListResponseAsString = """ 29 | {"meals":[{"strCategory":"Beef"},{"strCategory":"Breakfast"},{"strCategory":"Chicken"}]} 30 | """ 31 | 32 | // When 33 | let categoryListResponseData = try XCTUnwrap(categoryListResponseAsString.data(using: .utf8)) 34 | let categoryListResponse = try? sut.responseDecoder(categoryListResponseData) 35 | 36 | // Then 37 | XCTAssertNotNil(categoryListResponse) 38 | XCTAssertEqual(categoryListResponse?.meals.count, 3) 39 | XCTAssertEqual(categoryListResponse?.meals[0].category, "Beef") 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Dashboard/Views/HomeHeaderView/HomeHeaderView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class HomeHeaderView: UIView { 4 | 5 | // MARK: Outlets 6 | 7 | @IBOutlet private(set) weak var contentView: UIView! 8 | @IBOutlet private(set) weak var titleLabel: UILabel! 9 | @IBOutlet private(set) weak var subtitleLabel: UILabel! 10 | @IBOutlet private(set) weak var userImageView: UIImageView! 11 | 12 | // MARK: Init 13 | 14 | override init(frame: CGRect) { 15 | super.init(frame: frame) 16 | initView() 17 | } 18 | 19 | required init?(coder: NSCoder) { 20 | super.init(coder: coder) 21 | initView() 22 | } 23 | 24 | // MARK: Configurations 25 | 26 | private func initView() { 27 | loadViewFromNib() 28 | configureLayout() 29 | } 30 | 31 | private func configureLayout() { 32 | titleLabel.applyTitleBoldStyle() 33 | subtitleLabel.applySubtitleLabelStyle() 34 | } 35 | 36 | func configure(with viewModel: ViewModel) { 37 | titleLabel.text = viewModel.title 38 | subtitleLabel.text = viewModel.subtitle 39 | 40 | // TODO: - [HT-62] Waiting for adding kingfisher (or any library for handle image cashing). 41 | } 42 | } 43 | 44 | // MARK: ViewModel 45 | 46 | extension HomeHeaderView { 47 | struct ViewModel { 48 | let title: String 49 | let subtitle: String 50 | let imageUrl: URL? 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /HealthyUITests/HealthyUITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | final class HealthyUITests: XCTestCase { 4 | 5 | override func setUpWithError() throws { 6 | // Put setup code here. This method is called before the invocation of each 7 | // test method in the class. 8 | 9 | // In UI tests it is usually best to stop immediately when a failure occurs. 10 | continueAfterFailure = false 11 | 12 | // In UI tests it’s important to set the initial state - 13 | // such as interface orientation - 14 | // required for your tests before they run. The setUp method is a good place to do this. 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each 19 | // test method in the class. 20 | } 21 | 22 | func testExample() throws { 23 | // UI tests must launch the application that they test. 24 | let app = XCUIApplication() 25 | app.launch() 26 | 27 | // Use XCTAssert and related functions to verify your tests produce the correct results. 28 | } 29 | 30 | func testLaunchPerformance() throws { 31 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 32 | // This measures how long it takes to launch your application. 33 | measure(metrics: [XCTApplicationLaunchMetric()]) { 34 | XCUIApplication().launch() 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Networking/Sources/Networking/Requests/RegisterRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - RegisterResponse 4 | public struct RegisterResponse: Codable { 5 | public let tokenId, email, refreshToken, expiresIn: String 6 | public let localID: String 7 | 8 | enum CodingKeys: String, CodingKey { 9 | case tokenId = "idToken" 10 | case email, refreshToken, expiresIn 11 | case localID = "localId" 12 | } 13 | } 14 | 15 | // MARK: - RegisterRequest 16 | public struct RegisterRequest: RequestType { 17 | public typealias ResponseType = RegisterResponse 18 | 19 | private let email: String 20 | private let password: String 21 | private let returnSecureToken: Bool 22 | 23 | public init(email: String, password: String, returnSecureToken: Bool = true) { 24 | self.email = email 25 | self.password = password 26 | self.returnSecureToken = returnSecureToken 27 | } 28 | 29 | public var baseUrl: URL { Constants.firebaseAuth } 30 | public var path: String { "/accounts:signInWithPassword?key=\(Constants.firebaseKey)" } 31 | public var method: String = "POST" 32 | public var queryParameters: [String: Any] { 33 | [ 34 | "email": email, 35 | "password": password, 36 | "returnSecureToken": returnSecureToken 37 | ] 38 | } 39 | 40 | public let responseDecoder: (Data) throws -> RegisterResponse = { data in 41 | try JSONDecoder().decode(ResponseType.self, from: data) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Healthy/Classes/ReusableViews/CheckboxButton.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Custom UIButton subclass that implements a tickbox feature. 4 | /// 5 | /// This class allows you to create a checkbox button by updating the image according to the checked state. 6 | /// 7 | /// Example: 8 | /// 9 | /// ```swift 10 | /// let checkBox = CheckboxButton() 11 | /// checkbox.isChecked = true // Sets the checkbox to a checked state 12 | /// ``` 13 | /// 14 | class CheckboxButton: UIButton { 15 | 16 | // MARK: - Properties 17 | 18 | /// A boolean property to indicate if the checkbox is checked or not. 19 | /// 20 | /// The image of the checkbox button is updated accordingly when the value of this property is set. 21 | /// If checkbox is checked `UIImage.iconCheckboxSelected` is shown 22 | /// otherwise `UIImage.iconCheckboxNotSelected` is shown instead 23 | /// 24 | var isChecked: Bool = false { 25 | didSet { 26 | let image = isChecked ? UIImage.iconCheckboxSelected : UIImage.iconCheckboxNotSelected 27 | self.setImage(image, for: UIControl.State.normal) 28 | } 29 | } 30 | 31 | // MARK: - Lifecycle 32 | 33 | override func awakeFromNib() { 34 | super.awakeFromNib() 35 | self.addTarget(self, action: #selector(buttonClicked(sender:)), for: .touchUpInside) 36 | self.isChecked = false 37 | } 38 | 39 | // MARK: - Actions 40 | 41 | /// Toggles the `isChecked` state when the button is clicked 42 | /// 43 | @objc private func buttonClicked(sender: UIButton) { 44 | isChecked.toggle() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Onboarding/OnboardingCoordinator.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Managing the onboarding flow when the user is not signed in. 4 | protocol OnboardingCoordinator: AnyObject { 5 | /// Called when the user acknowledge the starting info. 6 | func didStartCooking() 7 | /// Called when the user successfully logs in. 8 | func didFinishSignIn() 9 | } 10 | 11 | // MARK: DefaultOnboardingCoordinator 12 | 13 | final class DefaultOnboardingCoordinator: Coordinator { 14 | let navigationController: UINavigationController 15 | private let onAuthentication: () -> Void 16 | 17 | init(navigationController: UINavigationController, 18 | onAuthentication: @escaping () -> Void) { 19 | self.navigationController = navigationController 20 | self.onAuthentication = onAuthentication 21 | } 22 | 23 | func start() { 24 | let splashViewModel = SplashViewModel(coordinator: self) 25 | let splashViewController = SplashViewController(viewModel: splashViewModel) 26 | navigationController.setViewControllers([splashViewController], animated: false) 27 | } 28 | } 29 | 30 | // MARK: OnboardingCoordinator Conformance 31 | 32 | extension DefaultOnboardingCoordinator: OnboardingCoordinator { 33 | func didStartCooking() { 34 | let viewModel = LoginViewModel(coordinator: self) 35 | let viewController = LoginViewController(viewModel: viewModel) 36 | navigationController.pushViewController(viewController, animated: true) 37 | } 38 | 39 | func didFinishSignIn() { 40 | onAuthentication() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Onboarding/Splash/SplashViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class SplashViewController: UIViewController { 4 | 5 | // MARK: Outlets 6 | 7 | @IBOutlet private(set) weak var startCookingButton: UIButton! 8 | @IBOutlet private(set) weak var logoCaptionLabel: UILabel! 9 | @IBOutlet private(set) weak var headerTitleLabel: UILabel! 10 | @IBOutlet private(set) weak var headerCaptionLabel: UILabel! 11 | 12 | // MARK: Properties 13 | 14 | private let viewModel: SplashViewModelType 15 | 16 | // MARK: Init 17 | 18 | init(viewModel: SplashViewModelType) { 19 | self.viewModel = viewModel 20 | super.init(nibName: nil, bundle: nil) 21 | } 22 | 23 | @available(*, unavailable) 24 | required init?(coder: NSCoder) { 25 | fatalError("init(coder:) has not been implemented") 26 | } 27 | 28 | // MARK: Lifecycle 29 | 30 | override func viewDidLoad() { 31 | super.viewDidLoad() 32 | 33 | configureAppearance() 34 | } 35 | } 36 | 37 | // MARK: - Actions 38 | 39 | extension SplashViewController { 40 | @IBAction func didTapStartCooking(_ sender: Any) { 41 | viewModel.startCooking() 42 | } 43 | } 44 | // MARK: - Configurations 45 | 46 | private extension SplashViewController { 47 | func configureAppearance() { 48 | headerTitleLabel.applyInvertedTitleBoldStyle() 49 | logoCaptionLabel.applyInvertedBodyStyle() 50 | startCookingButton.applyButtonStyle(.primary) 51 | } 52 | } 53 | 54 | // MARK: - Private Handlers 55 | 56 | private extension SplashViewController {} 57 | -------------------------------------------------------------------------------- /Networking/Tests/NetworkingTests/Requests/RegisterRequestTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Networking 3 | 4 | final class RegisterRequestTests: XCTestCase { 5 | 6 | // MARK: - Tests 7 | 8 | func testRegisterRequestProperties() { 9 | // Given 10 | let email = "test@example.com" 11 | let password = "newPassword" 12 | let registerRequest = RegisterRequest(email: email, password: password) 13 | 14 | // Then 15 | XCTAssertEqual(registerRequest.baseUrl, Constants.firebaseAuth) 16 | XCTAssertEqual(registerRequest.path, "/accounts:signInWithPassword?key=\(Constants.firebaseKey)") 17 | XCTAssertEqual(registerRequest.method, "POST") 18 | } 19 | 20 | func testRegisterRequestResponseDecoder() throws { 21 | // Given 22 | let registerResponseAsString = """ 23 | { 24 | "idToken": "[ID_TOKEN]", 25 | "email": "test@example.com", 26 | "refreshToken": "[REFRESH_TOKEN]", 27 | "expiresIn": "3600", 28 | "localId": "tRcfmLH7..." 29 | } 30 | """ 31 | let registerRequest = RegisterRequest(email: "test@example.com", password: "newPassword") 32 | 33 | // When 34 | let registerResponseData = try XCTUnwrap(registerResponseAsString.data(using: .utf8)) 35 | let registerResponse = try? registerRequest.responseDecoder(registerResponseData) 36 | 37 | // Then 38 | XCTAssertNotNil(registerResponse) 39 | XCTAssertEqual(registerResponse?.email, "test@example.com") 40 | XCTAssertEqual(registerResponse?.expiresIn, "3600") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Search/Search/SearchViewController.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Networking/Tests/NetworkingTests/Dispatcher/LoginRequestTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Networking 3 | 4 | final class LoginRequestTests: XCTestCase { 5 | 6 | func testLoginRequestProperties() { 7 | // Given 8 | let email = "test@example.com" 9 | let password = "secretpassword" 10 | let loginRequest = LoginRequest(email: email, password: password) 11 | 12 | // Then 13 | XCTAssertEqual(loginRequest.baseUrl, Constants.mockyBaseUrl) 14 | XCTAssertEqual(loginRequest.path, "ba2feb33-cc78-4f94-908e-a85fb1a1d262") 15 | XCTAssertEqual(loginRequest.method, "GET") 16 | XCTAssertEqual(loginRequest.queryParameters, [ 17 | "email": email, 18 | "password": password 19 | ]) 20 | } 21 | 22 | func testLoginRequestResponseDecoder() throws { 23 | // Given 24 | let loginResponseAsString = """ 25 | { 26 | "access_token": "abc123", 27 | "expires_in": 3600, 28 | "refresh_token": "def456" 29 | } 30 | """ 31 | let loginRequest = LoginRequest(email: "test@example.com", password: "secretpassword") 32 | 33 | // When 34 | let loginResponseData = try XCTUnwrap(loginResponseAsString.data(using: .utf8)) 35 | let loginResponse = try? loginRequest.responseDecoder(loginResponseData) 36 | 37 | // Then 38 | XCTAssertNotNil(loginResponse) 39 | XCTAssertEqual(loginResponse?.accessToken, "abc123") 40 | XCTAssertEqual(loginResponse?.expiresIn, 3600) 41 | XCTAssertEqual(loginResponse?.refreshToken, "def456") 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Dashboard/Views/NewRecipesView/NewRecipesCollectionViewLayout.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class NewRecipesCollectionViewLayout: UICollectionViewCompositionalLayout { 4 | 5 | // Custom initializer to create the desired layout 6 | convenience init(itemWidthPercentage: CGFloat) { 7 | let layout = Self.createLayout(itemWidthPercentage: itemWidthPercentage) 8 | let configuration = UICollectionViewCompositionalLayoutConfiguration() 9 | configuration.scrollDirection = .horizontal 10 | self.init(section: layout, configuration: configuration) 11 | } 12 | 13 | private static func createLayout(itemWidthPercentage: CGFloat) -> NSCollectionLayoutSection { 14 | // Item 15 | let itemSize = NSCollectionLayoutSize( 16 | widthDimension: .fractionalWidth(1.0), 17 | heightDimension: .fractionalHeight(1.0)) 18 | let item = NSCollectionLayoutItem(layoutSize: itemSize) 19 | 20 | // Group 21 | let groupSize = NSCollectionLayoutSize( 22 | widthDimension: .fractionalWidth(itemWidthPercentage), 23 | heightDimension: .fractionalHeight(1.0)) 24 | let group = NSCollectionLayoutGroup.vertical( 25 | layoutSize: groupSize, 26 | subitems: [item] 27 | ) 28 | 29 | // Section 30 | let section = NSCollectionLayoutSection(group: group) 31 | section.contentInsets = NSDirectionalEdgeInsets(top: 8.0, leading: 8.0, bottom: 8.0, trailing: 8.0) 32 | section.interGroupSpacing = 20.0 33 | 34 | // Return the layout section 35 | return section 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Search/FilterSearch/FilterSearchViewController.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /HealthyTests/Classes/Utilities/Validators/EmailValidatorsTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Healthy 3 | 4 | final class EmailValidatorsTests: XCTestCase { 5 | 6 | // MARK: - Properties 7 | 8 | private var validator: EmailValidator! 9 | 10 | // MARK: - Lifecycle 11 | 12 | override func setUp() { 13 | super.setUp() 14 | validator = EmailValidator() 15 | } 16 | 17 | // MARK: - Tests 18 | 19 | func test_emailValidator_whenEmailIsValid_shouldPass() { 20 | // Given 21 | let validEmail = "calara23@gmail.com" 22 | 23 | // Then 24 | XCTAssertTrue(validator.hasValidValue(validEmail)) 25 | } 26 | 27 | func test_emailValidator_whenEmailIsInValid_shouldFail() { 28 | // Given 29 | let noAtSymbolEmail = "testexample.com" 30 | 31 | // Then 32 | XCTAssertFalse(validator.hasValidValue(noAtSymbolEmail)) 33 | } 34 | 35 | func test_emailValidator_whenEmailDoesNotHaveDomain_shouldFail() { 36 | // Given 37 | let noDomain = "test@" 38 | 39 | // Then 40 | XCTAssertFalse(validator.hasValidValue(noDomain)) 41 | } 42 | 43 | func test_emailValidator_whenEmailHasInValidDomain_shouldFail() { 44 | // Given 45 | let inValidDomain = "test@example.c" 46 | 47 | // Then 48 | XCTAssertFalse(validator.hasValidValue(inValidDomain)) 49 | } 50 | 51 | func test_emailValidator_whenEmailInValidDomain_shouldFail() { 52 | // Given 53 | let inValidDomain = "2345test@example.c" 54 | 55 | // Then 56 | XCTAssertFalse(validator.hasValidValue(inValidDomain)) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /HealthyTests/Classes/Utilities/Validators/PasswordValidatorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Healthy 3 | 4 | final class PasswordValidatorTests: XCTestCase { 5 | 6 | // MARK: - Properties 7 | 8 | private var validator: PasswordValidator! 9 | 10 | // MARK: - Lifecycle 11 | 12 | override func setUp() { 13 | super.setUp() 14 | validator = PasswordValidator() 15 | } 16 | 17 | // MARK: - Tests 18 | 19 | func test_PasswordValidator_whenHasValidPassword_shouldPass() { 20 | // Given 21 | let validPassword = "SecurePassword123" 22 | 23 | // Then 24 | XCTAssertTrue(validator.hasValidValue(validPassword)) 25 | } 26 | 27 | func test_PasswordValidator_whenHasShortPassword_shouldFail() { 28 | // Given 29 | let shortPassword = "Abc" 30 | 31 | // Then 32 | XCTAssertFalse(validator.hasValidValue(shortPassword)) 33 | } 34 | 35 | func test_PasswordValidator_whenHasLongPassword_shouldFail() { 36 | // Given 37 | let longPassword = "Abc123Abc123Abc123Abc123" 38 | 39 | // Then 40 | XCTAssertFalse(validator.hasValidValue(longPassword)) 41 | } 42 | 43 | func test_PasswordValidator_whenPasswordIsNumericOnly_shouldFail() { 44 | // Given 45 | let numericPassword = "123456789" 46 | 47 | // Then 48 | XCTAssertFalse(validator.hasValidValue(numericPassword)) 49 | } 50 | func test_PasswordValidator_whenPasswordHasLettersOnly_shouldFail() { 51 | // Given 52 | let lettersPassword = "abhjklol;k" 53 | 54 | // Then 55 | XCTAssertFalse(validator.hasValidValue(lettersPassword)) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Healthy/Classes/Utilities/Authentication/Login/GoogleLoginAuthenticator.swift: -------------------------------------------------------------------------------- 1 | import GoogleSignIn 2 | 3 | enum SignInAuthenticationError: Error { 4 | case invalidUser 5 | } 6 | 7 | final class GoogleSignInAuthenticator: Authentication { 8 | 9 | // MARK: Properties 10 | 11 | private let viewController: UIViewController 12 | private let sharedInstance: GIDSignIn = .sharedInstance 13 | 14 | // MARK: Init 15 | 16 | init(viewController: UIViewController) { 17 | self.viewController = viewController 18 | } 19 | 20 | // MARK: Authentication 21 | 22 | func performLogin() async throws -> AuthenticatedUser { 23 | try await withCheckedThrowingContinuation { continuation in 24 | sharedInstance.signIn(withPresenting: viewController) { result, error in 25 | guard error == nil, 26 | let result, 27 | let user = result.user.profile, 28 | let userID = result.user.userID else { 29 | continuation.resume(throwing: SignInAuthenticationError.invalidUser) 30 | return 31 | } 32 | 33 | let authenticatedUser = AuthenticatedUser( 34 | id: userID, name: user.name, 35 | email: user.email, 36 | imageURL: user.imageURL(withDimension: Constants.imageDimensions) 37 | ) 38 | 39 | continuation.resume(returning: authenticatedUser) 40 | } 41 | } 42 | } 43 | } 44 | 45 | // MARK: Constants 46 | 47 | private extension GoogleSignInAuthenticator { 48 | enum Constants { 49 | static let imageDimensions: UInt = 320 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Healthy/Classes/Extensions/UIButton+Style.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | // MARK: - Button style 4 | // 5 | extension UIButton { 6 | enum ButtonStyle { 7 | case primary 8 | case secondary 9 | } 10 | } 11 | 12 | // MARK: - Apply button style 13 | // 14 | extension UIButton { 15 | func applyButtonStyle(_ style: ButtonStyle) { 16 | backgroundColor = style.backgroundColor 17 | titleLabel?.font = style.buttonFont 18 | tintColor = style.textColor 19 | layer.cornerRadius = style.cornerRadius 20 | layer.masksToBounds = true 21 | let heightConstraint = heightAnchor.constraint(equalToConstant: style.defaultHeight) 22 | heightConstraint.priority = .defaultHigh 23 | heightConstraint.isActive = true 24 | } 25 | } 26 | 27 | // MARK: Button Style Configurations 28 | // 29 | private extension UIButton.ButtonStyle { 30 | var backgroundColor: UIColor? { 31 | switch self { 32 | case .primary: return .primary100 33 | case .secondary: return .white 34 | } 35 | } 36 | 37 | var textColor: UIColor? { 38 | switch self { 39 | case .primary: return .white 40 | case .secondary: return .secondary100 41 | } 42 | } 43 | 44 | var buttonFont: UIFont? { 45 | switch self { 46 | case .primary: return .mediumBold 47 | case .secondary: return .mediumBold 48 | } 49 | } 50 | 51 | var defaultHeight: CGFloat { 52 | switch self { 53 | case .primary, .secondary: 54 | return 40.0 55 | } 56 | } 57 | 58 | var cornerRadius: CGFloat { 59 | switch self { 60 | case .primary, .secondary: 61 | return 12.0 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Networking/Tests/NetworkingTests/Requests/ACIListRequestsTests/IngredientsListRequestTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Networking 3 | 4 | final class IngrediantsListRequestTests: XCTestCase { 5 | 6 | // MARK: Properties 7 | 8 | private var sut: IngrediantsListRequest! 9 | 10 | // MARK: Life cycle 11 | 12 | override func setUp() { 13 | sut = IngrediantsListRequest() 14 | } 15 | 16 | // MARK: Tests 17 | 18 | func testCategoriesListRequestProperties() { 19 | // Then 20 | XCTAssertEqual(sut.baseUrl, Constants.theMealDB) 21 | XCTAssertEqual(sut.path, "list.php") 22 | XCTAssertEqual(sut.method, "GET") 23 | XCTAssertEqual(sut.queryParameters, ["i": "list"]) 24 | } 25 | 26 | func testAreaListRequestResponseDecoder() throws { 27 | // Given 28 | let ingrediantsListResponseAsString = """ 29 | {"meals": 30 | [{"idIngredient":"204", 31 | "strIngredient":"Macaroni", 32 | "strDescription":null, 33 | "strType":null}] 34 | } 35 | """ 36 | 37 | // When 38 | let ingrediantsListResponseData = try XCTUnwrap(ingrediantsListResponseAsString.data(using: .utf8)) 39 | let ingrediantsListResponse = try? sut.responseDecoder(ingrediantsListResponseData) 40 | 41 | // Then 42 | XCTAssertNotNil(ingrediantsListResponse) 43 | XCTAssertEqual(ingrediantsListResponse?.meals.count, 1) 44 | XCTAssertEqual(ingrediantsListResponse?.meals[0].id, "204") 45 | XCTAssertEqual(ingrediantsListResponse?.meals[0].ingrediant, "Macaroni") 46 | XCTAssertEqual(ingrediantsListResponse?.meals[0].description, nil) 47 | XCTAssertEqual(ingrediantsListResponse?.meals[0].type, nil) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Networking/Tests/NetworkingTests/Requests/RandomMealsRequestTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Networking 3 | 4 | final class RandomMealsRequestTests: XCTestCase { 5 | 6 | // MARK: Properties 7 | 8 | private var sut: RandomMealsRequest! 9 | 10 | // MARK: - Lifecycle 11 | 12 | override func setUp() { 13 | sut = RandomMealsRequest() 14 | } 15 | 16 | // MARK: - Tests 17 | 18 | func testRandomMealsRequestProperties() { 19 | // Then 20 | XCTAssertEqual(sut.baseUrl, Constants.theMealDB) 21 | XCTAssertEqual(sut.path, "random.php") 22 | XCTAssertEqual(sut.method, "GET") 23 | } 24 | 25 | func testRandomMealsResponseDecoder() throws { 26 | // Given 27 | let randomMealsResponseAsString = """ 28 | { 29 | "meals": [ 30 | { 31 | "idMeal": "52793", 32 | "strMeal": "Sticky Toffee Pudding Ultimate", 33 | "strCategory": "Dessert", 34 | "strArea": "British", 35 | "strMealThumb": "https://www.themealdb.com/images/media/meals/xrptpq1483909204.jpg", 36 | "strTags": "Pudding,Desert,Cake,Dairy", 37 | "strYoutube": "https://www.youtube.com/watch?v=hKq6RbxJHBo", 38 | } 39 | ] 40 | } 41 | """ 42 | 43 | // When 44 | let randomMealsResponseData = try XCTUnwrap(randomMealsResponseAsString.data(using: .utf8)) 45 | let randomMealsResponse = try? sut.responseDecoder(randomMealsResponseData) 46 | 47 | // Then 48 | XCTAssertNotNil(randomMealsResponse) 49 | XCTAssertEqual(randomMealsResponse?.meals.count, 1) 50 | XCTAssertEqual(randomMealsResponse?.meals[0].id, "52793") 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /Networking/Tests/NetworkingTests/Requests/FilterByAreaRequestTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Networking 3 | 4 | final class FilterByAreaRequestTests: XCTestCase { 5 | 6 | // MARK: Properties 7 | 8 | private var sut: FilterByAreaRequest! 9 | 10 | // MARK: Life cycle 11 | 12 | override func setUp() { 13 | sut = FilterByAreaRequest("Canadian") 14 | } 15 | 16 | // MARK: Tests 17 | 18 | func testFilterByAreaRequestProperties() { 19 | // Then 20 | XCTAssertEqual(sut.baseUrl, Constants.theMealDB) 21 | XCTAssertEqual(sut.path, "filter.php") 22 | XCTAssertEqual(sut.method, "GET") 23 | XCTAssertEqual(sut.queryParameters, ["a": "Canadian"]) 24 | } 25 | 26 | func testFilterByAreaRequestResponseDecoder() throws { 27 | // Given 28 | let filterByAreaResponseAsString = #""" 29 | {"meals": 30 | [{"strMeal":"BeaverTails", 31 | "strMealThumb":"https:\/\/www.themealdb.com\/images\/media\/meals\/ryppsv1511815505.jpg", 32 | "idMeal":"52928"}] 33 | } 34 | """# 35 | 36 | // When 37 | let filterByAreaResponseData = try XCTUnwrap(filterByAreaResponseAsString.data(using: .utf8)) 38 | let filterByAreaResponse = try? sut.responseDecoder(filterByAreaResponseData) 39 | 40 | // Then 41 | XCTAssertNotNil(filterByAreaResponse) 42 | XCTAssertEqual(filterByAreaResponse?.meals.count, 1) 43 | XCTAssertEqual(filterByAreaResponse?.meals[0].id, "52928") 44 | XCTAssertEqual(filterByAreaResponse?.meals[0].meal, "BeaverTails") 45 | XCTAssertEqual(filterByAreaResponse?.meals[0].mealThumb, 46 | "https://www.themealdb.com/images/media/meals/ryppsv1511815505.jpg") 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Dashboard/Views/SliderDishesView/SliderCollectionViewCell/SliderCollectionViewCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class SliderCollectionViewCell: UICollectionViewCell { 4 | 5 | // MARK: - Outlet 6 | 7 | @IBOutlet private (set) weak var dishImageView: UIImageView! 8 | @IBOutlet private (set) weak var dishName: UILabel! 9 | @IBOutlet private (set) weak var timeDuration: UILabel! 10 | @IBOutlet private (set) weak var dishView: UIView! 11 | @IBOutlet private (set) weak var rateView: UIView! 12 | @IBOutlet private (set) weak var rateLabel: UILabel! 13 | @IBOutlet private (set) weak var unionView: UIView! 14 | @IBOutlet private (set) weak var timeLabel: UILabel! 15 | 16 | // MARK: - Propperties 17 | 18 | // MARK: - Lifecycle 19 | 20 | override func awakeFromNib() { 21 | super.awakeFromNib() 22 | // Initialization code 23 | configureView() 24 | } 25 | 26 | // MARK: - Configurations 27 | 28 | private func configureView() { 29 | dishImageView.roundView() 30 | unionView.roundView() 31 | dishView.setRadius(radius: 12) 32 | rateView.setRadius(radius: 10) 33 | rateLabel.applyCaptionStyle() 34 | timeDuration.applyCaptionStyle() 35 | } 36 | 37 | // MARK: Configure with ViewModel 38 | 39 | func configure(with viewModel: ViewModel) { 40 | dishName.text = viewModel.dishName 41 | timeDuration.text = viewModel.duration 42 | // ... 43 | } 44 | } 45 | 46 | // MARK: ViewModel 47 | 48 | extension SliderCollectionViewCell { 49 | struct ViewModel { 50 | let imageUrl: UIImage? 51 | let dishName: String 52 | let time: String 53 | let duration: String 54 | let rating: Double 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/SavedRecipes/SavedRecipesViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import UIKit 3 | 4 | // MARK: SavedRecipesViewModel 5 | 6 | final class SavedRecipesViewModel { 7 | 8 | // MARK: - Properties 9 | 10 | @Published private (set) var savedRecipes: [SavedRecipesTableViewCell.ViewModel] = [ 11 | SavedRecipesTableViewCell.ViewModel(title: "Traditional spare ribs baked ", 12 | recipeImage: UIImage.iconFood, 13 | rating: 4.5, 14 | chefName: "By Chef John", 15 | cookingTime: 15, toggleBookmark: {}), 16 | SavedRecipesTableViewCell.ViewModel(title: "spice roasted chicken with flavored rice", 17 | recipeImage: UIImage.iconFood, 18 | rating: 5.0, 19 | chefName: "By Mark Kelvin", 20 | cookingTime: 20, toggleBookmark: {}) 21 | ] 22 | } 23 | 24 | // MARK: SavedRecipesViewModel 25 | 26 | extension SavedRecipesViewModel: SavedRecipesViewModelInput { 27 | func removeSavedRecipe(_ recipe: SavedRecipesTableViewCell.ViewModel) { 28 | if let index = savedRecipes.firstIndex(of: recipe) { 29 | savedRecipes.remove(at: index) 30 | } 31 | } 32 | } 33 | 34 | // MARK: SavedRecipesViewModelOutput 35 | 36 | extension SavedRecipesViewModel: SavedRecipesViewModelOutput { 37 | var recipesPublisher: any Publisher<[SavedRecipesTableViewCell.ViewModel], Never> { 38 | $savedRecipes.eraseToAnyPublisher() 39 | } 40 | } 41 | 42 | // MARK: Private Handlers 43 | 44 | private extension SavedRecipesViewModel {} 45 | -------------------------------------------------------------------------------- /Networking/Tests/NetworkingTests/Requests/MealCategoriesRequestTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Networking 3 | 4 | final class MealCategoriesRequestTests: XCTestCase { 5 | 6 | // MARK: Properties 7 | 8 | private var sut: MealCategoriesRequest! 9 | 10 | // MARK: - Lifecycle 11 | 12 | override func setUp() { 13 | sut = MealCategoriesRequest() 14 | } 15 | 16 | // MARK: - Tests 17 | 18 | func testMealCategoriesRequestProperties() { 19 | // Then 20 | XCTAssertEqual(sut.baseUrl, Constants.theMealDB) 21 | XCTAssertEqual(sut.path, "categories.php") 22 | XCTAssertEqual(sut.method, "GET") 23 | } 24 | 25 | func testMealCategoriesResponseDecoder() throws { 26 | // Given 27 | let mealCategoriesResponseAsString = """ 28 | { 29 | "categories": [ 30 | { 31 | "idCategory": "1", 32 | "strCategory": "Beef", 33 | "strCategoryThumb": "https://www.themealdb.com/images/category/beef.png", 34 | "strCategoryDescription": "Beef is the culinary name for meat from cattle, \ 35 | particularly skeletal muscle. Humans have been eating beef since prehistoric times.[1] \ 36 | Beef is a source of high-quality protein and essential nutrients.[2]" 37 | } 38 | ] 39 | } 40 | """ 41 | 42 | // When 43 | let mealCategoriesResponseData = try XCTUnwrap(mealCategoriesResponseAsString.data(using: .utf8)) 44 | let mealCategoriesResponse = try? sut.responseDecoder(mealCategoriesResponseData) 45 | 46 | // Then 47 | XCTAssertNotNil(mealCategoriesResponse) 48 | XCTAssertEqual(mealCategoriesResponse?.categories.count, 1) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Dashboard/DashboardViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | // MARK: DashboardViewModel 5 | 6 | final class DashboardViewModel { 7 | @Published private var newRecipes: [NewRecipeViewModel] = [] 8 | 9 | init() { 10 | loadPlaceholderNewRecipes() 11 | } 12 | } 13 | 14 | // MARK: DashboardViewModel 15 | 16 | extension DashboardViewModel: DashboardViewModelInput {} 17 | 18 | // MARK: DashboardViewModelOutput 19 | 20 | extension DashboardViewModel: DashboardViewModelOutput { 21 | var header: HomeHeaderView.ViewModel { 22 | .init(title: "", subtitle: "", imageUrl: URL(string: "www.google.com")) 23 | } 24 | 25 | var newRecipesPublisher: any Publisher<[NewRecipeViewModel], Never> { 26 | $newRecipes 27 | } 28 | } 29 | 30 | // MARK: Private Handlers 31 | 32 | private extension DashboardViewModel { 33 | private func loadPlaceholderNewRecipes() { 34 | newRecipes = [ 35 | NewRecipeViewModel( 36 | recipeName: "", 37 | userName: "", 38 | preparationTimeInMinutes: "", 39 | recipeImageUrl: "", 40 | rating: .zero, 41 | userImageUrl: nil 42 | ), 43 | NewRecipeViewModel( 44 | recipeName: "", 45 | userName: "", 46 | preparationTimeInMinutes: "", 47 | recipeImageUrl: "", 48 | rating: .zero, 49 | userImageUrl: nil 50 | ), 51 | NewRecipeViewModel( 52 | recipeName: "", 53 | userName: "", 54 | preparationTimeInMinutes: "", 55 | recipeImageUrl: "", 56 | rating: .zero, 57 | userImageUrl: nil 58 | ) 59 | ] 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /HealthyTests/Classes/Modules/Onboarding/Login/Mocks/LoginViewModelMock.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | @testable import Healthy 3 | 4 | final class LoginViewModelMock: LoginViewModelType { 5 | private let errorSubject = PassthroughSubject() 6 | 7 | var errorPublisher: AnyPublisher { 8 | errorSubject.eraseToAnyPublisher() 9 | } 10 | 11 | var isLoadingIndicatorPublisher: AnyPublisher { 12 | Just(false).eraseToAnyPublisher() 13 | } 14 | 15 | var isLoginEnabledPublisher: AnyPublisher { 16 | Just(false).eraseToAnyPublisher() 17 | } 18 | 19 | var isLoginStatusPublisher: AnyPublisher { 20 | Just(false).eraseToAnyPublisher() 21 | } 22 | 23 | func updateEmail(_ text: String) {} 24 | 25 | func updatePassword(_ text: String) {} 26 | 27 | func onLoadingIndicator(state: @escaping (Bool) -> Void) {} 28 | 29 | func onErrorMessage(message: @escaping (String) -> Void) {} 30 | 31 | func onButtonEnabled(onEnabled: @escaping (Bool) -> Void) {} 32 | 33 | func onLoginStatus(status: @escaping (Bool) -> Void) {} 34 | 35 | // Forget Password Spy 36 | private(set) var performForgetPasswordCount: Int = .zero 37 | func performForgetPassword() { 38 | performForgetPasswordCount += 1 39 | } 40 | 41 | // Sign-in Spy 42 | private(set) var performSignInCallCount: Int = .zero 43 | func performSignIn() { 44 | performSignInCallCount += 1 45 | } 46 | 47 | // Sign-up Spy 48 | private(set) var performSignUpCallCount: Int = .zero 49 | func performSignUp() { 50 | performSignUpCallCount += 1 51 | } 52 | 53 | // Sign-with social spy 54 | private(set) var performSocialMediaSignInCount: Int = .zero 55 | func performSocialMediaSignIn(_ authentication: Healthy.Authentication) { 56 | performSocialMediaSignInCount += 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Networking/Tests/NetworkingTests/Requests/MealSearchRequestTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Networking 3 | 4 | final class MealSearchRequestTests: XCTestCase { 5 | 6 | // MARK: Properties 7 | 8 | private var sut: MealSearchRequest! 9 | private var searchKey: String! 10 | // MARK: - Lifecycle 11 | 12 | override func setUp() { 13 | searchKey = "Arrabiata" 14 | sut = MealSearchRequest(searchKey) 15 | } 16 | 17 | // MARK: - Tests 18 | 19 | func testRandomMealsRequestProperties() { 20 | // Then 21 | XCTAssertEqual(sut.baseUrl, Constants.theMealDB) 22 | XCTAssertEqual(sut.path, "search.php") 23 | XCTAssertEqual(sut.method, "GET") 24 | XCTAssertEqual(sut.queryParameters, ["s": searchKey]) 25 | } 26 | 27 | func testRandomMealsResponseDecoder() throws { 28 | // Given 29 | let mealSearchResponseAsString = """ 30 | { 31 | "meals": [ 32 | { 33 | "idMeal": "52771", 34 | "strMeal": "Spicy Arrabiata Penne", 35 | "strDrinkAlternate": null, 36 | "strCategory": "Vegetarian", 37 | "strArea": "Italian", 38 | "strMealThumb": "https://www.themealdb.com/images/media/meals/ustsqw1468250014.jpg", 39 | "strTags": "Pasta,Curry", 40 | "strYoutube": "https://www.youtube.com/watch?v=1IszT_guI08", 41 | } 42 | ] 43 | } 44 | """ 45 | 46 | // When 47 | let mealSearchResponseData = try XCTUnwrap(mealSearchResponseAsString.data(using: .utf8)) 48 | let mealSearchResponse = try? sut.responseDecoder(mealSearchResponseData) 49 | 50 | // Then 51 | XCTAssertNotNil(mealSearchResponse) 52 | XCTAssertEqual(mealSearchResponse?.meals.count, 1) 53 | XCTAssertEqual(mealSearchResponse?.meals[0].id, "52771") 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Healthy 2 | 3 | Motoon Mentorship Program Team Educational Project 4 | 5 | Cover Photo 6 | 7 | ## Links 🔗 8 | 9 | - For comprehensive conceptual details, advanced topics, and tutorials, please refer to our [Wiki](https://github.com/motoon-eg/healthy/wiki) 10 | 11 | - [Edamam Recipe Search API Documentation](https://developer.edamam.com/edamam-docs-recipe-api): Edamam Recipe Search API allows you to search through millions of web recipes and integrate this information into your mobile or web applications. 12 | 13 | - [Design on Figma]() 14 | 15 | ## DI Container 💉 16 | **DI container** is like a central hub that stores and manages the dependencies. 17 | 18 | We are using [**Factory Package**](https://github.com/hmlongco/Factory) as a Dependency Injection Cantainer. 19 | 20 | ### How to use it: 21 | Here's a simple dependency registration that returns a service that conforms to `MyServiceType`. 22 | 23 | ```swift 24 | extension Container { 25 | var myService: Factory { 26 | Factory(self) { MyService() } 27 | } 28 | } 29 | ``` 30 | 31 | Injecting an instance of our service is straightforward, you can do it in two ways: 32 | 33 | **1** 34 | 35 | ```swift 36 | class ContentViewModel: ObservableObject { 37 | @Injected(\.myService) private var myService 38 | ... 39 | } 40 | ``` 41 | **2** 42 | ```swift 43 | class ContentViewModel: ObservableObject { 44 | private let myService = Container.shared.myService() 45 | ... 46 | } 47 | ``` 48 | 49 | ## Contributors 💫 50 | 51 | Original [design](https://www.figma.com/community/file/1160186880726418317) was created by. 52 | Illiyin Studio. You can reach them on [Figma](https://www.figma.com/@illiyinstudio) • [Dribble](dribbble.com/illiyinstudio) • [Instagram](https://www.instagram.com/illiyinstudio) 53 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Search/Search/Cell/SearchCollectionViewCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class SearchCollectionViewCell: UICollectionViewCell { 4 | 5 | // MARK: Outlets 6 | 7 | @IBOutlet private(set) weak var backImage: UIImageView! 8 | @IBOutlet private(set) weak var ratingNumber: UILabel! 9 | @IBOutlet private(set) weak var recipeName: UILabel! 10 | @IBOutlet private(set) weak var chefName: UILabel! 11 | @IBOutlet private(set) weak var ratingView: UIStackView! 12 | 13 | // MARK: Set up layout 14 | 15 | override func awakeFromNib() { 16 | super.awakeFromNib() 17 | setupGradientLayer() 18 | configureLayout() 19 | } 20 | } 21 | 22 | // MARK: Configuration 23 | 24 | extension SearchCollectionViewCell { 25 | private func configureLayout() { 26 | ratingView.layer.cornerRadius = ratingView.frame.height / 2 27 | backImage.layer.cornerRadius = 10 28 | backImage.layer.masksToBounds = true 29 | contentView.layer.cornerRadius = 12 30 | } 31 | 32 | private func setupGradientLayer() { 33 | let gradientLayer = CAGradientLayer() 34 | gradientLayer.colors = [UIColor.clear.cgColor, UIColor.black.cgColor] 35 | gradientLayer.locations = [0.2, 1] 36 | gradientLayer.frame = backImage.frame 37 | backImage.layer.addSublayer(gradientLayer) 38 | } 39 | } 40 | 41 | // MARK: - ViewModel configurations 42 | 43 | extension SearchCollectionViewCell { 44 | func configureCell(with viewModel: ViewModel) { 45 | // TODO: - [HT-62] Waiting for adding kingfisher To load image and set it to backImage. 46 | recipeName.text = viewModel.recipeName 47 | chefName.text = "By \(viewModel.chefName)" 48 | ratingNumber.text = "\(viewModel.ratingNumber)" 49 | } 50 | } 51 | 52 | // MARK: ViewModel 53 | 54 | extension SearchCollectionViewCell { 55 | struct ViewModel { 56 | let recipeName: String 57 | let chefName: String 58 | let ratingNumber: Float 59 | let imageUrl: URL? 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /HealthyTests/Classes/Modules/Onboarding/CreateAccount/CreateAccountViewControllerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Healthy 3 | 4 | final class CreateAccountViewControllerTests: XCTestCase { 5 | 6 | // MARK: - Properties 7 | var sut: CreateAccountViewController! 8 | var viewModel: CreateAccountViewModelMock! 9 | 10 | // MARK: - Setup and Teardown 11 | override func setUp() { 12 | super.setUp() 13 | viewModel = CreateAccountViewModelMock() 14 | sut = CreateAccountViewController(viewModel: viewModel) 15 | sut.loadViewIfNeeded() 16 | } 17 | 18 | override func tearDown() { 19 | sut = nil 20 | viewModel = nil 21 | super.tearDown() 22 | } 23 | 24 | // MARK: - Tests 25 | func testOutletsConnected() { 26 | XCTAssertNotNil(sut.parentVerticalStackView) 27 | XCTAssertNotNil(sut.nameTextField) 28 | XCTAssertNotNil(sut.emailTextField) 29 | XCTAssertNotNil(sut.passwordTextField) 30 | XCTAssertNotNil(sut.confirmPasswordContainerView) 31 | XCTAssertNotNil(sut.confirmPasswordTextField) 32 | XCTAssertNotNil(sut.termsAndConditionsHorizontalStackView) 33 | XCTAssertNotNil(sut.checkBoxButton) 34 | XCTAssertNotNil(sut.acceptTermsTextLabel) 35 | XCTAssertNotNil(sut.signInTextLabel) 36 | XCTAssertNotNil(sut.signUpButton) 37 | } 38 | 39 | func testTextFieldEditingChanged() { 40 | sut.nameTextField.sendActions(for: .editingChanged) 41 | XCTAssertEqual(viewModel.updateUsernameCallCount, 1) 42 | 43 | sut.emailTextField.sendActions(for: .editingChanged) 44 | XCTAssertEqual(viewModel.updateEmailCallCount, 1) 45 | 46 | sut.passwordTextField.sendActions(for: .editingChanged) 47 | XCTAssertEqual(viewModel.updatePasswordCallCount, 1) 48 | 49 | sut.confirmPasswordTextField.sendActions(for: .editingChanged) 50 | XCTAssertEqual(viewModel.updateConfirmPasswordCallCount, 1) 51 | } 52 | 53 | func testSignUpButtonTouchUpInside() { 54 | sut.signUpButton.sendActions(for: .touchUpInside) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Vendors/SwiftGen/bin/SwiftGen_SwiftGenCLI.bundle/Contents/Resources/templates/colors/literals-swift4.stencil: -------------------------------------------------------------------------------- 1 | // swiftlint:disable all 2 | // Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen 3 | 4 | {% if palettes %} 5 | {% set enumName %}{{param.enumName|default:"ColorName"}}{% endset %} 6 | {% set accessModifier %}{% if param.publicAccess %}public{% else %}internal{% endif %}{% endset %} 7 | #if os(macOS) 8 | import AppKit 9 | {% if enumName != 'NSColor' %} 10 | {{accessModifier}} enum {{enumName}} { } 11 | {% endif %} 12 | #elseif os(iOS) || os(tvOS) || os(watchOS) 13 | import UIKit 14 | {% if enumName != 'UIColor' %} 15 | {{accessModifier}} enum {{enumName}} { } 16 | {% endif %} 17 | #endif 18 | 19 | // swiftlint:disable superfluous_disable_command 20 | // swiftlint:disable file_length 21 | 22 | // MARK: - Colors 23 | 24 | // swiftlint:disable identifier_name line_length type_body_length 25 | {{accessModifier}} extension {{enumName}} { 26 | {% macro h2f hex %}{{hex|hexToInt|int255toFloat}}{% endmacro %} 27 | {% macro enumBlock colors accessPrefix %} 28 | {% for color in colors %} 29 | /// 0x{{color.red}}{{color.green}}{{color.blue}}{{color.alpha}} (r: {{color.red|hexToInt}}, g: {{color.green|hexToInt}}, b: {{color.blue|hexToInt}}, a: {{color.alpha|hexToInt}}) 30 | {{accessPrefix}}static let {{color.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}} = #colorLiteral(red: {%+ call h2f color.red %}, green: {%+ call h2f color.green %}, blue: {%+ call h2f color.blue %}, alpha: {%+ call h2f color.alpha %}) 31 | {% endfor %} 32 | {% endmacro %} 33 | {% if palettes.count > 1 or param.forceFileNameEnum %} 34 | {% set accessPrefix %}{{accessModifier}} {%+ endset %} 35 | {% for palette in palettes %} 36 | enum {{palette.name|swiftIdentifier:"pretty"|escapeReservedKeywords}} { 37 | {% filter indent:2," ",true %}{% call enumBlock palette.colors accessPrefix %}{% endfilter %} 38 | } 39 | {% endfor %} 40 | {% else %} 41 | {% call enumBlock palettes.first.colors "" %} 42 | {% endif %} 43 | } 44 | // swiftlint:enable identifier_name line_length type_body_length 45 | {% else %} 46 | // No color found 47 | {% endif %} 48 | -------------------------------------------------------------------------------- /Vendors/SwiftGen/bin/SwiftGen_SwiftGenCLI.bundle/Contents/Resources/templates/colors/literals-swift5.stencil: -------------------------------------------------------------------------------- 1 | // swiftlint:disable all 2 | // Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen 3 | 4 | {% if palettes %} 5 | {% set enumName %}{{param.enumName|default:"ColorName"}}{% endset %} 6 | {% set accessModifier %}{% if param.publicAccess %}public{% else %}internal{% endif %}{% endset %} 7 | #if os(macOS) 8 | import AppKit 9 | {% if enumName != 'NSColor' %} 10 | {{accessModifier}} enum {{enumName}} { } 11 | {% endif %} 12 | #elseif os(iOS) || os(tvOS) || os(watchOS) 13 | import UIKit 14 | {% if enumName != 'UIColor' %} 15 | {{accessModifier}} enum {{enumName}} { } 16 | {% endif %} 17 | #endif 18 | 19 | // swiftlint:disable superfluous_disable_command 20 | // swiftlint:disable file_length 21 | 22 | // MARK: - Colors 23 | 24 | // swiftlint:disable identifier_name line_length type_body_length 25 | {{accessModifier}} extension {{enumName}} { 26 | {% macro h2f hex %}{{hex|hexToInt|int255toFloat}}{% endmacro %} 27 | {% macro enumBlock colors accessPrefix %} 28 | {% for color in colors %} 29 | /// 0x{{color.red}}{{color.green}}{{color.blue}}{{color.alpha}} (r: {{color.red|hexToInt}}, g: {{color.green|hexToInt}}, b: {{color.blue|hexToInt}}, a: {{color.alpha|hexToInt}}) 30 | {{accessPrefix}}static let {{color.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}} = #colorLiteral(red: {%+ call h2f color.red %}, green: {%+ call h2f color.green %}, blue: {%+ call h2f color.blue %}, alpha: {%+ call h2f color.alpha %}) 31 | {% endfor %} 32 | {% endmacro %} 33 | {% if palettes.count > 1 or param.forceFileNameEnum %} 34 | {% set accessPrefix %}{{accessModifier}} {%+ endset %} 35 | {% for palette in palettes %} 36 | enum {{palette.name|swiftIdentifier:"pretty"|escapeReservedKeywords}} { 37 | {% filter indent:2," ",true %}{% call enumBlock palette.colors accessPrefix %}{% endfilter %} 38 | } 39 | {% endfor %} 40 | {% else %} 41 | {% call enumBlock palettes.first.colors "" %} 42 | {% endif %} 43 | } 44 | // swiftlint:enable identifier_name line_length type_body_length 45 | {% else %} 46 | // No color found 47 | {% endif %} 48 | -------------------------------------------------------------------------------- /Healthy/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleURLTypes 6 | 7 | 8 | CFBundleURLSchemes 9 | 10 | fb803528534332452 11 | 12 | 13 | 14 | CFBundleTypeRole 15 | Editor 16 | CFBundleURLName 17 | 18 | CFBundleURLSchemes 19 | 20 | 21 | 22 | FacebookAppID 23 | 803528534332452 24 | FacebookClientToken 25 | 4cb2b9e5260f24ef6fb62356ec043deb 26 | FacebookDisplayName 27 | Healthy Name 28 | LSApplicationQueriesSchemes 29 | 30 | fbapi 31 | fb-messenger-share-api 32 | 33 | UIAppFonts 34 | 35 | Poppins-Regular.ttf 36 | Poppins-Bold.ttf 37 | 38 | UIApplicationSceneManifest 39 | 40 | UIApplicationSupportsMultipleScenes 41 | 42 | UISceneConfigurations 43 | 44 | UIWindowSceneSessionRoleApplication 45 | 46 | 47 | UISceneConfigurationName 48 | Default Configuration 49 | UISceneDelegateClassName 50 | $(PRODUCT_MODULE_NAME).SceneDelegate 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/Onboarding/CreateAccount/CreateAccountViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: CreateAccountViewModel 4 | final class CreateAccountViewModel { 5 | private var username: String = "" 6 | private var email: String = "" 7 | private var password: String = "" 8 | private var confirmPassword: String = "" 9 | private var isChecked: Bool = false 10 | private var onButtonEnabled: (Bool) -> Void = { _ in } 11 | } 12 | 13 | // MARK: CreateAccountViewModelInput 14 | extension CreateAccountViewModel: CreateAccountViewModelInput { 15 | func updateUsername(_ text: String) { 16 | username = text 17 | updateEnabledStateButton() 18 | } 19 | 20 | func updateEmail(_ text: String) { 21 | email = text 22 | updateEnabledStateButton() 23 | } 24 | 25 | func updatePassword(_ text: String) { 26 | password = text 27 | updateEnabledStateButton() 28 | } 29 | 30 | func updateConfirmPassword(_ text: String) { 31 | confirmPassword = text 32 | updateEnabledStateButton() 33 | } 34 | 35 | func updateAcceptTermsAndConditions(_ isChecked: Bool) { 36 | self.isChecked = isChecked 37 | updateEnabledStateButton() 38 | } 39 | } 40 | 41 | // MARK: LoginViewModelOutput 42 | extension CreateAccountViewModel: CreateAccountViewModelOutput { 43 | func configureButtonEnabled(onEnabled: @escaping (Bool) -> Void) { 44 | onButtonEnabled = onEnabled 45 | updateEnabledStateButton() 46 | } 47 | } 48 | 49 | // MARK: Private Handlers 50 | private extension CreateAccountViewModel { 51 | func updateEnabledStateButton() { 52 | let isUsernameValid = !username.isEmpty 53 | // TODO: [HL-20] Add email validator 54 | let isEmailValid = !email.isEmpty 55 | let isPasswordValid = !password.isEmpty && PasswordValidator().hasValidValue(password) 56 | let isConfirmPasswordValid = !confirmPassword.isEmpty && confirmPassword == password 57 | 58 | let isButtonEnabled = isUsernameValid && isEmailValid && isPasswordValid 59 | && isConfirmPasswordValid 60 | && isChecked 61 | onButtonEnabled(isButtonEnabled) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Healthy/Classes/Extensions/UIButton+AnimatableView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIButton: AnimatableView { 4 | 5 | // MARK: - AnimatableView Conformance 6 | 7 | func startAnimating() { 8 | isEnabled = false 9 | 10 | // To avoid a problem that appear when setting the label alpha to 0 after disabling button 11 | // as when disabling it, it sets the configurations of disabled state 12 | // so it's not set to 0 alpha 13 | setTitleColor(.clear, for: .disabled) 14 | 15 | titleLabel?.alpha = 0 16 | if activityIndicator == nil { 17 | let activityIndicator = createActivityIndicator() 18 | addSubview(activityIndicator) 19 | centerInButton(activityIndicator) 20 | } 21 | activityIndicator?.startAnimating() 22 | } 23 | 24 | func stopAnimating() { 25 | 26 | // To get the color and alpha of titleLabel back to its original state 27 | // after re-enbaling the button after animating activityIndicator 28 | isEnabled = true 29 | setTitleColor(titleLabel?.textColor.withAlphaComponent(0.5), for: .disabled) 30 | 31 | titleLabel?.alpha = 1 32 | activityIndicator?.stopAnimating() 33 | activityIndicator?.removeFromSuperview() 34 | } 35 | 36 | // MARK: - AnimatableView Private Helpers 37 | 38 | private var activityIndicator: UIActivityIndicatorView? { 39 | subviews 40 | .filter { $0 is UIActivityIndicatorView } 41 | .first as? UIActivityIndicatorView 42 | } 43 | 44 | private func createActivityIndicator() -> UIActivityIndicatorView { 45 | let activityIndicator = UIActivityIndicatorView() 46 | activityIndicator.hidesWhenStopped = true 47 | activityIndicator.color = .white 48 | return activityIndicator 49 | } 50 | 51 | private func centerInButton(_ activityIndicator: UIView) { 52 | activityIndicator.translatesAutoresizingMaskIntoConstraints = false 53 | 54 | NSLayoutConstraint.activate([ 55 | activityIndicator.centerXAnchor.constraint(equalTo: centerXAnchor), 56 | activityIndicator.centerYAnchor.constraint(equalTo: centerYAnchor) 57 | ]) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Vendors/SwiftGen/bin/SwiftGen_SwiftGenCLI.bundle/Contents/Resources/templates/strings/objc-h.stencil: -------------------------------------------------------------------------------- 1 | // Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen 2 | 3 | {% if tables.count > 0 %} 4 | #import 5 | 6 | NS_ASSUME_NONNULL_BEGIN 7 | 8 | {% macro parametersBlock types %} 9 | {%- for type in types -%} 10 | ({% call paramTranslate type %})p{{ forloop.counter }}{{ " :" if not forloop.last }} 11 | {%- endfor -%} 12 | {% endmacro %} 13 | {% macro argumentsBlock types %} 14 | {%- for type in types -%} 15 | p{{forloop.counter}}{{ ", " if not forloop.last }} 16 | {%- endfor -%} 17 | {% endmacro %} 18 | {% macro paramTranslate swiftType %} 19 | {%- if swiftType == "Any" -%} 20 | id 21 | {%- elif swiftType == "CChar" -%} 22 | char 23 | {%- elif swiftType == "Float" -%} 24 | float 25 | {%- elif swiftType == "Int" -%} 26 | NSInteger 27 | {%- elif swiftType == "String" -%} 28 | id 29 | {%- elif swiftType == "UnsafePointer" -%} 30 | char* 31 | {%- elif swiftType == "UnsafeRawPointer" -%} 32 | void* 33 | {%- else -%} 34 | objc-h.stencil is missing '{{swiftType}}' 35 | {%- endif -%} 36 | {% endmacro %} 37 | {% macro emitOneMethod table item %} 38 | {% for string in item.strings %} 39 | {% if not param.noComments %} 40 | {% for line in string.comment|default:string.translation|split:"\n" %} 41 | /// {{line}} 42 | {% endfor %} 43 | {% endif %} 44 | {% if string.types %} 45 | {% if string.types.count == 1 %} 46 | + (NSString*){{string.key|swiftIdentifier:"pretty"|lowerFirstWord}}WithValue:{% call parametersBlock string.types %}; 47 | {% else %} 48 | + (NSString*){{string.key|swiftIdentifier:"pretty"|lowerFirstWord}}WithValues:{% call parametersBlock string.types %}; 49 | {% endif %} 50 | {% else %} 51 | + (NSString*){{string.key|swiftIdentifier:"pretty"|lowerFirstWord}}; 52 | {% endif %} 53 | {% endfor %} 54 | {% for child in item.children %} 55 | {% call emitOneMethod table child %} 56 | {% endfor %} 57 | {% endmacro %} 58 | {% for table in tables %} 59 | @interface {{ table.name }} : NSObject 60 | {% call emitOneMethod table.name table.levels %} 61 | @end 62 | 63 | {% endfor %} 64 | 65 | NS_ASSUME_NONNULL_END 66 | {% else %} 67 | // No strings found 68 | {% endif %} 69 | -------------------------------------------------------------------------------- /Networking/Tests/NetworkingTests/Requests/FilterByMainIngredientAPIRequestTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Networking 3 | 4 | final class FilterByMainIngredientAPI: XCTestCase { 5 | 6 | // MARK: Properties 7 | private var sample: FilterByMainIngredientAPIRequest! 8 | 9 | // MARK: Life cycle 10 | override func setUp() { 11 | sample = FilterByMainIngredientAPIRequest(ingredient: "chicken_breast") 12 | } 13 | 14 | // MARK: Tests 15 | func testFilterByIngredientRequestProperties() { 16 | // Then 17 | XCTAssertEqual(sample.baseUrl, Constants.theMealDB) 18 | XCTAssertEqual(sample.path, "filter.php") 19 | XCTAssertEqual(sample.method, "GET") 20 | XCTAssertEqual(sample.queryParameters, 21 | ["ingredient": "chicken_breast"]) 22 | } 23 | 24 | func testFilterByIngredientRequestResponseDecoder() throws { 25 | // Given 26 | let filterByIngredientResponseAsString = 27 | """ 28 | { 29 | "meals": [ 30 | { 31 | "strMeal": "Chick-Fil-A Sandwich", 32 | "strMealThumb": "https://www.themealdb.com/images/media/meals/sbx7n71587673021.jpg", 33 | "idMeal": "53016" 34 | } 35 | ] 36 | 37 | } 38 | """ 39 | 40 | // When 41 | let filterByIngredientResponseData = try XCTUnwrap(filterByIngredientResponseAsString.data(using: .utf8)) 42 | print(filterByIngredientResponseAsString 43 | .data(using: .utf8)) 44 | let filterByIngredientResponse = try? sample.responseDecoder(filterByIngredientResponseData) 45 | 46 | // Then 47 | XCTAssertNotNil(filterByIngredientResponse) 48 | print(filterByIngredientResponse) 49 | XCTAssertEqual(filterByIngredientResponse?.meals.count, 1) 50 | XCTAssertEqual(filterByIngredientResponse?.meals[0].meal, "Chick-Fil-A Sandwich") 51 | XCTAssertEqual(filterByIngredientResponse? 52 | .meals[0].thumbnailImageUrl, "https://www.themealdb.com/images/media/meals/sbx7n71587673021.jpg") 53 | XCTAssertEqual(filterByIngredientResponse? 54 | .meals[0].id, "53016") 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Healthy/Resources/Generated/Strings.Generated.swift: -------------------------------------------------------------------------------- 1 | // swiftlint:disable all 2 | // Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen 3 | 4 | import Foundation 5 | 6 | // swiftlint:disable superfluous_disable_command file_length implicit_return prefer_self_in_static_references 7 | 8 | // MARK: - Strings 9 | 10 | // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length 11 | // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces 12 | internal enum L10n { 13 | internal enum App { 14 | /// Healthy 15 | internal static let title = L10n.tr("Localizable", "app.title", fallback: "Healthy") 16 | } 17 | internal enum Common { 18 | /// Email 19 | internal static let email = L10n.tr("Localizable", "common.email", fallback: "Email") 20 | /// Password 21 | internal static let password = L10n.tr("Localizable", "common.password", fallback: "Password") 22 | } 23 | internal enum Home { 24 | internal enum NewRecipes { 25 | /// New Recipes 26 | internal static let header = L10n.tr("Localizable", "home.new_recipes.header", fallback: "New Recipes") 27 | } 28 | } 29 | internal enum Signin { 30 | /// Sign In 31 | internal static let buttonTitle = L10n.tr("Localizable", "signin.buttonTitle", fallback: "Sign In") 32 | } 33 | internal enum Signup { 34 | /// Sign Up 35 | internal static let buttonTitle = L10n.tr("Localizable", "signup.buttonTitle", fallback: "Sign Up") 36 | } 37 | } 38 | // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length 39 | // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces 40 | 41 | // MARK: - Implementation Details 42 | 43 | extension L10n { 44 | private static func tr(_ table: String, _ key: String, _ args: CVarArg..., fallback value: String) -> String { 45 | let format = BundleToken.bundle.localizedString(forKey: key, value: value, table: table) 46 | return String(format: format, locale: Locale.current, arguments: args) 47 | } 48 | } 49 | 50 | // swiftlint:disable convenience_type 51 | private final class BundleToken { 52 | static let bundle: Bundle = { 53 | #if SWIFT_PACKAGE 54 | return Bundle.module 55 | #else 56 | return Bundle(for: BundleToken.self) 57 | #endif 58 | }() 59 | } 60 | // swiftlint:enable convenience_type 61 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # paths to ignore during linting. Takes precedence over `included`. 2 | excluded: 3 | - Templates 4 | - Healthy/Resources/Generated/ 5 | 6 | # Some rules are turned off by default, so we need to opt-in 7 | opt_in_rules: 8 | # Prefer checking isEmpty over comparing count to zero 9 | - empty_count 10 | 11 | # Force casts should be avoided 12 | - force_cast 13 | 14 | # Force unwrapping should be avoided 15 | - force_unwrapping 16 | 17 | # Header comments should be consistent with project patterns. 18 | - file_header 19 | 20 | # MARK comment should be in valid format. e.g. ‘// MARK: …’ or ‘// MARK: - …’ 21 | - mark 22 | 23 | # Function parameters should be aligned vertically if they’re in multiple lines in a method call 24 | - vertical_parameter_alignment_on_call 25 | 26 | # Use shorthand syntax for optional binding 27 | - shorthand_optional_binding 28 | 29 | - custom_rules 30 | 31 | # By default, SwiftLint uses a set of sensible default rules you can adjust: 32 | disabled_rules: # rule identifiers turned on by default to exclude from running 33 | # TODOs and FIXMEs should be resolved. 34 | - todo 35 | 36 | # swiftlint:disable commands should be re-enabled before the end of the file 37 | - blanket_disable_command 38 | 39 | # configurable rules can be customized from this configuration file 40 | # binary rules can set their severity level 41 | force_cast: warning 42 | 43 | force_unwrapping: error 44 | 45 | force_try: 46 | severity: error 47 | 48 | line_length: 120 49 | 50 | type_body_length: 51 | - 300 # warning 52 | - 400 # error 53 | 54 | # or they can set both explicitly 55 | file_length: 56 | error: 400 57 | 58 | # naming rules can set warnings/errors for min_length and max_length 59 | # additionally they can set excluded names 60 | type_name: 61 | allowed_symbols: ["_"] # these are allowed in type names 62 | 63 | identifier_name: 64 | excluded: # excluded via string array 65 | - id 66 | - URL 67 | - GlobalAPIKey 68 | 69 | file_header: 70 | forbidden_string: "Created by" 71 | 72 | custom_rules: 73 | no_lines_around_braces: 74 | name: "No lines before or after closing curly braces" 75 | regex: "(\\s*{[^\\n]*)\\s*\\n{2,}(\\s*\\n)*\\s*([^\\n]*\\n\\s*})" 76 | message: "Don't leave lines before or after closing curly braces" 77 | severity: warning # or error 78 | correction: "$1\n$4" 79 | -------------------------------------------------------------------------------- /Healthy/Classes/Utilities/Logger/FileSystemLogger.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Logging the messages in the file that exists in the file system 4 | final class FileSystemLogger: Logging { 5 | private var logCache: [String] = [] 6 | private let logFileName = "app.log" 7 | private let logQueue = DispatchQueue(label: "com.motoon.default-logger", qos: .background, attributes: .concurrent) 8 | 9 | func log(_ message: String, 10 | level: LogLevel, 11 | file: StaticString, 12 | function: StaticString, 13 | line: UInt) { 14 | let timestamp = DateFormatter.localizedString(from: Date(), 15 | dateStyle: .medium, 16 | timeStyle: .medium) 17 | let logMessage = "\(timestamp) - \(level) - \(file) - \(function) - \(line)" 18 | logCache.append(logMessage) 19 | logQueue.async { [weak self] in self?.flushLogCache()} 20 | print(logMessage) 21 | } 22 | 23 | private func flushLogCache() { 24 | guard let logURL = FileManager.default 25 | .urls(for: .cachesDirectory, in: .userDomainMask) 26 | .first?.appendingPathComponent(logFileName) else { 27 | print("Error getting log URL") 28 | return 29 | } 30 | 31 | do { 32 | let logString = logCache.joined(separator: "\n") 33 | try logString.appendLineToURL(fileURL: logURL) 34 | logCache.removeAll() 35 | } catch { 36 | assertionFailure("Error writing log to file:\(error)") 37 | } 38 | } 39 | } 40 | 41 | // MARK: - String+Helpers 42 | private extension String { 43 | func appendLineToURL(fileURL: URL) throws { 44 | let data = self + "\n" 45 | try data.appendToURL(fileURL: fileURL) 46 | } 47 | 48 | func appendToURL(fileURL: URL) throws { 49 | if let data = self.data(using: String.Encoding.utf8) { 50 | if let fileHandle = FileHandle(forWritingAtPath: fileURL.path) { 51 | defer { 52 | fileHandle.closeFile() 53 | } 54 | fileHandle.seekToEndOfFile() 55 | fileHandle.write(data) 56 | } else { 57 | try data.write(to: fileURL, options: .atomic) 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Healthy/Classes/Entities/Recipe.swift: -------------------------------------------------------------------------------- 1 | /// A model representing a recipe in the HomeNewReceiptsView. 2 | /// 3 | /// This model contains properties for the recipe title, recipe image URL, rating (out of 5 stars), 4 | /// user image URL, user name, and preparation time. 5 | struct Recipe { 6 | 7 | // MARK: - Properties 8 | 9 | /// The title of the recipe 10 | let title: String? 11 | 12 | /// A URL string representing the recipe image 13 | let recipeImageUrl: String? 14 | 15 | /// The rating of the recipe in the form of a star rating (out of 5 stars) 16 | let rating: Int? 17 | 18 | /// A URL string representing the user who posted the recipe 19 | let userImageUrl: String? 20 | 21 | /// The name of the user who posted the recipe 22 | let userName: String? 23 | 24 | /// The preparation time for the recipe in seconds 25 | let preparationTime: Int? 26 | 27 | // MARK: - Initializers 28 | 29 | /// Initializes a new instance of `Recipe` with the given parameters. 30 | /// 31 | /// - Parameters: 32 | /// - title: The title of the recipe. 33 | /// - recipeImageUrl: A URL string representing the recipe image. 34 | /// - rating: The star rating of the recipe (out of 5 stars). 35 | /// - userImageUrl: A URL string representing the user who posted the recipe. 36 | /// - userName: The name of the user who posted the recipe. 37 | /// - preparationTime: The preparation time for the recipe in minutes. 38 | init(title: String = "", 39 | recipeImageUrl: String = "", 40 | rating: Int = 0, 41 | userImageUrl: String = "", 42 | userName: String = "", 43 | preparationTime: Int = 0) { 44 | self.title = title 45 | self.recipeImageUrl = recipeImageUrl 46 | self.rating = rating 47 | self.userImageUrl = userImageUrl 48 | self.userName = userName 49 | self.preparationTime = preparationTime 50 | } 51 | 52 | // MARK: - Methods 53 | 54 | /// Formats the preparation time as a string in the "Minutes:Seconds" format. 55 | /// 56 | /// - Returns: A string representing the preparation time formatted as "Minutes:Seconds". 57 | func formattedPreparationTime() -> String { 58 | let minutes = (preparationTime ?? 0) / 60 59 | let seconds = (preparationTime ?? 0) % 60 60 | return String(format: "%02d:%02d", minutes, seconds) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Healthy/Resources/Generated/UIImage.Generated.swift: -------------------------------------------------------------------------------- 1 | import UIKit.UIImage 2 | 3 | // This file is generated automatically, Don't ever try to change it 🔫 4 | // MARK: - Images 5 | 6 | // swiftlint:disable force_unwrapping 7 | extension UIImage { 8 | 9 | static var backgroundSplash: UIImage { 10 | UIImage(named: "background-splash")! 11 | } 12 | 13 | static var iconBookmark: UIImage { 14 | UIImage(named: "icon-bookmark")! 15 | } 16 | 17 | static var iconFacebook: UIImage { 18 | UIImage(named: "icon-facebook")! 19 | } 20 | 21 | static var iconFood: UIImage { 22 | UIImage(named: "icon-food")! 23 | } 24 | 25 | static var iconGoogle: UIImage { 26 | UIImage(named: "icon-google")! 27 | } 28 | 29 | static var iconHome: UIImage { 30 | UIImage(named: "icon-home")! 31 | } 32 | 33 | static var iconNoData: UIImage { 34 | UIImage(named: "icon-no-data")! 35 | } 36 | 37 | static var iconNotification: UIImage { 38 | UIImage(named: "icon-notification")! 39 | } 40 | 41 | static var iconProfile: UIImage { 42 | UIImage(named: "icon-profile")! 43 | } 44 | 45 | static var iconSplash: UIImage { 46 | UIImage(named: "icon-splash")! 47 | } 48 | 49 | static var iconTimer: UIImage { 50 | UIImage(named: "icon-timer")! 51 | } 52 | 53 | static var iconUnion: UIImage { 54 | UIImage(named: "icon-union")! 55 | } 56 | 57 | static var iconCheckboxNotSelected: UIImage { 58 | UIImage(named: "icon_checkbox_not_selected")! 59 | } 60 | 61 | static var iconCheckboxSelected: UIImage { 62 | UIImage(named: "icon_checkbox_selected")! 63 | } 64 | 65 | static var imageRecipePlaceholder1: UIImage { 66 | UIImage(named: "image-recipe-placeholder 1")! 67 | } 68 | 69 | static var imageRecipePlaceholder: UIImage { 70 | UIImage(named: "image-recipe-placeholder")! 71 | } 72 | 73 | static var imageUserRecipePlaceholder: UIImage { 74 | UIImage(named: "image-user-recipe-placeholder")! 75 | } 76 | 77 | static var patternFood: UIImage { 78 | UIImage(named: "pattern-food")! 79 | } 80 | 81 | static var previewDishes1: UIImage { 82 | UIImage(named: "preview-dishes-1")! 83 | } 84 | 85 | static var previewDishes2: UIImage { 86 | UIImage(named: "preview-dishes-2")! 87 | } 88 | 89 | static var star: UIImage { 90 | UIImage(named: "star")! 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /HealthyTests/Classes/Modules/Onboarding/Login/LoginViewModelTest.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Healthy 3 | 4 | final class LoginViewModelTest: XCTestCase { 5 | 6 | // MARK: Properties 7 | 8 | private var viewModel: LoginViewModel! 9 | 10 | // MARK: Lifecycle 11 | 12 | override func setUp() { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | viewModel = LoginViewModel() 15 | } 16 | 17 | // MARK: Tests 18 | 19 | func test_onButtonEnabled_whenEmailAndPasswordIsEmpty_shouldBeDisabled() { 20 | // Given 21 | var isEnabled: Bool? 22 | viewModel.onButtonEnabled { isEnabled = $0 } 23 | 24 | // When 25 | viewModel.updateEmail("") 26 | viewModel.updatePassword("") 27 | 28 | // Then 29 | XCTAssertFalse(isEnabled == true) 30 | } 31 | 32 | func test_onButtonEnabled_whenEmailOnlyIsEmpty_shouldBeDisabled() { 33 | // Given 34 | var isEnabled: Bool? 35 | viewModel.onButtonEnabled { isEnabled = $0 } 36 | 37 | // When 38 | viewModel.updateEmail("") 39 | viewModel.updatePassword("sample password") 40 | 41 | // Then 42 | XCTAssertFalse(isEnabled == true) 43 | } 44 | 45 | func test_onButtonEnabled_whenPasswordOnlyIsEmpty_shouldBeDisabled() { 46 | // Given 47 | var isEnabled: Bool? 48 | viewModel.onButtonEnabled { isEnabled = $0 } 49 | 50 | // When 51 | viewModel.updateEmail("sample email") 52 | viewModel.updatePassword("") 53 | 54 | // Then 55 | XCTAssertFalse(isEnabled == true) 56 | } 57 | 58 | func test_onButtonEnabled_whenPasswordLessThan6Digit_shouldBeDisabled() { 59 | // Given 60 | var isEnabled: Bool? 61 | viewModel.onButtonEnabled { isEnabled = $0 } 62 | 63 | // When 64 | viewModel.updateEmail("sample email") 65 | viewModel.updatePassword("123") 66 | 67 | // Then 68 | XCTAssertFalse(isEnabled == true) 69 | } 70 | 71 | func test_onButtonEnabled_whenEmailAndPasswordIsCorrect_shouldBeEnabled() { 72 | // Given 73 | var isEnabled: Bool? 74 | viewModel.onButtonEnabled { isEnabled = $0 } 75 | 76 | // When 77 | viewModel.updateEmail("sample email") 78 | viewModel.updatePassword("sample password") 79 | 80 | // Then 81 | XCTAssertTrue(isEnabled == true) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Healthy/Classes/AppCoordinator.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// A coordinator responsible for managing the flow of navigation within an app's lifecycle. 4 | final class AppCoordinator { 5 | private let window: UIWindow 6 | private var children: [Coordinator] = [] 7 | private var isLoggedIn = false // TODO: [HL-74] Replace with actual implentation 8 | 9 | /// Initializes a new `AppCoordinator` object with the specified window. 10 | /// 11 | /// - Parameter window: The window used for displaying the app's user interface. 12 | init(window: UIWindow) { 13 | self.window = window 14 | } 15 | 16 | /// Starts the navigation flow managed by the coordinator. 17 | /// 18 | /// This method is called to initiate the navigation flow managed by the coordinator. It determines 19 | /// whether the user is logged in or not, and displays the appropriate flow accordingly. 20 | func start() { 21 | if isLoggedIn { 22 | displayLoggedInFlow() 23 | } else { 24 | displayOnboardingFlow() 25 | } 26 | } 27 | } 28 | 29 | // MARK: Flows Helpers 30 | 31 | private extension AppCoordinator { 32 | /// Displays the logged-in flow. 33 | func displayLoggedInFlow() { 34 | let viewController = MainTabBarController() 35 | replaceRootViewController(viewController) 36 | } 37 | 38 | /// Displays the onboarding flow. 39 | func displayOnboardingFlow() { 40 | let navigationController = UINavigationController() 41 | let coordinator = DefaultOnboardingCoordinator(navigationController: navigationController) { [weak self] in 42 | guard let self else { 43 | return 44 | } 45 | 46 | self.children.removeAll(where: { $0 is DefaultOnboardingCoordinator }) 47 | self.isLoggedIn = true 48 | self.start() 49 | } 50 | 51 | coordinator.start() 52 | children.append(coordinator) 53 | replaceRootViewController(navigationController) 54 | } 55 | } 56 | 57 | // MARK: Window Replacement 58 | 59 | private extension AppCoordinator { 60 | /// Replaces the root view controller of the app's window. 61 | /// 62 | /// This method sets the specified view controller as the root view controller of the app's window, 63 | /// and makes the window visible. 64 | func replaceRootViewController(_ viewController: UIViewController) { 65 | window.rootViewController = viewController 66 | window.makeKeyAndVisible() 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Healthy/Resources/Generated/UIColors.Generated.swift: -------------------------------------------------------------------------------- 1 | import UIKit.UIColor 2 | 3 | // this is automatic generated file please don't edit it 🗡️ 4 | // MARK: - Colors 5 | 6 | extension UIColor { 7 | 8 | static var black100: UIColor { 9 | UIColor(named: "Black 100")! 10 | } 11 | 12 | static var black20: UIColor { 13 | UIColor(named: "Black 20")! 14 | } 15 | 16 | static var black40: UIColor { 17 | UIColor(named: "Black 40")! 18 | } 19 | 20 | static var black60: UIColor { 21 | UIColor(named: "Black 60")! 22 | } 23 | 24 | static var black80: UIColor { 25 | UIColor(named: "Black 80")! 26 | } 27 | 28 | static var success: UIColor { 29 | UIColor(named: "Success")! 30 | } 31 | 32 | static var warningLight: UIColor { 33 | UIColor(named: "Warning light")! 34 | } 35 | 36 | static var warning: UIColor { 37 | UIColor(named: "Warning")! 38 | } 39 | 40 | static var black: UIColor { 41 | UIColor(named: "black")! 42 | } 43 | 44 | static var gray1: UIColor { 45 | UIColor(named: "gray 1")! 46 | } 47 | 48 | static var gray2: UIColor { 49 | UIColor(named: "gray 2")! 50 | } 51 | 52 | static var gray3: UIColor { 53 | UIColor(named: "gray 3")! 54 | } 55 | 56 | static var gray4: UIColor { 57 | UIColor(named: "gray 4")! 58 | } 59 | 60 | static var primary100: UIColor { 61 | UIColor(named: "primary 100")! 62 | } 63 | 64 | static var primary20: UIColor { 65 | UIColor(named: "primary 20")! 66 | } 67 | 68 | static var primary40: UIColor { 69 | UIColor(named: "primary 40")! 70 | } 71 | 72 | static var primary60: UIColor { 73 | UIColor(named: "primary 60")! 74 | } 75 | 76 | static var primary80: UIColor { 77 | UIColor(named: "primary 80")! 78 | } 79 | 80 | static var rating: UIColor { 81 | UIColor(named: "rating")! 82 | } 83 | 84 | static var secondary100: UIColor { 85 | UIColor(named: "secondary 100")! 86 | } 87 | 88 | static var secondary20: UIColor { 89 | UIColor(named: "secondary 20")! 90 | } 91 | 92 | static var secondary40: UIColor { 93 | UIColor(named: "secondary 40")! 94 | } 95 | 96 | static var secondary60: UIColor { 97 | UIColor(named: "secondary 60")! 98 | } 99 | 100 | static var secondary80: UIColor { 101 | UIColor(named: "secondary 80")! 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Healthy/Classes/Extensions/UIFont+Style.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | // MARK: - Regular Fonts 4 | 5 | extension UIFont { 6 | 7 | static var titleRegular: UIFont { 8 | poppins(ofSize: 30, weight: .regular) 9 | } 10 | 11 | static var headerRegular: UIFont { 12 | poppins(ofSize: 20, weight: .regular) 13 | } 14 | 15 | static var largeRegular: UIFont { 16 | poppins(ofSize: 18, weight: .regular) 17 | } 18 | 19 | static var mediumRegular: UIFont { 20 | poppins(ofSize: 16, weight: .regular) 21 | } 22 | 23 | static var normalRegular: UIFont { 24 | poppins(ofSize: 14, weight: .regular) 25 | } 26 | 27 | static var smallRegular: UIFont { 28 | poppins(ofSize: 11, weight: .regular) 29 | } 30 | 31 | static var smallerRegular: UIFont { 32 | poppins(ofSize: 9, weight: .regular) 33 | } 34 | } 35 | 36 | // MARK: - Bold Fonts 37 | 38 | extension UIFont { 39 | 40 | static var titleBold: UIFont { 41 | poppins(ofSize: 30, weight: .bold) 42 | } 43 | 44 | static var headerBold: UIFont { 45 | poppins(ofSize: 20, weight: .bold) 46 | } 47 | 48 | static var largeBold: UIFont { 49 | poppins(ofSize: 18, weight: .bold) 50 | } 51 | 52 | static var mediumBold: UIFont { 53 | poppins(ofSize: 16, weight: .bold) 54 | } 55 | 56 | static var normalBold: UIFont { 57 | poppins(ofSize: 14, weight: .bold) 58 | } 59 | 60 | static var smallBold: UIFont { 61 | poppins(ofSize: 11, weight: .bold) 62 | } 63 | 64 | static var smallerBold: UIFont { 65 | poppins(ofSize: 9, weight: .bold) 66 | } 67 | } 68 | 69 | // MARK: Poppins UIFont Helpers 70 | 71 | private extension UIFont { 72 | 73 | /// Returns a Poppins UIFont instance with the specified Style. 74 | static func poppins(ofSize size: CGFloat, weight: UIFont.Weight) -> UIFont { 75 | let fontName = poppinsFontName(forWeight: weight) 76 | guard let font = UIFont(name: fontName, size: size) else { 77 | assertionFailure("Unable to get a font with name: \(fontName)") 78 | return UIFont.systemFont(ofSize: size, weight: weight) 79 | } 80 | return font 81 | } 82 | 83 | /// Returns a Poppins UIFont file name for the given weight. 84 | static func poppinsFontName(forWeight weight: UIFont.Weight) -> String { 85 | switch weight { 86 | case .bold: 87 | return "Poppins-Bold" 88 | default: 89 | return "Poppins-Regular" 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /.github/workflows/ios.yml: -------------------------------------------------------------------------------- 1 | name: Pull requests workflow 2 | 3 | on: 4 | push: 5 | branches: [ "develop" ] 6 | pull_request: 7 | branches: [ "develop" ] 8 | 9 | jobs: 10 | build: 11 | name: Build and Test default scheme using any available iPhone simulator 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | - name: Set Default Scheme 18 | run: | 19 | scheme_list=$(xcodebuild -list -json | tr -d "\n") 20 | default=$(echo $scheme_list | ruby -e "require 'json'; puts JSON.parse(STDIN.gets)['project']['targets'][0]") 21 | echo $default | cat >default 22 | echo Using default scheme: $default 23 | - name: Build 24 | env: 25 | scheme: ${{ 'default' }} 26 | platform: ${{ 'iOS Simulator' }} 27 | run: | 28 | # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959) 29 | device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"` 30 | if [ $scheme = default ]; then scheme=$(cat default); fi 31 | if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi 32 | file_to_build=`echo $file_to_build | awk '{$1=$1;print}'` 33 | xcodebuild build-for-testing -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,name=$device" 34 | - name: Test 35 | env: 36 | scheme: ${{ 'default' }} 37 | platform: ${{ 'iOS Simulator' }} 38 | run: | 39 | # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959) 40 | device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"` 41 | if [ $scheme = default ]; then scheme=$(cat default); fi 42 | if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi 43 | file_to_build=`echo $file_to_build | awk '{$1=$1;print}'` 44 | xcodebuild test-without-building -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,name=$device" 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | *.resolved 39 | 40 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 41 | # Packages/ 42 | # Package.pins 43 | # Package.resolved 44 | # *.xcodeproj 45 | # 46 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 47 | # hence it is not needed unless you have added a package configuration file to your project 48 | # .swiftpm 49 | 50 | .build/ 51 | 52 | # CocoaPods 53 | # 54 | # We recommend against adding the Pods directory to your .gitignore. However 55 | # you should judge for yourself, the pros and cons are mentioned at: 56 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 57 | # 58 | # Pods/ 59 | # 60 | # Add this line if you want to avoid checking in source code from the Xcode workspace 61 | # *.xcworkspace 62 | 63 | # Carthage 64 | # 65 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 66 | # Carthage/Checkouts 67 | 68 | Carthage/Build/ 69 | 70 | # Accio dependency management 71 | Dependencies/ 72 | .accio/ 73 | 74 | # fastlane 75 | # 76 | # It is recommended to not store the screenshots in the git repo. 77 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 78 | # For more information about the recommended setup visit: 79 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 80 | 81 | fastlane/report.xml 82 | fastlane/Preview.html 83 | fastlane/screenshots/**/*.png 84 | fastlane/test_output 85 | 86 | # Code Injection 87 | # 88 | # After new code Injection tools there's a generated folder /iOSInjectionProject 89 | # https://github.com/johnno1962/injectionforxcode 90 | 91 | iOSInjectionProject/ 92 | 93 | # System files 94 | 95 | .DS_Store -------------------------------------------------------------------------------- /Healthy/Resources/Assets.xcassets/icon-timer.imageset/icon-timer.pdf: -------------------------------------------------------------------------------- 1 | %PDF-1.7 2 | 3 | 1 0 obj 4 | << >> 5 | endobj 6 | 7 | 2 0 obj 8 | << /Length 3 0 R >> 9 | stream 10 | /DeviceRGB CS 11 | /DeviceRGB cs 12 | q 13 | 1.000000 0.000000 -0.000000 1.000000 1.770874 0.885422 cm 14 | 0.662745 0.662745 0.662745 scn 15 | 6.729167 -0.000007 m 16 | 3.017500 -0.000007 0.000000 3.017493 0.000000 6.729161 c 17 | 0.000000 10.440827 3.017500 13.458328 6.729167 13.458328 c 18 | 10.440834 13.458328 13.458334 10.440827 13.458334 6.729161 c 19 | 13.458334 3.017493 10.440834 -0.000007 6.729167 -0.000007 c 20 | h 21 | 6.729167 12.395828 m 22 | 3.605417 12.395828 1.062500 9.852911 1.062500 6.729161 c 23 | 1.062500 3.605411 3.605417 1.062493 6.729167 1.062493 c 24 | 9.852917 1.062493 12.395834 3.605411 12.395834 6.729161 c 25 | 12.395834 9.852911 9.852917 12.395828 6.729167 12.395828 c 26 | h 27 | f 28 | n 29 | Q 30 | q 31 | 1.000000 0.000000 -0.000000 1.000000 7.968750 7.260406 cm 32 | 0.662745 0.662745 0.662745 scn 33 | 0.531250 0.000005 m 34 | 0.240833 0.000005 0.000000 0.240838 0.000000 0.531255 c 35 | 0.000000 4.072922 l 36 | 0.000000 4.363338 0.240833 4.604172 0.531250 4.604172 c 37 | 0.821667 4.604172 1.062500 4.363338 1.062500 4.072922 c 38 | 1.062500 0.531255 l 39 | 1.062500 0.240838 0.821667 0.000005 0.531250 0.000005 c 40 | h 41 | f 42 | n 43 | Q 44 | q 45 | 1.000000 0.000000 -0.000000 1.000000 5.843750 15.052078 cm 46 | 0.662745 0.662745 0.662745 scn 47 | 4.781250 0.000000 m 48 | 0.531250 0.000000 l 49 | 0.240833 0.000000 0.000000 0.240833 0.000000 0.531250 c 50 | 0.000000 0.821667 0.240833 1.062500 0.531250 1.062500 c 51 | 4.781250 1.062500 l 52 | 5.071667 1.062500 5.312500 0.821667 5.312500 0.531250 c 53 | 5.312500 0.240833 5.071667 0.000000 4.781250 0.000000 c 54 | h 55 | f 56 | n 57 | Q 58 | 59 | endstream 60 | endobj 61 | 62 | 3 0 obj 63 | 1405 64 | endobj 65 | 66 | 4 0 obj 67 | << /Annots [] 68 | /Type /Page 69 | /MediaBox [ 0.000000 0.000000 17.000000 17.000000 ] 70 | /Resources 1 0 R 71 | /Contents 2 0 R 72 | /Parent 5 0 R 73 | >> 74 | endobj 75 | 76 | 5 0 obj 77 | << /Kids [ 4 0 R ] 78 | /Count 1 79 | /Type /Pages 80 | >> 81 | endobj 82 | 83 | 6 0 obj 84 | << /Pages 5 0 R 85 | /Type /Catalog 86 | >> 87 | endobj 88 | 89 | xref 90 | 0 7 91 | 0000000000 65535 f 92 | 0000000010 00000 n 93 | 0000000034 00000 n 94 | 0000001495 00000 n 95 | 0000001518 00000 n 96 | 0000001691 00000 n 97 | 0000001765 00000 n 98 | trailer 99 | << /ID [ (some) (id) ] 100 | /Root 6 0 R 101 | /Size 7 102 | >> 103 | startxref 104 | 1824 105 | %%EOF -------------------------------------------------------------------------------- /Vendors/SwiftGen/bin/SwiftGen_SwiftGenCLI.bundle/Contents/Resources/templates/ib/segues-swift4.stencil: -------------------------------------------------------------------------------- 1 | // swiftlint:disable all 2 | // Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen 3 | 4 | {% if platform and storyboards %} 5 | {% set accessModifier %}{% if param.publicAccess %}public{% else %}internal{% endif %}{% endset %} 6 | {% set isAppKit %}{% if platform == "macOS" %}true{% endif %}{% endset %} 7 | // swiftlint:disable sorted_imports 8 | import Foundation 9 | {% for module in modules where module != env.PRODUCT_MODULE_NAME and module != param.module %} 10 | import {{module}} 11 | {% endfor %} 12 | 13 | // swiftlint:disable superfluous_disable_command 14 | // swiftlint:disable file_length 15 | 16 | // MARK: - Storyboard Segues 17 | 18 | // swiftlint:disable explicit_type_interface identifier_name line_length type_body_length type_name 19 | {{accessModifier}} enum {{param.enumName|default:"StoryboardSegue"}} { 20 | {% for storyboard in storyboards where storyboard.segues %} 21 | {{accessModifier}} enum {{storyboard.name|swiftIdentifier:"pretty"|escapeReservedKeywords}}: String, SegueType { 22 | {% for segue in storyboard.segues %} 23 | {% set segueID %}{{segue.identifier|swiftIdentifier:"pretty"|lowerFirstWord}}{% endset %} 24 | case {{segueID|escapeReservedKeywords}}{% if segueID != segue.identifier %} = "{{segue.identifier}}"{% endif +%} 25 | {% endfor %} 26 | } 27 | {% endfor %} 28 | } 29 | // swiftlint:enable explicit_type_interface identifier_name line_length type_body_length type_name 30 | 31 | // MARK: - Implementation Details 32 | 33 | {{accessModifier}} protocol SegueType: RawRepresentable {} 34 | 35 | {{accessModifier}} extension {%+ if isAppKit %}NSSeguePerforming{% else %}UIViewController{% endif %} { 36 | func perform(segue: S, sender: Any? = nil) where S.RawValue == String { 37 | let identifier = {%+ if isAppKit %}NSStoryboardSegue.Identifier({% endif %}segue.rawValue{% if isAppKit %}){% endif +%} 38 | performSegue{% if isAppKit %}?{% endif %}(withIdentifier: identifier, sender: sender) 39 | } 40 | } 41 | 42 | {{accessModifier}} extension SegueType where RawValue == String { 43 | init?(_ segue: {%+ if isAppKit %}NS{% else %}UI{% endif %}StoryboardSegue) { 44 | {% if isAppKit %} 45 | #if swift(>=4.2) 46 | guard let identifier = segue.identifier else { return nil } 47 | #else 48 | guard let identifier = segue.identifier?.rawValue else { return nil } 49 | #endif 50 | {% else %} 51 | guard let identifier = segue.identifier else { return nil } 52 | {% endif %} 53 | self.init(rawValue: identifier) 54 | } 55 | } 56 | {% elif storyboards %} 57 | // Mixed AppKit and UIKit storyboard files found, please invoke swiftgen with these separately 58 | {% else %} 59 | // No storyboard found 60 | {% endif %} 61 | -------------------------------------------------------------------------------- /Vendors/SwiftGen/bin/SwiftGen_SwiftGenCLI.bundle/Contents/Resources/templates/ib/segues-swift5.stencil: -------------------------------------------------------------------------------- 1 | // swiftlint:disable all 2 | // Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen 3 | 4 | {% if platform and storyboards %} 5 | {% set accessModifier %}{% if param.publicAccess %}public{% else %}internal{% endif %}{% endset %} 6 | {% set isAppKit %}{% if platform == "macOS" %}true{% endif %}{% endset %} 7 | // swiftlint:disable sorted_imports 8 | import Foundation 9 | {% for module in modules where module != env.PRODUCT_MODULE_NAME and module != param.module %} 10 | import {{module}} 11 | {% endfor %} 12 | 13 | // swiftlint:disable superfluous_disable_command 14 | // swiftlint:disable file_length 15 | 16 | // MARK: - Storyboard Segues 17 | 18 | // swiftlint:disable explicit_type_interface identifier_name line_length type_body_length type_name 19 | {{accessModifier}} enum {{param.enumName|default:"StoryboardSegue"}} { 20 | {% for storyboard in storyboards where storyboard.segues %} 21 | {{accessModifier}} enum {{storyboard.name|swiftIdentifier:"pretty"|escapeReservedKeywords}}: String, SegueType { 22 | {% for segue in storyboard.segues %} 23 | {% set segueID %}{{segue.identifier|swiftIdentifier:"pretty"|lowerFirstWord}}{% endset %} 24 | case {{segueID|escapeReservedKeywords}}{% if segueID != segue.identifier %} = "{{segue.identifier}}"{% endif +%} 25 | {% endfor %} 26 | } 27 | {% endfor %} 28 | } 29 | // swiftlint:enable explicit_type_interface identifier_name line_length type_body_length type_name 30 | 31 | // MARK: - Implementation Details 32 | 33 | {{accessModifier}} protocol SegueType: RawRepresentable {} 34 | 35 | {{accessModifier}} extension {%+ if isAppKit %}NSSeguePerforming{% else %}UIViewController{% endif %} { 36 | func perform(segue: S, sender: Any? = nil) where S.RawValue == String { 37 | let identifier = {%+ if isAppKit %}NSStoryboardSegue.Identifier({% endif %}segue.rawValue{% if isAppKit %}){% endif +%} 38 | performSegue{% if isAppKit %}?{% endif %}(withIdentifier: identifier, sender: sender) 39 | } 40 | } 41 | 42 | {{accessModifier}} extension SegueType where RawValue == String { 43 | init?(_ segue: {%+ if isAppKit %}NS{% else %}UI{% endif %}StoryboardSegue) { 44 | {% if isAppKit %} 45 | #if swift(>=4.2) 46 | guard let identifier = segue.identifier else { return nil } 47 | #else 48 | guard let identifier = segue.identifier?.rawValue else { return nil } 49 | #endif 50 | {% else %} 51 | guard let identifier = segue.identifier else { return nil } 52 | {% endif %} 53 | self.init(rawValue: identifier) 54 | } 55 | } 56 | {% elif storyboards %} 57 | // Mixed AppKit and UIKit storyboard files found, please invoke swiftgen with these separately 58 | {% else %} 59 | // No storyboard found 60 | {% endif %} 61 | -------------------------------------------------------------------------------- /Healthy/Classes/Modules/SavedRecipes/SavedRecipesViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Combine 3 | 4 | final class SavedRecipesViewController: UIViewController { 5 | 6 | typealias ViewModel = SavedRecipesTableViewCell.ViewModel 7 | typealias DataSource = UITableViewDiffableDataSource 8 | 9 | enum Section { 10 | case main 11 | } 12 | 13 | // MARK: Outlets 14 | 15 | @IBOutlet weak var navigationBar: UINavigationBar! 16 | @IBOutlet weak var tableView: UITableView! 17 | 18 | // MARK: Properties 19 | 20 | private let viewModel: SavedRecipesViewModelType 21 | private (set) var dataSource: UITableViewDiffableDataSource! 22 | private var subscriptions: Set = [] 23 | 24 | // MARK: Init 25 | 26 | init(viewModel: SavedRecipesViewModelType) { 27 | self.viewModel = viewModel 28 | super.init(nibName: nil, bundle: nil) 29 | } 30 | 31 | @available(*, unavailable) 32 | required init?(coder: NSCoder) { 33 | fatalError("init(coder:) has not been implemented") 34 | } 35 | 36 | // MARK: Lifecycle 37 | 38 | override func viewDidLoad() { 39 | super.viewDidLoad() 40 | 41 | configureNavigationBar() 42 | configureTableView() 43 | configureTableViewDataSource() 44 | setupBindings() 45 | } 46 | } 47 | 48 | // MARK: - Actions 49 | 50 | extension SavedRecipesViewController {} 51 | 52 | // MARK: - Configurations 53 | 54 | extension SavedRecipesViewController { 55 | func configureNavigationBar() { 56 | navigationBar.topItem?.title = "Saved recipes" 57 | } 58 | 59 | func configureTableView() { 60 | tableView.registerNib(cell: SavedRecipesTableViewCell.self) 61 | } 62 | 63 | func configureTableViewDataSource() { 64 | dataSource = DataSource(tableView: tableView) { tableView, indexPath, viewModel in 65 | let cell: SavedRecipesTableViewCell = tableView.dequeueReusableCell(for: indexPath) 66 | cell.update(with: viewModel) 67 | return cell 68 | } 69 | } 70 | 71 | func updateTableData(viewModel: [ViewModel]) { 72 | var snapshot = NSDiffableDataSourceSnapshot() 73 | snapshot.appendSections([.main]) 74 | snapshot.appendItems(viewModel) 75 | dataSource.apply(snapshot, animatingDifferences: true) 76 | } 77 | 78 | func setupBindings() { 79 | viewModel.recipesPublisher 80 | .sink { [weak self] viewModels in 81 | self?.updateTableData(viewModel: viewModels) 82 | } 83 | .store(in: &subscriptions) 84 | } 85 | } 86 | 87 | // MARK: - Private Handlers 88 | 89 | private extension SavedRecipesViewController {} 90 | --------------------------------------------------------------------------------