diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift index d06681338..4164550fb 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift @@ -224,7 +224,7 @@ struct CreateProfileView: View { } } -private extension PotentialUsernameStatus { +extension PotentialUsernameStatus { var message: String? { switch self { case .tooShort: diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailAuthView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailAuthView.swift new file mode 100644 index 000000000..18b69768d --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailAuthView.swift @@ -0,0 +1,68 @@ +import Combine +import Models +import Services +import SwiftUI +import Utils +import Views + +enum EmailAuthState { + case signIn + case signUp + case loading +} + +@MainActor final class EmailAuthViewModel: ObservableObject { + @Published var loginError: LoginError? + @Published var emailAuthState = EmailAuthState.loading + @Published var potentialUsernameStatus = PotentialUsernameStatus.noUsername + @Published var potentialUsername = "" + + var subscriptions = Set() + + func loadAuthState() { + // check tokens here to determine pending/active/no user + emailAuthState = .signIn + } +} + +struct EmailAuthView: View { + @Environment(\.presentationMode) private var presentationMode + @StateObject private var viewModel = EmailAuthViewModel() + + @ViewBuilder var primaryContent: some View { + switch viewModel.emailAuthState { + case .signUp: + EmailSignupFormView(viewModel: viewModel) + case .signIn: + EmailLoginFormView(viewModel: viewModel) + case .loading: + VStack { + Spacer() + ProgressView() + Spacer() + } + } + } + + var body: some View { + NavigationView { + ZStack { + Color.appBackground.edgesIgnoringSafeArea(.all) + primaryContent + .frame(maxWidth: 300) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .barTrailing) { + Button( + action: { presentationMode.wrappedValue.dismiss() }, + label: { Image(systemName: "xmark").foregroundColor(.appGrayTextContrast) } + ) + } + } + } + } + .task { + viewModel.loadAuthState() + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailLoginFormView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailLoginFormView.swift new file mode 100644 index 000000000..40756a017 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailLoginFormView.swift @@ -0,0 +1,116 @@ +import Models +import Services +import SwiftUI +import Utils +import Views + +extension EmailAuthViewModel { + func submitCredentials( + email: String, + password: String, + authenticator: Authenticator + ) async { + do { + try await authenticator.submitEmailLogin(email: email, password: password) + } catch { + loginError = error as? LoginError + } + } +} + +struct EmailLoginFormView: View { + enum FocusedField { + case email, password + } + + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @EnvironmentObject var authenticator: Authenticator + @ObservedObject var viewModel: EmailAuthViewModel + + @FocusState private var focusedField: FocusedField? + @State private var email = "" + @State private var password = "" + + var body: some View { + VStack(spacing: 0) { + VStack(spacing: 28) { + ScrollView(showsIndicators: false) { + if horizontalSizeClass == .regular { + Spacer(minLength: 150) + } + VStack { + VStack(alignment: .leading, spacing: 6) { + Text("Email") + .font(.appFootnote) + .foregroundColor(.appGrayText) + TextField("", text: $email) + .keyboardType(.emailAddress) + .textContentType(.emailAddress) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .focused($focusedField, equals: .email) + .submitLabel(.next) + } + .padding(.bottom, 8) + + VStack(alignment: .leading, spacing: 6) { + Text("Password") + .font(.appFootnote) + .foregroundColor(.appGrayText) + SecureField("", text: $password) + .textContentType(.password) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .focused($focusedField, equals: .password) + .submitLabel(.done) + } + .padding(.bottom, 16) + + Button( + action: { + Task { + await viewModel.submitCredentials( + email: email, + password: password, + authenticator: authenticator + ) + } + }, + label: { Text("Submit") } + ) + .buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300)) + + if let loginError = viewModel.loginError { + LoginErrorMessageView(loginError: loginError) + } + + HStack { + Button( + action: { viewModel.emailAuthState = .signUp }, + label: { + Text("Don't have an account?") + .foregroundColor(.appGrayTextContrast) + .underline() + } + ) + .padding(.vertical) + Spacer() + } + } + .textFieldStyle(StandardTextFieldStyle()) + .onSubmit { + if focusedField == .email { + focusedField = .password + } else { + focusedField = nil + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Spacer() + } + } + .navigationTitle("Sign In") + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailSignupFormView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailSignupFormView.swift new file mode 100644 index 000000000..53215af01 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuth/EmailSignupFormView.swift @@ -0,0 +1,218 @@ +import Combine +import Models +import Services +import SwiftUI +import Utils +import Views + +extension EmailAuthViewModel { + func signUp( + email: String, + password: String, + username _: String, + fullName _: String, + authenticator: Authenticator + ) async { + do { + // TODO: add function to sign up + try await authenticator.submitEmailLogin(email: email, password: password) + } catch { + loginError = error as? LoginError + } + } + + func validateUsername(username: String, dataService: DataService) { + if let status = PotentialUsernameStatus.validationError(username: username.lowercased()) { + potentialUsernameStatus = status + return + } + + Task { + do { + try await dataService.validateUsernamePublisher(username: username) + potentialUsernameStatus = .available + } catch { + let usernameError = (error as? UsernameAvailabilityError) ?? .unknown + switch usernameError { + case .tooShort: + potentialUsernameStatus = .tooShort + case .tooLong: + potentialUsernameStatus = .tooLong + case .invalidPattern: + potentialUsernameStatus = .invalidPattern + case .nameUnavailable: + potentialUsernameStatus = .unavailable + case .internalServer, .unknown: + loginError = .unknown + case .network: + loginError = .network + } + } + } + } + + func configureUsernameValidation(dataService: DataService) { + $potentialUsername + .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main) + .sink(receiveValue: { [weak self] username in + self?.validateUsername(username: username, dataService: dataService) + }) + .store(in: &subscriptions) + } +} + +struct EmailSignupFormView: View { + enum FocusedField { + case email, password, fullName, username + } + + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @EnvironmentObject var authenticator: Authenticator + @EnvironmentObject var dataService: DataService + @ObservedObject var viewModel: EmailAuthViewModel + + @FocusState private var focusedField: FocusedField? + @State private var email = "" + @State private var password = "" + @State private var name = "" + @State private var username = "" + + var body: some View { + VStack(spacing: 0) { + VStack(spacing: 28) { + ScrollView(showsIndicators: false) { + if horizontalSizeClass == .regular { + Spacer(minLength: 150) + } + VStack { + // Email + VStack(alignment: .leading, spacing: 6) { + Text("Email") + .font(.appFootnote) + .foregroundColor(.appGrayText) + TextField("", text: $email) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .focused($focusedField, equals: .email) + .submitLabel(.next) + } + .padding(.bottom, 8) + + // Password + VStack(alignment: .leading, spacing: 6) { + Text("Password") + .font(.appFootnote) + .foregroundColor(.appGrayText) + SecureField("", text: $password) + .textContentType(.password) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .focused($focusedField, equals: .password) + .submitLabel(.next) + } + .padding(.bottom, 8) + + // Full Name + VStack(alignment: .leading, spacing: 6) { + Text("Full Name") + .font(.appFootnote) + .foregroundColor(.appGrayText) + TextField("", text: $name) + .textContentType(.name) + .keyboardType(.alphabet) + .disableAutocorrection(true) + .focused($focusedField, equals: .fullName) + .submitLabel(.next) + } + .padding(.bottom, 8) + + // Username + VStack(alignment: .leading, spacing: 6) { + HStack { + VStack(alignment: .leading, spacing: 6) { + Text("Username") + .font(.appFootnote) + .foregroundColor(.appGrayText) + TextField("", text: $viewModel.potentialUsername) + .textInputAutocapitalization(.never) + .textContentType(.username) + .disableAutocorrection(true) + .keyboardType(.alphabet) + .focused($focusedField, equals: .username) + .submitLabel(.done) + } + + if viewModel.potentialUsernameStatus == .available { + Image(systemName: "checkmark.circle.fill") + .font(.appBody) + .foregroundColor(.green) + } + } + if let message = viewModel.potentialUsernameStatus.message { + Text(message) + .font(.appCaption) + .foregroundColor(.red) + } + } + .padding(.bottom, 16) + .animation(.default, value: 0.35) + + Button( + action: { + Task { + await viewModel.signUp( + email: email, + password: password, + username: username, + fullName: name, + authenticator: authenticator + ) + } + }, + label: { Text("Submit") } + ) + .buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300)) + + if let loginError = viewModel.loginError { + LoginErrorMessageView(loginError: loginError) + } + + HStack { + Button( + action: { viewModel.emailAuthState = .signIn }, + label: { + Text("Already have an account?") + .foregroundColor(.appGrayTextContrast) + .underline() + } + ) + .padding(.vertical) + Spacer() + } + } + .textFieldStyle(StandardTextFieldStyle()) + .onSubmit { + if focusedField == .email { + focusedField = .password + } else if focusedField == .password { + focusedField = .fullName + } else if focusedField == .fullName { + focusedField = .username + } else { + focusedField = nil + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Spacer() + } + } + .navigationTitle("Sign Up") + .task { + viewModel.configureUsernameValidation(dataService: dataService) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuthView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuthView.swift deleted file mode 100644 index 9bbd0f154..000000000 --- a/apple/OmnivoreKit/Sources/App/Views/Registration/EmailAuthView.swift +++ /dev/null @@ -1,263 +0,0 @@ -import Models -import Services -import SwiftUI -import Utils -import Views - -enum EmailAuthState { - case signIn - case signUp - case loading -} - -@MainActor final class EmailAuthViewModel: ObservableObject { - @Published var loginError: LoginError? - @Published var emailAuthState = EmailAuthState.loading - - func loadAuthState() { - // check tokens here to determine pending/active/no user - emailAuthState = .signUp - } - - func submitCredentials( - email: String, - password: String, - authenticator: Authenticator - ) async { - do { - try await authenticator.submitEmailLogin(email: email, password: password) - } catch { - loginError = error as? LoginError - } - } -} - -struct EmailAuthView: View { - @Environment(\.presentationMode) private var presentationMode - @StateObject private var viewModel = EmailAuthViewModel() - - @ViewBuilder var primaryContent: some View { - switch viewModel.emailAuthState { - case .signUp: - EmailSignupFormView(viewModel: viewModel) - case .signIn: - EmailLoginFormView(viewModel: viewModel) - case .loading: - VStack { - Spacer() - ProgressView() - Spacer() - } - } - } - - var body: some View { - NavigationView { - ZStack { - Color.appBackground.edgesIgnoringSafeArea(.all) - primaryContent - .frame(maxWidth: 300) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .barTrailing) { - Button( - action: { presentationMode.wrappedValue.dismiss() }, - label: { Image(systemName: "xmark").foregroundColor(.appGrayTextContrast) } - ) - } - } - } - } - .task { - viewModel.loadAuthState() - } - } -} - -struct EmailLoginFormView: View { - enum FocusedField { - case email, password - } - - @Environment(\.horizontalSizeClass) var horizontalSizeClass - @EnvironmentObject var authenticator: Authenticator - @ObservedObject var viewModel: EmailAuthViewModel - - @FocusState private var focusedField: FocusedField? - @State private var email = "" - @State private var password = "" - - var body: some View { - VStack(spacing: 0) { - VStack(spacing: 28) { - ScrollView(showsIndicators: false) { - if horizontalSizeClass == .regular { - Spacer(minLength: 150) - } - VStack { - VStack(alignment: .leading, spacing: 6) { - Text("Email") - .font(.appFootnote) - .foregroundColor(.appGrayText) - TextField("", text: $email) - .textContentType(.emailAddress) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .focused($focusedField, equals: .email) - } - .padding(.bottom, 8) - - VStack(alignment: .leading, spacing: 6) { - Text("Password") - .font(.appFootnote) - .foregroundColor(.appGrayText) - SecureField("", text: $password) - .textContentType(.password) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .focused($focusedField, equals: .password) - } - .padding(.bottom, 16) - - Button( - action: { - Task { - await viewModel.submitCredentials( - email: email, - password: password, - authenticator: authenticator - ) - } - }, - label: { Text("Submit") } - ) - .buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300)) - - if let loginError = viewModel.loginError { - LoginErrorMessageView(loginError: loginError) - } - - HStack { - Button( - action: { viewModel.emailAuthState = .signUp }, - label: { - Text("Don't have an account?") - .foregroundColor(.appGrayTextContrast) - .underline() - } - ) - .padding(.vertical) - Spacer() - } - } - .textFieldStyle(StandardTextFieldStyle()) - .onSubmit { - if focusedField == .email { - focusedField = .password - } else { - focusedField = nil - } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - Spacer() - } - } - .navigationTitle("Sign In") - } -} - -struct EmailSignupFormView: View { - enum FocusedField { - case email, password, fullName, username - } - - @Environment(\.horizontalSizeClass) var horizontalSizeClass - @EnvironmentObject var authenticator: Authenticator - @ObservedObject var viewModel: EmailAuthViewModel - - @FocusState private var focusedField: FocusedField? - @State private var email = "" - @State private var password = "" - - var body: some View { - VStack(spacing: 0) { - VStack(spacing: 28) { - ScrollView(showsIndicators: false) { - if horizontalSizeClass == .regular { - Spacer(minLength: 150) - } - VStack { - VStack(alignment: .leading, spacing: 6) { - Text("Email") - .font(.appFootnote) - .foregroundColor(.appGrayText) - TextField("", text: $email) - .textContentType(.emailAddress) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .focused($focusedField, equals: .email) - } - .padding(.bottom, 8) - - VStack(alignment: .leading, spacing: 6) { - Text("Password") - .font(.appFootnote) - .foregroundColor(.appGrayText) - SecureField("", text: $password) - .textContentType(.password) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .focused($focusedField, equals: .password) - } - .padding(.bottom, 16) - - Button( - action: { - Task { - await viewModel.submitCredentials( - email: email, - password: password, - authenticator: authenticator - ) - } - }, - label: { Text("Submit") } - ) - .buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300)) - - if let loginError = viewModel.loginError { - LoginErrorMessageView(loginError: loginError) - } - - HStack { - Button( - action: { viewModel.emailAuthState = .signIn }, - label: { - Text("Already have an account?") - .foregroundColor(.appGrayTextContrast) - .underline() - } - ) - .padding(.vertical) - Spacer() - } - } - .textFieldStyle(StandardTextFieldStyle()) - .onSubmit { - if focusedField == .email { - focusedField = .password - } else { - focusedField = nil - } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - Spacer() - } - } - .navigationTitle("Sign Up") - } -}