Add room database for data caching & is new feature.

This commit is contained in:
Thomas Andres Gomez 2024-01-09 18:25:59 +01:00
parent a2f26cc2a5
commit 79a36fa9d9
23 changed files with 974 additions and 89 deletions

View file

@ -1,9 +1,14 @@
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
id("androidx.room")
id("com.google.devtools.ksp")
}
@ -19,7 +24,8 @@ android {
keyPassword = "123456"
}
create("pixelized") {
storeFile = (project.properties["PIXELIZED_RELEASE_STORE_FILE"] as? String)?.let { file(it) }
storeFile =
(project.properties["PIXELIZED_RELEASE_STORE_FILE"] as? String)?.let { file(it) }
storePassword = project.properties["PIXELIZED_RELEASE_STORE_PASSWORD"] as? String
keyAlias = project.properties["PIXELIZED_RELEASE_KEY_ALIAS"] as? String
keyPassword = project.properties["PIXELIZED_RELEASE_KEY_PASSWORD"] as? String
@ -64,6 +70,10 @@ android {
}
}
buildTypes.onEach {
it.buildConfigField("String", "DEFAULT_READ_TIME_STAMP", "\"$defaultReadTimestamp\"")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@ -141,6 +151,11 @@ dependencies {
ksp("com.google.dagger:hilt-android-compiler:2.50")
ksp("com.google.dagger:hilt-compiler:2.50")
// Room
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// Image
implementation("io.coil-kt:coil-compose:2.5.0")
}
@ -155,6 +170,16 @@ kotlin {
jvmToolchain(17)
}
room {
schemaDirectory("$projectDir/schemas")
}
val defaultReadTimestamp: String
get() {
val formatter = SimpleDateFormat("dd/MM/yyyy HH:mm:ss", Locale.FRANCE)
return formatter.format(Date()).toString()
}
val gitBuildNumber: Int
get() {
val stdout = org.apache.commons.io.output.ByteArrayOutputStream()

View file

@ -0,0 +1,210 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "f2d0339fd127a0e9f6e2e816647d9ea9",
"entities": [
{
"tableName": "lexicon",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `category` TEXT, `diminutive` TEXT, `gender` TEXT, `race` TEXT, `status` TEXT, `location` TEXT, `portrait` TEXT, `description` TEXT, `history` TEXT, `tags` TEXT, `lastUpdated` INTEGER, `lastRead` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "diminutive",
"columnName": "diminutive",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "gender",
"columnName": "gender",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "race",
"columnName": "race",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "location",
"columnName": "location",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "portrait",
"columnName": "portrait",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "history",
"columnName": "history",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastUpdated",
"columnName": "lastUpdated",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "lastRead",
"columnName": "lastRead",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "quest",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `category` TEXT, `title` TEXT NOT NULL, `subTitle` TEXT, `completed` INTEGER NOT NULL, `questGiver` TEXT, `area` TEXT, `groupReward` TEXT, `individualReward` TEXT, `description` TEXT NOT NULL, `illustrations` TEXT, `background` TEXT, `lastUpdated` INTEGER, `lastRead` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subTitle",
"columnName": "subTitle",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "completed",
"columnName": "completed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "questGiver",
"columnName": "questGiver",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "area",
"columnName": "area",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "groupReward",
"columnName": "groupReward",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "individualReward",
"columnName": "individualReward",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "illustrations",
"columnName": "illustrations",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "background",
"columnName": "background",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastUpdated",
"columnName": "lastUpdated",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "lastRead",
"columnName": "lastRead",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"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, 'f2d0339fd127a0e9f6e2e816647d9ea9')"
]
}
}

View file

@ -0,0 +1,210 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "f2d0339fd127a0e9f6e2e816647d9ea9",
"entities": [
{
"tableName": "lexicon",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `category` TEXT, `diminutive` TEXT, `gender` TEXT, `race` TEXT, `status` TEXT, `location` TEXT, `portrait` TEXT, `description` TEXT, `history` TEXT, `tags` TEXT, `lastUpdated` INTEGER, `lastRead` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "diminutive",
"columnName": "diminutive",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "gender",
"columnName": "gender",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "race",
"columnName": "race",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "location",
"columnName": "location",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "portrait",
"columnName": "portrait",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "history",
"columnName": "history",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastUpdated",
"columnName": "lastUpdated",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "lastRead",
"columnName": "lastRead",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "quest",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `category` TEXT, `title` TEXT NOT NULL, `subTitle` TEXT, `completed` INTEGER NOT NULL, `questGiver` TEXT, `area` TEXT, `groupReward` TEXT, `individualReward` TEXT, `description` TEXT NOT NULL, `illustrations` TEXT, `background` TEXT, `lastUpdated` INTEGER, `lastRead` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subTitle",
"columnName": "subTitle",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "completed",
"columnName": "completed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "questGiver",
"columnName": "questGiver",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "area",
"columnName": "area",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "groupReward",
"columnName": "groupReward",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "individualReward",
"columnName": "individualReward",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "illustrations",
"columnName": "illustrations",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "background",
"columnName": "background",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastUpdated",
"columnName": "lastUpdated",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "lastRead",
"columnName": "lastRead",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"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, 'f2d0339fd127a0e9f6e2e816647d9ea9')"
]
}
}

