Authentication error management like a boos !
This commit is contained in:
parent
f48dfd6488
commit
537cf214a9
8 changed files with 518 additions and 414 deletions
|
|
@ -1,5 +1,6 @@
|
|||
package com.pixelized.biblib.ui
|
||||
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -11,7 +12,6 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
|
|||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.pixelized.biblib.ui.navigation.ScreenNavHost
|
||||
import com.pixelized.biblib.ui.screen.launch.LauncherViewModel
|
||||
import com.pixelized.biblib.utils.extention.bibLib
|
||||
import com.skydoves.landscapist.glide.LocalGlideRequestOptions
|
||||
|
||||
val LocalSnackHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||
|
|
@ -33,7 +33,9 @@ fun MainContent(
|
|||
scaffoldState = scaffoldState,
|
||||
snackbarHost = { snackHostState ->
|
||||
SnackbarHost(
|
||||
modifier = Modifier.systemBarsPadding(),
|
||||
modifier = Modifier
|
||||
.systemBarsPadding()
|
||||
.imePadding(),
|
||||
hostState = snackHostState,
|
||||
) { snackBarData ->
|
||||
Snackbar(
|
||||
|
|
|
|||
|
|
@ -1,56 +1,65 @@
|
|||
package com.pixelized.biblib.ui.screen.authentication
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.sharp.Visibility
|
||||
import androidx.compose.material.icons.sharp.VisibilityOff
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.SnackbarResult
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.autofill.AutofillNode
|
||||
import androidx.compose.ui.autofill.AutofillType
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalAutofill
|
||||
import androidx.compose.ui.platform.LocalAutofillTree
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import com.pixelized.biblib.R
|
||||
import com.pixelized.biblib.network.client.IBibLibClient
|
||||
import com.pixelized.biblib.ui.composable.StateUio
|
||||
import com.pixelized.biblib.ui.composable.StateUioHandler
|
||||
import com.pixelized.biblib.ui.composable.animation.AnimatedDelayer
|
||||
import com.pixelized.biblib.ui.composable.animation.AnimatedOffset
|
||||
import com.pixelized.biblib.ui.LocalSnackHostState
|
||||
import com.pixelized.biblib.ui.navigation.LocalScreenNavHostController
|
||||
import com.pixelized.biblib.ui.navigation.navigateToHome
|
||||
import com.pixelized.biblib.ui.screen.authentication.viewModel.AuthenticationFormViewModel
|
||||
import com.pixelized.biblib.ui.screen.authentication.viewModel.AuthenticationViewModel
|
||||
import com.pixelized.biblib.ui.theme.BibLibTheme
|
||||
import com.pixelized.biblib.ui.theme.color.GoogleColorPalette
|
||||
import com.pixelized.biblib.ui.theme.color.ShadowPalette
|
||||
import com.pixelized.biblib.utils.extention.bibLib
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Stable
|
||||
@Immutable
|
||||
sealed class AuthenticationUio {
|
||||
@Stable
|
||||
@Immutable
|
||||
object Progress : AuthenticationUio()
|
||||
|
||||
@Stable
|
||||
@Immutable
|
||||
object Done : AuthenticationUio()
|
||||
}
|
||||
|
||||
@Stable
|
||||
@Immutable
|
||||
sealed class AuthenticationErrorUio(
|
||||
@StringRes val message: Int,
|
||||
@StringRes val action: Int,
|
||||
) {
|
||||
@Stable
|
||||
@Immutable
|
||||
class Login(
|
||||
@StringRes message: Int,
|
||||
@StringRes action: Int,
|
||||
) : AuthenticationErrorUio(message, action)
|
||||
|
||||
@Stable
|
||||
@Immutable
|
||||
class GoogleLogin(
|
||||
@StringRes message: Int,
|
||||
@StringRes action: Int,
|
||||
) : AuthenticationErrorUio(message, action)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthenticationScreen(
|
||||
|
|
@ -58,6 +67,12 @@ fun AuthenticationScreen(
|
|||
formViewModel: AuthenticationFormViewModel = hiltViewModel(),
|
||||
) {
|
||||
val navHostController = LocalScreenNavHostController.current
|
||||
val snackBarHostState = LocalSnackHostState.current
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
|
||||
|
||||
AuthenticationScreenContent(
|
||||
modifier = Modifier.systemBarsPadding(),
|
||||
|
|
@ -83,345 +98,81 @@ fun AuthenticationScreen(
|
|||
)
|
||||
},
|
||||
onRegister = {
|
||||
navHostController.navigateToRegister()
|
||||
uriHandler.openUri(uri = IBibLibClient.REGISTER_URL)
|
||||
},
|
||||
)
|
||||
|
||||
AuthenticationHandler(
|
||||
onDismissRequest = {
|
||||
if (it is StateUio.Failure) authenticationViewModel.dismissError()
|
||||
onFailure = {
|
||||
scope.launch {
|
||||
val result = snackBarHostState.showSnackbar(
|
||||
message = context.getString(it.message),
|
||||
actionLabel = context.getString(it.action),
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) {
|
||||
when (it) {
|
||||
is AuthenticationErrorUio.GoogleLogin -> {
|
||||
authenticationViewModel.loginWithGoogle()
|
||||
}
|
||||
is AuthenticationErrorUio.Login -> {
|
||||
authenticationViewModel.login(
|
||||
login = formViewModel.form.login,
|
||||
password = formViewModel.form.password,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess = {
|
||||
navHostController.navigateToHome()
|
||||
}
|
||||
)
|
||||
|
||||
AuthenticationProgress()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthenticationScreenContent(
|
||||
modifier: Modifier = Modifier,
|
||||
login: String,
|
||||
onLoginChange: (String) -> Unit,
|
||||
password: String,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
rememberPassword: Boolean,
|
||||
onRememberPasswordChange: (Boolean) -> Unit,
|
||||
onGoogleSignIn: () -> Unit,
|
||||
onSignIn: () -> Unit,
|
||||
onRegister: () -> Unit,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
AnimatedDelayer {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
AnimatedOffset(
|
||||
modifier = Modifier
|
||||
.padding(all = MaterialTheme.bibLib.dimen.dp16)
|
||||
.align(alignment = Alignment.CenterHorizontally),
|
||||
) {
|
||||
Text(
|
||||
style = MaterialTheme.typography.h4,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
text = stringResource(id = R.string.authentication_title),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
AnimatedOffset(
|
||||
modifier = Modifier.padding(horizontal = MaterialTheme.bibLib.dimen.dp16),
|
||||
) {
|
||||
LoginField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = login,
|
||||
onValueChange = onLoginChange,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.bibLib.dimen.dp8))
|
||||
|
||||
AnimatedOffset(
|
||||
modifier = Modifier.padding(horizontal = MaterialTheme.bibLib.dimen.dp16),
|
||||
) {
|
||||
PasswordField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = password,
|
||||
onValueChange = onPasswordChange,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions { focusManager.clearFocus() },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.bibLib.dimen.dp16))
|
||||
|
||||
AnimatedOffset(
|
||||
modifier = Modifier.padding(horizontal = MaterialTheme.bibLib.dimen.dp16),
|
||||
) {
|
||||
CredentialRemember(
|
||||
value = rememberPassword,
|
||||
onValueChange = onRememberPasswordChange,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.bibLib.dimen.dp16))
|
||||
|
||||
AnimatedOffset(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = MaterialTheme.bibLib.dimen.dp16)
|
||||
.align(Alignment.End),
|
||||
) {
|
||||
Row {
|
||||
Button(
|
||||
colors = ButtonDefaults.outlinedButtonColors(),
|
||||
onClick = onRegister,
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.action_register))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(MaterialTheme.bibLib.dimen.dp8))
|
||||
|
||||
Button(
|
||||
colors = ButtonDefaults.buttonColors(),
|
||||
onClick = onSignIn,
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.action_login))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(2f))
|
||||
|
||||
AnimatedOffset {
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.padding(all = MaterialTheme.bibLib.dimen.dp16)
|
||||
.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(),
|
||||
onClick = onGoogleSignIn,
|
||||
) {
|
||||
Text(text = googleStringResource())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//////////////////////////////////////
|
||||
// region: Authentication Handlers
|
||||
|
||||
@Composable
|
||||
fun AuthenticationHandler(
|
||||
viewModel: AuthenticationViewModel = hiltViewModel(),
|
||||
onDismissRequest: (StateUio<Unit>) -> Unit = {},
|
||||
onSuccess: () -> Unit = { },
|
||||
onFailure: (AuthenticationErrorUio) -> Unit = { },
|
||||
onSuccess: (AuthenticationUio.Done) -> Unit = { },
|
||||
) {
|
||||
viewModel.PrepareLoginWithGoogle()
|
||||
|
||||
val state by viewModel.authenticationProcess
|
||||
StateUioHandler(
|
||||
state = state,
|
||||
onDismissRequest = onDismissRequest,
|
||||
onSuccess = onSuccess,
|
||||
)
|
||||
}
|
||||
|
||||
//////////////////////////////////////
|
||||
// region: Content Helper Composable
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun LoginField(
|
||||
modifier: Modifier = Modifier,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
keyboardActions: KeyboardActions = KeyboardActions(),
|
||||
) {
|
||||
TextField(
|
||||
modifier = modifier.autofill(
|
||||
autofillTypes = listOf(AutofillType.Username, AutofillType.EmailAddress),
|
||||
onFill = onValueChange,
|
||||
),
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(text = stringResource(id = R.string.authentication_login)) },
|
||||
colors = TextFieldDefaults.outlinedTextFieldColors(),
|
||||
maxLines = 1,
|
||||
singleLine = true,
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun PasswordField(
|
||||
modifier: Modifier = Modifier,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
keyboardActions: KeyboardActions = KeyboardActions(),
|
||||
) {
|
||||
var passwordVisibility by remember { mutableStateOf(false) }
|
||||
TextField(
|
||||
modifier = modifier.autofill(
|
||||
autofillTypes = listOf(AutofillType.Password),
|
||||
onFill = onValueChange,
|
||||
),
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(text = stringResource(id = R.string.authentication_password)) },
|
||||
colors = TextFieldDefaults.outlinedTextFieldColors(),
|
||||
maxLines = 1,
|
||||
singleLine = true,
|
||||
visualTransformation = if (passwordVisibility) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisibility = passwordVisibility.not() }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisibility) Icons.Sharp.VisibilityOff else Icons.Sharp.Visibility,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CredentialRemember(
|
||||
modifier: Modifier = Modifier,
|
||||
value: Boolean,
|
||||
onValueChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = { onValueChange(value.not()) }
|
||||
)
|
||||
) {
|
||||
Checkbox(
|
||||
modifier = Modifier.align(Alignment.CenterVertically),
|
||||
checked = value,
|
||||
onCheckedChange = null
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(MaterialTheme.bibLib.dimen.dp8))
|
||||
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.CenterVertically),
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
text = stringResource(id = R.string.authentication_credential_remember)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
//////////////////////////////////////
|
||||
// region: Navigation Helper
|
||||
|
||||
private fun NavHostController.navigateToRegister() {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(IBibLibClient.REGISTER_URL)))
|
||||
}
|
||||
|
||||
// endregion
|
||||
//////////////////////////////////////
|
||||
// region: AnnotatedString
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
fun googleStringResource(): AnnotatedString = buildAnnotatedString {
|
||||
val default = LocalTextStyle.current.toSpanStyle()
|
||||
withStyle(
|
||||
style = default
|
||||
) {
|
||||
append(stringResource(id = R.string.action_google_sign_in))
|
||||
append(" ")
|
||||
}
|
||||
withStyle(
|
||||
style = default.copy(color = GoogleColorPalette.blue, fontWeight = FontWeight.ExtraBold),
|
||||
) {
|
||||
append("G")
|
||||
}
|
||||
withStyle(
|
||||
style = default.copy(color = GoogleColorPalette.red, fontWeight = FontWeight.ExtraBold),
|
||||
) {
|
||||
append("o")
|
||||
}
|
||||
withStyle(
|
||||
style = default.copy(color = GoogleColorPalette.yellow, fontWeight = FontWeight.ExtraBold),
|
||||
) {
|
||||
append("o")
|
||||
}
|
||||
withStyle(
|
||||
style = default.copy(color = GoogleColorPalette.blue, fontWeight = FontWeight.ExtraBold),
|
||||
) {
|
||||
append("g")
|
||||
}
|
||||
withStyle(
|
||||
style = default.copy(color = GoogleColorPalette.green, fontWeight = FontWeight.ExtraBold),
|
||||
) {
|
||||
append("l")
|
||||
}
|
||||
withStyle(
|
||||
style = default.copy(color = GoogleColorPalette.red, fontWeight = FontWeight.ExtraBold),
|
||||
) {
|
||||
append("e")
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun Modifier.autofill(
|
||||
autofillTypes: List<AutofillType>,
|
||||
onFill: ((String) -> Unit),
|
||||
) = composed {
|
||||
val autofill = LocalAutofill.current
|
||||
val autofillNode = AutofillNode(onFill = onFill, autofillTypes = autofillTypes)
|
||||
LocalAutofillTree.current += autofillNode
|
||||
|
||||
this
|
||||
.onGloballyPositioned {
|
||||
autofillNode.boundingBox = it.boundsInWindow()
|
||||
val state = viewModel.authenticationProcess
|
||||
LaunchedEffect(key1 = state.value) {
|
||||
when (val authentication = state.value) {
|
||||
is AuthenticationUio.Done -> onSuccess(authentication)
|
||||
else -> Unit
|
||||
}
|
||||
.onFocusChanged { focusState ->
|
||||
autofill?.run {
|
||||
if (focusState.isFocused) {
|
||||
requestAutofillForNode(autofillNode)
|
||||
} else {
|
||||
cancelAutofillForNode(autofillNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect("AuthenticationHandlerError") {
|
||||
viewModel.authenticationError.collect {
|
||||
onFailure(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
private fun AuthenticationScreenContentPreview() {
|
||||
BibLibTheme {
|
||||
AuthenticationScreenContent(
|
||||
login = "",
|
||||
onLoginChange = { },
|
||||
password = "",
|
||||
onPasswordChange = { },
|
||||
rememberPassword = true,
|
||||
onRememberPasswordChange = { },
|
||||
onGoogleSignIn = { },
|
||||
onSignIn = { },
|
||||
onRegister = { },
|
||||
)
|
||||
fun AuthenticationProgress(
|
||||
viewModel: AuthenticationViewModel = hiltViewModel(),
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = viewModel.isLoading,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(color = ShadowPalette.scrim),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(MaterialTheme.bibLib.dimen.dialog.iconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,308 @@
|
|||
package com.pixelized.biblib.ui.screen.authentication
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.sharp.Visibility
|
||||
import androidx.compose.material.icons.sharp.VisibilityOff
|
||||
import androidx.compose.runtime.*
|
||||
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.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.pixelized.biblib.R
|
||||
import com.pixelized.biblib.ui.composable.animation.AnimatedDelayer
|
||||
import com.pixelized.biblib.ui.composable.animation.AnimatedOffset
|
||||
import com.pixelized.biblib.ui.theme.BibLibTheme
|
||||
import com.pixelized.biblib.ui.theme.color.GoogleColorPalette
|
||||
import com.pixelized.biblib.utils.extention.autofill
|
||||
import com.pixelized.biblib.utils.extention.bibLib
|
||||
|
||||
@Composable
|
||||
fun AuthenticationScreenContent(
|
||||
modifier: Modifier = Modifier,
|
||||
scrollState: ScrollState = rememberScrollState(),
|
||||
login: String,
|
||||
onLoginChange: (String) -> Unit,
|
||||
password: String,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
rememberPassword: Boolean,
|
||||
onRememberPasswordChange: (Boolean) -> Unit,
|
||||
onGoogleSignIn: () -> Unit,
|
||||
onSignIn: () -> Unit,
|
||||
onRegister: () -> Unit,
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
AnimatedDelayer(targetState = "AuthenticationScreenContent") {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
AnimatedOffset(
|
||||
modifier = Modifier
|
||||
.padding(all = MaterialTheme.bibLib.dimen.dp16)
|
||||
.align(alignment = Alignment.CenterHorizontally),
|
||||
) {
|
||||
Text(
|
||||
style = MaterialTheme.typography.h4,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
text = stringResource(id = R.string.authentication_title),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
AnimatedOffset(
|
||||
modifier = Modifier.padding(horizontal = MaterialTheme.bibLib.dimen.dp16),
|
||||
) {
|
||||
LoginField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = login,
|
||||
onValueChange = onLoginChange,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.bibLib.dimen.dp8))
|
||||
|
||||
AnimatedOffset(
|
||||
modifier = Modifier.padding(horizontal = MaterialTheme.bibLib.dimen.dp16),
|
||||
) {
|
||||
PasswordField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = password,
|
||||
onValueChange = onPasswordChange,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions { focusManager.clearFocus() },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.bibLib.dimen.dp16))
|
||||
|
||||
AnimatedOffset(
|
||||
modifier = Modifier.padding(horizontal = MaterialTheme.bibLib.dimen.dp16),
|
||||
) {
|
||||
CredentialRemember(
|
||||
value = rememberPassword,
|
||||
onValueChange = onRememberPasswordChange,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.bibLib.dimen.dp16))
|
||||
|
||||
AnimatedOffset(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = MaterialTheme.bibLib.dimen.dp16)
|
||||
.align(Alignment.End),
|
||||
) {
|
||||
Row {
|
||||
Button(
|
||||
colors = ButtonDefaults.outlinedButtonColors(),
|
||||
onClick = onRegister,
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.action_register))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(MaterialTheme.bibLib.dimen.dp8))
|
||||
|
||||
Button(
|
||||
colors = ButtonDefaults.buttonColors(),
|
||||
onClick = onSignIn,
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.action_login))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(2f))
|
||||
|
||||
AnimatedOffset {
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.padding(all = MaterialTheme.bibLib.dimen.dp16)
|
||||
.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(),
|
||||
onClick = onGoogleSignIn,
|
||||
) {
|
||||
Text(text = googleStringResource())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////
|
||||
// region: Content Helper Composable
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun LoginField(
|
||||
modifier: Modifier = Modifier,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
keyboardActions: KeyboardActions = KeyboardActions(),
|
||||
) {
|
||||
TextField(
|
||||
modifier = modifier.autofill(
|
||||
autofillTypes = listOf(AutofillType.Username, AutofillType.EmailAddress),
|
||||
onFill = onValueChange,
|
||||
),
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(text = stringResource(id = R.string.authentication_login)) },
|
||||
colors = TextFieldDefaults.outlinedTextFieldColors(),
|
||||
maxLines = 1,
|
||||
singleLine = true,
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun PasswordField(
|
||||
modifier: Modifier = Modifier,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
keyboardActions: KeyboardActions = KeyboardActions(),
|
||||
) {
|
||||
var passwordVisibility by remember { mutableStateOf(false) }
|
||||
TextField(
|
||||
modifier = modifier.autofill(
|
||||
autofillTypes = listOf(AutofillType.Password),
|
||||
onFill = onValueChange,
|
||||
),
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(text = stringResource(id = R.string.authentication_password)) },
|
||||
colors = TextFieldDefaults.outlinedTextFieldColors(),
|
||||
maxLines = 1,
|
||||
singleLine = true,
|
||||
visualTransformation = if (passwordVisibility) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisibility = passwordVisibility.not() }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisibility) Icons.Sharp.VisibilityOff else Icons.Sharp.Visibility,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CredentialRemember(
|
||||
modifier: Modifier = Modifier,
|
||||
value: Boolean,
|
||||
onValueChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = { onValueChange(value.not()) }
|
||||
)
|
||||
) {
|
||||
Checkbox(
|
||||
modifier = Modifier.align(Alignment.CenterVertically),
|
||||
checked = value,
|
||||
onCheckedChange = null
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(MaterialTheme.bibLib.dimen.dp8))
|
||||
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.CenterVertically),
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
text = stringResource(id = R.string.authentication_credential_remember)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
private fun googleStringResource(): AnnotatedString = buildAnnotatedString {
|
||||
val default = LocalTextStyle.current.toSpanStyle()
|
||||
withStyle(
|
||||
style = default
|
||||
) {
|
||||
append(stringResource(id = R.string.action_google_sign_in))
|
||||
append(" ")
|
||||
}
|
||||
withStyle(
|
||||
style = default.copy(color = GoogleColorPalette.blue, fontWeight = FontWeight.ExtraBold),
|
||||
) {
|
||||
append("G")
|
||||
}
|
||||
withStyle(
|
||||
style = default.copy(color = GoogleColorPalette.red, fontWeight = FontWeight.ExtraBold),
|
||||
) {
|
||||
append("o")
|
||||
}
|
||||
withStyle(
|
||||
style = default.copy(color = GoogleColorPalette.yellow, fontWeight = FontWeight.ExtraBold),
|
||||
) {
|
||||
append("o")
|
||||
}
|
||||
withStyle(
|
||||
style = default.copy(color = GoogleColorPalette.blue, fontWeight = FontWeight.ExtraBold),
|
||||
) {
|
||||
append("g")
|
||||
}
|
||||
withStyle(
|
||||
style = default.copy(color = GoogleColorPalette.green, fontWeight = FontWeight.ExtraBold),
|
||||
) {
|
||||
append("l")
|
||||
}
|
||||
withStyle(
|
||||
style = default.copy(color = GoogleColorPalette.red, fontWeight = FontWeight.ExtraBold),
|
||||
) {
|
||||
append("e")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
private fun AuthenticationScreenContentPreview() {
|
||||
BibLibTheme {
|
||||
AuthenticationScreenContent(
|
||||
login = "",
|
||||
onLoginChange = { },
|
||||
password = "",
|
||||
onPasswordChange = { },
|
||||
rememberPassword = true,
|
||||
onRememberPasswordChange = { },
|
||||
onGoogleSignIn = { },
|
||||
onSignIn = { },
|
||||
onRegister = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +1,30 @@
|
|||
package com.pixelized.biblib.ui.screen.authentication.viewModel
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignIn
|
||||
import com.google.android.gms.common.api.ApiException
|
||||
import com.pixelized.biblib.R
|
||||
import com.pixelized.biblib.network.client.IBibLibClient
|
||||
import com.pixelized.biblib.network.data.query.AuthLoginQuery
|
||||
import com.pixelized.biblib.repository.apiCache.IAPICacheRepository
|
||||
import com.pixelized.biblib.repository.book.IBookRepository
|
||||
import com.pixelized.biblib.repository.book.updateBooks
|
||||
import com.pixelized.biblib.repository.credential.ICredentialRepository
|
||||
import com.pixelized.biblib.repository.googleSignIn.IGoogleSingInRepository
|
||||
import com.pixelized.biblib.ui.composable.StateUio
|
||||
import com.pixelized.biblib.ui.screen.authentication.AuthenticationErrorUio
|
||||
import com.pixelized.biblib.ui.screen.authentication.AuthenticationUio
|
||||
import com.pixelized.biblib.utils.exception.MissingGoogleTokenException
|
||||
import com.pixelized.biblib.utils.exception.MissingTokenException
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -33,14 +33,19 @@ class AuthenticationViewModel @Inject constructor(
|
|||
private val credentialRepository: ICredentialRepository,
|
||||
private val googleSignIn: IGoogleSingInRepository,
|
||||
private val client: IBibLibClient,
|
||||
private val bookRepository: IBookRepository,
|
||||
private val apiCache: IAPICacheRepository,
|
||||
) : ViewModel() {
|
||||
private var launcher: ActivityResultLauncher<Intent>? = null
|
||||
|
||||
private var authenticationJob: Job? = null
|
||||
private val _authenticationProcess = mutableStateOf<StateUio<Unit>?>(null)
|
||||
val authenticationProcess: State<StateUio<Unit>?> get() = _authenticationProcess
|
||||
|
||||
private val _authenticationError = MutableSharedFlow<AuthenticationErrorUio>()
|
||||
val authenticationError: Flow<AuthenticationErrorUio> get() = _authenticationError
|
||||
|
||||
private val _authenticationProcess = mutableStateOf<AuthenticationUio?>(null)
|
||||
val authenticationProcess: State<AuthenticationUio?> get() = _authenticationProcess
|
||||
|
||||
val isLoading: Boolean by derivedStateOf {
|
||||
authenticationProcess.value is AuthenticationUio.Progress
|
||||
}
|
||||
|
||||
//////////////////////////////////////
|
||||
// region: Login with BibLibClient
|
||||
|
|
@ -48,18 +53,23 @@ class AuthenticationViewModel @Inject constructor(
|
|||
fun login(login: String, password: String) {
|
||||
authenticationJob?.cancel()
|
||||
authenticationJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
val query = AuthLoginQuery(username = login, password = password)
|
||||
_authenticationProcess.value = StateUio.Progress()
|
||||
_authenticationProcess.value = AuthenticationUio.Progress
|
||||
try {
|
||||
val query = AuthLoginQuery(username = login, password = password)
|
||||
val response = client.service.login(query)
|
||||
val idToken = response.token ?: throw MissingTokenException()
|
||||
client.token = idToken
|
||||
credentialRepository.bearer = response.token
|
||||
updateBooks() // update book if needed
|
||||
_authenticationProcess.value = StateUio.Success(Unit)
|
||||
_authenticationProcess.value = AuthenticationUio.Done
|
||||
} catch (exception: Exception) {
|
||||
Log.e("AuthenticationViewModel", exception.message, exception)
|
||||
_authenticationProcess.value = StateUio.Failure(exception)
|
||||
_authenticationProcess.value = null
|
||||
_authenticationError.emit(
|
||||
AuthenticationErrorUio.Login(
|
||||
message = R.string.error_authentication_message,
|
||||
action = R.string.error_authentication_action,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -73,47 +83,37 @@ class AuthenticationViewModel @Inject constructor(
|
|||
launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
authenticationJob?.cancel()
|
||||
authenticationJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val task = GoogleSignIn.getSignedInAccountFromIntent(it.data)
|
||||
val account = task.getResult(ApiException::class.java)
|
||||
val googleToken = account?.idToken ?: throw MissingGoogleTokenException()
|
||||
val response = client.service.loginWithGoogle(token = googleToken)
|
||||
val token = response.token ?: throw MissingTokenException()
|
||||
client.token = token
|
||||
updateBooks() // update book if needed
|
||||
_authenticationProcess.value = StateUio.Success(Unit)
|
||||
} catch (exception: Exception) {
|
||||
Log.e("AuthenticationViewModel", exception.message, exception)
|
||||
_authenticationProcess.value = StateUio.Failure(exception)
|
||||
if (it.resultCode == Activity.RESULT_OK) {
|
||||
authenticationJob?.cancel()
|
||||
authenticationJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
_authenticationProcess.value = AuthenticationUio.Progress
|
||||
try {
|
||||
val task = GoogleSignIn.getSignedInAccountFromIntent(it.data)
|
||||
val account = task.getResult(ApiException::class.java)
|
||||
val googleToken = account?.idToken ?: throw MissingGoogleTokenException()
|
||||
val response = client.service.loginWithGoogle(token = googleToken)
|
||||
val token = response.token ?: throw MissingTokenException()
|
||||
client.token = token
|
||||
_authenticationProcess.value = AuthenticationUio.Done
|
||||
} catch (exception: Exception) {
|
||||
Log.e("AuthenticationViewModel", exception.message, exception)
|
||||
_authenticationProcess.value = null
|
||||
_authenticationError.emit(
|
||||
AuthenticationErrorUio.GoogleLogin(
|
||||
message = R.string.error_authentication_message,
|
||||
action = R.string.error_authentication_action,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loginWithGoogle() {
|
||||
_authenticationProcess.value = StateUio.Progress()
|
||||
launcher?.launch(googleSignIn.client.signInIntent)
|
||||
}
|
||||
|
||||
// endregion
|
||||
//////////////////////////////////////
|
||||
// region: Books update
|
||||
|
||||
private suspend fun updateBooks() = updateBooks(
|
||||
client = client,
|
||||
cache = apiCache,
|
||||
repository = bookRepository,
|
||||
)
|
||||
|
||||
// endregion
|
||||
//////////////////////////////////////
|
||||
// region: Dialog
|
||||
|
||||
fun dismissError() {
|
||||
_authenticationProcess.value = null
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.pixelized.biblib.ui.screen.home.detail
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
package com.pixelized.biblib.utils.extention
|
||||
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.autofill.AutofillNode
|
||||
import androidx.compose.ui.autofill.AutofillType
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalAutofill
|
||||
import androidx.compose.ui.platform.LocalAutofillTree
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun Modifier.autofill(
|
||||
autofillTypes: List<AutofillType>,
|
||||
onFill: ((String) -> Unit),
|
||||
) = composed {
|
||||
val autofill = LocalAutofill.current
|
||||
val autofillTree = LocalAutofillTree.current
|
||||
val autofillNode = remember {
|
||||
AutofillNode(
|
||||
onFill = onFill,
|
||||
autofillTypes = autofillTypes
|
||||
).also {
|
||||
autofillTree += it
|
||||
}
|
||||
}
|
||||
|
||||
return@composed this
|
||||
.onGloballyPositioned {
|
||||
autofillNode.boundingBox = it.boundsInWindow()
|
||||
}
|
||||
.onFocusChanged { focusState ->
|
||||
when (focusState.isFocused) {
|
||||
true -> autofill?.requestAutofillForNode(autofillNode)
|
||||
else -> autofill?.cancelAutofillForNode(autofillNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,10 +17,9 @@
|
|||
<!-- Error -->
|
||||
|
||||
<string name="error_action_retry">Réessayer</string>
|
||||
<string name="error_authentication_message">L\'authentification à BibLib à échouée.</string>
|
||||
<string name="error_get_book_detail_message">La récupération des détails a échouée.</string>
|
||||
<string name="error_get_book_detail_action">@string/error_action_retry</string>
|
||||
<string name="error_send_book_message">L\'envoi de l\'eBook a échoué.</string>
|
||||
<string name="error_send_book_action">@string/error_action_retry</string>
|
||||
|
||||
<!-- Dialogs -->
|
||||
|
||||
|
|
|
|||
|
|
@ -23,10 +23,12 @@
|
|||
<!-- Error -->
|
||||
|
||||
<string name="error_action_retry">Retry</string>
|
||||
<string name="error_authentication_message">Failed to connect to BibLib</string>
|
||||
<string name="error_authentication_action" translatable="false">@string/error_action_retry</string>
|
||||
<string name="error_get_book_detail_message">Failed to retrieve book details.</string>
|
||||
<string name="error_get_book_detail_action">@string/error_action_retry</string>
|
||||
<string name="error_get_book_detail_action" translatable="false">@string/error_action_retry</string>
|
||||
<string name="error_send_book_message">Failed to send the book to your kindle.</string>
|
||||
<string name="error_send_book_action">@string/error_action_retry</string>
|
||||
<string name="error_send_book_action" translatable="false">@string/error_action_retry</string>
|
||||
|
||||
<!-- Dialogs -->
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue