mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #1178 from omnivore-app/feature/android-sign-up
Android Sign Up
This commit is contained in:
commit
1ab1db4819
11 changed files with 729 additions and 43 deletions
|
|
@ -0,0 +1,3 @@
|
|||
query ValidateUsername($username: String!) {
|
||||
validateUsername(username: $username)
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ object Constants {
|
|||
object DatastoreKeys {
|
||||
const val omnivoreAuthToken = "omnivoreAuthToken"
|
||||
const val omnivoreAuthCookieString = "omnivoreAuthCookieString"
|
||||
const val omnivorePendingUserToken = "omnivorePendingUserToken"
|
||||
}
|
||||
|
||||
object AppleConstants {
|
||||
|
|
|
|||
|
|
@ -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<Boolean> = context
|
||||
.dataStore.data.map { preferences ->
|
||||
val key = stringPreferencesKey(DatastoreKeys.omnivoreAuthToken)
|
||||
|
|
|
|||
|
|
@ -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<AuthPayload>
|
||||
}
|
||||
|
||||
interface PendingUserSubmit {
|
||||
@Headers("Content-Type: application/json")
|
||||
@POST("/api/mobile-auth/sign-up")
|
||||
suspend fun submitPendingUser(@Body params: SignInParams): Response<PendingUserAuthPayload>
|
||||
}
|
||||
|
||||
interface CreateAccountSubmit {
|
||||
@Headers("Content-Type: application/json")
|
||||
@POST("/api/mobile-auth/create-account")
|
||||
suspend fun submitCreateAccount(@Body params: CreateAccountParams): Response<AuthPayload>
|
||||
}
|
||||
|
||||
interface CreateEmailAccountSubmit {
|
||||
@Headers("Content-Type: application/json")
|
||||
@POST("/api/mobile-auth/email-sign-up")
|
||||
suspend fun submitCreateEmailAccount(@Body params: EmailSignUpParams): Response<Unit>
|
||||
}
|
||||
|
||||
object RetrofitHelper {
|
||||
fun getInstance(): Retrofit {
|
||||
return Retrofit.Builder().baseUrl(Constants.apiURL)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String?>(null)
|
||||
private set
|
||||
|
||||
var hasValidUsername by mutableStateOf<Boolean>(false)
|
||||
private set
|
||||
|
||||
var usernameValidationErrorMessage by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
var pendingEmailUserCreds by mutableStateOf<PendingEmailUserCreds?>(null)
|
||||
private set
|
||||
|
||||
val hasAuthTokenLiveData: LiveData<Boolean> = 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<!-- Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
|
|
|
|||
Loading…
Reference in a new issue