Add Spreadsheet API to the project.

This commit is contained in:
Andres Gomez, Thomas (ITDV CC) - AF (ext) 2023-07-16 09:40:46 +02:00
parent f2357c6151
commit 6cfd673335
11 changed files with 274 additions and 143 deletions

View file

@ -99,13 +99,18 @@ dependencies {
// Google service
implementation("com.google.android.gms:play-services-auth:20.6.0")
implementation(
dependencyNotation = "com.google.api-client:google-api-client-android:1.23.0",
dependencyConfiguration = { exclude("org.apache.httpcomponents") },
)
implementation(
dependencyNotation = "com.google.apis:google-api-services-sheets:v4-rev20220927-2.0.0",
dependencyConfiguration = { exclude("org.apache.httpcomponents") },
)
// Image
implementation("com.github.skydoves:landscapist-glide:2.1.11")
kapt("com.github.bumptech.glide:compiler:4.14.2") // this have to be align with landscapist-glide
// Retrofit : Network
implementation("com.squareup.retrofit2:retrofit:2.9.0")
}
private val NamedDomainObjectContainer<SigningConfig>.pixelized get() = this.getByName("pixelized")

View file

@ -8,8 +8,11 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import com.pixelized.rplexicon.ui.navigation.ScreenNavHost
@ -17,6 +20,8 @@ import com.pixelized.rplexicon.ui.theme.LexiconTheme
import dagger.hilt.android.AndroidEntryPoint
val LocalActivity = staticCompositionLocalOf<Activity> { error("Activity not available") }
val LocalSnack =
staticCompositionLocalOf<SnackbarHostState> { error("SnackbarHostState not available") }
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@ -27,7 +32,8 @@ class MainActivity : ComponentActivity() {
setContent {
LexiconTheme {
CompositionLocalProvider(
LocalActivity provides this
LocalActivity provides this,
LocalSnack provides remember { SnackbarHostState() }
) {
Scaffold(
content = { padding ->
@ -39,6 +45,9 @@ class MainActivity : ComponentActivity() {
) {
ScreenNavHost()
}
},
snackbarHost = {
SnackbarHost(hostState = LocalSnack.current)
}
)
}

View file

@ -1,24 +0,0 @@
package com.pixelized.rplexicon.module
import com.pixelized.rplexicon.network.IGoogleSpreadSheet
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class NetworkModule {
@Provides
@Singleton
fun provideGoogleSpreadSheet(): IGoogleSpreadSheet {
val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(IGoogleSpreadSheet.HOST)
.build()
return retrofit.create(IGoogleSpreadSheet::class.java)
}
}

View file

@ -1,19 +0,0 @@
package com.pixelized.rplexicon.network
import retrofit2.http.GET
interface IGoogleSpreadSheet {
@GET("spreadsheets/d/$ID/edit#gid=$LEXICON_GID")
fun getLexicon()
@GET("spreadsheets/d/$ID/edit#gid=$META_DATA_GID")
fun getMetaData()
companion object {
const val HOST = "https://docs.google.com/"
const val ID = "1oL9Nu5y37BPEbKxHre4TN9o8nrgy2JQoON4RRkdAHMs"
const val LEXICON_GID = 0
const val META_DATA_GID = "957635233"
}
}

View file

@ -0,0 +1,44 @@
package com.pixelized.rplexicon.repository
import android.content.Context
import androidx.compose.runtime.State
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.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
import com.google.api.client.util.ExponentialBackOff
import com.google.api.services.sheets.v4.SheetsScopes
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@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)
val credential: GoogleAccountCredential
get() {
val credential = GoogleAccountCredential
.usingOAuth2(
context, listOf(
SheetsScopes.SPREADSHEETS,
SheetsScopes.SPREADSHEETS_READONLY,
)
)
.setBackOff(ExponentialBackOff())
credential.selectedAccount = account?.account
return credential
}
fun updateAuthenticationState() {
_isAuthenticated.value = account != null
}
}

View file

@ -1,80 +1,115 @@
package com.pixelized.rplexicon.repository
import android.net.Uri
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
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
import com.google.api.services.sheets.v4.model.ValueRange
import com.pixelized.rplexicon.model.Lexicon
import com.pixelized.rplexicon.network.IGoogleSpreadSheet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LexiconRepository @Inject constructor(
private val spreadSheet: IGoogleSpreadSheet,
private val authenticationRepository: AuthenticationRepository
) {
private val scope = CoroutineScope(Dispatchers.IO)
private val sheetService: Sheets? by derivedStateOf {
when (authenticationRepository.isAuthenticated.value) {
true -> Sheets
.Builder(
AndroidHttp.newCompatibleTransport(),
GsonFactory(),
authenticationRepository.credential,
)
.setApplicationName("RP-Lexique")
.build()
else -> null
}
}
private val _data = MutableStateFlow<List<Lexicon>>(emptyList())
val data: StateFlow<List<Lexicon>> get() = _data
init {
scope.launch {
_data.emit(sample())
@Throws(ServiceNotReady::class, Exception::class)
suspend fun fetchLexicon(): ValueRange? {
val service = sheetService
return if (service == null) {
throw ServiceNotReady()
} else {
withContext(Dispatchers.IO) {
val request = service.spreadsheets().values().get(ID, LEXIQUE)
request.execute()
}
}
}
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 HOST = "https://docs.google.com/"
const val ID = "1oL9Nu5y37BPEbKxHre4TN9o8nrgy2JQoON4RRkdAHMs"
const val LEXICON_GID = 0
const val META_DATA_GID = "957635233"
const val LEXIQUE = "Lexique"
}
class ServiceNotReady : Exception()
}
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,
),
)
}

