Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,211 +3,37 @@ package com.daedan.festabook.presentation.explore
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.ViewModelProvider
import com.daedan.festabook.R
import com.daedan.festabook.databinding.ActivityExploreBinding
import com.daedan.festabook.di.appGraph
import com.daedan.festabook.logging.logger
import com.daedan.festabook.logging.model.explore.ExploreSearchResultLogData
import com.daedan.festabook.logging.model.explore.ExploreSelectUniversityLogData
import com.daedan.festabook.logging.model.explore.ExploreViewLogData
import com.daedan.festabook.presentation.explore.adapter.OnUniversityClickListener
import com.daedan.festabook.presentation.explore.adapter.SearchResultAdapter
import com.daedan.festabook.presentation.explore.model.SearchResultUiModel
import com.daedan.festabook.presentation.explore.component.ExploreScreen
import com.daedan.festabook.presentation.main.MainActivity
import com.google.android.material.textfield.TextInputLayout
import com.daedan.festabook.presentation.theme.FestabookTheme

class ExploreActivity :
AppCompatActivity(),
OnUniversityClickListener {
class ExploreActivity : AppCompatActivity() {
override val defaultViewModelProviderFactory: ViewModelProvider.Factory
get() = appGraph.metroViewModelFactory
private val binding by lazy { ActivityExploreBinding.inflate(layoutInflater) }
private val viewModel: ExploreViewModel by viewModels()
private val searchResultAdapter by lazy { SearchResultAdapter(this) }

override fun onUniversityClick(university: SearchResultUiModel) {
binding.etSearchText.setText(university.universityName)
binding.etSearchText.setSelection(university.universityName.length)

viewModel.onUniversitySelected(university)
binding.tilSearchInputLayout.endIconMode = TextInputLayout.END_ICON_CUSTOM
binding.tilSearchInputLayout.setEndIconDrawable(R.drawable.ic_arrow_right)

binding.logger.log(
ExploreSelectUniversityLogData(
baseLogData = binding.logger.getBaseLogData(),
universityName = university.universityName,
),
)
}
private val viewModel: ExploreViewModel by viewModels()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
ViewCompat.setOnApplyWindowInsetsListener(binding.rvSearchResults) { view, insets ->
val systemInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())

if (imeInsets.bottom > systemInsets.bottom) {
view.setPadding(
view.paddingLeft,
view.paddingTop,
view.paddingRight,
imeInsets.bottom - systemInsets.bottom,
)
} else {
view.setPadding(
view.paddingLeft,
view.paddingTop,
view.paddingRight,
0,
setContent {
FestabookTheme {
ExploreScreen(
viewModel = viewModel,
onNavigateToMain = {
navigateToMainActivity()
},
onBackClick = { finish() },
)
}
insets
}

setContentView(binding.root)

viewModel.checkFestivalId()

setupBinding()
setupRecyclerView()
setupObservers()

binding.logger.log(
ExploreViewLogData(
baseLogData = binding.logger.getBaseLogData(),
hasFestivalId = viewModel.hasFestivalId.value ?: false,
),
)
}

private fun setupBinding() {
binding.etSearchText.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
binding.tilSearchInputLayout.boxStrokeColor = getColor(R.color.blue400)
} else {
binding.tilSearchInputLayout.boxStrokeColor = getColor(R.color.gray400)
}
}

binding.etSearchText.doOnTextChanged { text, _, _, _ ->
viewModel.onTextInputChanged(text?.toString().orEmpty())
binding.tilSearchInputLayout.endIconMode = TextInputLayout.END_ICON_CUSTOM

if (text.isNullOrEmpty()) {
// 검색 아이콘
binding.tilSearchInputLayout.setEndIconDrawable(R.drawable.ic_search)
binding.tilSearchInputLayout.setEndIconOnClickListener {
handleSearchAction()
}
binding.tilSearchInputLayout.endIconContentDescription = "검색"
} else {
// X 아이콘
binding.tilSearchInputLayout.setEndIconDrawable(R.drawable.ic_close)
binding.tilSearchInputLayout.setEndIconOnClickListener {
binding.etSearchText.text?.clear()
}
binding.tilSearchInputLayout.endIconContentDescription = "입력 내용 지우기"
}
}

// 키보드 엔터(검색) 리스너
binding.etSearchText.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
handleSearchAction()
return@setOnEditorActionListener true
}
return@setOnEditorActionListener false
}

