├── 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 |
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 |
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 |
--------------------------------------------------------------------------------