refactor onboarding

This commit is contained in:
Stefano Sansone
2024-06-23 19:53:19 +02:00
parent 1c9626eabf
commit f25acf0628
29 changed files with 1444 additions and 1198 deletions

View File

@ -3,10 +3,11 @@ import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
kotlin("android") alias(libs.plugins.org.jetbrains.kotlin.android)
id("dagger.hilt.android.plugin") id("dagger.hilt.android.plugin")
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.apollo) alias(libs.plugins.apollo)
alias(libs.plugins.compose.compiler)
} }
val keystorePropertiesFile = rootProject.file("app/external/keystore.properties") val keystorePropertiesFile = rootProject.file("app/external/keystore.properties")
@ -90,9 +91,7 @@ android {
compose = true compose = true
buildConfig = true buildConfig = true
} }
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get()
}
packaging { packaging {
resources { resources {
excludes += listOf("/META-INF/{AL2.0,LGPL2.1}") excludes += listOf("/META-INF/{AL2.0,LGPL2.1}")

View File

@ -0,0 +1,43 @@
package app.omnivore.omnivore.core.designsystem.component
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun DividerWithText(text: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
HorizontalDivider(
modifier = Modifier
.weight(1f)
.padding(end = 8.dp),
thickness = 1.dp,
color = Color.Black
)
Text(
text = text,
color = Color.Black,
)
HorizontalDivider(
modifier = Modifier
.weight(1f)
.width(50.dp)
.padding(start = 8.dp),
thickness = 1.dp,
color = Color.Black
)
}
}

View File

@ -0,0 +1,5 @@
package app.omnivore.omnivore.core.designsystem.theme
import androidx.compose.ui.graphics.Color
val OmnivoreBrand = Color(0xFFFCEBA8)

View File

@ -1,96 +0,0 @@
package app.omnivore.omnivore.feature.auth
import android.annotation.SuppressLint
import android.net.Uri
import android.view.ViewGroup
import android.webkit.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import app.omnivore.omnivore.utils.AppleConstants
import app.omnivore.omnivore.R
import java.net.URLEncoder
import java.util.*
@Composable
fun AppleAuthButton(viewModel: LoginViewModel) {
val showDialog = remember { mutableStateOf(false) }
LoadingButtonWithIcon(
text = stringResource(R.string.apple_auth_text),
loadingText = stringResource(R.string.apple_auth_loading),
isLoading = viewModel.isLoading,
icon = painterResource(id = R.drawable.ic_logo_apple),
modifier = Modifier.padding(vertical = 6.dp),
onClick = { showDialog.value = true }
)
if (showDialog.value) {
AppleAuthDialog(onDismiss = { token ->
if (token != null ) {
viewModel.handleAppleToken(token)
}
showDialog.value = false
})
}
}
@Composable
fun AppleAuthDialog(onDismiss: (String?) -> Unit) {
Dialog(onDismissRequest = { onDismiss(null) }) {
Surface(
shape = RoundedCornerShape(16.dp),
color = Color.White
) {
AppleAuthWebView(onDismiss)
}
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun AppleAuthWebView(onDismiss: (String?) -> Unit) {
val url = AppleConstants.authUrl +
"?client_id=" + AppleConstants.clientId +
"&redirect_uri=" + URLEncoder.encode(AppleConstants.redirectURI, "utf8") +
"&response_type=code%20id_token" +
"&scope=" + AppleConstants.scope +
"&response_mode=form_post" +
"&state=android:login"
// Adding a WebView inside AndroidView
// with layout as full screen
AndroidView(factory = {
WebView(it).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
if (request?.url.toString().contains("android-apple-token")) {
val uri = Uri.parse(request!!.url.toString())
val token = uri.getQueryParameter("token")
onDismiss(token)
}
return true
}
}
settings.javaScriptEnabled = true
loadUrl(url)
}
}, update = {
it.loadUrl(url)
})
}

View File

@ -1,159 +0,0 @@
package app.omnivore.omnivore.feature.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.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
@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 = stringResource(R.string.create_user_profile_title),
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(stringResource(R.string.create_user_profile_loading))
}
ClickableText(
text = AnnotatedString(stringResource(R.string.create_user_profile_action_cancel)),
style = MaterialTheme.typography.titleMedium
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = { viewModel.cancelNewUserSignUp() }
)
}
Spacer(modifier = Modifier.weight(1.0F))
}
}
@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(stringResource(R.string.create_user_profile_field_placeholder_name)) },
label = { Text(stringResource(R.string.create_user_profile_field_label_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(stringResource(R.string.create_user_profile_field_placeholder_username)) },
label = { Text(stringResource(R.string.create_user_profile_field_label_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,
context.getString(R.string.create_user_profile_error_msg),
Toast.LENGTH_SHORT
).show()
}
}, colors = ButtonDefaults.buttonColors(
contentColor = Color(0xFF3D3D3D),
containerColor = Color(0xffffd234)
)
) {
Text(
text = stringResource(R.string.create_user_profile_action_submit),
modifier = Modifier.padding(horizontal = 100.dp)
)
}
}
}

View File