View file

@ -42,7 +42,7 @@ fun AuthenticationScreen(
) {
val screen = LocalScreenNavHost.current
val activity = LocalActivity.current
val state = viewModel.rememberAuthenticationState(activity = activity)
val state = viewModel.rememberAuthenticationState()
Surface {
AuthenticationScreenContent(

View file

@ -16,51 +16,41 @@ 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.signin.GoogleSignIn
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
import javax.inject.Inject
@HiltViewModel
class AuthenticationViewModel @Inject constructor(
application: Application,
private val repository: AuthenticationRepository,
) : AndroidViewModel(application) {
private val context: Context get() = getApplication()
private var launcher: ManagedActivityResultLauncher<IntentSenderRequest, ActivityResult>? = null
private val account: GoogleSignInAccount? by lazy {
GoogleSignIn.getLastSignedInAccount(application)
}
private val lastGoogleToken: String? by lazy {
GoogleSignIn.getLastSignedInAccount(application)?.idToken
}
private val state = mutableStateOf(
when (account) {
null -> Authentication.Initial
else -> Authentication.Success
when (repository.isAuthenticated.value) {
true -> Authentication.Success
else -> Authentication.Initial
}
)
@Composable
fun rememberAuthenticationState(activity: Activity): State<Authentication> {
fun rememberAuthenticationState(): State<Authentication> {
launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartIntentSenderForResult(),
onResult = {
if (it.resultCode == Activity.RESULT_OK) {
val credential = Identity
.getSignInClient(activity)
.getSignInCredentialFromIntent(it.data)
state.value = Authentication.Success
repository.updateAuthenticationState()
} else {
state.value = Authentication.Failure
repository.updateAuthenticationState()
}
},
)
return state
}
@ -79,10 +69,12 @@ class AuthenticationViewModel @Inject constructor(
)
} catch (e: SendIntentException) {
state.value = Authentication.Failure
repository.updateAuthenticationState()
}
}
.addOnFailureListener { e ->
state.value = Authentication.Failure
repository.updateAuthenticationState()
}
}

View file

@ -5,6 +5,7 @@ import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.net.Uri
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -77,7 +78,7 @@ fun CharacterDetailScreen(
modifier = Modifier.fillMaxSize(),
item = viewModel.character,
onBack = { },
onItem = { },
onImage = { },
)
}
}
@ -89,7 +90,7 @@ private fun CharacterDetailScreenContent(
state: ScrollState = rememberScrollState(),
item: State<CharacterDetailUio>,
onBack: () -> Unit,
onItem: () -> Unit,
onImage: (Uri) -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
val typography = MaterialTheme.typography
@ -227,7 +228,9 @@ private fun CharacterDetailScreenContent(
) {
items(items = item.value.portrait) {
GlideImage(
modifier = Modifier.height(320.dp),
modifier = Modifier
.clickable { onImage(it) }
.height(320.dp),
imageModel = { it },
imageOptions = ImageOptions(
contentScale = ContentScale.FillHeight
@ -239,7 +242,6 @@ private fun CharacterDetailScreenContent(
}
}
}
}
}
@ -299,7 +301,7 @@ private fun CharacterDetailScreenContentPreview() {
modifier = Modifier.fillMaxSize(),
item = character,
onBack = { },
onItem = { },
onImage = { },
)
}
}

