From ffcbbf36679210e0f02fa3949d8bc022ed46b5d7 Mon Sep 17 00:00:00 2001 From: JUNWON LEE Date: Mon, 19 Aug 2024 20:57:25 +0900 Subject: [PATCH] =?UTF-8?q?[0.2.0.alpha/AN=5FFEAT=5FUI]=20PokemonList=20Fi?= =?UTF-8?q?lter,=20PokeChip,=20PokeChipGroup=20Component=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#242)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: PokeListFragment -> PokeActivity * test: EventFLowTest * chore: RefreshEventBus 네이밍 변경 * fix: 5초 지나고 화면 다시 돌아오면 오둥이 보이는 버그 수정 * feat: SearchView clearFocus * style: ktFormat * ui: margin 늘리기 * feat: Pokemon 프로퍼티 추가 * feat: Pokemon filter, sort * feat: Pokemon filtering, Sort 로직 * test: FakeDexRepository * style: KtFormat * [2.0.0-alph/AN-UI,Fix] 화면 나갔다가 5초 이후에 들어오면 오둥이 보이는 버그 수정 + clearFocus (#225) * refactor: PokeListFragment -> PokeActivity * test: EventFLowTest * chore: RefreshEventBus 네이밍 변경 * fix: 5초 지나고 화면 다시 돌아오면 오둥이 보이는 버그 수정 * feat: SearchView clearFocus * style: ktFormat * ui: margin 늘리기 * [2.0.0.alpha/AN-UI] 배틀 도우미 화면 구현 (#232) * feat: 배틀 페이지 이동 * chore: 필요한 아이콘 추가 * chore: 필요한 배경 및 스타일 지정 * ui: 배틀 페이지 xml 작성 * ui: 날씨 선택 스피너 구현 * ui: 배틀 페이지 액티비티 구현 * feat: 날씨 데이터 연결 * refactor: adapter의 리스트 데이터를 업데이트할 수 있도록 변경 * fix: 린트 수정 및 충돌 해결 * ui: PokeChip, PokeChipGroup * [0.2.0.alpha/AN-FEAT] Pokemon FIlter, Sort data 로직 (#228) * refactor: PokeListFragment -> PokeActivity * test: EventFLowTest * chore: RefreshEventBus 네이밍 변경 * fix: 5초 지나고 화면 다시 돌아오면 오둥이 보이는 버그 수정 * feat: SearchView clearFocus * style: ktFormat * ui: margin 늘리기 * feat: Pokemon 프로퍼티 추가 * feat: Pokemon filter, sort * feat: Pokemon filtering, Sort 로직 * test: FakeDexRepository * style: KtFormat * feat: 여러 filter 조건으로 받을 수 있도록 리팩토링 * refactor: filter 네이밍 변경 * refactor: packaing * ui: Chip select 시 bold 처리 * fix: DexRepository isBlanck() 로 변경 * build: crashlytics impl 위치 변경 * chore: sample Activity 삭제 * feat: PokeFIlter 구현 * style: ktFormat * refactor: 코드 정리 * chore: PokeChipSpec -> Spec * chore: 불필요한 Timber 삭제 * style: ktFormat * ui: PokeChip - ConerRadius, Padding 추가 * ui: PokeSpec - color, sizes naming 변경 --------- Co-authored-by: Yehyun Jo <81362348+JoYehyun99@users.noreply.github.com> --- android/app/build.gradle.kts | 2 +- android/app/src/main/AndroidManifest.xml | 2 +- .../presentation/dex/PokemonListActivity.kt | 52 ++- .../presentation/dex/PokemonListViewModel.kt | 84 ++++- .../dex/detail/PokemonDetailActivity.kt | 5 +- .../dex/detail/PokemonDetailViewModel.kt | 2 +- .../dex/filter/PokeFilterUiEvent.kt | 14 + .../dex/filter/PokeFilterUiModel.kt | 11 + .../dex/filter/PokeFilterUiState.kt | 57 +++ .../dex/filter/PokeFilterViewModel.kt | 134 ++++++++ .../dex/filter/PokeGenerationUiModel.kt | 26 ++ .../PokemonFilterBottomSheetFragment.kt | 146 ++++++++ .../dex/filter/SelectableUiModel.kt | 7 + .../presentation/type/model/TypeUiModel.kt | 7 +- .../presentation/util/view/DimensionUtils.kt | 2 + .../util/view/ViewInteractionUtils.kt | 2 + .../rogue/helper/ui/component/PokeChip.kt | 325 ++++++++++++++++++ .../helper/ui/component/PokeChipGroup.kt | 163 +++++++++ .../rogue/helper/ui/layout/PaddingValues.kt | 33 ++ .../main/res/drawable/bg_battle_selection.xml | 5 + .../app/src/main/res/drawable/ic_filter.xml | 5 + .../main/res/layout/activity_pokemon_list.xml | 26 +- .../layout/bottom_sheet_pokemon_filter.xml | 105 ++++++ android/app/src/main/res/values/attrs.xml | 34 +- android/app/src/main/res/values/colors.xml | 17 +- android/app/src/main/res/values/strings.xml | 13 + .../dex/PokemonListViewModelTest.kt | 12 +- .../data/repository/DefaultDexRepository.kt | 2 +- .../repository/FakeAbilityRepositoryTest.kt | 2 +- 29 files changed, 1251 insertions(+), 44 deletions(-) create mode 100644 android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiEvent.kt create mode 100644 android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiModel.kt create mode 100644 android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiState.kt create mode 100644 android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterViewModel.kt create mode 100644 android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeGenerationUiModel.kt create mode 100644 android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokemonFilterBottomSheetFragment.kt create mode 100644 android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/SelectableUiModel.kt create mode 100644 android/app/src/main/java/poke/rogue/helper/ui/component/PokeChip.kt create mode 100644 android/app/src/main/java/poke/rogue/helper/ui/component/PokeChipGroup.kt create mode 100644 android/app/src/main/java/poke/rogue/helper/ui/layout/PaddingValues.kt create mode 100644 android/app/src/main/res/drawable/bg_battle_selection.xml create mode 100644 android/app/src/main/res/drawable/ic_filter.xml create mode 100644 android/app/src/main/res/layout/bottom_sheet_pokemon_filter.xml diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index dc439bf9..fb74c886 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -183,7 +183,6 @@ dependencies { implementation(project(":data")) implementation(project(":local")) implementation(project(":analytics")) - implementation(libs.firebase.crashlytics.buildtools) testImplementation(project(":testing")) androidTestImplementation(project(":testing")) // androidx @@ -206,6 +205,7 @@ dependencies { implementation(libs.splash.screen) // google & firebase implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.crashlytics.buildtools) implementation(libs.bundles.firebase) // android test androidTestImplementation(libs.bundles.android.test) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 64197610..92ab3296 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -62,4 +62,4 @@ android:exported="false" /> - + \ No newline at end of file diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonListActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonListActivity.kt index 552f1cd9..afe8256c 100644 --- a/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonListActivity.kt +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonListActivity.kt @@ -4,6 +4,7 @@ import android.content.res.Configuration import android.os.Bundle import androidx.activity.viewModels import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.GridLayoutManager import poke.rogue.helper.R import poke.rogue.helper.data.repository.DefaultDexRepository @@ -11,10 +12,16 @@ import poke.rogue.helper.databinding.ActivityPokemonListBinding import poke.rogue.helper.presentation.base.error.ErrorHandleActivity import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel import poke.rogue.helper.presentation.dex.detail.PokemonDetailActivity +import poke.rogue.helper.presentation.dex.filter.PokeFilterUiModel +import poke.rogue.helper.presentation.dex.filter.PokemonFilterBottomSheetFragment import poke.rogue.helper.presentation.util.activity.hideKeyboard +import poke.rogue.helper.presentation.util.context.stringOf import poke.rogue.helper.presentation.util.repeatOnStarted import poke.rogue.helper.presentation.util.view.GridSpacingItemDecoration import poke.rogue.helper.presentation.util.view.dp +import poke.rogue.helper.ui.component.PokeChip +import poke.rogue.helper.ui.component.PokeChip.Companion.bindPokeChip +import poke.rogue.helper.ui.layout.PaddingValues class PokemonListActivity : ErrorHandleActivity(R.layout.activity_pokemon_list) { @@ -35,13 +42,11 @@ class PokemonListActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding.viewModel = viewModel + binding.vm = viewModel binding.lifecycleOwner = this initAdapter() initObservers() - binding.root.setOnClickListener { - hideKeyboard() - } + initListeners() } private fun initAdapter() { @@ -62,8 +67,29 @@ class PokemonListActivity : private fun initObservers() { repeatOnStarted { - viewModel.uiState.collect { pokemonUiModels -> - pokemonAdapter.submitList(pokemonUiModels) + viewModel.uiState.collect { uiState -> + pokemonAdapter.submitList(uiState.pokemons) + binding.chipPokeFiter.bindPokeChip( + PokeChip.Spec( + label = + stringOf( + R.string.dex_filter_chip, + if (uiState.isFiltered) uiState.filterCount.toString() else "", + ), + trailingIconRes = R.drawable.ic_filter, + isSelected = uiState.isFiltered, + padding = PaddingValues(horizontal = 10.dp, vertical = 8.dp), + onSelect = { + PokemonFilterBottomSheetFragment.newInstance( + uiState.filteredTypes, + uiState.filteredGeneration, + ).show( + supportFragmentManager, + PokemonFilterBottomSheetFragment.TAG, + ) + }, + ), + ) } } repeatOnStarted { @@ -72,9 +98,23 @@ class PokemonListActivity : startActivity(PokemonDetailActivity.intent(this, pokemonId)) } } + val fm: FragmentManager = supportFragmentManager + fm.setFragmentResultListener(RESULT_KEY, this) { key, bundle -> + val args: PokeFilterUiModel = + PokemonFilterBottomSheetFragment.argsFrom(bundle) + ?: return@setFragmentResultListener + viewModel.filterPokemon(args) + } + } + + private fun initListeners() { + binding.root.setOnClickListener { + hideKeyboard() + } } companion object { val TAG: String = PokemonListActivity::class.java.simpleName + const val RESULT_KEY = "PokemonListActivity_result_key" } } diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonListViewModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonListViewModel.kt index 1232467e..9606493b 100644 --- a/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonListViewModel.kt +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonListViewModel.kt @@ -11,8 +11,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn @@ -21,20 +21,34 @@ import kotlinx.coroutines.plus import poke.rogue.helper.analytics.AnalyticsLogger import poke.rogue.helper.analytics.analyticsLogger import poke.rogue.helper.data.exception.PokeException +import poke.rogue.helper.data.model.Pokemon +import poke.rogue.helper.data.model.PokemonFilter import poke.rogue.helper.data.repository.DexRepository import poke.rogue.helper.presentation.base.BaseViewModelFactory import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel +import poke.rogue.helper.presentation.dex.filter.PokeFilterUiModel +import poke.rogue.helper.presentation.dex.filter.PokeGenerationUiModel +import poke.rogue.helper.presentation.dex.filter.toDataOrNull import poke.rogue.helper.presentation.dex.model.PokemonUiModel import poke.rogue.helper.presentation.dex.model.toUi +import poke.rogue.helper.presentation.type.model.TypeUiModel +import poke.rogue.helper.presentation.type.model.toData class PokemonListViewModel( private val pokemonListRepository: DexRepository, logger: AnalyticsLogger = analyticsLogger(), ) : ErrorHandleViewModel(logger), PokemonListNavigateHandler, PokemonQueryHandler { private val searchQuery = MutableStateFlow("") + private val pokeFilter = + MutableStateFlow( + PokeFilterUiModel( + emptyList(), + PokeGenerationUiModel.ALL, + ), + ) - @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) - val uiState: StateFlow> = + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) + val uiState: StateFlow = merge(refreshEvent.map { "" }, searchQuery) .onStart { if (isEmpty.value) { @@ -42,19 +56,30 @@ class PokemonListViewModel( } } .debounce(300L) - .mapLatest { query -> - queriedPokemons(query) - } - .stateIn( + .flatMapLatest { query -> + pokeFilter.map { filter -> + PokemonListUiState( + pokemons = + queriedPokemons( + query, + filter.selectedTypes, + filter.selectedGeneration, + ), + filteredTypes = filter.selectedTypes, + filteredGeneration = filter.selectedGeneration, + ) + } + }.stateIn( viewModelScope + errorHandler, SharingStarted.WhileSubscribed(5000), - emptyList(), + PokemonListUiState(), ) + private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading.asStateFlow() val isEmpty: StateFlow = - uiState.map { it.isEmpty() && !isLoading.value } + uiState.map { it.pokemons.isEmpty() && !isLoading.value } .stateIn( viewModelScope + errorHandler, SharingStarted.WhileSubscribed(5000), @@ -64,13 +89,19 @@ class PokemonListViewModel( private val _navigateToDetailEvent = MutableSharedFlow() val navigateToDetailEvent = _navigateToDetailEvent.asSharedFlow() - private suspend fun queriedPokemons(query: String): List { + private suspend fun queriedPokemons( + query: String, + types: List, + generation: PokeGenerationUiModel, + ): List { return try { - if (query.isBlank()) { - pokemonListRepository.pokemons().toUi() - } else { - pokemonListRepository.filteredPokemons(query).toUi() - } + val filteredTypes = types.map { PokemonFilter.ByType(it.toData()) } + val filteredGenerations = + listOfNotNull(generation.toDataOrNull()).map { PokemonFilter.ByGeneration(it) } + pokemonListRepository.filteredPokemons( + query, + filters = filteredTypes + filteredGenerations, + ).map(Pokemon::toUi) } catch (e: PokeException) { handlePokemonError(e) emptyList() @@ -91,6 +122,12 @@ class PokemonListViewModel( } } + fun filterPokemon(filter: PokeFilterUiModel) { + viewModelScope.launch { + pokeFilter.value = filter + } + } + companion object { fun factory(pokemonListRepository: DexRepository): ViewModelProvider.Factory = BaseViewModelFactory { @@ -98,3 +135,20 @@ class PokemonListViewModel( } } } + +data class PokemonListUiState( + val pokemons: List = emptyList(), + val filteredTypes: List = emptyList(), + val filteredGeneration: PokeGenerationUiModel = PokeGenerationUiModel.ALL, +) { + val isFiltered get() = filteredTypes.isNotEmpty() || filteredGeneration != PokeGenerationUiModel.ALL + + val filterCount + get() = + run { + var count = 0 + if (filteredTypes.isNotEmpty()) count++ + if (filteredGeneration != PokeGenerationUiModel.ALL) count++ + count + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailActivity.kt index 2847cf5f..2da22c60 100644 --- a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailActivity.kt +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailActivity.kt @@ -22,7 +22,8 @@ import poke.rogue.helper.presentation.util.repeatOnStarted import poke.rogue.helper.presentation.util.view.dp import poke.rogue.helper.presentation.util.view.loadImageWithProgress -class PokemonDetailActivity : ToolbarActivity(R.layout.activity_pokemon_detail) { +class PokemonDetailActivity : + ToolbarActivity(R.layout.activity_pokemon_detail) { private val viewModel by viewModels { PokemonDetailViewModel.factory(DefaultDexRepository.instance()) } @@ -102,7 +103,7 @@ class PokemonDetailActivity : ToolbarActivity(R.la private fun observeNavigateToBiomeDetailEvent() { repeatOnStarted { - viewModel.navigationToDetailEvent.collect { biomeId -> + viewModel.navigationToBiomeDetailEvent.collect { biomeId -> startActivity(BiomeDetailActivity.intent(this, biomeId)) } } diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailViewModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailViewModel.kt index 406d1142..046bdd77 100644 --- a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailViewModel.kt +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailViewModel.kt @@ -36,7 +36,7 @@ class PokemonDetailViewModel( val navigationToAbilityDetailEvent: SharedFlow = _navigationToAbilityDetailEvent.asSharedFlow() private val _navigationToBiomeDetailEvent = MutableSharedFlow() - val navigationToDetailEvent: SharedFlow = _navigationToBiomeDetailEvent.asSharedFlow() + val navigationToBiomeDetailEvent: SharedFlow = _navigationToBiomeDetailEvent.asSharedFlow() private val _navigateToHomeEvent = MutableSharedFlow() val navigateToHomeEvent = _navigateToHomeEvent.asSharedFlow() diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiEvent.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiEvent.kt new file mode 100644 index 00000000..6d81d9eb --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiEvent.kt @@ -0,0 +1,14 @@ +package poke.rogue.helper.presentation.dex.filter + +import poke.rogue.helper.presentation.type.model.TypeUiModel + +sealed interface PokeFilterUiEvent { + data object IDLE : PokeFilterUiEvent + + data class ApplyFiltering( + val selectedTypes: List, + val generation: PokeGenerationUiModel, + ) : PokeFilterUiEvent + + data object CloseFilter : PokeFilterUiEvent +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiModel.kt new file mode 100644 index 00000000..72e0d718 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiModel.kt @@ -0,0 +1,11 @@ +package poke.rogue.helper.presentation.dex.filter + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import poke.rogue.helper.presentation.type.model.TypeUiModel + +@Parcelize +data class PokeFilterUiModel( + val selectedTypes: List, + val selectedGeneration: PokeGenerationUiModel, +) : Parcelable diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiState.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiState.kt new file mode 100644 index 00000000..654376c9 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiState.kt @@ -0,0 +1,57 @@ +package poke.rogue.helper.presentation.dex.filter + +import poke.rogue.helper.presentation.type.model.TypeUiModel + +data class PokeFilterUiState( + val types: List>, + val generations: List>, + val selectedTypes: List = emptyList(), +) { + init { + require(generations.any { it.isSelected }) { + "적어도 하나의 세대가 선택되어야 합니다." + } + require(generations.size == PokeGenerationUiModel.entries.size) { + "세대의 크기는 ${PokeGenerationUiModel.entries.size}여야 합니다." + } + require(types.size == TypeUiModel.entries.size) { + "타입의 크기는 ${TypeUiModel.entries.size}여야 합니다." + } + require(types.count { it.isSelected } <= 2) { + "최대 2개의 타입만 선택할 수 있습니다." + } + } + + val selectedGeneration: PokeGenerationUiModel + get() = generations.first { it.isSelected }.data + + companion object { + val DEFAULT = + PokeFilterUiState( + types = + TypeUiModel.entries.mapIndexed { index, typeUiModel -> + SelectableUiModel( + index, + false, + typeUiModel, + ) + }, + generations = + PokeGenerationUiModel.entries.mapIndexed { index, pokeGenerationUiModel -> + if (pokeGenerationUiModel == PokeGenerationUiModel.ALL) { + SelectableUiModel( + index, + true, + pokeGenerationUiModel, + ) + } else { + SelectableUiModel( + index, + false, + pokeGenerationUiModel, + ) + } + }, + ) + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterViewModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterViewModel.kt new file mode 100644 index 00000000..c51abaa9 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterViewModel.kt @@ -0,0 +1,134 @@ +package poke.rogue.helper.presentation.dex.filter + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import poke.rogue.helper.presentation.type.model.TypeUiModel +import poke.rogue.helper.presentation.util.event.EventFlow +import poke.rogue.helper.presentation.util.event.MutableEventFlow +import poke.rogue.helper.presentation.util.event.asEventFlow + +class PokeFilterViewModel : ViewModel() { + private val _uiState = MutableStateFlow(PokeFilterUiState.DEFAULT) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _uiEvent = MutableEventFlow() + val uiEvent: EventFlow = _uiEvent.asEventFlow() + + fun init(args: PokeFilterUiModel) { + _uiState.value = + PokeFilterUiState( + types = + TypeUiModel.entries.mapIndexed { index, typeUiModel -> + SelectableUiModel( + index, + args.selectedTypes.contains(typeUiModel), + typeUiModel, + ) + }, + generations = + PokeGenerationUiModel.entries.mapIndexed { index, pokeGenerationUiModel -> + SelectableUiModel( + index, + args.selectedGeneration == pokeGenerationUiModel, + pokeGenerationUiModel, + ) + }, + selectedTypes = args.selectedTypes, + ) + } + + fun selectType(id: Int) { + val selectedTypes = uiState.value.selectedTypes + val types = uiState.value.types + if (selectedTypes.size < LIMIT_TYPE_COUNT) { + return selectTypeWithinLimit(id, types, selectedTypes) + } + if (selectedTypes.any { it.id == id }) { + selectTypeWithinLimit(id, types, selectedTypes) + return + } + selectTypeExceedingLimit(id, types, selectedTypes) + } + + private fun selectTypeWithinLimit( + id: Int, + types: List>, + selectedTypes: List, + ) { + var newSelectedTypes = selectedTypes + val newTypes = + types.map { type -> + if (type.id == id) { + newSelectedTypes = + if (type.isSelected) { + selectedTypes - type.data + } else { + selectedTypes + type.data + } + return@map type.copy(isSelected = !type.isSelected) + } + type + } + _uiState.value = uiState.value.copy(types = newTypes, selectedTypes = newSelectedTypes) + } + + private fun selectTypeExceedingLimit( + id: Int, + types: List>, + selectedTypes: List, + ) { + var newSelectedTypes = selectedTypes + val firstSelectedType = selectedTypes.first() + val newTypes = + types.map { type -> + if (type.data == firstSelectedType) { + return@map type.copy(isSelected = false) + } + if (type.id == id) { + newSelectedTypes = selectedTypes.drop(1) + type.data + return@map type.copy(isSelected = !type.isSelected) + } + type + } + _uiState.value = uiState.value.copy(types = newTypes, selectedTypes = newSelectedTypes) + } + + fun toggleGeneration(generationId: Int) { + val generations = uiState.value.generations + if (generations[generationId].isSelected) return + val newGenerations = + uiState.value.generations.map { type -> + if (type.id == generationId) { + type.copy(isSelected = !type.isSelected) + } else { + type.copy(isSelected = false) + } + } + _uiState.value = uiState.value.copy(generations = newGenerations) + } + + fun applyFiltering() { + viewModelScope.launch { + _uiEvent.emit( + PokeFilterUiEvent.ApplyFiltering( + selectedTypes = uiState.value.selectedTypes, + generation = uiState.value.selectedGeneration, + ), + ) + } + } + + fun closeFilter() { + viewModelScope.launch { + _uiEvent.emit(PokeFilterUiEvent.CloseFilter) + } + } + + companion object { + private const val LIMIT_TYPE_COUNT: Int = 2 + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeGenerationUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeGenerationUiModel.kt new file mode 100644 index 00000000..71c6e3f9 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeGenerationUiModel.kt @@ -0,0 +1,26 @@ +package poke.rogue.helper.presentation.dex.filter + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import poke.rogue.helper.data.model.PokemonGeneration + +@Parcelize +enum class PokeGenerationUiModel(val number: Int) : Parcelable { + ALL(0), + ONE(1), + TWO(2), + THREE(3), + FOUR(4), + FIVE(5), + SIX(6), + SEVEN(7), + EIGHT(8), + NINE(9), +} + +fun PokeGenerationUiModel.toDataOrNull(): PokemonGeneration? { + if (this == PokeGenerationUiModel.ALL) { + return null + } + return PokemonGeneration.of(number) +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokemonFilterBottomSheetFragment.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokemonFilterBottomSheetFragment.kt new file mode 100644 index 00000000..5bdb2f85 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokemonFilterBottomSheetFragment.kt @@ -0,0 +1,146 @@ +package poke.rogue.helper.presentation.dex.filter + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.viewModels +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import poke.rogue.helper.R +import poke.rogue.helper.databinding.BottomSheetPokemonFilterBinding +import poke.rogue.helper.presentation.dex.PokemonListActivity.Companion.RESULT_KEY +import poke.rogue.helper.presentation.type.model.TypeUiModel +import poke.rogue.helper.presentation.util.fragment.stringOf +import poke.rogue.helper.presentation.util.parcelable +import poke.rogue.helper.presentation.util.repeatOnStarted +import poke.rogue.helper.presentation.util.view.dp +import poke.rogue.helper.ui.component.PokeChip + +class PokemonFilterBottomSheetFragment : BottomSheetDialogFragment() { + private var _binding: BottomSheetPokemonFilterBinding? = null + private val binding get() = requireNotNull(_binding) + private val viewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = BottomSheetPokemonFilterBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + binding.vm = viewModel + binding.lifecycleOwner = viewLifecycleOwner + if (arguments != null) { + val args = requireNotNull(argsFrom(requireArguments())) + viewModel.init(args) + } + + observeUiState() + observeEvents() + } + + private fun observeUiState() { + repeatOnStarted { + viewModel.uiState.collect { + binding.chipGroupPokeFilterType.submitList( + it.types.map { selectableType -> + PokeChip.Spec( + selectableType.id, + "", + leadingIconRes = selectableType.data.typeIconResId, + sizes = + PokeChip.Sizes( + leadingIconSize = 28.dp, + ), + colors = + PokeChip.Colors( + selectedContainerColor = selectableType.data.typeColor, + ), + isSelected = selectableType.isSelected, + onSelect = viewModel::selectType, + ) + }, + ) + binding.chipGroupPokeFilterGeneration.submitList( + it.generations.map { selectableGeneration -> + val generationText = + if (selectableGeneration.data == PokeGenerationUiModel.ALL) { + stringOf(R.string.dex_filter_all_generations) + } else { + stringOf( + R.string.dex_filter_generation_format, + selectableGeneration.data.number, + ) + } + PokeChip.Spec( + selectableGeneration.id, + generationText, + isSelected = selectableGeneration.isSelected, + onSelect = viewModel::toggleGeneration, + ) + }, + ) + } + } + } + + private fun observeEvents() { + repeatOnStarted { + viewModel.uiEvent.collect { event -> + when (event) { + is PokeFilterUiEvent.CloseFilter -> dismiss() + is PokeFilterUiEvent.ApplyFiltering -> { + val args = PokeFilterUiModel(event.selectedTypes, event.generation) + setFragmentResult( + RESULT_KEY, + bundleOf(ARGS_KEY to args), + ) + dismiss() + } + + is PokeFilterUiEvent.IDLE -> Unit + } + } + } + } + + override fun onStart() { + super.onStart() + val behavior = BottomSheetBehavior.from(requireView().parent as View) + behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + const val TAG = "PokemonFilterBottomSheetFragment" + private const val ARGS_KEY = "PokemonFilterBottomSheetFragment_args_key" + + fun argsFrom(result: Bundle): PokeFilterUiModel? { + return result.parcelable(ARGS_KEY) + } + + fun newInstance( + selectedTypes: List = emptyList(), + selectedGeneration: PokeGenerationUiModel = PokeGenerationUiModel.ALL, + ): PokemonFilterBottomSheetFragment { + return PokemonFilterBottomSheetFragment().apply { + arguments = + bundleOf(ARGS_KEY to PokeFilterUiModel(selectedTypes, selectedGeneration)) + } + } + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/SelectableUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/SelectableUiModel.kt new file mode 100644 index 00000000..92157e02 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/SelectableUiModel.kt @@ -0,0 +1,7 @@ +package poke.rogue.helper.presentation.dex.filter + +data class SelectableUiModel( + val id: Int, + val isSelected: Boolean, + val data: T, +) diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/type/model/TypeUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/type/model/TypeUiModel.kt index aa0c4b47..5d108573 100644 --- a/android/app/src/main/java/poke/rogue/helper/presentation/type/model/TypeUiModel.kt +++ b/android/app/src/main/java/poke/rogue/helper/presentation/type/model/TypeUiModel.kt @@ -1,16 +1,19 @@ package poke.rogue.helper.presentation.type.model +import android.os.Parcelable import androidx.annotation.ColorRes import androidx.annotation.DrawableRes +import kotlinx.parcelize.Parcelize import poke.rogue.helper.R import poke.rogue.helper.data.model.Type +@Parcelize enum class TypeUiModel( val id: Int, val typeName: String, @ColorRes val typeColor: Int, @DrawableRes val typeIconResId: Int, -) { +) : Parcelable { NORMAL(0, "노말", R.color.poke_normal, R.drawable.icon_type_normal), FIRE(1, "불꽃", R.color.poke_fire, R.drawable.icon_type_fire), WATER(2, "물", R.color.poke_water, R.drawable.icon_type_water), @@ -41,4 +44,6 @@ enum class TypeUiModel( fun Type.toUi(): TypeUiModel = TypeUiModel.valueOf(this.name) +fun TypeUiModel.toData(): Type = Type.fromId(this.id) + fun List.toUi(): List = map(Type::toUi) diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/view/DimensionUtils.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/DimensionUtils.kt index b9ad7757..c15574ac 100644 --- a/android/app/src/main/java/poke/rogue/helper/presentation/util/view/DimensionUtils.kt +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/DimensionUtils.kt @@ -4,6 +4,8 @@ import android.content.res.Resources val density = Resources.getSystem().displayMetrics.density +// TODO : move to ui/unit/Dimension.kt + val Int.dp get(): Int = (density * this).toInt() diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/view/ViewInteractionUtils.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/ViewInteractionUtils.kt index 47179fc5..8e68dad7 100644 --- a/android/app/src/main/java/poke/rogue/helper/presentation/util/view/ViewInteractionUtils.kt +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/ViewInteractionUtils.kt @@ -7,6 +7,8 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.TimeMark import kotlin.time.TimeSource +// TODO : move to ui/intereaction/SingleClick.kt + @BindingAdapter("duration", "onSingleClick", requireAll = false) fun View.setOnSingleClickListener( duration: Int = 500, diff --git a/android/app/src/main/java/poke/rogue/helper/ui/component/PokeChip.kt b/android/app/src/main/java/poke/rogue/helper/ui/component/PokeChip.kt new file mode 100644 index 00000000..0f1f337d --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/ui/component/PokeChip.kt @@ -0,0 +1,325 @@ +package poke.rogue.helper.ui.component + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.RippleDrawable +import android.graphics.drawable.StateListDrawable +import android.util.AttributeSet +import android.view.Gravity +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.Space +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.Dimension +import androidx.annotation.Dimension.Companion.DP +import androidx.annotation.Dimension.Companion.PX +import androidx.annotation.DrawableRes +import androidx.core.content.res.use +import androidx.core.graphics.ColorUtils +import androidx.databinding.BindingAdapter +import poke.rogue.helper.R +import poke.rogue.helper.presentation.util.context.colorOf +import poke.rogue.helper.presentation.util.view.dp +import poke.rogue.helper.presentation.util.view.setOnSingleClickListener +import poke.rogue.helper.ui.layout.PaddingValues +import poke.rogue.helper.ui.layout.applyTo + +class PokeChip + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + ) : LinearLayout(context, attrs, defStyleAttr) { + var chipId: Int = NO_ID + private set + + private var leadingIcon: ImageView = ImageView(context) + private var leadingSpacer: Space = Space(context) + private val label: TextView = TextView(context) + private var trailingIcon: ImageView = ImageView(context) + private var trailingSpacer: Space = Space(context) + + init { + context.obtainStyledAttributes(attrs, R.styleable.PokeChip).use { attributes -> + attributes.apply { + val leadingIcon = getResourceId(R.styleable.PokeChip_leadingIcon, NO_ICON) + val leadingIconSize = + getDimensionPixelSize(R.styleable.PokeChip_leadingIconSize, 24.dp) + val leadingSpacing = + getDimensionPixelSize(R.styleable.PokeChip_leadingSpacing, 8.dp) + + val label: String = getString(R.styleable.PokeChip_label) ?: "" + val labelSize = getDimension(R.styleable.PokeChip_labelSize, 16f) + + val trailingIcon = getResourceId(R.styleable.PokeChip_trailingIcon, NO_ICON) + val trailingIconSize = + getDimensionPixelSize(R.styleable.PokeChip_trailingIconSize, 24.dp) + val trailingSpacing = + getDimensionPixelSize(R.styleable.PokeChip_trailingSpacing, 4.dp) + + val labelColor = + getColor( + R.styleable.PokeChip_labelColor, + context.colorOf(R.color.poke_chip_text_default), + ) + val containerColor = + getColor( + R.styleable.PokeChip_containerColor, + context.colorOf(R.color.poke_chip_background_default), + ) + val strokeColor = + getColor( + R.styleable.PokeChip_strokeColor, + context.colorOf(R.color.poke_chip_stroke_default), + ) + val selectedLabelColor = + getColor( + R.styleable.PokeChip_selectedLabelColor, + context.colorOf(R.color.poke_chip_text_selected), + ) + val selectedStrokeColor = + getColor( + R.styleable.PokeChip_selectedStrokeColor, + context.colorOf(R.color.poke_chip_stroke_selected), + ) + val selectedContainerColor = + getColor( + R.styleable.PokeChip_selectedContainerColor, + context.colorOf(R.color.poke_chip_background_selected), + ) + + val cornerRadius = getDimensionPixelSize(R.styleable.PokeChip_cornerRadius, 10.dp) + val strokeWidth = getDimensionPixelSize(R.styleable.PokeChip_strokeWidth, 1.dp) + // init views + initLeadingIcon(leadingIcon, leadingIconSize, leadingSpacing, label.isNotBlank()) + initLabel(label, labelSize) + initTrailingIcon( + trailingIcon, + trailingIconSize, + trailingSpacing, + label.isNotBlank(), + ) + initDrawableBackground( + containerColor, + selectedContainerColor, + strokeColor, + selectedStrokeColor, + cornerRadius, + strokeWidth, + ) + initLabelColor(labelColor, selectedLabelColor) + initLayout() + } + } + } + + private fun initPokeChip(chipSpec: Spec) { + removeAllViews() + chipId = chipSpec.id + isSelected = chipSpec.isSelected + val (leadingIconSize, leadingSpacing, labelSize, trailingSpacing, trailingIconSize) = + chipSpec.sizes + val colorSpec = chipSpec.colors + initLeadingIcon( + chipSpec.leadingIconRes ?: NO_ICON, + leadingIconSize, + leadingSpacing, + chipSpec.label.isNotBlank(), + ) + initLabel(chipSpec.label, labelSize.toFloat()) + initTrailingIcon( + chipSpec.trailingIconRes ?: NO_ICON, + trailingIconSize, + trailingSpacing, + chipSpec.label.isNotBlank(), + ) + initDrawableBackground( + containerColor = context.colorOf(colorSpec.containerColor), + selectedContainerColor = context.colorOf(colorSpec.selectedContainerColor), + strokeColor = context.colorOf(colorSpec.strokeColor), + selectedStrokeColor = context.colorOf(colorSpec.selectedStrokeColor), + cornerRadius = chipSpec.cornerRadius, + strokeWidth = chipSpec.strokeWidth, + ) + initLabelColor( + labelColor = context.colorOf(colorSpec.labelColor), + selectedLabelColor = context.colorOf(colorSpec.selectedLabelColor), + ) + initLayout(chipSpec.padding) + setOnSingleClickListener(duration = 200) { + chipSpec.onSelect?.invoke(chipId) + } + } + + private fun initLayout(padding: PaddingValues = PaddingValues(8.dp)) { + gravity = Gravity.CENTER_VERTICAL + if (paddingStart == 0 && paddingTop == 0 && paddingEnd == 0 && paddingBottom == 0) { + padding.applyTo(this) + } + } + + private fun initLeadingIcon( + @DrawableRes leadingIconRes: Int, + @Dimension(DP) leadingIconSize: Int, + @Dimension(DP) leadingSpacing: Int, + hasLabel: Boolean, + ) { + leadingIcon.setImageResource(leadingIconRes) + leadingIcon.layoutParams = LayoutParams(leadingIconSize, leadingIconSize) + leadingSpacer.layoutParams = LayoutParams(leadingSpacing, LayoutParams.WRAP_CONTENT) + if (leadingIconRes != NO_ICON) { + addView(leadingIcon) + if (hasLabel) addView(leadingSpacer) + } + } + + private fun initLabel( + label: String, + @Dimension(PX) labelSize: Float, + ) { + this.label.text = label + this.label.layoutParams = + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + this.label.textSize = labelSize + this.label.paint.isFakeBoldText = isSelected + if (label.isNotBlank()) { + addView(this.label) + } + } + + private fun initTrailingIcon( + @DrawableRes trailingIconRes: Int, + @Dimension(DP) trailingIconSize: Int, + @Dimension(DP) trailingSpacing: Int, + hasLabel: Boolean, + ) { + trailingIcon.setImageResource(trailingIconRes) + trailingIcon.layoutParams = LayoutParams(trailingIconSize, trailingIconSize) + trailingSpacer.layoutParams = LayoutParams(trailingSpacing, LayoutParams.WRAP_CONTENT) + if (trailingIconRes != NO_ICON) { + if (hasLabel) addView(trailingSpacer) + addView(trailingIcon) + } + } + + private fun initDrawableBackground( + @ColorInt containerColor: Int, + @ColorInt selectedContainerColor: Int, + @ColorInt strokeColor: Int, + @ColorInt selectedStrokeColor: Int, + @Dimension(DP) cornerRadius: Int = 10.dp, + @Dimension(DP) strokeWidth: Int = 1.dp, + ) { + val unSelectedDrawable = + GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + this.cornerRadius = cornerRadius.toFloat() + setColor(containerColor) + setStroke(strokeWidth, strokeColor) + } + + val selectedDrawable = + GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + this.cornerRadius = cornerRadius.toFloat() + setColor(selectedContainerColor) + setStroke(strokeWidth, selectedStrokeColor) + } + + val states = + arrayOf( + intArrayOf(android.R.attr.state_selected), + intArrayOf(), + ) + val stateListDrawable = + StateListDrawable().apply { + addState(states[0], selectedDrawable) + addState(states[1], unSelectedDrawable) + } + val rippleColor = ColorUtils.setAlphaComponent(selectedContainerColor, 128) + this.background = + RippleDrawable( + ColorStateList.valueOf(rippleColor), + stateListDrawable, + null, + ) + } + + private fun initLabelColor( + @ColorInt labelColor: Int, + @ColorInt selectedLabelColor: Int, + ) { + val textColorList = + ColorStateList( + arrayOf( + intArrayOf(), + intArrayOf(android.R.attr.state_selected), + ), + intArrayOf( + labelColor, + selectedLabelColor, + ), + ) + label.setTextColor(textColorList) + } + + data class Spec( + val id: Int = NO_ID, + val label: String, + @DrawableRes val leadingIconRes: Int? = null, + @DrawableRes val trailingIconRes: Int? = null, + val colors: Colors = Colors(), + val sizes: Sizes = Sizes(), + val padding: PaddingValues = PaddingValues(8.dp), + @Dimension(DP) val strokeWidth: Int = 1.dp, + @Dimension(DP) val cornerRadius: Int = 10.dp, + val isSelected: Boolean = false, + val onSelect: ((chipId: Int) -> Unit)? = null, + ) { + init { + require(leadingIconRes != null || label.isNotBlank()) { + "leadingIconRes 와 label 중 하나는 반드시 있어야 합니다." + } + } + } + + data class Colors( + @ColorRes val labelColor: Int = R.color.poke_chip_text_default, + @ColorRes val strokeColor: Int = R.color.poke_chip_stroke_default, + @ColorRes val containerColor: Int = R.color.poke_chip_background_default, + @ColorRes val selectedLabelColor: Int = R.color.poke_chip_text_selected, + @ColorRes val selectedStrokeColor: Int = R.color.poke_chip_stroke_selected, + @ColorRes val selectedContainerColor: Int = R.color.poke_chip_background_selected, + ) + + data class Sizes( + @Dimension(DP) val leadingIconSize: Int = 24.dp, + @Dimension(DP) val leadingSpacing: Int = 8.dp, + @Dimension(PX) val labelSize: Int = 16, + @Dimension(DP) val trailingSpacing: Int = 4.dp, + @Dimension(DP) val trailingIconSize: Int = 24.dp, + ) { + init { + require(leadingIconSize >= 0) { "leadingIconSize can't be negative" } + require(leadingSpacing >= 0) { "leadingSpacing can't be negative" } + require(trailingIconSize >= 0) { "trailingIconSize can't be negative" } + require(trailingSpacing >= 0) { "trailingSpacing can't be negative" } + require(labelSize >= 0) { "labelSize can't be negative" } + } + } + + companion object { + private const val NO_ICON = 0 + private const val NO_ID = -1 + + @JvmStatic + @BindingAdapter("pokeChipSpec") + fun PokeChip.bindPokeChip(chipSpec: Spec) { + initPokeChip(chipSpec) + } + } + } diff --git a/android/app/src/main/java/poke/rogue/helper/ui/component/PokeChipGroup.kt b/android/app/src/main/java/poke/rogue/helper/ui/component/PokeChipGroup.kt new file mode 100644 index 00000000..1da639ec --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/ui/component/PokeChipGroup.kt @@ -0,0 +1,163 @@ +package poke.rogue.helper.ui.component + +import android.content.Context +import android.graphics.drawable.Drawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.RectShape +import android.util.AttributeSet +import android.widget.LinearLayout +import android.widget.Space +import androidx.annotation.Dimension +import androidx.annotation.Dimension.Companion.DP +import com.google.android.flexbox.AlignItems +import com.google.android.flexbox.FlexDirection +import com.google.android.flexbox.FlexWrap +import com.google.android.flexbox.FlexboxLayout +import com.google.android.flexbox.JustifyContent +import poke.rogue.helper.R +import poke.rogue.helper.presentation.util.context.colorOf +import poke.rogue.helper.presentation.util.view.dp +import poke.rogue.helper.ui.component.PokeChip.Companion.bindPokeChip + +class PokeChipGroup + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + ) : FlexboxLayout(context, attrs, defStyleAttr) { + private val chipViews = mutableListOf() + var direction: PokeChipGroupDirection = PokeChipGroupDirection.ROW + var itemSpacing: Int = 0 + var lineSpacing: Int = 0 + + init { + context.theme.obtainStyledAttributes( + attrs, + R.styleable.PokeChipGroup, + 0, + 0, + ).apply { + try { + direction = + PokeChipGroupDirection.from(getInt(R.styleable.PokeChipGroup_direction, 0)) + itemSpacing = getDimensionPixelSize(R.styleable.PokeChipGroup_itemSpacing, 0.dp) + lineSpacing = getDimensionPixelSize(R.styleable.PokeChipGroup_lineSpacing, 0.dp) + } finally { + recycle() + } + } + + flexWrap = FlexWrap.WRAP + alignItems = AlignItems.CENTER + flexDirection = direction.toFlexDirection() + justifyContent = JustifyContent.FLEX_START + val horizontalDivider = spacingDrawable(lineSpacing) + setDividerDrawable(horizontalDivider) + setShowDivider(SHOW_DIVIDER_MIDDLE) + } + + fun submitList( + specs: List, + onSelect: ((chipId: Int) -> Unit)? = null, + ) { + if (chipViews.isEmpty()) { + addChips(specs, onSelect) + } else { + updateChips(specs) + } + } + + private fun addChips( + specs: List, + onSelect: ((chipId: Int) -> Unit)?, + ) { + removeAllViews() + chipViews.clear() + specs.forEach { spec -> + addChip(spec, chipViews.toList(), onSelect) + } + } + + private fun addChip( + spec: PokeChip.Spec, + originalChipViews: List, + onSelect: ((chipId: Int) -> Unit)?, + ) { + require(originalChipViews.any { it.chipId == spec.id }.not()) { + "id=${spec.id}인 chip이 이미 존재합니다." + } + val chip = PokeChip(context) + onSelect?.let { + chip.setOnClickListener { onSelect(spec.id) } + } + chip.layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ) + chip.bindPokeChip(spec) + addView(chip) + val spacer = Space(context) + spacer.layoutParams = LinearLayout.LayoutParams(itemSpacing, itemSpacing) + addView(spacer) + chipViews.add(chip) + } + + private fun updateChips(specs: List) { + require(chipViews.all { chip -> specs.any { it.id == chip.chipId } }) { + "업데이트할 chip이 존재하지 않습니다." + } + + specs.forEach { spec -> + chipViews.find { it.chipId == spec.id }?.bindPokeChip(spec) + } + } + + private fun spacingDrawable( + @Dimension(unit = DP) height: Int, + ): Drawable { + val drawable = ShapeDrawable(RectShape()) + drawable.intrinsicHeight = height + drawable.paint.color = context.colorOf(android.R.color.transparent) + return drawable + } + + data class PokeChipGroupSpec( + val direction: PokeChipGroupDirection = PokeChipGroupDirection.ROW, + @Dimension(DP) val itemSpacing: Int, + @Dimension(DP) val lineSpacing: Int, + ) { + init { + require(itemSpacing >= 0) { + "chip 사이 간격은 0 이상이어야 합니다." + } + require(lineSpacing >= 0) { + "line 사이 간격은 0 이상이어야 합니다." + } + } + } + + enum class PokeChipGroupDirection { + ROW, + COLUMN, + ; + + fun toFlexDirection(): Int { + return when (this) { + ROW -> FlexDirection.ROW + COLUMN -> FlexDirection.COLUMN + } + } + + companion object { + fun from(value: Int): PokeChipGroupDirection { + return when (value) { + 0 -> ROW + 1 -> COLUMN + else -> error("PokeChipGroupDirection - Unknown value: $value") + } + } + } + } + } diff --git a/android/app/src/main/java/poke/rogue/helper/ui/layout/PaddingValues.kt b/android/app/src/main/java/poke/rogue/helper/ui/layout/PaddingValues.kt new file mode 100644 index 00000000..698a3b5b --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/ui/layout/PaddingValues.kt @@ -0,0 +1,33 @@ +package poke.rogue.helper.ui.layout + +import android.view.View +import androidx.annotation.Dimension +import androidx.annotation.Dimension.Companion.DP +import poke.rogue.helper.presentation.util.view.dp + +data class PaddingValues( + @Dimension(DP) val start: Int = 0.dp, + @Dimension(DP) val top: Int = 0.dp, + @Dimension(DP) val end: Int = 0.dp, + @Dimension(DP) val bottom: Int = 0.dp, +) { + constructor(horizontal: Int, vertical: Int) : this( + horizontal, + vertical, + horizontal, + vertical, + ) + + constructor(all: Int) : this(all, all, all, all) + + init { + require(start >= 0) { "start padding can't be negative" } + require(top >= 0) { "top padding can't be negative" } + require(end >= 0) { "end padding can't be negative" } + require(bottom >= 0) { "bottom padding can't be negative" } + } +} + +fun PaddingValues.applyTo(view: View) { + view.setPadding(start, top, end, bottom) +} diff --git a/android/app/src/main/res/drawable/bg_battle_selection.xml b/android/app/src/main/res/drawable/bg_battle_selection.xml new file mode 100644 index 00000000..7e5f6697 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_battle_selection.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_filter.xml b/android/app/src/main/res/drawable/ic_filter.xml new file mode 100644 index 00000000..6191d258 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_filter.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/layout/activity_pokemon_list.xml b/android/app/src/main/res/layout/activity_pokemon_list.xml index 65f3023d..e6290d94 100644 --- a/android/app/src/main/res/layout/activity_pokemon_list.xml +++ b/android/app/src/main/res/layout/activity_pokemon_list.xml @@ -6,7 +6,7 @@ @@ -33,11 +33,25 @@ android:layout_gravity="end|center_vertical" android:imeOptions="actionSearch|flagNoExtractUi|flagNoFullscreen" android:theme="@style/CustomSearchViewTheme" - app:onQueryTextChange="@{viewModel}" - app:queryHint="@string/pokemon_list_list_search_pokemon_hint" /> + app:onQueryTextChange="@{vm}" + app:queryHint="@string/dex_list_search_pokemon_hint" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values/attrs.xml b/android/app/src/main/res/values/attrs.xml index 7a3a89d5..274f3301 100644 --- a/android/app/src/main/res/values/attrs.xml +++ b/android/app/src/main/res/values/attrs.xml @@ -9,9 +9,39 @@ - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 3212d439..7df2b60e 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -6,11 +6,17 @@ #FDFDFDFD #000000 #242627 + #383939 + #4B4B4B + #717171 #969696 #DCDCDC - + #6C7EE2 + #5981F2 + #182E6A #74CC73 #F66868 + #E1CA13 #BF242627 @@ -46,4 +52,13 @@ #00000000 + + @color/poke_grey_65 + @color/poke_grey_75 + + @color/poke_white + @color/poke_white + + @color/poke_white + @color/poke_grey_80 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 8283e1bf..20605714 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -28,6 +28,19 @@ 바이옴 도감 아이템 도감 + + + 포켓몬 도감 + %03d/%03d + %s #%d + 포켓몬 이름으로 검색하세요. + 잘못된 containerId값(-1) 입니다. + Loading… + 해당하는 포켓몬이 없어요 + 필터 %s + 모든 세대 + %d세대 + 포켓몬 도감 %03d/%03d diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/dex/PokemonListViewModelTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/dex/PokemonListViewModelTest.kt index 997780fe..520cd2bc 100644 --- a/android/app/src/test/java/poke/rogue/helper/presentation/dex/PokemonListViewModelTest.kt +++ b/android/app/src/test/java/poke/rogue/helper/presentation/dex/PokemonListViewModelTest.kt @@ -34,9 +34,9 @@ class PokemonListViewModelTest { // when val pokemons = - viewModel.uiState.first { pokemons -> - pokemons.isNotEmpty() - } + viewModel.uiState.first { uiState -> + uiState.pokemons.isNotEmpty() + }.pokemons // then pokemons shouldBe FakeDexRepository.POKEMONS.map(Pokemon::toUi) @@ -51,9 +51,9 @@ class PokemonListViewModelTest { // when viewModel.queryName("리자") val queriedPokemons = - viewModel.uiState.first { pokemons -> - pokemons.isNotEmpty() - } + viewModel.uiState.first { uiState -> + uiState.pokemons.isNotEmpty() + }.pokemons // then queriedPokemons shouldBe diff --git a/android/data/src/main/java/poke/rogue/helper/data/repository/DefaultDexRepository.kt b/android/data/src/main/java/poke/rogue/helper/data/repository/DefaultDexRepository.kt index 2a0ec71e..aea48be0 100644 --- a/android/data/src/main/java/poke/rogue/helper/data/repository/DefaultDexRepository.kt +++ b/android/data/src/main/java/poke/rogue/helper/data/repository/DefaultDexRepository.kt @@ -25,7 +25,7 @@ class DefaultDexRepository( sort: PokemonSort, filters: List, ): List { - return if (name.isEmpty()) { + return if (name.isBlank()) { pokemons() } else { pokemons().filter { it.name.has(name) } diff --git a/android/testing/src/test/java/poke/rogue/helper/testing/data/repository/FakeAbilityRepositoryTest.kt b/android/testing/src/test/java/poke/rogue/helper/testing/data/repository/FakeAbilityRepositoryTest.kt index dde43930..3faaea83 100644 --- a/android/testing/src/test/java/poke/rogue/helper/testing/data/repository/FakeAbilityRepositoryTest.kt +++ b/android/testing/src/test/java/poke/rogue/helper/testing/data/repository/FakeAbilityRepositoryTest.kt @@ -68,7 +68,7 @@ class FakeAbilityRepositoryTest { runTest { // when, then assertThrows { - repository.abilityDetail(-1) + repository.abilityDetail("-1") } } }