View file

@ -0,0 +1,41 @@
package com.pixelized.rplexicon.data.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.pixelized.rplexicon.data.database.lexicon.LexiconDao
import com.pixelized.rplexicon.data.database.lexicon.LexiconDbo
import com.pixelized.rplexicon.data.database.quest.QuestDao
import com.pixelized.rplexicon.data.database.quest.QuestDbo
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
@Database(
entities = [LexiconDbo::class, QuestDbo::class],
version = 2,
exportSchema = true,
)
abstract class CompanionDatabase : RoomDatabase() {
abstract fun lexiconDao(): LexiconDao
abstract fun questsDao(): QuestDao
}
@Module
@InstallIn(SingletonComponent::class)
class DatabaseModule {
@Provides
fun provideCompanionDatabase(
@ApplicationContext context: Context,
): CompanionDatabase {
return synchronized(this) {
Room.databaseBuilder(context, CompanionDatabase::class.java, "companion_database")
.fallbackToDestructiveMigration()
.build()
}
}
}

View file

@ -0,0 +1,30 @@
package com.pixelized.rplexicon.data.database.lexicon
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface LexiconDao {
@Query("SELECT * from lexicon")
fun getAllFlow(): Flow<List<LexiconDbo>>
@Query("SELECT * from lexicon WHERE id = :id")
fun getByIdFlow(id: String): Flow<LexiconDbo>
@Query("SELECT id from lexicon WHERE name = :name LIMIT 1")
suspend fun getIdByName(name: String): String?
@Insert(entity = LexiconDbo::class, onConflict = OnConflictStrategy.IGNORE)
fun insert(item: LexiconDataDbo)
@Update(entity = LexiconDbo::class)
fun update(item: LexiconDataDbo): Int
@Update(entity = LexiconDbo::class)
suspend fun update(item: LexiconReadTimestampDbo): Int
}

View file

@ -0,0 +1,48 @@
package com.pixelized.rplexicon.data.database.lexicon
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "lexicon")
data class LexiconDbo(
@PrimaryKey
val id: String,
val name: String,
val category: String?,
val diminutive: String?,
val gender: String?,
val race: String?,
val status: String?,
val location: String?,
val portrait: String?,
val description: String?,
val history: String?,
val tags: String?,
val lastUpdated: Long?,
val lastRead: Long?,
)
@Entity(tableName = "lexicon")
data class LexiconDataDbo(
@PrimaryKey
val id: String,
val name: String,
val category: String?,
val diminutive: String?,
val gender: String?,
val race: String?,
val status: String?,
val location: String?,
val portrait: String?,
val description: String?,
val history: String?,
val tags: String?,
val lastUpdated: Long?,
)
@Entity(tableName = "lexicon")
data class LexiconReadTimestampDbo(
@PrimaryKey
val id: String,
val lastRead: Long?,
)

View file

