Skip to content

Commit

Permalink
[0.2.0.alpha/AN_FEAT_UI] PokemonList Filter, PokeChip, PokeChipGroup …
Browse files Browse the repository at this point in the history
…Component 구현 (#242)

* 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 <[email protected]>
  • Loading branch information
murjune and JoYehyun99 authored Aug 19, 2024
1 parent 5707f7c commit ffcbbf3
Show file tree
Hide file tree
Showing 29 changed files with 1,251 additions and 44 deletions.
2 changes: 1 addition & 1 deletion android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,4 @@
android:exported="false" />
</application>

</manifest>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,24 @@ 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
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<ActivityPokemonListBinding>(R.layout.activity_pokemon_list) {
Expand All @@ -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() {
Expand All @@ -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 {
Expand All @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,40 +21,65 @@ 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>(
PokeFilterUiModel(
emptyList(),
PokeGenerationUiModel.ALL,
),
)

@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val uiState: StateFlow<List<PokemonUiModel>> =
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
val uiState: StateFlow<PokemonListUiState> =
merge(refreshEvent.map { "" }, searchQuery)
.onStart {
if (isEmpty.value) {
_isLoading.value = true
}
}
.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<Boolean> = _isLoading.asStateFlow()

val isEmpty: StateFlow<Boolean> =
uiState.map { it.isEmpty() && !isLoading.value }
uiState.map { it.pokemons.isEmpty() && !isLoading.value }
.stateIn(
viewModelScope + errorHandler,
SharingStarted.WhileSubscribed(5000),
Expand All @@ -64,13 +89,19 @@ class PokemonListViewModel(
private val _navigateToDetailEvent = MutableSharedFlow<String>()
val navigateToDetailEvent = _navigateToDetailEvent.asSharedFlow()

private suspend fun queriedPokemons(query: String): List<PokemonUiModel> {
private suspend fun queriedPokemons(
query: String,
types: List<TypeUiModel>,
generation: PokeGenerationUiModel,
): List<PokemonUiModel> {
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()
Expand All @@ -91,10 +122,33 @@ class PokemonListViewModel(
}
}

fun filterPokemon(filter: PokeFilterUiModel) {
viewModelScope.launch {
pokeFilter.value = filter
}
}

companion object {
fun factory(pokemonListRepository: DexRepository): ViewModelProvider.Factory =
BaseViewModelFactory {
PokemonListViewModel(pokemonListRepository)
}
}
}

data class PokemonListUiState(
val pokemons: List<PokemonUiModel> = emptyList(),
val filteredTypes: List<TypeUiModel> = 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActivityPokemonDetailBinding>(R.layout.activity_pokemon_detail) {
class PokemonDetailActivity :
ToolbarActivity<ActivityPokemonDetailBinding>(R.layout.activity_pokemon_detail) {
private val viewModel by viewModels<PokemonDetailViewModel> {
PokemonDetailViewModel.factory(DefaultDexRepository.instance())
}
Expand Down Expand Up @@ -102,7 +103,7 @@ class PokemonDetailActivity : ToolbarActivity<ActivityPokemonDetailBinding>(R.la

private fun observeNavigateToBiomeDetailEvent() {
repeatOnStarted {
viewModel.navigationToDetailEvent.collect { biomeId ->
viewModel.navigationToBiomeDetailEvent.collect { biomeId ->
startActivity(BiomeDetailActivity.intent(this, biomeId))
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class PokemonDetailViewModel(
val navigationToAbilityDetailEvent: SharedFlow<String> = _navigationToAbilityDetailEvent.asSharedFlow()

private val _navigationToBiomeDetailEvent = MutableSharedFlow<String>()
val navigationToDetailEvent: SharedFlow<String> = _navigationToBiomeDetailEvent.asSharedFlow()
val navigationToBiomeDetailEvent: SharedFlow<String> = _navigationToBiomeDetailEvent.asSharedFlow()

private val _navigateToHomeEvent = MutableSharedFlow<Boolean>()
val navigateToHomeEvent = _navigateToHomeEvent.asSharedFlow()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TypeUiModel>,
val generation: PokeGenerationUiModel,
) : PokeFilterUiEvent

data object CloseFilter : PokeFilterUiEvent
}
Original file line number Diff line number Diff line change
@@ -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<TypeUiModel>,
val selectedGeneration: PokeGenerationUiModel,
) : Parcelable
Original file line number Diff line number Diff line change
@@ -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<SelectableUiModel<TypeUiModel>>,
val generations: List<SelectableUiModel<PokeGenerationUiModel>>,
val selectedTypes: List<TypeUiModel> = 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,
)
}
},
)
}
}
Loading

0 comments on commit ffcbbf3

Please sign in to comment.