Add specific adventure book refresh feature

This commit is contained in:
Andres Gomez, Thomas (ITDV RL) 2024-06-19 17:58:20 +02:00
parent 532f5810d7
commit 96269cf84a
13 changed files with 250 additions and 93 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "ad9094e2a7611443722a5415154015bf",
"identityHash": "eecd0da0c8ae6578a5d36c3b926c2fe8",
"entities": [
{
"tableName": "lexicon",
@ -321,7 +321,7 @@
},
{
"tableName": "adventures",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookTitle` TEXT NOT NULL, `adventureTitle` TEXT NOT NULL, `adventureCategory` TEXT, `bookIcon` TEXT, `adventureBackground` TEXT, `index` INTEGER NOT NULL, `revision` INTEGER NOT NULL, PRIMARY KEY(`bookTitle`, `adventureTitle`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookTitle` TEXT NOT NULL, `adventureTitle` TEXT NOT NULL, `adventureCategory` TEXT, `bookIcon` TEXT, `adventureBackground` TEXT, `index` INTEGER NOT NULL, `revision` INTEGER NOT NULL, `documentId` TEXT NOT NULL, PRIMARY KEY(`bookTitle`, `adventureTitle`))",
"fields": [
{
"fieldPath": "bookTitle",
@ -364,6 +364,12 @@
"columnName": "revision",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "documentId",
"columnName": "documentId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
@ -440,7 +446,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ad9094e2a7611443722a5415154015bf')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'eecd0da0c8ae6578a5d36c3b926c2fe8')"
]
}
}

View file

