Refine social auth UI and simplify manage auth state.
Extract Google sign-in into a dedicated manager with explicit cancel handling, update shared social button variants, and switch manage UI state from enum modes to a direct authorization flag. Made-with: Cursor
This commit is contained in:
@@ -9,9 +9,8 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
@@ -54,7 +53,7 @@ enum class AirMQButtonStyle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val LegacyButtonShape = RoundedCornerShape(18.dp)
|
private val LegacyButtonShape = RoundedCornerShape(18.dp)
|
||||||
private val LegacyButtonHeight = 48.dp
|
private val LegacyButtonHeight = 36.dp
|
||||||
private val LegacyDisabledContainer = Color(0xFFE0E0E0)
|
private val LegacyDisabledContainer = Color(0xFFE0E0E0)
|
||||||
private val LegacyDisabledContent = Color(0x61000000)
|
private val LegacyDisabledContent = Color(0x61000000)
|
||||||
|
|
||||||
@@ -99,6 +98,69 @@ fun AirMQButton(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AirMQSocialButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
leadingIconRes: Int? = null,
|
||||||
|
iconTint: Color = Color.Unspecified,
|
||||||
|
containerColor: Color = Color.White,
|
||||||
|
contentColor: Color = Color(0xFF202124)
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = LegacyButtonShape,
|
||||||
|
modifier = modifier.height(LegacyButtonHeight),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = containerColor,
|
||||||
|
contentColor = contentColor,
|
||||||
|
disabledContainerColor = LegacyDisabledContainer,
|
||||||
|
disabledContentColor = LegacyDisabledContent
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
AirMQButtonLabel(
|
||||||
|
text = text,
|
||||||
|
leadingIconRes = leadingIconRes,
|
||||||
|
iconTint = if (enabled) iconTint else LegacyDisabledContent,
|
||||||
|
textColor = if (enabled) contentColor else LegacyDisabledContent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AirMQOutlinedLightButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
leadingIconRes: Int? = null
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = LegacyButtonShape,
|
||||||
|
border = BorderStroke(
|
||||||
|
width = 1.dp,
|
||||||
|
color = if (enabled) Color.White.copy(alpha = 0.55f) else LegacyDisabledContent
|
||||||
|
),
|
||||||
|
modifier = modifier.height(LegacyButtonHeight),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = Color.White,
|
||||||
|
disabledContentColor = LegacyDisabledContent
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
AirMQButtonLabel(
|
||||||
|
text = text,
|
||||||
|
leadingIconRes = leadingIconRes,
|
||||||
|
iconTint = if (enabled) Color.White else LegacyDisabledContent,
|
||||||
|
textColor = if (enabled) Color.White else LegacyDisabledContent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AirMQContainedButton(
|
fun AirMQContainedButton(
|
||||||
text: String,
|
text: String,
|
||||||
@@ -111,7 +173,7 @@ fun AirMQContainedButton(
|
|||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
shape = LegacyButtonShape,
|
shape = LegacyButtonShape,
|
||||||
modifier = modifier.heightIn(min = LegacyButtonHeight),
|
modifier = modifier.height(LegacyButtonHeight),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = LegacyButtonContained,
|
containerColor = LegacyButtonContained,
|
||||||
contentColor = LegacyButtonOnContained,
|
contentColor = LegacyButtonOnContained,
|
||||||
@@ -143,7 +205,7 @@ fun AirMQOutlinedButton(
|
|||||||
width = 1.dp,
|
width = 1.dp,
|
||||||
color = if (enabled) LegacyOutlineLight else LegacyDisabledContent
|
color = if (enabled) LegacyOutlineLight else LegacyDisabledContent
|
||||||
),
|
),
|
||||||
modifier = modifier.heightIn(min = LegacyButtonHeight),
|
modifier = modifier.height(LegacyButtonHeight),
|
||||||
colors = ButtonDefaults.outlinedButtonColors(
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
contentColor = LegacyButtonOnOutlined,
|
contentColor = LegacyButtonOnOutlined,
|
||||||
disabledContentColor = LegacyDisabledContent
|
disabledContentColor = LegacyDisabledContent
|
||||||
@@ -169,7 +231,7 @@ fun AirMQTextButton(
|
|||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
shape = LegacyButtonShape,
|
shape = LegacyButtonShape,
|
||||||
modifier = modifier.heightIn(min = LegacyButtonHeight),
|
modifier = modifier.height(LegacyButtonHeight),
|
||||||
colors = ButtonDefaults.textButtonColors(
|
colors = ButtonDefaults.textButtonColors(
|
||||||
contentColor = LegacyButtonOnText,
|
contentColor = LegacyButtonOnText,
|
||||||
disabledContentColor = LegacyDisabledContent
|
disabledContentColor = LegacyDisabledContent
|
||||||
@@ -205,7 +267,7 @@ fun AirMQGradientButton(
|
|||||||
shape = LegacyButtonShape,
|
shape = LegacyButtonShape,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.heightIn(min = LegacyButtonHeight)
|
.height(LegacyButtonHeight)
|
||||||
.clip(LegacyButtonShape),
|
.clip(LegacyButtonShape),
|
||||||
contentPadding = PaddingValues(0.dp),
|
contentPadding = PaddingValues(0.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
@@ -218,7 +280,7 @@ fun AirMQGradientButton(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = LegacyButtonHeight)
|
.height(LegacyButtonHeight)
|
||||||
.clip(LegacyButtonShape)
|
.clip(LegacyButtonShape)
|
||||||
.background(
|
.background(
|
||||||
brush = Brush.horizontalGradient(
|
brush = Brush.horizontalGradient(
|
||||||
@@ -330,3 +392,71 @@ private fun AirMQButtonsPreviewGradientWithIcon() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Buttons - Social", showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun AirMQButtonsPreviewSocial() {
|
||||||
|
AirMQTheme {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
AirMQSocialButton(
|
||||||
|
text = "Sign in with Google",
|
||||||
|
leadingIconRes = R.drawable.ic_google,
|
||||||
|
iconTint = Color.Unspecified,
|
||||||
|
containerColor = Color.White,
|
||||||
|
contentColor = Color(0xFF202124),
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
AirMQSocialButton(
|
||||||
|
text = "Sign in with Facebook",
|
||||||
|
leadingIconRes = R.drawable.ic_facebook,
|
||||||
|
iconTint = Color.White,
|
||||||
|
containerColor = Color(0xFF3B5998),
|
||||||
|
contentColor = Color.White,
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
AirMQOutlinedLightButton(
|
||||||
|
text = "Continue anonymously",
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Buttons - Social Icon Comparison", showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun AirMQButtonsPreviewSocialIconComparison() {
|
||||||
|
AirMQTheme {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
AirMQSocialButton(
|
||||||
|
text = "Sign in with Google",
|
||||||
|
leadingIconRes = R.drawable.ic_google,
|
||||||
|
iconTint = Color.Unspecified,
|
||||||
|
containerColor = Color.White,
|
||||||
|
contentColor = Color(0xFF202124),
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
AirMQSocialButton(
|
||||||
|
text = "Sign in with Google",
|
||||||
|
leadingIconRes = null,
|
||||||
|
containerColor = Color.White,
|
||||||
|
contentColor = Color(0xFF202124),
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,10 +19,6 @@ fun DashboardScreen(
|
|||||||
title = stringResource(id = R.string.title_dashboard),
|
title = stringResource(id = R.string.title_dashboard),
|
||||||
subtitle = stringResource(id = R.string.dashboard_subtitle),
|
subtitle = stringResource(id = R.string.dashboard_subtitle),
|
||||||
actions = listOf(
|
actions = listOf(
|
||||||
ScreenAction(stringResource(id = R.string.dashboard_open_map), onOpenMap),
|
|
||||||
ScreenAction(stringResource(id = R.string.dashboard_open_manage), onOpenManage),
|
|
||||||
ScreenAction(stringResource(id = R.string.dashboard_open_city), onOpenCity),
|
|
||||||
ScreenAction(stringResource(id = R.string.dashboard_open_device), onOpenDevice),
|
|
||||||
ScreenAction(stringResource(id = R.string.dashboard_open_news), onOpenNews),
|
ScreenAction(stringResource(id = R.string.dashboard_open_news), onOpenNews),
|
||||||
ScreenAction(stringResource(id = R.string.manage_open_widget_constructor), onOpenWidgetConstructor)
|
ScreenAction(stringResource(id = R.string.manage_open_widget_constructor), onOpenWidgetConstructor)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package org.db3.airmq.features.login
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.ContextWrapper
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.credentials.CredentialManager
|
||||||
|
import androidx.credentials.CustomCredential
|
||||||
|
import androidx.credentials.GetCredentialRequest
|
||||||
|
import androidx.credentials.exceptions.GetCredentialCancellationException
|
||||||
|
import androidx.credentials.exceptions.GetCredentialException
|
||||||
|
import androidx.credentials.exceptions.GetCredentialInterruptedException
|
||||||
|
import androidx.credentials.exceptions.GetCredentialProviderConfigurationException
|
||||||
|
import androidx.credentials.exceptions.NoCredentialException
|
||||||
|
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
|
||||||
|
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
||||||
|
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
|
||||||
|
import org.db3.airmq.R
|
||||||
|
|
||||||
|
sealed interface GoogleSignInResult {
|
||||||
|
data class Success(val idToken: String) : GoogleSignInResult
|
||||||
|
data object Cancelled : GoogleSignInResult
|
||||||
|
data class Error(val message: String) : GoogleSignInResult
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val GOOGLE_SIGN_IN_TAG = "GoogleSignIn"
|
||||||
|
|
||||||
|
suspend fun launchGoogleSignIn(context: Context): GoogleSignInResult {
|
||||||
|
return try {
|
||||||
|
val activity = context.findActivity()
|
||||||
|
val request = GetCredentialRequest.Builder()
|
||||||
|
.addCredentialOption(
|
||||||
|
GetGoogleIdOption.Builder()
|
||||||
|
.setServerClientId(context.getString(R.string.default_web_client_id))
|
||||||
|
.setFilterByAuthorizedAccounts(false)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
val response = CredentialManager.create(context).getCredential(
|
||||||
|
context = activity,
|
||||||
|
request = request
|
||||||
|
)
|
||||||
|
val credential = response.credential
|
||||||
|
if (credential is CustomCredential &&
|
||||||
|
credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL
|
||||||
|
) {
|
||||||
|
GoogleSignInResult.Success(GoogleIdTokenCredential.createFrom(credential.data).idToken)
|
||||||
|
} else {
|
||||||
|
GoogleSignInResult.Error("Unsupported credential type for Google sign-in.")
|
||||||
|
}
|
||||||
|
} catch (error: GetCredentialCancellationException) {
|
||||||
|
logGoogleSignInError(error)
|
||||||
|
GoogleSignInResult.Cancelled
|
||||||
|
} catch (error: GetCredentialException) {
|
||||||
|
logGoogleSignInError(error)
|
||||||
|
GoogleSignInResult.Error(context.getString(R.string.toast_oauth_failed))
|
||||||
|
} catch (error: GoogleIdTokenParsingException) {
|
||||||
|
Log.e(GOOGLE_SIGN_IN_TAG, "Google ID token parsing failed", error)
|
||||||
|
GoogleSignInResult.Error(context.getString(R.string.toast_oauth_failed))
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
GoogleSignInResult.Error(error.message ?: context.getString(R.string.toast_oauth_failed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logGoogleSignInError(exception: GetCredentialException) {
|
||||||
|
val message = when (exception) {
|
||||||
|
is GetCredentialCancellationException -> "Google sign-in cancelled by user"
|
||||||
|
is NoCredentialException -> "No Google credential available on device"
|
||||||
|
is GetCredentialProviderConfigurationException -> "Credential provider is not configured correctly"
|
||||||
|
is GetCredentialInterruptedException -> "Credential flow interrupted; try again"
|
||||||
|
else -> "CredentialManager returned an unknown sign-in error"
|
||||||
|
}
|
||||||
|
Log.e(GOOGLE_SIGN_IN_TAG, message, exception)
|
||||||
|
}
|
||||||
|
|
||||||
|
private tailrec fun Context.findActivity(): Activity {
|
||||||
|
return when (this) {
|
||||||
|
is Activity -> this
|
||||||
|
is ContextWrapper -> baseContext.findActivity()
|
||||||
|
else -> error("Unable to find Activity context for Google sign-in.")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,8 @@
|
|||||||
package org.db3.airmq.features.login
|
package org.db3.airmq.features.login
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.ContextWrapper
|
|
||||||
import android.util.Log
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.credentials.CredentialManager
|
|
||||||
import androidx.credentials.CustomCredential
|
|
||||||
import androidx.credentials.GetCredentialRequest
|
|
||||||
import androidx.credentials.exceptions.GetCredentialCancellationException
|
|
||||||
import androidx.credentials.exceptions.GetCredentialException
|
|
||||||
import androidx.credentials.exceptions.GetCredentialInterruptedException
|
|
||||||
import androidx.credentials.exceptions.GetCredentialProviderConfigurationException
|
|
||||||
import androidx.credentials.exceptions.NoCredentialException
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -25,13 +12,9 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -57,20 +40,15 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.text.ClickableText
|
import androidx.compose.foundation.text.ClickableText
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
|
|
||||||
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
|
|
||||||
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
|
||||||
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
|
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import org.db3.airmq.R
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.features.common.AirMQOutlinedLightButton
|
||||||
|
import org.db3.airmq.features.common.AirMQSocialButton
|
||||||
import org.db3.airmq.features.login.LoginScreenContract.Action
|
import org.db3.airmq.features.login.LoginScreenContract.Action
|
||||||
import org.db3.airmq.features.login.LoginScreenContract.Event
|
import org.db3.airmq.features.login.LoginScreenContract.Event
|
||||||
import org.db3.airmq.features.login.LoginScreenContract.State
|
import org.db3.airmq.features.login.LoginScreenContract.State
|
||||||
@@ -79,7 +57,6 @@ import org.db3.airmq.ui.theme.AirMQTheme
|
|||||||
private val LegacyLoginGradientStart = Color(0xFF449CF5)
|
private val LegacyLoginGradientStart = Color(0xFF449CF5)
|
||||||
private val LegacyLoginGradientEnd = Color(0xFF5CE4BB)
|
private val LegacyLoginGradientEnd = Color(0xFF5CE4BB)
|
||||||
private val LegacyFacebookBlue = Color(0xFF3B5998)
|
private val LegacyFacebookBlue = Color(0xFF3B5998)
|
||||||
private const val LOGIN_TAG = "LoginScreen"
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(
|
fun LoginScreen(
|
||||||
@@ -95,13 +72,11 @@ fun LoginScreen(
|
|||||||
when (action) {
|
when (action) {
|
||||||
Action.OpenManage -> onLogInToManage()
|
Action.OpenManage -> onLogInToManage()
|
||||||
Action.LaunchGoogleSignIn -> {
|
Action.LaunchGoogleSignIn -> {
|
||||||
val result = launchGoogleSignIn(context)
|
when (val result = launchGoogleSignIn(context)) {
|
||||||
result.fold(
|
is GoogleSignInResult.Success -> viewModel.onEvent(Event.GoogleTokenReceived(result.idToken))
|
||||||
onSuccess = { viewModel.onEvent(Event.GoogleTokenReceived(it)) },
|
GoogleSignInResult.Cancelled -> viewModel.onEvent(Event.GoogleSignInCancelled)
|
||||||
onFailure = { error ->
|
is GoogleSignInResult.Error -> viewModel.onEvent(Event.GoogleSignInFailed(result.message))
|
||||||
viewModel.onEvent(Event.GoogleSignInFailed(error.message))
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Action.ShowContinueAnonymousDialog -> showContinueAnonymousDialog = true
|
Action.ShowContinueAnonymousDialog -> showContinueAnonymousDialog = true
|
||||||
Action.OpenPrivacyPolicy -> {
|
Action.OpenPrivacyPolicy -> {
|
||||||
@@ -230,41 +205,32 @@ private fun LoginScreenContent(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
SocialSignInButton(
|
AirMQSocialButton(
|
||||||
text = stringResource(id = R.string.button_sign_in_google),
|
text = stringResource(id = R.string.button_sign_in_google),
|
||||||
iconRes = R.drawable.ic_google,
|
leadingIconRes = R.drawable.ic_google,
|
||||||
iconTint = Color.Unspecified,
|
iconTint = Color.Unspecified,
|
||||||
backgroundColor = Color.White,
|
containerColor = Color.White,
|
||||||
textColor = Color(0xFF202124),
|
contentColor = Color(0xFF202124),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onClick = { onEvent(Event.GoogleClicked) }
|
onClick = { onEvent(Event.GoogleClicked) }
|
||||||
)
|
)
|
||||||
|
|
||||||
SocialSignInButton(
|
AirMQSocialButton(
|
||||||
text = stringResource(id = R.string.button_sign_in_facebook),
|
text = stringResource(id = R.string.button_sign_in_facebook),
|
||||||
iconRes = R.drawable.ic_facebook,
|
leadingIconRes = R.drawable.ic_facebook,
|
||||||
iconTint = Color.White,
|
iconTint = Color.White,
|
||||||
backgroundColor = LegacyFacebookBlue,
|
containerColor = LegacyFacebookBlue,
|
||||||
textColor = Color.White,
|
contentColor = Color.White,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onClick = { onEvent(Event.FacebookClicked) }
|
onClick = { onEvent(Event.FacebookClicked) }
|
||||||
)
|
)
|
||||||
|
|
||||||
OutlinedButton(
|
AirMQOutlinedLightButton(
|
||||||
onClick = { onEvent(Event.ContinueAnonymousClicked) },
|
text = stringResource(id = R.string.button_continue_anonym),
|
||||||
shape = RoundedCornerShape(18.dp),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = ButtonDefaults.outlinedButtonColors(
|
onClick = { onEvent(Event.ContinueAnonymousClicked) }
|
||||||
contentColor = Color.White
|
|
||||||
),
|
|
||||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.55f)),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(40.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.button_continue_anonym).uppercase(),
|
|
||||||
style = MaterialTheme.typography.labelLarge
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
@@ -275,45 +241,6 @@ private fun LoginScreenContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SocialSignInButton(
|
|
||||||
text: String,
|
|
||||||
iconRes: Int,
|
|
||||||
iconTint: Color,
|
|
||||||
backgroundColor: Color,
|
|
||||||
textColor: Color,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = onClick,
|
|
||||||
shape = RoundedCornerShape(18.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = backgroundColor,
|
|
||||||
contentColor = textColor
|
|
||||||
),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(40.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxHeight()
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = iconRes),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = iconTint,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
Text(
|
|
||||||
text = text.uppercase(),
|
|
||||||
style = MaterialTheme.typography.labelLarge
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PrivacyAndTermsFooter(onEvent: (Event) -> Unit) {
|
private fun PrivacyAndTermsFooter(onEvent: (Event) -> Unit) {
|
||||||
val privacyPolicy = "Privacy Policy"
|
val privacyPolicy = "Privacy Policy"
|
||||||
@@ -388,61 +315,6 @@ private fun PrivacyAndTermsFooter(onEvent: (Event) -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun launchGoogleSignIn(context: Context): Result<String> = runCatching {
|
|
||||||
val activity = context.findActivity()
|
|
||||||
val request = GetCredentialRequest.Builder()
|
|
||||||
.addCredentialOption(
|
|
||||||
GetGoogleIdOption.Builder()
|
|
||||||
.setServerClientId(context.getString(R.string.default_web_client_id))
|
|
||||||
.setFilterByAuthorizedAccounts(false)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
val response = CredentialManager.create(context).getCredential(
|
|
||||||
context = activity,
|
|
||||||
request = request
|
|
||||||
)
|
|
||||||
val credential = response.credential
|
|
||||||
if (credential is CustomCredential &&
|
|
||||||
credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL
|
|
||||||
) {
|
|
||||||
GoogleIdTokenCredential.createFrom(credential.data).idToken
|
|
||||||
} else {
|
|
||||||
error("Unsupported credential type for Google sign-in.")
|
|
||||||
}
|
|
||||||
}.recoverCatching {
|
|
||||||
when (it) {
|
|
||||||
is GetCredentialException -> {
|
|
||||||
logGoogleSignInError(it)
|
|
||||||
throw IllegalStateException(context.getString(R.string.toast_oauth_failed), it)
|
|
||||||
}
|
|
||||||
is GoogleIdTokenParsingException -> {
|
|
||||||
Log.e(LOGIN_TAG, "Google ID token parsing failed", it)
|
|
||||||
throw IllegalStateException(context.getString(R.string.toast_oauth_failed), it)
|
|
||||||
}
|
|
||||||
else -> throw it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun logGoogleSignInError(exception: GetCredentialException) {
|
|
||||||
val message = when (exception) {
|
|
||||||
is GetCredentialCancellationException -> "Google sign-in cancelled by user"
|
|
||||||
is NoCredentialException -> "No Google credential available on device"
|
|
||||||
is GetCredentialProviderConfigurationException -> "Credential provider is not configured correctly"
|
|
||||||
is GetCredentialInterruptedException -> "Credential flow interrupted; try again"
|
|
||||||
else -> "CredentialManager returned an unknown sign-in error"
|
|
||||||
}
|
|
||||||
Log.e(LOGIN_TAG, message, exception)
|
|
||||||
}
|
|
||||||
|
|
||||||
private tailrec fun Context.findActivity(): Activity {
|
|
||||||
return when (this) {
|
|
||||||
is Activity -> this
|
|
||||||
is ContextWrapper -> baseContext.findActivity()
|
|
||||||
else -> error("Unable to find Activity context for Google sign-in.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun LoginScreenPreview() {
|
private fun LoginScreenPreview() {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ object LoginScreenContract {
|
|||||||
sealed interface Event {
|
sealed interface Event {
|
||||||
data object GoogleClicked : Event
|
data object GoogleClicked : Event
|
||||||
data class GoogleTokenReceived(val idToken: String) : Event
|
data class GoogleTokenReceived(val idToken: String) : Event
|
||||||
|
data object GoogleSignInCancelled : Event
|
||||||
data class GoogleSignInFailed(val message: String? = null) : Event
|
data class GoogleSignInFailed(val message: String? = null) : Event
|
||||||
data object FacebookClicked : Event
|
data object FacebookClicked : Event
|
||||||
data object ContinueAnonymousClicked : Event
|
data object ContinueAnonymousClicked : Event
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ class LoginViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Event.GoogleSignInCancelled -> {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
|
}
|
||||||
Event.FacebookClicked -> {
|
Event.FacebookClicked -> {
|
||||||
_actions.tryEmit(Action.ShowMessage(appContext.getString(R.string.coming_soon)))
|
_actions.tryEmit(Action.ShowMessage(appContext.getString(R.string.coming_soon)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ import org.db3.airmq.features.manage.ManageScreenContract.Action
|
|||||||
import org.db3.airmq.features.manage.ManageScreenContract.DeviceItem
|
import org.db3.airmq.features.manage.ManageScreenContract.DeviceItem
|
||||||
import org.db3.airmq.features.manage.ManageScreenContract.Event
|
import org.db3.airmq.features.manage.ManageScreenContract.Event
|
||||||
import org.db3.airmq.features.manage.ManageScreenContract.State
|
import org.db3.airmq.features.manage.ManageScreenContract.State
|
||||||
import org.db3.airmq.features.manage.ManageScreenContract.UserMode
|
|
||||||
import org.db3.airmq.ui.theme.AirMQTheme
|
import org.db3.airmq.ui.theme.AirMQTheme
|
||||||
import org.db3.airmq.ui.theme.LegacyNavGradientEnd
|
import org.db3.airmq.ui.theme.LegacyNavGradientEnd
|
||||||
import org.db3.airmq.ui.theme.LegacyNavGradientStart
|
import org.db3.airmq.ui.theme.LegacyNavGradientStart
|
||||||
@@ -99,16 +98,16 @@ private fun ManageScreenContent(
|
|||||||
ProfileHeader(
|
ProfileHeader(
|
||||||
name = uiState.userName,
|
name = uiState.userName,
|
||||||
email = uiState.userEmail,
|
email = uiState.userEmail,
|
||||||
isAnonymous = uiState.userMode == UserMode.ANONYMOUS,
|
isAnonymous = !uiState.isAuthorized,
|
||||||
onSettingsClick = { onEvent(Event.SettingsClicked) }
|
onSettingsClick = { onEvent(Event.SettingsClicked) }
|
||||||
)
|
)
|
||||||
when (uiState.userMode) {
|
when (uiState.isAuthorized) {
|
||||||
UserMode.ANONYMOUS -> AnonymousContent(
|
false -> AnonymousContent(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
devicesLabel = uiState.devicesLabel,
|
devicesLabel = uiState.devicesLabel,
|
||||||
onSignIn = { onEvent(Event.SignInClicked) }
|
onSignIn = { onEvent(Event.SignInClicked) }
|
||||||
)
|
)
|
||||||
UserMode.AUTHORIZED -> AuthorizedContent(
|
true -> AuthorizedContent(
|
||||||
devicesLabel = uiState.devicesLabel,
|
devicesLabel = uiState.devicesLabel,
|
||||||
devices = uiState.devices,
|
devices = uiState.devices,
|
||||||
onOpenSetup = { onEvent(Event.SetupClicked) },
|
onOpenSetup = { onEvent(Event.SetupClicked) },
|
||||||
@@ -116,7 +115,7 @@ private fun ManageScreenContent(
|
|||||||
onOpenLocation = { onEvent(Event.DeviceLocationClicked(it)) }
|
onOpenLocation = { onEvent(Event.DeviceLocationClicked(it)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (uiState.userMode == UserMode.AUTHORIZED) {
|
if (uiState.isAuthorized) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
AirMQButton(
|
AirMQButton(
|
||||||
text = stringResource(id = R.string.manage_open_widget_constructor),
|
text = stringResource(id = R.string.manage_open_widget_constructor),
|
||||||
@@ -147,7 +146,7 @@ private fun ProfileHeader(
|
|||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxSize()
|
||||||
.background(
|
.background(
|
||||||
brush = Brush.verticalGradient(
|
brush = Brush.verticalGradient(
|
||||||
colors = listOf(LegacyNavGradientEnd, LegacyNavGradientStart)
|
colors = listOf(LegacyNavGradientEnd, LegacyNavGradientStart)
|
||||||
@@ -245,7 +244,6 @@ private fun AnonymousContent(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.fillMaxWidth(0.46f)
|
.fillMaxWidth(0.46f)
|
||||||
.height(48.dp)
|
|
||||||
.padding(bottom = 16.dp)
|
.padding(bottom = 16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -336,7 +334,7 @@ private fun ManageScreenAnonymousPreview() {
|
|||||||
AirMQTheme {
|
AirMQTheme {
|
||||||
ManageScreenContent(
|
ManageScreenContent(
|
||||||
uiState = State(
|
uiState = State(
|
||||||
userMode = UserMode.ANONYMOUS,
|
isAuthorized = false,
|
||||||
userName = "Anonymous user",
|
userName = "Anonymous user",
|
||||||
userEmail = "Your preferences are not being synced, please sign in",
|
userEmail = "Your preferences are not being synced, please sign in",
|
||||||
devicesLabel = "Sign in to add devices"
|
devicesLabel = "Sign in to add devices"
|
||||||
@@ -354,7 +352,7 @@ private fun ManageScreenAuthorizedPreview() {
|
|||||||
AirMQTheme {
|
AirMQTheme {
|
||||||
ManageScreenContent(
|
ManageScreenContent(
|
||||||
uiState = State(
|
uiState = State(
|
||||||
userMode = UserMode.AUTHORIZED,
|
isAuthorized = true,
|
||||||
userName = "Anton Betsun",
|
userName = "Anton Betsun",
|
||||||
userEmail = "messbees@gmail.com",
|
userEmail = "messbees@gmail.com",
|
||||||
devicesLabel = "My devices",
|
devicesLabel = "My devices",
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
package org.db3.airmq.features.manage
|
package org.db3.airmq.features.manage
|
||||||
|
|
||||||
object ManageScreenContract {
|
object ManageScreenContract {
|
||||||
|
|
||||||
enum class UserMode {
|
|
||||||
ANONYMOUS,
|
|
||||||
AUTHORIZED
|
|
||||||
}
|
|
||||||
|
|
||||||
data class DeviceItem(
|
data class DeviceItem(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
@@ -15,7 +9,7 @@ object ManageScreenContract {
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class State(
|
data class State(
|
||||||
val userMode: UserMode = UserMode.ANONYMOUS,
|
val isAuthorized: Boolean = false,
|
||||||
val userName: String = "",
|
val userName: String = "",
|
||||||
val userEmail: String = "",
|
val userEmail: String = "",
|
||||||
val devicesLabel: String = "",
|
val devicesLabel: String = "",
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import org.db3.airmq.features.manage.ManageScreenContract.Action
|
|||||||
import org.db3.airmq.features.manage.ManageScreenContract.DeviceItem
|
import org.db3.airmq.features.manage.ManageScreenContract.DeviceItem
|
||||||
import org.db3.airmq.features.manage.ManageScreenContract.Event
|
import org.db3.airmq.features.manage.ManageScreenContract.Event
|
||||||
import org.db3.airmq.features.manage.ManageScreenContract.State
|
import org.db3.airmq.features.manage.ManageScreenContract.State
|
||||||
import org.db3.airmq.features.manage.ManageScreenContract.UserMode
|
|
||||||
import org.db3.airmq.sdk.auth.AuthService
|
import org.db3.airmq.sdk.auth.AuthService
|
||||||
import org.db3.airmq.sdk.auth.model.User
|
import org.db3.airmq.sdk.auth.model.User
|
||||||
|
|
||||||
@@ -62,14 +61,14 @@ class ManageViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun anonymousState(): State = State(
|
private fun anonymousState(): State = State(
|
||||||
userMode = UserMode.ANONYMOUS,
|
isAuthorized = false,
|
||||||
userName = appContext.getString(R.string.text_anonymous_user),
|
userName = appContext.getString(R.string.text_anonymous_user),
|
||||||
userEmail = appContext.getString(R.string.text_please_sign_in),
|
userEmail = appContext.getString(R.string.text_please_sign_in),
|
||||||
devicesLabel = appContext.getString(R.string.text_sign_in_small)
|
devicesLabel = appContext.getString(R.string.text_sign_in_small)
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun authorizedState(user: User): State = State(
|
private fun authorizedState(user: User): State = State(
|
||||||
userMode = UserMode.AUTHORIZED,
|
isAuthorized = true,
|
||||||
userName = user.displayName ?: appContext.getString(R.string.text_anonymous_user),
|
userName = user.displayName ?: appContext.getString(R.string.text_anonymous_user),
|
||||||
userEmail = user.email ?: "",
|
userEmail = user.email ?: "",
|
||||||
devicesLabel = appContext.getString(R.string.text_your_devices),
|
devicesLabel = appContext.getString(R.string.text_your_devices),
|
||||||
|
|||||||
Reference in New Issue
Block a user