diff --git a/Domain/Package.swift b/Domain/Package.swift new file mode 100644 index 00000000..f06d48c2 --- /dev/null +++ b/Domain/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Domain", + platforms: [.iOS(.v13)], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "Domain", + targets: ["Domain"]) + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "Domain", + dependencies: []), + .testTarget( + name: "DomainTests", + dependencies: ["Domain"]) + ] +) diff --git a/Domain/README.md b/Domain/README.md new file mode 100644 index 00000000..e2d4dcc5 --- /dev/null +++ b/Domain/README.md @@ -0,0 +1,5 @@ +# Domain + +The Domain Layer is a crucial component of a software architecture that represents the +core business logic and rules of the application. It acts as the heart of the system, +encapsulating all the domain-specific logic and entities. diff --git a/Domain/Sources/Domain/Entities/User.swift b/Domain/Sources/Domain/Entities/User.swift new file mode 100644 index 00000000..9cce783e --- /dev/null +++ b/Domain/Sources/Domain/Entities/User.swift @@ -0,0 +1,9 @@ +public struct User { + public let email: String + public let tokenID: String + + public init(email: String, tokenID: String) { + self.email = email + self.tokenID = tokenID + } +} diff --git a/Domain/Sources/Domain/UseCases/LoginUseCase.swift b/Domain/Sources/Domain/UseCases/LoginUseCase.swift new file mode 100644 index 00000000..de424d4b --- /dev/null +++ b/Domain/Sources/Domain/UseCases/LoginUseCase.swift @@ -0,0 +1,3 @@ +public protocol LoginUseCase { + func login(email: String, password: String) async throws -> User +} diff --git a/Domain/Tests/DomainTests/DomainTests.swift b/Domain/Tests/DomainTests/DomainTests.swift new file mode 100644 index 00000000..6892ec2c --- /dev/null +++ b/Domain/Tests/DomainTests/DomainTests.swift @@ -0,0 +1,10 @@ +import XCTest +@testable import Domain + +final class DomainTests: XCTestCase { + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct + // results. + } +} diff --git a/Healthy.xcodeproj/project.pbxproj b/Healthy.xcodeproj/project.pbxproj index 70b096b1..900ca6de 100644 --- a/Healthy.xcodeproj/project.pbxproj +++ b/Healthy.xcodeproj/project.pbxproj @@ -41,6 +41,8 @@ 025511D42A3D0A8B00295B91 /* CreateAccountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025511CC2A3D0A8B00295B91 /* CreateAccountViewModel.swift */; }; 025511D52A3D0A8B00295B91 /* CreateAccountviewModelType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025511CD2A3D0A8B00295B91 /* CreateAccountviewModelType.swift */; }; 025511D62A3D0A8B00295B91 /* CreateAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025511CE2A3D0A8B00295B91 /* CreateAccountViewController.swift */; }; + 02557D712A697DF90022756A /* LoginUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02557D702A697DF90022756A /* LoginUseCase.swift */; }; + 02557D732A697E8E0022756A /* Domain in Frameworks */ = {isa = PBXBuildFile; productRef = 02557D722A697E8E0022756A /* Domain */; }; 025A47532A336B05008BF85A /* DashboardViewModelType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025A474F2A336B05008BF85A /* DashboardViewModelType.swift */; }; 025A47542A336B05008BF85A /* DashboardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025A47502A336B05008BF85A /* DashboardViewController.swift */; }; 025A47552A336B05008BF85A /* DashboardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025A47512A336B05008BF85A /* DashboardViewModel.swift */; }; @@ -72,6 +74,7 @@ 029C89142A71E79A00AF380B /* NewRecipesCollectionViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029C89132A71E79A00AF380B /* NewRecipesCollectionViewLayout.swift */; }; 02C37B862A71CF5600FD58E5 /* NewRecipesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C37B852A71CF5600FD58E5 /* NewRecipesView.swift */; }; 02C3F8402A71CB3A000E62B2 /* UIView+ReuseIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7918F03D2A1F6448000F36A5 /* UIView+ReuseIdentifier.swift */; }; + 02CB9B642A6ADE4E00C1E765 /* DefaultLoginUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CB9B632A6ADE4E00C1E765 /* DefaultLoginUseCaseTests.swift */; }; 02EEC3FB2A4C22C90007DA0C /* SavedRecipesViewModelMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EEC3FA2A4C22C90007DA0C /* SavedRecipesViewModelMock.swift */; }; 02EEC3FD2A4C23090007DA0C /* SavedRecipesViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EEC3FC2A4C23090007DA0C /* SavedRecipesViewControllerTests.swift */; }; 02EEC3FF2A4C244A0007DA0C /* SavedRecipesViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EEC3FE2A4C244A0007DA0C /* SavedRecipesViewModelTests.swift */; }; @@ -200,6 +203,8 @@ 025511CC2A3D0A8B00295B91 /* CreateAccountViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateAccountViewModel.swift; sourceTree = ""; }; 025511CD2A3D0A8B00295B91 /* CreateAccountviewModelType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateAccountviewModelType.swift; sourceTree = ""; }; 025511CE2A3D0A8B00295B91 /* CreateAccountViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateAccountViewController.swift; sourceTree = ""; }; + 02557D6E2A697C3D0022756A /* Domain */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Domain; sourceTree = ""; }; + 02557D702A697DF90022756A /* LoginUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginUseCase.swift; sourceTree = ""; }; 025A474F2A336B05008BF85A /* DashboardViewModelType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardViewModelType.swift; sourceTree = ""; }; 025A47502A336B05008BF85A /* DashboardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardViewController.swift; sourceTree = ""; }; 025A47512A336B05008BF85A /* DashboardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardViewModel.swift; sourceTree = ""; }; @@ -231,6 +236,7 @@ 029C89112A71E78000AF380B /* NSLayoutConstraint+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSLayoutConstraint+Helpers.swift"; sourceTree = ""; }; 029C89132A71E79A00AF380B /* NewRecipesCollectionViewLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewRecipesCollectionViewLayout.swift; sourceTree = ""; }; 02C37B852A71CF5600FD58E5 /* NewRecipesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewRecipesView.swift; sourceTree = ""; }; + 02CB9B632A6ADE4E00C1E765 /* DefaultLoginUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultLoginUseCaseTests.swift; sourceTree = ""; }; 02EEC3FA2A4C22C90007DA0C /* SavedRecipesViewModelMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedRecipesViewModelMock.swift; sourceTree = ""; }; 02EEC3FC2A4C23090007DA0C /* SavedRecipesViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedRecipesViewControllerTests.swift; sourceTree = ""; }; 02EEC3FE2A4C244A0007DA0C /* SavedRecipesViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedRecipesViewModelTests.swift; sourceTree = ""; }; @@ -321,6 +327,7 @@ 6D611B9F2A299E3600A1FC65 /* GoogleSignInSwift in Frameworks */, 025511B72A3C623000295B91 /* NewRelic in Frameworks */, 6DDA138D2A1109E8004390D4 /* Factory in Frameworks */, + 02557D732A697E8E0022756A /* Domain in Frameworks */, 0296F7952A4342B500DBC86A /* FacebookLogin in Frameworks */, 6D611B9D2A299E3600A1FC65 /* GoogleSignIn in Frameworks */, ); @@ -457,6 +464,14 @@ path = CreateAccount; sourceTree = ""; }; + 02557D6F2A697D8E0022756A /* UseCases */ = { + isa = PBXGroup; + children = ( + 02557D702A697DF90022756A /* LoginUseCase.swift */, + ); + path = UseCases; + sourceTree = ""; + }; 025A474E2A336AE3008BF85A /* Dashboard */ = { isa = PBXGroup; children = ( @@ -523,6 +538,7 @@ 027DDA122A0E6A660052818C = { isa = PBXGroup; children = ( + 02557D6E2A697C3D0022756A /* Domain */, 02892EC52A585558001A3DB4 /* Networking */, 027DDA1D2A0E6A660052818C /* Healthy */, 027DDA342A0E6A680052818C /* HealthyTests */, @@ -597,6 +613,7 @@ 025511C32A3D058800295B91 /* AppCoordinator.swift */, 027DDA1E2A0E6A660052818C /* AppDelegate.swift */, 027DDA202A0E6A660052818C /* SceneDelegate.swift */, + 02557D6F2A697D8E0022756A /* UseCases */, ); path = Classes; sourceTree = ""; @@ -680,6 +697,14 @@ name = Frameworks; sourceTree = ""; }; + 02CB9B5F2A6AD5DD00C1E765 /* LoginUseCase */ = { + isa = PBXGroup; + children = ( + 02CB9B632A6ADE4E00C1E765 /* DefaultLoginUseCaseTests.swift */, + ); + path = LoginUseCase; + sourceTree = ""; + }; 02EEC3F92A4C22B70007DA0C /* Mocks */ = { isa = PBXGroup; children = ( @@ -826,6 +851,7 @@ 6DFEC6202A2E97FF0090B2E2 /* Modules */ = { isa = PBXGroup; children = ( + 02CB9B5F2A6AD5DD00C1E765 /* LoginUseCase */, 6DA228F92A431FC60011E43E /* Search */, B2B7AEE22A3B4CE500AF04F5 /* MainTabBarController */, A21C51F62A42FF6400850B15 /* SavedRecipes */, @@ -1067,6 +1093,7 @@ 0296F7922A4342B500DBC86A /* FacebookCore */, 0296F7942A4342B500DBC86A /* FacebookLogin */, 02892EC72A58575C001A3DB4 /* Networking */, + 02557D722A697E8E0022756A /* Domain */, ); productName = Healthy; productReference = 027DDA1B2A0E6A660052818C /* Healthy.app */; @@ -1213,7 +1240,7 @@ 025511B82A3C624E00295B91 /* NewRelic */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; - buildActionMask = 2147483647; + buildActionMask = 12; files = ( ); inputFileListPaths = ( @@ -1330,6 +1357,7 @@ 79311FD82A19214700764707 /* UITextField+Style.swift in Sources */, 0255119F2A3C5D7300295B91 /* SearchViewModel.swift in Sources */, B2D402DF2A3BB1D700FDB941 /* UILabel+Style.swift in Sources */, + 02557D712A697DF90022756A /* LoginUseCase.swift in Sources */, A2FA41112A44412500C9C9A0 /* UIView+Style.swift in Sources */, 6D2145472A44C9EB0085C519 /* SearchFilter.swift in Sources */, 79311FD02A191D3700764707 /* FormTextField.swift in Sources */, @@ -1376,6 +1404,7 @@ 6DFEC6232A2E982C0090B2E2 /* LoginViewControllerTests.swift in Sources */, B85D26A72A3902DF000A463D /* EmailValidatorsTests.swift in Sources */, 6DFEC6252A2EA5770090B2E2 /* LoginViewModelMock.swift in Sources */, + 02CB9B642A6ADE4E00C1E765 /* DefaultLoginUseCaseTests.swift in Sources */, B29ABA5C2A3469CA00171A0C /* MainTapBarControllerTest.swift in Sources */, A21C51FB2A43036A00850B15 /* UITableViewMock.swift in Sources */, A21C51F92A42FF8D00850B15 /* SavedRecipesTableViewCellTests.swift in Sources */, @@ -1766,6 +1795,10 @@ package = 025511B52A3C623000295B91 /* XCRemoteSwiftPackageReference "newrelic-ios-agent-spm" */; productName = NewRelic; }; + 02557D722A697E8E0022756A /* Domain */ = { + isa = XCSwiftPackageProductDependency; + productName = Domain; + }; 02892EC72A58575C001A3DB4 /* Networking */ = { isa = XCSwiftPackageProductDependency; productName = Networking; diff --git a/Healthy/Classes/Modules/Onboarding/Login/LoginViewController.swift b/Healthy/Classes/Modules/Onboarding/Login/LoginViewController.swift index 2efa83d9..b7df59f6 100644 --- a/Healthy/Classes/Modules/Onboarding/Login/LoginViewController.swift +++ b/Healthy/Classes/Modules/Onboarding/Login/LoginViewController.swift @@ -116,14 +116,20 @@ private extension LoginViewController { viewModel.isLoadingIndicatorPublisher .sink { _ in // TODO: Show loading indicator. + } .store(in: &subscriptions) } func bindErrorMessage() { - viewModel.isShowErrorMessagePublisher - .sink { _ in - // TODO: Show error message. + viewModel.errorPublisher + .sink { [weak self] error in + let alertController = UIAlertController( + title: "Error!!", + message: error.localizedDescription, + preferredStyle: .alert) + + self?.present(alertController, animated: true) } .store(in: &subscriptions) } diff --git a/Healthy/Classes/Modules/Onboarding/Login/LoginViewModel.swift b/Healthy/Classes/Modules/Onboarding/Login/LoginViewModel.swift index dada944a..09c2b2e7 100644 --- a/Healthy/Classes/Modules/Onboarding/Login/LoginViewModel.swift +++ b/Healthy/Classes/Modules/Onboarding/Login/LoginViewModel.swift @@ -1,18 +1,26 @@ import Foundation import Combine +import Domain +import Factory +import UIKit final class LoginViewModel { + @Injected(\.loginUseCase) private var loginUseCase private unowned let coordinator: OnboardingCoordinator private var subscriptions = Set() @Published private var email: String = "" @Published private var password: String = "" @Published private var isLoadingState: Bool = false - @Published private var isShowErrorMessage: String = "" + @Published private var errorSubject = PassthroughSubject() @Published private var isLoginEnabled: Bool = true @Published private var isLoginStatus: Bool = false + // Add a reference to the UIButton that you want to animate + private weak var signInButton: UIButton? + init(coordinator: OnboardingCoordinator) { self.coordinator = coordinator + _ = User(email: "ahmdmhasn@gmail.com", tokenID: "12345678") } } @@ -29,12 +37,25 @@ extension LoginViewModel: LoginViewModelInput { } func performSignIn() { - // Sending API Request - // Caching User - // Did complete signIn - DispatchQueue.global().asyncAfter(deadline: .now() + 1) { + Task { @MainActor in + // show loading DispatchQueue.main.async { + self.signInButton?.startAnimating() + } + defer { + // dismiss loading + DispatchQueue.main.async { + self.signInButton?.stopAnimating() + } + } + do { + _ = try await loginUseCase.login( + email: email, + password: password) self.coordinator.didFinishSignIn() + } catch { + // handle error + print(error) } } } @@ -51,6 +72,7 @@ extension LoginViewModel: LoginViewModelInput { // End loading } catch { // Show error + errorSubject.send(error) } } } @@ -59,12 +81,13 @@ extension LoginViewModel: LoginViewModelInput { // MARK: Output extension LoginViewModel: LoginViewModelOutput { + var isLoadingIndicatorPublisher: AnyPublisher { $isLoadingState.eraseToAnyPublisher() } - var isShowErrorMessagePublisher: AnyPublisher { - $isShowErrorMessage.eraseToAnyPublisher() + var errorPublisher: AnyPublisher { + errorSubject.eraseToAnyPublisher() } var isLoginEnabledPublisher: AnyPublisher { diff --git a/Healthy/Classes/Modules/Onboarding/Login/LoginViewModelType.swift b/Healthy/Classes/Modules/Onboarding/Login/LoginViewModelType.swift index 64b708d6..3826d34b 100644 --- a/Healthy/Classes/Modules/Onboarding/Login/LoginViewModelType.swift +++ b/Healthy/Classes/Modules/Onboarding/Login/LoginViewModelType.swift @@ -14,7 +14,7 @@ protocol LoginViewModelInput { protocol LoginViewModelOutput { var isLoadingIndicatorPublisher: AnyPublisher { get } - var isShowErrorMessagePublisher: AnyPublisher { get } + var errorPublisher: AnyPublisher { get } var isLoginEnabledPublisher: AnyPublisher { get } var isLoginStatusPublisher: AnyPublisher { get } } diff --git a/Healthy/Classes/UseCases/LoginUseCase.swift b/Healthy/Classes/UseCases/LoginUseCase.swift new file mode 100644 index 00000000..97df6879 --- /dev/null +++ b/Healthy/Classes/UseCases/LoginUseCase.swift @@ -0,0 +1,30 @@ +import Domain +import Networking +import Factory +import Foundation + +extension Container { + var loginUseCase: Factory { + Factory(self) { + DefaultLoginUseCase() + } + } +} + +final class DefaultLoginUseCase: LoginUseCase { + + @Injected(\.networking) private var networking + + func login(email: String, password: String) async throws -> User { + // TODO: replace mock Request with Actual Request + let request = LoginRequest(email: email, password: password) + do { + _ = try await networking.dispatch(request) + return User(email: "ahmdmhasn@gmail.com", tokenID: "12345678") + + } catch { + // If Error + throw NSError(domain: "Some Error", code: -1) + } + } +} diff --git a/HealthyTests/Classes/Modules/LoginUseCase/DefaultLoginUseCaseTests.swift b/HealthyTests/Classes/Modules/LoginUseCase/DefaultLoginUseCaseTests.swift new file mode 100644 index 00000000..3eb199ee --- /dev/null +++ b/HealthyTests/Classes/Modules/LoginUseCase/DefaultLoginUseCaseTests.swift @@ -0,0 +1,28 @@ +import XCTest +@testable import Domain +@testable import Networking +@testable import Healthy + +final class DefaultLoginUseCaseTests: XCTestCase { + var loginUseCase: DefaultLoginUseCase! + + override func setUp() { + super.setUp() + loginUseCase = DefaultLoginUseCase() + } + + override func tearDown() { + loginUseCase = nil + super.tearDown() + } + + func testLogin() async throws { + let email = "test@example.com" + let password = "password" + + let user = try await loginUseCase.login(email: email, password: password) + + XCTAssertEqual(user.email, "ahmdmhasn@gmail.com") + XCTAssertEqual(user.tokenID, "12345678") + } +} diff --git a/HealthyTests/Classes/Modules/Onboarding/Login/Mocks/LoginViewModelMock.swift b/HealthyTests/Classes/Modules/Onboarding/Login/Mocks/LoginViewModelMock.swift index 160a5893..a4a77d44 100644 --- a/HealthyTests/Classes/Modules/Onboarding/Login/Mocks/LoginViewModelMock.swift +++ b/HealthyTests/Classes/Modules/Onboarding/Login/Mocks/LoginViewModelMock.swift @@ -2,13 +2,14 @@ import Combine @testable import Healthy final class LoginViewModelMock: LoginViewModelType { + private let errorSubject = PassthroughSubject() - var isLoadingIndicatorPublisher: AnyPublisher { - Just(false).eraseToAnyPublisher() + var errorPublisher: AnyPublisher { + errorSubject.eraseToAnyPublisher() } - var isShowErrorMessagePublisher: AnyPublisher { - Just("").eraseToAnyPublisher() + var isLoadingIndicatorPublisher: AnyPublisher { + Just(false).eraseToAnyPublisher() } var isLoginEnabledPublisher: AnyPublisher { diff --git a/Networking/Tests/NetworkingTests/Requests/MealCategoriesRequestTests.swift b/Networking/Tests/NetworkingTests/Requests/MealCategoriesRequestTests.swift index d631babf..d8df5758 100644 --- a/Networking/Tests/NetworkingTests/Requests/MealCategoriesRequestTests.swift +++ b/Networking/Tests/NetworkingTests/Requests/MealCategoriesRequestTests.swift @@ -24,19 +24,20 @@ final class MealCategoriesRequestTests: XCTestCase { func testMealCategoriesResponseDecoder() throws { // Given - // swiftlint:disable line_length let mealCategoriesResponseAsString = """ - { - "categories": [ - { - "idCategory": "1", - "strCategory": "Beef", - "strCategoryThumb": "https://www.themealdb.com/images/category/beef.png", - "strCategoryDescription": "Beef is the culinary name for meat from cattle, particularly skeletal muscle. Humans have been eating beef since prehistoric times.[1] Beef is a source of high-quality protein and essential nutrients.[2]" - } - ] - } - """ + { + "categories": [ + { + "idCategory": "1", + "strCategory": "Beef", + "strCategoryThumb": "https://www.themealdb.com/images/category/beef.png", + "strCategoryDescription": "Beef is the culinary name for meat from cattle, \ + particularly skeletal muscle. Humans have been eating beef since prehistoric times.[1] \ + Beef is a source of high-quality protein and essential nutrients.[2]" + } + ] + } + """ // When let mealCategoriesResponseData = try XCTUnwrap(mealCategoriesResponseAsString.data(using: .utf8))