@ -1,171 +0,0 @@
package app.omnivore.omnivore.feature.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.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalUriHandler
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.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import app.omnivore.omnivore.BuildConfig
import app.omnivore.omnivore.R
import app.omnivore.omnivore.feature.auth.AuthUtils.autofill
@SuppressLint("CoroutineCreationDuringComposition")
@Composable
fun EmailLoginView(viewModel: LoginViewModel) {
val uriHandler = LocalUriHandler.current
var email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
Row(
horizontalArrangement = Arrangement.Center
) {
Spacer(modifier = Modifier.weight(1.0F))
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
LoginFields(
email,
password,
onEmailChange = { email = it },
onPasswordChange = { password = it },
onLoginClick = { viewModel.login(email, password) }
)
// TODO: add a activity indicator (maybe after a delay?)
if (viewModel.isLoading) {
Text(stringResource(R.string.email_login_loading))
}
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
ClickableText(
text = AnnotatedString(stringResource(R.string.email_login_action_back)),
style = MaterialTheme.typography.titleMedium
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = { viewModel.showSocialLogin() }
)
ClickableText(
text = AnnotatedString(stringResource(R.string.email_login_action_no_account)),
style = MaterialTheme.typography.titleMedium
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = { viewModel.showEmailSignUp() }
)
ClickableText(
text = AnnotatedString(stringResource(R.string.email_login_action_forgot_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))
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun LoginFields(
email: String,
password: String,
onEmailChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onLoginClick: () -> 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(
modifier = Modifier.autofill(
autofillTypes = listOf(
AutofillType.EmailAddress,
),
onFill = { onEmailChange(it) }
),
value = email,
placeholder = { Text(stringResource(R.string.email_login_field_placeholder_email)) },
label = { Text(stringResource(R.string.email_login_field_label_email)) },
onValueChange = onEmailChange,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Email,
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
)
OutlinedTextField(
modifier = Modifier.autofill(
autofillTypes = listOf(
AutofillType.Password,
),
onFill = { onPasswordChange(it) }
),
value = password,
placeholder = { Text(stringResource(R.string.email_login_field_placeholder_password)) },
label = { Text(stringResource(R.string.email_login_field_label_password)) },
onValueChange = onPasswordChange,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Password,
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
)
Button(
onClick = {
if (email.isNotBlank() && password.isNotBlank()) {
onLoginClick()
focusManager.clearFocus()
} else {
Toast.makeText(
context,
context.getString(R.string.email_login_error_msg),
Toast.LENGTH_SHORT
).show()
}
}, colors = ButtonDefaults.buttonColors(
contentColor = Color(0xFF3D3D3D),
containerColor = Color(0xffffd234)
)
) {
Text(
text = stringResource(R.string.email_login_action_login),
modifier = Modifier.padding(horizontal = 100.dp)
)
}
}
}

View File

@ -1,264 +0,0 @@
package app.omnivore.omnivore.feature.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.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType
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.input.PasswordVisualTransformation
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 app.omnivore.omnivore.feature.auth.AuthUtils.autofill
@Composable
fun EmailSignUpView(viewModel: LoginViewModel) {
if (viewModel.pendingEmailUserCreds != null) {
val email = viewModel.pendingEmailUserCreds?.email ?: ""
val password = viewModel.pendingEmailUserCreds?.password ?: ""
Row(
horizontalArrangement = Arrangement.Center
) {
Spacer(modifier = Modifier.weight(1.0F))
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.email_signup_verification_message, email),
style = MaterialTheme.typography.titleMedium
)
Button(onClick = {
viewModel.login(email, password)
}, colors = ButtonDefaults.buttonColors(
contentColor = Color(0xFF3D3D3D),
containerColor = Color(0xffffd234)
)
) {
Text(
text = stringResource(R.string.email_signup_check_status),
modifier = Modifier.padding(horizontal = 100.dp)
)
}
ClickableText(
text = AnnotatedString(stringResource(R.string.email_signup_action_use_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(stringResource(R.string.email_signup_loading))
}
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
ClickableText(
text = AnnotatedString(stringResource(R.string.email_signup_action_back)),
style = MaterialTheme.typography.titleMedium
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = { viewModel.showSocialLogin() }
)
ClickableText(
text = AnnotatedString(stringResource(R.string.email_signup_action_already_have_account)),
style = MaterialTheme.typography.titleMedium
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = { viewModel.showEmailSignIn() }
)
}
}
Spacer(modifier = Modifier.weight(1.0F))
}
}
@OptIn(ExperimentalComposeUiApi::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(
modifier = Modifier.autofill(
autofillTypes = listOf(
AutofillType.EmailAddress,
),
onFill = { onEmailChange(it) }
),
value = email,
placeholder = { Text(stringResource(R.string.email_signup_field_placeholder_email)) },
label = { Text(stringResource(R.string.email_signup_field_label_email)) },
onValueChange = onEmailChange,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
)
OutlinedTextField(
modifier = Modifier.autofill(
autofillTypes = listOf(
AutofillType.Password,
),
onFill = { onPasswordChange(it) }
),
value = password,
placeholder = { Text(stringResource(R.string.email_signup_field_placeholder_password)) },
label = { Text(stringResource(R.string.email_signup_field_label_password)) },
onValueChange = onPasswordChange,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
)
OutlinedTextField(
value = name,
placeholder = { Text(stringResource(R.string.email_signup_field_placeholder_name)) },
label = { Text(stringResource(R.string.email_signup_field_label_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(stringResource(R.string.email_signup_field_placeholder_username)) },
label = { Text(stringResource(R.string.email_signup_field_label_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,
context.getString(R.string.email_signup_error_msg),
Toast.LENGTH_SHORT
).show()
}
}, colors = ButtonDefaults.buttonColors(
contentColor = Color(0xFF3D3D3D),
containerColor = Color(0xffffd234)
)
) {
Text(
text = stringResource(R.string.email_signup_action_sign_up),
modifier = Modifier.padding(horizontal = 100.dp)
)
}
}
}

View File

@ -1,62 +0,0 @@
package app.omnivore.omnivore.feature.auth
import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import app.omnivore.omnivore.BuildConfig
import app.omnivore.omnivore.R
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.tasks.Task
@Composable
fun GoogleAuthButton(viewModel: LoginViewModel) {
val context = LocalContext.current
val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestIdToken(BuildConfig.OMNIVORE_GAUTH_SERVER_CLIENT_ID)
.requestEmail()
.build()
val startForResult =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
val intent = result.data
if (result.data != null) {
val task: Task<GoogleSignInAccount> = GoogleSignIn.getSignedInAccountFromIntent(intent)
viewModel.handleGoogleAuthTask(task)
}
} else {
viewModel.showGoogleErrorMessage()
}
}
LoadingButtonWithIcon(
text = stringResource(R.string.google_auth_text),
loadingText = stringResource(R.string.google_auth_loading),
isLoading = viewModel.isLoading,
icon = painterResource(id = R.drawable.ic_logo_google),
onClick = {
val googleSignIn = GoogleSignIn.getClient(context, signInOptions)
googleSignIn.silentSignIn()
.addOnCompleteListener { task ->
if (task.isSuccessful) {
viewModel.handleGoogleAuthTask(task)
} else {
startForResult.launch(googleSignIn.signInIntent)
}
}
.addOnFailureListener {
startForResult.launch(googleSignIn.signInIntent)
}
}
)
}

View File

@ -1,69 +0,0 @@
package app.omnivore.omnivore.feature.auth
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.dp
@Composable
fun LoadingButtonWithIcon(
modifier: Modifier = Modifier,
text: String,
loadingText: String,
icon: Painter,
isLoading: Boolean = false,
shape: Shape = Shapes().medium,
borderColor: Color = Color.LightGray,
backgroundColor: Color = MaterialTheme.colorScheme.surface,
progressIndicatorColor: Color = MaterialTheme.colorScheme.primary,
onClick: () -> Unit
) {
Surface(
modifier = modifier.clickable(
enabled = !isLoading,
onClick = onClick
),
shape = shape,
border = BorderStroke(width = 1.dp, color = borderColor),
color = backgroundColor
) {
Row(
modifier = Modifier
.padding(
start = 12.dp,
end = 16.dp,
top = 12.dp,
bottom = 12.dp
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Icon(
painter = icon,
contentDescription = "SignInButton",
tint = Color.Unspecified
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = if (isLoading) loadingText else text)
if (isLoading) {
Spacer(modifier = Modifier.width(16.dp))
CircularProgressIndicator(
modifier = Modifier
.height(16.dp)
.width(16.dp),
strokeWidth = 2.dp,
color = progressIndicatorColor
)
}
}
}
}

View File

@ -1,174 +0,0 @@
package app.omnivore.omnivore.feature.auth
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.Image
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import app.omnivore.omnivore.R
import app.omnivore.omnivore.feature.theme.OmnivoreTheme
import com.google.android.gms.common.GoogleApiAvailability
@Composable
fun WelcomeScreen(viewModel: LoginViewModel) {
OmnivoreTheme(darkTheme = false) {
Surface(
modifier = Modifier.fillMaxSize()
) {
WelcomeScreenContent(viewModel = viewModel)
}
}
}
@SuppressLint("CoroutineCreationDuringComposition")
@Composable
fun WelcomeScreenContent(viewModel: LoginViewModel) {
val registrationState: RegistrationState by viewModel.registrationStateLiveData.observeAsState(
RegistrationState.SocialLogin
)
val snackBarHostState = remember { SnackbarHostState() }
Scaffold(
modifier = Modifier.fillMaxSize(),
snackbarHost = { SnackbarHost(hostState = snackBarHostState) },
containerColor = Color(0xFFFCEBA8)
) { paddingValues ->
Column(
verticalArrangement = Arrangement.SpaceAround,
horizontalAlignment = Alignment.Start,
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.padding(paddingValues)
) {
Spacer(modifier = Modifier.height(50.dp))
Image(
painter = painterResource(id = R.drawable.ic_omnivore_name_logo),
contentDescription = "Omnivore Icon with Name"
)
Spacer(modifier = Modifier.height(50.dp))
when (registrationState) {
RegistrationState.EmailSignIn -> {
EmailLoginView(viewModel = viewModel)
}
RegistrationState.EmailSignUp -> {
EmailSignUpView(viewModel = viewModel)
}
RegistrationState.SelfHosted -> {
SelfHostedView(viewModel = viewModel)
}
RegistrationState.SocialLogin -> {
Text(
text = stringResource(id = R.string.welcome_title),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineLarge
)
Text(
text = stringResource(id = R.string.welcome_subtitle),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleSmall
)
MoreInfoButton()
Spacer(modifier = Modifier.height(50.dp))
AuthProviderView(viewModel = viewModel)
}
RegistrationState.PendingUser -> {
CreateUserProfileView(viewModel = viewModel)
}
}
Spacer(modifier = Modifier.weight(1.0F))
}
}
LaunchedEffect(viewModel.errorMessage) {
viewModel.errorMessage?.let { message ->
val result = snackBarHostState.showSnackbar(
message,
actionLabel = "Dismiss",
duration = SnackbarDuration.Indefinite
)
when (result) {
SnackbarResult.ActionPerformed -> viewModel.resetErrorMessage()
else -> {}
}
}
}
}
@Composable
fun AuthProviderView(viewModel: LoginViewModel) {
val isGoogleAuthAvailable: Boolean =
GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(LocalContext.current) == 0
Row(
horizontalArrangement = Arrangement.Center
) {
Spacer(modifier = Modifier.weight(1.0F))
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (isGoogleAuthAvailable) {
GoogleAuthButton(viewModel)
}
AppleAuthButton(viewModel)
ClickableText(text = AnnotatedString(stringResource(R.string.welcome_screen_action_continue_with_email)),
style = MaterialTheme.typography.titleMedium.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = { viewModel.showEmailSignIn() })
Spacer(modifier = Modifier.weight(1.0F))
ClickableText(
text = AnnotatedString(stringResource(R.string.welcome_screen_action_self_hosting_options)),
style = MaterialTheme.typography.titleMedium.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = { viewModel.showSelfHostedSettings() },
modifier = Modifier.padding(vertical = 10.dp)
)
}
Spacer(modifier = Modifier.weight(1.0F))
}
}
@Composable
fun MoreInfoButton() {
val context = LocalContext.current
val intent = remember { Intent(Intent.ACTION_VIEW, Uri.parse("https://omnivore.app/about")) }
ClickableText(
text = AnnotatedString(
stringResource(id = R.string.learn_more),
),
style = MaterialTheme.typography.titleSmall.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = {
context.startActivity(intent)
},
modifier = Modifier.padding(vertical = 6.dp)
)
}

