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
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(

View file

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

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

View file

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

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 -->
<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 -->

View file

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