diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8f003c8c..8bd906f3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -122,6 +122,7 @@ dependencies { implementation(project(":feature:sync:android")) implementation(project(":feature:search:android")) implementation(project(":feature:settings:android")) + implementation(project(":feature:spell-compendium:android")) implementation(project(":feature:spell-detail:android")) implementation(project(":ui:core")) diff --git a/app/src/main/kotlin/br/alexandregpereira/hunter/app/HunterApplication.kt b/app/src/main/kotlin/br/alexandregpereira/hunter/app/HunterApplication.kt index df5d72ff..a3f10d86 100644 --- a/app/src/main/kotlin/br/alexandregpereira/hunter/app/HunterApplication.kt +++ b/app/src/main/kotlin/br/alexandregpereira/hunter/app/HunterApplication.kt @@ -33,6 +33,7 @@ import br.alexandregpereira.hunter.monster.lore.detail.di.monsterLoreDetailModul import br.alexandregpereira.hunter.monster.registration.di.monsterRegistrationModule import br.alexandregpereira.hunter.search.di.searchModule import br.alexandregpereira.hunter.settings.di.settingsModule +import br.alexandregpereira.hunter.spell.compendium.di.spellCompendiumModule import br.alexandregpereira.hunter.spell.detail.di.spellDetailModule import br.alexandregpereira.hunter.sync.di.syncModule import com.google.firebase.analytics.ktx.analytics @@ -83,7 +84,8 @@ class HunterApplication : Application() { monsterContentManagerModule + monsterContentPreviewModule + syncModule + - monsterRegistrationModule + monsterRegistrationModule + + spellCompendiumModule ) modules( analyticsModule, diff --git a/app/src/main/kotlin/br/alexandregpereira/hunter/app/ui/MainScreen.kt b/app/src/main/kotlin/br/alexandregpereira/hunter/app/ui/MainScreen.kt index f6ef16f2..bc9da3a3 100644 --- a/app/src/main/kotlin/br/alexandregpereira/hunter/app/ui/MainScreen.kt +++ b/app/src/main/kotlin/br/alexandregpereira/hunter/app/ui/MainScreen.kt @@ -32,6 +32,7 @@ import br.alexandregpereira.hunter.folder.preview.FolderPreviewFeature import br.alexandregpereira.hunter.monster.content.MonsterContentManagerFeature import br.alexandregpereira.hunter.monster.lore.detail.MonsterLoreDetailFeature import br.alexandregpereira.hunter.monster.registration.MonsterRegistrationFeature +import br.alexandregpereira.hunter.spell.compendium.SpellCompendiumFeature import br.alexandregpereira.hunter.spell.detail.SpellDetailFeature import br.alexandregpereira.hunter.sync.SyncFeature @@ -71,6 +72,10 @@ fun MainScreen( MonsterRegistrationFeature(contentPadding = contentPadding) + SpellCompendiumFeature( + contentPadding = contentPadding, + ) + SpellDetailFeature( contentPadding = contentPadding, ) diff --git a/core/event/src/commonMain/kotlin/br/alexandregpereira/hunter/event/Event.kt b/core/event/src/commonMain/kotlin/br/alexandregpereira/hunter/event/Event.kt index f2ec543a..94e43a6d 100644 --- a/core/event/src/commonMain/kotlin/br/alexandregpereira/hunter/event/Event.kt +++ b/core/event/src/commonMain/kotlin/br/alexandregpereira/hunter/event/Event.kt @@ -9,6 +9,11 @@ interface EventDispatcher { fun dispatchEvent(event: Event) } +interface EventResultDispatcher { + + fun dispatchEventResult(event: Event): Flow +} + interface EventListener { val events: Flow diff --git a/core/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/state/StateHolder.kt b/core/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/state/StateHolder.kt index 2aa28d91..9a1dc148 100644 --- a/core/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/state/StateHolder.kt +++ b/core/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/state/StateHolder.kt @@ -91,3 +91,7 @@ private class DefaultStateHolderWithRecovery( } } } + +internal fun StateHolder.setState(block: State.() -> State) { + (this as MutableStateHolder).setState(block) +} diff --git a/domain/app/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/database/dao/SpellDaoImpl.kt b/domain/app/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/database/dao/SpellDaoImpl.kt index c10b708a..bee00405 100644 --- a/domain/app/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/database/dao/SpellDaoImpl.kt +++ b/domain/app/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/database/dao/SpellDaoImpl.kt @@ -64,7 +64,11 @@ internal class SpellDaoImpl( override suspend fun getSpells( indexes: List ): List = withContext(dispatcher) { - spellQueries.getSpells(indexes).executeAsList().map { it.asSpellEntity() } + spellQueries.getSpellsByIds(indexes).executeAsList().map { it.asSpellEntity() } + } + + override suspend fun getSpells(): List = withContext(dispatcher) { + spellQueries.getSpells().executeAsList().map { it.asSpellEntity() } } private fun SpellDatabaseEntity.asSpellEntity(): SpellEntity { diff --git a/domain/app/data/src/commonMain/sqldelight/br/alexandregpereira/hunter/database/Spell.sq b/domain/app/data/src/commonMain/sqldelight/br/alexandregpereira/hunter/database/Spell.sq index 90fa7600..1ae95352 100644 --- a/domain/app/data/src/commonMain/sqldelight/br/alexandregpereira/hunter/database/Spell.sq +++ b/domain/app/data/src/commonMain/sqldelight/br/alexandregpereira/hunter/database/Spell.sq @@ -9,5 +9,8 @@ DELETE FROM SpellEntity; getSpell: SELECT * FROM SpellEntity WHERE spellIndex == ?; -getSpells: +getSpellsByIds: SELECT * FROM SpellEntity WHERE spellIndex IN ?; + +getSpells: +SELECT * FROM SpellEntity ORDER BY level ASC, name ASC; diff --git a/domain/monster/core/src/commonMain/kotlin/br/alexandregpereira/hunter/domain/locale/LocaleNumber.kt b/domain/monster/core/src/commonMain/kotlin/br/alexandregpereira/hunter/domain/locale/LocaleNumber.kt new file mode 100644 index 00000000..6c5d1c34 --- /dev/null +++ b/domain/monster/core/src/commonMain/kotlin/br/alexandregpereira/hunter/domain/locale/LocaleNumber.kt @@ -0,0 +1,3 @@ +package br.alexandregpereira.hunter.domain.locale + +internal expect fun Int.formatToNumber(): String diff --git a/domain/monster/core/src/commonMain/kotlin/br/alexandregpereira/hunter/domain/model/Monster.kt b/domain/monster/core/src/commonMain/kotlin/br/alexandregpereira/hunter/domain/model/Monster.kt index 50d57677..3a8806e7 100644 --- a/domain/monster/core/src/commonMain/kotlin/br/alexandregpereira/hunter/domain/model/Monster.kt +++ b/domain/monster/core/src/commonMain/kotlin/br/alexandregpereira/hunter/domain/model/Monster.kt @@ -16,12 +16,12 @@ package br.alexandregpereira.hunter.domain.model +import br.alexandregpereira.hunter.domain.locale.formatToNumber import br.alexandregpereira.hunter.domain.monster.spell.model.SchoolOfMagic import br.alexandregpereira.hunter.domain.monster.spell.model.SpellPreview import br.alexandregpereira.hunter.domain.monster.spell.model.SpellUsage import br.alexandregpereira.hunter.domain.monster.spell.model.Spellcasting import br.alexandregpereira.hunter.domain.monster.spell.model.SpellcastingType -import java.text.NumberFormat import kotlin.native.ObjCName @ObjCName(name = "Monster", exact = true) @@ -116,7 +116,7 @@ fun Monster.xpFormatted(): String { val xpString = when { xp < 1000 -> xp.toString() else -> { - val xpFormatted = NumberFormat.getIntegerInstance().format(xp) + val xpFormatted = xp.formatToNumber() .dropLastWhile { it == '0' } .let { if (it.last().isDigit().not()) it.dropLast(1) else it } "${xpFormatted}k" diff --git a/domain/monster/core/src/iosMain/kotlin/br/alexandregpereira/hunter/domain/locale/LocaleNumber.kt b/domain/monster/core/src/iosMain/kotlin/br/alexandregpereira/hunter/domain/locale/LocaleNumber.kt new file mode 100644 index 00000000..9131539a --- /dev/null +++ b/domain/monster/core/src/iosMain/kotlin/br/alexandregpereira/hunter/domain/locale/LocaleNumber.kt @@ -0,0 +1,5 @@ +package br.alexandregpereira.hunter.domain.locale + +internal actual fun Int.formatToNumber(): String { + return this.toString() +} diff --git a/domain/monster/core/src/jvmMain/kotlin/br/alexandregpereira/hunter/domain/locale/LocaleNumber.kt b/domain/monster/core/src/jvmMain/kotlin/br/alexandregpereira/hunter/domain/locale/LocaleNumber.kt new file mode 100644 index 00000000..971b61eb --- /dev/null +++ b/domain/monster/core/src/jvmMain/kotlin/br/alexandregpereira/hunter/domain/locale/LocaleNumber.kt @@ -0,0 +1,7 @@ +package br.alexandregpereira.hunter.domain.locale + +import java.text.NumberFormat + +internal actual fun Int.formatToNumber(): String { + return NumberFormat.getIntegerInstance().format(this) +} diff --git a/domain/spell/core/src/commonMain/kotlin/br/alexandregpereira/hunter/domain/spell/SpellLocalRepository.kt b/domain/spell/core/src/commonMain/kotlin/br/alexandregpereira/hunter/domain/spell/SpellLocalRepository.kt index a002da44..a062d970 100644 --- a/domain/spell/core/src/commonMain/kotlin/br/alexandregpereira/hunter/domain/spell/SpellLocalRepository.kt +++ b/domain/spell/core/src/commonMain/kotlin/br/alexandregpereira/hunter/domain/spell/SpellLocalRepository.kt @@ -24,5 +24,6 @@ interface SpellLocalRepository { fun saveSpells(spells: List): Flow fun getLocalSpell(index: String): Flow fun getLocalSpells(indexes: List): Flow> + fun getLocalSpells(): Flow> fun deleteLocalSpells(): Flow } diff --git a/domain/spell/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/spell/DefaultSpellLocalRepository.kt b/domain/spell/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/spell/DefaultSpellLocalRepository.kt index 9013e671..e772e83f 100644 --- a/domain/spell/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/spell/DefaultSpellLocalRepository.kt +++ b/domain/spell/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/spell/DefaultSpellLocalRepository.kt @@ -42,6 +42,12 @@ internal class DefaultSpellLocalRepository( } } + override fun getLocalSpells(): Flow> { + return localDataSource.getSpells().map { spells -> + spells.map { it.toDomain() } + } + } + override fun deleteLocalSpells(): Flow { return localDataSource.deleteSpells() } diff --git a/domain/spell/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/spell/DefaultSpellRepository.kt b/domain/spell/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/spell/DefaultSpellRepository.kt index 0ded35f2..5ffd650f 100644 --- a/domain/spell/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/spell/DefaultSpellRepository.kt +++ b/domain/spell/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/spell/DefaultSpellRepository.kt @@ -43,6 +43,10 @@ internal class DefaultSpellRepository( return localRepository.getLocalSpells(indexes) } + override fun getLocalSpells(): Flow> { + return localRepository.getLocalSpells() + } + override fun deleteLocalSpells(): Flow { return localRepository.deleteLocalSpells() } diff --git a/domain/spell/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/spell/local/DefaultSpellLocalDataSource.kt b/domain/spell/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/spell/local/DefaultSpellLocalDataSource.kt index e0055fa1..4a5a6c9d 100644 --- a/domain/spell/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/spell/local/DefaultSpellLocalDataSource.kt +++ b/domain/spell/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/spell/local/DefaultSpellLocalDataSource.kt @@ -40,4 +40,8 @@ internal class DefaultSpellLocalDataSource( override fun getSpells(indexes: List): Flow> = flow { emit(spellDao.getSpells(indexes)) } + + override fun getSpells(): Flow> = flow { + emit(spellDao.getSpells()) + } } diff --git a/domain/spell/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/spell/local/SpellLocalDataSource.kt b/domain/spell/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/spell/local/SpellLocalDataSource.kt index 7c8e6fde..f517ece6 100644 --- a/domain/spell/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/spell/local/SpellLocalDataSource.kt +++ b/domain/spell/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/spell/local/SpellLocalDataSource.kt @@ -25,4 +25,5 @@ internal interface SpellLocalDataSource { fun getSpell(index: String): Flow fun deleteSpells(): Flow fun getSpells(indexes: List): Flow> + fun getSpells(): Flow> } diff --git a/domain/spell/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/spell/local/dao/SpellDao.kt b/domain/spell/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/spell/local/dao/SpellDao.kt index e925e612..cdba4441 100644 --- a/domain/spell/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/spell/local/dao/SpellDao.kt +++ b/domain/spell/data/src/commonMain/kotlin/br/alexandregpereira/hunter/data/spell/local/dao/SpellDao.kt @@ -27,4 +27,6 @@ interface SpellDao { suspend fun deleteAll() suspend fun getSpells(indexes: List): List + + suspend fun getSpells(): List } diff --git a/feature/monster-registration/android/src/main/kotlin/br/alexandregpereira/hunter/monster/registration/ui/MonsterRegistrationForm.kt b/feature/monster-registration/android/src/main/kotlin/br/alexandregpereira/hunter/monster/registration/ui/MonsterRegistrationForm.kt index d184bdfe..7b4a10ab 100644 --- a/feature/monster-registration/android/src/main/kotlin/br/alexandregpereira/hunter/monster/registration/ui/MonsterRegistrationForm.kt +++ b/feature/monster-registration/android/src/main/kotlin/br/alexandregpereira/hunter/monster/registration/ui/MonsterRegistrationForm.kt @@ -159,6 +159,8 @@ internal fun MonsterRegistrationForm( item(key = "spells") { MonsterSpellcastingsForm( spellcastings = monster.spellcastings, + onChanged = { intent.onMonsterChanged(monster.copy(spellcastings = it)) }, + onSpellClick = intent::onSpellClick, ) } } diff --git a/feature/monster-registration/android/src/main/kotlin/br/alexandregpereira/hunter/monster/registration/ui/MonsterRegistrationScreen.kt b/feature/monster-registration/android/src/main/kotlin/br/alexandregpereira/hunter/monster/registration/ui/MonsterRegistrationScreen.kt index e6dee279..a159a180 100644 --- a/feature/monster-registration/android/src/main/kotlin/br/alexandregpereira/hunter/monster/registration/ui/MonsterRegistrationScreen.kt +++ b/feature/monster-registration/android/src/main/kotlin/br/alexandregpereira/hunter/monster/registration/ui/MonsterRegistrationScreen.kt @@ -57,6 +57,8 @@ private fun MonsterRegistrationScreenPreview() { } override fun onSaved() {} + + override fun onSpellClick(spellIndex: String) {} } MonsterRegistrationScreen( state = state.value, diff --git a/feature/monster-registration/android/src/main/kotlin/br/alexandregpereira/hunter/monster/registration/ui/form/AddButton.kt b/feature/monster-registration/android/src/main/kotlin/br/alexandregpereira/hunter/monster/registration/ui/form/AddButton.kt index 98102fc5..0bf6e854 100644 --- a/feature/monster-registration/android/src/main/kotlin/br/alexandregpereira/hunter/monster/registration/ui/form/AddButton.kt +++ b/feature/monster-registration/android/src/main/kotlin/br/alexandregpereira/hunter/monster/registration/ui/form/AddButton.kt @@ -20,6 +20,7 @@ import br.alexandregpereira.hunter.monster.registration.R @Composable internal fun AddButton( modifier: Modifier = Modifier, + text: String = "", onClick: () -> Unit = {}, ) = Row( verticalAlignment = Alignment.CenterVertically, @@ -28,13 +29,16 @@ internal fun AddButton( .fillMaxWidth() .clickable { onClick() }, ) { + val string = text.ifEmpty { + stringResource(R.string.monster_registration_add_new) + } Icon( painter = painterResource(R.drawable.ic_add), - contentDescription = stringResource(R.string.monster_registration_add_new), + contentDescription = string, modifier = Modifier.padding(end = 8.dp), ) Text( - text = stringResource(R.string.monster_registration_add_new), + text = string, fontSize = 16.sp, fontWeight = FontWeight.Normal, ) diff --git a/feature/monster-registration/android/src/main/kotlin/br/alexandregpereira/hunter/monster/registration/ui/form/MonsterSpellcastingsForm.kt b/feature/monster-registration/android/src/main/kotlin/br/alexandregpereira/hunter/monster/registration/ui/form/MonsterSpellcastingsForm.kt index c0c78d04..50f50fcd 100644 --- a/feature/monster-registration/android/src/main/kotlin/br/alexandregpereira/hunter/monster/registration/ui/form/MonsterSpellcastingsForm.kt +++ b/feature/monster-registration/android/src/main/kotlin/br/alexandregpereira/hunter/monster/registration/ui/form/MonsterSpellcastingsForm.kt @@ -1,27 +1,116 @@ package br.alexandregpereira.hunter.monster.registration.ui.form -import androidx.compose.material.Text +import androidx.compose.foundation.clickable import androidx.compose.runtime.Composable 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.unit.sp -import br.alexandregpereira.hunter.domain.model.Action +import br.alexandregpereira.hunter.domain.monster.spell.model.SpellPreview +import br.alexandregpereira.hunter.domain.monster.spell.model.SpellUsage import br.alexandregpereira.hunter.domain.monster.spell.model.Spellcasting +import br.alexandregpereira.hunter.domain.monster.spell.model.SpellcastingType import br.alexandregpereira.hunter.monster.registration.R +import br.alexandregpereira.hunter.monster.registration.ui.changeAt +import br.alexandregpereira.hunter.ui.compose.AppTextField +import br.alexandregpereira.hunter.ui.compose.ClickableField import br.alexandregpereira.hunter.ui.compose.Form +import br.alexandregpereira.hunter.ui.compose.PickerField @Composable internal fun MonsterSpellcastingsForm( spellcastings: List, modifier: Modifier = Modifier, - onChanged: (List) -> Unit = {} + onSpellClick: (String) -> Unit = {}, + onChanged: (List) -> Unit = {} ) = Form(modifier, stringResource(R.string.monster_registration_spells)) { - Text( - text = stringResource(R.string.monster_registration_work_in_progress), - fontSize = 14.sp, - fontWeight = FontWeight.Light, - fontStyle = FontStyle.Italic, - ) + if (spellcastings.isEmpty()) { + AddButton(text = stringResource(R.string.monster_registration_add_spellcasting_type)) + return@Form + } + val newSpellcastings = spellcastings.toMutableList() + val options = SpellcastingType.entries + val optionStrings = SpellcastingType.entries.map { it.toState().getStringName() } + + spellcastings.forEachIndexed { index, spellcasting -> + PickerField( + value = spellcasting.type.toState().getStringName(), + label = stringResource(R.string.monster_registration_spellcasting_type_label), + options = optionStrings, + onValueChange = { optionIndex -> + onChanged(newSpellcastings.changeAt(index) { copy(type = options[optionIndex]) }) + } + ) + AppTextField( + text = spellcasting.description, + label = stringResource(R.string.monster_registration_description), + multiline = true, + onValueChange = { newValue -> + onChanged(newSpellcastings.changeAt(index) { copy(description = newValue) }) + } + ) + + MonsterSpellsUsageForm( + spellsUsage = spellcasting.usages, + onSpellClick = onSpellClick, + onChanged = { newSpellsUsage -> + onChanged(newSpellcastings.changeAt(index) { copy(usages = newSpellsUsage) }) + } + ) + + AddButton(text = stringResource(R.string.monster_registration_add_spellcasting_type)) + } } + +@Composable +internal fun MonsterSpellsUsageForm( + spellsUsage: List, + onSpellClick: (String) -> Unit = {}, + onChanged: (List) -> Unit = {} +) { + val newSpellsUsage = spellsUsage.toMutableList() + + AddButton(text = stringResource(R.string.monster_registration_add_spell_group)) + + spellsUsage.forEachIndexed { index, spellUsage -> + AppTextField( + text = spellUsage.group, + label = stringResource(R.string.monster_registration_spell_group), + onValueChange = { newValue -> + onChanged(newSpellsUsage.changeAt(index) { copy(group = newValue) }) + } + ) + + MonsterSpellsForm(spells = spellUsage.spells, onSpellClick = onSpellClick) + + AddButton(text = stringResource(R.string.monster_registration_add_spell_group)) + } +} + +@Composable +internal fun MonsterSpellsForm( + spells: List, + onSpellClick: (String) -> Unit = {} +) { + spells.forEach { spell -> + ClickableField( + text = spell.name, + label = stringResource(R.string.monster_registration_spell_label), + onClick = { onSpellClick(spell.index) }, + ) + } + + AddButton(text = stringResource(R.string.monster_registration_add_spell)) +} + +private fun SpellcastingType.toState() = when (this) { + SpellcastingType.SPELLCASTER -> SpellcastingTypeState.SPELLCASTER + SpellcastingType.INNATE -> SpellcastingTypeState.INNATE +} + +private enum class SpellcastingTypeState(val stringRes: Int) { + SPELLCASTER(R.string.monster_registration_spellcasting_caster_type), + INNATE(R.string.monster_registration_spellcasting_innate_type), +} + +@Composable +private fun SpellcastingTypeState.getStringName() = stringResource(stringRes) + diff --git a/feature/monster-registration/android/src/main/res/values-pt-rBR/strings.xml b/feature/monster-registration/android/src/main/res/values-pt-rBR/strings.xml index ed461b75..55b4ea64 100644 --- a/feature/monster-registration/android/src/main/res/values-pt-rBR/strings.xml +++ b/feature/monster-registration/android/src/main/res/values-pt-rBR/strings.xml @@ -89,4 +89,13 @@ Natação Escalagem Escavação + + Conjuração + Conjuração Inata + Tipo de Conjuração + Grupo de Magia + Magia + Adicionar grupo de magia + Adicionar magia + Adicionar tipo de conjuração \ No newline at end of file diff --git a/feature/monster-registration/android/src/main/res/values/strings.xml b/feature/monster-registration/android/src/main/res/values/strings.xml index 9f61030e..e3754fa7 100644 --- a/feature/monster-registration/android/src/main/res/values/strings.xml +++ b/feature/monster-registration/android/src/main/res/values/strings.xml @@ -89,4 +89,13 @@ Swim Climb Burrow + + Spellcaster + Innate Spellcaster + Spellcasting Type + Spell Group + Spell + Add spell group + Add spell + Add spellcasting type \ No newline at end of file diff --git a/feature/monster-registration/state-holder/build.gradle.kts b/feature/monster-registration/state-holder/build.gradle.kts index bfb86540..f81860ed 100644 --- a/feature/monster-registration/state-holder/build.gradle.kts +++ b/feature/monster-registration/state-holder/build.gradle.kts @@ -25,12 +25,14 @@ kotlin { val commonMain by getting { dependencies { api(project(":core:analytics")) + api(project(":core:event")) api(project(":core:state-holder")) api(project(":domain:monster:core")) implementation(project(":domain:monster-lore:core")) implementation(project(":domain:spell:core")) implementation(project(":feature:monster-registration:event")) implementation(project(":feature:spell-detail:event")) + implementation(project(":feature:spell-compendium:event")) implementation(libs.kotlin.coroutines.core) implementation(libs.koin.core) } diff --git a/feature/monster-registration/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/monster/registration/MonsterRegistrationIntent.kt b/feature/monster-registration/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/monster/registration/MonsterRegistrationIntent.kt index b2765643..c9cdb221 100644 --- a/feature/monster-registration/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/monster/registration/MonsterRegistrationIntent.kt +++ b/feature/monster-registration/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/monster/registration/MonsterRegistrationIntent.kt @@ -9,6 +9,8 @@ interface MonsterRegistrationIntent { fun onMonsterChanged(monster: Monster) fun onSaved() + + fun onSpellClick(spellIndex: String) } class EmptyMonsterRegistrationIntent : MonsterRegistrationIntent { @@ -18,4 +20,6 @@ class EmptyMonsterRegistrationIntent : MonsterRegistrationIntent { override fun onMonsterChanged(monster: Monster) {} override fun onSaved() {} + + override fun onSpellClick(spellIndex: String) {} } diff --git a/feature/monster-registration/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/monster/registration/MonsterRegistrationStateHolder.kt b/feature/monster-registration/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/monster/registration/MonsterRegistrationStateHolder.kt index 2d3ee06f..334a4902 100644 --- a/feature/monster-registration/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/monster/registration/MonsterRegistrationStateHolder.kt +++ b/feature/monster-registration/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/monster/registration/MonsterRegistrationStateHolder.kt @@ -2,12 +2,20 @@ package br.alexandregpereira.hunter.monster.registration import br.alexandregpereira.hunter.analytics.Analytics import br.alexandregpereira.hunter.domain.model.Monster +import br.alexandregpereira.hunter.domain.monster.spell.model.SchoolOfMagic +import br.alexandregpereira.hunter.domain.spell.GetSpellUseCase import br.alexandregpereira.hunter.domain.usecase.GetMonsterUseCase import br.alexandregpereira.hunter.domain.usecase.SaveMonstersUseCase import br.alexandregpereira.hunter.event.EventManager import br.alexandregpereira.hunter.monster.registration.domain.NormalizeMonsterUseCase import br.alexandregpereira.hunter.monster.registration.event.MonsterRegistrationEvent import br.alexandregpereira.hunter.monster.registration.event.MonsterRegistrationResult +import br.alexandregpereira.hunter.spell.compendium.event.SpellCompendiumEvent +import br.alexandregpereira.hunter.spell.compendium.event.SpellCompendiumEvent.Show +import br.alexandregpereira.hunter.spell.compendium.event.SpellCompendiumEventResultDispatcher +import br.alexandregpereira.hunter.spell.compendium.event.SpellCompendiumResult +import br.alexandregpereira.hunter.spell.detail.event.SpellDetailEvent +import br.alexandregpereira.hunter.spell.detail.event.SpellDetailEventDispatcher import br.alexandregpereira.hunter.state.MutableActionHandler import br.alexandregpereira.hunter.state.StateHolderParams import br.alexandregpereira.hunter.state.UiModel @@ -28,6 +36,9 @@ class MonsterRegistrationStateHolder internal constructor( private val saveMonsters: SaveMonstersUseCase, private val normalizeMonster: NormalizeMonsterUseCase, private val analytics: Analytics, + private val spellCompendiumEventDispatcher: SpellCompendiumEventResultDispatcher, + private val spellDetailEventDispatcher: SpellDetailEventDispatcher, + private val getSpell: GetSpellUseCase, ) : UiModel(MonsterRegistrationState()), MutableActionHandler by MutableActionHandler(), MonsterRegistrationIntent { @@ -63,6 +74,64 @@ class MonsterRegistrationStateHolder internal constructor( .launchIn(scope) } + override fun onSpellClick(spellIndex: String) { + val showEvent = Show( + spellIndex = spellIndex, + selectedSpellIndexes = state.value.monster.spellcastings + .flatMap { it.usages } + .flatMap { it.spells } + .map { it.index } + ) + spellCompendiumEventDispatcher.dispatchEventResult(showEvent).onEach { result -> + when (result) { + is SpellCompendiumResult.OnSpellClick -> updateSpells( + currentSpellIndex = spellIndex, + newSpellIndex= result.spellIndex + ) + is SpellCompendiumResult.OnSpellLongClick -> openSpellDetail(result.spellIndex) + } + }.launchIn(scope) + } + + private fun updateSpells(currentSpellIndex: String, newSpellIndex: String) { + spellCompendiumEventDispatcher.dispatchEventResult(SpellCompendiumEvent.Hide) + getSpell(newSpellIndex) + .flowOn(dispatcher) + .onEach { newSpell -> + setState { + copy( + monster = monster.copy( + spellcastings = monster.spellcastings.map { spellcasting -> + spellcasting.copy( + usages = spellcasting.usages.map { usage -> + usage.copy( + spells = usage.spells.map { spell -> + if (spell.index == currentSpellIndex) { + spell.copy( + index = newSpellIndex, + name = newSpell.name, + level = newSpell.level, + school = SchoolOfMagic.valueOf(newSpell.school.name), + ) + } else { + spell + } + } + ) + } + ) + } + ) + ) + } + } + .launchIn(scope) + } + + private fun openSpellDetail(spellIndex: String) { + spellDetailEventDispatcher.dispatchEvent(SpellDetailEvent.ShowSpell(spellIndex)) + } + private fun loadMonster() { val monsterIndex = params.value.monsterIndex?.takeUnless { it.isBlank() } ?: return diff --git a/feature/monster-registration/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/monster/registration/di/Module.kt b/feature/monster-registration/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/monster/registration/di/Module.kt index 4bda3948..f2888036 100644 --- a/feature/monster-registration/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/monster/registration/di/Module.kt +++ b/feature/monster-registration/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/monster/registration/di/Module.kt @@ -50,6 +50,9 @@ val monsterRegistrationStateModule = module { saveMonsters = get(), normalizeMonster = get(), analytics = get(), + spellCompendiumEventDispatcher = get(), + spellDetailEventDispatcher = get(), + getSpell = get() ) } } diff --git a/feature/search/android/src/main/java/br/alexandregpereira/hunter/search/ui/SearchBar.kt b/feature/search/android/src/main/java/br/alexandregpereira/hunter/search/ui/SearchBar.kt index ffd53202..8b9fc13b 100644 --- a/feature/search/android/src/main/java/br/alexandregpereira/hunter/search/ui/SearchBar.kt +++ b/feature/search/android/src/main/java/br/alexandregpereira/hunter/search/ui/SearchBar.kt @@ -37,6 +37,7 @@ internal fun SearchBar( ) { AppTextField( text = text, + capitalize = false, onValueChange = onValueChange, label = stringResource(R.string.search_search_label), modifier = modifier diff --git a/feature/spell-compendium/android/.gitignore b/feature/spell-compendium/android/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/feature/spell-compendium/android/.gitignore @@ -0,0 +1 @@ +/build diff --git a/feature/spell-compendium/android/build.gradle b/feature/spell-compendium/android/build.gradle new file mode 100644 index 00000000..de3d340e --- /dev/null +++ b/feature/spell-compendium/android/build.gradle @@ -0,0 +1,44 @@ +plugins { + id 'com.android.library' + id 'kotlin-android' +} + +android { + compileSdkVersion project.findProperty('compileSdk') + + defaultConfig { + minSdkVersion project.findProperty('minSdk') + } + + kotlinOptions { + freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn" + } + + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion libs.versions.compose.compiler.get() + } + namespace 'br.alexandregpereira.hunter.spell.compendium' +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } +} + +dependencies { + implementation project(':core:analytics') + implementation project(':core:state-holder') + implementation project(':feature:spell-compendium:state-holder') + implementation project(':ui:core') + implementation project(':ui:compendium') + + implementation libs.bundles.viewmodel.bundle + implementation libs.bundles.compose + + implementation libs.koin.compose +} diff --git a/feature/spell-compendium/android/src/main/AndroidManifest.xml b/feature/spell-compendium/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..c63dbe02 --- /dev/null +++ b/feature/spell-compendium/android/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + diff --git a/feature/spell-compendium/android/src/main/java/br/alexandregpereira/hunter/spell/compendium/SpellCompendiumFeature.kt b/feature/spell-compendium/android/src/main/java/br/alexandregpereira/hunter/spell/compendium/SpellCompendiumFeature.kt new file mode 100644 index 00000000..e8ff909c --- /dev/null +++ b/feature/spell-compendium/android/src/main/java/br/alexandregpereira/hunter/spell/compendium/SpellCompendiumFeature.kt @@ -0,0 +1,19 @@ +package br.alexandregpereira.hunter.spell.compendium + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import br.alexandregpereira.hunter.spell.compendium.ui.SpellCompendiumScreen +import org.koin.androidx.compose.koinViewModel + +@Composable +fun SpellCompendiumFeature( + contentPadding: PaddingValues = PaddingValues(), +) { + val viewModel: SpellCompendiumViewModel = koinViewModel() + SpellCompendiumScreen( + state = viewModel.state.collectAsState().value, + contentPadding = contentPadding, + intent = viewModel, + ) +} diff --git a/feature/spell-compendium/android/src/main/java/br/alexandregpereira/hunter/spell/compendium/SpellCompendiumViewModel.kt b/feature/spell-compendium/android/src/main/java/br/alexandregpereira/hunter/spell/compendium/SpellCompendiumViewModel.kt new file mode 100644 index 00000000..188ccd46 --- /dev/null +++ b/feature/spell-compendium/android/src/main/java/br/alexandregpereira/hunter/spell/compendium/SpellCompendiumViewModel.kt @@ -0,0 +1,10 @@ +package br.alexandregpereira.hunter.spell.compendium + +import androidx.lifecycle.ViewModel +import br.alexandregpereira.hunter.state.StateHolder + +internal class SpellCompendiumViewModel( + private val stateHolder: SpellCompendiumStateHolder, +) : ViewModel(), + SpellCompendiumIntent by stateHolder, + StateHolder by stateHolder diff --git a/feature/spell-compendium/android/src/main/java/br/alexandregpereira/hunter/spell/compendium/di/UIModule.kt b/feature/spell-compendium/android/src/main/java/br/alexandregpereira/hunter/spell/compendium/di/UIModule.kt new file mode 100644 index 00000000..883d29c9 --- /dev/null +++ b/feature/spell-compendium/android/src/main/java/br/alexandregpereira/hunter/spell/compendium/di/UIModule.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2023 Alexandre Gomes Pereira + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package br.alexandregpereira.hunter.spell.compendium.di + +import br.alexandregpereira.hunter.spell.compendium.SpellCompendiumViewModel +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val spellCompendiumModule = listOf(spellCompendiumStateModule) + module { + + viewModel { + SpellCompendiumViewModel(get()) + } +} diff --git a/feature/spell-compendium/android/src/main/java/br/alexandregpereira/hunter/spell/compendium/ui/SpellCompendiumScreen.kt b/feature/spell-compendium/android/src/main/java/br/alexandregpereira/hunter/spell/compendium/ui/SpellCompendiumScreen.kt new file mode 100644 index 00000000..9a494a47 --- /dev/null +++ b/feature/spell-compendium/android/src/main/java/br/alexandregpereira/hunter/spell/compendium/ui/SpellCompendiumScreen.kt @@ -0,0 +1,56 @@ +package br.alexandregpereira.hunter.spell.compendium.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import br.alexandregpereira.hunter.spell.compendium.EmptySpellCompendiumIntent +import br.alexandregpereira.hunter.spell.compendium.SpellCompendiumIntent +import br.alexandregpereira.hunter.spell.compendium.SpellCompendiumState +import br.alexandregpereira.hunter.ui.compose.AppTextField +import br.alexandregpereira.hunter.ui.compose.SwipeVerticalToDismiss +import br.alexandregpereira.hunter.ui.compose.Window + +@Composable +internal fun SpellCompendiumScreen( + state: SpellCompendiumState, + contentPadding: PaddingValues, + intent: SpellCompendiumIntent = EmptySpellCompendiumIntent(), +) { + BackHandler(enabled = state.isShowing, onBack = intent::onClose) + + SwipeVerticalToDismiss( + visible = state.isShowing, + onClose = intent::onClose, + ) { + Window( + modifier = Modifier.fillMaxSize(), + ) { + Column( + modifier = Modifier.padding( + top = contentPadding.calculateTopPadding(), + bottom = contentPadding.calculateBottomPadding(), + start = 16.dp, + end = 16.dp, + ), + ) { + AppTextField( + text = state.searchText, + label = "Search", + capitalize = false, + onValueChange = intent::onSearchTextChange + ) + + SpellList( + spellsGroupByLevel = state.spellsGroupByLevel, + initialItemIndex = state.initialItemIndex, + intent = intent + ) + } + } + } +} diff --git a/feature/spell-compendium/android/src/main/java/br/alexandregpereira/hunter/spell/compendium/ui/SpellList.kt b/feature/spell-compendium/android/src/main/java/br/alexandregpereira/hunter/spell/compendium/ui/SpellList.kt new file mode 100644 index 00000000..369b8b62 --- /dev/null +++ b/feature/spell-compendium/android/src/main/java/br/alexandregpereira/hunter/spell/compendium/ui/SpellList.kt @@ -0,0 +1,65 @@ +package br.alexandregpereira.hunter.spell.compendium.ui + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.unit.dp +import br.alexandregpereira.hunter.spell.compendium.SpellCompendiumIntent +import br.alexandregpereira.hunter.spell.compendium.SpellCompendiumItemState +import br.alexandregpereira.hunter.ui.compendium.Compendium +import br.alexandregpereira.hunter.ui.compendium.CompendiumColumns +import br.alexandregpereira.hunter.ui.compendium.CompendiumItemState +import br.alexandregpereira.hunter.ui.compose.SchoolOfMagicState +import br.alexandregpereira.hunter.ui.compose.SpellIconInfo +import br.alexandregpereira.hunter.ui.compose.SpellIconSize + +@Composable +internal fun SpellList( + spellsGroupByLevel: Map>, + initialItemIndex: Int, + intent: SpellCompendiumIntent, +) { + val items = remember(spellsGroupByLevel) { spellsGroupByLevel.toCompendiumItems() } + val listState = rememberLazyGridState(initialFirstVisibleItemIndex = initialItemIndex) + Compendium( + items = items, + animateItems = true, + listState = listState, + columns = CompendiumColumns.Adaptive(minSize = SpellIconSize.SMALL.value + 16), + ) { item -> + val spell = item.value as SpellCompendiumItemState + val alpha = if (spell.selected) 0.5f else 1f + SpellIconInfo( + name = spell.name, + school = SchoolOfMagicState.valueOf(spell.school.name), + size = SpellIconSize.SMALL, + modifier = Modifier.padding(bottom = 16.dp).alpha(alpha), + onClick = { intent.onSpellClick(spell.index) }, + onLongClick = { intent.onSpellLongClick(spell.index) }, + ) + } + + LaunchedEffect(initialItemIndex) { + if (listState.firstVisibleItemIndex != initialItemIndex) { + listState.scrollToItem(initialItemIndex) + } + } +} + +private fun Map>.toCompendiumItems(): List { + val result = mutableListOf() + entries.forEach { (level, spells) -> + result.add(CompendiumItemState.Title(level)) + result.addAll(spells.toCompendiumItems()) + } + + return result +} + +private fun List.toCompendiumItems(): List { + return map { CompendiumItemState.Item(it) } +} diff --git a/feature/spell-compendium/android/src/main/res/values-pt-rBR/strings.xml b/feature/spell-compendium/android/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 00000000..b649eca6 --- /dev/null +++ b/feature/spell-compendium/android/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/feature/spell-compendium/android/src/main/res/values/strings.xml b/feature/spell-compendium/android/src/main/res/values/strings.xml new file mode 100644 index 00000000..db8db5a7 --- /dev/null +++ b/feature/spell-compendium/android/src/main/res/values/strings.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/feature/spell-compendium/event/.gitignore b/feature/spell-compendium/event/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/feature/spell-compendium/event/.gitignore @@ -0,0 +1 @@ +/build diff --git a/feature/spell-compendium/event/build.gradle.kts b/feature/spell-compendium/event/build.gradle.kts new file mode 100644 index 00000000..1a14580c --- /dev/null +++ b/feature/spell-compendium/event/build.gradle.kts @@ -0,0 +1,35 @@ +/* + * Copyright 2023 Alexandre Gomes Pereira + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + kotlin("multiplatform") +} + +configureJvmTargets() + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(project(":core:event")) + implementation(libs.kotlin.coroutines.core) + } + } + if (isMac()) { + val iosMain by getting + } + } +} diff --git a/feature/spell-compendium/event/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/event/Event.kt b/feature/spell-compendium/event/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/event/Event.kt new file mode 100644 index 00000000..a854d8ca --- /dev/null +++ b/feature/spell-compendium/event/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/event/Event.kt @@ -0,0 +1,22 @@ +package br.alexandregpereira.hunter.spell.compendium.event + +import br.alexandregpereira.hunter.event.EventResultDispatcher + +sealed class SpellCompendiumEvent { + + data class Show( + val spellIndex: String? = null, + val selectedSpellIndexes: List = emptyList(), + ) : SpellCompendiumEvent() + + data object Hide : SpellCompendiumEvent() +} + +sealed class SpellCompendiumResult { + + data class OnSpellClick(val spellIndex: String) : SpellCompendiumResult() + + data class OnSpellLongClick(val spellIndex: String) : SpellCompendiumResult() +} + +interface SpellCompendiumEventResultDispatcher : EventResultDispatcher diff --git a/feature/spell-compendium/state-holder/.gitignore b/feature/spell-compendium/state-holder/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/feature/spell-compendium/state-holder/.gitignore @@ -0,0 +1 @@ +/build diff --git a/feature/spell-compendium/state-holder/build.gradle.kts b/feature/spell-compendium/state-holder/build.gradle.kts new file mode 100644 index 00000000..0435143c --- /dev/null +++ b/feature/spell-compendium/state-holder/build.gradle.kts @@ -0,0 +1,39 @@ +/* + * Copyright 2023 Alexandre Gomes Pereira + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + kotlin("multiplatform") +} + +configureJvmTargets() + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(project(":core:state-holder")) + implementation(project(":core:event")) + implementation(project(":domain:spell:core")) + implementation(project(":feature:spell-compendium:event")) + implementation(libs.kotlin.coroutines.core) + implementation(libs.koin.core) + } + } + if (isMac()) { + val iosMain by getting + } + } +} diff --git a/feature/spell-compendium/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/SpellCompendiumIntent.kt b/feature/spell-compendium/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/SpellCompendiumIntent.kt new file mode 100644 index 00000000..f977f68d --- /dev/null +++ b/feature/spell-compendium/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/SpellCompendiumIntent.kt @@ -0,0 +1,22 @@ +package br.alexandregpereira.hunter.spell.compendium + +interface SpellCompendiumIntent { + fun onSearchTextChange(text: String) + + fun onSpellClick(spellIndex: String) + + fun onSpellLongClick(spellIndex: String) + + fun onClose() +} + +class EmptySpellCompendiumIntent : SpellCompendiumIntent { + + override fun onSearchTextChange(text: String) {} + + override fun onSpellClick(spellIndex: String) {} + + override fun onSpellLongClick(spellIndex: String) {} + + override fun onClose() {} +} diff --git a/feature/spell-compendium/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/SpellCompendiumState.kt b/feature/spell-compendium/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/SpellCompendiumState.kt new file mode 100644 index 00000000..4eb8a2e3 --- /dev/null +++ b/feature/spell-compendium/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/SpellCompendiumState.kt @@ -0,0 +1,26 @@ +package br.alexandregpereira.hunter.spell.compendium + +data class SpellCompendiumState( + val isShowing: Boolean = false, + val spellsGroupByLevel: Map> = emptyMap(), + val searchText: String = "", + val initialItemIndex: Int = 0, +) + +data class SpellCompendiumItemState( + val index: String, + val name: String, + val school: SpellCompendiumSchoolOfMagicState, + val selected: Boolean = false, +) + +enum class SpellCompendiumSchoolOfMagicState { + ABJURATION, + CONJURATION, + DIVINATION, + ENCHANTMENT, + EVOCATION, + ILLUSION, + NECROMANCY, + TRANSMUTATION, +} diff --git a/feature/spell-compendium/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/SpellCompendiumStateHolder.kt b/feature/spell-compendium/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/SpellCompendiumStateHolder.kt new file mode 100644 index 00000000..0c27b811 --- /dev/null +++ b/feature/spell-compendium/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/SpellCompendiumStateHolder.kt @@ -0,0 +1,145 @@ +package br.alexandregpereira.hunter.spell.compendium + +import br.alexandregpereira.hunter.domain.spell.model.Spell +import br.alexandregpereira.hunter.event.EventDispatcher +import br.alexandregpereira.hunter.event.EventListener +import br.alexandregpereira.hunter.spell.compendium.domain.GetSpellsUseCase +import br.alexandregpereira.hunter.spell.compendium.event.SpellCompendiumEvent +import br.alexandregpereira.hunter.spell.compendium.event.SpellCompendiumResult +import br.alexandregpereira.hunter.state.MutableStateHolder +import br.alexandregpereira.hunter.state.ScopeManager +import br.alexandregpereira.hunter.state.StateHolder +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@OptIn(FlowPreview::class) +class SpellCompendiumStateHolder internal constructor( + private val dispatcher: CoroutineDispatcher, + private val getSpellsUseCase: GetSpellsUseCase, + private val stateHandler: MutableStateHolder, + private val eventListener: EventListener, + private val resultDispatcher: EventDispatcher, +) : ScopeManager(), + SpellCompendiumIntent, + StateHolder by stateHandler { + + private val searchQuery = MutableStateFlow(state.value.searchText) + private val originalSpellsGroupByLevel = mutableMapOf>() + + init { + debounceSearch() + observeEvents() + if (state.value.isShowing && state.value.spellsGroupByLevel.isEmpty()) { + fetch() + } + } + + private fun fetch( + spellIndex: String? = null, + selectedSpellIndexes: List = emptyList(), + ) { + getSpellsUseCase() + .onEach { spells -> + val spellsGroupByLevel = spells.groupByLevel(selectedSpellIndexes) + val compendiumIndex = spells.firstOrNull { + it.index == spellIndex + }?.level?.let { level -> + spellsGroupByLevel.compendiumIndexOf(level) + } + originalSpellsGroupByLevel.clear() + originalSpellsGroupByLevel.putAll(spellsGroupByLevel) + stateHandler.setState { + copy( + spellsGroupByLevel = spellsGroupByLevel, + initialItemIndex = compendiumIndex?.takeIf { it >= 0 } ?: 0 + ) + } + } + .flowOn(dispatcher) + .launchIn(scope) + } + + private fun debounceSearch() { + searchQuery.debounce(500L) + .onEach { text -> + stateHandler.setState { + val spellsGroupByLevel = if (text.isNotBlank()){ + val spellsFiltered = spellsGroupByLevel.values.flatten() + .filter { it.name.contains(text, ignoreCase = true) } + mapOf("${spellsFiltered.size} results" to spellsFiltered) + } else originalSpellsGroupByLevel + + copy(spellsGroupByLevel = spellsGroupByLevel) + } + } + .flowOn(dispatcher) + .launchIn(scope) + } + + private fun observeEvents() { + eventListener.events.onEach { event -> + when (event) { + is SpellCompendiumEvent.Show -> { + fetch( + spellIndex = event.spellIndex, + selectedSpellIndexes = event.selectedSpellIndexes, + ) + stateHandler.setState { copy(isShowing = true) } + } + is SpellCompendiumEvent.Hide -> onClose() + } + }.launchIn(scope) + } + + override fun onSearchTextChange(text: String) { + stateHandler.setState { copy(searchText = text) } + searchQuery.value = text + } + + override fun onSpellClick(spellIndex: String) { + resultDispatcher.dispatchEvent(SpellCompendiumResult.OnSpellClick(spellIndex)) + } + + override fun onSpellLongClick(spellIndex: String) { + resultDispatcher.dispatchEvent(SpellCompendiumResult.OnSpellLongClick(spellIndex)) + } + + override fun onClose() { + stateHandler.setState { copy(isShowing = false) } + } + + private fun List.groupByLevel( + selectedSpellIndexes: List, + ): Map> { + return groupBy { + it.level.getSpellLevelText() + }.mapValues { (_, spells) -> spells.asState(selectedSpellIndexes) } + } + + private fun Int.getSpellLevelText(): String { + return when (this) { + 0 -> "Cantrip" + else -> "Level $this" + } + } + + private fun Map>.compendiumIndexOf(level: Int): Int { + val list = mutableListOf() + entries.forEach { (key, value) -> + list.add(key) + list.addAll(value) + } + + return list.indexOfFirst { + when (it) { + is String -> it == level.getSpellLevelText() + else -> false + } + } + } +} diff --git a/feature/spell-compendium/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/SpellCompendiumStateMapper.kt b/feature/spell-compendium/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/SpellCompendiumStateMapper.kt new file mode 100644 index 00000000..f0efc357 --- /dev/null +++ b/feature/spell-compendium/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/SpellCompendiumStateMapper.kt @@ -0,0 +1,40 @@ +package br.alexandregpereira.hunter.spell.compendium + +import br.alexandregpereira.hunter.domain.spell.model.Spell +import br.alexandregpereira.hunter.domain.spell.model.SchoolOfMagic.ABJURATION as ABJURATION_DOMAIN +import br.alexandregpereira.hunter.domain.spell.model.SchoolOfMagic.CONJURATION as CONJURATION_DOMAIN +import br.alexandregpereira.hunter.domain.spell.model.SchoolOfMagic.DIVINATION as DIVINATION_DOMAIN +import br.alexandregpereira.hunter.domain.spell.model.SchoolOfMagic.ENCHANTMENT as ENCHANTMENT_DOMAIN +import br.alexandregpereira.hunter.domain.spell.model.SchoolOfMagic.EVOCATION as EVOCATION_DOMAIN +import br.alexandregpereira.hunter.domain.spell.model.SchoolOfMagic.ILLUSION as ILLUSION_DOMAIN +import br.alexandregpereira.hunter.domain.spell.model.SchoolOfMagic.NECROMANCY as NECROMANCY_DOMAIN +import br.alexandregpereira.hunter.domain.spell.model.SchoolOfMagic.TRANSMUTATION as TRANSMUTATION_DOMAIN + +internal fun List.asState( + selectedSpellIndexes: List = emptyList(), +): List { + val allIndexes = map { spellIndex -> + spellIndex.index.takeIf { selectedSpellIndexes.contains(it) } + } + return mapIndexed { i, spell -> + spell.asState(selected = allIndexes[i] != null) + } +} + +internal fun Spell.asState(selected: Boolean): SpellCompendiumItemState { + return SpellCompendiumItemState( + index = index, + name = name, + school = when (school) { + ABJURATION_DOMAIN -> SpellCompendiumSchoolOfMagicState.ABJURATION + CONJURATION_DOMAIN -> SpellCompendiumSchoolOfMagicState.CONJURATION + DIVINATION_DOMAIN -> SpellCompendiumSchoolOfMagicState.DIVINATION + ENCHANTMENT_DOMAIN -> SpellCompendiumSchoolOfMagicState.ENCHANTMENT + EVOCATION_DOMAIN -> SpellCompendiumSchoolOfMagicState.EVOCATION + ILLUSION_DOMAIN -> SpellCompendiumSchoolOfMagicState.ILLUSION + NECROMANCY_DOMAIN -> SpellCompendiumSchoolOfMagicState.NECROMANCY + TRANSMUTATION_DOMAIN -> SpellCompendiumSchoolOfMagicState.TRANSMUTATION + }, + selected = selected, + ) +} diff --git a/feature/spell-compendium/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/di/Module.kt b/feature/spell-compendium/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/di/Module.kt new file mode 100644 index 00000000..e073aafa --- /dev/null +++ b/feature/spell-compendium/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/di/Module.kt @@ -0,0 +1,56 @@ +package br.alexandregpereira.hunter.spell.compendium.di + +import br.alexandregpereira.hunter.event.EventDispatcher +import br.alexandregpereira.hunter.event.EventListener +import br.alexandregpereira.hunter.event.EventManager +import br.alexandregpereira.hunter.spell.compendium.SpellCompendiumState +import br.alexandregpereira.hunter.spell.compendium.SpellCompendiumStateHolder +import br.alexandregpereira.hunter.spell.compendium.domain.GetSpellsUseCase +import br.alexandregpereira.hunter.spell.compendium.event.SpellCompendiumEvent +import br.alexandregpereira.hunter.spell.compendium.event.SpellCompendiumEventResultDispatcher +import br.alexandregpereira.hunter.spell.compendium.event.SpellCompendiumResult +import br.alexandregpereira.hunter.state.MutableStateHolder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import org.koin.dsl.module +import kotlin.native.HiddenFromObjC + +@HiddenFromObjC +val spellCompendiumStateModule = module { + single { SpellCompendiumEventManager() } + + factory { get() } + + factory { GetSpellsUseCase(repository = get()) } + + factory { + SpellCompendiumStateHolder( + dispatcher = Dispatchers.Default, + getSpellsUseCase = get(), + stateHandler = MutableStateHolder(SpellCompendiumState()), + eventListener = get(), + resultDispatcher = get() + ) + } +} + +private class SpellCompendiumEventManager : + EventListener, + EventDispatcher, + SpellCompendiumEventResultDispatcher { + + private val eventDelegate = EventManager() + private var resultDelegate = EventManager() + + override val events: Flow = eventDelegate.events + + override fun dispatchEvent(event: SpellCompendiumResult) { + resultDelegate.dispatchEvent(event) + } + + override fun dispatchEventResult(event: SpellCompendiumEvent): Flow { + resultDelegate = EventManager() + eventDelegate.dispatchEvent(event) + return resultDelegate.events + } +} diff --git a/feature/spell-compendium/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/domain/GetSpellsUseCase.kt b/feature/spell-compendium/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/domain/GetSpellsUseCase.kt new file mode 100644 index 00000000..74dd8c77 --- /dev/null +++ b/feature/spell-compendium/state-holder/src/commonMain/kotlin/br/alexandregpereira/hunter/spell/compendium/domain/GetSpellsUseCase.kt @@ -0,0 +1,14 @@ +package br.alexandregpereira.hunter.spell.compendium.domain + +import br.alexandregpereira.hunter.domain.spell.SpellLocalRepository +import br.alexandregpereira.hunter.domain.spell.model.Spell +import kotlinx.coroutines.flow.Flow + +internal fun interface GetSpellsUseCase { + + operator fun invoke(): Flow> +} + +internal fun GetSpellsUseCase(repository: SpellLocalRepository): GetSpellsUseCase { + return GetSpellsUseCase { repository.getLocalSpells() } +} diff --git a/settings.gradle b/settings.gradle index ecdaa9f2..afd1db93 100644 --- a/settings.gradle +++ b/settings.gradle @@ -63,6 +63,9 @@ include ':feature:search:android' include ':feature:settings:android' include ':feature:spell-detail:android' include ':feature:spell-detail:event' +include ':feature:spell-compendium:android' +include ':feature:spell-compendium:event' +include ':feature:spell-compendium:state-holder' include ':feature:sync:android' include ':feature:sync:event' include ':feature:sync:state-holder' diff --git a/ui/compendium/src/main/java/br/alexandregpereira/hunter/ui/compendium/Compendium.kt b/ui/compendium/src/main/java/br/alexandregpereira/hunter/ui/compendium/Compendium.kt index 8dbebf34..d407fbeb 100644 --- a/ui/compendium/src/main/java/br/alexandregpereira/hunter/ui/compendium/Compendium.kt +++ b/ui/compendium/src/main/java/br/alexandregpereira/hunter/ui/compendium/Compendium.kt @@ -38,6 +38,7 @@ fun Compendium( items: List, modifier: Modifier = Modifier, animateItems: Boolean = false, + columns: CompendiumColumns = CompendiumColumns.Fixed(count = 2), listState: LazyGridState = rememberLazyGridState(), contentPadding: PaddingValues = PaddingValues(0.dp), key: (CompendiumItemState.Item) -> Any? = { null }, @@ -45,7 +46,7 @@ fun Compendium( cardContent: @Composable (CompendiumItemState.Item) -> Unit, ) = Surface(modifier) { LazyVerticalGrid( - columns = GridCells.Fixed(count = 2), + columns = columns.toGridCells(), state = listState, contentPadding = PaddingValues( start = 16.dp, @@ -105,6 +106,19 @@ fun Compendium( } } +sealed class CompendiumColumns { + data class Fixed(val count: Int) : CompendiumColumns() + + data class Adaptive( + val minSize: Int, + ) : CompendiumColumns() +} + +private fun CompendiumColumns.toGridCells(): GridCells = when (this) { + is CompendiumColumns.Fixed -> GridCells.Fixed(count) + is CompendiumColumns.Adaptive -> GridCells.Adaptive(minSize.dp) +} + @OptIn(ExperimentalFoundationApi::class) private fun Modifier.animateItems( scope: LazyGridItemScope, diff --git a/ui/core/src/main/kotlin/br/alexandregpereira/hunter/ui/compose/AppTextField.kt b/ui/core/src/main/kotlin/br/alexandregpereira/hunter/ui/compose/AppTextField.kt index d2b16244..d2a03dfd 100644 --- a/ui/core/src/main/kotlin/br/alexandregpereira/hunter/ui/compose/AppTextField.kt +++ b/ui/core/src/main/kotlin/br/alexandregpereira/hunter/ui/compose/AppTextField.kt @@ -16,19 +16,32 @@ package br.alexandregpereira.hunter.ui.compose +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview @Composable fun AppTextField( @@ -38,7 +51,24 @@ fun AppTextField( keyboardType: AppKeyboardType = AppKeyboardType.TEXT, multiline: Boolean = false, capitalize: Boolean = true, - onValueChange: (String) -> Unit = {} + onValueChange: (String) -> Unit = {}, + trailingIcon: @Composable (() -> Unit)? = { + AnimatedVisibility( + visible = text.isNotEmpty(), + enter = fadeIn(), + exit = fadeOut(), + ) { + IconButton( + onClick = { onValueChange("") }, + ) { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = "Clear", + tint = MaterialTheme.colors.onSurface + ) + } + } + }, ) { val focusManager = LocalFocusManager.current val capitalization = if (capitalize) { @@ -64,7 +94,8 @@ fun AppTextField( }, capitalization = capitalization, ), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + trailingIcon = trailingIcon ) } @@ -90,3 +121,17 @@ enum class AppKeyboardType { TEXT, NUMBER } + +@Preview +@Composable +private fun AppTextFieldPreview() = Window { + var value by remember { mutableStateOf("Text") } + AppTextField(text = "Text", label = "Label", onValueChange = { value = it }) +} + +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun AppTextFieldDarkPreview() = Window { + var value by remember { mutableStateOf("Text") } + AppTextField(text = "Text", label = "Label", onValueChange = { value = it }) +} diff --git a/ui/core/src/main/kotlin/br/alexandregpereira/hunter/ui/compose/ClickableField.kt b/ui/core/src/main/kotlin/br/alexandregpereira/hunter/ui/compose/ClickableField.kt new file mode 100644 index 00000000..95030f95 --- /dev/null +++ b/ui/core/src/main/kotlin/br/alexandregpereira/hunter/ui/compose/ClickableField.kt @@ -0,0 +1,66 @@ +package br.alexandregpereira.hunter.ui.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun ClickableField( + label: String, + text: String, + modifier: Modifier = Modifier, + trailingIcon: @Composable (() -> Unit)? = { + Icon( + imageVector = Icons.Filled.KeyboardArrowDown, + contentDescription = "", + tint = MaterialTheme.colors.onSurface + ) + }, + onClick: () -> Unit = {} +) { + Box(modifier) { + Column { + AppTextField(text = text, label = label, trailingIcon = trailingIcon) + } + Spacer( + modifier = Modifier + .matchParentSize() + .background(Color.Transparent) + .clickable( + onClick = onClick + ) + ) + } +} + +@Preview +@Composable +private fun ClickableFieldPreview() = Window { + ClickableField( + label = "Label", + text = "", + modifier = Modifier.padding(16.dp), + ) +} + +@Preview +@Composable +private fun ClickableFieldWithValuePreview() = Window { + ClickableField( + label = "Label", + text = "Value", + modifier = Modifier.padding(16.dp), + ) +} diff --git a/ui/core/src/main/kotlin/br/alexandregpereira/hunter/ui/compose/PickerField.kt b/ui/core/src/main/kotlin/br/alexandregpereira/hunter/ui/compose/PickerField.kt index ee4d33f5..8c3c990b 100644 --- a/ui/core/src/main/kotlin/br/alexandregpereira/hunter/ui/compose/PickerField.kt +++ b/ui/core/src/main/kotlin/br/alexandregpereira/hunter/ui/compose/PickerField.kt @@ -10,7 +10,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -31,7 +35,17 @@ fun PickerField( val isOpen = remember { mutableStateOf(false) } Box(modifier) { Column { - AppTextField(text = value, label = label) + AppTextField( + text = value, + label = label, + trailingIcon = { + Icon( + imageVector = Icons.Filled.KeyboardArrowDown, + contentDescription = "", + tint = MaterialTheme.colors.onSurface + ) + } + ) DropdownMenu( modifier = Modifier.fillMaxWidth(), expanded = isOpen.value, diff --git a/ui/core/src/main/kotlin/br/alexandregpereira/hunter/ui/compose/SpellIconInfo.kt b/ui/core/src/main/kotlin/br/alexandregpereira/hunter/ui/compose/SpellIconInfo.kt index 1a6c88ad..e3e9818e 100644 --- a/ui/core/src/main/kotlin/br/alexandregpereira/hunter/ui/compose/SpellIconInfo.kt +++ b/ui/core/src/main/kotlin/br/alexandregpereira/hunter/ui/compose/SpellIconInfo.kt @@ -20,7 +20,6 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import br.alexandregpereira.hunter.ui.R import br.alexandregpereira.hunter.ui.util.toColor @@ -31,7 +30,8 @@ fun SpellIconInfo( modifier: Modifier = Modifier, name: String? = null, size: SpellIconSize = SpellIconSize.SMALL, - onClick: () -> Unit = {} + onClick: () -> Unit = {}, + onLongClick: () -> Unit = {}, ) { val iconColor = if (isSystemInDarkTheme()) school.iconColorDark else school.iconColorLight IconInfo( @@ -39,16 +39,17 @@ fun SpellIconInfo( painter = painterResource(school.iconRes), iconColor = iconColor.toColor(), iconAlpha = 1f, - iconSize = size.value, + iconSize = size.value.dp, modifier = modifier.animatePressed( pressedScale = 0.85f, - onClick = onClick + onClick = onClick, + onLongClick = onLongClick, ) ) } -enum class SpellIconSize(val value: Dp) { - LARGE(72.dp), SMALL(56.dp) +enum class SpellIconSize(val value: Int) { + LARGE(72), SMALL(56) } enum class SchoolOfMagicState(val iconRes: Int, val iconColorLight: String, val iconColorDark: String) {