From f25acf0628bcf4e80092c16b6ec7188771557ed8 Mon Sep 17 00:00:00 2001 From: Stefano Sansone Date: Sun, 23 Jun 2024 19:53:19 +0200 Subject: [PATCH] refactor onboarding --- android/Omnivore/app/build.gradle.kts | 7 +- .../designsystem/component/DividerWithText.kt | 43 +++ .../core/designsystem/theme/Colors.kt | 5 + .../omnivore/feature/auth/AppleAuth.kt | 96 ------- .../feature/auth/CreateUserProfile.kt | 159 ----------- .../omnivore/feature/auth/EmailLogin.kt | 171 ------------ .../omnivore/feature/auth/EmailSignUpView.kt | 264 ------------------ .../omnivore/feature/auth/GoogleAuth.kt | 62 ---- .../feature/auth/LoadingButtonWithIcon.kt | 69 ----- .../omnivore/feature/auth/WelcomeScreen.kt | 174 ------------ .../feature/onboarding/OnboardingScreen.kt | 213 ++++++++++++++ .../OnboardingViewModel.kt} | 95 ++----- .../onboarding/auth/AuthProviderScreen.kt | 93 ++++++ .../auth/CreateUserProfileScreen.kt | 163 +++++++++++ .../onboarding/auth/EmailSignInScreen.kt | 212 ++++++++++++++ .../onboarding/auth/EmailSignUpScreen.kt | 259 +++++++++++++++++ .../auth/SelfHostedScreen.kt} | 135 +++++---- .../auth/provider/AppleAuthButton.kt | 128 +++++++++ .../auth/provider/GoogleAuthButton.kt | 95 +++++++ .../omnivore/feature/profile/ProfileScreen.kt | 2 +- .../feature/reader/WebReaderContent.kt | 96 ++++--- .../omnivore/feature/root/RootView.kt | 38 ++- .../omnivore/navigation/OmnivoreNavHost.kt | 28 ++ .../omnivore/omnivore/navigation/Routes.kt | 4 + .../{feature/auth => utils}/AuthUtils.kt | 2 +- .../app/omnivore/omnivore/utils/Constants.kt | 3 + .../app/src/main/res/values/strings.xml | 1 + android/Omnivore/build.gradle.kts | 2 + android/Omnivore/gradle/libs.versions.toml | 23 +- 29 files changed, 1444 insertions(+), 1198 deletions(-) create mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/component/DividerWithText.kt create mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/theme/Colors.kt delete mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/AppleAuth.kt delete mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/CreateUserProfile.kt delete mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/EmailLogin.kt delete mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/EmailSignUpView.kt delete mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/GoogleAuth.kt delete mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoadingButtonWithIcon.kt delete mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/WelcomeScreen.kt create mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/OnboardingScreen.kt rename android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/{auth/LoginViewModel.kt => onboarding/OnboardingViewModel.kt} (84%) create mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/AuthProviderScreen.kt create mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/CreateUserProfileScreen.kt create mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/EmailSignInScreen.kt create mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/EmailSignUpScreen.kt rename android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/{auth/SelfHostedView.kt => onboarding/auth/SelfHostedScreen.kt} (53%) create mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/provider/AppleAuthButton.kt create mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/provider/GoogleAuthButton.kt create mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/OmnivoreNavHost.kt rename android/Omnivore/app/src/main/java/app/omnivore/omnivore/{feature/auth => utils}/AuthUtils.kt (96%) diff --git a/android/Omnivore/app/build.gradle.kts b/android/Omnivore/app/build.gradle.kts index 8ef784415..78299be3b 100644 --- a/android/Omnivore/app/build.gradle.kts +++ b/android/Omnivore/app/build.gradle.kts @@ -3,10 +3,11 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) - kotlin("android") + alias(libs.plugins.org.jetbrains.kotlin.android) id("dagger.hilt.android.plugin") alias(libs.plugins.ksp) alias(libs.plugins.apollo) + alias(libs.plugins.compose.compiler) } val keystorePropertiesFile = rootProject.file("app/external/keystore.properties") @@ -90,9 +91,7 @@ android { compose = true buildConfig = true } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get() - } + packaging { resources { excludes += listOf("/META-INF/{AL2.0,LGPL2.1}") diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/component/DividerWithText.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/component/DividerWithText.kt new file mode 100644 index 000000000..31b0b4d06 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/component/DividerWithText.kt @@ -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 + ) + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/theme/Colors.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/theme/Colors.kt new file mode 100644 index 000000000..9c44ed78a --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/theme/Colors.kt @@ -0,0 +1,5 @@ +package app.omnivore.omnivore.core.designsystem.theme + +import androidx.compose.ui.graphics.Color + +val OmnivoreBrand = Color(0xFFFCEBA8) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/AppleAuth.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/AppleAuth.kt deleted file mode 100644 index 16c8e5227..000000000 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/AppleAuth.kt +++ /dev/null @@ -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) - }) -} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/CreateUserProfile.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/CreateUserProfile.kt deleted file mode 100644 index 43b1e7413..000000000 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/CreateUserProfile.kt +++ /dev/null @@ -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) - ) - } - } -} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/EmailLogin.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/EmailLogin.kt deleted file mode 100644 index 5f9f4d57e..000000000 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/EmailLogin.kt +++ /dev/null @@ -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) - ) - } - } -} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/EmailSignUpView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/EmailSignUpView.kt deleted file mode 100644 index d8ad8b808..000000000 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/EmailSignUpView.kt +++ /dev/null @@ -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) - ) - } - } -} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/GoogleAuth.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/GoogleAuth.kt deleted file mode 100644 index bee519a98..000000000 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/GoogleAuth.kt +++ /dev/null @@ -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 = 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) - } - } - ) -} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoadingButtonWithIcon.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoadingButtonWithIcon.kt deleted file mode 100644 index 2c1a9f136..000000000 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoadingButtonWithIcon.kt +++ /dev/null @@ -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 - ) - } - } - } -} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/WelcomeScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/WelcomeScreen.kt deleted file mode 100644 index 6b772ec45..000000000 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/WelcomeScreen.kt +++ /dev/null @@ -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) - ) -} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/OnboardingScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/OnboardingScreen.kt new file mode 100644 index 000000000..a02a40a0b --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/OnboardingScreen.kt @@ -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) + ) +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoginViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/OnboardingViewModel.kt similarity index 84% rename from android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoginViewModel.kt rename to android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/OnboardingViewModel.kt index 18fbf4703..7468123f7 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoginViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/OnboardingViewModel.kt @@ -1,11 +1,10 @@ -package app.omnivore.omnivore.feature.auth +package app.omnivore.omnivore.feature.onboarding import android.content.Context import android.widget.Toast import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.omnivore.omnivore.BuildConfig @@ -43,8 +42,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel import io.intercom.android.sdk.Intercom import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -52,10 +53,6 @@ import kotlinx.coroutines.runBlocking import java.util.regex.Pattern import javax.inject.Inject -enum class RegistrationState { - SocialLogin, EmailSignIn, EmailSignUp, PendingUser, SelfHosted -} - data class PendingEmailUserCreds( val email: String, val password: String ) @@ -73,8 +70,8 @@ class LoginViewModel @Inject constructor( var isLoading by mutableStateOf(false) private set - var errorMessage by mutableStateOf(null) - private set + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow get() = _errorMessage.asStateFlow() var hasValidUsername by mutableStateOf(false) private set @@ -92,8 +89,6 @@ class LoginViewModel @Inject constructor( initialValue = true ) - val registrationStateLiveData = MutableLiveData(RegistrationState.SocialLogin) - val followingTabActiveState: StateFlow = datastoreRepository.getBoolean( followingTabActive ).stateIn( @@ -124,29 +119,11 @@ class LoginViewModel @Inject constructor( Toast.LENGTH_SHORT ).show() } - - } - fun showSocialLogin() { - resetState() - registrationStateLiveData.value = RegistrationState.SocialLogin - } - - fun showEmailSignIn() { - resetState() - registrationStateLiveData.value = RegistrationState.EmailSignIn - } - - fun showEmailSignUp(pendingCreds: PendingEmailUserCreds? = null) { + private fun showEmailSignUp(pendingCreds: PendingEmailUserCreds? = null) { resetState() pendingEmailUserCreds = pendingCreds - registrationStateLiveData.value = RegistrationState.EmailSignUp - } - - fun showSelfHostedSettings() { - resetState() - registrationStateLiveData.value = RegistrationState.SelfHosted } fun cancelNewUserSignUp() { @@ -154,7 +131,7 @@ class LoginViewModel @Inject constructor( viewModelScope.launch { datastoreRepository.clearValue(omnivorePendingUserToken) } - showSocialLogin() + resetState() } 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 isLoading = false - errorMessage = null + resetErrorMessage() hasValidUsername = false usernameValidationErrorMessage = null pendingEmailUserCreds = null @@ -240,7 +225,7 @@ class LoginViewModel @Inject constructor( RetrofitHelper.getInstance(networker).create(EmailLoginSubmit::class.java) isLoading = true - errorMessage = null + resetErrorMessage() val result = emailLogin.submitEmailLogin( EmailLoginCredentials(email = email, password = password) @@ -260,9 +245,7 @@ class LoginViewModel @Inject constructor( if (result.body()?.authToken != null) { datastoreRepository.putString(omnivoreAuthToken, result.body()?.authToken!!) } else { - errorMessage = resourceProvider.getString( - R.string.login_view_model_something_went_wrong_error_msg - ) + setErrorMessage(resourceProvider.getString(R.string.login_view_model_something_went_wrong_error_msg)) } if (result.body()?.authCookieString != null) { @@ -284,7 +267,7 @@ class LoginViewModel @Inject constructor( RetrofitHelper.getInstance(networker).create(CreateEmailAccountSubmit::class.java) isLoading = true - errorMessage = null + resetErrorMessage() val params = EmailSignUpParams( email = email, password = password, name = name, username = username @@ -295,9 +278,7 @@ class LoginViewModel @Inject constructor( isLoading = false if (result.errorBody() != null) { - errorMessage = resourceProvider.getString( - R.string.login_view_model_something_went_wrong_two_error_msg - ) + setErrorMessage(resourceProvider.getString(R.string.login_view_model_something_went_wrong_error_msg)) } else { pendingEmailUserCreds = PendingEmailUserCreds(email, password) } @@ -314,7 +295,7 @@ class LoginViewModel @Inject constructor( RetrofitHelper.getInstance(networker).create(CreateAccountSubmit::class.java) isLoading = true - errorMessage = null + resetErrorMessage() val pendingUserToken = getPendingAuthToken() ?: "" @@ -330,9 +311,7 @@ class LoginViewModel @Inject constructor( if (result.body()?.authToken != null) { datastoreRepository.putString(omnivoreAuthToken, result.body()?.authToken!!) } else { - errorMessage = resourceProvider.getString( - R.string.login_view_model_something_went_wrong_error_msg - ) + setErrorMessage(resourceProvider.getString(R.string.login_view_model_something_went_wrong_error_msg)) } if (result.body()?.authCookieString != null) { @@ -358,12 +337,8 @@ class LoginViewModel @Inject constructor( } } - fun resetErrorMessage() { - errorMessage = null - } - 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) { @@ -372,9 +347,7 @@ class LoginViewModel @Inject constructor( // If token is missing then set the error message if (googleIdToken.isEmpty()) { - errorMessage = resourceProvider.getString( - R.string.login_view_model_missing_auth_token_error_msg - ) + setErrorMessage(resourceProvider.getString(R.string.login_view_model_missing_auth_token_error_msg)) return } @@ -390,7 +363,7 @@ class LoginViewModel @Inject constructor( RetrofitHelper.getInstance(networker).create(AuthProviderLoginSubmit::class.java) isLoading = true - errorMessage = null + resetErrorMessage() val result = login.submitAuthProviderLogin(params) @@ -413,15 +386,11 @@ class LoginViewModel @Inject constructor( 418 -> { // Show pending email state - errorMessage = resourceProvider.getString( - R.string.login_view_model_something_went_wrong_two_error_msg - ) + setErrorMessage(resourceProvider.getString(R.string.login_view_model_something_went_wrong_two_error_msg)) } else -> { - errorMessage = resourceProvider.getString( - R.string.login_view_model_something_went_wrong_two_error_msg - ) + setErrorMessage(resourceProvider.getString(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) { isLoading = true - errorMessage = null + resetErrorMessage() val request = RetrofitHelper.getInstance(networker).create(PendingUserSubmit::class.java) val result = request.submitPendingUser(params) @@ -441,11 +410,9 @@ class LoginViewModel @Inject constructor( datastoreRepository.putString( omnivorePendingUserToken, result.body()?.pendingUserToken!! ) - registrationStateLiveData.value = RegistrationState.PendingUser + // TODO go to pending user } else { - errorMessage = resourceProvider.getString( - R.string.login_view_model_something_went_wrong_two_error_msg - ) + setErrorMessage(resourceProvider.getString(R.string.login_view_model_something_went_wrong_two_error_msg)) } } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/AuthProviderScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/AuthProviderScreen.kt new file mode 100644 index 000000000..2c39e523e --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/AuthProviderScreen.kt @@ -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 + ) + } + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/CreateUserProfileScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/CreateUserProfileScreen.kt new file mode 100644 index 000000000..269c1b7e8 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/CreateUserProfileScreen.kt @@ -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) + ) + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/EmailSignInScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/EmailSignInScreen.kt new file mode 100644 index 000000000..da2c0e922 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/EmailSignInScreen.kt @@ -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() + ) + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/EmailSignUpScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/EmailSignUpScreen.kt new file mode 100644 index 000000000..8b92e377b --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/EmailSignUpScreen.kt @@ -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 + ) + } + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/SelfHostedView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/SelfHostedScreen.kt similarity index 53% rename from android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/SelfHostedView.kt rename to android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/SelfHostedScreen.kt index 616c3c7e7..da174a133 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/SelfHostedView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/SelfHostedScreen.kt @@ -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.net.Uri 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.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.Button +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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.unit.dp import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel 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 -fun SelfHostedView(viewModel: LoginViewModel) { +fun SelfHostedScreen( + viewModel: LoginViewModel = hiltViewModel() +) { var apiServer by rememberSaveable { mutableStateOf("") } var webServer by rememberSaveable { mutableStateOf("") } val context = LocalContext.current Row( - horizontalArrangement = Arrangement.Center + 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 ) { @@ -48,51 +70,25 @@ fun SelfHostedView(viewModel: LoginViewModel) { onWebServerChange = { webServer = it }, onSaveClick = { viewModel.setSelfHostingDetails(context, apiServer, webServer) - } + }, + onResetClick = { viewModel.resetSelfHostingDetails(context) }, + isLoading = viewModel.isLoading ) - // TODO: add a activity indicator (maybe after a delay?) - if (viewModel.isLoading) { - Text(stringResource(R.string.self_hosted_view_loading)) - } - - Row( - horizontalArrangement = Arrangement.Center, + Column( + modifier = Modifier.padding(top = 16.dp) ) { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - ClickableText( - text = AnnotatedString(stringResource(R.string.self_hosted_view_action_reset)), - style = MaterialTheme.typography.titleMedium - .plus(TextStyle(textDecoration = TextDecoration.Underline)), - onClick = { viewModel.resetSelfHostingDetails(context) }, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - 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) - ) - } + 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(SELF_HOSTING_URL) + val browserIntent = Intent(Intent.ACTION_VIEW, uri) + ContextCompat.startActivity(context, browserIntent, null) + }, + modifier = Modifier.padding(vertical = 10.dp) + ) } } Spacer(modifier = Modifier.weight(1.0F)) @@ -105,19 +101,19 @@ fun SelfHostedFields( webServer: String, onAPIServerChange: (String) -> Unit, onWebServerChange: (String) -> Unit, - onSaveClick: () -> Unit + onSaveClick: () -> Unit, + onResetClick: () -> Unit, + isLoading: Boolean ) { val context = LocalContext.current val focusManager = LocalFocusManager.current Column( - modifier = Modifier - .fillMaxWidth() - .height(300.dp), - verticalArrangement = Arrangement.spacedBy(25.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { OutlinedTextField( + modifier = Modifier.fillMaxWidth(), value = apiServer, placeholder = { Text(text = "https://api-prod.omnivore.app/") }, label = { Text(stringResource(R.string.self_hosted_view_field_api_url_label)) }, @@ -130,6 +126,7 @@ fun SelfHostedFields( ) OutlinedTextField( + modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp), value = webServer, placeholder = { Text(text = "https://omnivore.app/") }, label = { Text(stringResource(R.string.self_hosted_view_field_web_url_label)) }, @@ -141,7 +138,9 @@ fun SelfHostedFields( keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) ) - Button(onClick = { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { if (apiServer.isNotBlank() && webServer.isNotBlank()) { onSaveClick() focusManager.clearFocus() @@ -158,8 +157,28 @@ fun SelfHostedFields( ) ) { Text( - text = stringResource(R.string.self_hosted_view_action_save), - modifier = Modifier.padding(horizontal = 100.dp) + text = stringResource(R.string.self_hosted_view_action_save).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") + + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = { onResetClick() } + ) { + Text( + text = "Reset".uppercase() ) } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/provider/AppleAuthButton.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/provider/AppleAuthButton.kt new file mode 100644 index 000000000..54b5005db --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/provider/AppleAuthButton.kt @@ -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) + }) +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/provider/GoogleAuthButton.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/provider/GoogleAuthButton.kt new file mode 100644 index 000000000..db59802b3 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/provider/GoogleAuthButton.kt @@ -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 = + 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 + ) + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/ProfileScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/ProfileScreen.kt index 3f1d9977b..9282ca60e 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/ProfileScreen.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/ProfileScreen.kt @@ -18,7 +18,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import app.omnivore.omnivore.R 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 internal const val RELEASE_URL = "https://github.com/omnivore-app/omnivore/releases" diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReaderContent.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReaderContent.kt index 14978263a..67f3e0fcf 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReaderContent.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReaderContent.kt @@ -1,68 +1,78 @@ package app.omnivore.omnivore.feature.reader 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.SavedItem import com.google.gson.Gson enum class WebFont(val displayText: String, val rawValue: String) { - INTER("Inter", "Inter"), - SYSTEM("System Default", "system-ui"), - OPEN_DYSLEXIC("Open Dyslexic", "OpenDyslexic"), - MERRIWEATHER("Merriweather", "Merriweather"), - LORA("Lora", "Lora"), - OPEN_SANS("Open Sans", "Open Sans"), - ROBOTO("Roboto", "Roboto"), - CRIMSON_TEXT("Crimson Text", "Crimson Text"), - SOURCE_SERIF_PRO("Source Serif Pro", "Source Serif Pro"), - NEWSREADER("Newsreader", "Newsreader"), - 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"), + INTER("Inter", "Inter"), SYSTEM("System Default", "system-ui"), OPEN_DYSLEXIC( + "Open Dyslexic", + "OpenDyslexic" + ), + MERRIWEATHER("Merriweather", "Merriweather"), LORA("Lora", "Lora"), OPEN_SANS( + "Open Sans", + "Open Sans" + ), + ROBOTO("Roboto", "Roboto"), CRIMSON_TEXT( + "Crimson Text", + "Crimson Text" + ), + SOURCE_SERIF_PRO("Source Serif Pro", "Source Serif Pro"), NEWSREADER( + "Newsreader", + "Newsreader" + ), + 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) { - FAILED("FAILED"), - PROCESSING("PROCESSING"), - SUCCEEDED("SUCCEEDED"), - UNKNOWN("UNKNOWN") + FAILED("FAILED"), PROCESSING("PROCESSING"), SUCCEEDED("SUCCEEDED"), UNKNOWN("UNKNOWN") } data class ArticleContent( - val title: String, - val htmlContent: String, - val highlights: List, - val contentStatus: String, // ArticleContentStatus, - val labelsJSONString: String + val title: String, + val htmlContent: String, + val highlights: List, + val contentStatus: String, // ArticleContentStatus, + val labelsJSONString: String ) { - fun highlightsJSONString(): String { - return Gson().toJson(highlights) - } + fun highlightsJSONString(): String { + return Gson().toJson(highlights) + } } data class WebReaderContent( - val preferences: WebPreferences, - val item: SavedItem, - val articleContent: ArticleContent, + val preferences: WebPreferences, + val item: SavedItem, + val articleContent: ArticleContent, ) { - fun styledContent(): String { - val savedAt = "\"${item.savedAt}\"" - val createdAt = "\"${item.createdAt}\"" - val publishedAt = if (item.publishDate != null) "\"${item.publishDate}\"" else "undefined" + fun styledContent(): String { + val savedAt = "\"${item.savedAt}\"" + val createdAt = "\"${item.createdAt}\"" + val publishedAt = if (item.publishDate != null) "\"${item.publishDate}\"" else "undefined" - val textFontSize = preferences.textFontSize - val highlightCssFilePath = "highlight${if (preferences.themeKey == "Dark" || preferences.themeKey == "Black") "-dark" else ""}.css" + val textFontSize = preferences.textFontSize + 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 """ @@ -118,5 +128,5 @@ data class WebReaderContent( """ - } + } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt index cd81bb79e..eab690fd5 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt @@ -24,7 +24,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource @@ -34,15 +33,13 @@ import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController -import app.omnivore.omnivore.core.designsystem.motion.materialSharedAxisXIn -import app.omnivore.omnivore.core.designsystem.motion.materialSharedAxisXOut -import app.omnivore.omnivore.feature.auth.LoginViewModel -import app.omnivore.omnivore.feature.auth.WelcomeScreen +import app.omnivore.omnivore.core.designsystem.theme.OmnivoreBrand +import app.omnivore.omnivore.feature.onboarding.LoginViewModel +import app.omnivore.omnivore.feature.onboarding.OnboardingScreen import app.omnivore.omnivore.feature.following.FollowingScreen import app.omnivore.omnivore.feature.library.LibraryView 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.filters.FiltersScreen import app.omnivore.omnivore.feature.web.WebViewScreen +import app.omnivore.omnivore.navigation.OmnivoreNavHost import app.omnivore.omnivore.navigation.Routes import app.omnivore.omnivore.navigation.TopLevelDestination @@ -81,7 +79,7 @@ fun RootView( } }) { padding -> Box( - modifier = if (!hasAuthToken) Modifier.background(Color(0xFFFCEBA8)) else Modifier + modifier = if (!hasAuthToken) Modifier.background(OmnivoreBrand) else Modifier .fillMaxSize() .padding(padding) .consumeWindowInsets(padding) @@ -95,8 +93,7 @@ fun RootView( PrimaryNavigator( navController = navController, snackbarHostState = snackbarHostState, - startDestination = startDestination, - loginViewModel = loginViewModel + startDestination = startDestination ) LaunchedEffect(hasAuthToken) { if (hasAuthToken) { @@ -107,33 +104,32 @@ fun RootView( } } -private const val INITIAL_OFFSET_FACTOR = 0.10f + @Composable fun PrimaryNavigator( navController: NavHostController, snackbarHostState: SnackbarHostState, - startDestination: String, - loginViewModel: LoginViewModel + startDestination: String ) { - 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() }) }) { + OmnivoreNavHost( + navController = navController, + startDestination = startDestination + ) { composable(Routes.Welcome.route) { - WelcomeScreen(viewModel = loginViewModel) + OnboardingScreen(navController = navController) } - navigation(startDestination = Routes.Inbox.route, + navigation( + startDestination = Routes.Inbox.route, route = Routes.Home.route, enterTransition = { EnterTransition.None }, exitTransition = { ExitTransition.None }, popEnterTransition = { EnterTransition.None }, - popExitTransition = { ExitTransition.None }) { + popExitTransition = { ExitTransition.None } + ) { composable(Routes.Inbox.route) { LibraryView(navController = navController) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/OmnivoreNavHost.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/OmnivoreNavHost.kt new file mode 100644 index 000000000..a22b533fd --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/OmnivoreNavHost.kt @@ -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() + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/Routes.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/Routes.kt index e40e06aa1..5a434ec03 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/Routes.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/Routes.kt @@ -3,6 +3,10 @@ package app.omnivore.omnivore.navigation sealed class Routes(val route: String) { data object Home : Routes("Home") 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 Inbox : Routes("Inbox") data object Settings : Routes("Settings") diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/AuthUtils.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/AuthUtils.kt similarity index 96% rename from android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/AuthUtils.kt rename to android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/AuthUtils.kt index f5ad49c98..7941afff4 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/AuthUtils.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/AuthUtils.kt @@ -1,4 +1,4 @@ -package app.omnivore.omnivore.feature.auth +package app.omnivore.omnivore.utils import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/Constants.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/Constants.kt index d0c909dd4..63f9c4f19 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/Constants.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/Constants.kt @@ -13,3 +13,6 @@ object AppleConstants { const val scope = "name%20email" 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" diff --git a/android/Omnivore/app/src/main/res/values/strings.xml b/android/Omnivore/app/src/main/res/values/strings.xml index d190f4f75..3e0d3263e 100644 --- a/android/Omnivore/app/src/main/res/values/strings.xml +++ b/android/Omnivore/app/src/main/res/values/strings.xml @@ -268,4 +268,5 @@ Cancel Error while editing article! Article info successfully updated! + Forgot Password? diff --git a/android/Omnivore/build.gradle.kts b/android/Omnivore/build.gradle.kts index 622300773..18c43bd7f 100644 --- a/android/Omnivore/build.gradle.kts +++ b/android/Omnivore/build.gradle.kts @@ -15,6 +15,8 @@ buildscript { plugins { alias(libs.plugins.android.application) 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("clean") { diff --git a/android/Omnivore/gradle/libs.versions.toml b/android/Omnivore/gradle/libs.versions.toml index f875bf6c9..eeb8cec32 100644 --- a/android/Omnivore/gradle/libs.versions.toml +++ b/android/Omnivore/gradle/libs.versions.toml @@ -2,14 +2,13 @@ accompanistFlowLayout = "0.34.0" androidGradlePlugin = "8.3.2" androidxActivity = "1.9.0" -androidxAppCompat = "1.6.1" -androidxComposeBom = "2024.04.01" -androidxComposeCompiler = "1.5.9" -androidxCore = "1.13.0" -androidxDataStore = "1.1.0" +androidxAppCompat = "1.7.0" +androidxComposeBom = "2024.06.00" +androidxCore = "1.13.1" +androidxDataStore = "1.1.1" androidxEspresso = "3.5.1" androidxHiltNavigationCompose = "1.2.0" -androidxLifecycle = "2.7.0" +androidxLifecycle = "2.8.2" androidxNavigation = "2.7.7" androidxSecurity = "1.0.0" androidxTestExt = "1.1.5" @@ -19,14 +18,14 @@ coil = "2.6.0" composeMarkdown = "0.3.3" coreSplashscreen = "1.0.1" gson = "2.10.1" -hilt = "2.51" +hilt = "2.51.1" intercom = "15.8.2" junit4 = "4.13.2" -kotlin = "1.9.22" -ksp = "1.9.22-1.0.18" +kotlin = "2.0.0" +ksp = "2.0.0-1.0.21" kotlinxCoroutines = "1.8.0" -playServices = "18.4.0" -playServicesAuth = "21.1.0" +playServices = "18.5.0" +playServicesAuth = "21.2.0" posthog = "2.0.3" pspdfkit = "8.9.1" 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"} [plugins] +org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } apollo = { id = "com.apollographql.apollo3", version.ref = "apollo" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }