Add Stories and Adventures section.

This commit is contained in:
Andres Gomez, Thomas (ITDV RL) 2024-06-17 13:03:13 +02:00
parent 4e103a2a37
commit 1a9104edec
37 changed files with 2381 additions and 13 deletions

View file

@ -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<FetchErrorUio?>(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
}
}
}

View file

@ -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

View file

@ -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<List<AdventureDbo>>
@Query("SELECT * from adventuresStories")
fun getAllAdventureStoryFlow(): Flow<List<AdventureStoryDbo>>
@Query("SELECT * from adventures")
fun findAdventures(): List<AdventureDbo>
@Query("SELECT * from adventures where bookTitle = :bookTitle")
fun findAdventures(bookTitle: String): List<AdventureDbo>
@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<BookPartialId>,
storiesToRemove: List<StoryPartialId>,
adventure: List<Pair<AdventureDbo, List<AdventureStoryDbo>>>
) {
// 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) }
}
}
}

View file

@ -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,
)

View file

@ -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<AdventureLine>,
)

View file

@ -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?,
)

View file

@ -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,
}
}

View file

@ -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?,
)

View file

@ -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<AdventureBook> = parserScope {
val adventures = mutableListOf<AdventureBook>()
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,
)
}
}

View file

@ -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<AdventureLine> {
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 <T> ValueRange.mapNotNull(lambda: (row: ArrayList<*>) -> T?): List<T> {
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"
}
}

View file

@ -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<AdventureStory> = parserScope(timeParser) {
val adventures = mutableListOf<AdventureStory>()
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,
)
}
}

View file

@ -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 <T> fetch(
lambda: suspend CoroutineScope.(service: Sheets.Spreadsheets.Values) -> T,
): T = withContext(Dispatchers.IO) {
lambda(service.spreadsheets().values())
}

View file

@ -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"
}

View file

@ -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<AdventureDbo>,
stories: List<AdventureStoryDbo>,
): List<Adventure> {
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,
)
}

View file

@ -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<Adventure> {
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<AdventureBook> {
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<AdventureStory> {
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<AdventureLine> {
return try {
googleRepository.fetch { sheet ->
val request = sheet.get(book.document, story.title)
adventureStoryLineParser.parse(sheet = request.execute())
}
} catch (exception: Exception) {
emptyList()
}
}
}

View file

@ -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))
},

View file

@ -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<Dp> {
val shadowTarget = remember(lazyListState) {
derivedStateOf {
if (lazyListState.firstVisibleItemScrollOffset > 0 || lazyListState.firstVisibleItemIndex != 0) {
target
} else {
rest
}
}
}
return animateDpAsState(
targetValue = shadowTarget.value,
label = "animated shadow",
)
}

View file

@ -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()
}
}
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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 = {},
)
}
}
}

View file

@ -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<List<AdventureBookUio>>,
refreshState: PullRefreshState,
refreshing: State<Boolean>,
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 = { },
)
}
}
}

View file

@ -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<FetchErrorUio>()
private val _isLoading = mutableStateOf(false)
val isLoading: State<Boolean> 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<List<AdventureBookUio>>
@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
}
}
}
}

View file

@ -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 = { },
)
}
}
}

View file

@ -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<String>,
chapters: State<List<AdventureChapterUio>>,
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 = { },
)
}
}
}

View file

@ -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
}
}
}

View file

@ -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<String?>,
background: State<Uri?>,
adventures: State<List<AdventureUio>>,
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 dil y a quelques jours. J'éteignis les dernières braises de mon feu, récupérai dagues, javelot et mon outre deau 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 deau où j'avais par le passé déjà aperçu des cervidés. Une fois sur place, je me mis à labri des regards dans un fourré et dissimulai mon odeur avec de la boue. Lattente fut longue, mais la chasse exige patience et discipline. Puis, sur lautre 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 quelle ne pourrisse. Je choisis donc une femelle au ventre plat indiquant quelle navait pas mis bas récemment. Dès linstant 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 danimaux apeurés, des traces de sang expulsées par la contraction de muscles bandés, des empreintes laissées dans le sol meuble dune 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 leffort 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, larriè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 mempêchait de latteindre.",
style = PARAGRAPH,
),
AdventureUio(
text = "Mais même lorganisme 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 lavaient abandonnée lorsquelle s'était effondrée, comprenant quil ny avait plus rien à faire pour elle. La fin de sa course signifiait aussi la fin de mon plaisir. La mise à mort dune créature paniquée nétant pas quelque chose que japprécie, je la tuai rapidement dun coup de dague sûr en plein cœur, métant ainsi un terme à ses souffrances.",
style = PARAGRAPH,
),
AdventureUio(
text = "Bien quexténuée par cette épreuve jétais heureuse. Sa force ferait bientôt partie de moi. Après la mort de lanimal, 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é. Cest ainsi chargée du fruit de ma chasse que sur le chemin du retour je sentis lodeur 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 jentendis les bribes dune conversation. Consciente de mes limites et de crainte dêtre repérée, je napprochai pas suffisamment pour les voir. Ils s'exprimaient en commun. Lun deux avait un fort accent nain. Un autre était indubitablement dascendance 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 dune troupe orc confirmèrent que javais 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 lanimal, coupant les morceaux de viande pour faciliter leur cuisson et leur transport. Javais 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 = { },
)
}
}
}

View file

@ -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
}
}

View file

@ -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
}
}
)
}

View file

@ -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,
)
)
}

View file

@ -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 = { },
)
}
}

View file

@ -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(

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View file

@ -19,6 +19,7 @@
<string name="error__structure_lexicon">La structure du lexique semble avoir changé et n\'est plus compatible avec cette application.</string>
<string name="error__structure_location">La structure des cartes semble avoir changé et n\'est plus compatible avec cette application.</string>
<string name="error__structure_quest">La structure des quêtes semble avoir changé et n\'est plus compatible avec cette application.</string>
<string name="error__structure_adventure">La structure des péripéties semble avoir changé et n\'est plus compatible avec cette application.</string>
<string name="character_sheet__death_header__success_label">Succès</string>
<string name="roll_overlay__critical_success">SUCCÈS CRITIQUE</string>
@ -237,4 +238,6 @@
<string name="landing__caterogy__character">Feuilles de personnage</string>
<string name="landing__caterogy__encyclopedia">Encyclopédie</string>
<string name="landing__caterogy__tools">Outils</string>
<string name="adventures_title">Histoires &amp; Péripéties</string>
</resources>

View file

@ -16,6 +16,7 @@
<string name="error__structure_lexicon">The lexicon sheet structure appears to have changed and is no longer compatible with this application</string>
<string name="error__structure_location">The location sheet structure appears to have changed and is no longer compatible with this application</string>
<string name="error__structure_quest">The quest sheet structure appears to have changed and is no longer compatible with this application</string>
<string name="error__structure_adventure">The adventure sheet structure appears to have changed and is no longer compatible with this application</string>
<string name="default_category_other_label">Others</string>
<string name="default_search_label">Search</string>
@ -242,4 +243,6 @@
<string name="character_sheet_equipment_empty_description">This equipment does not have any description</string>
<string name="summary__title">Game Master</string>
<string name="adventures_title">Stories &amp; Adventures</string>
</resources>