View file

@ -1,7 +1,11 @@
package com.pixelized.rplexicon.ui.screens.lexicon
import android.app.Activity
import android.content.Intent
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
@ -11,16 +15,33 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.pixelized.rplexicon.LocalSnack
import com.pixelized.rplexicon.ui.navigation.LocalScreenNavHost
import com.pixelized.rplexicon.ui.navigation.navigateToCharacterDetail
import com.pixelized.rplexicon.ui.screens.lexicon.LexiconErrorUio.Default
import com.pixelized.rplexicon.ui.screens.lexicon.LexiconErrorUio.Permission
import com.pixelized.rplexicon.ui.theme.LexiconTheme
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
@Stable
sealed class LexiconErrorUio {
@Stable
data class Permission(val intent: Intent) : LexiconErrorUio()
@Stable
object Default : LexiconErrorUio()
}
@Composable
fun LexiconScreen(
@ -35,6 +56,13 @@ fun LexiconScreen(
screen.navigateToCharacterDetail(id = "")
},
)
HandleError(
errors = viewModel.error,
onLexiconPermissionGranted = {
viewModel.fetchLexicon()
}
)
}
}
@ -60,6 +88,34 @@ private fun LexiconScreenContent(
}
}
@Composable
fun HandleError(
errors: SharedFlow<LexiconErrorUio>,
onLexiconPermissionGranted: suspend () -> Unit,
) {
val snack = LocalSnack.current
val scope = rememberCoroutineScope()
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
scope.launch {
onLexiconPermissionGranted()
}
}
}
LaunchedEffect(key1 = "LexiconErrorManagement") {
errors.collect { error ->
when (error) {
is Permission -> launcher.launch(error.intent)
is Default -> snack.showSnackbar(message = "Oops")
}
}
}
}
@Composable
@Preview(uiMode = UI_MODE_NIGHT_NO)
@Preview(uiMode = UI_MODE_NIGHT_YES)

View file

@ -1,12 +1,17 @@
package com.pixelized.rplexicon.ui.screens.lexicon
import android.util.Log
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.api.client.googleapis.extensions.android.gms.auth.UserRecoverableAuthIOException
import com.pixelized.rplexicon.model.Lexicon
import com.pixelized.rplexicon.repository.LexiconRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -15,28 +20,54 @@ class LexiconViewModel @Inject constructor(
private val repository: LexiconRepository,
) : ViewModel() {
// TODO : link it to a paginated DataSource
private val _items = mutableStateOf<List<LexiconItemUio>>(emptyList())
val items: State<List<LexiconItemUio>> get() = _items
private val _error = MutableSharedFlow<LexiconErrorUio>()
val error: SharedFlow<LexiconErrorUio> get() = _error
init {
viewModelScope.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,
)
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,
)
}
}
}
}
launch {
delay(100)
fetchLexicon()
}
}
}
suspend fun fetchLexicon() {
try {
repository.fetchLexicon()
} 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) {
Log.e(TAG, exception.message, exception)
_error.emit(LexiconErrorUio.Default)
}
}
companion object {
private const val TAG = "LexiconViewModel"
}
}