Add Spreadsheet API to the project.
This commit is contained in:
parent
f2357c6151
commit
6cfd673335
11 changed files with 274 additions and 143 deletions
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +1,66 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
|
|
@ -77,4 +113,3 @@ class LexiconRepository @Inject constructor(
|
|||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,12 +20,15 @@ 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 {
|
||||
launch {
|
||||
repository.data.collect { items ->
|
||||
_items.value = items.mapNotNull { item ->
|
||||
item.name?.let {
|
||||
|
|
@ -38,5 +46,28 @@ class LexiconViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue