Link the spreadsheet API to the dataSource.

This commit is contained in:
Andres Gomez, Thomas (ITDV CC) - AF (ext) 2023-07-16 11:55:27 +02:00
parent 6cfd673335
commit 6167999001
10 changed files with 164 additions and 125 deletions

View file

@ -3,7 +3,8 @@ package com.pixelized.rplexicon.model
import android.net.Uri import android.net.Uri
data class Lexicon( data class Lexicon(
val name: String?, val id: Int,
val name: String,
val diminutive: String?, val diminutive: String?,
val gender: Gender = Gender.UNDETERMINED, val gender: Gender = Gender.UNDETERMINED,
val race: String?, val race: String?,

View file

@ -1,10 +1,11 @@
package com.pixelized.rplexicon.repository package com.pixelized.rplexicon.repository
import android.accounts.Account
import android.content.Context import android.content.Context
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import com.google.android.gms.auth.api.signin.GoogleSignIn import com.google.android.gms.auth.api.identity.SignInCredential
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
import com.google.api.client.util.ExponentialBackOff import com.google.api.client.util.ExponentialBackOff
import com.google.api.services.sheets.v4.SheetsScopes import com.google.api.services.sheets.v4.SheetsScopes
@ -16,11 +17,8 @@ import javax.inject.Singleton
class AuthenticationRepository @Inject constructor( class AuthenticationRepository @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) { ) {
private val _isAuthenticated = mutableStateOf(account != null) private val signInCredential = mutableStateOf<SignInCredential?>(null)
val isAuthenticated: State<Boolean> get() = _isAuthenticated val isAuthenticated: State<Boolean> = derivedStateOf { signInCredential.value != null }
private val account: GoogleSignInAccount?
get() = GoogleSignIn.getLastSignedInAccount(context)
val credential: GoogleAccountCredential val credential: GoogleAccountCredential
get() { get() {
@ -33,12 +31,16 @@ class AuthenticationRepository @Inject constructor(
) )
.setBackOff(ExponentialBackOff()) .setBackOff(ExponentialBackOff())
credential.selectedAccount = account?.account credential.selectedAccount = signInCredential.value?.let {
Account(it.id, "google")
}
return credential return credential
} }
fun updateAuthenticationState() { fun updateAuthenticationState(
_isAuthenticated.value = account != null credential: SignInCredential? = null,
) {
signInCredential.value = credential
} }
} }

View file

@ -3,6 +3,7 @@ package com.pixelized.rplexicon.repository
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.core.net.toUri
import com.google.api.client.extensions.android.http.AndroidHttp import com.google.api.client.extensions.android.http.AndroidHttp
import com.google.api.client.json.gson.GsonFactory import com.google.api.client.json.gson.GsonFactory
import com.google.api.services.sheets.v4.Sheets import com.google.api.services.sheets.v4.Sheets
@ -37,79 +38,102 @@ class LexiconRepository @Inject constructor(
private val _data = MutableStateFlow<List<Lexicon>>(emptyList()) private val _data = MutableStateFlow<List<Lexicon>>(emptyList())
val data: StateFlow<List<Lexicon>> get() = _data val data: StateFlow<List<Lexicon>> get() = _data
@Throws(ServiceNotReady::class, Exception::class) @Throws(ServiceNotReady::class, IncompatibleSheetStructure::class, Exception::class)
suspend fun fetchLexicon(): ValueRange? { suspend fun fetchLexicon() {
val service = sheetService val service = sheetService
return if (service == null) { if (service == null) {
throw ServiceNotReady() throw ServiceNotReady()
} else { } else {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val request = service.spreadsheets().values().get(ID, LEXIQUE) val request = service.spreadsheets().values().get(ID, LEXIQUE)
request.execute() val data = request.execute()
updateData(data = data)
} }
} }
} }
companion object { @Throws(IncompatibleSheetStructure::class)
const val HOST = "https://docs.google.com/" private fun updateData(data: ValueRange?) {
const val ID = "1oL9Nu5y37BPEbKxHre4TN9o8nrgy2JQoON4RRkdAHMs" val sheet = data?.values?.sheet()
const val LEXICON_GID = 0 val lexicon: List<Lexicon> = sheet?.mapIndexedNotNull { index, row ->
const val META_DATA_GID = "957635233" if (index == 0) {
const val LEXIQUE = "Lexique" checkSheetStructure(firstRow = row)
null
} else {
parseCharacterRow(index = index - 1, row = row as? List<Any>?)
}
} ?: emptyList()
_data.tryEmit(lexicon)
}
@Throws(IncompatibleSheetStructure::class)
private fun checkSheetStructure(firstRow: Any?) {
when {
firstRow !is ArrayList<*> -> throw IncompatibleSheetStructure("First row is not a List: $firstRow")
firstRow.size < 7 -> throw IncompatibleSheetStructure("First row have not enough column: ${firstRow.size}, $firstRow")
firstRow.size > 7 -> throw IncompatibleSheetStructure("First row have too mush columns: ${firstRow.size}, $firstRow")
else -> {
for (index in 0..6) {
if (columns[index] != firstRow[index]) {
throw IncompatibleSheetStructure("Column at index:$index should be ${columns[index]} but was ${firstRow[index]}")
}
}
}
}
}
private fun parseCharacterRow(index: Int, row: List<Any>?): Lexicon? {
val name = row?.getOrNull(0) as? String
val diminutive = row?.getOrNull(1) as? String?
val gender = row?.getOrNull(2) as? String?
val race = row?.getOrNull(3) as? String?
val portrait = row?.getOrNull(4) as? String?
val description = row?.getOrNull(5) as? String?
val history = row?.getOrNull(6) as? String?
return if (name != null) {
Lexicon(
id = index,
name = name,
diminutive = diminutive,
gender = when (gender) {
"Male" -> Lexicon.Gender.MALE
"Femelle" -> Lexicon.Gender.FEMALE
else -> Lexicon.Gender.UNDETERMINED
},
race = race,
portrait = portrait?.split("\n")?.mapNotNull { it.toUriOrNull() } ?: emptyList(),
description = description,
history = history,
)
} else {
null
}
}
private fun MutableCollection<Any>?.sheet(): List<*>? {
return this?.firstOrNull {
val sheet = it as? ArrayList<*>
sheet != null
} as List<*>?
}
private fun String?.toUriOrNull(): Uri? = try {
this?.toUri()
} catch (_: Exception) {
null
} }
class ServiceNotReady : Exception() class ServiceNotReady : Exception()
}
class IncompatibleSheetStructure(message: String?) : Exception(message)
private fun sample(): List<Lexicon> { companion object {
return listOf( const val TAG = "LexiconRepository"
Lexicon( const val ID = "1oL9Nu5y37BPEbKxHre4TN9o8nrgy2JQoON4RRkdAHMs"
name = "Brulkhai", const val LEXIQUE = "Lexique"
diminutive = "Bru",
gender = Lexicon.Gender.FEMALE, val columns =
race = "Demi-Orc", listOf("Nom", "Diminutif", "Sexe", "Race", "Portrait", "Description", "Histoire")
portrait = listOf( }
Uri.parse("https://drive.google.com/file/d/1a31xJ6DQnzqmGBndG-uo65HNQHPEUJnI/view?usp=sharing"),
),
description = "Brulkhai, ou plus simplement Bru, est solidement bâti. Elle mesure 192 cm pour 110 kg de muscles lorsquelle est en bonne santé. Elle a les cheveux châtains, les yeux noisettes et la peau couleur gris-vert typique de son espèce. Dun tempérament taciturne, elle parle peu et de façon concise. Elle est parfois brutale, aussi bien physiquement que verbalement, Elle ne prend cependant aucun plaisir à malmener ceux quelle considère plus faibles quelle. Dune nature simple et honnête, elle ne mâche pas ses mots et ne dissimule généralement pas ses pensées. Son intelligence modeste est plus le reflet dun manque déducation et dune capacité limitée à gérer ses émotions quà une débilité congénitale. Elle voue à la force un culte car cest par son expression quelle se sent vraiment vivante et éprouve de grandes difficultés vis à vis de ceux quelle nomme foshnu (bébé, chouineur en commun).",
history = null,
),
Lexicon(
name = "Léandre",
diminutive = null,
gender = Lexicon.Gender.MALE,
race = "Humain",
portrait = emptyList(),
description = null,
history = null,
),
Lexicon(
name = "Nelia",
diminutive = null,
gender = Lexicon.Gender.FEMALE,
race = "Elfe",
portrait = emptyList(),
description = null,
history = null,
),
Lexicon(
name = "Tigrane",
diminutive = null,
gender = Lexicon.Gender.MALE,
race = "Tieffelin",
portrait = emptyList(),
description = null,
history = null,
),
Lexicon(
name = "Unathana",
diminutive = "Una",
gender = Lexicon.Gender.FEMALE,
race = "Demi-Elfe",
portrait = emptyList(),
description = null,
history = null,
),
)
} }

View file

@ -18,7 +18,7 @@ val CHARACTER_DETAIL_ROUTE = "$ROUTE?${ARG_ID.ARG}"
@Stable @Stable
@Immutable @Immutable
data class CharacterDetailArgument( data class CharacterDetailArgument(
val id: String, val id: Int,
) )
val SavedStateHandle.characterDetailArgument: CharacterDetailArgument val SavedStateHandle.characterDetailArgument: CharacterDetailArgument
@ -31,7 +31,7 @@ fun NavGraphBuilder.composableCharacterDetail() {
route = CHARACTER_DETAIL_ROUTE, route = CHARACTER_DETAIL_ROUTE,
arguments = listOf( arguments = listOf(
navArgument(name = ARG_ID) { navArgument(name = ARG_ID) {
type = NavType.StringType type = NavType.IntType
} }
), ),
animation = NavigationAnimation.Push, animation = NavigationAnimation.Push,
@ -41,7 +41,7 @@ fun NavGraphBuilder.composableCharacterDetail() {
} }
fun NavHostController.navigateToCharacterDetail( fun NavHostController.navigateToCharacterDetail(
id: String, id: Int,
option: NavOptionsBuilder.() -> Unit = {}, option: NavOptionsBuilder.() -> Unit = {},
) { ) {
val route = "$ROUTE?$ARG_ID=$id" val route = "$ROUTE?$ARG_ID=$id"

View file

@ -16,6 +16,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import com.google.android.gms.auth.api.identity.GetSignInIntentRequest import com.google.android.gms.auth.api.identity.GetSignInIntentRequest
import com.google.android.gms.auth.api.identity.Identity import com.google.android.gms.auth.api.identity.Identity
import com.google.android.gms.auth.api.identity.SignInCredential
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.pixelized.rplexicon.R import com.pixelized.rplexicon.R
import com.pixelized.rplexicon.repository.AuthenticationRepository import com.pixelized.rplexicon.repository.AuthenticationRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -43,8 +45,12 @@ class AuthenticationViewModel @Inject constructor(
contract = ActivityResultContracts.StartIntentSenderForResult(), contract = ActivityResultContracts.StartIntentSenderForResult(),
onResult = { onResult = {
if (it.resultCode == Activity.RESULT_OK) { if (it.resultCode == Activity.RESULT_OK) {
val credential: SignInCredential = Identity
.getSignInClient(context)
.getSignInCredentialFromIntent(it.data)
state.value = Authentication.Success state.value = Authentication.Success
repository.updateAuthenticationState() repository.updateAuthenticationState(credential)
} else { } else {
state.value = Authentication.Failure state.value = Authentication.Failure
repository.updateAuthenticationState() repository.updateAuthenticationState()

View file

@ -54,6 +54,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.pixelized.rplexicon.R import com.pixelized.rplexicon.R
import com.pixelized.rplexicon.ui.navigation.LocalScreenNavHost
import com.pixelized.rplexicon.ui.theme.LexiconTheme import com.pixelized.rplexicon.ui.theme.LexiconTheme
import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.ImageOptions
import com.skydoves.landscapist.glide.GlideImage import com.skydoves.landscapist.glide.GlideImage
@ -73,11 +74,13 @@ data class CharacterDetailUio(
fun CharacterDetailScreen( fun CharacterDetailScreen(
viewModel: CharacterDetailViewModel = hiltViewModel(), viewModel: CharacterDetailViewModel = hiltViewModel(),
) { ) {
val screen = LocalScreenNavHost.current
Surface { Surface {
CharacterDetailScreenContent( CharacterDetailScreenContent(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
item = viewModel.character, item = viewModel.character,
onBack = { }, onBack = { screen.popBackStack() },
onImage = { }, onImage = { },
) )
} }

View file

@ -1,35 +1,36 @@
package com.pixelized.rplexicon.ui.screens.detail package com.pixelized.rplexicon.ui.screens.detail
import android.net.Uri
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.pixelized.rplexicon.model.Lexicon
import com.pixelized.rplexicon.repository.LexiconRepository
import com.pixelized.rplexicon.ui.navigation.characterDetailArgument
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class CharacterDetailViewModel @Inject constructor() : ViewModel() { class CharacterDetailViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val repository: LexiconRepository,
) : ViewModel() {
val character: State<CharacterDetailUio> = mutableStateOf(init())
val character: State<CharacterDetailUio> = mutableStateOf( private fun init(): CharacterDetailUio {
CharacterDetailUio( val source = repository.data.value[savedStateHandle.characterDetailArgument.id]
name = "Brulkhai", return CharacterDetailUio(
diminutive = "./ Bru", name = source.name,
gender = "female", diminutive = source.diminutive?.let { "./ $it" },
race = "Demi-Orc", gender = when (source.gender) {
portrait = listOf( Lexicon.Gender.MALE -> "homme"
Uri.parse("https://cdnb.artstation.com/p/assets/images/images/003/024/889/large/bayard-wu-0716.jpg?1468642855"), Lexicon.Gender.FEMALE -> "femme"
Uri.parse("https://cdnb.artstation.com/p/assets/images/images/003/024/877/large/bayard-wu-0714.jpg?1468642665"), Lexicon.Gender.UNDETERMINED -> "inconnu"
Uri.parse("https://cdnb.artstation.com/p/assets/images/images/003/024/887/large/bayard-wu-0715.jpg?1468642839"), },
Uri.parse("https://cdnb.artstation.com/p/assets/images/images/003/024/891/large/bayard-wu-0623-03.jpg?1468642872"), race = source.race,
Uri.parse("https://cdna.artstation.com/p/assets/images/images/002/869/868/large/bayard-wu-0622-03.jpg?1466664135"), portrait = source.portrait,
Uri.parse("https://cdnb.artstation.com/p/assets/images/images/002/869/871/large/bayard-wu-0622-04.jpg?1466664153"), description = source.description,
Uri.parse("https://cdnb.artstation.com/p/assets/images/images/004/347/181/large/bayard-wu-1217.jpg?1482770883"), history = source.history,
Uri.parse("https://cdnb.artstation.com/p/assets/images/images/004/297/635/large/bayard-wu-1215.jpg?1482166826"),
Uri.parse("https://cdnb.artstation.com/p/assets/images/images/004/297/631/large/bayard-wu-1209.jpg?1482166803"),
Uri.parse("https://cdnb.artstation.com/p/assets/images/images/004/297/641/large/bayard-wu-1212.jpg?1482166838"),
),
description = "Brulkhai, ou plus simplement Bru, est solidement bâti. Elle mesure 192 cm pour 110 kg de muscles lorsquelle est en bonne santé. Elle a les cheveux châtains, les yeux noisettes et la peau couleur gris-vert typique de son espèce. Dun tempérament taciturne, elle parle peu et de façon concise. Elle est parfois brutale, aussi bien physiquement que verbalement, Elle ne prend cependant aucun plaisir à malmener ceux quelle considère plus faibles quelle. Dune nature simple et honnête, elle ne mâche pas ses mots et ne dissimule généralement pas ses pensées. Son intelligence modeste est plus le reflet dun manque déducation et dune capacité limitée à gérer ses émotions quà une débilité congénitale. Elle voue à la force un culte car cest par son expression quelle se sent vraiment vivante et éprouve de grandes difficultés vis à vis de ceux quelle nomme foshnu (bébé, chouineur en commun).",
history = null,
) )
) }
} }

View file

@ -21,6 +21,7 @@ import com.pixelized.rplexicon.ui.theme.LexiconTheme
@Stable @Stable
data class LexiconItemUio( data class LexiconItemUio(
val id: Int,
val name: String, val name: String,
val diminutive: String?, val diminutive: String?,
val gender: String?, val gender: String?,
@ -77,6 +78,7 @@ private fun LexiconItemContentPreview() {
LexiconItem( LexiconItem(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
item = LexiconItemUio( item = LexiconItemUio(
id = 0,
name = "Brulkhai", name = "Brulkhai",
diminutive = "Bru", diminutive = "Bru",
gender = "f.", gender = "f.",

View file

@ -53,7 +53,7 @@ fun LexiconScreen(
LexiconScreenContent( LexiconScreenContent(
items = viewModel.items, items = viewModel.items,
onItem = { onItem = {
screen.navigateToCharacterDetail(id = "") screen.navigateToCharacterDetail(id = it.id)
}, },
) )
@ -128,6 +128,7 @@ private fun LexiconScreenContentPreview() {
mutableStateOf( mutableStateOf(
listOf( listOf(
LexiconItemUio( LexiconItemUio(
id = 2,
name = "Brulkhai", name = "Brulkhai",
diminutive = "Bru", diminutive = "Bru",
gender = "f.", gender = "f.",

View file

@ -28,40 +28,39 @@ class LexiconViewModel @Inject constructor(
init { init {
viewModelScope.launch { viewModelScope.launch {
launch { repository.data.collect { items ->
repository.data.collect { items -> _items.value = items.map { item ->
_items.value = items.mapNotNull { item -> LexiconItemUio(
item.name?.let { id = item.id,
LexiconItemUio( name = item.name,
name = item.name, diminutive = item.diminutive?.takeIf { it.isNotBlank() }?.let { "./ $it" },
diminutive = item.diminutive?.let { "./ $it" }, gender = when (item.gender) {
gender = when (item.gender) { Lexicon.Gender.MALE -> "h."
Lexicon.Gender.MALE -> "m." Lexicon.Gender.FEMALE -> "f."
Lexicon.Gender.FEMALE -> "f." Lexicon.Gender.UNDETERMINED -> "u."
Lexicon.Gender.UNDETERMINED -> "u." },
}, race = item.race,
race = item.race, )
) }.sortedBy { it.name }
}
}
}
} }
}
launch { viewModelScope.launch {
delay(100) fetchLexicon()
fetchLexicon()
}
} }
} }
suspend fun fetchLexicon() { suspend fun fetchLexicon() {
try { try {
repository.fetchLexicon() repository.fetchLexicon()
} catch (exception: UserRecoverableAuthIOException) { }
// user need to accept OAuth2 permission.
catch (exception: UserRecoverableAuthIOException) {
Log.e(TAG, exception.message, exception) Log.e(TAG, exception.message, exception)
// user need to accept OAuth2 permission.
_error.emit(LexiconErrorUio.Permission(intent = exception.intent)) _error.emit(LexiconErrorUio.Permission(intent = exception.intent))
} catch (exception: Exception) { }
// default exception
catch (exception: Exception) {
Log.e(TAG, exception.message, exception) Log.e(TAG, exception.message, exception)
_error.emit(LexiconErrorUio.Default) _error.emit(LexiconErrorUio.Default)
} }