From fb31de8130fca44c65866667b0fdcd1fade669da Mon Sep 17 00:00:00 2001 From: "Andres Gomez, Thomas (ITDV CC) - AF (ext)" Date: Mon, 17 Jul 2023 10:56:38 +0200 Subject: [PATCH] Change the parsing mechanist to allow more flexibility. --- app/build.gradle.kts | 4 +- app/release/output-metadata.json | 4 +- .../com/pixelized/rplexicon/model/Lexicon.kt | 25 ++- .../repository/AuthenticationRepository.kt | 4 +- .../rplexicon/repository/LexiconRepository.kt | 145 ++++++++++++++---- .../authentication/AuthenticationScreen.kt | 18 ++- .../screens/detail/CharacterDetailScreen.kt | 40 ++--- .../detail/CharacterDetailViewModel.kt | 24 ++- .../ui/screens/lexicon/LexiconItem.kt | 58 +++++-- .../ui/screens/lexicon/LexiconScreen.kt | 98 +++++++++--- .../ui/screens/lexicon/LexiconViewModel.kt | 28 +++- .../com/pixelized/rplexicon/ui/theme/Color.kt | 46 +++++- .../com/pixelized/rplexicon/ui/theme/Theme.kt | 50 +++--- .../ui/theme/colors/BaseColorPalette.kt | 57 +++++++ .../ui/theme/colors/ShadowPalette.kt | 10 ++ .../utilitary/extentions/MaterialThemeEx.kt | 12 ++ .../utilitary/extentions/ModifierEx.kt | 30 ++++ app/src/main/res/values-fr/strings.xml | 32 +++- app/src/main/res/values/strings.xml | 32 +++- 19 files changed, 577 insertions(+), 140 deletions(-) create mode 100644 app/src/main/java/com/pixelized/rplexicon/ui/theme/colors/BaseColorPalette.kt create mode 100644 app/src/main/java/com/pixelized/rplexicon/ui/theme/colors/ShadowPalette.kt create mode 100644 app/src/main/java/com/pixelized/rplexicon/utilitary/extentions/MaterialThemeEx.kt create mode 100644 app/src/main/java/com/pixelized/rplexicon/utilitary/extentions/ModifierEx.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d014ee8..e2f9c91 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,8 +24,8 @@ android { applicationId = "com.pixelized.rplexicon" minSdk = 26 targetSdk = 33 - versionCode = 1 - versionName = "0.1.0" + versionCode = 2 + versionName = "0.1.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json index d6679a7..f9f85a7 100644 --- a/app/release/output-metadata.json +++ b/app/release/output-metadata.json @@ -11,8 +11,8 @@ "type": "SINGLE", "filters": [], "attributes": [], - "versionCode": 1, - "versionName": "0.1.0", + "versionCode": 2, + "versionName": "0.1.1", "outputFile": "app-release.apk" } ], diff --git a/app/src/main/java/com/pixelized/rplexicon/model/Lexicon.kt b/app/src/main/java/com/pixelized/rplexicon/model/Lexicon.kt index 1034591..55cf2fe 100644 --- a/app/src/main/java/com/pixelized/rplexicon/model/Lexicon.kt +++ b/app/src/main/java/com/pixelized/rplexicon/model/Lexicon.kt @@ -6,13 +6,32 @@ data class Lexicon( val id: Int, val name: String, val diminutive: String?, - val gender: Gender = Gender.UNDETERMINED, - val race: String?, + val gender: Gender, + val race: Race, val portrait: List, val description: String?, val history: String?, ) { enum class Gender { - MALE, FEMALE, UNDETERMINED + MALE, + FEMALE, + UNDETERMINED + } + + enum class Race { + ELF, + HALFLING, + HUMAN, + DWARF, + HALF_ELF, + HALF_ORC, + DRAGONBORN, + GNOME, + TIEFLING, + AARAKOCRA, + GENASI, + DEEP_GNOME, + GOLIATH, + UNDETERMINED } } \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/repository/AuthenticationRepository.kt b/app/src/main/java/com/pixelized/rplexicon/repository/AuthenticationRepository.kt index 000a66f..956db43 100644 --- a/app/src/main/java/com/pixelized/rplexicon/repository/AuthenticationRepository.kt +++ b/app/src/main/java/com/pixelized/rplexicon/repository/AuthenticationRepository.kt @@ -27,7 +27,7 @@ class AuthenticationRepository @Inject constructor( .setBackOff(ExponentialBackOff()) credential.selectedAccount = signInCredential.value?.let { - Account(it.id, "google") + Account(it.id, ACCOUNT_TYPE) } return credential @@ -40,8 +40,8 @@ class AuthenticationRepository @Inject constructor( } companion object { + private const val ACCOUNT_TYPE = "google" private val capabilities = listOf( -// SheetsScopes.SPREADSHEETS, SheetsScopes.SPREADSHEETS_READONLY, ) } diff --git a/app/src/main/java/com/pixelized/rplexicon/repository/LexiconRepository.kt b/app/src/main/java/com/pixelized/rplexicon/repository/LexiconRepository.kt index 1d55d4a..81f8394 100644 --- a/app/src/main/java/com/pixelized/rplexicon/repository/LexiconRepository.kt +++ b/app/src/main/java/com/pixelized/rplexicon/repository/LexiconRepository.kt @@ -28,7 +28,6 @@ class LexiconRepository @Inject constructor( GsonFactory(), authenticationRepository.credential, ) - .setApplicationName("RP-Lexique") .build() else -> null @@ -45,7 +44,7 @@ class LexiconRepository @Inject constructor( throw ServiceNotReady() } else { withContext(Dispatchers.IO) { - val request = service.spreadsheets().values().get(ID, LEXIQUE) + val request = service.spreadsheets().values().get(Sheet.ID, Sheet.LEXIQUE) val data = request.execute() updateData(data = data) } @@ -55,53 +54,98 @@ class LexiconRepository @Inject constructor( @Throws(IncompatibleSheetStructure::class) private fun updateData(data: ValueRange?) { val sheet = data?.values?.sheet() + var sheetStructure: Map? = null val lexicon: List = sheet?.mapIndexedNotNull { index, row -> - if (index == 0) { - checkSheetStructure(firstRow = row) - null - } else { - parseCharacterRow(index = index - 1, row = row as? List?) + when { + index == 0 -> { + sheetStructure = checkSheetStructure(firstRow = row) + null + } + + row is List<*> -> parseCharacterRow( + sheetStructure = sheetStructure, + index = index - 1, + row = row, + ) + + else -> null } } ?: emptyList() _data.tryEmit(lexicon) } @Throws(IncompatibleSheetStructure::class) - private fun checkSheetStructure(firstRow: Any?) { - when { - firstRow !is ArrayList<*> -> throw IncompatibleSheetStructure("First row is not a List: $firstRow") - firstRow.size < 7 -> throw IncompatibleSheetStructure("First row have not enough column: ${firstRow.size}, $firstRow") - firstRow.size > 7 -> throw IncompatibleSheetStructure("First row have too mush columns: ${firstRow.size}, $firstRow") - else -> { - for (index in 0..6) { - if (columns[index] != firstRow[index]) { - throw IncompatibleSheetStructure("Column at index:$index should be ${columns[index]} but was ${firstRow[index]}") - } - } + private fun checkSheetStructure(firstRow: Any?): HashMap { + // check if the row is a list + if (firstRow !is ArrayList<*>) { + throw IncompatibleSheetStructure("First row is not a List: $firstRow") + } + // parse the first line to find element that we recognize. + val sheetStructure = hashMapOf() + firstRow.forEachIndexed { index, cell -> + when (cell as? String) { + Sheet.NAME, + Sheet.DIMINUTIVE, + Sheet.GENDER, + Sheet.RACE, + Sheet.PORTRAIT, + Sheet.DESCRIPTION, + Sheet.HISTORY -> sheetStructure[cell] = index } } + // check if we found everything we need. + when { + sheetStructure.size < Sheet.KNOWN_COLUMN -> throw IncompatibleSheetStructure( + message = "Sheet header row does not have enough column: ${firstRow.size}.\nstructure: $firstRow\nheader: $sheetStructure" + ) + + sheetStructure.size > Sheet.KNOWN_COLUMN -> throw IncompatibleSheetStructure( + message = "Sheet header row does have too mush columns: ${firstRow.size}.\nstructure: $firstRow\nheader: $sheetStructure" + ) + } + + return sheetStructure } - private fun parseCharacterRow(index: Int, row: List?): Lexicon? { - val name = row?.getOrNull(0) as? String - val diminutive = row?.getOrNull(1) as? String? - val gender = row?.getOrNull(2) as? String? - val race = row?.getOrNull(3) as? String? - val portrait = row?.getOrNull(4) as? String? - val description = row?.getOrNull(5) as? String? - val history = row?.getOrNull(6) as? String? + private fun parseCharacterRow( + sheetStructure: Map?, + index: Int, + row: List<*>?, + ): Lexicon? { + val name = row?.getOrNull(sheetStructure.name) as? String + val diminutive = row?.getOrNull(sheetStructure.diminutive) as? String? + val gender = row?.getOrNull(sheetStructure.gender) as? String? + val race = row?.getOrNull(sheetStructure.race) as? String? + val portrait = row?.getOrNull(sheetStructure.portrait) as? String? + val description = row?.getOrNull(sheetStructure.description) as? String? + val history = row?.getOrNull(sheetStructure.history) as? String? return if (name != null) { Lexicon( id = index, name = name, diminutive = diminutive?.takeIf { it.isNotBlank() }, - gender = when (gender) { - "Male" -> Lexicon.Gender.MALE - "Femelle" -> Lexicon.Gender.FEMALE + gender = when (gender?.takeIf { it.isNotBlank() }) { + Gender.MALE -> Lexicon.Gender.MALE + Gender.FEMALE -> Lexicon.Gender.FEMALE else -> Lexicon.Gender.UNDETERMINED }, - race = race?.takeIf { it.isNotBlank() }, + race = when (race?.takeIf { it.isNotBlank() }) { + Race.ELF -> Lexicon.Race.ELF + Race.HALFLING -> Lexicon.Race.HALFLING + Race.HUMAN -> Lexicon.Race.HUMAN + Race.DWARF -> Lexicon.Race.DWARF + Race.HALF_ELF -> Lexicon.Race.HALF_ELF + Race.HALF_ORC -> Lexicon.Race.HALF_ORC + Race.DRAGONBORN -> Lexicon.Race.DRAGONBORN + Race.GNOME -> Lexicon.Race.GNOME + Race.TIEFLING -> Lexicon.Race.TIEFLING + Race.AARAKOCRA -> Lexicon.Race.AARAKOCRA + Race.GENASI -> Lexicon.Race.GENASI + Race.DEEP_GNOME -> Lexicon.Race.DEEP_GNOME + Race.GOLIATH -> Lexicon.Race.GOLIATH + else -> Lexicon.Race.UNDETERMINED + }, portrait = portrait?.split("\n")?.mapNotNull { it.toUriOrNull() } ?: emptyList(), description = description?.takeIf { it.isNotBlank() }, history = history?.takeIf { it.isNotBlank() }, @@ -124,16 +168,53 @@ class LexiconRepository @Inject constructor( null } + private val Map?.name: Int get() = this?.getValue(Sheet.NAME) ?: 0 + private val Map?.diminutive: Int get() = this?.getValue(Sheet.DIMINUTIVE) ?: 1 + private val Map?.gender: Int get() = this?.getValue(Sheet.GENDER) ?: 2 + private val Map?.race: Int get() = this?.getValue(Sheet.RACE) ?: 3 + private val Map?.portrait: Int get() = this?.getValue(Sheet.PORTRAIT) ?: 4 + private val Map?.description: Int get() = this?.getValue(Sheet.DESCRIPTION) ?: 5 + private val Map?.history: Int get() = this?.getValue(Sheet.HISTORY) ?: 6 + class ServiceNotReady : Exception() class IncompatibleSheetStructure(message: String?) : Exception(message) companion object { const val TAG = "LexiconRepository" + } + + private object Sheet { const val ID = "1oL9Nu5y37BPEbKxHre4TN9o8nrgy2JQoON4RRkdAHMs" const val LEXIQUE = "Lexique" + const val KNOWN_COLUMN = 7 + const val NAME = "Nom" + const val DIMINUTIVE = "Diminutif" + const val GENDER = "Sexe" + const val RACE = "Race" + const val PORTRAIT = "Portrait" + const val DESCRIPTION = "Description" + const val HISTORY = "Histoire" + } - val columns = - listOf("Nom", "Diminutif", "Sexe", "Race", "Portrait", "Description", "Histoire") + private object Gender { + const val MALE = "Male" + const val FEMALE = "Femelle" + } + + private object Race { + const val ELF = "Elfe" + const val HALFLING = "Halfelin" + const val HUMAN = "Humain" + const val DWARF = "Nain" + const val HALF_ELF = "Demi-Elfe" + const val HALF_ORC = "Demi-Orc" + const val DRAGONBORN = "Drakéide" + const val GNOME = "Gnome" + const val TIEFLING = "Tieffelin" + const val AARAKOCRA = "Aarakocra" + const val GENASI = "Génasi" + const val DEEP_GNOME = "Gnome des Profondeurs" + const val GOLIATH = "Goliath" } } \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/authentication/AuthenticationScreen.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/authentication/AuthenticationScreen.kt index 50528c8..f66ff4b 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/authentication/AuthenticationScreen.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/authentication/AuthenticationScreen.kt @@ -2,11 +2,13 @@ package com.pixelized.rplexicon.ui.screens.authentication import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.LocalTextStyle @@ -22,6 +24,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview @@ -81,23 +84,28 @@ private fun AuthenticationScreenContent( version: VersionViewModel.Version, onGoogleSignIn: () -> Unit, ) { + val typography = MaterialTheme.typography Column( modifier = modifier, - verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.Bottom), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Bottom), horizontalAlignment = Alignment.End, ) { Button( modifier = Modifier - .padding(all = 16.dp) - .fillMaxWidth(), - colors = ButtonDefaults.buttonColors(), + .fillMaxWidth() + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.primary, + shape = CircleShape, + ), + colors = ButtonDefaults.outlinedButtonColors(), onClick = onGoogleSignIn, ) { Text(text = rememeberGoogleStringResource()) } Text( - style = MaterialTheme.typography.labelSmall, + style = remember { typography.labelSmall.copy(fontStyle = FontStyle.Italic) }, text = version.toText(), ) } diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/detail/CharacterDetailScreen.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/detail/CharacterDetailScreen.kt index 0c717dd..7609cef 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/detail/CharacterDetailScreen.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/detail/CharacterDetailScreen.kt @@ -3,6 +3,7 @@ package com.pixelized.rplexicon.ui.screens.detail import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.net.Uri +import androidx.annotation.StringRes import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -48,6 +49,7 @@ import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -63,8 +65,8 @@ import com.skydoves.landscapist.glide.GlideImage data class CharacterDetailUio( val name: String?, val diminutive: String?, - val gender: String?, - val race: String?, + @StringRes val gender: Int, + @StringRes val race: Int, val portrait: List, val description: String?, val history: String?, @@ -111,7 +113,9 @@ private fun CharacterDetailScreenContent( ) } }, - title = { }, + title = { + Text(text = stringResource(id = R.string.detail_title)) + }, ) }, ) { paddingValues -> @@ -175,24 +179,20 @@ private fun CharacterDetailScreenContent( modifier = Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - item.value.gender?.let { - Text( - style = remember { typography.labelMedium.copy(fontStyle = FontStyle.Italic) }, - text = it, - ) - } - item.value.race?.let { - Text( - style = remember { typography.labelMedium.copy(fontStyle = FontStyle.Italic) }, - text = it, - ) - } + Text( + style = remember { typography.labelMedium.copy(fontStyle = FontStyle.Italic) }, + text = stringResource(id = item.value.gender), + ) + Text( + style = remember { typography.labelMedium.copy(fontStyle = FontStyle.Italic) }, + text = stringResource(id = item.value.race), + ) } item.value.description?.let { Text( modifier = Modifier.padding(start = 16.dp, top = 24.dp, end = 16.dp), style = typography.titleMedium, - text = "Description", + text = stringResource(id = R.string.detail_description), ) Text( modifier = Modifier.padding(horizontal = 16.dp), @@ -211,7 +211,7 @@ private fun CharacterDetailScreenContent( Text( modifier = Modifier.padding(start = 16.dp, top = 24.dp, end = 16.dp), style = typography.titleMedium, - text = "Histoire", + text = stringResource(id = R.string.detail_history), ) Text( modifier = Modifier.padding(horizontal = 16.dp), @@ -223,7 +223,7 @@ private fun CharacterDetailScreenContent( Text( modifier = Modifier.padding(start = 16.dp, top = 24.dp, end = 16.dp), style = typography.titleMedium, - text = "Portrait", + text = stringResource(id = R.string.detail_portrait), ) LazyRow( contentPadding = PaddingValues(horizontal = 16.dp), @@ -281,8 +281,8 @@ private fun CharacterDetailScreenContentPreview() { CharacterDetailUio( name = "Brulkhai", diminutive = "./ Bru", - gender = "female", - race = "Demi-Orc", + gender = R.string.gender_female, + race = R.string.race_half_orc, portrait = listOf( Uri.parse("https://cdnb.artstation.com/p/assets/images/images/003/024/889/large/bayard-wu-0716.jpg?1468642855"), Uri.parse("https://cdnb.artstation.com/p/assets/images/images/003/024/877/large/bayard-wu-0714.jpg?1468642665"), diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/detail/CharacterDetailViewModel.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/detail/CharacterDetailViewModel.kt index d1da6db..0547e45 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/detail/CharacterDetailViewModel.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/detail/CharacterDetailViewModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import com.pixelized.rplexicon.R import com.pixelized.rplexicon.model.Lexicon import com.pixelized.rplexicon.repository.LexiconRepository import com.pixelized.rplexicon.ui.navigation.screens.characterDetailArgument @@ -23,11 +24,26 @@ class CharacterDetailViewModel @Inject constructor( name = source.name, diminutive = source.diminutive?.let { "./ $it" }, gender = when (source.gender) { - Lexicon.Gender.MALE -> "Male" - Lexicon.Gender.FEMALE -> "Femelle" - Lexicon.Gender.UNDETERMINED -> "Inconnu" + Lexicon.Gender.MALE -> R.string.gender_male + Lexicon.Gender.FEMALE -> R.string.gender_female + Lexicon.Gender.UNDETERMINED -> R.string.gender_undetermined + }, + race = when (source.race) { + Lexicon.Race.ELF -> R.string.race_elf + Lexicon.Race.HALFLING -> R.string.race_halfling + Lexicon.Race.HUMAN -> R.string.race_human + Lexicon.Race.DWARF -> R.string.race_dwarf + Lexicon.Race.HALF_ELF -> R.string.race_half_elf + Lexicon.Race.HALF_ORC -> R.string.race_half_orc + Lexicon.Race.DRAGONBORN -> R.string.race_dragonborn + Lexicon.Race.GNOME -> R.string.race_gnome + Lexicon.Race.TIEFLING -> R.string.race_tiefling + Lexicon.Race.AARAKOCRA -> R.string.race_aarakocra + Lexicon.Race.GENASI -> R.string.race_genasi + Lexicon.Race.DEEP_GNOME -> R.string.race_deep_gnome + Lexicon.Race.GOLIATH -> R.string.race_goliath + Lexicon.Race.UNDETERMINED -> R.string.race_undetermined }, - race = source.race, portrait = source.portrait, description = source.description, history = source.history, diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconItem.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconItem.kt index a71a0e2..32ec9d9 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconItem.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconItem.kt @@ -2,10 +2,14 @@ package com.pixelized.rplexicon.ui.screens.lexicon import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -13,21 +17,37 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.pixelized.rplexicon.R import com.pixelized.rplexicon.ui.theme.LexiconTheme +import com.pixelized.rplexicon.utilitary.extentions.placeholder @Stable data class LexiconItemUio( val id: Int, val name: String, val diminutive: String?, - val gender: String?, - val race: String?, -) + @StringRes val gender: Int, + @StringRes val race: Int, + val placeholder: Boolean = false, +) { + companion object { + val Brulkhai = LexiconItemUio( + id = 0, + name = "Brulkhai", + diminutive = "Bru", + gender = R.string.gender_female_short, + race = R.string.race_half_orc, + placeholder = true, + ) + } +} +@OptIn(ExperimentalLayoutApi::class) @Composable fun LexiconItem( modifier: Modifier = Modifier, @@ -39,17 +59,23 @@ fun LexiconItem( modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Row( + FlowRow( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - modifier = Modifier.alignByBaseline(), + modifier = Modifier + .alignByBaseline() + .placeholder { item.placeholder }, style = remember { typography.bodyLarge.copy(fontWeight = FontWeight.Bold) }, + maxLines = 1, text = item.name, ) Text( - modifier = Modifier.alignByBaseline(), + modifier = Modifier + .alignByBaseline() + .placeholder { item.placeholder }, style = typography.labelMedium, + maxLines = 1, text = item.diminutive ?: "" ) } @@ -57,12 +83,20 @@ fun LexiconItem( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Text( + modifier = Modifier + .alignByBaseline() + .placeholder { item.placeholder }, style = remember { typography.labelMedium.copy(fontStyle = FontStyle.Italic) }, - text = item.gender ?: "" + maxLines = 1, + text = stringResource(id = item.gender) ) Text( + modifier = Modifier + .alignByBaseline() + .placeholder { item.placeholder }, style = remember { typography.labelMedium.copy(fontStyle = FontStyle.Italic) }, - text = item.race ?: "" + maxLines = 1, + text = stringResource(id = item.race) ) } } @@ -76,13 +110,15 @@ private fun LexiconItemContentPreview() { LexiconTheme { Surface { LexiconItem( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp), item = LexiconItemUio( id = 0, name = "Brulkhai", diminutive = "Bru", - gender = "f.", - race = "Demi-Orc", + gender = R.string.gender_female_short, + race = R.string.race_half_orc, ) ) } diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconScreen.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconScreen.kt index 590f3ae..833b3c1 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconScreen.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconScreen.kt @@ -6,6 +6,8 @@ import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -16,14 +18,14 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.pullrefresh.PullRefreshIndicator 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.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -38,20 +40,20 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.pixelized.rplexicon.LocalSnack import com.pixelized.rplexicon.R -import com.pixelized.rplexicon.ui.composable.FloatingActionButton import com.pixelized.rplexicon.ui.navigation.LocalScreenNavHost import com.pixelized.rplexicon.ui.navigation.screens.navigateToCharacterDetail import com.pixelized.rplexicon.ui.navigation.screens.navigateToSearch import com.pixelized.rplexicon.ui.screens.lexicon.LexiconErrorUio.Default import com.pixelized.rplexicon.ui.screens.lexicon.LexiconErrorUio.Permission +import com.pixelized.rplexicon.ui.screens.lexicon.LexiconErrorUio.Structure import com.pixelized.rplexicon.ui.theme.LexiconTheme import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch @@ -61,6 +63,9 @@ sealed class LexiconErrorUio { @Stable data class Permission(val intent: Intent) : LexiconErrorUio() + @Stable + object Structure : LexiconErrorUio() + @Stable object Default : LexiconErrorUio() } @@ -75,7 +80,7 @@ fun LexiconScreen( val screen = LocalScreenNavHost.current val refresh = rememberPullRefreshState( - refreshing = viewModel.isLoading.value, + refreshing = false, onRefresh = { scope.launch { viewModel.fetchLexicon() @@ -113,7 +118,9 @@ fun LexiconScreen( } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class, + ExperimentalFoundationApi::class +) @Composable private fun LexiconScreenContent( modifier: Modifier = Modifier, @@ -156,9 +163,9 @@ private fun LexiconScreenContent( // }, // ) } - ) { + ) { padding -> Box( - modifier = Modifier.padding(paddingValues = it), + modifier = Modifier.padding(paddingValues = padding), contentAlignment = Alignment.TopCenter, ) { LazyColumn( @@ -171,30 +178,72 @@ private fun LexiconScreenContent( bottom = 8.dp + 16.dp // + 56.dp + 16.dp, ), ) { - items(items = items.value) { item -> - LexiconItem( - modifier = Modifier - .clickable { onItem(item) } - .fillMaxWidth() - .padding(vertical = 8.dp, horizontal = 16.dp), - item = item, - ) + if (items.value.isEmpty()) { + items( + count = 6, + key = { it }, + contentType = { "Lexicon" }, + ) { + LexiconItem( + modifier = Modifier + .animateItemPlacement() + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp), + item = LexiconItemUio.Brulkhai, + ) + } + } else { + items( + items = items.value, + key = { it.id }, + contentType = { "Lexicon" }, + ) { + LexiconItem( + modifier = Modifier + .animateItemPlacement() + .clickable { onItem(it) } + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp), + item = it, + ) + } } } - PullRefreshIndicator( - refreshing = refreshing.value, - state = refreshState, + Loader( + refreshState = refreshState, + refreshing = refreshing, ) } } } +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun Loader( + refreshState: PullRefreshState, + refreshing: State, +) { + if (refreshing.value) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .clip(shape = CircleShape) + ) + } + + PullRefreshIndicator( + refreshing = false, + state = refreshState, + ) +} + @Composable fun HandleError( errors: SharedFlow, onLexiconPermissionGranted: suspend () -> Unit, ) { + val context = LocalContext.current val snack = LocalSnack.current val scope = rememberCoroutineScope() @@ -212,7 +261,8 @@ fun HandleError( errors.collect { error -> when (error) { is Permission -> launcher.launch(error.intent) - is Default -> snack.showSnackbar(message = "Oops") + is Structure -> snack.showSnackbar(message = context.getString(R.string.error_structure)) + is Default -> snack.showSnackbar(message = context.getString(R.string.error_generic)) } } } @@ -238,11 +288,11 @@ private fun LexiconScreenContentPreview() { mutableStateOf( listOf( LexiconItemUio( - id = 2, + id = 0, name = "Brulkhai", diminutive = "Bru", - gender = "f.", - race = "Demi-Orc", + gender = R.string.gender_female_short, + race = R.string.race_half_orc, ) ) ) diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconViewModel.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconViewModel.kt index 6c688a5..c437ac7 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconViewModel.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconViewModel.kt @@ -6,8 +6,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.api.client.googleapis.extensions.android.gms.auth.UserRecoverableAuthIOException +import com.pixelized.rplexicon.R import com.pixelized.rplexicon.model.Lexicon import com.pixelized.rplexicon.repository.LexiconRepository +import com.pixelized.rplexicon.repository.LexiconRepository.IncompatibleSheetStructure import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -37,11 +39,26 @@ class LexiconViewModel @Inject constructor( name = item.name, diminutive = item.diminutive?.takeIf { it.isNotBlank() }?.let { "./ $it" }, gender = when (item.gender) { - Lexicon.Gender.MALE -> "m." - Lexicon.Gender.FEMALE -> "f." - Lexicon.Gender.UNDETERMINED -> "u." + Lexicon.Gender.MALE -> R.string.gender_male_short + Lexicon.Gender.FEMALE -> R.string.gender_female_short + Lexicon.Gender.UNDETERMINED -> R.string.gender_undetermined_short + }, + race = when (item.race) { + Lexicon.Race.ELF -> R.string.race_elf + Lexicon.Race.HALFLING -> R.string.race_halfling + Lexicon.Race.HUMAN -> R.string.race_human + Lexicon.Race.DWARF -> R.string.race_dwarf + Lexicon.Race.HALF_ELF -> R.string.race_half_elf + Lexicon.Race.HALF_ORC -> R.string.race_half_orc + Lexicon.Race.DRAGONBORN -> R.string.race_dragonborn + Lexicon.Race.GNOME -> R.string.race_gnome + Lexicon.Race.TIEFLING -> R.string.race_tiefling + Lexicon.Race.AARAKOCRA -> R.string.race_aarakocra + Lexicon.Race.GENASI -> R.string.race_genasi + Lexicon.Race.DEEP_GNOME -> R.string.race_deep_gnome + Lexicon.Race.GOLIATH -> R.string.race_goliath + Lexicon.Race.UNDETERMINED -> R.string.race_undetermined }, - race = item.race, ) }.sortedBy { it.name } } @@ -61,6 +78,9 @@ class LexiconViewModel @Inject constructor( catch (exception: UserRecoverableAuthIOException) { Log.e(TAG, exception.message, exception) _error.emit(LexiconErrorUio.Permission(intent = exception.intent)) + } catch (exception: IncompatibleSheetStructure) { + Log.e(TAG, exception.message, exception) + _error.emit(LexiconErrorUio.Structure) } // default exception catch (exception: Exception) { diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/theme/Color.kt b/app/src/main/java/com/pixelized/rplexicon/ui/theme/Color.kt index c058ebb..fbb9210 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/theme/Color.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/theme/Color.kt @@ -1,11 +1,45 @@ package com.pixelized.rplexicon.ui.theme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import androidx.compose.ui.graphics.Color +import com.pixelized.rplexicon.ui.theme.colors.BaseDark +import com.pixelized.rplexicon.ui.theme.colors.BaseLight -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) +@Stable +@Immutable +class LexiconColors( + val base: ColorScheme, + val placeholder: Color, +) -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +@Stable +fun darkColorScheme( + base: ColorScheme = darkColorScheme( + primary = BaseDark.Purple80, + secondary = BaseDark.PurpleGrey80, + tertiary = BaseDark.Pink80, + onPrimary = Color.White, + ), + placeholder: Color = Color(red = 49, green = 48, blue = 51), +) = LexiconColors( + base = base, + placeholder = placeholder, +) + +@Stable +fun lightColorScheme( + base: ColorScheme = lightColorScheme( + primary = BaseLight.Purple40, + secondary = BaseLight.PurpleGrey40, + tertiary = BaseLight.Pink40, + onPrimary = Color.White, + ), + placeholder: Color = Color(red = 230, green = 225, blue = 229), +) = LexiconColors( + base = base, + placeholder = placeholder, +) diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/theme/Theme.kt b/app/src/main/java/com/pixelized/rplexicon/ui/theme/Theme.kt index e1a1d16..be7a62c 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/theme/Theme.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/theme/Theme.kt @@ -3,27 +3,23 @@ package com.pixelized.rplexicon.ui.theme import android.app.Activity import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect -import androidx.compose.ui.graphics.Color +import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80, - onPrimary = Color.White, -) +val LocalLexiconTheme = compositionLocalOf { + error("LocalLexiconTheme not ready yet.") +} -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40, - onPrimary = Color.White, +@Stable +data class LexiconTheme( + val colorScheme: LexiconColors, ) @Composable @@ -31,16 +27,20 @@ fun LexiconTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit ) { - val colorScheme = when { - darkTheme -> DarkColorScheme - else -> LightColorScheme + val lexiconTheme = remember { + LexiconTheme( + colorScheme = when (darkTheme) { + true -> darkColorScheme() + else -> lightColorScheme() + } + ) } val view = LocalView.current if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window - colorScheme.background.toArgb().let { + lexiconTheme.colorScheme.base.background.toArgb().let { window.statusBarColor = it window.navigationBarColor = it } @@ -51,9 +51,13 @@ fun LexiconTheme( } } - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) + CompositionLocalProvider( + LocalLexiconTheme provides lexiconTheme, + ) { + MaterialTheme( + colorScheme = lexiconTheme.colorScheme.base, + typography = Typography, + content = content + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/theme/colors/BaseColorPalette.kt b/app/src/main/java/com/pixelized/rplexicon/ui/theme/colors/BaseColorPalette.kt new file mode 100644 index 0000000..53e4dc3 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/ui/theme/colors/BaseColorPalette.kt @@ -0,0 +1,57 @@ +package com.pixelized.rplexicon.ui.theme.colors + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +@Immutable +object BaseDark { + val Purple80 = Color(0xFFD0BCFF) + val PurpleGrey80 = Color(0xFFCCC2DC) + val Pink80 = Color(0xFFEFB8C8) +} + +@Immutable +object BaseLight { + val Purple40 = Color(0xFF6650a4) + val PurpleGrey40 = Color(0xFF625b71) + val Pink40 = Color(0xFF7D5260) +} + +@Immutable +object BaseColorPalette { + val VeryDarkBlue: Color = Color(0xFF09179D) + val DarkBlue: Color = Color(0xFF1A2BDB) + val Blue: Color = Color(0xFF2970F2) + val LightBlue: Color = Color(0xFF1A91DB) + val VeryLightBlue: Color = Color(0xFF1EDDEF) + val VeryDarkPurple: Color = Color(0xFF5F0E9E) + val DarkPurple: Color = Color(0xFF8330DB) + val Purple: Color = Color(0xFF9B54C3) + val LightPurple: Color = Color(0xFFBC52D9) + val VeryLightPurple: Color = Color(0xFFC856D1) + val VeryDarkGreen: Color = Color(0xFF16544A) + val DarkGreen: Color = Color(0xFF207A6B) + val Green: Color = Color(0xFF269482) + val LightGreen: Color = Color(0xFF2AA18D) + val VeryLightGreen: Color = Color(0xFF3AE0C5) + val VeryDarkRed: Color = Color(0xFF631221) + val DarkRed: Color = Color(0xFFA21D36) + val Red: Color = Color(0xFFC92443) + val LightRed: Color = Color(0xFFE32849) + val VeryLightRed: Color = Color(0xFFF02B4F) + val VeryDarkPink: Color = Color(0xFF960064) + val DarkPink: Color = Color(0xFFBD007E) + val Pink: Color = Color(0xFFD6008F) + val LightPink: Color = Color(0xFFE35BB5) + val VeryLightPink: Color = Color(0xFFFF66CC) + val VeryDarkYellow: Color = Color(0xFFB76036) + val DarkYellow: Color = Color(0xFFD48341) + val Yellow: Color = Color(0xFFF3A850) + val LightYellow: Color = Color(0xFFF5BF63) + val VeryLightYellow: Color = Color(0xFFF9D679) + val VeryDarkGrey: Color = Color(0xFF1D1D1D) + val DarkGrey: Color = Color(0xFF424242) + val Grey: Color = Color(0xFF919195) + val LightGrey: Color = Color(0xFFDFDFDF) + val VeryLightGrey: Color = Color(0xFFF9F9F9) +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/theme/colors/ShadowPalette.kt b/app/src/main/java/com/pixelized/rplexicon/ui/theme/colors/ShadowPalette.kt new file mode 100644 index 0000000..5269e9b --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/ui/theme/colors/ShadowPalette.kt @@ -0,0 +1,10 @@ +package com.pixelized.rplexicon.ui.theme.colors + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +@Immutable +object ShadowPalette { + val system: Color = Color.Black.copy(alpha = 0.37f) + val scrim: Color = Color.Black.copy(alpha = 0.37f) +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/utilitary/extentions/MaterialThemeEx.kt b/app/src/main/java/com/pixelized/rplexicon/utilitary/extentions/MaterialThemeEx.kt new file mode 100644 index 0000000..19b48f7 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/utilitary/extentions/MaterialThemeEx.kt @@ -0,0 +1,12 @@ +package com.pixelized.rplexicon.utilitary.extentions + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import com.pixelized.rplexicon.ui.theme.LexiconTheme +import com.pixelized.rplexicon.ui.theme.LocalLexiconTheme + +val MaterialTheme.lexicon: LexiconTheme + @Composable + @Stable + get() = LocalLexiconTheme.current \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/utilitary/extentions/ModifierEx.kt b/app/src/main/java/com/pixelized/rplexicon/utilitary/extentions/ModifierEx.kt new file mode 100644 index 0000000..60d8a20 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/utilitary/extentions/ModifierEx.kt @@ -0,0 +1,30 @@ +package com.pixelized.rplexicon.utilitary.extentions + +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.spring +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import com.google.accompanist.placeholder.PlaceholderHighlight +import com.google.accompanist.placeholder.placeholder + +@Composable +fun Modifier.placeholder( + color: Color = MaterialTheme.lexicon.colorScheme.placeholder, + shape: Shape = CircleShape, + highlight: PlaceholderHighlight? = null, + placeholderFadeTransitionSpec: @Composable Transition.Segment.() -> FiniteAnimationSpec = { spring() }, + contentFadeTransitionSpec: @Composable Transition.Segment.() -> FiniteAnimationSpec = { spring() }, + visible: () -> Boolean, +): Modifier = placeholder( + visible = visible(), + color = color, + shape = shape, + highlight = highlight, + placeholderFadeTransitionSpec = placeholderFadeTransitionSpec, + contentFadeTransitionSpec = contentFadeTransitionSpec, +) \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ab20bf3..60e24f5 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,5 +1,35 @@ - Lexique + Rp-Lexique + + Ah !? y\'a un truc qui foire quelque part. + La structure du fichier semble avoir changé et n\'est plus compatible avec cette application. + + Mâle + Femelle + Indéterminé + m. + f. + i. + + Elfe + Halfelin + Humain + Nain + Demi-Elfe + Demi-Orc + Drakéide + Gnome + Tieffelin + Aarakocra + Génasi + Gnome des Profondeurs + Goliath + Indéterminé Se connecter avec + + Détails du personnage + Description + History + Portrait \ 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 e0179a5..bd3828e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,35 @@ - Lexique + Rp-Lexicon + + Oups, it should not be rocket science. + The file structure appears to have changed and is no longer compatible with this application + + Male + Female + Undetermined + m. + f. + u. + + Elf + Halfling + Human + Dwarf + Half-elf + Half-Orc + Dragonborn + Gnome + Tiefling + Aarakocra + Genasi + Deep Gnome + Goliath + Undetermined Sign in with + + Character\'s details + Description + Histoire + Portrait \ No newline at end of file