Add loading and error dialogs for authentication.

This commit is contained in:
Thomas Andres Gomez 2021-05-08 21:07:17 +02:00
parent b07bfd45d3
commit 59d84963a9
11 changed files with 296 additions and 127 deletions

12
.idea/misc.xml generated
View file

@ -107,6 +107,18 @@
<entry key="../../../../../layout/compose-model-1620480106062.xml" value="0.13900501672240803" />
<entry key="../../../../../layout/compose-model-1620480615996.xml" value="0.25" />
<entry key="../../../../../layout/compose-model-1620480616087.xml" value="0.13900501672240803" />
<entry key="../../../../../layout/compose-model-1620481118331.xml" value="0.13900501672240803" />
<entry key="../../../../../layout/compose-model-1620481354267.xml" value="0.28125" />
<entry key="../../../../../layout/compose-model-1620482758442.xml" value="0.25" />
<entry key="../../../../../layout/compose-model-1620482758448.xml" value="0.1" />
<entry key="../../../../../layout/compose-model-1620494685438.xml" value="0.1" />
<entry key="../../../../../layout/compose-model-1620495359014.xml" value="0.13900501672240803" />
<entry key="../../../../../layout/compose-model-1620495397383.xml" value="0.13900501672240803" />
<entry key="../../../../../layout/compose-model-1620495568311.xml" value="0.5818181818181818" />
<entry key="../../../../../layout/compose-model-1620496473763.xml" value="0.5818181818181818" />
<entry key="../../../../../layout/compose-model-1620499743476.xml" value="0.5818181818181818" />
<entry key="../../../../../layout/compose-model-1620500516060.xml" value="0.5818181818181818" />
<entry key="../../../../../layout/compose-model-1620500563383.xml" value="0.5818181818181818" />
<entry key="app/src/main/res/drawable-v24/ic_launcher_foreground.xml" value="0.2898148148148148" />
<entry key="app/src/main/res/drawable/ic_baseline_local_library_24.xml" value="0.25462962962962965" />
<entry key="app/src/main/res/drawable/ic_google.xml" value="0.2962962962962963" />

View file

@ -22,7 +22,7 @@ class BibLibClient : IBibLibClient {
///////////////////////////////////
// region BibLib webservice Auth
override fun updateBearerToken(token: String?) {
override fun updateBearerToken(token: String) {
interceptor.token = token
}

View file

@ -4,7 +4,7 @@ interface IBibLibClient {
val service: IBibLibWebServiceAPI
fun updateBearerToken(token: String?)
fun updateBearerToken(token: String)
companion object {
const val BASE_URL = "https://bib.bibulle.fr"

View file

@ -1,100 +0,0 @@
package com.pixelized.biblib.ui.composable.items
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.Card
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.pixelized.biblib.R
import com.pixelized.biblib.ui.theme.BibLibTheme
@Preview
@Composable
fun WaitingComposableLightPreview() {
BibLibTheme(darkTheme = false) {
WaitingComposable(
visible = true,
message = stringResource(id = R.string.loading)
)
}
}
@Preview
@Composable
fun WaitingComposableDarkPreview() {
BibLibTheme(darkTheme = true) {
WaitingComposable(
visible = true,
message = stringResource(id = R.string.loading)
)
}
}
@Composable
fun WaitingComposable(
visible: Boolean,
modifier: Modifier = Modifier,
message: String? = null
) {
Crossfade(
modifier = modifier,
targetState = visible
) {
if (it) {
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.clickable { }
) {
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.alpha(.25f)
.background(Color.Black)
)
Card(
elevation = 8.dp,
modifier = Modifier.align(Alignment.Center)
) {
Column(
modifier = Modifier
.width(200.dp)
.padding(16.dp)
) {
CircularProgressIndicator(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(16.dp)
)
if (message?.isNotEmpty() == true) {
val typography = MaterialTheme.typography
Text(
modifier = Modifier.align(Alignment.CenterHorizontally),
style = typography.body1,
textAlign = TextAlign.Center,
text = message
)
}
}
}
}
} else {
Box {}
}
}
}

View file

@ -0,0 +1,82 @@
package com.pixelized.biblib.ui.composable.items.dialog
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.sharp.ErrorOutline
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.pixelized.biblib.R
import com.pixelized.biblib.ui.theme.BibLibTheme
import com.pixelized.biblib.utils.exception.NoBearerException
@Composable
fun ErrorCard(
modifier: Modifier = Modifier,
message: String = stringResource(id = R.string.error_generic),
exception: Exception? = null,
) {
Card(elevation = 8.dp, modifier = modifier) {
Column(
modifier = Modifier
.width(200.dp)
.padding(16.dp)
) {
Icon(
modifier = Modifier
.width(72.dp)
.height(72.dp)
.align(Alignment.CenterHorizontally)
.padding(16.dp),
tint = MaterialTheme.colors.error,
imageVector = Icons.Sharp.ErrorOutline,
contentDescription = "error"
)
val typography = MaterialTheme.typography
Text(
modifier = Modifier.align(Alignment.CenterHorizontally),
style = typography.body1,
textAlign = TextAlign.Center,
text = message
)
if (exception != null) {
Text(
modifier = Modifier
.padding(top = 8.dp)
.align(Alignment.CenterHorizontally),
style = typography.caption,
textAlign = TextAlign.Center,
text = exception::class.java.simpleName
)
}
}
}
}
@Preview
@Composable
private fun ErrorCardLightPreview() {
BibLibTheme(darkTheme = false) {
ErrorCard(exception = NoBearerException())
}
}
@Preview
@Composable
private fun ErrorCardDarkPreview() {
BibLibTheme(darkTheme = true) {
ErrorCard(exception = NoBearerException())
}
}

View file

@ -0,0 +1,68 @@
package com.pixelized.biblib.ui.composable.items.dialog
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.Card
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.pixelized.biblib.R
import com.pixelized.biblib.ui.theme.BibLibTheme
@Composable
fun LoadingCard(
modifier: Modifier = Modifier,
message: String? = null
) {
Card(elevation = 8.dp, modifier = modifier) {
Column(
modifier = Modifier
.width(200.dp)
.padding(16.dp)
) {
CircularProgressIndicator(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(16.dp)
)
if (message?.isNotEmpty() == true) {
val typography = MaterialTheme.typography
Text(
modifier = Modifier.align(Alignment.CenterHorizontally),
style = typography.body1,
textAlign = TextAlign.Center,
text = message
)
}
}
}
}
@Preview
@Composable
private fun LoadingCardLightPreview() {
BibLibTheme(darkTheme = false) {
LoadingCard(
message = stringResource(id = R.string.loading)
)
}
}
@Preview
@Composable
private fun LoadingCardDarkPreview() {
BibLibTheme(darkTheme = true) {
LoadingCard(
message = stringResource(id = R.string.loading)
)
}
}

View file

@ -0,0 +1,43 @@
package com.pixelized.biblib.ui.composable.items.dialog
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
@Composable
fun CrossFadeOverlay(
visible: Boolean,
content: @Composable BoxScope.() -> Unit
) {
Crossfade(targetState = visible) {
if (it) {
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.clickable { }
) {
// Transparent background.
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.alpha(.25f)
.background(Color.Black)
)
// Overlay content.
content()
}
} else {
Box {}
}
}
}

View file

@ -30,12 +30,15 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.pixelized.biblib.R
import com.pixelized.biblib.ui.composable.items.WaitingComposable
import com.pixelized.biblib.ui.composable.items.dialog.CrossFadeOverlay
import com.pixelized.biblib.ui.composable.items.dialog.ErrorCard
import com.pixelized.biblib.ui.composable.items.dialog.LoadingCard
import com.pixelized.biblib.ui.theme.BibLibTheme
import com.pixelized.biblib.ui.viewmodel.authentication.AuthenticationViewModel
import com.pixelized.biblib.ui.viewmodel.authentication.IAuthentication
import com.pixelized.biblib.ui.viewmodel.navigation.INavigation
import com.pixelized.biblib.ui.viewmodel.navigation.NavigationViewModel
import com.pixelized.biblib.utils.exception.NoBearerException
@Composable
@ -49,14 +52,15 @@ fun LoginScreenComposable(
.fillMaxHeight()
) {
authentication.PrepareLoginWithGoogle()
LoginScreenContentComposable(navigation, authentication)
LoginScreenWaitingComposable(authentication)
LoginScreenNavigationComposable(navigation, authentication)
LoginScreenContentComposable(authentication)
LoginScreenDialogComposable(authentication)
}
}
@Composable
private fun LoginScreenContentComposable(
navigation: INavigation,
authentication: IAuthentication,
) {
val typography = MaterialTheme.typography
@ -142,12 +146,40 @@ private fun LoginScreenContentComposable(
}
@Composable
private fun LoginScreenWaitingComposable(authentication: IAuthentication) {
private fun LoginScreenDialogComposable(
authentication: IAuthentication
) {
val state = authentication.state.observeAsState()
WaitingComposable(
visible = state.value is IAuthentication.State.Loading,
CrossFadeOverlay(
visible = (state.value is IAuthentication.State.Initial).not()
) {
when (val currentState = state.value) {
is IAuthentication.State.Error -> ErrorCard(
modifier = Modifier
.align(Alignment.Center)
.clickable { authentication.clearState() },
message = stringResource(id = R.string.error_generic),
exception = currentState.exception
)
is IAuthentication.State.Connect,
is IAuthentication.State.Loading -> LoadingCard(
modifier = Modifier.align(Alignment.Center),
message = stringResource(id = R.string.loading)
)
else -> Box {}
}
}
}
@Composable
private fun LoginScreenNavigationComposable(
navigation: INavigation,
authentication: IAuthentication
) {
val state = authentication.state.observeAsState()
if (state.value == IAuthentication.State.Connect) {
navigation.navigateTo(INavigation.Screen.MainScreen)
}
}
@Composable
@ -237,12 +269,24 @@ fun LoginScreenComposablePreview() {
}
}
@Preview
@Preview(name = "Loading")
@Composable
fun LoginScreenComposableWaitingPreview() {
BibLibTheme {
val navigationViewModel = INavigation.Mock()
val authenticationViewModel = IAuthentication.Mock(true)
val state = IAuthentication.State.Loading
val authenticationViewModel = IAuthentication.Mock(state)
LoginScreenComposable(navigationViewModel, authenticationViewModel)
}
}
@Preview(name = "Error")
@Composable
fun LoginScreenComposableErrorPreview() {
BibLibTheme {
val navigationViewModel = INavigation.Mock()
val state = IAuthentication.State.Error(NoBearerException())
val authenticationViewModel = IAuthentication.Mock(state)
LoginScreenComposable(navigationViewModel, authenticationViewModel)
}
}

View file

@ -1,7 +1,6 @@
package com.pixelized.biblib.ui.viewmodel.authentication
import android.content.Intent
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
@ -14,12 +13,12 @@ import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.common.api.ApiException
import com.pixelized.biblib.data.network.query.AuthLoginQuery
import com.pixelized.biblib.utils.injection.inject
import com.pixelized.biblib.network.client.IBibLibClient
import com.pixelized.biblib.repository.credential.ICredentialRepository
import com.pixelized.biblib.repository.googlesignin.IGoogleSingInRepository
import com.pixelized.biblib.ui.viewmodel.authentication.IAuthentication.State
import com.pixelized.biblib.utils.exception.MissingTokenException
import com.pixelized.biblib.utils.injection.inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -61,10 +60,13 @@ class AuthenticationViewModel : ViewModel(), IAuthentication {
}
override fun updateRememberCredential(rememberCredential: Boolean) {
_rememberCredential.postValue(rememberCredential)
viewModelScope.launch {
_rememberCredential.postValue(rememberCredential)
credentialRepository.rememberCredential = rememberCredential
if (rememberCredential.not()) {
if (rememberCredential) {
credentialRepository.login = login.value
credentialRepository.password = password.value
} else {
credentialRepository.login = null
credentialRepository.password = null
}
@ -75,24 +77,37 @@ class AuthenticationViewModel : ViewModel(), IAuthentication {
viewModelScope.launch {
_state.postValue(State.Loading)
delay(3000)
_state.postValue(State.Initial)
_state.postValue(State.Error(MissingTokenException()))
}
}
override fun clearState() {
_state.postValue(State.Initial)
}
override fun login() {
viewModelScope.launch(Dispatchers.IO) {
// TODO : validation !
if (rememberCredential.value == true) {
credentialRepository.login = login.value
credentialRepository.password = password.value
} else {
credentialRepository.login = null
credentialRepository.password = null
}
// TODO : validation !
val query = AuthLoginQuery(
username = login.value,
password = password.value
)
// TODO : Repository (token management & co)
_state.postValue(State.Loading)
try {
val response = client.service.login(query)
Log.e("pouet", response.toString())
val idToken = response.token ?: throw MissingTokenException()
client.updateBearerToken(idToken)
_state.postValue(State.Connect)
} catch (exception: Exception) {
_state.postValue(State.Error(exception))
}
}
}
@ -105,7 +120,8 @@ class AuthenticationViewModel : ViewModel(), IAuthentication {
val task = GoogleSignIn.getSignedInAccountFromIntent(it.data)
val account: GoogleSignInAccount? = task.getResult(ApiException::class.java)
val idToken = account?.idToken ?: throw MissingTokenException()
_state.postValue(State.Connect(idToken))
client.updateBearerToken(idToken)
_state.postValue(State.Connect)
} catch (exception: Exception) {
_state.postValue(State.Error(exception))
}

View file

@ -13,6 +13,7 @@ interface IAuthentication {
fun updateLoginField(login: String)
fun updatePasswordField(password: String)
fun updateRememberCredential(rememberCredential: Boolean)
fun clearState()
fun register()
fun login()
@ -24,13 +25,12 @@ interface IAuthentication {
sealed class State {
object Initial : State()
object Loading : State()
data class Connect(val token: String) : State()
object Connect : State()
data class Error(val exception: Exception) : State()
}
class Mock(waiting: Boolean = false) : IAuthentication {
override val state: LiveData<State> =
MutableLiveData(if (waiting) State.Loading else State.Initial)
class Mock(state: State = State.Initial) : IAuthentication {
override val state: LiveData<State> = MutableLiveData(state)
override val login: LiveData<String?> = MutableLiveData("")
override val password: LiveData<String?> = MutableLiveData("")
override val rememberCredential: LiveData<Boolean> = MutableLiveData(true)
@ -38,6 +38,8 @@ interface IAuthentication {
override fun updateLoginField(login: String) = Unit
override fun updatePasswordField(password: String) = Unit
override fun updateRememberCredential(rememberCredential: Boolean) = Unit
override fun clearState() = Unit
override fun register() = Unit
override fun login() = Unit

View file

@ -7,6 +7,8 @@
<string name="loading">Entering the Imperial Library of Trantor.</string>
<string name="error_generic">Oops!</string>
<string name="welcome_sign_in">Sign in to BibLib</string>
<string name="authentication_login">Login</string>