diff --git a/android/Omnivore/app/src/main/graphql/ValidateUsername.graphql b/android/Omnivore/app/src/main/graphql/ValidateUsername.graphql new file mode 100644 index 000000000..79938a855 --- /dev/null +++ b/android/Omnivore/app/src/main/graphql/ValidateUsername.graphql @@ -0,0 +1,3 @@ +query ValidateUsername($username: String!) { + validateUsername(username: $username) +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Constants.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Constants.kt index b351e3ecf..3b219d6a3 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Constants.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Constants.kt @@ -9,6 +9,7 @@ object Constants { object DatastoreKeys { const val omnivoreAuthToken = "omnivoreAuthToken" const val omnivoreAuthCookieString = "omnivoreAuthCookieString" + const val omnivorePendingUserToken = "omnivorePendingUserToken" } object AppleConstants { diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/DatastoreRepository.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/DatastoreRepository.kt index 7392ffeb9..725054822 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/DatastoreRepository.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/DatastoreRepository.kt @@ -16,6 +16,7 @@ interface DatastoreRepository { suspend fun putInt(key: String, value: Int) suspend fun getString(key: String): String? suspend fun getInt(key: String): Int? + suspend fun clearValue(key: String) } class OmnivoreDatastore @Inject constructor( @@ -55,6 +56,11 @@ class OmnivoreDatastore @Inject constructor( context.dataStore.edit { it.clear() } } + override suspend fun clearValue(key: String) { + val preferencesKey = stringPreferencesKey(key) + context.dataStore.edit { it.remove(preferencesKey) } + } + override val hasAuthTokenFlow: Flow = context .dataStore.data.map { preferences -> val key = stringPreferencesKey(DatastoreKeys.omnivoreAuthToken) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/RESTNetworker.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/RESTNetworker.kt index c9e238e67..11be50365 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/RESTNetworker.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/RESTNetworker.kt @@ -12,12 +12,23 @@ data class AuthPayload( val authToken: String ) +data class PendingUserAuthPayload( + val pendingUserToken: String, +) + data class SignInParams( val token: String, val provider: String, // APPLE or GOOGLE val source: String = "ANDROID" ) +data class EmailSignUpParams( + val email: String, + val password: String, + val username: String, + val name: String +) + data class EmailAuthPayload( val authCookieString: String?, val authToken: String?, @@ -29,6 +40,16 @@ data class EmailLoginCredentials( val password: String ) +data class CreateAccountParams( + val pendingUserToken: String, + val userProfile: UserProfile +) + +data class UserProfile( + val username: String, + val name: String +) + interface EmailLoginSubmit { @Headers("Content-Type: application/json") @POST("/api/mobile-auth/email-sign-in") @@ -41,6 +62,24 @@ interface AuthProviderLoginSubmit { suspend fun submitAuthProviderLogin(@Body params: SignInParams): Response } +interface PendingUserSubmit { + @Headers("Content-Type: application/json") + @POST("/api/mobile-auth/sign-up") + suspend fun submitPendingUser(@Body params: SignInParams): Response +} + +interface CreateAccountSubmit { + @Headers("Content-Type: application/json") + @POST("/api/mobile-auth/create-account") + suspend fun submitCreateAccount(@Body params: CreateAccountParams): Response +} + +interface CreateEmailAccountSubmit { + @Headers("Content-Type: application/json") + @POST("/api/mobile-auth/email-sign-up") + suspend fun submitCreateEmailAccount(@Body params: EmailSignUpParams): Response +} + object RetrofitHelper { fun getInstance(): Retrofit { return Retrofit.Builder().baseUrl(Constants.apiURL) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/CreateUserProfile.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/CreateUserProfile.kt new file mode 100644 index 000000000..153d3804e --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/CreateUserProfile.kt @@ -0,0 +1,162 @@ +package app.omnivore.omnivore.ui.auth + +import android.annotation.SuppressLint +import android.widget.Toast +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import app.omnivore.omnivore.R +import org.intellij.lang.annotations.JdkConstants + +@SuppressLint("CoroutineCreationDuringComposition") +@Composable +fun CreateUserProfileView(viewModel: LoginViewModel) { + var name by rememberSaveable { mutableStateOf("") } + var username by rememberSaveable { mutableStateOf("") } + + Row( + horizontalArrangement = Arrangement.Center + ) { + Spacer(modifier = Modifier.weight(1.0F)) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Create Your Profile", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + UserProfileFields( + name = name, + username = username, + usernameValidationErrorMessage = viewModel.usernameValidationErrorMessage, + showUsernameAsAvailable = viewModel.hasValidUsername, + onNameChange = { name = it }, + onUsernameChange = { + username = it + viewModel.validateUsername(it) + }, + onSubmit = { viewModel.submitProfile(username = username, name = name) } + ) + + // TODO: add a activity indicator (maybe after a delay?) + if (viewModel.isLoading) { + Text("Loading...") + } + + ClickableText( + text = AnnotatedString("Cancel Sign Up"), + style = MaterialTheme.typography.titleMedium + .plus(TextStyle(textDecoration = TextDecoration.Underline)), + onClick = { viewModel.cancelNewUserSignUp() } + ) + } + Spacer(modifier = Modifier.weight(1.0F)) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UserProfileFields( + name: String, + username: String, + usernameValidationErrorMessage: String?, + showUsernameAsAvailable: Boolean, + onNameChange: (String) -> Unit, + onUsernameChange: (String) -> Unit, + onSubmit: () -> Unit +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + + Column( + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + verticalArrangement = Arrangement.spacedBy(25.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + value = name, + placeholder = { Text(text = "Name") }, + label = { Text(text = "Name") }, + onValueChange = onNameChange, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + ) + + Column( + verticalArrangement = Arrangement.spacedBy(5.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + value = username, + placeholder = { Text(text = "Username") }, + label = { Text(text = "Username") }, + onValueChange = onUsernameChange, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + trailingIcon = { + if (showUsernameAsAvailable) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null + ) + } + } + ) + + if (usernameValidationErrorMessage != null) { + Text( + text = usernameValidationErrorMessage!!, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + } + + Button( + onClick = { + if (name.isNotBlank() && username.isNotBlank()) { + onSubmit() + focusManager.clearFocus() + } else { + Toast.makeText( + context, + "Please enter a valid name and username.", + Toast.LENGTH_SHORT + ).show() + } + }, colors = ButtonDefaults.buttonColors( + contentColor = Color(0xFF3D3D3D), + containerColor = Color(0xffffd234) + ) + ) { + Text( + text = "Submit", + modifier = Modifier.padding(horizontal = 100.dp) + ) + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/EmailLogin.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/EmailLogin.kt index 99b41f432..bc3c1056a 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/EmailLogin.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/EmailLogin.kt @@ -1,6 +1,10 @@ package app.omnivore.omnivore.ui.auth import android.annotation.SuppressLint +import android.view.ViewGroup +import android.webkit.CookieManager +import android.webkit.WebView +import android.webkit.WebViewClient import android.widget.Toast import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.ClickableText @@ -15,16 +19,20 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import app.omnivore.omnivore.BuildConfig @SuppressLint("CoroutineCreationDuringComposition") @Composable -fun EmailLoginView(viewModel: LoginViewModel, onAuthProviderButtonTap: () -> Unit) { +fun EmailLoginView(viewModel: LoginViewModel) { + val uriHandler = LocalUriHandler.current var email by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") } @@ -49,12 +57,33 @@ fun EmailLoginView(viewModel: LoginViewModel, onAuthProviderButtonTap: () -> Uni Text("Loading...") } - ClickableText( - text = AnnotatedString("Return to Social Login"), - style = MaterialTheme.typography.titleMedium - .plus(TextStyle(textDecoration = TextDecoration.Underline)), - onClick = { onAuthProviderButtonTap() } - ) + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + ClickableText( + text = AnnotatedString("Return to Social Login"), + style = MaterialTheme.typography.titleMedium + .plus(TextStyle(textDecoration = TextDecoration.Underline)), + onClick = { viewModel.showSocialLogin() } + ) + + ClickableText( + text = AnnotatedString("Don't have an account?"), + style = MaterialTheme.typography.titleMedium + .plus(TextStyle(textDecoration = TextDecoration.Underline)), + onClick = { viewModel.showEmailSignUp() } + ) + + ClickableText( + text = AnnotatedString("Forgot your password?"), + style = MaterialTheme.typography.titleMedium + .plus(TextStyle(textDecoration = TextDecoration.Underline)), + onClick = { + val uri = "${BuildConfig.OMNIVORE_WEB_URL}/auth/forgot-password" + uriHandler.openUri(uri) + } + ) + } } Spacer(modifier = Modifier.weight(1.0F)) } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/EmailSignUpView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/EmailSignUpView.kt new file mode 100644 index 000000000..e9b486184 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/EmailSignUpView.kt @@ -0,0 +1,249 @@ +package app.omnivore.omnivore.ui.auth + +import android.annotation.SuppressLint +import android.widget.Toast +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp + +@Composable +fun EmailSignUpView(viewModel: LoginViewModel) { + if (viewModel.pendingEmailUserCreds != null) { + val email = viewModel.pendingEmailUserCreds?.email ?: "" + val password = viewModel.pendingEmailUserCreds?.password ?: "" + + val verificationMessage = "We've sent a verification email to ${email}. Please verify your email and then tap the button below." + + Row( + horizontalArrangement = Arrangement.Center + ) { + Spacer(modifier = Modifier.weight(1.0F)) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = verificationMessage, + style = MaterialTheme.typography.titleMedium + ) + + Button(onClick = { + viewModel.login(email, password) + }, colors = ButtonDefaults.buttonColors( + contentColor = Color(0xFF3D3D3D), + containerColor = Color(0xffffd234) + ) + ) { + Text( + text = "Check Status", + modifier = Modifier.padding(horizontal = 100.dp) + ) + } + + ClickableText( + text = AnnotatedString("Use a different email?"), + style = MaterialTheme.typography.titleMedium + .plus(TextStyle(textDecoration = TextDecoration.Underline)), + onClick = { viewModel.showEmailSignUp() } + ) + } + } + } else { + EmailSignUpForm(viewModel = viewModel) + } +} + +@SuppressLint("CoroutineCreationDuringComposition") +@Composable +fun EmailSignUpForm(viewModel: LoginViewModel) { + var email by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var name by rememberSaveable { mutableStateOf("") } + var username by rememberSaveable { mutableStateOf("") } + + Row( + horizontalArrangement = Arrangement.Center + ) { + Spacer(modifier = Modifier.weight(1.0F)) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + EmailSignUpFields( + email = email, + password = password, + name = name, + username = username, + usernameValidationErrorMessage = viewModel.usernameValidationErrorMessage, + showUsernameAsAvailable = viewModel.hasValidUsername, + onEmailChange = { email = it }, + onPasswordChange = { password = it }, + onNameChange = { name = it }, + onUsernameChange = { + username = it + viewModel.validateUsername(it) + }, + onSubmit = { + viewModel.submitEmailSignUp( + email = email, + password = password, + username = username, + name = name + ) + } + ) + + // TODO: add a activity indicator (maybe after a delay?) + if (viewModel.isLoading) { + Text("Loading...") + } + + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + ClickableText( + text = AnnotatedString("Return to Social Login"), + style = MaterialTheme.typography.titleMedium + .plus(TextStyle(textDecoration = TextDecoration.Underline)), + onClick = { viewModel.showSocialLogin() } + ) + + ClickableText( + text = AnnotatedString("Already have an account?"), + style = MaterialTheme.typography.titleMedium + .plus(TextStyle(textDecoration = TextDecoration.Underline)), + onClick = { viewModel.showEmailSignIn() } + ) + } + } + Spacer(modifier = Modifier.weight(1.0F)) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EmailSignUpFields( + email: String, + password: String, + name: String, + username: String, + usernameValidationErrorMessage: String?, + showUsernameAsAvailable: Boolean, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onNameChange: (String) -> Unit, + onUsernameChange: (String) -> Unit, + onSubmit: () -> Unit, +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(25.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + value = email, + placeholder = { Text(text = "user@email.com") }, + label = { Text(text = "Email") }, + onValueChange = onEmailChange, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + ) + + OutlinedTextField( + value = password, + placeholder = { Text(text = "Password") }, + label = { Text(text = "Password") }, + onValueChange = onPasswordChange, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + ) + + OutlinedTextField( + value = name, + placeholder = { Text(text = "Name") }, + label = { Text(text = "Name") }, + onValueChange = onNameChange, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + ) + + Column( + verticalArrangement = Arrangement.spacedBy(5.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + value = username, + placeholder = { Text(text = "Username") }, + label = { Text(text = "Username") }, + onValueChange = onUsernameChange, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + trailingIcon = { + if (showUsernameAsAvailable) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null + ) + } + } + ) + + if (usernameValidationErrorMessage != null) { + Text( + text = usernameValidationErrorMessage!!, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + } + + Button(onClick = { + if (email.isNotBlank() && password.isNotBlank() && username.isNotBlank() && name.isNotBlank()) { + onSubmit() + focusManager.clearFocus() + } else { + Toast.makeText( + context, + "Please complete all fields.", + Toast.LENGTH_SHORT + ).show() + } + }, colors = ButtonDefaults.buttonColors( + contentColor = Color(0xFF3D3D3D), + containerColor = Color(0xffffd234) + ) + ) { + Text( + text = "Sign Up", + modifier = Modifier.padding(horizontal = 100.dp) + ) + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/LoginViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/LoginViewModel.kt index 2b2f682eb..ae058da27 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/LoginViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/LoginViewModel.kt @@ -1,46 +1,151 @@ package app.omnivore.omnivore.ui.auth -import android.content.ContentValues -import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.* +import androidx.lifecycle.viewmodel.compose.viewModel import app.omnivore.omnivore.* +import app.omnivore.omnivore.graphql.generated.SearchQuery +import app.omnivore.omnivore.graphql.generated.ValidateUsernameQuery +import com.apollographql.apollo3.ApolloClient +import com.apollographql.apollo3.api.Optional import com.google.android.gms.auth.api.signin.GoogleSignInAccount import com.google.android.gms.common.api.ApiException import com.google.android.gms.tasks.Task import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import java.util.regex.Pattern import javax.inject.Inject - enum class RegistrationState { - AuthProviderButtons, - EmailSignIn + SocialLogin, + EmailSignIn, + EmailSignUp, + PendingUser } +data class PendingEmailUserCreds( + val email: String, + val password: String +) + @HiltViewModel class LoginViewModel @Inject constructor( private val datastoreRepo: DatastoreRepository ): ViewModel() { + private var validateUsernameJob: Job? = null + var isLoading by mutableStateOf(false) private set var errorMessage by mutableStateOf(null) private set + var hasValidUsername by mutableStateOf(false) + private set + + var usernameValidationErrorMessage by mutableStateOf(null) + private set + + var pendingEmailUserCreds by mutableStateOf(null) + private set + val hasAuthTokenLiveData: LiveData = datastoreRepo .hasAuthTokenFlow .distinctUntilChanged() .asLiveData() + val registrationStateLiveData = MutableLiveData(RegistrationState.SocialLogin) + fun getAuthCookieString(): String? = runBlocking { datastoreRepo.getString(DatastoreKeys.omnivoreAuthCookieString) } + fun showSocialLogin() { + resetState() + registrationStateLiveData.value = RegistrationState.SocialLogin + } + + fun showEmailSignIn() { + resetState() + registrationStateLiveData.value = RegistrationState.EmailSignIn + } + + fun showEmailSignUp(pendingCreds: PendingEmailUserCreds? = null) { + resetState() + pendingEmailUserCreds = pendingCreds + registrationStateLiveData.value = RegistrationState.EmailSignUp + } + + fun cancelNewUserSignUp() { + resetState() + viewModelScope.launch { + datastoreRepo.clearValue(DatastoreKeys.omnivorePendingUserToken) + } + showSocialLogin() + } + + private fun resetState() { + validateUsernameJob = null + isLoading = false + errorMessage = null + hasValidUsername = false + usernameValidationErrorMessage = null + pendingEmailUserCreds = null + } + + fun validateUsername(potentialUsername: String) { + validateUsernameJob?.cancel() + + validateUsernameJob = viewModelScope.launch { + delay(500) + + // Check the username requirements first + if (potentialUsername.isEmpty()) { + usernameValidationErrorMessage = null + hasValidUsername = false + return@launch + } + + if (potentialUsername.length < 4 || potentialUsername.length > 15) { + usernameValidationErrorMessage = "Username must be between 4 and 15 characters long." + hasValidUsername = false + return@launch + } + + val isValidPattern = Pattern.compile("^[a-z0-9][a-z0-9_]+[a-z0-9]$") + .matcher(potentialUsername) + .matches() + + if (!isValidPattern) { + usernameValidationErrorMessage = "Username can contain only letters and numbers" + hasValidUsername = false + return@launch + } + + val apolloClient = ApolloClient.Builder() + .serverUrl("${Constants.apiURL}/api/graphql") + .build() + + val response = apolloClient.query( + ValidateUsernameQuery(username = potentialUsername) + ).execute() + + if (response.data?.validateUsername == true) { + usernameValidationErrorMessage = null + hasValidUsername = true + } else { + hasValidUsername = false + usernameValidationErrorMessage = "This username is not available." + } + } + } + fun login(email: String, password: String) { val emailLogin = RetrofitHelper.getInstance().create(EmailLoginSubmit::class.java) @@ -55,7 +160,7 @@ class LoginViewModel @Inject constructor( isLoading = false if (result.body()?.pendingEmailVerification == true) { - errorMessage = "Email needs verification" + showEmailSignUp(pendingCreds = PendingEmailUserCreds(email = email, password = password)) return@launch } @@ -73,6 +178,74 @@ class LoginViewModel @Inject constructor( } } + fun submitEmailSignUp( + email: String, + password: String, + username: String, + name: String, + ) { + viewModelScope.launch { + val request = RetrofitHelper.getInstance().create(CreateEmailAccountSubmit::class.java) + + isLoading = true + errorMessage = null + + val params = EmailSignUpParams( + email = email, + password = password, + name = name, + username = username + ) + + val result = request.submitCreateEmailAccount(params) + + isLoading = false + + if (result.errorBody() != null) { + errorMessage = "Something went wrong. Please check your and try again" + } else { + pendingEmailUserCreds = PendingEmailUserCreds(email, password) + } + } + } + + private fun getPendingAuthToken(): String? = runBlocking { + datastoreRepo.getString(DatastoreKeys.omnivorePendingUserToken) + } + + fun submitProfile(username: String, name: String) { + viewModelScope.launch { + val request = RetrofitHelper.getInstance().create(CreateAccountSubmit::class.java) + + isLoading = true + errorMessage = null + + val pendingUserToken = getPendingAuthToken() ?: "" + + val userProfile = UserProfile(name = name, username = username) + val params = CreateAccountParams( + pendingUserToken = pendingUserToken, + userProfile = userProfile + ) + + val result = request.submitCreateAccount(params) + + isLoading = false + + if (result.body()?.authToken != null) { + datastoreRepo.putString(DatastoreKeys.omnivoreAuthToken, result.body()?.authToken!!) + } else { + errorMessage = "Something went wrong. Please check your email/password and try again" + } + + if (result.body()?.authCookieString != null) { + datastoreRepo.putString( + DatastoreKeys.omnivoreAuthCookieString, result.body()?.authCookieString!! + ) + } + } + } + fun handleAppleToken(authToken: String) { submitAuthProviderPayload( params = SignInParams(token = authToken, provider = "APPLE") @@ -121,15 +294,46 @@ class LoginViewModel @Inject constructor( if (result.body()?.authToken != null) { datastoreRepo.putString(DatastoreKeys.omnivoreAuthToken, result.body()?.authToken!!) - } else { - errorMessage = "Something went wrong. Please check your credentials and try again" - } - if (result.body()?.authCookieString != null) { - datastoreRepo.putString( - DatastoreKeys.omnivoreAuthCookieString, result.body()?.authCookieString!! - ) + if (result.body()?.authCookieString != null) { + datastoreRepo.putString( + DatastoreKeys.omnivoreAuthCookieString, result.body()?.authCookieString!! + ) + } + } else { + when (result.code()) { + 401, 403 -> { + // This is a new user so they should go through the new user flow + submitAuthProviderPayloadForPendingToken(params = params) + } + 418 -> { + // Show pending email state + errorMessage = "Something went wrong. Please check your credentials and try again" + } + else -> { + errorMessage = "Something went wrong. Please check your credentials and try again" + } + } } } } + + private suspend fun submitAuthProviderPayloadForPendingToken(params: SignInParams) { + isLoading = true + errorMessage = null + + val request = RetrofitHelper.getInstance().create(PendingUserSubmit::class.java) + val result = request.submitPendingUser(params) + + isLoading = false + + if (result.body()?.pendingUserToken != null) { + datastoreRepo.putString( + DatastoreKeys.omnivorePendingUserToken, result.body()?.pendingUserToken!! + ) + registrationStateLiveData.value = RegistrationState.PendingUser + } else { + errorMessage = "Something went wrong. Please check your credentials and try again" + } + } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/WelcomeScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/WelcomeScreen.kt index 8f9c5fe8a..7f497d556 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/WelcomeScreen.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/WelcomeScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -35,11 +36,9 @@ fun WelcomeScreen(viewModel: LoginViewModel) { @SuppressLint("CoroutineCreationDuringComposition") @Composable fun WelcomeScreenContent(viewModel: LoginViewModel) { - var registrationState by rememberSaveable { mutableStateOf(RegistrationState.AuthProviderButtons) } - - val onRegistrationStateChange = { state: RegistrationState -> - registrationState = state - } + val registrationState: RegistrationState by viewModel + .registrationStateLiveData + .observeAsState(RegistrationState.SocialLogin) val snackBarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() @@ -62,14 +61,12 @@ fun WelcomeScreenContent(viewModel: LoginViewModel) { when(registrationState) { RegistrationState.EmailSignIn -> { - EmailLoginView( - viewModel = viewModel, - onAuthProviderButtonTap = { - onRegistrationStateChange(RegistrationState.AuthProviderButtons) - } - ) + EmailLoginView(viewModel = viewModel) } - RegistrationState.AuthProviderButtons -> { + RegistrationState.EmailSignUp -> { + EmailSignUpView(viewModel = viewModel) + } + RegistrationState.SocialLogin -> { Text( text = stringResource(id = R.string.welcome_title), style = MaterialTheme.typography.headlineLarge @@ -84,10 +81,10 @@ fun WelcomeScreenContent(viewModel: LoginViewModel) { Spacer(modifier = Modifier.height(50.dp)) - AuthProviderView( - viewModel = viewModel, - onEmailButtonTap = { onRegistrationStateChange(RegistrationState.EmailSignIn) } - ) + AuthProviderView(viewModel = viewModel) + } + RegistrationState.PendingUser -> { + CreateUserProfileView(viewModel = viewModel) } } @@ -112,10 +109,7 @@ fun WelcomeScreenContent(viewModel: LoginViewModel) { } @Composable -fun AuthProviderView( - viewModel: LoginViewModel, - onEmailButtonTap: () -> Unit -) { +fun AuthProviderView(viewModel: LoginViewModel) { val isGoogleAuthAvailable: Boolean = GoogleApiAvailability .getInstance() .isGooglePlayServicesAvailable(LocalContext.current) == 0 @@ -138,7 +132,7 @@ fun AuthProviderView( text = AnnotatedString("Continue with Email"), style = MaterialTheme.typography.titleMedium .plus(TextStyle(textDecoration = TextDecoration.Underline)), - onClick = { onEmailButtonTap() } + onClick = { viewModel.showEmailSignIn() } ) } Spacer(modifier = Modifier.weight(1.0F)) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/HomeViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/HomeViewModel.kt index 5d4ab6074..85ba6ecec 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/HomeViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/HomeViewModel.kt @@ -1,6 +1,5 @@ package app.omnivore.omnivore.ui.home -import android.util.Log import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel diff --git a/android/Omnivore/app/src/main/res/xml/data_extraction_rules.xml b/android/Omnivore/app/src/main/res/xml/data_extraction_rules.xml index 0c4f95cab..e59428ef8 100644 --- a/android/Omnivore/app/src/main/res/xml/data_extraction_rules.xml +++ b/android/Omnivore/app/src/main/res/xml/data_extraction_rules.xml @@ -5,7 +5,7 @@ --> -