diff --git a/Domain/.gitignore b/Domain/.gitignore new file mode 100644 index 00000000..3b298120 --- /dev/null +++ b/Domain/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Domain/Package.swift b/Domain/Package.swift new file mode 100644 index 00000000..59892495 --- /dev/null +++ b/Domain/Package.swift @@ -0,0 +1,28 @@ +// 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", + 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..eb40e6c7 --- /dev/null +++ b/Domain/README.md @@ -0,0 +1,3 @@ +# Domain + +A description of this package. 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..714dc842 --- /dev/null +++ b/Domain/Sources/Domain/UseCases/LoginUseCase.swift @@ -0,0 +1,4 @@ +@available(iOS 13.0.0, *) +public protocol LoginUseCase { + func login(email: String, password: String) async throws -> User +} diff --git a/Domain/Sources/Domain/UseCases/RegisterUseCase.swift b/Domain/Sources/Domain/UseCases/RegisterUseCase.swift new file mode 100644 index 00000000..24307aec --- /dev/null +++ b/Domain/Sources/Domain/UseCases/RegisterUseCase.swift @@ -0,0 +1,4 @@ +@available(iOS 13.0.0, *) +public protocol RegisterUseCase { + func register(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 71515ab4..abe2bf1c 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 */; }; @@ -68,6 +70,7 @@ 0296F7932A4342B500DBC86A /* FacebookCore in Frameworks */ = {isa = PBXBuildFile; productRef = 0296F7922A4342B500DBC86A /* FacebookCore */; }; 0296F7952A4342B500DBC86A /* FacebookLogin in Frameworks */ = {isa = PBXBuildFile; productRef = 0296F7942A4342B500DBC86A /* FacebookLogin */; }; 0296F7972A43491B00DBC86A /* FacebookAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0296F7962A43491B00DBC86A /* FacebookAuthenticator.swift */; }; + 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 */; }; @@ -111,6 +114,8 @@ 79311FD82A19214700764707 /* UITextField+Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79311FD72A19214700764707 /* UITextField+Style.swift */; }; 798B59B22A5D15E600E4DCFF /* MealCategories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 798B59B12A5D15E600E4DCFF /* MealCategories.swift */; }; 79B65D282A0FB4310042CC46 /* UIButton+Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B65D272A0FB4310042CC46 /* UIButton+Style.swift */; }; + 79FC924F2A704DC30001F255 /* RegisterUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79FC924E2A704DC30001F255 /* RegisterUseCase.swift */; }; + 79FC92512A705BE80001F255 /* DefaultRegisterUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79FC92502A705BE80001F255 /* DefaultRegisterUseCaseTests.swift */; }; A21C51F92A42FF8D00850B15 /* SavedRecipesTableViewCellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A21C51F82A42FF8D00850B15 /* SavedRecipesTableViewCellTests.swift */; }; A21C51FB2A43036A00850B15 /* UITableViewMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A21C51FA2A43036A00850B15 /* UITableViewMock.swift */; }; A27861152A445E8E00BA7018 /* UITableView+RegisterNib.swift in Sources */ = {isa = PBXBuildFile; fileRef = A27861142A445E8E00BA7018 /* UITableView+RegisterNib.swift */; }; @@ -193,6 +198,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 = ""; }; @@ -221,6 +228,7 @@ 02892EC52A585558001A3DB4 /* Networking */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Networking; sourceTree = ""; }; 02892EC92A599CBC001A3DB4 /* Container+Networking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Container+Networking.swift"; sourceTree = ""; }; 0296F7962A43491B00DBC86A /* FacebookAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FacebookAuthenticator.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 = ""; }; @@ -262,6 +270,8 @@ 79311FD72A19214700764707 /* UITextField+Style.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextField+Style.swift"; sourceTree = ""; }; 798B59B12A5D15E600E4DCFF /* MealCategories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealCategories.swift; sourceTree = ""; }; 79B65D272A0FB4310042CC46 /* UIButton+Style.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIButton+Style.swift"; sourceTree = ""; }; + 79FC924E2A704DC30001F255 /* RegisterUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterUseCase.swift; sourceTree = ""; }; + 79FC92502A705BE80001F255 /* DefaultRegisterUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultRegisterUseCaseTests.swift; sourceTree = ""; }; A21C51F82A42FF8D00850B15 /* SavedRecipesTableViewCellTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedRecipesTableViewCellTests.swift; sourceTree = ""; }; A21C51FA2A43036A00850B15 /* UITableViewMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableViewMock.swift; sourceTree = ""; }; A27861142A445E8E00BA7018 /* UITableView+RegisterNib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+RegisterNib.swift"; sourceTree = ""; }; @@ -308,6 +318,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 */, ); @@ -400,7 +411,6 @@ 025511BC2A3C656300295B91 /* Recipe.swift */, 025511BA2A3C655200295B91 /* SavedRecipe.swift */, 798B59B12A5D15E600E4DCFF /* MealCategories.swift */, - 0286F2852A5EEB1800F20478 /* RandomMealEntity.swift */, C972DB2E2A5F6F91000041D1 /* FilterByMainIngredient.swift */, 6D6B89AC2A5DE99F00E52F4C /* FilterByArea.swift */, 0286F2852A5EEB1800F20478 /* RandomMealEntity.swift */, @@ -441,6 +451,15 @@ path = CreateAccount; sourceTree = ""; }; + 02557D6F2A697D8E0022756A /* UseCases */ = { + isa = PBXGroup; + children = ( + 02557D702A697DF90022756A /* LoginUseCase.swift */, + 79FC924E2A704DC30001F255 /* RegisterUseCase.swift */, + ); + path = UseCases; + sourceTree = ""; + }; 025A474E2A336AE3008BF85A /* Dashboard */ = { isa = PBXGroup; children = ( @@ -507,6 +526,7 @@ 027DDA122A0E6A660052818C = { isa = PBXGroup; children = ( + 02557D6E2A697C3D0022756A /* Domain */, 02892EC52A585558001A3DB4 /* Networking */, 027DDA1D2A0E6A660052818C /* Healthy */, 027DDA342A0E6A680052818C /* HealthyTests */, @@ -530,6 +550,9 @@ isa = PBXGroup; children = ( 027DDA4F2A0E745F0052818C /* Classes */, + 027DDA202A0E6A660052818C /* SceneDelegate.swift */, + 027DDA1E2A0E6A660052818C /* AppDelegate.swift */, + 025511C32A3D058800295B91 /* AppCoordinator.swift */, 027DDA4E2A0E74490052818C /* Resources */, ); path = Healthy; @@ -576,10 +599,8 @@ 027DDA542A0E76C50052818C /* ReusableViews */, 027DDA552A0E76DC0052818C /* Services */, 025A47832A337337008BF85A /* System */, + 02557D6F2A697D8E0022756A /* UseCases */, 027DDA532A0E76A00052818C /* Utilities */, - 025511C32A3D058800295B91 /* AppCoordinator.swift */, - 027DDA1E2A0E6A660052818C /* AppDelegate.swift */, - 027DDA202A0E6A660052818C /* SceneDelegate.swift */, ); path = Classes; sourceTree = ""; @@ -665,6 +686,15 @@ name = Frameworks; sourceTree = ""; }; + 02CB9B5F2A6AD5DD00C1E765 /* LoginUseCase */ = { + isa = PBXGroup; + children = ( + 02CB9B632A6ADE4E00C1E765 /* DefaultLoginUseCaseTests.swift */, + 79FC92502A705BE80001F255 /* DefaultRegisterUseCaseTests.swift */, + ); + path = LoginUseCase; + sourceTree = ""; + }; 02EEC3F92A4C22B70007DA0C /* Mocks */ = { isa = PBXGroup; children = ( @@ -818,6 +848,7 @@ 6DFEC6202A2E97FF0090B2E2 /* Modules */ = { isa = PBXGroup; children = ( + 02CB9B5F2A6AD5DD00C1E765 /* LoginUseCase */, 6DA228F92A431FC60011E43E /* Search */, B2B7AEE22A3B4CE500AF04F5 /* MainTabBarController */, A21C51F62A42FF6400850B15 /* SavedRecipes */, @@ -1075,6 +1106,7 @@ 0296F7922A4342B500DBC86A /* FacebookCore */, 0296F7942A4342B500DBC86A /* FacebookLogin */, 02892EC72A58575C001A3DB4 /* Networking */, + 02557D722A697E8E0022756A /* Domain */, ); productName = Healthy; productReference = 027DDA1B2A0E6A660052818C /* Healthy.app */; @@ -1328,6 +1360,7 @@ 025511BD2A3C656300295B91 /* Recipe.swift in Sources */, 0255119D2A3C5D7300295B91 /* SearchViewModelType.swift in Sources */, 6DF5250C2A1E855F0027502C /* LoginViewModelType.swift in Sources */, + 79FC924F2A704DC30001F255 /* RegisterUseCase.swift in Sources */, 27C7C7F72A4341A300FECE25 /* Logging.swift in Sources */, 0229242A2A33A137009290A8 /* UIView+Helpers.swift in Sources */, 798B59B22A5D15E600E4DCFF /* MealCategories.swift in Sources */, @@ -1335,6 +1368,7 @@ 79311FD82A19214700764707 /* UITextField+Style.swift in Sources */, 0255119F2A3C5D7300295B91 /* SearchViewModel.swift in Sources */, B2D402DF2A3BB1D700FDB941 /* UILabelStyle.swift in Sources */, + 02557D712A697DF90022756A /* LoginUseCase.swift in Sources */, A2FA41112A44412500C9C9A0 /* UIView+Style.swift in Sources */, 6D2145472A44C9EB0085C519 /* SearchFilter.swift in Sources */, 79311FD02A191D3700764707 /* FormTextField.swift in Sources */, @@ -1378,6 +1412,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 */, @@ -1386,6 +1421,7 @@ 02279EA72A3373C400C607AD /* CreateAccountViewModelTests.swift in Sources */, 02EEC3FB2A4C22C90007DA0C /* SavedRecipesViewModelMock.swift in Sources */, C9E6BBE92A38D7EA0073E51A /* SplashViewControllerTest.swift in Sources */, + 79FC92512A705BE80001F255 /* DefaultRegisterUseCaseTests.swift in Sources */, 206FF79A2A3A32DD00130CA4 /* CreateViewModelMock.swift in Sources */, 6DA228FB2A431FF30011E43E /* SearchViewModelTests.swift in Sources */, 6D9D97A72A4B188400BB3589 /* PublisherMultibleValueSpy.swift in Sources */, @@ -1768,6 +1804,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/AppCoordinator.swift b/Healthy/AppCoordinator.swift similarity index 100% rename from Healthy/Classes/AppCoordinator.swift rename to Healthy/AppCoordinator.swift diff --git a/Healthy/Classes/AppDelegate.swift b/Healthy/AppDelegate.swift similarity index 100% rename from Healthy/Classes/AppDelegate.swift rename to Healthy/AppDelegate.swift diff --git a/Healthy/Classes/Modules/Onboarding/CreateAccount/CreateAccountViewController.swift b/Healthy/Classes/Modules/Onboarding/CreateAccount/CreateAccountViewController.swift index d3dde1cf..c614b8e8 100644 --- a/Healthy/Classes/Modules/Onboarding/CreateAccount/CreateAccountViewController.swift +++ b/Healthy/Classes/Modules/Onboarding/CreateAccount/CreateAccountViewController.swift @@ -1,4 +1,6 @@ import UIKit +import Combine + // MARK: - CreateAccountViewController final class CreateAccountViewController: UIViewController { @@ -19,7 +21,8 @@ final class CreateAccountViewController: UIViewController { // MARK: Properties private let viewModel: CreateAccountViewModelType - + private var subscriptions: Set = [] + // MARK: Init init(viewModel: CreateAccountViewModelType) { @@ -63,9 +66,9 @@ private extension CreateAccountViewController { } func configureViewModel() { - viewModel.configureButtonEnabled { [weak self] isEnabled in - self?.signUpButton.isEnabled = isEnabled - } + bindLoadingIndicator() + bindErrorMessage() + bindButtonState() } } @@ -89,6 +92,43 @@ private extension CreateAccountViewController { } @objc private func didTapSignUp(_ sender: Any) { + viewModel.performSignUp() + } +} + +// MARK: - Configure ViewModel + +private extension CreateAccountViewController { + func bindLoadingIndicator() { + viewModel.loadingIndicatorPublisher + .sink { [weak self] isLoading in + guard let self = self else { return } + switch isLoading { + case true: + self.signUpButton.startAnimating() + case false: + self.signUpButton.stopAnimating() + } + } + .store(in: &subscriptions) + } + + func bindErrorMessage() { + viewModel.errorPublisher + .sink { error in + let alertController = UIAlertController( + title: "Error!!", + message: error.localizedDescription, + preferredStyle: .alert) + + self.present(alertController, animated: true) + } + .store(in: &subscriptions) + } + func bindButtonState() { + viewModel.registerButtonEnabledPublisher + .assign(to: \.isEnabled, on: signUpButton) + .store(in: &subscriptions) } } diff --git a/Healthy/Classes/Modules/Onboarding/CreateAccount/CreateAccountViewModel.swift b/Healthy/Classes/Modules/Onboarding/CreateAccount/CreateAccountViewModel.swift index b7c2be6a..cfad51ba 100644 --- a/Healthy/Classes/Modules/Onboarding/CreateAccount/CreateAccountViewModel.swift +++ b/Healthy/Classes/Modules/Onboarding/CreateAccount/CreateAccountViewModel.swift @@ -1,63 +1,85 @@ import Foundation +import Combine +import Factory // MARK: CreateAccountViewModel final class CreateAccountViewModel { - private var username: String = "" - private var email: String = "" - private var password: String = "" - private var confirmPassword: String = "" - private var isChecked: Bool = false - private var onButtonEnabled: (Bool) -> Void = { _ in } + @Injected(\.registerUseCase) private var registerUseCase + private unowned let coordinator: OnboardingCoordinator + private var subscriptions = Set() + + @Published private var username: String = "" + @Published private var email: String = "" + @Published private var password: String = "" + @Published private var confirmPassword: String = "" + @Published private var isChecked: Bool = false + + @Published private var loadingState: Bool = false + @Published private var errorSubject = PassthroughSubject() + @Published private var registerButtonEnabled: Bool = true + + init(coordinator: OnboardingCoordinator) { + self.coordinator = coordinator + // TODO: - Need to add validation in text fields. + } } // MARK: CreateAccountViewModelInput extension CreateAccountViewModel: CreateAccountViewModelInput { func updateUsername(_ text: String) { username = text - updateEnabledStateButton() } func updateEmail(_ text: String) { email = text - updateEnabledStateButton() } func updatePassword(_ text: String) { password = text - updateEnabledStateButton() } func updateConfirmPassword(_ text: String) { confirmPassword = text - updateEnabledStateButton() } func updateAcceptTermsAndConditions(_ isChecked: Bool) { self.isChecked = isChecked - updateEnabledStateButton() + } + + func performSignUp() { + Task { + // show loading + loadingState = true + + defer { + // dismiss loading + loadingState = false + } + + do { + _ = try await registerUseCase.register( + email: email, + password: password) + coordinator.didFinishSignIn() + } catch let error { + // handle error + errorSubject.send(error) + } + } } } // MARK: LoginViewModelOutput extension CreateAccountViewModel: CreateAccountViewModelOutput { - func configureButtonEnabled(onEnabled: @escaping (Bool) -> Void) { - onButtonEnabled = onEnabled - updateEnabledStateButton() + var loadingIndicatorPublisher: AnyPublisher { + $loadingState.eraseToAnyPublisher() } -} - -// MARK: Private Handlers -private extension CreateAccountViewModel { - func updateEnabledStateButton() { - let isUsernameValid = !username.isEmpty - // TODO: [HL-20] Add email validator - let isEmailValid = !email.isEmpty - let isPasswordValid = !password.isEmpty && PasswordValidator().hasValidValue(password) - let isConfirmPasswordValid = !confirmPassword.isEmpty && confirmPassword == password - - let isButtonEnabled = isUsernameValid && isEmailValid && isPasswordValid - && isConfirmPasswordValid - && isChecked - onButtonEnabled(isButtonEnabled) + + var errorPublisher: AnyPublisher { + errorSubject.eraseToAnyPublisher() + } + + var registerButtonEnabledPublisher: AnyPublisher { + $registerButtonEnabled.eraseToAnyPublisher() } } diff --git a/Healthy/Classes/Modules/Onboarding/CreateAccount/CreateAccountviewModelType.swift b/Healthy/Classes/Modules/Onboarding/CreateAccount/CreateAccountviewModelType.swift index 44a5a212..fce07765 100644 --- a/Healthy/Classes/Modules/Onboarding/CreateAccount/CreateAccountviewModelType.swift +++ b/Healthy/Classes/Modules/Onboarding/CreateAccount/CreateAccountviewModelType.swift @@ -1,3 +1,5 @@ +import Combine + typealias CreateAccountViewModelType = CreateAccountViewModelInput & CreateAccountViewModelOutput // MARK: CreateAccountViewModelInput @@ -7,9 +9,12 @@ protocol CreateAccountViewModelInput { func updatePassword(_ text: String) func updateConfirmPassword(_ text: String) func updateAcceptTermsAndConditions(_ isChecked: Bool) + func performSignUp() } // MARK: CreateAccountViewModelOutput protocol CreateAccountViewModelOutput { - func configureButtonEnabled(onEnabled: @escaping (Bool) -> Void) + var loadingIndicatorPublisher: AnyPublisher { get } + var errorPublisher: AnyPublisher { get } + var registerButtonEnabledPublisher: AnyPublisher { get } } diff --git a/Healthy/Classes/Modules/Onboarding/Login/LoginViewController.swift b/Healthy/Classes/Modules/Onboarding/Login/LoginViewController.swift index 2c7a16cc..20db9b99 100644 --- a/Healthy/Classes/Modules/Onboarding/Login/LoginViewController.swift +++ b/Healthy/Classes/Modules/Onboarding/Login/LoginViewController.swift @@ -35,7 +35,6 @@ final class LoginViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - configureAppearance() configureViewModelInputs() configureViewModelOutputs() @@ -116,14 +115,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 { 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/Healthy/Classes/UseCases/RegisterUseCase.swift b/Healthy/Classes/UseCases/RegisterUseCase.swift new file mode 100644 index 00000000..dbdf1870 --- /dev/null +++ b/Healthy/Classes/UseCases/RegisterUseCase.swift @@ -0,0 +1,29 @@ +import Foundation +import Factory +import Domain +import Networking + +extension Container { + var registerUseCase: Factory { + Factory(self) { + DefaultRegisterUseCase() + } + } +} + +final class DefaultRegisterUseCase: RegisterUseCase { + + @Injected(\.networking) private var networking + + func register(email: String, password: String) async throws -> Domain.User { + let request = RegisterRequest(email: email, password: password) + + do { + let response = try await networking.dispatch(request) + return User(email: response.email, tokenID: response.tokenId) + } catch { + throw NSError(domain: "we have error !!!", code: -1) + } + } +} + diff --git a/Healthy/Classes/SceneDelegate.swift b/Healthy/SceneDelegate.swift similarity index 100% rename from Healthy/Classes/SceneDelegate.swift rename to Healthy/SceneDelegate.swift 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/LoginUseCase/DefaultRegisterUseCaseTests.swift b/HealthyTests/Classes/Modules/LoginUseCase/DefaultRegisterUseCaseTests.swift new file mode 100644 index 00000000..9ac0c1b9 --- /dev/null +++ b/HealthyTests/Classes/Modules/LoginUseCase/DefaultRegisterUseCaseTests.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import Domain +@testable import Networking +@testable import Healthy + +final class DefaultRegisterUseCaseTests: XCTestCase { + + // MARK: Properties + + private var sut: DefaultRegisterUseCase! + + // MARK: Life cycle + + override func setUp() { + super.setUp() + sut = DefaultRegisterUseCase() + } + + // MARK: Tests + + func testRegister() async throws { + let email = "test@example.com" + let password = "password" + + let user = try await sut.register(email: email, password: password) + + XCTAssertEqual(user.email, "test@example.com") + XCTAssertEqual(user.tokenID, "password") + } +} 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 0792dd93..d8df5758 100644 --- a/Networking/Tests/NetworkingTests/Requests/MealCategoriesRequestTests.swift +++ b/Networking/Tests/NetworkingTests/Requests/MealCategoriesRequestTests.swift @@ -4,43 +4,45 @@ import XCTest final class MealCategoriesRequestTests: XCTestCase { // MARK: Properties - + private var sut: MealCategoriesRequest! - + // MARK: - Lifecycle - + override func setUp() { sut = MealCategoriesRequest() } - + // MARK: - Tests - + func testMealCategoriesRequestProperties() { // Then XCTAssertEqual(sut.baseUrl, Constants.theMealDB) XCTAssertEqual(sut.path, "categories.php") XCTAssertEqual(sut.method, "GET") } - + func testMealCategoriesResponseDecoder() throws { // Given 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)) let mealCategoriesResponse = try? sut.responseDecoder(mealCategoriesResponseData) - + // Then XCTAssertNotNil(mealCategoriesResponse) XCTAssertEqual(mealCategoriesResponse?.categories.count, 1)