@ -0,0 +1,26 @@
package com.pixelized.rplexicon.data.database.quest
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface QuestDao {
@Query("SELECT * from quest")
fun getAllFlow(): Flow<List<QuestDbo>>
@Query("SELECT * from quest WHERE title IN (SELECT title from quest WHERE id = :id)")
fun getByIdFlow(id: String): Flow<List<QuestDbo>>
@Insert(entity = QuestDbo::class, onConflict = OnConflictStrategy.IGNORE)
fun insert(item: QuestDataDbo)
@Update(entity = QuestDbo::class)
fun update(item: QuestDataDbo): Int
@Update(entity = QuestDbo::class)
suspend fun update(item: QuestsReadTimestampDbo): Int
}

View file

@ -0,0 +1,49 @@
package com.pixelized.rplexicon.data.database.quest
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "quest")
data class QuestDbo(
@PrimaryKey
val id: String,
val category: String?,
val title: String,
val subTitle: String?,
val completed: Boolean,
val questGiver: String?,
val area: String?,
val groupReward: String?,
val individualReward: String?,
val description: String,
val illustrations: String?,
val background: String?,
val lastUpdated: Long?,
val lastRead: Long?,
)
@Entity(tableName = "quest")
data class QuestDataDbo(
@PrimaryKey
val id: String,
val category: String?,
val title: String,
val subTitle: String?,
val completed: Boolean,
val questGiver: String?,
val area: String?,
val groupReward: String?,
val individualReward: String?,
val description: String,
val illustrations: String?,
val background: String?,
val lastUpdated: Long?,
)
@Entity(tableName = "quest")
data class QuestsReadTimestampDbo(
@PrimaryKey
val id: String,
val lastRead: Long?,
)

View file

@ -17,4 +17,8 @@ data class Lexicon(
val description: String?,
val history: String?,
val tags: String?,
)
val lastUpdated: Long?,
val lastRead: Long,
) {
val isNew: Boolean get() = lastRead - (lastUpdated ?: 0) < 0
}

View file

