Skip to content

Commit

Permalink
fix: sign up
Browse files Browse the repository at this point in the history
  • Loading branch information
bouassaba committed Nov 27, 2024
1 parent 5ca827b commit 775eb9c
Show file tree
Hide file tree
Showing 4 changed files with 246 additions and 97 deletions.
35 changes: 35 additions & 0 deletions Sources/Helpers/String+Requirements.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright (c) 2024 Anass Bouassaba.
//
// Use of this software is governed by the Business Source License
// included in the file LICENSE in the root of this repository.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the GNU Affero General Public License v3.0 only, included in the file
// AGPL-3.0-only in the root of this repository.

import Foundation

extension String {
func hasMinLength(_ count: Int) -> Bool {
self.count >= count
}

func hasMinLowerCase(_ count: Int) -> Bool {
self.filter { $0.isLowercase }.count >= count
}

func hasMinUpperCase(_ count: Int) -> Bool {
self.filter { $0.isUppercase }.count >= count
}

func hasMinNumbers(_ count: Int) -> Bool {
self.filter { $0.isNumber }.count >= count
}

func hasMinSymbols(_ count: Int) -> Bool {
let symbolsSet = CharacterSet.alphanumerics.union(.whitespaces).inverted
let symbolsCount = self.unicodeScalars.filter { symbolsSet.contains($0) }.count
return symbolsCount >= count
}
}
221 changes: 142 additions & 79 deletions Sources/Screens/SignUp/SignUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@

import SwiftUI