binding.tilSearchInputLayout.setEndIconOnClickListener {
handleSearchAction()
}

binding.btnBackToMain.setOnClickListener {
finish()
}
}

private fun handleSearchAction() {
hideKeyboard()
}

private fun setupRecyclerView() {
binding.rvSearchResults.adapter = searchResultAdapter
}

private fun setupObservers() {
viewModel.searchState.observe(this) { state ->
when (state) {
is SearchUiState.Idle -> {
binding.tilSearchInputLayout.isErrorEnabled = false
binding.tilSearchInputLayout.error = null
binding.tilSearchInputLayout.endIconMode = TextInputLayout.END_ICON_CUSTOM
binding.tilSearchInputLayout.setEndIconDrawable(R.drawable.ic_search)
searchResultAdapter.submitList(emptyList())
}

is SearchUiState.Loading -> Unit
is SearchUiState.Success -> {
// 검색 결과가 없을 때
if (state.universitiesFound.isEmpty()) {
binding.tilSearchInputLayout.isErrorEnabled = true
binding.tilSearchInputLayout.error =
getString(R.string.explore_no_search_result_text)
binding.tilSearchInputLayout.endIconMode = TextInputLayout.END_ICON_NONE
} else {
// 검색 결과가 있을 때
binding.tilSearchInputLayout.isErrorEnabled = false
searchResultAdapter.submitList(state.universitiesFound)
}

binding.logger.log(
ExploreSearchResultLogData(
baseLogData = binding.logger.getBaseLogData(),
query =
binding.etSearchText.text
?.toString()
.orEmpty(),
resultCount = state.universitiesFound.size,
),
)
}

is SearchUiState.Error -> {
binding.tilSearchInputLayout.isErrorEnabled = true
binding.tilSearchInputLayout.error =
getString(R.string.explore_error_text, state.throwable.message)
binding.tilSearchInputLayout.endIconMode = TextInputLayout.END_ICON_NONE
}
}
}

viewModel.navigateToMain.observe(this) { university ->
university?.let {
navigateToMainActivity()
}
}
viewModel.hasFestivalId.observe(this) { hasId ->
binding.layoutExploreToolbar.visibility = if (hasId) View.VISIBLE else View.GONE
binding.ivLogoTitle.visibility = if (hasId) View.GONE else View.VISIBLE
}
}

private fun hideKeyboard() {
val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(binding.etSearchText.windowToken, 0)
}

private fun navigateToMainActivity() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.daedan.festabook.presentation.explore

import com.daedan.festabook.presentation.explore.model.SearchResultUiModel