@ -8,15 +8,17 @@ data class Quest(
val id: String,
val category: String?,
val title: String,
val lastRead: Long,
val entries: List<QuestEntry>,
) {
val complete = entries.all { it.complete }
val isNew = lastRead - entries.maxOf { it.lastUpdated ?: 0 } < 0
}
@Stable
data class QuestEntry(
val sheetIndex: Int,
val group: String?,
val id: String,
val category: String?,
val title: String,
val subtitle: String?,
val complete: Boolean,
@ -25,6 +27,7 @@ data class QuestEntry(
val groupReward: String?,
val individualReward: String?,
val description: String,
val images: List<Uri>,
val illustrations: List<Uri>,
val background: Uri?,
val lastUpdated: Long?,
)

View file

@ -1,28 +1,31 @@
package com.pixelized.rplexicon.data.parser
import com.google.api.services.sheets.v4.model.ValueRange
import com.pixelized.rplexicon.BuildConfig
import com.pixelized.rplexicon.data.database.lexicon.LexiconDataDbo
import com.pixelized.rplexicon.data.database.lexicon.LexiconDbo
import com.pixelized.rplexicon.data.model.Lexicon
import com.pixelized.rplexicon.utilitary.exceptions.IncompatibleSheetStructure
import javax.inject.Inject
class LexiconParser @Inject constructor(
private val illustrationParser: IllustrationParser,
private val timeParser: TimeUpdateParser
) {
@Throws(IncompatibleSheetStructure::class)
fun parse(sheet: ValueRange): List<Lexicon> = parserScope {
val ids = hashMapOf<String, Int>()
val lexicons = mutableListOf<Lexicon>()
fun parse(sheet: ValueRange): List<LexiconDataDbo> = parserScope(timeParser) {
val lexicons = mutableListOf<LexiconDataDbo>()
sheet.forEachRowIndexed { index, row ->
when (index) {
0 -> updateStructure(row = row, columns = COLUMNS)
else -> {
val id = row.parse(column = ID)
val name = row.parse(column = NAME)
if (name != null) {
ids[name] = ids.getOrDefault(name, 0) + 1
val lexicon = Lexicon(
id = "$name-${ids[name]}",
if (id != null && name != null) {
val lexicon = LexiconDataDbo(
id = id,
name = name,
category = row.parse(column = CATEGORY),
diminutive = row.parse(column = SHORT),
@ -30,10 +33,11 @@ class LexiconParser @Inject constructor(
race = row.parse(column = RACE),
status = row.parse(column = STATUS),
location = row.parse(column = LOCATION),
portrait = illustrationParser.parse(row.parse(column = ILLUSTRATIONS)),
portrait = row.parse(column = ILLUSTRATIONS),
description = row.parse(column = DESCRIPTION),
history = row.parse(column = HISTORY),
tags = row.parse(column = TAGS),
lastUpdated = row.parseTime(column = UPDATE),
)
lexicons.add(lexicon)
}
@ -44,7 +48,31 @@ class LexiconParser @Inject constructor(
return@parserScope lexicons
}
fun convert(data: List<LexiconDbo>): List<Lexicon> {
return data.map { convert(data = it) }
}
fun convert(data: LexiconDbo): Lexicon {
return Lexicon(
id = data.id,
name = data.name,
category = data.category,
diminutive = data.diminutive,
gender = data.gender,
race = data.race,
status = data.status,
location = data.location,
portrait = illustrationParser.parse(value = data.portrait),
description = data.description,
history = data.history,
tags = data.tags,
lastUpdated = data.lastUpdated,
lastRead = data.lastRead ?: timeParser.parser(BuildConfig.DEFAULT_READ_TIME_STAMP) ?: 0,
)
}
companion object {
private val ID = column("Id")
private val NAME = column("Nom")
private val CATEGORY = column("Catégorie")
private val SHORT = column("Diminutif")
@ -56,9 +84,11 @@ class LexiconParser @Inject constructor(
private val DESCRIPTION = column("Description")
private val HISTORY = column("Histoire")
private val TAGS = column("Mots clés")
private val UPDATE = column("Mise à jour")
private val COLUMNS
get() = listOf(
ID,
NAME,
CATEGORY,
SHORT,
@ -70,6 +100,7 @@ class LexiconParser @Inject constructor(
DESCRIPTION,
HISTORY,
TAGS,
UPDATE,
)
}
}

View file

@ -2,57 +2,88 @@ package com.pixelized.rplexicon.data.parser
import com.google.api.services.sheets.v4.model.ValueRange
import com.pixelized.rplexicon.BuildConfig
import com.pixelized.rplexicon.data.database.quest.QuestDataDbo
import com.pixelized.rplexicon.data.database.quest.QuestDbo
import com.pixelized.rplexicon.data.model.Quest
import com.pixelized.rplexicon.data.model.QuestEntry
import com.pixelized.rplexicon.utilitary.extentions.toUriOrNull
import javax.inject.Inject
class QuestParser @Inject constructor(
private val imageParser: IllustrationParser
private val illustrationParser: IllustrationParser,
private val timeParser: TimeUpdateParser
) {
fun parse(sheet: ValueRange): List<Quest> = parserScope {
val entries = hashMapOf<String, MutableList<QuestEntry>>()
fun parse(sheet: ValueRange): List<QuestDataDbo> = parserScope(timeParser) {
val quests = mutableListOf<QuestDataDbo>()
sheet.forEachRowIndexed { index, item ->
when (index) {
0 -> updateStructure(row = item, columns = COLUMNS)
else -> {
val id = item.parse(column = ID)
val quest = item.parse(column = TITLE)
val description = item.parse(column = DESCRIPTION)
if (quest != null && description != null) {
val entry = QuestEntry(
sheetIndex = index,
if (id != null && quest != null && description != null) {
val entry = QuestDataDbo(
id = id,
category = item.parse(column = CATEGORY),
title = quest,
group = item.parse(column = CATEGORY),
subtitle = item.parse(column = SUB_TITLE),
complete = item.parseBool(column = COMPLETED) ?: false,
subTitle = item.parse(column = SUB_TITLE),
completed = item.parseBool(column = COMPLETED) ?: false,
questGiver = item.parse(column = QUEST_GIVER),
area = item.parse(column = AREA),
groupReward = item.parse(column = GROUP_REWARD),
individualReward = item.parse(column = INDIVIDUAL_REWARD),
description = description,
images = imageParser.parse(item.parse(column = ILLUSTRATIONS)),
background = item.parseUri(column = BACKGROUND),
illustrations = item.parse(column = ILLUSTRATIONS),
background = item.parse(column = BACKGROUND),
lastUpdated = item.parseTime(column = UPDATE),
)
entries.getOrPut(quest) { mutableListOf() }.add(entry)
quests.add(entry)
}
}
}
}
val quests = entries.keys.map { quest ->
val relatedEntries = entries[quest] ?: emptyList()
Quest(
id = "$quest-1", // TODO refactor that when quest have ids in the google sheet.
title = quest,
category = relatedEntries.firstNotNullOfOrNull { it.group },
entries = relatedEntries,
)
}
return@parserScope quests
}
fun convert(data: List<QuestDbo>): List<Quest> {
return data.groupBy { it.title }.mapNotNull { entry ->
entry.value.firstOrNull()?.let { main ->
Quest(
id = main.id,
category = main.category,
title = main.title,
lastRead = main.lastRead ?: timeParser.parser(BuildConfig.DEFAULT_READ_TIME_STAMP) ?: 0,
entries = entry.value.map { convert(data = it) },
)
}
}
}
fun convert(data: QuestDbo): QuestEntry {
return QuestEntry(
id = data.id,
category = data.category,
title = data.title,
subtitle = data.subTitle,
complete = data.completed,
questGiver = data.questGiver,
area = data.area,
groupReward = data.groupReward,
individualReward = data.individualReward,
description = data.description,
illustrations = illustrationParser.parse(value = data.illustrations),
background = data.background?.toUriOrNull(),
lastUpdated = data.lastUpdated,
)
}
companion object {
private val ID = column("Id")
private val TITLE = column("Titre")
private val CATEGORY = column("Catégorie")
private val SUB_TITLE = column("Sous Titre")
@ -62,11 +93,13 @@ class QuestParser @Inject constructor(
private val GROUP_REWARD = column("Récompense de groupe")
private val INDIVIDUAL_REWARD = column("Récompense individuelle")
private val DESCRIPTION = column("Description")
private val ILLUSTRATIONS = column("Image", "Illustrations") // TODO remove Image after 0.9.0 release
private val BACKGROUND = column("fond", "Fond") // TODO remove "fond" after 0.7.0 release
private val ILLUSTRATIONS = column("Illustrations")
private val BACKGROUND = column("Fond")
private val UPDATE = column("Mise à jour")
private val COLUMNS
get() = listOf(
ID,
TITLE,
CATEGORY,
SUB_TITLE,
@ -78,6 +111,7 @@ class QuestParser @Inject constructor(
DESCRIPTION,
ILLUSTRATIONS,
BACKGROUND,
UPDATE,
)
}
}

View file

@ -7,12 +7,15 @@ import com.pixelized.rplexicon.utilitary.extentions.local.checkSheetStructure
import com.pixelized.rplexicon.utilitary.extentions.sheet
inline fun <reified T> parserScope(
timeParser: TimeUpdateParser? = null,
noinline block: SheetParserScope<T>.() -> T
): T {
return SheetParserScope<T>().parse(block)
return SheetParserScope<T>(timeParser).parse(block)
}
class SheetParserScope<T> {
class SheetParserScope<T>(
private val timeParser: TimeUpdateParser?,
) {
private var structure: Map<Column, Int> = hashMapOf()
fun updateStructure(row: Any, columns: List<Column>) {
@ -81,6 +84,9 @@ class SheetParserScope<T> {
fun List<*>.parseUri(column: Column): Uri? =
parse(column)?.takeIf { it.isNotBlank() }?.toUri()
fun List<*>.parseTime(column: Column): Long? =
parse(column = column)?.let { timeParser?.parser(value = it) }
fun List<*>.parseList(column: Column, separator: String = ","): List<String> =
parse(column)
?.takeIf { it.isNotBlank() }

View file

@ -0,0 +1,13 @@
package com.pixelized.rplexicon.data.parser
import java.text.SimpleDateFormat
import java.util.Locale
import javax.inject.Inject
class TimeUpdateParser @Inject constructor() {
private val formatter = SimpleDateFormat("dd/MM/yyyy HH:mm:ss", Locale.FRANCE)
fun parser(value: String?): Long? {
return value?.let { formatter.parse(it) }?.time
}
}

View file

@ -1,43 +1,101 @@
package com.pixelized.rplexicon.data.repository.lexicon
import com.pixelized.rplexicon.data.database.CompanionDatabase
import com.pixelized.rplexicon.data.database.lexicon.LexiconReadTimestampDbo
import com.pixelized.rplexicon.data.model.Lexicon
import com.pixelized.rplexicon.data.parser.LexiconParser
import com.pixelized.rplexicon.data.repository.GoogleSheetServiceRepository
import com.pixelized.rplexicon.data.repository.LexiconBinder
import com.pixelized.rplexicon.utilitary.Update
import com.pixelized.rplexicon.utilitary.exceptions.IncompatibleSheetStructure
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LexiconRepository @Inject constructor(
private val googleRepository: GoogleSheetServiceRepository,
private val database: CompanionDatabase,
private val lexiconParser: LexiconParser,
) {
private val scope = CoroutineScope(Dispatchers.Default + Job())
private val _data = MutableStateFlow<List<Lexicon>>(emptyList())
val data: StateFlow<List<Lexicon>> get() = _data
var lastSuccessFullUpdate: Update = Update.INITIAL
private set
fun find(id: String?): Lexicon? {
return id?.let { _data.value.firstOrNull { item -> item.id == it } }
init {
scope.launch(Dispatchers.IO) {
database.lexiconDao().getAllFlow().collect { data ->
_data.value = lexiconParser.convert(data = data)
}
}
}
fun findId(name: String?): String? {
return name?.let { _data.value.firstOrNull { item -> item.name == it }?.id }
/**
* Get a [Flow] of a nullable [Lexicon] instance in the list by filtering by id.
* @param id the id of the [Lexicon] instance.
* @return a [Flow] of a nullable [Lexicon] instance.
*/
fun getByIdFlow(id: String?): Flow<Lexicon> = when (id) {
null -> emptyFlow()
else -> database.lexiconDao().getByIdFlow(id = id).map {
lexiconParser.convert(it)
}
}
/**
* Find the first or null [Lexicon] instance in the list by filtering by name.
* @param name the name of the [Lexicon] instance.
* @return a nullable [Lexicon] instance.
*/
suspend fun getByNameFlow(name: String?): String? {
return name?.let { database.lexiconDao().getIdByName(name = name) }
}
/**
* Query the [Lexicon] from the backend.
* @throws IncompatibleSheetStructure if the data structure change and mandatory data are missing.
* @throws Exception if other kind of exception happen, network for example.
*/
@Throws(IncompatibleSheetStructure::class, Exception::class)
suspend fun fetchLexicon() {
googleRepository.fetch { sheet ->
val request = sheet.get(LexiconBinder.ID, LexiconBinder.LEXICON)
val data = lexiconParser.parse(sheet = request.execute())
_data.tryEmit(data)
database.lexiconDao().also { dao ->
data.forEach {
val row = dao.update(item = it)
if (row == 0) dao.insert(item = it)
}
}
lastSuccessFullUpdate = Update.currentTime()
}
}
/**
* Update the [Lexicon#lastTime] field of a [Lexicon] instance.
* @param id the id of the [Lexicon] instance.
* @param timestamp the timestamp that will update the lastRead filed.
*/
suspend fun updateReadTime(id: String, timestamp: Long = System.currentTimeMillis()) {
database.lexiconDao().update(
item = LexiconReadTimestampDbo(
id = id,
lastRead = timestamp,
)
)
}
}

View file

@ -1,38 +1,91 @@
package com.pixelized.rplexicon.data.repository.lexicon
import com.pixelized.rplexicon.data.database.CompanionDatabase
import com.pixelized.rplexicon.data.database.quest.QuestsReadTimestampDbo
import com.pixelized.rplexicon.data.model.Quest
import com.pixelized.rplexicon.data.parser.QuestParser
import com.pixelized.rplexicon.data.repository.GoogleSheetServiceRepository
import com.pixelized.rplexicon.data.repository.LexiconBinder
import com.pixelized.rplexicon.utilitary.Update
import com.pixelized.rplexicon.utilitary.exceptions.IncompatibleSheetStructure
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class QuestRepository @Inject constructor(
private val googleRepository: GoogleSheetServiceRepository,
private val database: CompanionDatabase,
private val questParser: QuestParser,
) {
private val scope = CoroutineScope(Dispatchers.Default + Job())
private val _data = MutableStateFlow<List<Quest>>(emptyList())
val data: StateFlow<List<Quest>> get() = _data
var lastSuccessFullUpdate: Update = Update.INITIAL
private set
fun find(id: String?): Quest? {
return id?.let { data.value.firstOrNull { it.id == id } }
init {
scope.launch(Dispatchers.IO) {
database.questsDao().getAllFlow().collect { data ->
_data.value = questParser.convert(data = data)
}
}
}
/**
* Find the first or null [Quest] instance in the database by filtering by id.
* @param id the id of the [Quest] instance.
* @return a nullable [Quest] instance.
*/
fun getByIdFlow(id: String?): Flow<Quest> = when (id) {
null -> emptyFlow()
else -> database.questsDao().getByIdFlow(id = id).mapNotNull {
questParser.convert(it).firstOrNull()
}
}
/**
* Query the [Quest] from the backend.
* @throws IncompatibleSheetStructure if the data structure change and mandatory data are missing.
* @throws Exception if other kind of exception happen, network for example.
*/
@Throws(IncompatibleSheetStructure::class, Exception::class)
suspend fun fetchQuests() {
googleRepository.fetch { sheet ->
val request = sheet.get(LexiconBinder.ID, LexiconBinder.QUEST_JOURNAL)
val quests = questParser.parse(sheet = request.execute())
_data.emit(quests)
val dao = database.questsDao()
quests.forEach {
val row = dao.update(item = it)
if (row == 0) dao.insert(item = it)
}
lastSuccessFullUpdate = Update.currentTime()
}
}
/**
* Update the [QuestBbo#lastTime] field of a [Quest] instance.
* @param id the id of the [Quest] instance.
* @param timestamp the timestamp that will update the lastRead filed.
*/
suspend fun updateReadTime(id: String, timestamp: Long = System.currentTimeMillis()) {
database.questsDao().update(
item = QuestsReadTimestampDbo(
id = id,
lastRead = timestamp,
)
)
}
}

View file

@ -4,10 +4,14 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pixelized.rplexicon.data.repository.character.CharacterSheetRepository
import com.pixelized.rplexicon.data.repository.lexicon.LexiconRepository
import com.pixelized.rplexicon.ui.navigation.screens.lexiconDetailArgument
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
@ -16,7 +20,9 @@ class LexiconDetailViewModel @Inject constructor(
lexiconRepository: LexiconRepository,
characterSheetRepository: CharacterSheetRepository,
) : ViewModel() {
val haveCharacterSheet: State<Boolean>
private val _haveCharacterSheet = mutableStateOf(false)
val haveCharacterSheet: State<Boolean> get() = _haveCharacterSheet
private val _character = mutableStateOf<LexiconDetailUio?>(null)
val character: State<LexiconDetailUio?> get() = _character
@ -25,9 +31,16 @@ class LexiconDetailViewModel @Inject constructor(
init {
val argument = savedStateHandle.lexiconDetailArgument
val source = lexiconRepository.find(id = argument.id)
if (source != null) {
_character.value = LexiconDetailUio(
viewModelScope.launch {
launch(Dispatchers.IO) {
// update the last read time for that lexicon
lexiconRepository.updateReadTime(id = argument.id)
}
launch(Dispatchers.IO) {
lexiconRepository.getByIdFlow(id = argument.id).collect { source ->
// build the UI object.
val character = LexiconDetailUio(
name = source.name,
diminutive = source.diminutive?.let { "./ $it" },
gender = source.gender,
@ -39,10 +52,17 @@ class LexiconDetailViewModel @Inject constructor(
history = source.history,
tags = source.tags,
)
}
haveCharacterSheet = mutableStateOf(
characterSheetRepository.find(name = source?.name) != null
)
// Check if we have a character sheet for that character.
val haveCharacterSheet = characterSheetRepository.find(
name = source.name,
) != null
// Update the UI state
withContext(Dispatchers.Main) {
_character.value = character
_haveCharacterSheet.value = haveCharacterSheet
}
}
}
}
}
}

View file

@ -50,6 +50,7 @@ data class LexiconItemUio(
val race: String?,
val isPlayingCharacter: Boolean = false,
val placeholder: Boolean = false,
val isNew: Boolean = false,
) {
companion object {
fun placeholder() = LexiconItemUio(
@ -93,6 +94,7 @@ fun LexiconItem(
.padding(end = 4.dp)
.alignByBaseline(),
style = typography.base.titleMedium,
color = if (item.isNew) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
text = if (item.isPlayingCharacter) LOS_FULL else LOS_HOLLOW,
)
@ -206,6 +208,7 @@ private class LexiconItemPreviewProvider : PreviewParameterProvider<LexiconItemU
diminutive = "Bru",
gender = "Femme",
race = "Demi-orc",
isNew = true,
),
LexiconItemUio.placeholder(),
)

View file

@ -62,7 +62,8 @@ class LexiconViewModel @Inject constructor(
diminutive = item.diminutive?.let { "./ $it" },
gender = item.gender,
race = item.race,
isPlayingCharacter = characterSheetRepository.haveSheet(item.name)
isPlayingCharacter = characterSheetRepository.haveSheet(item.name),
isNew = item.isNew,
)
},
)

View file

@ -1,15 +1,20 @@
package com.pixelized.rplexicon.ui.screens.quest.detail
import android.util.Log
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pixelized.rplexicon.data.repository.lexicon.LexiconRepository
import com.pixelized.rplexicon.data.repository.lexicon.LocationRepository
import com.pixelized.rplexicon.data.repository.lexicon.QuestRepository
import com.pixelized.rplexicon.ui.navigation.screens.lexiconDetailArgument
import com.pixelized.rplexicon.ui.navigation.screens.questDetailArgument
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
@ -26,10 +31,16 @@ class QuestDetailViewModel @Inject constructor(
init {
val argument = savedStateHandle.questDetailArgument
val source = questRepository.find(id = argument.id)
if (source != null) {
_quest.value = QuestDetailUio(
viewModelScope.launch {
launch(Dispatchers.IO) {
// update the last read time for that lexicon
questRepository.updateReadTime(id = argument.id)
}
launch(Dispatchers.IO) {
// fetch and display the detail data.
questRepository.getByIdFlow(id = argument.id).collect { source ->
val quest = QuestDetailUio(
id = source.id,
completed = source.entries.all { it.complete },
background = source.entries.mapNotNull { it.background }.randomOrNull(),
@ -38,17 +49,23 @@ class QuestDetailViewModel @Inject constructor(
val location = locationRepository.find(id = entry.area)
QuestDetailUio.QuestStep(
subtitle = entry.subtitle,
giverId = lexiconRepository.findId(entry.questGiver),
giverId = lexiconRepository.getByNameFlow(name = entry.questGiver),
giver = entry.questGiver,
placeId = location?.id,
place = location?.name ?: entry.area,
globalReward = entry.groupReward,
individualReward = entry.individualReward,
description = entry.description,
images = entry.images,
images = entry.illustrations,
)
},
)
// Update the UI state
withContext(Dispatchers.Main) {
_quest.value = quest
}
}
}
}
}
}

View file

@ -40,6 +40,7 @@ data class QuestItemUio(
val title: String,
val complete: Boolean,
val placeholder: Boolean = false,
val isNew: Boolean = false,
) {
companion object {
fun preview(
@ -77,6 +78,7 @@ fun QuestItem(
Text(
modifier = alignModifier,
style = typography.base.titleMedium,
color = if (item.isNew) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
text = if (item.complete) LOS_FULL else LOS_HOLLOW,
)
Text(

View file

@ -59,6 +59,7 @@ class QuestListViewModel @Inject constructor(
id = item.id,
title = item.title,
complete = item.complete,
isNew = item.isNew,
)
},
)

View file

@ -5,6 +5,6 @@ plugins {
id("com.google.gms.google-services") version "4.3.14" apply false
id("com.google.dagger.hilt.android") version "2.50" apply false
id("com.google.firebase.crashlytics") version "2.9.7" apply false
id("org.jetbrains.kotlin.kapt") version "1.9.10" apply false
id("androidx.room") version "2.6.0" apply false
id("com.google.devtools.ksp") version "1.9.21-1.0.16" apply false
}