struct SignUp: View {
struct SignUp: View, ViewDataProvider, LoadStateProvider, TimerLifecycle, FormValidatable {
@StateObject private var signUpStore = SignUpStore()
@State private var fullName: String = ""
@State private var email: String = ""
@State private var password: String = ""
@State private var confirmPassword: String = ""
@State private var isLoading = false
@State private var isProcessing = false
private let onCompletion: (() -> Void)?
private let onSignIn: (() -> Void)?

Expand All @@ -27,95 +27,158 @@ struct SignUp: View {

var body: some View {
NavigationView {
if let passwordRequirements = signUpStore.passwordRequirements {
VStack(spacing: VOMetrics.spacing) {
VOLogo(isGlossy: true, size: .init(width: 100, height: 100))
Text("Sign Up to Voltaserve")
.voHeading(fontSize: VOMetrics.headingFontSize)
TextField("Full name", text: $fullName)
.voTextField(width: VOMetrics.formWidth)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.disabled(isLoading)
TextField("Email", text: $email)
.voTextField(width: VOMetrics.formWidth)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.disabled(isLoading)
SecureField("Password", text: $password)
.voTextField(width: VOMetrics.formWidth)
.disabled(isLoading)
VStack(alignment: .listRowSeparatorLeading) {
PasswordHint("\(passwordRequirements.minLength) characters.")
PasswordHint("\(passwordRequirements.minLowercase) lowercase character.")
PasswordHint("\(passwordRequirements.minUppercase) uppercase character.")
PasswordHint("\(passwordRequirements.minNumbers) number.")
PasswordHint("\(passwordRequirements.minSymbols) special character(s) (!#$%).")
}
.frame(width: VOMetrics.formWidth)
SecureField("Confirm password", text: $confirmPassword)
.voTextField(width: VOMetrics.formWidth)
.disabled(isLoading)
Button {
isLoading = true
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
isLoading = false
onCompletion?()
}
} label: {
VOButtonLabel(
"Sign Up",
isLoading: isLoading,
progressViewTint: .white
)
}
.voPrimaryButton(width: VOMetrics.formWidth, isDisabled: isLoading)
HStack {
Text("Already a member?")
.voFormHintText()
Button {
onSignIn?()
} label: {
Text("Sign In")
.voFormHintLabel()
VStack {
if isLoading {
ProgressView()
} else if let error {
VOErrorMessage(error)
} else {
if let passwordRequirements = signUpStore.passwordRequirements {
VStack(spacing: VOMetrics.spacing) {
VOLogo(isGlossy: true, size: .init(width: 100, height: 100))
Text("Sign Up to Voltaserve")
.voHeading(fontSize: VOMetrics.headingFontSize)
TextField("Full name", text: $fullName)
.voTextField(width: VOMetrics.formWidth)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.disabled(isProcessing)
TextField("Email", text: $email)
.voTextField(width: VOMetrics.formWidth)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.disabled(isProcessing)
SecureField("Password", text: $password)
.voTextField(width: VOMetrics.formWidth)
.disabled(isProcessing)
VStack(alignment: .listRowSeparatorLeading) {
SignUpPasswordHint(
"\(passwordRequirements.minLength) characters.",
isFulfilled: password.hasMinLength(passwordRequirements.minLength))
SignUpPasswordHint(
"\(passwordRequirements.minLowercase) lowercase character.",
isFulfilled: password.hasMinLowerCase(passwordRequirements.minLowercase))
SignUpPasswordHint(
"\(passwordRequirements.minUppercase) uppercase character.",
isFulfilled: password.hasMinUpperCase(passwordRequirements.minUppercase))
SignUpPasswordHint(
"\(passwordRequirements.minNumbers) number.",
isFulfilled: password.hasMinNumbers(passwordRequirements.minNumbers))
SignUpPasswordHint(
"\(passwordRequirements.minSymbols) special character(s) (!#$%).",
isFulfilled: password.hasMinSymbols(passwordRequirements.minSymbols))
SignUpPasswordHint(
"Passwords match.",
isFulfilled: !password.isEmpty && !confirmPassword.isEmpty
&& password == confirmPassword)
}
.frame(width: VOMetrics.formWidth)
SecureField("Confirm password", text: $confirmPassword)
.voTextField(width: VOMetrics.formWidth)
.disabled(isProcessing)
Button {
if isValid() {
performSignUp()
}
} label: {
VOButtonLabel(
"Sign Up",
isLoading: isProcessing,
progressViewTint: .white
)
}
.voPrimaryButton(width: VOMetrics.formWidth, isDisabled: isProcessing)
HStack {
Text("Already a member?")
.voFormHintText()
Button {
onSignIn?()
} label: {
Text("Sign In")
.voFormHintLabel()
}
.disabled(isProcessing)
}
}
.disabled(isLoading)

}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
onSignIn?()
} label: {
Text("Back to Sign In")
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
onSignIn?()
} label: {
Text("Back to Sign In")
}
}
} else {
ProgressView()
}
}
.onAppear {
onAppearOrChange()
}
.onDisappear {
stopTimers()
}
.voErrorSheet(isPresented: $errorIsPresented, message: errorMessage)
}
}

struct PasswordHint: View {
var text: String
var isFulfilled: Bool
private func performSignUp() {
withErrorHandling {
_ = try await signUpStore.signUp(.init(email: email, password: password, fullName: fullName))
return true
} before: {
isProcessing = true
} success: {
onCompletion?()
} failure: { message in
errorMessage = message
errorIsPresented = true
} anyways: {
isProcessing = false
}
}

// MARK: - ErrorPresentable

@State var errorIsPresented: Bool = false
@State var errorMessage: String?

init(_ text: String, isFulfilled: Bool = false) {
self.text = text
self.isFulfilled = isFulfilled
// MARK: - ViewDataProvider

var isLoading: Bool {
signUpStore.passwordRequirementsIsLoading
}

var body: some View {
HStack {
Image(systemName: "checkmark")
.imageScale(.small)
Text(text)
.voFormHintText()
Spacer()
}
.foregroundStyle(isFulfilled ? .green : .secondary)
var error: String? {
signUpStore.passwordRequirementsError
}

// MARK: - ViewDataProvider

func onAppearOrChange() {
fetchData()
}

func fetchData() {
signUpStore.fetchPasswordRequirements()
}

// MARK: - TimerLifecycle

func startTimers() {
signUpStore.startTimer()
}

func stopTimers() {
signUpStore.stopTimer()
}

// MARK: - FormValidatable

func isValid() -> Bool {
!email.isEmpty && !fullName.isEmpty && !password.isEmpty && !confirmPassword.isEmpty
&& password == confirmPassword
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,25 @@
// by the GNU Affero General Public License v3.0 only, included in the file
// AGPL-3.0-only in the root of this repository.

import Combine
import Foundation
import SwiftUI

extension View {
func sync<T: Equatable>(_ published: Binding<T>, with binding: Binding<T>) -> some View {
onChange(of: published.wrappedValue) { _, published in
binding.wrappedValue = published
}
.onChange(of: binding.wrappedValue) { _, binding in
published.wrappedValue = binding
struct SignUpPasswordHint: View {
var text: String
var isFulfilled: Bool

init(_ text: String, isFulfilled: Bool = false) {
self.text = text
self.isFulfilled = isFulfilled
}

var body: some View {
HStack {
Image(systemName: "checkmark")
.imageScale(.small)
Text(text)
.voFormHintText()
Spacer()
}
.foregroundStyle(isFulfilled ? .green : .secondary)
}
}
61 changes: 52 additions & 9 deletions Sources/Screens/SignUp/SignUpStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,59 @@ import VoltaserveCore

class SignUpStore: ObservableObject {
@Published var passwordRequirements: VOAccount.PasswordRequirements?
@Published var passwordRequirementsIsLoading: Bool = false
@Published var passwordRequirementsError: String?
private var timer: Timer?
private var accountClient: VOAccount = .init(baseURL: Config.production.idpURL)

init() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.passwordRequirements = VOAccount.PasswordRequirements(
minLength: 8,
minLowercase: 1,
minUppercase: 1,
minNumbers: 1,
minSymbols: 1
)
// MARK: - Fetch

private func fetchPasswordRequirements() async throws -> VOAccount.PasswordRequirements? {
return try await accountClient.fetchPasswordRequirements()
}

func fetchPasswordRequirements() {
var passwordRequirements: VOAccount.PasswordRequirements?
withErrorHandling {
passwordRequirements = try await self.fetchPasswordRequirements()
return true
} before: {
self.passwordRequirementsIsLoading = true
} success: {
self.passwordRequirements = passwordRequirements
} failure: { message in
self.passwordRequirementsError = message
} anyways: {
self.passwordRequirementsIsLoading = false
}
}

// MARK: - Update

func signUp(_ options: VOAccount.CreateOptions) async throws -> VOIdentityUser.Entity {
return try await accountClient.create(options)
}

// MARK: - Timer

func startTimer() {
guard timer == nil else { return }
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
if self.passwordRequirements != nil {
Task {
let passwordRequirements = try await self.fetchPasswordRequirements()
if let passwordRequirements {
DispatchQueue.main.async {
self.passwordRequirements = passwordRequirements
}
}
}
}
}
}

func stopTimer() {
timer?.invalidate()
timer = nil
}
}

0 comments on commit 775eb9c

Please sign in to comment.