diff --git a/SwiftUIBasics.xcodeproj/project.pbxproj b/SwiftUIBasics.xcodeproj/project.pbxproj index b66523d..70672e9 100644 --- a/SwiftUIBasics.xcodeproj/project.pbxproj +++ b/SwiftUIBasics.xcodeproj/project.pbxproj @@ -8,6 +8,9 @@ /* Begin PBXBuildFile section */ 30CA734E2B24AE460025B2EB /* SymbolsAnimations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30CA734D2B24AE460025B2EB /* SymbolsAnimations.swift */; }; + 5544EF492B52003C0070BA56 /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5544EF482B52003C0070BA56 /* AuthenticationViewModel.swift */; }; + 5544EF4B2B5202A60070BA56 /* TestSignUp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5544EF4A2B5202A60070BA56 /* TestSignUp.swift */; }; + 5544EF4D2B5204A00070BA56 /* SignUpViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5544EF4C2B5204A00070BA56 /* SignUpViewModel.swift */; }; 557042892B24CB310048C81C /* ProfileListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 557042882B24CB310048C81C /* ProfileListView.swift */; }; 5570428B2B24D2F90048C81C /* TabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5570428A2B24D2F90048C81C /* TabBarView.swift */; }; 5570428D2B24D8050048C81C /* SignUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5570428C2B24D8050048C81C /* SignUpView.swift */; }; @@ -24,6 +27,9 @@ /* Begin PBXFileReference section */ 30CA734D2B24AE460025B2EB /* SymbolsAnimations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymbolsAnimations.swift; sourceTree = ""; }; + 5544EF482B52003C0070BA56 /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; + 5544EF4A2B5202A60070BA56 /* TestSignUp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSignUp.swift; sourceTree = ""; }; + 5544EF4C2B5204A00070BA56 /* SignUpViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpViewModel.swift; sourceTree = ""; }; 557042882B24CB310048C81C /* ProfileListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileListView.swift; sourceTree = ""; }; 5570428A2B24D2F90048C81C /* TabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarView.swift; sourceTree = ""; }; 5570428C2B24D8050048C81C /* SignUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpView.swift; sourceTree = ""; }; @@ -54,6 +60,8 @@ isa = PBXGroup; children = ( 5598D3252B2404FC00A6AFD3 /* ProfileCardVM.swift */, + 5544EF482B52003C0070BA56 /* AuthenticationViewModel.swift */, + 5544EF4C2B5204A00070BA56 /* SignUpViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -77,6 +85,7 @@ isa = PBXGroup; children = ( 5598D3212B2403CE00A6AFD3 /* CircularImage.swift */, + 5544EF4A2B5202A60070BA56 /* TestSignUp.swift */, ); path = Components; sourceTree = ""; @@ -188,10 +197,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5544EF492B52003C0070BA56 /* AuthenticationViewModel.swift in Sources */, 5570428B2B24D2F90048C81C /* TabBarView.swift in Sources */, 5598D3162B23DF6D00A6AFD3 /* ContentView.swift in Sources */, 5598D3262B2404FC00A6AFD3 /* ProfileCardVM.swift in Sources */, 5570428F2B24E50E0048C81C /* RatingView.swift in Sources */, + 5544EF4B2B5202A60070BA56 /* TestSignUp.swift in Sources */, + 5544EF4D2B5204A00070BA56 /* SignUpViewModel.swift in Sources */, 30CA734E2B24AE460025B2EB /* SymbolsAnimations.swift in Sources */, 5598D3142B23DF6D00A6AFD3 /* SwiftUIBasicsApp.swift in Sources */, 5598D3242B24049B00A6AFD3 /* ProfileCardView.swift in Sources */, diff --git a/SwiftUIBasics.xcodeproj/project.xcworkspace/xcuserdata/diplomado.xcuserdatad/UserInterfaceState.xcuserstate b/SwiftUIBasics.xcodeproj/project.xcworkspace/xcuserdata/diplomado.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..a4bf5b8 Binary files /dev/null and b/SwiftUIBasics.xcodeproj/project.xcworkspace/xcuserdata/diplomado.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/SwiftUIBasics/ViewModels/AuthenticationViewModel.swift b/SwiftUIBasics/ViewModels/AuthenticationViewModel.swift new file mode 100644 index 0000000..2dc767a --- /dev/null +++ b/SwiftUIBasics/ViewModels/AuthenticationViewModel.swift @@ -0,0 +1,68 @@ +// +// AuthenticationViewModel.swift +// SwiftUIBasics +// +// Created by Diplomado on 12/01/24. +// + +import Foundation +import Combine + +final class AuthenticationViewModel: ObservableObject { + // MARK: - Password input + @Published var password = "" + @Published var confirmPassword = "" + + // MARK: - Password requirements + @Published var hasEightChar = false + @Published var hasSpacialChar = false + @Published var hasOneDigit = false + @Published var hasOneUpperCaseChar = false + @Published var confirmationMatch = false + @Published var areAllFieldsValid = false + + init() { + validateSignUpFields() + } + + private func validateSignUpFields() { + /// Check password has minimum 8 characters + $password + .map { password in + password.count >= 8 + } + .assign(to: &$hasEightChar) + /// Check password has minimum 1 special character + $password + .map { password in + password.rangeOfCharacter(from: CharacterSet(charactersIn: "!@#$%^&*()_+-=[]{}|:\"';<>,.?/~`")) != nil + } + .assign(to: &$hasSpacialChar) + /// Check password has minimum 1 digit + $password + .map { password in + password.contains { $0.isNumber } + } + .assign(to: &$hasOneDigit) + /// Check password has minimum 1 uppercase letter + $password + .map { password in + password.contains { $0.isUppercase } + } + .assign(to: &$hasOneUpperCaseChar) + /// Check confirmation match password + Publishers.CombineLatest($password, $confirmPassword) + .map { [weak self] _, _ in + guard let self else { return false} + return self.password == self.confirmPassword + } + .assign(to: &$confirmationMatch) + /// Check all fields match + Publishers.CombineLatest($password, $confirmPassword) + .map { [weak self] _, _ in + guard let self else { return false} + return self.hasEightChar && self.hasSpacialChar && self.hasOneDigit && self.hasOneUpperCaseChar && self.confirmationMatch + } + .assign(to: &$areAllFieldsValid) + } + } diff --git a/SwiftUIBasics/ViewModels/SignUpViewModel.swift b/SwiftUIBasics/ViewModels/SignUpViewModel.swift new file mode 100644 index 0000000..f9b7d31 --- /dev/null +++ b/SwiftUIBasics/ViewModels/SignUpViewModel.swift @@ -0,0 +1,80 @@ +// +// SignUpViewModel.swift +// SwiftUIBasics +// +// Created by Diplomado on 12/01/24. +// + +import Foundation +import Combine + + +final class SignUpViewModel: ObservableObject { + + // Input values from view + @Published var userName = "" + @Published var userEmail = "" + @Published var userPassword = "" + @Published var userRepeatedPassword = "" + + // Output subscribers + @Published var formIsValid = false + + private var publishers = Set() + + init() { + isSignupFormValidPublisher + .receive(on: RunLoop.main) + .assign(to: \.formIsValid, on: self) + .store(in: &publishers) + } + +} + +private extension SignUpViewModel { + + var isUserNameValidPublisher: AnyPublisher { + $userName + .map { name in + return name.count >= 3 + } + .eraseToAnyPublisher() + } + + var isUserEmailValidPublisher: AnyPublisher { + $userPassword + .map { password in + return password.count >= 8 + } + .eraseToAnyPublisher() + } + + var isPasswordValidPublisher: AnyPublisher { + $userPassword + .map { password in + return password.count >= 8 + } + .eraseToAnyPublisher() + } + + var passwordMatchesPublisher: AnyPublisher { + Publishers.CombineLatest($userPassword, $userRepeatedPassword) + .map { password, repeated in + return password == repeated + } + .eraseToAnyPublisher() + } + + var isSignupFormValidPublisher: AnyPublisher { + Publishers.CombineLatest4( + isUserNameValidPublisher, + isUserEmailValidPublisher, + isPasswordValidPublisher, + passwordMatchesPublisher) + .map { isNameValid, isEmailValid, isPasswordValid, passwordMatches in + return isNameValid && isEmailValid && isPasswordValid && passwordMatches + } + .eraseToAnyPublisher() + } + +} diff --git a/SwiftUIBasics/Views/Components/TestSignUp.swift b/SwiftUIBasics/Views/Components/TestSignUp.swift new file mode 100644 index 0000000..7580542 --- /dev/null +++ b/SwiftUIBasics/Views/Components/TestSignUp.swift @@ -0,0 +1,50 @@ +// +// TestSignUp.swift +// SwiftUIBasics +// +// Created by Diplomado on 12/01/24. +// + +import SwiftUI + +struct UserFormTextField: View { + + + + +} + + + +struct SignUpPasswordScreen: View { + @StateObject private var authVM = AuthenticationViewModel() + + var body: some View { + VStack(spacing: 0) { + VStack { + VStack(alignment: .leading, spacing: 10) { + Text("Password") + .font(.title) + .bold() + Text("Password must have more than 8 characters, contain some special character, one digit, one uppercase letter") + .font(.caption) + } + Group { + UserFormTextField(text: $authVM.password, type: .password) + VStack(alignment: .leading) { + RequirementsPickerView(type: .eightChar, toggleState: $authVM.hasEightChar) + RequirementsPickerView(type: .spacialChar, toggleState: $authVM.hasSpacialChar) + RequirementsPickerView(type: .oneDigit, toggleState: $authVM.hasOneDigit) + RequirementsPickerView(type: .upperCaseChar, toggleState: $authVM.hasOneUpperCaseChar) + } + UserFormTextField(text: $authVM.confirmPassword, type: .repeatPassword) + RequirementsPickerView(type: .confirmation, toggleState: $authVM.confirmationMatch) + } + } + } + } +} + +#Preview { + SignUpPasswordScreen() +} diff --git a/SwiftUIBasics/Views/PlansView.swift b/SwiftUIBasics/Views/PlansView.swift index cb97876..2d026b2 100644 --- a/SwiftUIBasics/Views/PlansView.swift +++ b/SwiftUIBasics/Views/PlansView.swift @@ -7,12 +7,69 @@ import SwiftUI +struct Plans: View{ + let hasImg: Bool + let imgName: String + let planName: String + let planCost: Double + let hasDesc: Bool + let description: String + let backColor: Color + let fontColor: Color + let sizesBack: CGSize + var body: some View{ + ZStack{ + RoundedRectangle(cornerSize: CGSize(width: 20, height: 20)) + .frame(width: sizesBack.width, height: sizesBack.height) + .aspectRatio(contentMode: .fill) + .foregroundStyle(backColor) + VStack{ + if hasImg{ + Image(systemName: imgName) + + .font(.largeTitle) + } + Text(planName) + .font(.title.bold()) + .foregroundStyle(fontColor) + Text("$\(planCost)") + .font(.title.bold()) + .foregroundStyle(fontColor) + Text("per month") + .font(.footnote) + .foregroundStyle(fontColor) + if hasDesc{ + Text(description) + .font(.footnote) + .padding(5) + .foregroundStyle(.white) + .background(.orange) + + } + } + } + } +} + struct PlansView: View { var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + + Text("Choose \n ur plan 🐥") + .font(.title.bold()) + .foregroundStyle(.black) + + VStack{ + HStack{ + Plans(hasImg: false, imgName: "", planName: "Basic", planCost: 9.9, hasDesc: false, description: "", backColor: .purple, fontColor: .white, sizesBack: CGSize(width: 177, height: 177)) + Plans(hasImg: false, imgName: "", planName: "Pro", planCost: 19, hasDesc: true, description: "Best for designer", backColor: .gray, fontColor: .black, sizesBack: CGSize(width: 177, height: 177)) + } + + Plans(hasImg: true, imgName: "wand.and.stars", planName: "Team", planCost: 299, hasDesc: true, description: "Perfect for teams with 20 members", backColor: .gray, fontColor: .white, sizesBack: CGSize(width: 300, height: 177)) + } } } #Preview { PlansView() } + diff --git a/SwiftUIBasics/Views/RatingView.swift b/SwiftUIBasics/Views/RatingView.swift index 3ca74af..10b700a 100644 --- a/SwiftUIBasics/Views/RatingView.swift +++ b/SwiftUIBasics/Views/RatingView.swift @@ -4,13 +4,39 @@ // // Created by Diplomado on 09/12/23. // +// import SwiftUI struct RatingView: View { - var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) - } + @State var rating: Int? = 0 + @State private var animate = false + + private func starType(index: Int) -> String { + + if let rating = self.rating { + return index <= rating ? "star.fill" : "star" + } else { + return "star" + } + } + + var body: some View { + HStack { + ForEach(1...5, id: \.self) { index in + Image(systemName: self.starType(index: index)) + .symbolEffect(.bounce, value: animate) + .contentTransition(.symbolEffect(.replace)) + .foregroundColor(Color.orange) + .onTapGesture { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + if self.rating != index{ + self.rating = index + } else { self.rating = index-1} + } + } + } + } } #Preview { diff --git a/SwiftUIBasics/Views/SignUpView.swift b/SwiftUIBasics/Views/SignUpView.swift index 4f8aeb9..854cd3d 100644 --- a/SwiftUIBasics/Views/SignUpView.swift +++ b/SwiftUIBasics/Views/SignUpView.swift @@ -6,40 +6,187 @@ // import SwiftUI +import Combine + +class SignUpViewModel: ObservableObject { + // inputs + @Published var email: String = "" + @Published var password: String = "" + @Published var passwordConfirm: String = "" + + // outputs + + @Published var isValidPasswordLowerCase: Bool = false + @Published var isValidPasswordHasASymbol: Bool = false + @Published var isValidPasswordHasANumber: Bool = false + @Published var isValidUsernameLength: Bool = false + @Published var isValidPasswordLength: Bool = false + @Published var isValidPasswordUpperCase: Bool = false + @Published var isValidPasswordMatch: Bool = false + @Published var isValid: Bool = false +// mailOutputs + + @Published var isValidEmail: Bool = false + + private var cancelableSet: Set = [] + + init() { + + $email + .receive(on: RunLoop.main) + .map { mail in + let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" + let emailPred = NSPredicate(format:"SELF MATCHES %@", emailRegEx) + return emailPred.evaluate(with: mail) + } + .assign(to: \.isValidEmail, on: self) + .store(in: &cancelableSet) + + + $password + .receive(on: RunLoop.main) + .map { password in + return password.count >= 8 + } + .assign(to: \.isValidPasswordLength, on: self) + .store(in: &cancelableSet) + + $password + .receive(on: RunLoop.main) + .map { password in + let pattern = "[A-Z]" + if let _ = password.range(of: pattern, options: .regularExpression) { + return true + } else { + return false + } + } + .assign(to: \.isValidPasswordUpperCase, on: self) + .store(in: &cancelableSet) + + $password + .receive(on: RunLoop.main) + .map { password in + let pattern = "[a-z]" + if let _ = password.range(of: pattern, options: .regularExpression) { + return true + } else { + return false + } + } + .assign(to: \.isValidPasswordLowerCase, on: self) + .store(in: &cancelableSet) + + $password + .receive(on: RunLoop.main) + .map { password in + let pattern = "[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]" + if let _ = password.range(of: pattern, options: .regularExpression) { + return true + } else { + return false + } + } + .assign(to: \.isValidPasswordHasASymbol, on: self) + .store(in: &cancelableSet) + + $password + .receive(on: RunLoop.main) + .map { password in + let pattern = "[0-9]+" + if let _ = password.range(of: pattern, options: .regularExpression) { + return true + } else { + return false + } + } + .assign(to: \.isValidPasswordHasANumber, on: self) + .store(in: &cancelableSet) + + Publishers.CombineLatest($password, $passwordConfirm) + .receive(on: RunLoop.main) + .map { (password, passwordConfirm) in + return !password.isEmpty && !passwordConfirm.isEmpty && password == passwordConfirm + } + .assign(to: \.isValidPasswordMatch, on: self) + .store(in: &cancelableSet) + + Publishers.CombineLatest4($isValidUsernameLength, $isValidPasswordLength, $isValidPasswordUpperCase, $isValidPasswordMatch) + .map { (a, b, c, d) in + return a && b && c && d + } + .assign(to: \.isValid, on: self) + .store(in: &cancelableSet) + } +} + +/// struct SignUpView: View { - @State var email: String = "" - @State var password: String = "" - @State var passwordConfirmation: String = "" - @State var terms: Bool = true + @ObservedObject var vm = SignUpViewModel() + var body: some View { - NavigationView { + VStack { + Text("Create an account") + .font(.system(.largeTitle, design: .rounded)) + .bold() + .foregroundStyle(.maryBlue) + .padding(.bottom, 30) + FormTextField(name: "Email", value: $vm.email).keyboardType(.emailAddress) + + RequirementText(text: "Valid Email", isValid: vm.isValidEmail) + .padding() + + + FormTextField(name: "Password", value: $vm.password, isSecure: true) VStack { - Form { - TextField(text: $email) { - Text("email") - } - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - SecureField(text: $password) { - Text("Password") - } - .autocorrectionDisabled() - SecureField(text: $passwordConfirmation) { - Text("Password Confirmation") - } - .autocorrectionDisabled() - - Toggle("Accept Terms and conditions", isOn: $terms) - } - Button("Create Account") { - print("Create account") - } - .font(.system(size: 24)) - .buttonStyle(.borderedProminent) + RequirementText(text: "A minimum of 8 characters", isValid: vm.isValidPasswordLength) + RequirementText(text: "One uppercase letter", isValid: vm.isValidPasswordUpperCase) + RequirementText(text: "One lowercase letter", isValid: vm.isValidPasswordLowerCase) + RequirementText(text: "One symbol", isValid: vm.isValidPasswordHasASymbol) + RequirementText(text: "One number", isValid: vm.isValidPasswordHasANumber) + } + .padding() + FormTextField(name: "Confirm Password", value: $vm.passwordConfirm, isSecure: true) + VStack{ + RequirementText(text: "Your confirm password should be the same as password", isValid: vm.isValidPasswordMatch) + } - .navigationTitle("Sign Up") + .padding() + .padding(.bottom, 50) + Button(action: { + print("Doing") + // Proceed to the next screen + }) { + Text("Sign Up") + .font(.system(.body, design: .rounded)) + .foregroundColor(.white) + .bold() + .padding() + .frame(minWidth: 0, maxWidth: .infinity) + .background(vm.isValid ? .maryBlue :.turquoise) + // .background(LinearGradient(gradient: Gradient(colors: [.turquoise, .maryBlue]), startPoint: .leading, endPoint: .trailing)) + .cornerRadius(10) + .padding(.horizontal) + } + .disabled(!vm.isValid) + + HStack { + Text("Already have an account?") + .font(.system(.body, design: .rounded)) + .bold() + Button(action: { + // Proceed to Sign in screen + }) { + Text("Sign in") + .font(.system(.body, design: .rounded)) + .bold() + .foregroundColor(.maryBlue) + } + }.padding(.top, 50) + Spacer() } + .padding() } }