sealed interface ExploreSideEffect {
data class NavigateToMain(
val searchResult: SearchResultUiModel,
) : ExploreSideEffect
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.daedan.festabook.presentation.explore

data class ExploreUiState(
val query: String = "",
val searchState: SearchUiState = SearchUiState.Idle,
val hasFestivalId: Boolean = false,
)
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
package com.daedan.festabook.presentation.explore

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.daedan.festabook.di.viewmodel.ViewModelKey
import com.daedan.festabook.domain.repository.ExploreRepository
import com.daedan.festabook.presentation.common.SingleLiveData
import com.daedan.festabook.presentation.explore.model.SearchResultUiModel
import com.daedan.festabook.presentation.explore.model.toUiModel
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber

Expand All @@ -27,78 +30,65 @@ import timber.log.Timber
class ExploreViewModel(
private val exploreRepository: ExploreRepository,
) : ViewModel() {
private val searchQuery = MutableStateFlow("")
private val _uiState = MutableStateFlow(ExploreUiState())
val uiState: StateFlow<ExploreUiState> = _uiState.asStateFlow()

private val _searchState = MutableLiveData<SearchUiState>()
val searchState: LiveData<SearchUiState> = _searchState

private val _navigateToMain = SingleLiveData<SearchResultUiModel?>()
val navigateToMain: LiveData<SearchResultUiModel?> = _navigateToMain

private val _hasFestivalId = MutableLiveData<Boolean>(false)
val hasFestivalId: LiveData<Boolean> = _hasFestivalId

private var selectedUniversity: SearchResultUiModel? = null
private val _sideEffect = MutableSharedFlow<ExploreSideEffect>(replay = 0, extraBufferCapacity = 1)
val sideEffect = _sideEffect.asSharedFlow()

init {
checkFestivalId()
observeSearchQuery()
}

private fun checkFestivalId() {
val festivalId = exploreRepository.getFestivalId()
Timber.d("festival ID : $festivalId")
if (festivalId != null) {
_uiState.update { it.copy(hasFestivalId = true) }
}
}
Comment on lines +44 to +50
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find ExploreRepository and getFestivalId implementation
find . -type f -name "*.kt" | xargs grep -l "fun getFestivalId"

Repository: festabook/android

Length of output: 406


🏁 Script executed:

# Look for ExploreRepository file
fd -e "\.kt$" ExploreRepository

Repository: festabook/android

Length of output: 43


🏁 Script executed:

# Check ExploreViewModel.kt to see the current implementation
fd -e "\.kt$" ExploreViewModel

Repository: festabook/android

Length of output: 43


🏁 Script executed:

# Search for SplashViewModel to compare patterns
fd -e "\.kt$" SplashViewModel

Repository: festabook/android

Length of output: 43


🏁 Script executed:

# Find all ViewModel files
find . -type f -name "*ViewModel.kt"

Repository: festabook/android

Length of output: 790


🏁 Script executed:

# Look at ExploreRepositoryImpl to understand getFestivalId
cat ./app/src/main/java/com/daedan/festabook/data/repository/ExploreRepositoryImpl.kt

Repository: festabook/android

Length of output: 1398


🏁 Script executed:

# Check the ExploreRepository interface
cat ./app/src/main/java/com/daedan/festabook/domain/repository/ExploreRepository.kt

Repository: festabook/android

Length of output: 331


🏁 Script executed:

# Search for ExploreViewModel in the codebase
rg "class ExploreViewModel" -A 50

Repository: festabook/android

Length of output: 11420


🏁 Script executed:

# Check FestivalLocalDataSource implementation
cat ./app/src/main/java/com/daedan/festabook/data/datasource/local/FestivalLocalDataSourceImpl.kt

Repository: festabook/android

Length of output: 1360


🏁 Script executed:

# Check the interface
cat ./app/src/main/java/com/daedan/festabook/data/datasource/local/FestivalLocalDataSource.kt

Repository: festabook/android

Length of output: 259


🏁 Script executed:

# Check SplashViewModel to see the claimed pattern
cat ./app/src/main/java/com/daedan/festabook/presentation/splash/SplashViewModel.kt

Repository: festabook/android

Length of output: 1996


checkFestivalId()가 메인 스레드에서 동기적으로 SharedPreferences I/O를 수행합니다.

getFestivalId()SharedPreferences.getLong()을 호출하는 동기 작업입니다. SplashViewModel에서는 동일한 작업을 viewModelScope.launch(iODispatcher)로 감싸서 실행합니다. 메인 스레드 블로킹을 피하려면 이 패턴을 따르세요.

🛡️ 수정 제안
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+
 class ExploreViewModel(
     private val exploreRepository: ExploreRepository,
+    private val iODispatcher: CoroutineDispatcher = Dispatchers.IO,
 ) : ViewModel() {

     private fun checkFestivalId() {
+        viewModelScope.launch(iODispatcher) {
             val festivalId = exploreRepository.getFestivalId()
             Timber.d("festival ID : $festivalId")
             if (festivalId != null) {
                 _uiState.update { it.copy(hasFestivalId = true) }
             }
+        }
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private fun checkFestivalId() {
val festivalId = exploreRepository.getFestivalId()
Timber.d("festival ID : $festivalId")
if (festivalId != null) {
_uiState.update { it.copy(hasFestivalId = true) }
}
}
private fun checkFestivalId() {
viewModelScope.launch {
val festivalId = withContext(iODispatcher) {
exploreRepository.getFestivalId()
}
Timber.d("festival ID : $festivalId")
if (festivalId != null) {
_uiState.update { it.copy(hasFestivalId = true) }
}
}
}
🤖 Prompt for AI Agents
In
`@app/src/main/java/com/daedan/festabook/presentation/explore/ExploreViewModel.kt`
around lines 44 - 50, checkFestivalId() performs synchronous SharedPreferences
I/O by calling exploreRepository.getFestivalId() on the main thread; wrap the
call in viewModelScope.launch(iODispatcher) (same pattern as SplashViewModel) so
getFestivalId() runs on the IO dispatcher and then update _uiState (via
_uiState.update { ... }) from that coroutine, avoiding blocking the main thread.


private fun observeSearchQuery() {
viewModelScope.launch {
searchQuery
.debounce(300L)
_uiState
.map { it.query }
.distinctUntilChanged()
.debounce(300L)
.collectLatest { query ->
// 현재는 검색어가 없을 시, 전체 리스트를 보여주기 위해 아래의 코드를 주석처리해두었음.
// if (query.isEmpty()) {
// _searchState.value = SearchUiState.Idle
// return@collectLatest
// }
if (query.isBlank()) {
_uiState.update { it.copy(searchState = SearchUiState.Idle) }
return@collectLatest
}

_searchState.value = SearchUiState.Loading
_uiState.update { it.copy(searchState = SearchUiState.Loading) }

val result = exploreRepository.search(query)
result
exploreRepository
.search(query)
.onSuccess { universitiesFound ->
Timber.d("검색 성공 - received: $universitiesFound")
_searchState.value =
SearchUiState.Success(universitiesFound = universitiesFound.map { it.toUiModel() })
}.onFailure {
Timber.d(it, "검색 실패")
_searchState.value = SearchUiState.Error(it)
val uiModels = universitiesFound.map { it.toUiModel() }
_uiState.update {
it.copy(searchState = SearchUiState.Success(universitiesFound = uiModels))
}
}.onFailure { throwable ->
Timber.d(throwable, "검색 실패")
_uiState.update {
it.copy(searchState = SearchUiState.Error(throwable))
}
}
}
}
}

fun checkFestivalId() {
val festivalId = exploreRepository.getFestivalId()
Timber.d("festival ID : $festivalId")
if (festivalId != null) {
_hasFestivalId.value = true
}
}

fun onUniversitySelected(university: SearchResultUiModel) {
selectedUniversity = university
_searchState.value =
SearchUiState.Success(
universitiesFound = listOf(university),
// selectedUniversity = university,
)
navigateToMainScreen()
exploreRepository.saveFestivalId(university.festivalId)
viewModelScope.launch {
_sideEffect.tryEmit(ExploreSideEffect.NavigateToMain(university))
}
}

fun onTextInputChanged(query: String) {
searchQuery.value = query
}

private fun navigateToMainScreen() {
val selectedUniversity = selectedUniversity

if (selectedUniversity != null) {
Timber.d("festivalId 로 화면 이동 - ${selectedUniversity.festivalId}")
_navigateToMain.setValue(selectedUniversity)
exploreRepository.saveFestivalId(selectedUniversity.festivalId)
}
_uiState.update { it.copy(query = query) }
}
}

This file was deleted.

Loading
Loading