@ -15,6 +15,7 @@ data class AdventureDbo(
val adventureBackground: String?,
val index: Int,
val revision: Long,
val documentId: String,
)
@Entity(

View file

@ -8,5 +8,6 @@ data class Adventure(
val adventureCategory: String?,
val adventureTitle: String,
val adventureBackground: Uri?,
val documentId: String,
val story: List<AdventureLine>,
)

View file

@ -4,6 +4,6 @@ import android.net.Uri
data class AdventureBook(
val title: String,
val document: String,
val documentId: String,
val icon: Uri?,
)

View file

@ -23,7 +23,7 @@ class AdventureBookParser @Inject constructor() {
if (character != null && document != null) {
val adventure = AdventureBook(
title = character,
document = document,
documentId = document,
icon = row.parseUri(ICON),
)
adventures.add(adventure)

View file

@ -24,6 +24,7 @@ class AdventureDboFactory @Inject constructor() {
adventureTitle = adventure.adventureTitle,
adventureCategory = adventure.adventureCategory,
adventureBackground = adventure.adventureBackground.toUriOrNull(),
documentId = adventure.documentId,
story = stories
.filter {
it.bookTitle == adventure.bookTitle && it.adventureTitle == adventure.adventureTitle
@ -57,6 +58,7 @@ class AdventureDboFactory @Inject constructor() {
adventureBackground = story.background.toString(),
index = index,
revision = story.revision,
documentId = book.documentId,
)
fun convertToStoryDbo(

View file

@ -2,6 +2,8 @@ package com.pixelized.rplexicon.data.repository.adventure
import com.pixelized.rplexicon.data.database.CompanionDatabase
import com.pixelized.rplexicon.data.database.adventure.AdventureDao
import com.pixelized.rplexicon.data.database.adventure.AdventureDbo
import com.pixelized.rplexicon.data.database.adventure.AdventureStoryDbo
import com.pixelized.rplexicon.data.model.adventure.Adventure
import com.pixelized.rplexicon.data.model.adventure.AdventureBook
import com.pixelized.rplexicon.data.model.adventure.AdventureLine
@ -45,19 +47,76 @@ class AdventureRepository @Inject constructor(
val database = database.adventureDao()
val bookStore = database.findAdventures()
val bookToRemove = bookStore.associate {
val bookToRemove: MutableMap<String, Boolean> = bookStore.associate {
it.bookTitle to true
}.toMutableMap()
val storyToRemove = bookStore.associate {
val storyToRemove: MutableMap<Pair<String, String>, Boolean> = bookStore.associate {
(it.bookTitle to it.adventureTitle) to true
}.toMutableMap()
val adventures = fetchAdventureBook().flatMap { book ->
val adventures = fetchAdventureBooks().flatMap { book ->
// tag this book to keep it in the database
bookToRemove[book.title] = false
val stories = fetchAdventureStory(book = book)
stories.mapNotNull { story ->
fetchAdventuresContent(
database = database,
storyToRemove = storyToRemove,
book = book,
)
}
database.update(
booksToRemove = bookToRemove
.filter { it.value }
.map { AdventureDao.BookPartialId(it.key) },
storiesToRemove = storyToRemove
.filter { it.value }
.map { AdventureDao.StoryPartialId(it.key.first, it.key.second) },
adventure = adventures,
)
}
@Throws(IncompatibleSheetStructure::class, Exception::class)
suspend fun fetchAdventures(
bookTitle: String,
documentId: String,
) {
val database = database.adventureDao()
val bookStore = database.findAdventures(bookTitle = bookTitle)
val storyToRemove: MutableMap<Pair<String, String>, Boolean> = bookStore.associate {
(it.bookTitle to it.adventureTitle) to true
}.toMutableMap()
val adventures = fetchAdventuresContent(
database = database,
storyToRemove = storyToRemove,
book = AdventureBook(
title = bookTitle,
documentId = documentId,
icon = null,
),
)
database.update(
booksToRemove = emptyList(),
storiesToRemove = storyToRemove
.filter { it.value }
.map { AdventureDao.StoryPartialId(it.key.first, it.key.second) },
adventure = adventures,
)
}
private suspend fun fetchAdventuresContent(
database: AdventureDao,
storyToRemove: MutableMap<Pair<String, String>, Boolean>,
book: AdventureBook
): List<Pair<AdventureDbo, List<AdventureStoryDbo>>> {
val stories = fetchAdventureStory(
documentId = book.documentId,
)
return stories.mapNotNull { story ->
// tag this story to keep it in the database
storyToRemove[book.title to story.title] = false
@ -72,8 +131,10 @@ class AdventureRepository @Inject constructor(
)
if (cache == null || cache.revision < adventure.revision) {
val lines = fetchAdventureLine(book = book, story = story)
.mapIndexed { index, line ->
val lines = fetchAdventureLine(
documentId = book.documentId,
adventureTitle = story.title
).mapIndexed { index, line ->
adventureDboFactory.convertToStoryDbo(
book = book,
story = story,
@ -88,17 +149,6 @@ class AdventureRepository @Inject constructor(
}
}
database.update(
booksToRemove = bookToRemove
.filter { it.value }
.map { AdventureDao.BookPartialId(it.key) },
storiesToRemove = storyToRemove
.filter { it.value }
.map { AdventureDao.StoryPartialId(it.key.first, it.key.second) },
adventure = adventures,
)
}
fun find(bookTitle: String): List<Adventure> {
return adventure.value.filter { adventure ->
adventure.bookTitle == bookTitle
@ -112,7 +162,7 @@ class AdventureRepository @Inject constructor(
}
@Throws(IncompatibleSheetStructure::class, Exception::class)
private suspend fun fetchAdventureBook(): List<AdventureBook> {
private suspend fun fetchAdventureBooks(): List<AdventureBook> {
return googleRepository.fetch { sheet ->
val request = sheet.get(Adventures.ID, Adventures.ADVENTURES)
adventureBookParser.parse(sheet = request.execute())
@ -120,20 +170,20 @@ class AdventureRepository @Inject constructor(
}
@Throws(IncompatibleSheetStructure::class, Exception::class)
private suspend fun fetchAdventureStory(book: AdventureBook): List<AdventureStory> {
private suspend fun fetchAdventureStory(documentId: String): List<AdventureStory> {
return googleRepository.fetch { sheet ->
val request = sheet.get(book.document, Adventures.ADVENTURES)
val request = sheet.get(documentId, Adventures.ADVENTURES)
adventureStoryParser.parse(sheet = request.execute())
}
}
private suspend fun fetchAdventureLine(
book: AdventureBook,
story: AdventureStory
documentId: String,
adventureTitle: String,
): List<AdventureLine> {
return try {
googleRepository.fetch { sheet ->
val request = sheet.get(book.document, story.title)
val request = sheet.get(documentId, adventureTitle)
adventureStoryLineParser.parse(sheet = request.execute())
}
} catch (exception: Exception) {

View file

@ -15,19 +15,23 @@ import com.pixelized.rplexicon.utilitary.extentions.string.ARG
private const val ROUTE = "adventureDetail"
private const val BOOK_TITLE_ARG = "bookTitle"
private const val DOCUMENT_ID_ARG = "documentId"
val ADVENTURE_CHAPTER_ROUTE = "$ROUTE?${BOOK_TITLE_ARG.ARG}"
val ADVENTURE_CHAPTER_ROUTE = "$ROUTE?${BOOK_TITLE_ARG.ARG}&${DOCUMENT_ID_ARG.ARG}"
@Stable
@Immutable
class AdventureChapterArgument(
val bookTitle: String,
val documentId: String,
)
val SavedStateHandle.adventureChaptersArgument: AdventureChapterArgument
get() = AdventureChapterArgument(
bookTitle = get(BOOK_TITLE_ARG)
?: error("AdventureDetailArgument missing argument: $BOOK_TITLE_ARG"),
documentId = get(DOCUMENT_ID_ARG)
?: error("AdventureDetailArgument missing argument: $BOOK_TITLE_ARG"),
)
fun NavGraphBuilder.composableAdventureChapters() {
@ -39,6 +43,10 @@ fun NavGraphBuilder.composableAdventureChapters() {
type = NavType.StringType
nullable = false
},
navArgument(name = DOCUMENT_ID_ARG) {
type = NavType.StringType
nullable = false
},
)
) {
AdventureChaptersScreen()
@ -47,8 +55,9 @@ fun NavGraphBuilder.composableAdventureChapters() {
fun NavHostController.navigateToAdventureChapters(
bookTitle: String,
documentId: String,
option: NavOptionsBuilder.() -> Unit = {},
) {
val route = "$ROUTE?$BOOK_TITLE_ARG=$bookTitle"
val route = "$ROUTE?$BOOK_TITLE_ARG=$bookTitle&$DOCUMENT_ID_ARG=$documentId"
navigate(route = route, builder = option)
}

View file

@ -31,6 +31,7 @@ import com.pixelized.rplexicon.utilitary.extentions.lexicon
data class AdventureBookUio(
val bookTitle: String,
val bookIcon: Uri?,
val documentId: String,
)
@Composable
@ -80,6 +81,7 @@ private fun AdventureItemPreview() {
item = AdventureBookUio(
bookIcon = null,
bookTitle = "Les chroniques d'une orc",
documentId = "",
),
onClick = {},
)

View file

@ -67,11 +67,16 @@ fun AdventureBooksScreen(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding(),
items = viewModel.books,
refreshState = refresh,
refreshing = viewModel.isLoading,
items = viewModel.books,
onBack = { screen.popBackStack() },
onBook = { screen.navigateToAdventureChapters(bookTitle = it.bookTitle) },
onBook = {
screen.navigateToAdventureChapters(
bookTitle = it.bookTitle,
documentId = it.documentId,
)
},
)
}
}
@ -82,9 +87,9 @@ private fun AdventureListContent(
modifier: Modifier = Modifier,
gridState: LazyGridState = rememberLazyGridState(),
paddingValues: PaddingValues = PaddingValues(all = 16.dp),
items: State<List<AdventureBookUio>>,
refreshState: PullRefreshState,
refreshing: State<Boolean>,
items: State<List<AdventureBookUio>>,
onBack: () -> Unit,
onBook: (AdventureBookUio) -> Unit,
) {
@ -113,7 +118,9 @@ private fun AdventureListContent(
contentAlignment = Alignment.TopCenter,
) {
LazyVerticalGrid(
modifier = Modifier.fillMaxSize().pullRefresh(state = refreshState),
modifier = Modifier
.fillMaxSize()
.pullRefresh(state = refreshState),
state = gridState,
columns = GridCells.Fixed(3),
contentPadding = paddingValues,
@ -152,21 +159,22 @@ private fun AdventureListPreview(
) {
AdventureListContent(
modifier = Modifier.fillMaxSize(),
refreshState = rememberPullRefreshState(
refreshing = false,
onRefresh = { },
),
refreshing = remember { mutableStateOf(false) },
items = remember {
mutableStateOf(
listOf(
AdventureBookUio(
bookTitle = "Les chroniques d'une orc",
bookIcon = null,
documentId = "",
)
)
)
},
refreshState = rememberPullRefreshState(
refreshing = false,
onRefresh = { },
),
refreshing = remember { mutableStateOf(false) },
onBack = { },
onBook = { },
)

View file

@ -36,6 +36,7 @@ class AdventureBooksViewModel @Inject constructor(
AdventureBookUio(
bookTitle = adventure.bookTitle,
bookIcon = adventure.bookIcon,
documentId = adventure.documentId,
)
}
.toSet()

View file

@ -2,6 +2,7 @@ package com.pixelized.rplexicon.ui.screens.adventure.chapter
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -11,6 +12,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshState
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -22,6 +27,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.res.painterResource
@ -29,17 +36,27 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.pixelized.rplexicon.R
import com.pixelized.rplexicon.ui.composable.Loader
import com.pixelized.rplexicon.ui.composable.rememberAnimatedShadow
import com.pixelized.rplexicon.ui.navigation.LocalScreenNavHost
import com.pixelized.rplexicon.ui.navigation.screens.navigateToAdventureDetail
import com.pixelized.rplexicon.ui.theme.LexiconTheme
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun AdventureChaptersScreen(
viewModel: AdventureChaptersViewModel = hiltViewModel(),
) {
val screen = LocalScreenNavHost.current
val scope = rememberCoroutineScope()
val refresh = rememberPullRefreshState(
refreshing = false,
onRefresh = {
scope.launch { viewModel.update() }
},
)
Surface(
modifier = Modifier.fillMaxSize()
@ -48,6 +65,8 @@ fun AdventureChaptersScreen(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding(),
refreshState = refresh,
refreshing = viewModel.isLoading,
bookTitle = viewModel.bookTitle,
chapters = viewModel.chapters,
onChapter = {
@ -61,12 +80,14 @@ fun AdventureChaptersScreen(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable
private fun AdventureChapterContent(
modifier: Modifier = Modifier,
lazyListState: LazyListState = rememberLazyListState(),
paddingValues: PaddingValues = PaddingValues(vertical = 16.dp),
refreshState: PullRefreshState,
refreshing: State<Boolean>,
bookTitle: State<String>,
chapters: State<List<AdventureChapterUio>>,
onChapter: (AdventureChapterUio.AdventureItem) -> Unit,
@ -91,9 +112,13 @@ private fun AdventureChapterContent(
},
)
},
content = { it ->
LazyColumn(
content = {
Box(
modifier = Modifier.padding(paddingValues = it),
contentAlignment = Alignment.TopCenter,
) {
LazyColumn(
modifier = Modifier.pullRefresh(state = refreshState),
state = lazyListState,
contentPadding = paddingValues,
verticalArrangement = Arrangement.spacedBy(space = 8.dp),
@ -119,10 +144,16 @@ private fun AdventureChapterContent(
}
}
}
Loader(
refreshState = refreshState,
refreshing = refreshing,
)
}
}
)
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@ -131,6 +162,11 @@ private fun AdventureChapterPreview() {
Surface {
AdventureChapterContent(
modifier = Modifier.fillMaxSize(),
refreshState = rememberPullRefreshState(
refreshing = false,
onRefresh = { },
),
refreshing = remember { mutableStateOf(false) },
bookTitle = remember {
mutableStateOf(
"Les chroniques d'une orc"

View file

@ -1,34 +1,50 @@
package com.pixelized.rplexicon.ui.screens.adventure.chapter
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import com.pixelized.rplexicon.data.repository.adventure.AdventureRepository
import com.pixelized.rplexicon.ui.composable.error.FetchErrorUio
import com.pixelized.rplexicon.ui.composable.error.FetchErrorUio.Structure
import com.pixelized.rplexicon.ui.composable.error.FetchErrorUio.Structure.Type.ADVENTURE
import com.pixelized.rplexicon.ui.navigation.screens.adventureChaptersArgument
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
class AdventureChaptersViewModel @Inject constructor(
adventureRepository: AdventureRepository,
private val adventureRepository: AdventureRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val argument = savedStateHandle.adventureChaptersArgument
private val _chapters = mutableStateOf(
adventureRepository.find(bookTitle = argument.bookTitle)
)
private val _error = MutableSharedFlow<FetchErrorUio>()
private val _isLoading = mutableStateOf(false)
val isLoading: State<Boolean> get() = _isLoading
val bookTitle = derivedStateOf {
argument.bookTitle
}
val chapters = derivedStateOf {
_chapters.value
.groupBy { it.adventureCategory }
private val _chapters = adventureRepository.adventure
.map { adventures ->
adventures
.filter { it.bookTitle == argument.bookTitle && it.documentId == argument.documentId }
.groupBy { it.adventureCategory ?: "" }
.flatMap { entry ->
val header = entry.key?.let {
listOf(AdventureChapterUio.AdventureCategory(title = it))
} ?: emptyList()
val header = listOf(
AdventureChapterUio.AdventureCategory(title = entry.key)
)
val stories = entry.value.map {
AdventureChapterUio.AdventureItem(
bookTitle = it.bookTitle,
@ -38,4 +54,29 @@ class AdventureChaptersViewModel @Inject constructor(
header + stories
}
}
val chapters: State<List<AdventureChapterUio>>
@Composable
@Stable
get() = _chapters.collectAsState(initial = emptyList())
suspend fun update() {
try {
withContext(Dispatchers.Main) {
_isLoading.value = true
}
withContext(Dispatchers.Default) {
adventureRepository.fetchAdventures(
bookTitle = argument.bookTitle,
documentId = argument.documentId,
)
}
} catch (_: Exception) {
_error.emit(value = Structure(ADVENTURE))
} finally {
withContext(Dispatchers.Main) {
_isLoading.value = false
}
}
}
}