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-1620480106062.xml" value="0.13900501672240803" />
<entry key="../../../../../layout/compose-model-1620480615996.xml" value="0.25" /> <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-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-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_baseline_local_library_24.xml" value="0.25462962962962965" />
<entry key="app/src/main/res/drawable/ic_google.xml" value="0.2962962962962963" /> <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 // region BibLib webservice Auth
override fun updateBearerToken(token: String?) { override fun updateBearerToken(token: String) {
interceptor.token = token interceptor.token = token
} }

View file

@ -4,7 +4,7 @@ interface IBibLibClient {
val service: IBibLibWebServiceAPI val service: IBibLibWebServiceAPI
fun updateBearerToken(token: String?) fun updateBearerToken(token: String)
companion object { companion object {
const val BASE_URL = "https://bib.bibulle.fr" 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.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.pixelized.biblib.R 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.theme.BibLibTheme
import com.pixelized.biblib.ui.viewmodel.authentication.AuthenticationViewModel import com.pixelized.biblib.ui.viewmodel.authentication.AuthenticationViewModel
import com.pixelized.biblib.ui.viewmodel.authentication.IAuthentication import com.pixelized.biblib.ui.viewmodel.authentication.IAuthentication
import com.pixelized.biblib.ui.viewmodel.navigation.INavigation import com.pixelized.biblib.ui.viewmodel.navigation.INavigation
import com.pixelized.biblib.ui.viewmodel.navigation.NavigationViewModel import com.pixelized.biblib.ui.viewmodel.navigation.NavigationViewModel
import com.pixelized.biblib.utils.exception.NoBearerException
@Composable @Composable
@ -49,14 +52,15 @@ fun LoginScreenComposable(
.fillMaxHeight() .fillMaxHeight()
) { ) {
authentication.PrepareLoginWithGoogle() authentication.PrepareLoginWithGoogle()
LoginScreenContentComposable(navigation, authentication) LoginScreenNavigationComposable(navigation, authentication)
LoginScreenWaitingComposable(authentication)
LoginScreenContentComposable(authentication)
LoginScreenDialogComposable(authentication)
} }
} }
@Composable @Composable
private fun LoginScreenContentComposable( private fun LoginScreenContentComposable(
navigation: INavigation,
authentication: IAuthentication, authentication: IAuthentication,
) { ) {
val typography = MaterialTheme.typography val typography = MaterialTheme.typography
@ -142,12 +146,40 @@ private fun LoginScreenContentComposable(
} }
@Composable @Composable
private fun LoginScreenWaitingComposable(authentication: IAuthentication) { private fun LoginScreenDialogComposable(
authentication: IAuthentication
) {
val state = authentication.state.observeAsState() val state = authentication.state.observeAsState()
WaitingComposable( CrossFadeOverlay(
visible = state.value is IAuthentication.State.Loading, visible = (state.value is IAuthentication.State.Initial).not()
message = stringResource(id = R.string.loading) ) {
) 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 @Composable
@ -237,12 +269,24 @@ fun LoginScreenComposablePreview() {
} }
} }
@Preview @Preview(name = "Loading")
@Composable @Composable
fun LoginScreenComposableWaitingPreview() { fun LoginScreenComposableWaitingPreview() {
BibLibTheme { BibLibTheme {
val navigationViewModel = INavigation.Mock() 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) LoginScreenComposable(navigationViewModel, authenticationViewModel)
} }
} }

View file

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

View file

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

View file

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