View File

@ -0,0 +1,213 @@
package app.omnivore.omnivore.feature.onboarding
import android.content.Intent
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowInsetsControllerCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.designsystem.theme.OmnivoreBrand
import app.omnivore.omnivore.feature.onboarding.auth.AuthProviderScreen
import app.omnivore.omnivore.feature.onboarding.auth.EmailSignInScreen
import app.omnivore.omnivore.feature.onboarding.auth.EmailSignUpScreen
import app.omnivore.omnivore.feature.onboarding.auth.SelfHostedScreen
import app.omnivore.omnivore.feature.theme.OmnivoreTheme
import app.omnivore.omnivore.navigation.OmnivoreNavHost
import app.omnivore.omnivore.navigation.Routes
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OnboardingScreen(
navController: NavHostController,
viewModel: LoginViewModel = hiltViewModel()
) {
val activity = LocalContext.current as ComponentActivity
val welcomeNavController = rememberNavController()
val snackBarHostState = remember { SnackbarHostState() }
val currentRoute by welcomeNavController.currentBackStackEntryFlow.collectAsState(
initial = welcomeNavController.currentBackStackEntry
)
val errorMessage by viewModel.errorMessage.collectAsStateWithLifecycle()
OmnivoreTheme(darkTheme = false) {
LaunchedEffect(key1 = errorMessage) {
errorMessage?.let { message ->
val result = snackBarHostState.showSnackbar(
message = message,
actionLabel = "Dismiss",
duration = SnackbarDuration.Indefinite
)
when (result) {
SnackbarResult.ActionPerformed -> viewModel.resetErrorMessage()
else -> {}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { },
navigationIcon = {
if (currentRoute?.destination?.route != Routes.AuthProvider.route) {
IconButton(onClick = { welcomeNavController.popBackStack() }) {
Icon(imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = OmnivoreBrand
)
)
},
modifier = Modifier
.fillMaxSize()
.imePadding(),
snackbarHost = { SnackbarHost(hostState = snackBarHostState) },
containerColor = OmnivoreBrand
) { paddingValues ->
LazyColumn(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.padding(paddingValues)
) {
item {
WelcomeHeader()
}
item {
OmnivoreNavHost(
navController = welcomeNavController,
startDestination = Routes.AuthProvider.route
) {
composable(Routes.AuthProvider.route) {
AuthProviderScreen(
navController = navController,
welcomeNavController = welcomeNavController
)
}
composable(Routes.EmailSignIn.route) {
EmailSignInScreen(
navController = navController,
welcomeNavController = welcomeNavController
)
}
composable(Routes.EmailSignUp.route) {
EmailSignUpScreen()
}
composable(Routes.SelfHosting.route){
SelfHostedScreen()
}
}
}
}
}
// Set the light status bar
DisposableEffect(Unit) {
val windowInsetsController = WindowInsetsControllerCompat(activity.window, activity.window.decorView)
val originalAppearanceLightStatusBars = windowInsetsController.isAppearanceLightStatusBars
val originalStatusBarColor = activity.window.statusBarColor
// Set light status bar
windowInsetsController.isAppearanceLightStatusBars = true
activity.window.statusBarColor = Color.Transparent.toArgb()
onDispose {
// Restore original status bar settings
windowInsetsController.isAppearanceLightStatusBars = originalAppearanceLightStatusBars
activity.window.statusBarColor = originalStatusBarColor
}
}
}
}
@Composable
fun WelcomeHeader() {
Column(
modifier = Modifier.padding(bottom = 64.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Image(
painter = painterResource(id = R.drawable.ic_omnivore_name_logo),
contentDescription = "Omnivore Icon with Name"
)
Text(
text = stringResource(id = R.string.welcome_title),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineLarge
)
Text(
text = stringResource(id = R.string.welcome_subtitle),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleSmall
)
MoreInfoButton()
}
}
@Composable
fun MoreInfoButton() {
val context = LocalContext.current
val intent = remember { Intent(Intent.ACTION_VIEW, Uri.parse("https://omnivore.app/about")) }
ClickableText(
text = AnnotatedString(
stringResource(id = R.string.learn_more),
),
style = MaterialTheme.typography.titleSmall.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = {
context.startActivity(intent)
},
modifier = Modifier.padding(vertical = 6.dp)
)
}

View File

@ -1,11 +1,10 @@
package app.omnivore.omnivore.feature.auth package app.omnivore.omnivore.feature.onboarding
import android.content.Context import android.content.Context
import android.widget.Toast import android.widget.Toast
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.BuildConfig import app.omnivore.omnivore.BuildConfig
@ -43,8 +42,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import io.intercom.android.sdk.Intercom import io.intercom.android.sdk.Intercom
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -52,10 +53,6 @@ import kotlinx.coroutines.runBlocking
import java.util.regex.Pattern import java.util.regex.Pattern
import javax.inject.Inject import javax.inject.Inject
enum class RegistrationState {
SocialLogin, EmailSignIn, EmailSignUp, PendingUser, SelfHosted
}
data class PendingEmailUserCreds( data class PendingEmailUserCreds(
val email: String, val password: String val email: String, val password: String
) )
@ -73,8 +70,8 @@ class LoginViewModel @Inject constructor(
var isLoading by mutableStateOf(false) var isLoading by mutableStateOf(false)
private set private set
var errorMessage by mutableStateOf<String?>(null) private val _errorMessage = MutableStateFlow<String?>(null)
private set val errorMessage: StateFlow<String?> get() = _errorMessage.asStateFlow()
var hasValidUsername by mutableStateOf(false) var hasValidUsername by mutableStateOf(false)
private set private set
@ -92,8 +89,6 @@ class LoginViewModel @Inject constructor(
initialValue = true initialValue = true
) )
val registrationStateLiveData = MutableLiveData(RegistrationState.SocialLogin)
val followingTabActiveState: StateFlow<Boolean> = datastoreRepository.getBoolean( val followingTabActiveState: StateFlow<Boolean> = datastoreRepository.getBoolean(
followingTabActive followingTabActive
).stateIn( ).stateIn(
@ -124,29 +119,11 @@ class LoginViewModel @Inject constructor(
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
).show() ).show()
} }
} }
fun showSocialLogin() { private fun showEmailSignUp(pendingCreds: PendingEmailUserCreds? = null) {
resetState()
registrationStateLiveData.value = RegistrationState.SocialLogin
}
fun showEmailSignIn() {
resetState()
registrationStateLiveData.value = RegistrationState.EmailSignIn
}
fun showEmailSignUp(pendingCreds: PendingEmailUserCreds? = null) {
resetState() resetState()
pendingEmailUserCreds = pendingCreds pendingEmailUserCreds = pendingCreds
registrationStateLiveData.value = RegistrationState.EmailSignUp
}
fun showSelfHostedSettings() {
resetState()
registrationStateLiveData.value = RegistrationState.SelfHosted
} }
fun cancelNewUserSignUp() { fun cancelNewUserSignUp() {
@ -154,7 +131,7 @@ class LoginViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
datastoreRepository.clearValue(omnivorePendingUserToken) datastoreRepository.clearValue(omnivorePendingUserToken)
} }
showSocialLogin() resetState()
} }
fun registerUser() { fun registerUser() {
@ -166,10 +143,18 @@ class LoginViewModel @Inject constructor(
} }
} }
private fun resetState() { private fun setErrorMessage(message: String) {
_errorMessage.value = message
}
fun resetErrorMessage() {
_errorMessage.value = null
}
fun resetState() {
validateUsernameJob = null validateUsernameJob = null
isLoading = false isLoading = false
errorMessage = null resetErrorMessage()
hasValidUsername = false hasValidUsername = false
usernameValidationErrorMessage = null usernameValidationErrorMessage = null
pendingEmailUserCreds = null pendingEmailUserCreds = null
@ -240,7 +225,7 @@ class LoginViewModel @Inject constructor(
RetrofitHelper.getInstance(networker).create(EmailLoginSubmit::class.java) RetrofitHelper.getInstance(networker).create(EmailLoginSubmit::class.java)
isLoading = true isLoading = true
errorMessage = null resetErrorMessage()
val result = emailLogin.submitEmailLogin( val result = emailLogin.submitEmailLogin(
EmailLoginCredentials(email = email, password = password) EmailLoginCredentials(email = email, password = password)
@ -260,9 +245,7 @@ class LoginViewModel @Inject constructor(
if (result.body()?.authToken != null) { if (result.body()?.authToken != null) {
datastoreRepository.putString(omnivoreAuthToken, result.body()?.authToken!!) datastoreRepository.putString(omnivoreAuthToken, result.body()?.authToken!!)
} else { } else {
errorMessage = resourceProvider.getString( setErrorMessage(resourceProvider.getString(R.string.login_view_model_something_went_wrong_error_msg))
R.string.login_view_model_something_went_wrong_error_msg
)
} }
if (result.body()?.authCookieString != null) { if (result.body()?.authCookieString != null) {
@ -284,7 +267,7 @@ class LoginViewModel @Inject constructor(
RetrofitHelper.getInstance(networker).create(CreateEmailAccountSubmit::class.java) RetrofitHelper.getInstance(networker).create(CreateEmailAccountSubmit::class.java)
isLoading = true isLoading = true
errorMessage = null resetErrorMessage()
val params = EmailSignUpParams( val params = EmailSignUpParams(
email = email, password = password, name = name, username = username email = email, password = password, name = name, username = username
@ -295,9 +278,7 @@ class LoginViewModel @Inject constructor(
isLoading = false isLoading = false
if (result.errorBody() != null) { if (result.errorBody() != null) {
errorMessage = resourceProvider.getString( setErrorMessage(resourceProvider.getString(R.string.login_view_model_something_went_wrong_error_msg))
R.string.login_view_model_something_went_wrong_two_error_msg
)
} else { } else {
pendingEmailUserCreds = PendingEmailUserCreds(email, password) pendingEmailUserCreds = PendingEmailUserCreds(email, password)
} }
@ -314,7 +295,7 @@ class LoginViewModel @Inject constructor(
RetrofitHelper.getInstance(networker).create(CreateAccountSubmit::class.java) RetrofitHelper.getInstance(networker).create(CreateAccountSubmit::class.java)
isLoading = true isLoading = true
errorMessage = null resetErrorMessage()
val pendingUserToken = getPendingAuthToken() ?: "" val pendingUserToken = getPendingAuthToken() ?: ""
@ -330,9 +311,7 @@ class LoginViewModel @Inject constructor(
if (result.body()?.authToken != null) { if (result.body()?.authToken != null) {
datastoreRepository.putString(omnivoreAuthToken, result.body()?.authToken!!) datastoreRepository.putString(omnivoreAuthToken, result.body()?.authToken!!)
} else { } else {
errorMessage = resourceProvider.getString( setErrorMessage(resourceProvider.getString(R.string.login_view_model_something_went_wrong_error_msg))
R.string.login_view_model_something_went_wrong_error_msg
)
} }
if (result.body()?.authCookieString != null) { if (result.body()?.authCookieString != null) {
@ -358,12 +337,8 @@ class LoginViewModel @Inject constructor(
} }
} }
fun resetErrorMessage() {
errorMessage = null
}
fun showGoogleErrorMessage() { fun showGoogleErrorMessage() {
errorMessage = resourceProvider.getString(R.string.login_view_model_google_auth_error_msg) setErrorMessage(resourceProvider.getString(R.string.login_view_model_google_auth_error_msg))
} }
fun handleGoogleAuthTask(task: Task<GoogleSignInAccount>) { fun handleGoogleAuthTask(task: Task<GoogleSignInAccount>) {
@ -372,9 +347,7 @@ class LoginViewModel @Inject constructor(
// If token is missing then set the error message // If token is missing then set the error message
if (googleIdToken.isEmpty()) { if (googleIdToken.isEmpty()) {
errorMessage = resourceProvider.getString( setErrorMessage(resourceProvider.getString(R.string.login_view_model_missing_auth_token_error_msg))
R.string.login_view_model_missing_auth_token_error_msg
)
return return
} }
@ -390,7 +363,7 @@ class LoginViewModel @Inject constructor(
RetrofitHelper.getInstance(networker).create(AuthProviderLoginSubmit::class.java) RetrofitHelper.getInstance(networker).create(AuthProviderLoginSubmit::class.java)
isLoading = true isLoading = true
errorMessage = null resetErrorMessage()
val result = login.submitAuthProviderLogin(params) val result = login.submitAuthProviderLogin(params)
@ -413,15 +386,11 @@ class LoginViewModel @Inject constructor(
418 -> { 418 -> {
// Show pending email state // Show pending email state
errorMessage = resourceProvider.getString( setErrorMessage(resourceProvider.getString(R.string.login_view_model_something_went_wrong_two_error_msg))
R.string.login_view_model_something_went_wrong_two_error_msg
)
} }
else -> { else -> {
errorMessage = resourceProvider.getString( setErrorMessage(resourceProvider.getString(R.string.login_view_model_something_went_wrong_two_error_msg))
R.string.login_view_model_something_went_wrong_two_error_msg
)
} }
} }
} }
@ -430,7 +399,7 @@ class LoginViewModel @Inject constructor(
private suspend fun submitAuthProviderPayloadForPendingToken(params: SignInParams) { private suspend fun submitAuthProviderPayloadForPendingToken(params: SignInParams) {
isLoading = true isLoading = true
errorMessage = null resetErrorMessage()
val request = RetrofitHelper.getInstance(networker).create(PendingUserSubmit::class.java) val request = RetrofitHelper.getInstance(networker).create(PendingUserSubmit::class.java)
val result = request.submitPendingUser(params) val result = request.submitPendingUser(params)
@ -441,11 +410,9 @@ class LoginViewModel @Inject constructor(
datastoreRepository.putString( datastoreRepository.putString(
omnivorePendingUserToken, result.body()?.pendingUserToken!! omnivorePendingUserToken, result.body()?.pendingUserToken!!
) )
registrationStateLiveData.value = RegistrationState.PendingUser // TODO go to pending user
} else { } else {
errorMessage = resourceProvider.getString( setErrorMessage(resourceProvider.getString(R.string.login_view_model_something_went_wrong_two_error_msg))
R.string.login_view_model_something_went_wrong_two_error_msg
)
} }
} }
} }

View File

@ -0,0 +1,93 @@
package app.omnivore.omnivore.feature.onboarding.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.res.stringResource
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import app.omnivore.omnivore.R
import app.omnivore.omnivore.feature.onboarding.LoginViewModel
import app.omnivore.omnivore.feature.onboarding.auth.provider.AppleAuthButton
import app.omnivore.omnivore.feature.onboarding.auth.provider.GoogleAuthButton
import app.omnivore.omnivore.navigation.Routes
import com.google.android.gms.common.GoogleApiAvailability
@Composable
fun AuthProviderScreen(
navController: NavHostController,
welcomeNavController: NavHostController,
viewModel: LoginViewModel = hiltViewModel()
) {
val isGoogleAuthAvailable: Boolean =
GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(LocalContext.current) == 0
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(bottom = 64.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.width(500.dp)
) {
if (isGoogleAuthAvailable) {
GoogleAuthButton(viewModel)
}
AppleAuthButton(viewModel)
OutlinedButton(
onClick = {
welcomeNavController.navigate(Routes.EmailSignIn.route)
viewModel.resetState()
},
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp),
shape = RoundedCornerShape(6.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Text(text = stringResource(R.string.welcome_screen_action_continue_with_email), modifier = Modifier.padding(vertical = 6.dp))
}
Spacer(modifier = Modifier.weight(1.0F))
TextButton(
onClick = {
viewModel.resetState()
welcomeNavController.navigate(Routes.SelfHosting.route)
},
modifier = Modifier.padding(vertical = 10.dp),
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onSurface
)
){
Text(
text = stringResource(R.string.welcome_screen_action_self_hosting_options),
textDecoration = TextDecoration.Underline
)
}
}
}
}

View File

@ -0,0 +1,163 @@
package app.omnivore.omnivore.feature.onboarding.auth
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
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 app.omnivore.omnivore.feature.onboarding.LoginViewModel
@Composable
fun CreateUserProfileScreen(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 = stringResource(R.string.create_user_profile_title),
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(stringResource(R.string.create_user_profile_loading))
}
ClickableText(text = AnnotatedString(stringResource(R.string.create_user_profile_action_cancel)),
style = MaterialTheme.typography.titleMedium.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = { viewModel.cancelNewUserSignUp() })
}
Spacer(modifier = Modifier.weight(1.0F))
}
}
@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(stringResource(R.string.create_user_profile_field_placeholder_name)) },
label = { Text(stringResource(R.string.create_user_profile_field_label_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(stringResource(R.string.create_user_profile_field_placeholder_username)) },
label = { Text(stringResource(R.string.create_user_profile_field_label_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,
context.getString(R.string.create_user_profile_error_msg),
Toast.LENGTH_SHORT
).show()
}
}, colors = ButtonDefaults.buttonColors(
contentColor = Color(0xFF3D3D3D), containerColor = Color(0xffffd234)
)
) {
Text(
text = stringResource(R.string.create_user_profile_action_submit),
modifier = Modifier.padding(horizontal = 100.dp)
)
}
}
}

View File

@ -0,0 +1,212 @@
package app.omnivore.omnivore.feature.onboarding.auth
import android.annotation.SuppressLint
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.designsystem.component.DividerWithText
import app.omnivore.omnivore.feature.onboarding.LoginViewModel
import app.omnivore.omnivore.feature.theme.OmnivoreTheme
import app.omnivore.omnivore.utils.AuthUtils.autofill
import app.omnivore.omnivore.navigation.Routes
import app.omnivore.omnivore.utils.FORGOT_PASSWORD_URL
@SuppressLint("CoroutineCreationDuringComposition")
@Composable
fun EmailSignInScreen(
navController: NavHostController,
welcomeNavController: NavHostController
) {
OmnivoreTheme(darkTheme = false) {
EmailSignInContent(navController, welcomeNavController)
}
}
@Composable
fun EmailSignInContent(
navController: NavHostController,
welcomeNavController: NavHostController,
viewModel: LoginViewModel = hiltViewModel()
) {
val uriHandler = LocalUriHandler.current
var email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.padding(bottom = 64.dp)
) {
Spacer(modifier = Modifier.weight(1.0F))
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
LoginFields(
email,
password,
onEmailChange = { email = it },
onPasswordChange = { password = it },
onLoginClick = { viewModel.login(email, password) },
onCreateAccountClick = {
welcomeNavController.navigate(Routes.EmailSignUp.route)
viewModel.resetState()
},
isLoading = viewModel.isLoading
)
}
Spacer(modifier = Modifier.weight(1.0F))
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun LoginFields(
email: String,
password: String,
onEmailChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onLoginClick: () -> Unit,
onCreateAccountClick: () -> Unit,
isLoading: Boolean
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val uriHandler = LocalUriHandler.current
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedTextField(modifier = Modifier
.autofill(autofillTypes = listOf(
AutofillType.EmailAddress,
), onFill = { onEmailChange(it) })
.fillMaxWidth(),
value = email,
placeholder = { Text(stringResource(R.string.email_login_field_placeholder_email)) },
label = { Text(stringResource(R.string.email_login_field_label_email)) },
onValueChange = onEmailChange,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Email,
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
)
OutlinedTextField(modifier = Modifier
.autofill(autofillTypes = listOf(
AutofillType.Password,
), onFill = { onPasswordChange(it) })
.fillMaxWidth(),
value = password,
placeholder = { Text(stringResource(R.string.email_login_field_placeholder_password)) },
label = { Text(stringResource(R.string.email_login_field_label_password)) },
onValueChange = onPasswordChange,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Password,
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
)
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth()
) {
TextButton(
onClick = {
val uri = FORGOT_PASSWORD_URL
uriHandler.openUri(uri)
}
) {
Text(text = stringResource(R.string.forgot_password))
}
}
Button(
modifier = Modifier.fillMaxWidth(),
enabled = email.isNotBlank() && password.isNotBlank(),
onClick = {
if (email.isNotBlank() && password.isNotBlank()) {
onLoginClick()
focusManager.clearFocus()
} else {
Toast.makeText(
context,
context.getString(R.string.email_login_error_msg),
Toast.LENGTH_SHORT
).show()
}
}, colors = ButtonDefaults.buttonColors(
contentColor = Color(0xFF3D3D3D), containerColor = Color(0xffffd234)
)
) {
Text(
text = stringResource(R.string.email_login_action_login).uppercase()
)
if (isLoading) {
Spacer(modifier = Modifier.width(16.dp))
CircularProgressIndicator(
modifier = Modifier
.height(16.dp)
.width(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
}
}
DividerWithText(text = "or")
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { onCreateAccountClick() },
colors = ButtonDefaults.buttonColors(
contentColor = Color(0xFF3D3D3D), containerColor = Color(0xffffd234)
)
) {
Text(
text = "Create Account".uppercase()
)
}
}
}

View File

@ -0,0 +1,259 @@
package app.omnivore.omnivore.feature.onboarding.auth
import android.annotation.SuppressLint
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType
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.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.omnivore.omnivore.R
import app.omnivore.omnivore.feature.onboarding.LoginViewModel
import app.omnivore.omnivore.utils.AuthUtils.autofill
@Composable
fun EmailSignUpScreen(
viewModel: LoginViewModel = hiltViewModel()
) {
if (viewModel.pendingEmailUserCreds != null) {
val email = viewModel.pendingEmailUserCreds?.email ?: ""
val password = viewModel.pendingEmailUserCreds?.password ?: ""
Row(
horizontalArrangement = Arrangement.Center
) {
Spacer(modifier = Modifier.weight(1.0F))
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.email_signup_verification_message, email),
style = MaterialTheme.typography.titleMedium
)
Button(
onClick = {
viewModel.login(email, password)
}, colors = ButtonDefaults.buttonColors(
contentColor = Color(0xFF3D3D3D), containerColor = Color(0xffffd234)
)
) {
Text(
text = stringResource(R.string.email_signup_check_status),
modifier = Modifier.padding(horizontal = 100.dp)
)
}
ClickableText(
text = AnnotatedString(stringResource(R.string.email_signup_action_use_different_email)),
style = MaterialTheme.typography.titleMedium.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = { viewModel.resetState() }
)
}
}
} 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,
modifier = Modifier.padding(bottom = 64.dp)
) {
Spacer(modifier = Modifier.weight(1.0F))
Column(
modifier = Modifier.fillMaxWidth(),
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
)
},
isLoading = viewModel.isLoading
)
}
Spacer(modifier = Modifier.weight(1.0F))
}
}
@OptIn(ExperimentalComposeUiApi::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,
isLoading: Boolean
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedTextField(
modifier = Modifier
.autofill(
autofillTypes = listOf(AutofillType.EmailAddress),
onFill = { onEmailChange(it) }
)
.fillMaxWidth(),
value = email,
placeholder = { Text(stringResource(R.string.email_signup_field_placeholder_email)) },
label = { Text(stringResource(R.string.email_signup_field_label_email)) },
onValueChange = onEmailChange,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
)
OutlinedTextField(modifier = Modifier.autofill(autofillTypes = listOf(
AutofillType.Password,
), onFill = { onPasswordChange(it) }).fillMaxWidth(),
value = password,
placeholder = { Text(stringResource(R.string.email_signup_field_placeholder_password)) },
label = { Text(stringResource(R.string.email_signup_field_label_password)) },
onValueChange = onPasswordChange,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = name,
placeholder = { Text(stringResource(R.string.email_signup_field_placeholder_name)) },
label = { Text(stringResource(R.string.email_signup_field_label_name)) },
onValueChange = onNameChange,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp),
value = username,
placeholder = { Text(stringResource(R.string.email_signup_field_placeholder_username)) },
label = { Text(stringResource(R.string.email_signup_field_label_username)) },
onValueChange = onUsernameChange,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
isError = usernameValidationErrorMessage != null,
trailingIcon = {
if (showUsernameAsAvailable) {
Icon(
imageVector = Icons.Filled.CheckCircle, contentDescription = null
)
}
},
supportingText = {
if (usernameValidationErrorMessage != null) {
Text(
text = usernameValidationErrorMessage,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Left
)
}
}
)
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
if (email.isNotBlank() && password.isNotBlank() && username.isNotBlank() && name.isNotBlank()) {
onSubmit()
focusManager.clearFocus()
} else {
Toast.makeText(
context,
context.getString(R.string.email_signup_error_msg),
Toast.LENGTH_SHORT
).show()
}
}, colors = ButtonDefaults.buttonColors(
contentColor = Color(0xFF3D3D3D), containerColor = Color(0xffffd234)
)
) {
Text(
text = stringResource(R.string.email_signup_action_sign_up).uppercase()
)
if (isLoading) {
Spacer(modifier = Modifier.width(16.dp))
CircularProgressIndicator(
modifier = Modifier
.height(16.dp)
.width(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}

View File

@ -1,16 +1,31 @@
package app.omnivore.omnivore.feature.auth package app.omnivore.omnivore.feature.onboarding.auth
import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.* import androidx.compose.material3.Button
import androidx.compose.runtime.* import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -24,20 +39,27 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import app.omnivore.omnivore.R import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.designsystem.component.DividerWithText
import app.omnivore.omnivore.feature.onboarding.LoginViewModel
import app.omnivore.omnivore.utils.SELF_HOSTING_URL
@SuppressLint("CoroutineCreationDuringComposition")
@Composable @Composable
fun SelfHostedView(viewModel: LoginViewModel) { fun SelfHostedScreen(
viewModel: LoginViewModel = hiltViewModel()
) {
var apiServer by rememberSaveable { mutableStateOf("") } var apiServer by rememberSaveable { mutableStateOf("") }
var webServer by rememberSaveable { mutableStateOf("") } var webServer by rememberSaveable { mutableStateOf("") }
val context = LocalContext.current val context = LocalContext.current
Row( Row(
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center,
modifier = Modifier.padding(bottom = 64.dp)
) { ) {
Spacer(modifier = Modifier.weight(1.0F)) Spacer(modifier = Modifier.weight(1.0F))
Column( Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
@ -48,51 +70,25 @@ fun SelfHostedView(viewModel: LoginViewModel) {
onWebServerChange = { webServer = it }, onWebServerChange = { webServer = it },
onSaveClick = { onSaveClick = {
viewModel.setSelfHostingDetails(context, apiServer, webServer) viewModel.setSelfHostingDetails(context, apiServer, webServer)
} },
onResetClick = { viewModel.resetSelfHostingDetails(context) },
isLoading = viewModel.isLoading
) )
// TODO: add a activity indicator (maybe after a delay?) Column(
if (viewModel.isLoading) { modifier = Modifier.padding(top = 16.dp)
Text(stringResource(R.string.self_hosted_view_loading))
}
Row(
horizontalArrangement = Arrangement.Center,
) { ) {
Column( ClickableText(
verticalArrangement = Arrangement.spacedBy(12.dp) text = AnnotatedString(stringResource(R.string.self_hosted_view_action_learn_more)),
) { style = MaterialTheme.typography.titleMedium
ClickableText( .plus(TextStyle(textDecoration = TextDecoration.Underline)),
text = AnnotatedString(stringResource(R.string.self_hosted_view_action_reset)), onClick = {
style = MaterialTheme.typography.titleMedium val uri = Uri.parse(SELF_HOSTING_URL)
.plus(TextStyle(textDecoration = TextDecoration.Underline)), val browserIntent = Intent(Intent.ACTION_VIEW, uri)
onClick = { viewModel.resetSelfHostingDetails(context) }, ContextCompat.startActivity(context, browserIntent, null)
modifier = Modifier.align(Alignment.CenterHorizontally) },
) modifier = Modifier.padding(vertical = 10.dp)
ClickableText( )
text = AnnotatedString(stringResource(R.string.self_hosted_view_action_back)),
style = MaterialTheme.typography.titleMedium
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = { viewModel.showSocialLogin() },
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Spacer(modifier = Modifier.weight(1.0F))
// Text("Omnivore is a free and open-source software project and allows self hosting. \n\n" +
// "If you have chosen to deploy your own server instance, fill in the above fields to " +
// "your private self-hosted instance.\n\n"
// )
ClickableText(
text = AnnotatedString(stringResource(R.string.self_hosted_view_action_learn_more)),
style = MaterialTheme.typography.titleMedium
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = {
val uri = Uri.parse("https://docs.omnivore.app/self-hosting/self-hosting.html")
val browserIntent = Intent(Intent.ACTION_VIEW, uri)
ContextCompat.startActivity(context, browserIntent, null)
},
modifier = Modifier.padding(vertical = 10.dp)
)
}
} }
} }
Spacer(modifier = Modifier.weight(1.0F)) Spacer(modifier = Modifier.weight(1.0F))
@ -105,19 +101,19 @@ fun SelfHostedFields(
webServer: String, webServer: String,
onAPIServerChange: (String) -> Unit, onAPIServerChange: (String) -> Unit,
onWebServerChange: (String) -> Unit, onWebServerChange: (String) -> Unit,
onSaveClick: () -> Unit onSaveClick: () -> Unit,
onResetClick: () -> Unit,
isLoading: Boolean
) { ) {
val context = LocalContext.current val context = LocalContext.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
.fillMaxWidth()
.height(300.dp),
verticalArrangement = Arrangement.spacedBy(25.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
OutlinedTextField( OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = apiServer, value = apiServer,
placeholder = { Text(text = "https://api-prod.omnivore.app/") }, placeholder = { Text(text = "https://api-prod.omnivore.app/") },
label = { Text(stringResource(R.string.self_hosted_view_field_api_url_label)) }, label = { Text(stringResource(R.string.self_hosted_view_field_api_url_label)) },
@ -130,6 +126,7 @@ fun SelfHostedFields(
) )
OutlinedTextField( OutlinedTextField(
modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp),
value = webServer, value = webServer,
placeholder = { Text(text = "https://omnivore.app/") }, placeholder = { Text(text = "https://omnivore.app/") },
label = { Text(stringResource(R.string.self_hosted_view_field_web_url_label)) }, label = { Text(stringResource(R.string.self_hosted_view_field_web_url_label)) },
@ -141,7 +138,9 @@ fun SelfHostedFields(
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
) )
Button(onClick = { Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
if (apiServer.isNotBlank() && webServer.isNotBlank()) { if (apiServer.isNotBlank() && webServer.isNotBlank()) {
onSaveClick() onSaveClick()
focusManager.clearFocus() focusManager.clearFocus()
@ -158,8 +157,28 @@ fun SelfHostedFields(
) )
) { ) {
Text( Text(
text = stringResource(R.string.self_hosted_view_action_save), text = stringResource(R.string.self_hosted_view_action_save).uppercase(),
modifier = Modifier.padding(horizontal = 100.dp) )
if (isLoading) {
Spacer(modifier = Modifier.width(16.dp))
CircularProgressIndicator(
modifier = Modifier
.height(16.dp)
.width(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
}
}
DividerWithText(text = "or")
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
onClick = { onResetClick() }
) {
Text(
text = "Reset".uppercase()
) )
} }
} }

View File

@ -0,0 +1,128 @@
package app.omnivore.omnivore.feature.onboarding.auth.provider
import android.annotation.SuppressLint
import android.net.Uri
import android.view.ViewGroup
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import app.omnivore.omnivore.R
import app.omnivore.omnivore.feature.onboarding.LoginViewModel
import app.omnivore.omnivore.utils.AppleConstants
import java.net.URLEncoder
@Composable
fun AppleAuthButton(viewModel: LoginViewModel) {
val showDialog = remember { mutableStateOf(false) }
OutlinedButton(
onClick = {
showDialog.value = true
},
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp),
shape = RoundedCornerShape(6.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Image(
painter = painterResource(id = R.drawable.ic_logo_apple),
contentDescription = "",
modifier = Modifier.padding(end = 10.dp)
)
Text(text = stringResource(R.string.apple_auth_text), modifier = Modifier.padding(vertical = 6.dp))
if (viewModel.isLoading) {
Spacer(modifier = Modifier.width(16.dp))
CircularProgressIndicator(
modifier = Modifier
.height(16.dp)
.width(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
}
}
if (showDialog.value) {
AppleAuthDialog(onDismiss = { token ->
if (token != null) {
viewModel.handleAppleToken(token)
}
showDialog.value = false
})
}
}
@Composable
fun AppleAuthDialog(onDismiss: (String?) -> Unit) {
Dialog(onDismissRequest = { onDismiss(null) }) {
Surface(
shape = RoundedCornerShape(16.dp), color = Color.White
) {
AppleAuthWebView(onDismiss)
}
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun AppleAuthWebView(onDismiss: (String?) -> Unit) {
val url =
AppleConstants.authUrl + "?client_id=" + AppleConstants.clientId + "&redirect_uri=" + URLEncoder.encode(
AppleConstants.redirectURI,
"utf8"
) + "&response_type=code%20id_token" + "&scope=" + AppleConstants.scope + "&response_mode=form_post" + "&state=android:login"
// Adding a WebView inside AndroidView
// with layout as full screen
AndroidView(factory = {
WebView(it).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
)
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?, request: WebResourceRequest?
): Boolean {
if (request?.url.toString().contains("android-apple-token")) {
val uri = Uri.parse(request!!.url.toString())
val token = uri.getQueryParameter("token")
onDismiss(token)
}
return true
}
}
settings.javaScriptEnabled = true
loadUrl(url)
}
}, update = {
it.loadUrl(url)
})
}

View File

@ -0,0 +1,95 @@
package app.omnivore.omnivore.feature.onboarding.auth.provider
import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.omnivore.omnivore.BuildConfig
import app.omnivore.omnivore.R
import app.omnivore.omnivore.feature.onboarding.LoginViewModel
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.tasks.Task
@Composable
fun GoogleAuthButton(viewModel: LoginViewModel) {
val context = LocalContext.current
val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestIdToken(BuildConfig.OMNIVORE_GAUTH_SERVER_CLIENT_ID).requestEmail().build()
val startForResult =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
val intent = result.data
if (result.data != null) {
val task: Task<GoogleSignInAccount> =
GoogleSignIn.getSignedInAccountFromIntent(intent)
viewModel.handleGoogleAuthTask(task)
}
} else {
viewModel.showGoogleErrorMessage()
}
}
OutlinedButton(
onClick = {
val googleSignIn = GoogleSignIn.getClient(context, signInOptions)
googleSignIn.silentSignIn().addOnCompleteListener { task ->
if (task.isSuccessful) {
viewModel.handleGoogleAuthTask(task)
} else {
startForResult.launch(googleSignIn.signInIntent)
}
}.addOnFailureListener {
startForResult.launch(googleSignIn.signInIntent)
}
},
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp),
shape = RoundedCornerShape(6.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Image(
painter = painterResource(id = R.drawable.ic_logo_google),
contentDescription = "",
modifier = Modifier.padding(end = 10.dp)
)
Text(text = stringResource(R.string.google_auth_text), modifier = Modifier.padding(vertical = 6.dp))
if (viewModel.isLoading) {
Spacer(modifier = Modifier.width(16.dp))
CircularProgressIndicator(
modifier = Modifier
.height(16.dp)
.width(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
}
}
}

View File

@ -18,7 +18,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import app.omnivore.omnivore.R import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.designsystem.component.TextPreferenceWidget import app.omnivore.omnivore.core.designsystem.component.TextPreferenceWidget
import app.omnivore.omnivore.feature.auth.LoginViewModel import app.omnivore.omnivore.feature.onboarding.LoginViewModel
import app.omnivore.omnivore.navigation.Routes import app.omnivore.omnivore.navigation.Routes
internal const val RELEASE_URL = "https://github.com/omnivore-app/omnivore/releases" internal const val RELEASE_URL = "https://github.com/omnivore-app/omnivore/releases"

View File

@ -1,68 +1,78 @@
package app.omnivore.omnivore.feature.reader package app.omnivore.omnivore.feature.reader
import android.util.Log import android.util.Log
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.Highlight import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.core.database.entities.SavedItem
import com.google.gson.Gson import com.google.gson.Gson
enum class WebFont(val displayText: String, val rawValue: String) { enum class WebFont(val displayText: String, val rawValue: String) {
INTER("Inter", "Inter"), INTER("Inter", "Inter"), SYSTEM("System Default", "system-ui"), OPEN_DYSLEXIC(
SYSTEM("System Default", "system-ui"), "Open Dyslexic",
OPEN_DYSLEXIC("Open Dyslexic", "OpenDyslexic"), "OpenDyslexic"
MERRIWEATHER("Merriweather", "Merriweather"), ),
LORA("Lora", "Lora"), MERRIWEATHER("Merriweather", "Merriweather"), LORA("Lora", "Lora"), OPEN_SANS(
OPEN_SANS("Open Sans", "Open Sans"), "Open Sans",
ROBOTO("Roboto", "Roboto"), "Open Sans"
CRIMSON_TEXT("Crimson Text", "Crimson Text"), ),
SOURCE_SERIF_PRO("Source Serif Pro", "Source Serif Pro"), ROBOTO("Roboto", "Roboto"), CRIMSON_TEXT(
NEWSREADER("Newsreader", "Newsreader"), "Crimson Text",
LEXEND("Lexend", "Lexend"), "Crimson Text"
LXGWWENKAI("LXGW WenKai", "LXGWWenKai"), ),
ATKINSON_HYPERLEGIBLE("Atkinson Hyperlegible", "AtkinsonHyperlegible"), SOURCE_SERIF_PRO("Source Serif Pro", "Source Serif Pro"), NEWSREADER(
SOURCE_SANS_PRO("Source Sans Pro", "SourceSansPro"), "Newsreader",
IBM_PLEX_SANS("IBM Plex Sans", "IBMPlexSans"), "Newsreader"
LITERATA("Literata", "Literata"), ),
FRAUNCES("Fraunces", "Fraunces"), LEXEND("Lexend", "Lexend"), LXGWWENKAI(
"LXGW WenKai",
"LXGWWenKai"
),
ATKINSON_HYPERLEGIBLE(
"Atkinson Hyperlegible",
"AtkinsonHyperlegible"
),
SOURCE_SANS_PRO("Source Sans Pro", "SourceSansPro"), IBM_PLEX_SANS(
"IBM Plex Sans",
"IBMPlexSans"
),
LITERATA("Literata", "Literata"), FRAUNCES("Fraunces", "Fraunces"),
} }
enum class ArticleContentStatus(val rawValue: String) { enum class ArticleContentStatus(val rawValue: String) {
FAILED("FAILED"), FAILED("FAILED"), PROCESSING("PROCESSING"), SUCCEEDED("SUCCEEDED"), UNKNOWN("UNKNOWN")
PROCESSING("PROCESSING"),
SUCCEEDED("SUCCEEDED"),
UNKNOWN("UNKNOWN")
} }
data class ArticleContent( data class ArticleContent(
val title: String, val title: String,
val htmlContent: String, val htmlContent: String,
val highlights: List<Highlight>, val highlights: List<Highlight>,
val contentStatus: String, // ArticleContentStatus, val contentStatus: String, // ArticleContentStatus,
val labelsJSONString: String val labelsJSONString: String
) { ) {
fun highlightsJSONString(): String { fun highlightsJSONString(): String {
return Gson().toJson(highlights) return Gson().toJson(highlights)
} }
} }
data class WebReaderContent( data class WebReaderContent(
val preferences: WebPreferences, val preferences: WebPreferences,
val item: SavedItem, val item: SavedItem,
val articleContent: ArticleContent, val articleContent: ArticleContent,
) { ) {
fun styledContent(): String { fun styledContent(): String {
val savedAt = "\"${item.savedAt}\"" val savedAt = "\"${item.savedAt}\""
val createdAt = "\"${item.createdAt}\"" val createdAt = "\"${item.createdAt}\""
val publishedAt = if (item.publishDate != null) "\"${item.publishDate}\"" else "undefined" val publishedAt = if (item.publishDate != null) "\"${item.publishDate}\"" else "undefined"
val textFontSize = preferences.textFontSize val textFontSize = preferences.textFontSize
val highlightCssFilePath = "highlight${if (preferences.themeKey == "Dark" || preferences.themeKey == "Black") "-dark" else ""}.css" val highlightCssFilePath =
"highlight${if (preferences.themeKey == "Dark" || preferences.themeKey == "Black") "-dark" else ""}.css"
Log.d("theme", "current theme is: ${preferences.themeKey}") Log.d("theme", "current theme is: ${preferences.themeKey}")
Log.d("sync", "HIGHLIGHTS JSON: ${articleContent.highlightsJSONString()}") Log.d("sync", "HIGHLIGHTS JSON: ${articleContent.highlightsJSONString()}")
return """ return """
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@ -118,5 +128,5 @@ data class WebReaderContent(
</body> </body>
</html> </html>
""" """
} }
} }

View File

@ -24,7 +24,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
@ -34,15 +33,13 @@ import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.navigation import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import app.omnivore.omnivore.core.designsystem.motion.materialSharedAxisXIn import app.omnivore.omnivore.core.designsystem.theme.OmnivoreBrand
import app.omnivore.omnivore.core.designsystem.motion.materialSharedAxisXOut import app.omnivore.omnivore.feature.onboarding.LoginViewModel
import app.omnivore.omnivore.feature.auth.LoginViewModel import app.omnivore.omnivore.feature.onboarding.OnboardingScreen
import app.omnivore.omnivore.feature.auth.WelcomeScreen
import app.omnivore.omnivore.feature.following.FollowingScreen import app.omnivore.omnivore.feature.following.FollowingScreen
import app.omnivore.omnivore.feature.library.LibraryView import app.omnivore.omnivore.feature.library.LibraryView
import app.omnivore.omnivore.feature.library.SearchView import app.omnivore.omnivore.feature.library.SearchView
@ -51,6 +48,7 @@ import app.omnivore.omnivore.feature.profile.about.AboutScreen
import app.omnivore.omnivore.feature.profile.account.AccountScreen import app.omnivore.omnivore.feature.profile.account.AccountScreen
import app.omnivore.omnivore.feature.profile.filters.FiltersScreen import app.omnivore.omnivore.feature.profile.filters.FiltersScreen
import app.omnivore.omnivore.feature.web.WebViewScreen import app.omnivore.omnivore.feature.web.WebViewScreen
import app.omnivore.omnivore.navigation.OmnivoreNavHost
import app.omnivore.omnivore.navigation.Routes import app.omnivore.omnivore.navigation.Routes
import app.omnivore.omnivore.navigation.TopLevelDestination import app.omnivore.omnivore.navigation.TopLevelDestination
@ -81,7 +79,7 @@ fun RootView(
} }
}) { padding -> }) { padding ->
Box( Box(
modifier = if (!hasAuthToken) Modifier.background(Color(0xFFFCEBA8)) else Modifier modifier = if (!hasAuthToken) Modifier.background(OmnivoreBrand) else Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
@ -95,8 +93,7 @@ fun RootView(
PrimaryNavigator( PrimaryNavigator(
navController = navController, navController = navController,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
startDestination = startDestination, startDestination = startDestination
loginViewModel = loginViewModel
) )
LaunchedEffect(hasAuthToken) { LaunchedEffect(hasAuthToken) {
if (hasAuthToken) { if (hasAuthToken) {
@ -107,33 +104,32 @@ fun RootView(
} }
} }
private const val INITIAL_OFFSET_FACTOR = 0.10f
@Composable @Composable
fun PrimaryNavigator( fun PrimaryNavigator(
navController: NavHostController, navController: NavHostController,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
startDestination: String, startDestination: String
loginViewModel: LoginViewModel
) { ) {
NavHost(navController = navController, OmnivoreNavHost(
startDestination = startDestination, navController = navController,
enterTransition = { materialSharedAxisXIn(initialOffsetX = { (it * INITIAL_OFFSET_FACTOR).toInt() }) }, startDestination = startDestination
exitTransition = { materialSharedAxisXOut(targetOffsetX = { -(it * INITIAL_OFFSET_FACTOR).toInt() }) }, ) {
popEnterTransition = { materialSharedAxisXIn(initialOffsetX = { -(it * INITIAL_OFFSET_FACTOR).toInt() }) },
popExitTransition = { materialSharedAxisXOut(targetOffsetX = { (it * INITIAL_OFFSET_FACTOR).toInt() }) }) {
composable(Routes.Welcome.route) { composable(Routes.Welcome.route) {
WelcomeScreen(viewModel = loginViewModel) OnboardingScreen(navController = navController)
} }
navigation(startDestination = Routes.Inbox.route, navigation(
startDestination = Routes.Inbox.route,
route = Routes.Home.route, route = Routes.Home.route,
enterTransition = { EnterTransition.None }, enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None }, exitTransition = { ExitTransition.None },
popEnterTransition = { EnterTransition.None }, popEnterTransition = { EnterTransition.None },
popExitTransition = { ExitTransition.None }) { popExitTransition = { ExitTransition.None }
) {
composable(Routes.Inbox.route) { composable(Routes.Inbox.route) {
LibraryView(navController = navController) LibraryView(navController = navController)

View File

@ -0,0 +1,28 @@
package app.omnivore.omnivore.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import app.omnivore.omnivore.core.designsystem.motion.materialSharedAxisXIn
import app.omnivore.omnivore.core.designsystem.motion.materialSharedAxisXOut
private const val INITIAL_OFFSET_FACTOR = 0.10f
@Composable
fun OmnivoreNavHost(
navController: NavHostController,
startDestination: String,
builder: NavGraphBuilder.() -> Unit
) {
return NavHost(
navController = navController,
startDestination = startDestination,
enterTransition = { materialSharedAxisXIn(initialOffsetX = { (it * INITIAL_OFFSET_FACTOR).toInt() }) },
exitTransition = { materialSharedAxisXOut(targetOffsetX = { -(it * INITIAL_OFFSET_FACTOR).toInt() }) },
popEnterTransition = { materialSharedAxisXIn(initialOffsetX = { -(it * INITIAL_OFFSET_FACTOR).toInt() }) },
popExitTransition = { materialSharedAxisXOut(targetOffsetX = { (it * INITIAL_OFFSET_FACTOR).toInt() }) }
) {
builder()
}
}

View File

@ -3,6 +3,10 @@ package app.omnivore.omnivore.navigation
sealed class Routes(val route: String) { sealed class Routes(val route: String) {
data object Home : Routes("Home") data object Home : Routes("Home")
data object Welcome : Routes("Welcome") data object Welcome : Routes("Welcome")
data object EmailSignIn : Routes("EmailSignIn")
data object EmailSignUp : Routes("EmailSignUp")
data object SelfHosting : Routes("SelfHosting")
data object AuthProvider : Routes("AuthProvider")
data object Following : Routes("Following") data object Following : Routes("Following")
data object Inbox : Routes("Inbox") data object Inbox : Routes("Inbox")
data object Settings : Routes("Settings") data object Settings : Routes("Settings")

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.feature.auth package app.omnivore.omnivore.utils
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier

View File

@ -13,3 +13,6 @@ object AppleConstants {
const val scope = "name%20email" const val scope = "name%20email"
const val authUrl = "https://appleid.apple.com/auth/authorize" const val authUrl = "https://appleid.apple.com/auth/authorize"
} }
const val FORGOT_PASSWORD_URL = "${BuildConfig.OMNIVORE_WEB_URL}/auth/forgot-password"
const val SELF_HOSTING_URL = "https://docs.omnivore.app/self-hosting/self-hosting.html"

View File

@ -268,4 +268,5 @@
<string name="edit_info_sheet_action_cancel">Cancel</string> <string name="edit_info_sheet_action_cancel">Cancel</string>
<string name="edit_info_sheet_error">Error while editing article!</string> <string name="edit_info_sheet_error">Error while editing article!</string>
<string name="edit_info_sheet_success">Article info successfully updated!</string> <string name="edit_info_sheet_success">Article info successfully updated!</string>
<string name="forgot_password">Forgot Password?</string>
</resources> </resources>

View File

@ -15,6 +15,8 @@ buildscript {
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.ksp) apply false alias(libs.plugins.ksp) apply false
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.org.jetbrains.kotlin.android) apply false
} }
task<Delete>("clean") { task<Delete>("clean") {

View File

@ -2,14 +2,13 @@
accompanistFlowLayout = "0.34.0" accompanistFlowLayout = "0.34.0"
androidGradlePlugin = "8.3.2" androidGradlePlugin = "8.3.2"
androidxActivity = "1.9.0" androidxActivity = "1.9.0"
androidxAppCompat = "1.6.1" androidxAppCompat = "1.7.0"
androidxComposeBom = "2024.04.01" androidxComposeBom = "2024.06.00"
androidxComposeCompiler = "1.5.9" androidxCore = "1.13.1"
androidxCore = "1.13.0" androidxDataStore = "1.1.1"
androidxDataStore = "1.1.0"
androidxEspresso = "3.5.1" androidxEspresso = "3.5.1"
androidxHiltNavigationCompose = "1.2.0" androidxHiltNavigationCompose = "1.2.0"
androidxLifecycle = "2.7.0" androidxLifecycle = "2.8.2"
androidxNavigation = "2.7.7" androidxNavigation = "2.7.7"
androidxSecurity = "1.0.0" androidxSecurity = "1.0.0"
androidxTestExt = "1.1.5" androidxTestExt = "1.1.5"
@ -19,14 +18,14 @@ coil = "2.6.0"
composeMarkdown = "0.3.3" composeMarkdown = "0.3.3"
coreSplashscreen = "1.0.1" coreSplashscreen = "1.0.1"
gson = "2.10.1" gson = "2.10.1"
hilt = "2.51" hilt = "2.51.1"
intercom = "15.8.2" intercom = "15.8.2"
junit4 = "4.13.2" junit4 = "4.13.2"
kotlin = "1.9.22" kotlin = "2.0.0"
ksp = "1.9.22-1.0.18" ksp = "2.0.0-1.0.21"
kotlinxCoroutines = "1.8.0" kotlinxCoroutines = "1.8.0"
playServices = "18.4.0" playServices = "18.5.0"
playServicesAuth = "21.1.0" playServicesAuth = "21.2.0"
posthog = "2.0.3" posthog = "2.0.3"
pspdfkit = "8.9.1" pspdfkit = "8.9.1"
retrofit = "2.11.0" retrofit = "2.11.0"
@ -88,6 +87,8 @@ hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltWo
hilt-work-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltWork"} hilt-work-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltWork"}
[plugins] [plugins]
org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
apollo = { id = "com.apollographql.apollo3", version.ref = "apollo" } apollo = { id = "com.apollographql.apollo3", version.ref = "apollo" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }