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
data class Lexicon(
val name: String?,
val id: Int,
val name: String,
val diminutive: String?,
val gender: Gender = Gender.UNDETERMINED,
val race: String?,

View file

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

View file

@ -3,6 +3,7 @@ package com.pixelized.rplexicon.repository
import android.net.Uri
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.core.net.toUri
import com.google.api.client.extensions.android.http.AndroidHttp
import com.google.api.client.json.gson.GsonFactory
import com.google.api.services.sheets.v4.Sheets
@ -37,79 +38,102 @@ class LexiconRepository @Inject constructor(
private val _data = MutableStateFlow<List<Lexicon>>(emptyList())
val data: StateFlow<List<Lexicon>> get() = _data
@Throws(ServiceNotReady::class, Exception::class)
suspend fun fetchLexicon(): ValueRange? {
@Throws(ServiceNotReady::class, IncompatibleSheetStructure::class, Exception::class)
suspend fun fetchLexicon() {
val service = sheetService
return if (service == null) {
if (service == null) {
throw ServiceNotReady()
} else {
withContext(Dispatchers.IO) {
val request = service.spreadsheets().values().get(ID, LEXIQUE)
request.execute()
val data = request.execute()
updateData(data = data)
}
}
}
companion object {
const val HOST = "https://docs.google.com/"
const val ID = "1oL9Nu5y37BPEbKxHre4TN9o8nrgy2JQoON4RRkdAHMs"
const val LEXICON_GID = 0
const val META_DATA_GID = "957635233"
const val LEXIQUE = "Lexique"
@Throws(IncompatibleSheetStructure::class)
private fun updateData(data: ValueRange?) {
val sheet = data?.values?.sheet()
val lexicon: List<Lexicon> = sheet?.mapIndexedNotNull { index, row ->
if (index == 0) {
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 IncompatibleSheetStructure(message: String?) : Exception(message)
private fun sample(): List<Lexicon> {
return listOf(
Lexicon(
name = "Brulkhai",
diminutive = "Bru",
gender = Lexicon.Gender.FEMALE,
race = "Demi-Orc",
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,
),
)
companion object {
const val TAG = "LexiconRepository"
const val ID = "1oL9Nu5y37BPEbKxHre4TN9o8nrgy2JQoON4RRkdAHMs"
const val LEXIQUE = "Lexique"
val columns =
listOf("Nom", "Diminutif", "Sexe", "Race", "Portrait", "Description", "Histoire")
}
}

View file

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

View file

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

View file

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

View file

@ -1,35 +1,36 @@
package com.pixelized.rplexicon.ui.screens.detail
import android.net.Uri
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.SavedStateHandle
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 javax.inject.Inject
@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(
CharacterDetailUio(
name = "Brulkhai",
diminutive = "./ Bru",
gender = "female",
race = "Demi-Orc",
portrait = listOf(
Uri.parse("https://cdnb.artstation.com/p/assets/images/images/003/024/889/large/bayard-wu-0716.jpg?1468642855"),
Uri.parse("https://cdnb.artstation.com/p/assets/images/images/003/024/877/large/bayard-wu-0714.jpg?1468642665"),
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"),
Uri.parse("https://cdna.artstation.com/p/assets/images/images/002/869/868/large/bayard-wu-0622-03.jpg?1466664135"),
Uri.parse("https://cdnb.artstation.com/p/assets/images/images/002/869/871/large/bayard-wu-0622-04.jpg?1466664153"),
Uri.parse("https://cdnb.artstation.com/p/assets/images/images/004/347/181/large/bayard-wu-1217.jpg?1482770883"),
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,
private fun init(): CharacterDetailUio {
val source = repository.data.value[savedStateHandle.characterDetailArgument.id]
return CharacterDetailUio(
name = source.name,
diminutive = source.diminutive?.let { "./ $it" },
gender = when (source.gender) {
Lexicon.Gender.MALE -> "homme"
Lexicon.Gender.FEMALE -> "femme"
Lexicon.Gender.UNDETERMINED -> "inconnu"
},
race = source.race,
portrait = source.portrait,
description = source.description,
history = source.history,
)
)
}
}

View file

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

View file

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

View file

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