Authentication error management like a boos !

This commit is contained in:
Thomas Andres Gomez 2022-10-21 15:46:07 +02:00
parent f48dfd6488
commit 537cf214a9
8 changed files with 518 additions and 414 deletions

View file

@ -1,5 +1,6 @@
package com.pixelized.biblib.ui package com.pixelized.biblib.ui
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -11,7 +12,6 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import com.pixelized.biblib.ui.navigation.ScreenNavHost import com.pixelized.biblib.ui.navigation.ScreenNavHost
import com.pixelized.biblib.ui.screen.launch.LauncherViewModel import com.pixelized.biblib.ui.screen.launch.LauncherViewModel
import com.pixelized.biblib.utils.extention.bibLib
import com.skydoves.landscapist.glide.LocalGlideRequestOptions import com.skydoves.landscapist.glide.LocalGlideRequestOptions
val LocalSnackHostState = staticCompositionLocalOf<SnackbarHostState> { val LocalSnackHostState = staticCompositionLocalOf<SnackbarHostState> {
@ -33,7 +33,9 @@ fun MainContent(
scaffoldState = scaffoldState, scaffoldState = scaffoldState,
snackbarHost = { snackHostState -> snackbarHost = { snackHostState ->
SnackbarHost( SnackbarHost(
modifier = Modifier.systemBarsPadding(), modifier = Modifier
.systemBarsPadding()
.imePadding(),
hostState = snackHostState, hostState = snackHostState,
) { snackBarData -> ) { snackBarData ->
Snackbar( Snackbar(

View file

@ -1,56 +1,65 @@
package com.pixelized.biblib.ui.screen.authentication package com.pixelized.biblib.ui.screen.authentication
import android.content.Intent import androidx.annotation.StringRes
import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility
import android.net.Uri import androidx.compose.animation.fadeIn
import androidx.compose.foundation.clickable import androidx.compose.animation.fadeOut
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.verticalScroll import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.* import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons import androidx.compose.material.SnackbarResult
import androidx.compose.material.icons.sharp.Visibility
import androidx.compose.material.icons.sharp.VisibilityOff
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillNode import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.autofill.AutofillType import androidx.compose.ui.platform.LocalUriHandler
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.hilt.navigation.compose.hiltViewModel 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.network.client.IBibLibClient
import com.pixelized.biblib.ui.composable.StateUio import com.pixelized.biblib.ui.LocalSnackHostState
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.navigation.LocalScreenNavHostController import com.pixelized.biblib.ui.navigation.LocalScreenNavHostController
import com.pixelized.biblib.ui.navigation.navigateToHome import com.pixelized.biblib.ui.navigation.navigateToHome
import com.pixelized.biblib.ui.screen.authentication.viewModel.AuthenticationFormViewModel import com.pixelized.biblib.ui.screen.authentication.viewModel.AuthenticationFormViewModel
import com.pixelized.biblib.ui.screen.authentication.viewModel.AuthenticationViewModel import com.pixelized.biblib.ui.screen.authentication.viewModel.AuthenticationViewModel
import com.pixelized.biblib.ui.theme.BibLibTheme import com.pixelized.biblib.ui.theme.color.ShadowPalette
import com.pixelized.biblib.ui.theme.color.GoogleColorPalette
import com.pixelized.biblib.utils.extention.bibLib 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 @Composable
fun AuthenticationScreen( fun AuthenticationScreen(
@ -58,6 +67,12 @@ fun AuthenticationScreen(
formViewModel: AuthenticationFormViewModel = hiltViewModel(), formViewModel: AuthenticationFormViewModel = hiltViewModel(),
) { ) {
val navHostController = LocalScreenNavHostController.current val navHostController = LocalScreenNavHostController.current
val snackBarHostState = LocalSnackHostState.current
val uriHandler = LocalUriHandler.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
AuthenticationScreenContent( AuthenticationScreenContent(
modifier = Modifier.systemBarsPadding(), modifier = Modifier.systemBarsPadding(),
@ -83,345 +98,81 @@ fun AuthenticationScreen(
) )
}, },
onRegister = { onRegister = {
navHostController.navigateToRegister() uriHandler.openUri(uri = IBibLibClient.REGISTER_URL)
}, },
) )
AuthenticationHandler( AuthenticationHandler(
onDismissRequest = { onFailure = {
if (it is StateUio.Failure) authenticationViewModel.dismissError() 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 = { onSuccess = {
navHostController.navigateToHome() 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 @Composable
fun AuthenticationHandler( fun AuthenticationHandler(
viewModel: AuthenticationViewModel = hiltViewModel(), viewModel: AuthenticationViewModel = hiltViewModel(),
onDismissRequest: (StateUio<Unit>) -> Unit = {}, onFailure: (AuthenticationErrorUio) -> Unit = { },
onSuccess: () -> Unit = { }, onSuccess: (AuthenticationUio.Done) -> Unit = { },
) { ) {
viewModel.PrepareLoginWithGoogle() viewModel.PrepareLoginWithGoogle()
val state by viewModel.authenticationProcess val state = viewModel.authenticationProcess
StateUioHandler( LaunchedEffect(key1 = state.value) {
state = state, when (val authentication = state.value) {
onDismissRequest = onDismissRequest, is AuthenticationUio.Done -> onSuccess(authentication)
onSuccess = onSuccess, else -> Unit
)
}
//////////////////////////////////////
// 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()
} }
.onFocusChanged { focusState -> }
autofill?.run {
if (focusState.isFocused) { LaunchedEffect("AuthenticationHandlerError") {
requestAutofillForNode(autofillNode) viewModel.authenticationError.collect {
} else { onFailure(it)
cancelAutofillForNode(autofillNode)
}
}
} }
}
} }
@Composable @Composable
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) fun AuthenticationProgress(
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) viewModel: AuthenticationViewModel = hiltViewModel(),
private fun AuthenticationScreenContentPreview() { ) {
BibLibTheme { AnimatedVisibility(
AuthenticationScreenContent( visible = viewModel.isLoading,
login = "", enter = fadeIn(),
onLoginChange = { }, exit = fadeOut(),
password = "", ) {
onPasswordChange = { }, Box(
rememberPassword = true, modifier = Modifier
onRememberPasswordChange = { }, .fillMaxSize()
onGoogleSignIn = { }, .background(color = ShadowPalette.scrim),
onSignIn = { }, contentAlignment = Alignment.Center,
onRegister = { }, ) {
) CircularProgressIndicator(
modifier = Modifier.size(MaterialTheme.bibLib.dimen.dialog.iconSize),
)
}
} }
} }

View file

@ -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 = { },
)
}
}

View file

@ -1,30 +1,30 @@
package com.pixelized.biblib.ui.screen.authentication.viewModel package com.pixelized.biblib.ui.screen.authentication.viewModel
import android.app.Activity
import android.content.Intent import android.content.Intent
import android.util.Log import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.android.gms.auth.api.signin.GoogleSignIn import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.common.api.ApiException 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.client.IBibLibClient
import com.pixelized.biblib.network.data.query.AuthLoginQuery 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.credential.ICredentialRepository
import com.pixelized.biblib.repository.googleSignIn.IGoogleSingInRepository 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.MissingGoogleTokenException
import com.pixelized.biblib.utils.exception.MissingTokenException import com.pixelized.biblib.utils.exception.MissingTokenException
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -33,14 +33,19 @@ class AuthenticationViewModel @Inject constructor(
private val credentialRepository: ICredentialRepository, private val credentialRepository: ICredentialRepository,
private val googleSignIn: IGoogleSingInRepository, private val googleSignIn: IGoogleSingInRepository,
private val client: IBibLibClient, private val client: IBibLibClient,
private val bookRepository: IBookRepository,
private val apiCache: IAPICacheRepository,
) : ViewModel() { ) : ViewModel() {
private var launcher: ActivityResultLauncher<Intent>? = null private var launcher: ActivityResultLauncher<Intent>? = null
private var authenticationJob: Job? = 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 // region: Login with BibLibClient
@ -48,18 +53,23 @@ class AuthenticationViewModel @Inject constructor(
fun login(login: String, password: String) { fun login(login: String, password: String) {
authenticationJob?.cancel() authenticationJob?.cancel()
authenticationJob = viewModelScope.launch(Dispatchers.IO) { authenticationJob = viewModelScope.launch(Dispatchers.IO) {
val query = AuthLoginQuery(username = login, password = password) _authenticationProcess.value = AuthenticationUio.Progress
_authenticationProcess.value = StateUio.Progress()
try { try {
val query = AuthLoginQuery(username = login, password = password)
val response = client.service.login(query) val response = client.service.login(query)
val idToken = response.token ?: throw MissingTokenException() val idToken = response.token ?: throw MissingTokenException()
client.token = idToken client.token = idToken
credentialRepository.bearer = response.token credentialRepository.bearer = response.token
updateBooks() // update book if needed _authenticationProcess.value = AuthenticationUio.Done
_authenticationProcess.value = StateUio.Success(Unit)
} catch (exception: Exception) { } catch (exception: Exception) {
Log.e("AuthenticationViewModel", exception.message, 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( launcher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult() ActivityResultContracts.StartActivityForResult()
) { ) {
authenticationJob?.cancel() if (it.resultCode == Activity.RESULT_OK) {
authenticationJob = viewModelScope.launch(Dispatchers.IO) { authenticationJob?.cancel()
try { authenticationJob = viewModelScope.launch(Dispatchers.IO) {
val task = GoogleSignIn.getSignedInAccountFromIntent(it.data) _authenticationProcess.value = AuthenticationUio.Progress
val account = task.getResult(ApiException::class.java) try {
val googleToken = account?.idToken ?: throw MissingGoogleTokenException() val task = GoogleSignIn.getSignedInAccountFromIntent(it.data)
val response = client.service.loginWithGoogle(token = googleToken) val account = task.getResult(ApiException::class.java)
val token = response.token ?: throw MissingTokenException() val googleToken = account?.idToken ?: throw MissingGoogleTokenException()
client.token = token val response = client.service.loginWithGoogle(token = googleToken)
updateBooks() // update book if needed val token = response.token ?: throw MissingTokenException()
_authenticationProcess.value = StateUio.Success(Unit) client.token = token
} catch (exception: Exception) { _authenticationProcess.value = AuthenticationUio.Done
Log.e("AuthenticationViewModel", exception.message, exception) } catch (exception: Exception) {
_authenticationProcess.value = StateUio.Failure(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() { fun loginWithGoogle() {
_authenticationProcess.value = StateUio.Progress()
launcher?.launch(googleSignIn.client.signInIntent) launcher?.launch(googleSignIn.client.signInIntent)
} }
// endregion // endregion
////////////////////////////////////// //////////////////////////////////////
// region: Books update
private suspend fun updateBooks() = updateBooks(
client = client,
cache = apiCache,
repository = bookRepository,
)
// endregion
//////////////////////////////////////
// region: Dialog
fun dismissError() {
_authenticationProcess.value = null
}
// endregion
} }

View file

@ -1,5 +1,6 @@
package com.pixelized.biblib.ui.screen.home.detail package com.pixelized.biblib.ui.screen.home.detail
import android.content.Context
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState

View file

@ -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)
}
}
}

View file

@ -17,10 +17,9 @@
<!-- Error --> <!-- Error -->
<string name="error_action_retry">Réessayer</string> <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_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_message">L\'envoi de l\'eBook a échoué.</string>
<string name="error_send_book_action">@string/error_action_retry</string>
<!-- Dialogs --> <!-- Dialogs -->

View file

@ -23,10 +23,12 @@
<!-- Error --> <!-- Error -->
<string name="error_action_retry">Retry</string> <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_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_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 --> <!-- Dialogs -->