From 537cf214a93a40aba9340309ce0fac70784d16b1 Mon Sep 17 00:00:00 2001 From: Thomas Andres Gomez Date: Fri, 21 Oct 2022 15:46:07 +0200 Subject: [PATCH] Authentication error management like a boos ! --- .../com/pixelized/biblib/ui/MainContent.kt | 6 +- .../authentication/AuthenticationScreen.kt | 469 ++++-------------- .../AuthenticationScreenContent.kt | 308 ++++++++++++ .../viewModel/AuthenticationViewModel.kt | 98 ++-- .../ui/screen/home/detail/DetailScreen.kt | 1 + .../biblib/utils/extention/ModifierEx.kt | 41 ++ app/src/main/res/values-fr/strings.xml | 3 +- app/src/main/res/values/strings.xml | 6 +- 8 files changed, 518 insertions(+), 414 deletions(-) create mode 100644 app/src/main/java/com/pixelized/biblib/ui/screen/authentication/AuthenticationScreenContent.kt create mode 100644 app/src/main/java/com/pixelized/biblib/utils/extention/ModifierEx.kt diff --git a/app/src/main/java/com/pixelized/biblib/ui/MainContent.kt b/app/src/main/java/com/pixelized/biblib/ui/MainContent.kt index 3246866..17efc3c 100644 --- a/app/src/main/java/com/pixelized/biblib/ui/MainContent.kt +++ b/app/src/main/java/com/pixelized/biblib/ui/MainContent.kt @@ -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 { @@ -33,7 +33,9 @@ fun MainContent( scaffoldState = scaffoldState, snackbarHost = { snackHostState -> SnackbarHost( - modifier = Modifier.systemBarsPadding(), + modifier = Modifier + .systemBarsPadding() + .imePadding(), hostState = snackHostState, ) { snackBarData -> Snackbar( diff --git a/app/src/main/java/com/pixelized/biblib/ui/screen/authentication/AuthenticationScreen.kt b/app/src/main/java/com/pixelized/biblib/ui/screen/authentication/AuthenticationScreen.kt index a293e36..a5b821e 100644 --- a/app/src/main/java/com/pixelized/biblib/ui/screen/authentication/AuthenticationScreen.kt +++ b/app/src/main/java/com/pixelized/biblib/ui/screen/authentication/AuthenticationScreen.kt @@ -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 = {}, - 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, - 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), + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/biblib/ui/screen/authentication/AuthenticationScreenContent.kt b/app/src/main/java/com/pixelized/biblib/ui/screen/authentication/AuthenticationScreenContent.kt new file mode 100644 index 0000000..fc8de20 --- /dev/null +++ b/app/src/main/java/com/pixelized/biblib/ui/screen/authentication/AuthenticationScreenContent.kt @@ -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 = { }, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/biblib/ui/screen/authentication/viewModel/AuthenticationViewModel.kt b/app/src/main/java/com/pixelized/biblib/ui/screen/authentication/viewModel/AuthenticationViewModel.kt index 159767a..91b33b7 100644 --- a/app/src/main/java/com/pixelized/biblib/ui/screen/authentication/viewModel/AuthenticationViewModel.kt +++ b/app/src/main/java/com/pixelized/biblib/ui/screen/authentication/viewModel/AuthenticationViewModel.kt @@ -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? = null - private var authenticationJob: Job? = null - private val _authenticationProcess = mutableStateOf?>(null) - val authenticationProcess: State?> get() = _authenticationProcess + + private val _authenticationError = MutableSharedFlow() + val authenticationError: Flow get() = _authenticationError + + private val _authenticationProcess = mutableStateOf(null) + val authenticationProcess: State 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 } \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/biblib/ui/screen/home/detail/DetailScreen.kt b/app/src/main/java/com/pixelized/biblib/ui/screen/home/detail/DetailScreen.kt index 99d2de2..7f816b8 100644 --- a/app/src/main/java/com/pixelized/biblib/ui/screen/home/detail/DetailScreen.kt +++ b/app/src/main/java/com/pixelized/biblib/ui/screen/home/detail/DetailScreen.kt @@ -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 diff --git a/app/src/main/java/com/pixelized/biblib/utils/extention/ModifierEx.kt b/app/src/main/java/com/pixelized/biblib/utils/extention/ModifierEx.kt new file mode 100644 index 0000000..e2545f1 --- /dev/null +++ b/app/src/main/java/com/pixelized/biblib/utils/extention/ModifierEx.kt @@ -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, + 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b00a13f..c1640f2 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -17,10 +17,9 @@ Réessayer + L\'authentification à BibLib à échouée. La récupération des détails a échouée. - @string/error_action_retry L\'envoi de l\'eBook a échoué. - @string/error_action_retry diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b26a567..1d45f98 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,10 +23,12 @@ Retry + Failed to connect to BibLib + @string/error_action_retry Failed to retrieve book details. - @string/error_action_retry + @string/error_action_retry Failed to send the book to your kindle. - @string/error_action_retry + @string/error_action_retry