diff --git a/app/schemas/com.pixelized.rplexicon.data.database.CompanionDatabase/2.json b/app/schemas/com.pixelized.rplexicon.data.database.CompanionDatabase/2.json new file mode 100644 index 0000000..74c0e79 --- /dev/null +++ b/app/schemas/com.pixelized.rplexicon.data.database.CompanionDatabase/2.json @@ -0,0 +1,432 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "10e1da384a3b4f07ee4bba8c16828742", + "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 NOT NULL, 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": true + } + ], + "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, `questGiverId` TEXT, `questGiverName` TEXT, `locationId` TEXT, `locationName` TEXT, `groupReward` TEXT, `individualReward` TEXT, `description` TEXT NOT NULL, `illustrations` TEXT, `background` TEXT, `lastUpdated` INTEGER, `lastRead` INTEGER NOT NULL, 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": "questGiverId", + "columnName": "questGiverId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "questGiverName", + "columnName": "questGiverName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locationId", + "columnName": "locationId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locationName", + "columnName": "locationName", + "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": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "location", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `category` TEXT, `description` TEXT, `map` TEXT, `illustrations` TEXT, `lastUpdated` INTEGER, `lastRead` INTEGER NOT NULL, 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": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "map", + "columnName": "map", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "illustrations", + "columnName": "illustrations", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastRead", + "columnName": "lastRead", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "world", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`parentId` TEXT NOT NULL, `childId` TEXT NOT NULL, `child` TEXT NOT NULL, `x` REAL, `y` REAL, PRIMARY KEY(`parentId`, `childId`))", + "fields": [ + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "childId", + "columnName": "childId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "child", + "columnName": "child", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "x", + "columnName": "x", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "y", + "columnName": "y", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "parentId", + "childId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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`))", + "fields": [ + { + "fieldPath": "bookTitle", + "columnName": "bookTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "adventureTitle", + "columnName": "adventureTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "adventureCategory", + "columnName": "adventureCategory", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookIcon", + "columnName": "bookIcon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "adventureBackground", + "columnName": "adventureBackground", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "bookTitle", + "adventureTitle" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "adventuresStories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookTitle` TEXT NOT NULL, `adventureTitle` TEXT NOT NULL, `index` INTEGER NOT NULL, `text` TEXT NOT NULL, `format` TEXT NOT NULL, PRIMARY KEY(`bookTitle`, `adventureTitle`, `index`))", + "fields": [ + { + "fieldPath": "bookTitle", + "columnName": "bookTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "adventureTitle", + "columnName": "adventureTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "bookTitle", + "adventureTitle", + "index" + ] + }, + "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, '10e1da384a3b4f07ee4bba8c16828742')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/LauncherViewModel.kt b/app/src/main/java/com/pixelized/rplexicon/LauncherViewModel.kt index dc025b4..754120c 100644 --- a/app/src/main/java/com/pixelized/rplexicon/LauncherViewModel.kt +++ b/app/src/main/java/com/pixelized/rplexicon/LauncherViewModel.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.pixelized.rplexicon.data.repository.adventure.AdventureRepository import com.pixelized.rplexicon.data.repository.character.ActionRepository import com.pixelized.rplexicon.data.repository.character.AlterationRepository import com.pixelized.rplexicon.data.repository.character.CharacterSheetRepository @@ -22,11 +23,13 @@ import com.pixelized.rplexicon.data.repository.lexicon.QuestRepository import com.pixelized.rplexicon.ui.composable.error.FetchErrorUio import com.pixelized.rplexicon.ui.composable.error.FetchErrorUio.Structure.Type import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject @HiltViewModel @@ -44,6 +47,7 @@ class LauncherViewModel @Inject constructor( descriptionRepository: DescriptionRepository, inventoryRepository: InventoryRepository, equipmentRepository: EquipmentRepository, + adventureRepository: AdventureRepository, ) : ViewModel() { private val _error = MutableStateFlow(null) @@ -53,7 +57,7 @@ class LauncherViewModel @Inject constructor( private set init { - viewModelScope.launch { + viewModelScope.launch(Dispatchers.Default) { val order = async { try { categoryRepository.fetchCategoryOrder() @@ -164,7 +168,9 @@ class LauncherViewModel @Inject constructor( awaitAll(order, lexicon, location, quest) awaitAll(description, inventory, equipment, alteration, action, objects, spell, skill) - isLoading = false + withContext(Dispatchers.Main) { + isLoading = false + } } } diff --git a/app/src/main/java/com/pixelized/rplexicon/data/database/CompanionDatabase.kt b/app/src/main/java/com/pixelized/rplexicon/data/database/CompanionDatabase.kt index 2f6cafe..9827326 100644 --- a/app/src/main/java/com/pixelized/rplexicon/data/database/CompanionDatabase.kt +++ b/app/src/main/java/com/pixelized/rplexicon/data/database/CompanionDatabase.kt @@ -1,9 +1,13 @@ package com.pixelized.rplexicon.data.database import android.content.Context +import androidx.room.AutoMigration import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +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.database.lexicon.LexiconDao import com.pixelized.rplexicon.data.database.lexicon.LexiconDbo import com.pixelized.rplexicon.data.database.location.LocationDao @@ -19,15 +23,26 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent @Database( - entities = [LexiconDbo::class, QuestDbo::class, LocationDbo::class, WorldDbo::class], - version = 1, + entities = [ + LexiconDbo::class, + QuestDbo::class, + LocationDbo::class, + WorldDbo::class, + AdventureDbo::class, + AdventureStoryDbo::class, + ], + version = 2, exportSchema = true, + autoMigrations = [ + AutoMigration(from = 1, to = 2) + ] ) abstract class CompanionDatabase : RoomDatabase() { abstract fun lexiconDao(): LexiconDao abstract fun questsDao(): QuestDao abstract fun locationDao(): LocationDao abstract fun worldDao(): WorldDao + abstract fun adventureDao(): AdventureDao } @Module diff --git a/app/src/main/java/com/pixelized/rplexicon/data/database/adventure/AdventureDao.kt b/app/src/main/java/com/pixelized/rplexicon/data/database/adventure/AdventureDao.kt new file mode 100644 index 0000000..98fcba8 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/data/database/adventure/AdventureDao.kt @@ -0,0 +1,86 @@ +package com.pixelized.rplexicon.data.database.adventure + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow + +@Dao +interface AdventureDao { + + @Query("SELECT * from adventures") + fun getAllAdventureFlow(): Flow> + + @Query("SELECT * from adventuresStories") + fun getAllAdventureStoryFlow(): Flow> + + @Query("SELECT * from adventures") + fun findAdventures(): List + + @Query("SELECT * from adventures where bookTitle = :bookTitle") + fun findAdventures(bookTitle: String): List + + @Query("SELECT * from adventures where bookTitle = :bookTitle and adventureTitle = :adventureTitle") + fun findAdventure(bookTitle: String, adventureTitle: String): AdventureDbo? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertBook(item: AdventureDbo) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertStory(item: AdventureStoryDbo) + + @Delete(entity = AdventureDbo::class) + fun deleteBook(id: BookPartialId) + + @Delete(entity = AdventureDbo::class) + fun deleteBook(id: StoryPartialId) + + @Delete(entity = AdventureStoryDbo::class) + fun deleteStory(id: BookPartialId) + + @Delete(entity = AdventureStoryDbo::class) + fun deleteStory(id: StoryPartialId) + + data class BookPartialId( + val bookTitle: String, + ) + + data class StoryPartialId( + val bookTitle: String, + val adventureTitle: String, + ) + + @Transaction + fun update( + booksToRemove: List, + storiesToRemove: List, + adventure: List>> + ) { + // First clean the database from old unused data. + booksToRemove.forEach { + deleteBook(id = it) + deleteStory(id = it) + } + storiesToRemove.forEach { + deleteBook(id = it) + deleteStory(id = it) + } + // then update the data. + adventure.forEach { (adventure, lines) -> + // then remove the story lines before inserting them again (easier than to keep track of them all) + deleteStory( + id = StoryPartialId( + bookTitle = adventure.bookTitle, + adventureTitle = adventure.adventureTitle, + ) + ) + // insert the adventure + insertBook(item = adventure) + // insert its lines. + lines.forEach { insertStory(item = it) } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/data/database/adventure/AdventureDbo.kt b/app/src/main/java/com/pixelized/rplexicon/data/database/adventure/AdventureDbo.kt new file mode 100644 index 0000000..7c5ba8a --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/data/database/adventure/AdventureDbo.kt @@ -0,0 +1,29 @@ +package com.pixelized.rplexicon.data.database.adventure + +import androidx.room.Entity + +@Entity( + tableName = "adventures", + primaryKeys = ["bookTitle", "adventureTitle"], +) +data class AdventureDbo( + val bookTitle: String, + val adventureTitle: String, + val adventureCategory: String?, + val bookIcon: String?, + val adventureBackground: String?, + val index: Int, + val revision: Long, +) + +@Entity( + tableName = "adventuresStories", + primaryKeys = ["bookTitle", "adventureTitle", "index"], +) +data class AdventureStoryDbo( + val bookTitle: String, + val adventureTitle: String, + val index: Int, + val text: String, + val format: String, +) \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/data/model/adventure/Adventure.kt b/app/src/main/java/com/pixelized/rplexicon/data/model/adventure/Adventure.kt new file mode 100644 index 0000000..62a1623 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/data/model/adventure/Adventure.kt @@ -0,0 +1,12 @@ +package com.pixelized.rplexicon.data.model.adventure + +import android.net.Uri + +data class Adventure( + val bookTitle: String, + val bookIcon: Uri?, + val adventureCategory: String?, + val adventureTitle: String, + val adventureBackground: Uri?, + val story: List, +) \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/data/model/adventure/AdventureBook.kt b/app/src/main/java/com/pixelized/rplexicon/data/model/adventure/AdventureBook.kt new file mode 100644 index 0000000..1ccae92 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/data/model/adventure/AdventureBook.kt @@ -0,0 +1,9 @@ +package com.pixelized.rplexicon.data.model.adventure + +import android.net.Uri + +data class AdventureBook( + val title: String, + val document: String, + val icon: Uri?, +) \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/data/model/adventure/AdventureLine.kt b/app/src/main/java/com/pixelized/rplexicon/data/model/adventure/AdventureLine.kt new file mode 100644 index 0000000..a51f90b --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/data/model/adventure/AdventureLine.kt @@ -0,0 +1,15 @@ +package com.pixelized.rplexicon.data.model.adventure + +data class AdventureLine( + val format: Format, + val text: String, +) { + enum class Format { + TITLE, + CHAPTER, + PARAGRAPH, + DIALOGUE, + ANNEX, + LEGEND, + } +} diff --git a/app/src/main/java/com/pixelized/rplexicon/data/model/adventure/AdventureStory.kt b/app/src/main/java/com/pixelized/rplexicon/data/model/adventure/AdventureStory.kt new file mode 100644 index 0000000..b4b4d02 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/data/model/adventure/AdventureStory.kt @@ -0,0 +1,10 @@ +package com.pixelized.rplexicon.data.model.adventure + +import android.net.Uri + +data class AdventureStory( + val title: String, + val category: String?, + val revision: Long, + val background: Uri?, +) \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/data/parser/adventure/AdventureBookParser.kt b/app/src/main/java/com/pixelized/rplexicon/data/parser/adventure/AdventureBookParser.kt new file mode 100644 index 0000000..f577ee8 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/data/parser/adventure/AdventureBookParser.kt @@ -0,0 +1,50 @@ +package com.pixelized.rplexicon.data.parser.adventure + +import com.google.api.services.sheets.v4.model.ValueRange +import com.pixelized.rplexicon.data.parser.column +import com.pixelized.rplexicon.data.parser.parserScope +import com.pixelized.rplexicon.data.model.adventure.AdventureBook +import com.pixelized.rplexicon.utilitary.exceptions.IncompatibleSheetStructure +import javax.inject.Inject + +class AdventureBookParser @Inject constructor() { + + @Throws(IncompatibleSheetStructure::class) + fun parse(sheet: ValueRange): List = parserScope { + val adventures = mutableListOf() + + sheet.forEachRowIndexed { index, row -> + when (index) { + 0 -> updateStructure(row = row, columns = COLUMNS) + else -> { + val character = row.parse(CHARACTER) + val document = row.parse(DOCUMENT) + + if (character != null && document != null) { + val adventure = AdventureBook( + title = character, + document = document, + icon = row.parseUri(ICON), + ) + adventures.add(adventure) + } + } + } + } + + return@parserScope adventures + } + + companion object { + private val CHARACTER = column("Titre") + private val ICON = column("Icone") + private val DOCUMENT = column("GoogleID") + + val COLUMNS + get() = listOf( + CHARACTER, + ICON, + DOCUMENT, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/data/parser/adventure/AdventureStoryLineParser.kt b/app/src/main/java/com/pixelized/rplexicon/data/parser/adventure/AdventureStoryLineParser.kt new file mode 100644 index 0000000..77a4719 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/data/parser/adventure/AdventureStoryLineParser.kt @@ -0,0 +1,54 @@ +package com.pixelized.rplexicon.data.parser.adventure + +import com.google.api.services.sheets.v4.model.ValueRange +import com.pixelized.rplexicon.data.model.adventure.AdventureLine +import com.pixelized.rplexicon.utilitary.extentions.sheet +import javax.inject.Inject + +class AdventureStoryLineParser @Inject constructor() { + + fun parse(sheet: ValueRange): List { + return sheet.mapNotNull { row -> + val format = row.parseFormat() + val text = row.parseText() + if (format != null && text != null) { + AdventureLine( + format = format, + text = text, + ) + } else { + null + } + } + } + + private fun ArrayList<*>.parseFormat(): AdventureLine.Format? = when (this[0] as? String) { + TITLE -> AdventureLine.Format.TITLE + CHAPTER -> AdventureLine.Format.CHAPTER + PARAGRAPH -> AdventureLine.Format.PARAGRAPH + DIALOGUE -> AdventureLine.Format.DIALOGUE + ANNEX -> AdventureLine.Format.ANNEX + LEGEND -> AdventureLine.Format.LEGEND + else -> null + } + + private fun ArrayList<*>.parseText(): String? = this[1] as? String + + private fun ValueRange.mapNotNull(lambda: (row: ArrayList<*>) -> T?): List { + return values.sheet()?.mapNotNull { row: Any? -> + when (row) { + is ArrayList<*> -> lambda(row) + else -> null + } + } ?: emptyList() + } + + companion object { + private const val TITLE = "Titre" + private const val CHAPTER = "Chapitre" + private const val PARAGRAPH = "Paragraphe" + private const val DIALOGUE = "Dialogue" + private const val ANNEX = "Appendice" + private const val LEGEND = "Légende" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/data/parser/adventure/AdventureStoryParser.kt b/app/src/main/java/com/pixelized/rplexicon/data/parser/adventure/AdventureStoryParser.kt new file mode 100644 index 0000000..0621be2 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/data/parser/adventure/AdventureStoryParser.kt @@ -0,0 +1,56 @@ +package com.pixelized.rplexicon.data.parser.adventure + +import com.google.api.services.sheets.v4.model.ValueRange +import com.pixelized.rplexicon.data.model.adventure.AdventureStory +import com.pixelized.rplexicon.data.parser.TimeUpdateParser +import com.pixelized.rplexicon.data.parser.column +import com.pixelized.rplexicon.data.parser.parserScope +import com.pixelized.rplexicon.utilitary.exceptions.IncompatibleSheetStructure +import javax.inject.Inject + +class AdventureStoryParser @Inject constructor( + private val timeParser: TimeUpdateParser +) { + + @Throws(IncompatibleSheetStructure::class) + fun parse(sheet: ValueRange): List = parserScope(timeParser) { + val adventures = mutableListOf() + + sheet.forEachRowIndexed { index, row -> + when (index) { + 0 -> updateStructure(row = row, columns = COLUMNS) + else -> { + val title = row.parse(TITLE) + val revision = row.parseTime(REVISION) + + if (title != null && revision != null) { + val adventure = AdventureStory( + title = title, + category = row.parse(CATEGORY), + revision = revision, + background = row.parseUri(BACKGROUND), + ) + adventures.add(adventure) + } + } + } + } + + return@parserScope adventures + } + + companion object { + val TITLE = column("Titre") + val CATEGORY = column("Catégorie") + val BACKGROUND = column("Fond") + val REVISION = column("Révision") + + val COLUMNS + get() = listOf( + TITLE, + CATEGORY, + BACKGROUND, + REVISION, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/data/repository/GoogleSheetServiceRepository.kt b/app/src/main/java/com/pixelized/rplexicon/data/repository/GoogleSheetServiceRepository.kt index 2a778fd..efbda07 100644 --- a/app/src/main/java/com/pixelized/rplexicon/data/repository/GoogleSheetServiceRepository.kt +++ b/app/src/main/java/com/pixelized/rplexicon/data/repository/GoogleSheetServiceRepository.kt @@ -28,9 +28,9 @@ class GoogleSheetServiceRepository @Inject constructor( } .build() - suspend fun fetch( - lambda: suspend CoroutineScope.(service: Sheets.Spreadsheets.Values) -> Unit, - ): Unit = withContext(Dispatchers.IO) { + suspend fun fetch( + lambda: suspend CoroutineScope.(service: Sheets.Spreadsheets.Values) -> T, + ): T = withContext(Dispatchers.IO) { lambda(service.spreadsheets().values()) } diff --git a/app/src/main/java/com/pixelized/rplexicon/data/repository/Sheets.kt b/app/src/main/java/com/pixelized/rplexicon/data/repository/Sheets.kt index 7b8972b..8de6f82 100644 --- a/app/src/main/java/com/pixelized/rplexicon/data/repository/Sheets.kt +++ b/app/src/main/java/com/pixelized/rplexicon/data/repository/Sheets.kt @@ -23,4 +23,9 @@ object CharacterBinder { const val DESCRIPTION = "Descriptions" const val INVENTORY = "Inventaires" const val EQUIPMENT = "Équipements" +} + +object Adventures { + const val ID = "1Rm3dtuwKk96RXU7Nzw_T6tnvNIUBdu7rGu-H6RZTm00" + const val ADVENTURES = "Histoires & Péripéties" } \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/data/repository/adventure/AdventureDboFactory.kt b/app/src/main/java/com/pixelized/rplexicon/data/repository/adventure/AdventureDboFactory.kt new file mode 100644 index 0000000..83b1ed7 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/data/repository/adventure/AdventureDboFactory.kt @@ -0,0 +1,74 @@ +package com.pixelized.rplexicon.data.repository.adventure + +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 +import com.pixelized.rplexicon.data.model.adventure.AdventureStory +import com.pixelized.rplexicon.utilitary.extentions.string.toUriOrNull +import javax.inject.Inject + +class AdventureDboFactory @Inject constructor() { + + fun convertFromDbo( + adventures: List, + stories: List, + ): List { + return adventures + .sortedBy { it.index } + .map { adventure -> + Adventure( + bookTitle = adventure.bookTitle, + bookIcon = adventure.bookIcon.toUriOrNull(), + adventureTitle = adventure.adventureTitle, + adventureCategory = adventure.adventureCategory, + adventureBackground = adventure.adventureBackground.toUriOrNull(), + story = stories + .filter { + it.bookTitle == adventure.bookTitle && it.adventureTitle == adventure.adventureTitle + } + .sortedBy { + it.index + } + .map { story -> + AdventureLine( + text = story.text, + format = try { + AdventureLine.Format.valueOf(story.format) + } catch (_: Exception) { + AdventureLine.Format.PARAGRAPH + }, + ) + }, + ) + } + } + + fun convertToAdventureDbo( + book: AdventureBook, + story: AdventureStory, + index: Int, + ) = AdventureDbo( + bookTitle = book.title, + adventureTitle = story.title, + adventureCategory = story.category, + bookIcon = book.icon.toString(), + adventureBackground = story.background.toString(), + index = index, + revision = story.revision, + ) + + fun convertToStoryDbo( + book: AdventureBook, + story: AdventureStory, + index: Int, + line: AdventureLine + ) = AdventureStoryDbo( + bookTitle = book.title, + adventureTitle = story.title, + index = index, + text = line.text, + format = line.format.name, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/data/repository/adventure/AdventureRepository.kt b/app/src/main/java/com/pixelized/rplexicon/data/repository/adventure/AdventureRepository.kt new file mode 100644 index 0000000..dbcba50 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/data/repository/adventure/AdventureRepository.kt @@ -0,0 +1,143 @@ +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.model.adventure.Adventure +import com.pixelized.rplexicon.data.model.adventure.AdventureBook +import com.pixelized.rplexicon.data.model.adventure.AdventureLine +import com.pixelized.rplexicon.data.model.adventure.AdventureStory +import com.pixelized.rplexicon.data.parser.adventure.AdventureBookParser +import com.pixelized.rplexicon.data.parser.adventure.AdventureStoryLineParser +import com.pixelized.rplexicon.data.parser.adventure.AdventureStoryParser +import com.pixelized.rplexicon.data.repository.Adventures +import com.pixelized.rplexicon.data.repository.GoogleSheetServiceRepository +import com.pixelized.rplexicon.utilitary.exceptions.IncompatibleSheetStructure +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AdventureRepository @Inject constructor( + private val googleRepository: GoogleSheetServiceRepository, + private val database: CompanionDatabase, + private val adventureBookParser: AdventureBookParser, + private val adventureStoryParser: AdventureStoryParser, + private val adventureStoryLineParser: AdventureStoryLineParser, + private val adventureDboFactory: AdventureDboFactory, +) { + val adventure = database.adventureDao().getAllAdventureFlow() + .combine( + flow = database.adventureDao().getAllAdventureStoryFlow(), + transform = adventureDboFactory::convertFromDbo, + ).stateIn( + scope = CoroutineScope(Dispatchers.Default + Job()), + started = SharingStarted.Lazily, + initialValue = emptyList(), + ) + + @Throws(IncompatibleSheetStructure::class, Exception::class) + suspend fun fetchAdventures() { + val database = database.adventureDao() + val bookStore = database.findAdventures() + + val bookToRemove = bookStore.associate { + it.bookTitle to true + }.toMutableMap() + val storyToRemove = bookStore.associate { + (it.bookTitle to it.adventureTitle) to true + }.toMutableMap() + + val adventures = fetchAdventureBook().flatMap { book -> + // tag this book to keep it in the database + bookToRemove[book.title] = false + + val stories = fetchAdventureStory(book = book) + stories.mapNotNull { story -> + // tag this story to keep it in the database + storyToRemove[book.title to story.title] = false + + val adventure = adventureDboFactory.convertToAdventureDbo( + book = book, + story = story, + index = stories.indexOf(story), + ) + val cache = database.findAdventure( + bookTitle = book.title, + adventureTitle = story.title, + ) + + if (cache == null || cache.revision < adventure.revision) { + val lines = fetchAdventureLine(book = book, story = story) + .mapIndexed { index, line -> + adventureDboFactory.convertToStoryDbo( + book = book, + story = story, + index = index, + line = line + ) + } + adventure to lines + } else { + null + } + } + } + + 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 { + return adventure.value.filter { adventure -> + adventure.bookTitle == bookTitle + } + } + + fun find(bookTitle: String, adventureTitle: String): Adventure? { + return adventure.value.firstOrNull { adventure -> + adventure.bookTitle == bookTitle && adventure.adventureTitle == adventureTitle + } + } + + @Throws(IncompatibleSheetStructure::class, Exception::class) + private suspend fun fetchAdventureBook(): List { + return googleRepository.fetch { sheet -> + val request = sheet.get(Adventures.ID, Adventures.ADVENTURES) + adventureBookParser.parse(sheet = request.execute()) + } + } + + @Throws(IncompatibleSheetStructure::class, Exception::class) + private suspend fun fetchAdventureStory(book: AdventureBook): List { + return googleRepository.fetch { sheet -> + val request = sheet.get(book.document, Adventures.ADVENTURES) + adventureStoryParser.parse(sheet = request.execute()) + } + } + + private suspend fun fetchAdventureLine( + book: AdventureBook, + story: AdventureStory + ): List { + return try { + googleRepository.fetch { sheet -> + val request = sheet.get(book.document, story.title) + adventureStoryLineParser.parse(sheet = request.execute()) + } + } catch (exception: Exception) { + emptyList() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/composable/error/FetchErrorUio.kt b/app/src/main/java/com/pixelized/rplexicon/ui/composable/error/FetchErrorUio.kt index 1f59559..66ab1b5 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/composable/error/FetchErrorUio.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/composable/error/FetchErrorUio.kt @@ -35,6 +35,7 @@ sealed class FetchErrorUio { LEXICON, LOCATION, QUEST, + ADVENTURE, } } @@ -62,6 +63,7 @@ fun HandleFetchError( FetchErrorUio.Structure.Type.LEXICON -> R.string.error__structure_lexicon FetchErrorUio.Structure.Type.LOCATION -> R.string.error__structure_location FetchErrorUio.Structure.Type.QUEST -> R.string.error__structure_quest + FetchErrorUio.Structure.Type.ADVENTURE -> R.string.error__structure_adventure } snackHost.showSnackbar(message = context.getString(messageResources)) }, diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/composable/rememberAnimatedShadow.kt b/app/src/main/java/com/pixelized/rplexicon/ui/composable/rememberAnimatedShadow.kt index ba7649b..0689e32 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/composable/rememberAnimatedShadow.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/composable/rememberAnimatedShadow.kt @@ -2,6 +2,7 @@ package com.pixelized.rplexicon.ui.composable import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf @@ -28,4 +29,25 @@ fun rememberAnimatedShadow( targetValue = shadowTarget.value, label = "animated shadow", ) +} + +@Composable +fun rememberAnimatedShadow( + lazyListState: LazyGridState, + rest: Dp = 0.dp, + target: Dp = 4.dp, +): State { + val shadowTarget = remember(lazyListState) { + derivedStateOf { + if (lazyListState.firstVisibleItemScrollOffset > 0 || lazyListState.firstVisibleItemIndex != 0) { + target + } else { + rest + } + } + } + return animateDpAsState( + targetValue = shadowTarget.value, + label = "animated shadow", + ) } \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/navigation/ScreenNavHost.kt b/app/src/main/java/com/pixelized/rplexicon/ui/navigation/ScreenNavHost.kt index 169c91c..1dfc751 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/navigation/ScreenNavHost.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/navigation/ScreenNavHost.kt @@ -10,6 +10,9 @@ import androidx.navigation.NavOptionsBuilder import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import com.pixelized.rplexicon.ui.navigation.screens.AUTHENTICATION_ROUTE +import com.pixelized.rplexicon.ui.navigation.screens.composableAdventureDetail +import com.pixelized.rplexicon.ui.navigation.screens.composableAdventureBooks +import com.pixelized.rplexicon.ui.navigation.screens.composableAdventureChapters import com.pixelized.rplexicon.ui.navigation.screens.composableAuthentication import com.pixelized.rplexicon.ui.navigation.screens.composableCharacterSheet import com.pixelized.rplexicon.ui.navigation.screens.composableLanding @@ -60,6 +63,9 @@ fun ScreenNavHost( composableCharacterSheet() composableSpellDetail() composableSummary() + composableAdventureBooks() + composableAdventureChapters() + composableAdventureDetail() } } } diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/navigation/screens/ComposableAdventureBooks.kt b/app/src/main/java/com/pixelized/rplexicon/ui/navigation/screens/ComposableAdventureBooks.kt new file mode 100644 index 0000000..09e1e0f --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/ui/navigation/screens/ComposableAdventureBooks.kt @@ -0,0 +1,25 @@ +package com.pixelized.rplexicon.ui.navigation.screens + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavOptionsBuilder +import com.pixelized.rplexicon.ui.navigation.NavigationAnimation +import com.pixelized.rplexicon.ui.navigation.animatedComposable +import com.pixelized.rplexicon.ui.screens.adventure.book.AdventureBooksScreen + +private const val ROUTE = "adventures" + +fun NavGraphBuilder.composableAdventureBooks() { + animatedComposable( + route = ROUTE, + animation = NavigationAnimation.Push, + ) { + AdventureBooksScreen() + } +} + +fun NavHostController.navigateToAdventures( + option: NavOptionsBuilder.() -> Unit = {}, +) { + navigate(route = ROUTE, builder = option) +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/navigation/screens/ComposableAdventureChapter.kt b/app/src/main/java/com/pixelized/rplexicon/ui/navigation/screens/ComposableAdventureChapter.kt new file mode 100644 index 0000000..f0704cc --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/ui/navigation/screens/ComposableAdventureChapter.kt @@ -0,0 +1,54 @@ +package com.pixelized.rplexicon.ui.navigation.screens + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavOptionsBuilder +import androidx.navigation.NavType +import androidx.navigation.navArgument +import com.pixelized.rplexicon.ui.navigation.NavigationAnimation +import com.pixelized.rplexicon.ui.navigation.animatedComposable +import com.pixelized.rplexicon.ui.screens.adventure.chapter.AdventureChaptersScreen +import com.pixelized.rplexicon.utilitary.extentions.string.ARG + +private const val ROUTE = "adventureDetail" +private const val BOOK_TITLE_ARG = "bookTitle" + +val ADVENTURE_CHAPTER_ROUTE = "$ROUTE?${BOOK_TITLE_ARG.ARG}" + +@Stable +@Immutable +class AdventureChapterArgument( + val bookTitle: String, +) + +val SavedStateHandle.adventureChaptersArgument: AdventureChapterArgument + get() = AdventureChapterArgument( + bookTitle = get(BOOK_TITLE_ARG) + ?: error("AdventureDetailArgument missing argument: $BOOK_TITLE_ARG"), + ) + +fun NavGraphBuilder.composableAdventureChapters() { + animatedComposable( + route = ADVENTURE_CHAPTER_ROUTE, + animation = NavigationAnimation.Push, + arguments = listOf( + navArgument(name = BOOK_TITLE_ARG) { + type = NavType.StringType + nullable = false + }, + ) + ) { + AdventureChaptersScreen() + } +} + +fun NavHostController.navigateToAdventureChapters( + bookTitle: String, + option: NavOptionsBuilder.() -> Unit = {}, +) { + val route = "$ROUTE?$BOOK_TITLE_ARG=$bookTitle" + navigate(route = route, builder = option) +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/navigation/screens/ComposableAdventureDetail.kt b/app/src/main/java/com/pixelized/rplexicon/ui/navigation/screens/ComposableAdventureDetail.kt new file mode 100644 index 0000000..9d6e79f --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/ui/navigation/screens/ComposableAdventureDetail.kt @@ -0,0 +1,63 @@ +package com.pixelized.rplexicon.ui.navigation.screens + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavOptionsBuilder +import androidx.navigation.NavType +import androidx.navigation.navArgument +import com.pixelized.rplexicon.ui.navigation.NavigationAnimation +import com.pixelized.rplexicon.ui.navigation.animatedComposable +import com.pixelized.rplexicon.ui.screens.adventure.detail.AdventureDetailScreen +import com.pixelized.rplexicon.utilitary.extentions.string.ARG + +private const val ROUTE = "adventureDetail" +private const val BOOK_TITLE_ARG = "bookTitle" +private const val ADVENTURE_TITLE_ARG = "adventureTitle" + +val ADVENTURE_DETAIL_ROUTE = "$ROUTE?${BOOK_TITLE_ARG.ARG}&${ADVENTURE_TITLE_ARG.ARG}" + +@Stable +@Immutable +class AdventureDetailArgument( + val bookTitle: String, + val adventureTitle: String, +) + +val SavedStateHandle.adventureDetailArgument: AdventureDetailArgument + get() = AdventureDetailArgument( + bookTitle = get(BOOK_TITLE_ARG) + ?: error("AdventureDetailArgument missing argument: $BOOK_TITLE_ARG"), + adventureTitle = get(ADVENTURE_TITLE_ARG) + ?: error("AdventureDetailArgument missing argument: $ADVENTURE_TITLE_ARG"), + ) + +fun NavGraphBuilder.composableAdventureDetail() { + animatedComposable( + route = ADVENTURE_DETAIL_ROUTE, + animation = NavigationAnimation.Push, + arguments = listOf( + navArgument(name = BOOK_TITLE_ARG) { + type = NavType.StringType + nullable = false + }, + navArgument(name = ADVENTURE_TITLE_ARG) { + type = NavType.StringType + nullable = false + }, + ) + ) { + AdventureDetailScreen() + } +} + +fun NavHostController.navigateToAdventureDetail( + bookTitle: String, + adventureTitle: String, + option: NavOptionsBuilder.() -> Unit = {}, +) { + val route = "$ROUTE?$BOOK_TITLE_ARG=$bookTitle&$ADVENTURE_TITLE_ARG=$adventureTitle" + navigate(route = route, builder = option) +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/book/AdventureBook.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/book/AdventureBook.kt new file mode 100644 index 0000000..6c734aa --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/book/AdventureBook.kt @@ -0,0 +1,88 @@ +package com.pixelized.rplexicon.ui.screens.adventure.book + +import android.content.res.Configuration +import android.net.Uri +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.pixelized.rplexicon.ui.composable.images.AsyncImage +import com.pixelized.rplexicon.ui.composable.images.BackgroundImage +import com.pixelized.rplexicon.ui.theme.LexiconTheme +import com.pixelized.rplexicon.utilitary.annotateWithDropCap +import com.pixelized.rplexicon.utilitary.extentions.lexicon + +@Stable +data class AdventureBookUio( + val bookTitle: String, + val bookIcon: Uri?, +) + +@Composable +fun AdventureBook( + modifier: Modifier = Modifier, + paddingValues: PaddingValues = PaddingValues(all = 8.dp), + shape: Shape = remember { RoundedCornerShape(size = 8.dp) }, + item: AdventureBookUio, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .clip(shape = shape) + .clickable(onClick = onClick) + .then(other = modifier), + ) { + BackgroundImage( + modifier = Modifier + .matchParentSize() + .align(alignment = Alignment.TopCenter), + colorFilter = null, + contentScale = ContentScale.FillWidth, + alignment = Alignment.TopCenter, + model = item.bookIcon, + ) + Text( + modifier = Modifier + .align(alignment = Alignment.BottomCenter) + .padding(paddingValues), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + text = annotateWithDropCap( + style = MaterialTheme.lexicon.typography.dropCap.bodyMedium, + text = item.bookTitle + ), + ) + } +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun AdventureItemPreview() { + LexiconTheme { + Surface { + AdventureBook( + item = AdventureBookUio( + bookIcon = null, + bookTitle = "Les chroniques d'une orc", + ), + onClick = {}, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/book/AdventureBooksScreen.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/book/AdventureBooksScreen.kt new file mode 100644 index 0000000..cd2e234 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/book/AdventureBooksScreen.kt @@ -0,0 +1,175 @@ +package com.pixelized.rplexicon.ui.screens.adventure.book + +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +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 +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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 +import androidx.compose.ui.res.stringResource +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.navigateToAdventureChapters +import com.pixelized.rplexicon.ui.theme.LexiconTheme +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun AdventureBooksScreen( + viewModel: AdventureBooksViewModel = hiltViewModel(), +) { + val screen = LocalScreenNavHost.current + val scope = rememberCoroutineScope() + val refresh = rememberPullRefreshState( + refreshing = false, + onRefresh = { + scope.launch { viewModel.update() } + }, + ) + + Surface( + modifier = Modifier.fillMaxSize(), + ) { + AdventureListContent( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding(), + items = viewModel.books, + refreshState = refresh, + refreshing = viewModel.isLoading, + onBack = { screen.popBackStack() }, + onBook = { screen.navigateToAdventureChapters(bookTitle = it.bookTitle) }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) +@Composable +private fun AdventureListContent( + modifier: Modifier = Modifier, + gridState: LazyGridState = rememberLazyGridState(), + paddingValues: PaddingValues = PaddingValues(all = 16.dp), + items: State>, + refreshState: PullRefreshState, + refreshing: State, + onBack: () -> Unit, + onBook: (AdventureBookUio) -> Unit, +) { + Scaffold( + modifier = modifier, + topBar = { + val shadow = rememberAnimatedShadow(gridState) + TopAppBar( + modifier = Modifier.shadow(elevation = shadow.value), + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_back_ios_new_24), + contentDescription = null + ) + } + }, + title = { + Text(text = stringResource(id = R.string.adventures_title)) + }, + ) + }, + content = { it -> + Box( + modifier = Modifier.padding(paddingValues = it), + contentAlignment = Alignment.TopCenter, + ) { + LazyVerticalGrid( + modifier = Modifier.fillMaxSize().pullRefresh(state = refreshState), + state = gridState, + columns = GridCells.Fixed(3), + contentPadding = paddingValues, + horizontalArrangement = Arrangement.spacedBy(space = 8.dp), + verticalArrangement = Arrangement.spacedBy(space = 8.dp), + ) { + items(items = items.value) { + AdventureBook( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(ratio = 0.8f), + item = it, + onClick = { onBook(it) }, + ) + } + } + Loader( + refreshState = refreshState, + refreshing = refreshing, + ) + } + } + ) +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun AdventureListPreview( + modifier: Modifier = Modifier, +) { + LexiconTheme { + Surface( + modifier = Modifier.fillMaxSize(), + ) { + AdventureListContent( + modifier = Modifier.fillMaxSize(), + items = remember { + mutableStateOf( + listOf( + AdventureBookUio( + bookTitle = "Les chroniques d'une orc", + bookIcon = null, + ) + ) + ) + }, + refreshState = rememberPullRefreshState( + refreshing = false, + onRefresh = { }, + ), + refreshing = remember { mutableStateOf(false) }, + onBack = { }, + onBook = { }, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/book/AdventureBooksViewModel.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/book/AdventureBooksViewModel.kt new file mode 100644 index 0000000..e1524a1 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/book/AdventureBooksViewModel.kt @@ -0,0 +1,78 @@ +package com.pixelized.rplexicon.ui.screens.adventure.book + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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 dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +class AdventureBooksViewModel @Inject constructor( + private val adventureRepository: AdventureRepository, +) : ViewModel() { + + private val _error = MutableSharedFlow() + + private val _isLoading = mutableStateOf(false) + val isLoading: State get() = _isLoading + + private val _books = adventureRepository.adventure + .map { adventures -> + adventures + .map { adventure -> + AdventureBookUio( + bookTitle = adventure.bookTitle, + bookIcon = adventure.bookIcon, + ) + } + .toSet() + .sortedBy { + it.bookTitle + } + } + + val books: State> + @Composable + @Stable + get() = _books.collectAsState(initial = emptyList()) + + init { + viewModelScope.launch(Dispatchers.Default) { + try { + adventureRepository.fetchAdventures() + } catch (exception: Exception) { + _error.emit(value = Structure(ADVENTURE)) + } + } + } + + suspend fun update() { + try { + withContext(Dispatchers.Main) { + _isLoading.value = true + } + withContext(Dispatchers.Default) { + adventureRepository.fetchAdventures() + } + } catch (_: Exception) { + _error.emit(value = Structure(ADVENTURE)) + } finally { + withContext(Dispatchers.Main) { + _isLoading.value = false + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/chapter/AdventureChapter.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/chapter/AdventureChapter.kt new file mode 100644 index 0000000..e66ef2a --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/chapter/AdventureChapter.kt @@ -0,0 +1,120 @@ +package com.pixelized.rplexicon.ui.screens.adventure.chapter + +import android.content.res.Configuration +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material.minimumInteractiveComponentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.pixelized.rplexicon.ui.theme.LexiconTheme +import com.pixelized.rplexicon.utilitary.LOS_FULL +import com.pixelized.rplexicon.utilitary.annotateWithDropCap +import com.pixelized.rplexicon.utilitary.extentions.lexicon + +@Stable +sealed class AdventureChapterUio { + + @Stable + data class AdventureCategory( + val title: String, + ) : AdventureChapterUio() + + @Stable + data class AdventureItem( + val bookTitle: String, + val adventureTitle: String, + ) : AdventureChapterUio() +} + +@Composable +fun AdventureChapterCategory( + modifier: Modifier = Modifier, + paddingValues: PaddingValues = PaddingValues(horizontal = 16.dp), + category: AdventureChapterUio.AdventureCategory, +) { + Text( + modifier = modifier.padding(paddingValues = paddingValues), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Light, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + text = category.title, + ) +} + +@Composable +fun AdventureChapterItem( + modifier: Modifier = Modifier, + paddingValues: PaddingValues = PaddingValues(horizontal = 16.dp), + item: AdventureChapterUio.AdventureItem, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .clickable(onClick = onClick) + .minimumInteractiveComponentSize() + .padding(paddingValues = paddingValues) + .then(other = modifier), + horizontalArrangement = Arrangement.spacedBy(space = 8.dp), + ) { + Text( + modifier = Modifier.alignByBaseline(), + style = MaterialTheme.typography.titleMedium, + text = LOS_FULL, + ) + + Text( + modifier = Modifier.alignByBaseline(), + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + text = annotateWithDropCap( + text = item.adventureTitle, + style = MaterialTheme.lexicon.typography.dropCap.titleMedium, + ), + ) + } +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun AdventureChapterCategoryPreview() { + LexiconTheme { + Surface { + AdventureChapterCategory( + category = AdventureChapterUio.AdventureCategory( + title = "Les chroniques d'une orc", + ), + ) + } + } +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun AdventureChapterItemPreview() { + LexiconTheme { + Surface { + AdventureChapterItem( + item = AdventureChapterUio.AdventureItem( + bookTitle = "Les chroniques d'une orc", + adventureTitle = "La traque", + ), + onClick = { }, + ) + } + } +} diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/chapter/AdventureChaptersScreen.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/chapter/AdventureChaptersScreen.kt new file mode 100644 index 0000000..217e810 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/chapter/AdventureChaptersScreen.kt @@ -0,0 +1,185 @@ +package com.pixelized.rplexicon.ui.screens.adventure.chapter + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.res.painterResource +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.rememberAnimatedShadow +import com.pixelized.rplexicon.ui.navigation.LocalScreenNavHost +import com.pixelized.rplexicon.ui.navigation.screens.navigateToAdventureDetail +import com.pixelized.rplexicon.ui.theme.LexiconTheme + + +@Composable +fun AdventureChaptersScreen( + viewModel: AdventureChaptersViewModel = hiltViewModel(), +) { + val screen = LocalScreenNavHost.current + + Surface( + modifier = Modifier.fillMaxSize() + ) { + AdventureChapterContent( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding(), + bookTitle = viewModel.bookTitle, + chapters = viewModel.chapters, + onChapter = { + screen.navigateToAdventureDetail( + bookTitle = it.bookTitle, + adventureTitle = it.adventureTitle + ) + }, + onBack = { screen.popBackStack() }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AdventureChapterContent( + modifier: Modifier = Modifier, + lazyListState: LazyListState = rememberLazyListState(), + paddingValues: PaddingValues = PaddingValues(vertical = 16.dp), + bookTitle: State, + chapters: State>, + onChapter: (AdventureChapterUio.AdventureItem) -> Unit, + onBack: () -> Unit, +) { + Scaffold( + modifier = modifier, + topBar = { + val shadow = rememberAnimatedShadow(lazyListState) + TopAppBar( + modifier = Modifier.shadow(elevation = shadow.value), + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_back_ios_new_24), + contentDescription = null + ) + } + }, + title = { + Text(text = bookTitle.value) + }, + ) + }, + content = { it -> + LazyColumn( + modifier = Modifier.padding(paddingValues = it), + state = lazyListState, + contentPadding = paddingValues, + verticalArrangement = Arrangement.spacedBy(space = 8.dp), + ) { + itemsIndexed(items = chapters.value) { index, item -> + when (item) { + is AdventureChapterUio.AdventureCategory -> { + AdventureChapterCategory( + modifier = Modifier + .padding(top = if (index != 0) 32.dp else 0.dp) + .fillMaxWidth(), + category = item, + ) + } + + is AdventureChapterUio.AdventureItem -> { + AdventureChapterItem( + modifier = Modifier.fillMaxWidth(), + item = item, + onClick = { onChapter(item) }, + ) + } + } + } + } + } + ) +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun AdventureChapterPreview() { + LexiconTheme { + Surface { + AdventureChapterContent( + modifier = Modifier.fillMaxSize(), + bookTitle = remember { + mutableStateOf( + "Les chroniques d'une orc" + ) + }, + chapters = remember { + mutableStateOf( + listOf( + AdventureChapterUio.AdventureItem( + bookTitle = "Les chroniques d'une orc", + adventureTitle = "Biographie", + ), + AdventureChapterUio.AdventureCategory( + title = "Mémoire d'une orc", + ), + AdventureChapterUio.AdventureItem( + bookTitle = "Les chroniques d'une orc", + adventureTitle = "La traque", + ), + AdventureChapterUio.AdventureItem( + bookTitle = "Les chroniques d'une orc", + adventureTitle = "Les six mercenaires", + ), + AdventureChapterUio.AdventureItem( + bookTitle = "Les chroniques d'une orc", + adventureTitle = "La couronne de cuivre", + ), + AdventureChapterUio.AdventureItem( + bookTitle = "Les chroniques d'une orc", + adventureTitle = "Le seigneur tout puissant", + ), + AdventureChapterUio.AdventureItem( + bookTitle = "Les chroniques d'une orc", + adventureTitle = "Vague à l'Amn", + ), + AdventureChapterUio.AdventureItem( + bookTitle = "Tu parles d'une galère", + adventureTitle = "Tu parles d'une galère", + ), + AdventureChapterUio.AdventureItem( + bookTitle = "Les chroniques d'une orc", + adventureTitle = "Liberté", + ), + ) + ) + }, + onBack = { }, + onChapter = { }, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/chapter/AdventureChaptersViewModel.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/chapter/AdventureChaptersViewModel.kt new file mode 100644 index 0000000..578eda4 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/chapter/AdventureChaptersViewModel.kt @@ -0,0 +1,41 @@ +package com.pixelized.rplexicon.ui.screens.adventure.chapter + +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.navigation.screens.adventureChaptersArgument +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class AdventureChaptersViewModel @Inject constructor( + adventureRepository: AdventureRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val argument = savedStateHandle.adventureChaptersArgument + private val _chapters = mutableStateOf( + adventureRepository.find(bookTitle = argument.bookTitle) + ) + + val bookTitle = derivedStateOf { + argument.bookTitle + } + val chapters = derivedStateOf { + _chapters.value + .groupBy { it.adventureCategory } + .flatMap { entry -> + val header = entry.key?.let { + listOf(AdventureChapterUio.AdventureCategory(title = it)) + } ?: emptyList() + val stories = entry.value.map { + AdventureChapterUio.AdventureItem( + bookTitle = it.bookTitle, + adventureTitle = it.adventureTitle, + ) + } + header + stories + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/detail/AdventureDetailScreen.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/detail/AdventureDetailScreen.kt new file mode 100644 index 0000000..aaf6a13 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/detail/AdventureDetailScreen.kt @@ -0,0 +1,269 @@ +package com.pixelized.rplexicon.ui.screens.adventure.detail + +import android.content.res.Configuration +import android.net.Uri +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import com.pixelized.rplexicon.R +import com.pixelized.rplexicon.ui.composable.images.BackgroundImage +import com.pixelized.rplexicon.ui.navigation.LocalScreenNavHost +import com.pixelized.rplexicon.ui.screens.adventure.detail.AdventureUio.Style.LEGEND +import com.pixelized.rplexicon.ui.screens.adventure.detail.AdventureUio.Style.PARAGRAPH +import com.pixelized.rplexicon.ui.screens.adventure.detail.AdventureUio.Style.TITLE +import com.pixelized.rplexicon.ui.theme.LexiconTheme + +@Composable +fun AdventureDetailScreen( + viewModel: AdventureDetailViewModel = hiltViewModel(), +) { + val screen = LocalScreenNavHost.current + + Surface( + modifier = Modifier.fillMaxSize(), + ) { + AdventureDetailContent( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + background = viewModel.background, + adventureTitle = viewModel.title, + adventures = viewModel.adventure, + onBack = { screen.popBackStack() }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AdventureDetailContent( + modifier: Modifier = Modifier, + paddingValues: PaddingValues = PaddingValues( + start = 16.dp, + top = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp.toDp() } + 56.dp, + end = 16.dp, + bottom = 16.dp, + ), + lazyListState: LazyListState = rememberLazyListState(), + adventureTitle: State, + background: State, + adventures: State>, + onBack: () -> Unit, +) { + val nestedScrollOffset = rememberSaveable { mutableFloatStateOf(0f) } + val titlePosition = rememberSaveable { mutableFloatStateOf(0f) } + val titleHeight = rememberSaveable { mutableFloatStateOf(0f) } + + val nestedScrollConnexion = remember { + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + nestedScrollOffset.floatValue += consumed.y + return super.onPostScroll(consumed, available, source) + } + } + } + val topAppBarAlpha = remember { + derivedStateOf { + when { + -nestedScrollOffset.floatValue < titlePosition.floatValue -> 0f + -nestedScrollOffset.floatValue > titlePosition.floatValue + titleHeight.floatValue -> 1f + titleHeight.floatValue != 0f -> (-nestedScrollOffset.floatValue - titlePosition.floatValue) / titleHeight.floatValue + else -> 0f + }.coerceIn(minimumValue = 0f, maximumValue = 1f) + } + } + val backgroundAlpha = remember { + derivedStateOf { + (titlePosition.floatValue).let { + (it + nestedScrollOffset.floatValue) / it + } + } + } + + Box( + modifier = modifier, + ) { + background.value?.let { uri -> + BackgroundImage( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(ratio = 1f) + .alpha(alpha = backgroundAlpha.value) + .offset { IntOffset(x = 0, y = nestedScrollOffset.floatValue.toInt() / 2) }, + model = uri, + ) + } + + Column( + modifier = Modifier.nestedScroll(connection = nestedScrollConnexion) + ) { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + ), + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_back_ios_new_24), + contentDescription = null + ) + } + }, + title = { + val alpha = animateFloatAsState( + targetValue = topAppBarAlpha.value, + label = "TopAppBarLabel", + ) + Text( + modifier = Modifier.alpha(alpha = alpha.value), + text = adventureTitle.value ?: "", + ) + }, + ) + LazyColumn( + state = lazyListState, + contentPadding = paddingValues, + ) { + itemsIndexed( + items = adventures.value, + ) { index, adventure -> + val previous = adventures.value.getOrNull(index - 1) + AdventureLine( + modifier = when (index) { + 0 -> Modifier.onGloballyPositioned { coordinate -> + if (titlePosition.floatValue == 0f) { + titlePosition.floatValue = coordinate.positionInParent().y + } + if (titleHeight.floatValue == 0f) { + titleHeight.floatValue = coordinate.size.height.toFloat() + } + } + else -> Modifier + }, + paddingValues = rememberPaddingValues( + current = adventure, + previous = previous, + ), + item = adventure, + ) + } + } + } + } +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun AdventureDetailPreview() { + LexiconTheme { + Surface { + AdventureDetailContent( + adventureTitle = remember { + mutableStateOf("La traque") + }, + background = remember { + mutableStateOf( + "https://img.freepik.com/premium-photo/painting-deer-forest-with-stream-water-generative-ai_955925-17321.jpg".toUri() + ) + }, + adventures = remember { + mutableStateOf( + listOf( + AdventureUio( + text = "La traque", + style = TITLE, + ), + AdventureUio( + text = "Il était temps pour moi de partir à la chasse. Il ne restait déjà plus grand chose du loup d’il y a quelques jours. J'éteignis les dernières braises de mon feu, récupérai dagues, javelot et mon outre d’eau puis dissimulai le reste de mes biens au fond de la caverne me servant d'abri…", + style = PARAGRAPH, + ), + AdventureUio( + text = "Je me dirigeai vers un point d’eau où j'avais par le passé déjà aperçu des cervidés. Une fois sur place, je me mis à l’abri des regards dans un fourré et dissimulai mon odeur avec de la boue. L’attente fut longue, mais la chasse exige patience et discipline. Puis, sur l’autre rive, je repérai une harde de cerfs et l'excitation monta en moi.", + style = PARAGRAPH, + ), + AdventureUio( + text = "Je pris soin de bien choisir ma cible. Tuer un mâle serait gâcher. Seule, je serais incapable de finir sa viande avant qu’elle ne pourrisse. Je choisis donc une femelle au ventre plat indiquant qu’elle n’avait pas mis bas récemment. Dès l’instant où ses lèvres touchèrent la surface de la marre, je lançai mon javelot qui frôla la biche pour se planter dans le sol derrière elle. Je bondis de ma cachette pour me précipiter sur ma proie prise de stupeur. Elle ne resta cependant pas figée longtemps. Son instinct de survie prenant le dessus, elle se cabra et s'échappa précédée par ses compagnons.", + style = PARAGRAPH, + ), + AdventureUio( + text = "Des taches de sang étaient clairement visibles là où elle se trouvait quelques secondes auparavant. Elle était blessée. Mais pas suffisamment pour être immobilisée. La traque commença. L’échec ou la réussite ne serait plus déterminé que par nos endurances respectives. Tous mes sens en éveil je me lançai sur sa piste. Les branches brisées par une course effrénée d’animaux apeurés, des traces de sang expulsées par la contraction de muscles bandés, des empreintes laissées dans le sol meuble d’une tourbière ; tous ces indices que seul un œil expérimenté pouvait déceler me permirent de retrouver la harde.", + style = PARAGRAPH, + ), + AdventureUio( + text = "Les effluves de mon corps suant sous l’effort me firent repérer, me privant de l'opportunité de tirer à nouveau. Je pus cependant apercevoir ma proie parmi ses congénères. La pauvre bête boitait, l’arrière cuisse bien entamée par mon arme. Ce marathon arriverait tôt ou tard à son terme et j’étais désormais certaine que cette biche assoiffée courait à sa perte. Je continuai donc la traque sans relâche, ce manège se répétant plusieurs fois. Les cerfs plaçant toujours la future victime au centre de la harde, ils m’empêchait de l’atteindre.", + style = PARAGRAPH, + ), + AdventureUio( + text = "Mais même l’organisme le plus endurci ne pouvait continuer indéfiniment cette fuite désespérée tout en se vidant de son sang. Je trouvai bientôt ma proie couchée sur le flanc, exténuée, la respiration saccadée, un pas de plus lui aurait coûté la vie. Ses compagnons l’avaient abandonnée lorsqu’elle s'était effondrée, comprenant qu’il n’y avait plus rien à faire pour elle. La fin de sa course signifiait aussi la fin de mon plaisir. La mise à mort d’une créature paniquée n’étant pas quelque chose que j’apprécie, je la tuai rapidement d’un coup de dague sûr en plein cœur, m’étant ainsi un terme à ses souffrances.", + style = PARAGRAPH, + ), + AdventureUio( + text = "Bien qu’exténuée par cette épreuve j’étais heureuse. Sa force ferait bientôt partie de moi. Après la mort de l’animal, je pris soin de l'égorger avant de la prendre sur mes épaules. Je sentis son sang encore chaud ruisseler le long de mon dos. Il quittait son corps lentement, comme si la vie rechignait à abandonner cette chair déjà saisie par la rigidité. C’est ainsi chargée du fruit de ma chasse que sur le chemin du retour je sentis l’odeur de la chair calcinée.", + style = PARAGRAPH, + ), + AdventureUio( + text = "Je me dirigeai lentement vers cette puanteur, prenant garde de ne faire aucun bruit, jusqu'à ce que j’entendis les bribes d’une conversation. Consciente de mes limites et de crainte d’être repérée, je n’approchai pas suffisamment pour les voir. Ils s'exprimaient en commun. L’un d’eux avait un fort accent nain. Un autre était indubitablement d’ascendance elfique et le dernier était humain. Ils semblaient être en désaccord quant à la direction à prendre. Ne souhaitant pas avoir maille à partir après une journée de course, je pris la décision de les contourner. Les cadavres encore fumants d’une troupe orc confirmèrent que j’avais fait le bon choix. Ces vagabonds n'étaient pas de ceux avec qui il fallait jouer.", + style = PARAGRAPH, + ), + AdventureUio( + text = "Une fois de retour à mon refuge, je pris le temps de préparer l’animal, coupant les morceaux de viande pour faciliter leur cuisson et leur transport. J’avais fait mon choix. Je ne resterais pas encore longtemps dans cette région où la guerre se frayait déjà un chemin. Je m'enfoncerais plus loin dans les terres, m’éloignant toujours un peu plus de la civilisation.", + style = PARAGRAPH, + ), + AdventureUio( + text = "Les chroniques de Brulkhaï - 16 ans", + style = LEGEND, + ), + ) + ) + }, + onBack = { }, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/detail/AdventureDetailViewModel.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/detail/AdventureDetailViewModel.kt new file mode 100644 index 0000000..4b98cd9 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/detail/AdventureDetailViewModel.kt @@ -0,0 +1,48 @@ +package com.pixelized.rplexicon.ui.screens.adventure.detail + +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.pixelized.rplexicon.data.model.adventure.AdventureLine.Format +import com.pixelized.rplexicon.data.repository.adventure.AdventureRepository +import com.pixelized.rplexicon.ui.navigation.screens.adventureDetailArgument +import com.pixelized.rplexicon.ui.screens.adventure.detail.AdventureUio.Style +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class AdventureDetailViewModel @Inject constructor( + adventureRepository: AdventureRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val argument = savedStateHandle.adventureDetailArgument + private val _detail = mutableStateOf( + adventureRepository.find( + bookTitle = argument.bookTitle, + adventureTitle = argument.adventureTitle, + ) + ) + + val background = derivedStateOf { + _detail.value?.adventureBackground + } + val adventure = derivedStateOf { + _detail.value?.story?.map { line -> + AdventureUio( + text = line.text, + style = when (line.format) { + Format.TITLE -> Style.TITLE + Format.CHAPTER -> Style.CHAPTER + Format.PARAGRAPH -> Style.PARAGRAPH + Format.DIALOGUE -> Style.DIALOGUE + Format.ANNEX -> Style.ANNEX + Format.LEGEND -> Style.LEGEND + }, + ) + } ?: emptyList() + } + val title = derivedStateOf { + adventure.value.firstOrNull()?.text + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/detail/AdventureLine.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/detail/AdventureLine.kt new file mode 100644 index 0000000..a788429 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/detail/AdventureLine.kt @@ -0,0 +1,107 @@ +package com.pixelized.rplexicon.ui.screens.adventure.detail + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import com.pixelized.rplexicon.ui.screens.adventure.detail.AdventureUio.Style.ANNEX +import com.pixelized.rplexicon.ui.screens.adventure.detail.AdventureUio.Style.CHAPTER +import com.pixelized.rplexicon.ui.screens.adventure.detail.AdventureUio.Style.DIALOGUE +import com.pixelized.rplexicon.ui.screens.adventure.detail.AdventureUio.Style.LEGEND +import com.pixelized.rplexicon.ui.screens.adventure.detail.AdventureUio.Style.PARAGRAPH +import com.pixelized.rplexicon.ui.screens.adventure.detail.AdventureUio.Style.TITLE +import com.pixelized.rplexicon.utilitary.annotateWithDropCap +import com.pixelized.rplexicon.utilitary.extentions.lexicon + +@Stable +data class AdventureUio( + val text: String, + val style: Style, +) { + enum class Style { + TITLE, + CHAPTER, + PARAGRAPH, + DIALOGUE, + LEGEND, + ANNEX, + } +} + +@Composable +fun AdventureLine( + modifier: Modifier = Modifier, + paddingValues: PaddingValues, + item: AdventureUio, +) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues = paddingValues) + .then(other = modifier), + style = when (item.style) { + TITLE -> MaterialTheme.lexicon.typography.adventure.title + CHAPTER -> MaterialTheme.lexicon.typography.adventure.chapter + PARAGRAPH -> MaterialTheme.lexicon.typography.adventure.paragraph + DIALOGUE -> MaterialTheme.lexicon.typography.adventure.dialogue + ANNEX -> MaterialTheme.lexicon.typography.adventure.annex + LEGEND -> MaterialTheme.lexicon.typography.adventure.legend + }, + text = when (item.style) { + TITLE -> annotateWithDropCap( + text = item.text, + style = MaterialTheme.lexicon.typography.adventure.dropCap.title + ) + + CHAPTER -> annotateWithDropCap( + text = item.text, + style = MaterialTheme.lexicon.typography.adventure.dropCap.chapter + ) + + PARAGRAPH -> annotateWithDropCap( + text = item.text, + style = MaterialTheme.lexicon.typography.adventure.dropCap.paragraph + ) + + else -> AnnotatedString(text = item.text) + }, + ) +} + +@Stable +fun rememberPaddingValues( + current: AdventureUio, + previous: AdventureUio?, +): PaddingValues { + return PaddingValues( + top = when (current.style) { + TITLE -> 32.dp + + CHAPTER -> 32.dp + + PARAGRAPH -> when (previous?.style) { + PARAGRAPH -> 8.dp + TITLE -> 32.dp + else -> 16.dp + } + + DIALOGUE -> when (previous?.style) { + DIALOGUE -> 8.dp + else -> 16.dp + } + + ANNEX -> 16.dp + + LEGEND -> when (previous?.style) { + LEGEND -> 8.dp + else -> 16.dp + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/landing/LandingItem.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/landing/LandingItem.kt index 76500e6..6441719 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/landing/LandingItem.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/landing/LandingItem.kt @@ -28,6 +28,8 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -38,7 +40,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import com.pixelized.rplexicon.R import com.pixelized.rplexicon.ui.theme.LexiconTheme -import com.pixelized.rplexicon.utilitary.annotateWithDropCap +import com.pixelized.rplexicon.utilitary.annotateMajWithDropCap import com.pixelized.rplexicon.utilitary.extentions.lexicon @Stable @@ -55,6 +57,8 @@ fun LandingItem( paddings: PaddingValues = PaddingValues(), imagePadding: PaddingValues = PaddingValues(all = 16.dp), shape: Shape = remember { RoundedCornerShape(size = 8.dp) }, + textStyle: TextStyle = MaterialTheme.typography.bodyMedium, + dropCapStyle: SpanStyle = MaterialTheme.lexicon.typography.dropCap.bodyMedium, backgroundFilter: ColorFilter? = ColorFilter.tint(color = MaterialTheme.colorScheme.onSurface), @FloatRange(from = 0.0, to = 1.0) backgroundGradientFrom: Float = 0.5f, @FloatRange(from = 0.0, to = 1.0) backgroundGradientTo: Float = 1f, @@ -102,14 +106,14 @@ fun LandingItem( ) { item.title?.let { Text( - style = MaterialTheme.typography.titleMedium, + style = textStyle, fontWeight = FontWeight.Normal, maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, - text = annotateWithDropCap( + text = annotateMajWithDropCap( text = it, - style = MaterialTheme.lexicon.typography.dropCap.titleMedium, + style = dropCapStyle, ) ) } diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/landing/LandingScreen.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/landing/LandingScreen.kt index fb255d0..084ace9 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/landing/LandingScreen.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/landing/LandingScreen.kt @@ -51,6 +51,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.pixelized.rplexicon.R import com.pixelized.rplexicon.ui.composable.images.rememberBackgroundGradient import com.pixelized.rplexicon.ui.navigation.LocalScreenNavHost +import com.pixelized.rplexicon.ui.navigation.screens.navigateToAdventures import com.pixelized.rplexicon.ui.navigation.screens.navigateToCharacterSheet import com.pixelized.rplexicon.ui.navigation.screens.navigateToLexicon import com.pixelized.rplexicon.ui.navigation.screens.navigateToLocation @@ -97,6 +98,7 @@ fun LandingScreen( onLexicon = { screen.navigateToLexicon() }, onQuest = { screen.navigateToQuestList() }, onMap = { screen.navigateToLocation() }, + onAdventure = { screen.navigateToAdventures() }, ) } } @@ -119,6 +121,7 @@ private fun LandingContent( onLexicon: () -> Unit, onQuest: () -> Unit, onMap: () -> Unit, + onAdventure: () -> Unit, ) { val charactersSection = remember { derivedStateOf { @@ -203,6 +206,8 @@ private fun LandingContent( .aspectRatio(0.8f) .weight(1f), imagePadding = PaddingValues(bottom = 8.dp), + textStyle = MaterialTheme.typography.titleMedium, + dropCapStyle = MaterialTheme.lexicon.typography.dropCap.titleMedium, item = character, backgroundFilter = null, backgroundGradientFrom = 0f, @@ -344,6 +349,34 @@ private fun LandingContent( onClick = onMap, ) } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(space = 8.dp), + ) { + LandingItem( + modifier = Modifier + .weight(1f) + .aspectRatio(ratio = 1f), + imagePadding = PaddingValues( + top = 8.dp, + start = 16.dp, + end = 16.dp, + bottom = 24.dp + ), + item = LandingItemUio( + title = stringResource(id = R.string.adventures_title), + subTitle = null, + icon = R.drawable.icbg_book_generic_c_unfaded, + ), + backgroundFilter = null, + backgroundGradientFrom = 0.0f, + backgroundGradientTo = 0.5f, + onClick = onAdventure, + ) + + Spacer(modifier = Modifier.weight(2f)) + } } } } @@ -385,8 +418,8 @@ private fun Modifier.magic(): Modifier = composed { } @Composable -@Preview(uiMode = UI_MODE_NIGHT_NO, heightDp = 1200) -@Preview(uiMode = UI_MODE_NIGHT_YES, heightDp = 1200) +@Preview(uiMode = UI_MODE_NIGHT_NO, heightDp = 1300) +@Preview(uiMode = UI_MODE_NIGHT_YES, heightDp = 1300) private fun LandingPreview() { LexiconTheme { Surface { @@ -429,6 +462,7 @@ private fun LandingPreview() { onLexicon = { }, onQuest = { }, onMap = { }, + onAdventure = { }, ) } } diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/theme/typography/LexiconTypography.kt b/app/src/main/java/com/pixelized/rplexicon/ui/theme/typography/LexiconTypography.kt index 09f9c28..c0269bd 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/theme/typography/LexiconTypography.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/theme/typography/LexiconTypography.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp import com.pixelized.rplexicon.R @@ -32,6 +33,7 @@ data class LexiconTypography( val dropCap: DropCapTypography, val detail: Detail, val search: Search, + val adventure: Adventure, ) { @Stable data class Detail( @@ -49,6 +51,24 @@ data class LexiconTypography( val extractBold: SpanStyle, ) + @Stable + data class Adventure( + val dropCap: DropCap, + val title: TextStyle, + val chapter: TextStyle, + val paragraph: TextStyle, + val dialogue: TextStyle, + val legend: TextStyle, + val annex: TextStyle + ) { + @Stable + data class DropCap( + val title: SpanStyle, + val chapter: SpanStyle, + val paragraph: SpanStyle, + ) + } + @Stable data class DropCapTypography( val displayLarge: SpanStyle, @@ -183,12 +203,37 @@ fun lexiconTypography( fontWeight = FontWeight.Black, ).toSpanStyle(), ), + adventure: LexiconTypography.Adventure = LexiconTypography.Adventure( + title = base.displaySmall.copy( + textAlign = TextAlign.Center, + ), + chapter = base.titleLarge, + paragraph = base.bodyMedium.copy( + textAlign = TextAlign.Justify, + ), + dialogue = base.bodyMedium, + legend = base.labelMedium.copy( + fontWeight = FontWeight.Light, + textAlign = TextAlign.End, + fontStyle = FontStyle.Italic, + ), + annex = base.labelMedium.copy( + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Italic, + ), + dropCap = LexiconTypography.Adventure.DropCap( + title = dropCap.displaySmall, + chapter = dropCap.titleLarge, + paragraph = dropCap.bodyMedium, + ) + ) ): LexiconTypography = LexiconTypography( base = base, dropCap = dropCap, stamp = stamp, detail = detail, search = search, + adventure = adventure, ) private fun TextStyle.toDropCapSpan( diff --git a/app/src/main/res/drawable/icbg_book_generic_c_unfaded.png b/app/src/main/res/drawable/icbg_book_generic_c_unfaded.png new file mode 100644 index 0000000..b95baf7 Binary files /dev/null and b/app/src/main/res/drawable/icbg_book_generic_c_unfaded.png differ diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e24974b..00c38ff 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -19,6 +19,7 @@ La structure du lexique semble avoir changé et n\'est plus compatible avec cette application. La structure des cartes semble avoir changé et n\'est plus compatible avec cette application. La structure des quêtes semble avoir changé et n\'est plus compatible avec cette application. + La structure des péripéties semble avoir changé et n\'est plus compatible avec cette application. Succès SUCCÈS CRITIQUE @@ -237,4 +238,6 @@ Feuilles de personnage Encyclopédie Outils + + Histoires & Péripéties \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 99d1b3c..2dec333 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ The lexicon sheet structure appears to have changed and is no longer compatible with this application The location sheet structure appears to have changed and is no longer compatible with this application The quest sheet structure appears to have changed and is no longer compatible with this application + The adventure sheet structure appears to have changed and is no longer compatible with this application Others Search @@ -242,4 +243,6 @@ This equipment does not have any description Game Master + + Stories & Adventures \ No newline at end of file