From 989fed4682e5e99ab40d18c6b78901d3be6e1fc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Tue, 25 Nov 2025 17:29:52 +0900 Subject: [PATCH 001/140] =?UTF-8?q?feat:=20=EC=86=8C=EC=8B=9D=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20StateFlow=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/news/NewsViewModel.kt | 24 +++++++++---------- .../news/notice/NoticeFragment.kt | 19 +++------------ .../news/notice/component/NoticeScreen.kt | 19 +++++++++++++++ 3 files changed, 34 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt index 539da37c..9e16d9f3 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt @@ -25,6 +25,9 @@ import com.daedan.festabook.presentation.news.notice.model.toUiModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @ContributesIntoMap(AppScope::class) @@ -35,12 +38,9 @@ class NewsViewModel( private val faqRepository: FAQRepository, private val lostItemRepository: LostItemRepository, ) : ViewModel() { - var noticeUiState by mutableStateOf(NoticeUiState.InitialLoading) - private set - - val isNoticeScreenRefreshing by derivedStateOf { - noticeUiState is NoticeUiState.Refreshing - } + private val _noticeUiState: MutableStateFlow = + MutableStateFlow(NoticeUiState.InitialLoading) + val noticeUiState: StateFlow = _noticeUiState.asStateFlow() var faqUiState by mutableStateOf(FAQUiState.InitialLoading) private set @@ -62,7 +62,7 @@ class NewsViewModel( fun loadAllNotices(state: NoticeUiState) { viewModelScope.launch { - noticeUiState = state + _noticeUiState.value = state val result = noticeRepository.fetchNotices() result .onSuccess { notices -> @@ -76,11 +76,11 @@ class NewsViewModel( notices.indexOfFirst { it.id == noticeIdToExpand }.let { if (it == -1) DEFAULT_POSITION else it } - noticeUiState = + _noticeUiState.value = NoticeUiState.Success(updatedNotices, expandPosition) noticeIdToExpand = null }.onFailure { - noticeUiState = NoticeUiState.Error(it) + _noticeUiState.value = NoticeUiState.Error(it) } } } @@ -100,7 +100,7 @@ class NewsViewModel( fun expandNotice(noticeId: Long) { this.noticeIdToExpand = noticeId val notices = - when (val currentState = noticeUiState) { + when (val currentState = _noticeUiState.value) { is NoticeUiState.Refreshing -> currentState.oldNotices is NoticeUiState.Success -> currentState.notices else -> return @@ -166,8 +166,8 @@ class NewsViewModel( } private fun updateNoticeUiState(onUpdate: (List) -> List) { - noticeUiState = - when (val currentState = noticeUiState) { + _noticeUiState.value = + when (val currentState = _noticeUiState.value) { is NoticeUiState.Success -> currentState.copy( notices = onUpdate(currentState.notices), diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/NoticeFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/NoticeFragment.kt index ba6e5c40..26c654ac 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/NoticeFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/notice/NoticeFragment.kt @@ -14,14 +14,14 @@ import com.daedan.festabook.di.appGraph import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.main.MainViewModel import com.daedan.festabook.presentation.news.NewsViewModel -import com.daedan.festabook.presentation.news.notice.adapter.NewsClickListener -import com.daedan.festabook.presentation.news.notice.component.NoticeScreen +import com.daedan.festabook.presentation.news.notice.component.NoticeScreenContainer class NoticeFragment : BaseFragment() { override val layoutId: Int = R.layout.fragment_notice override val defaultViewModelProviderFactory: ViewModelProvider.Factory get() = appGraph.metroViewModelFactory + private val newsViewModel: NewsViewModel by viewModels({ requireParentFragment() }) private val mainViewModel: MainViewModel by viewModels({ requireActivity() }) @@ -33,20 +33,7 @@ class NoticeFragment : BaseFragment() { ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - NoticeScreen( - uiState = newsViewModel.noticeUiState, - onNoticeClick = { notice -> - (requireParentFragment() as NewsClickListener) - .onNoticeClick(notice) - }, - isRefreshing = newsViewModel.isNoticeScreenRefreshing, - onRefresh = { - val currentUiState = newsViewModel.noticeUiState - val oldNotices = - if (currentUiState is NoticeUiState.Success) currentUiState.notices else emptyList() - newsViewModel.loadAllNotices(NoticeUiState.Refreshing(oldNotices)) - }, - ) + NoticeScreenContainer(newsViewModel = newsViewModel) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt index 4edb88b7..ffdc28dd 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt @@ -15,11 +15,13 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.EmptyStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen import com.daedan.festabook.presentation.common.component.PULL_OFFSET_LIMIT import com.daedan.festabook.presentation.common.component.PullToRefreshContainer +import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.component.NewsItem import com.daedan.festabook.presentation.news.notice.NoticeUiState import com.daedan.festabook.presentation.news.notice.NoticeUiState.Companion.DEFAULT_POSITION @@ -28,6 +30,23 @@ import timber.log.Timber private const val PADDING: Int = 8 +@Composable +fun NoticeScreenContainer(newsViewModel: NewsViewModel) { + val uiState = newsViewModel.noticeUiState.collectAsStateWithLifecycle() + val isRefreshing = uiState.value is NoticeUiState.Refreshing + + NoticeScreen( + uiState = uiState.value, + onNoticeClick = { notice -> newsViewModel.toggleNoticeExpanded(notice) }, + isRefreshing = isRefreshing, + onRefresh = { + val oldNotices = + (uiState.value as? NoticeUiState.Success)?.notices ?: emptyList() + newsViewModel.loadAllNotices(NoticeUiState.Refreshing(oldNotices)) + }, + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun NoticeScreen( From 1ff88a5b56ea484edc50aad668f9c495dcb2c34d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Tue, 25 Nov 2025 17:40:25 +0900 Subject: [PATCH 002/140] =?UTF-8?q?feat:=20FAQ=ED=99=94=EB=A9=B4=20StateFl?= =?UTF-8?q?ow=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/news/NewsViewModel.kt | 18 +++++++++++------- .../presentation/news/faq/FAQFragment.kt | 7 ++----- .../news/faq/component/FAQScreen.kt | 9 +++++++++ 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt index 9e16d9f3..a3cdd4f1 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt @@ -42,8 +42,12 @@ class NewsViewModel( MutableStateFlow(NoticeUiState.InitialLoading) val noticeUiState: StateFlow = _noticeUiState.asStateFlow() - var faqUiState by mutableStateOf(FAQUiState.InitialLoading) - private set + private val _faqUiState: MutableStateFlow = + MutableStateFlow(FAQUiState.InitialLoading) + val faqUiState: StateFlow = _faqUiState.asStateFlow() + +// var faqUiState by mutableStateOf(FAQUiState.InitialLoading) +// private set var lostUiState by mutableStateOf(LostUiState.InitialLoading) private set @@ -152,15 +156,15 @@ class NewsViewModel( private fun loadAllFAQs(state: FAQUiState = FAQUiState.InitialLoading) { viewModelScope.launch { - faqUiState = state + _faqUiState.value = state val result = faqRepository.getAllFAQ() result .onSuccess { faqItems -> - faqUiState = FAQUiState.Success(faqItems.map { it.toUiModel() }) + _faqUiState.value = FAQUiState.Success(faqItems.map { it.toUiModel() }) }.onFailure { - faqUiState = FAQUiState.Error(it) + _faqUiState.value = FAQUiState.Error(it) } } } @@ -178,8 +182,8 @@ class NewsViewModel( } private fun updateFAQUiState(onUpdate: (List) -> List) { - val currentState = faqUiState - faqUiState = + val currentState = _faqUiState.value + _faqUiState.value = when (currentState) { is FAQUiState.Success -> currentState.copy(faqs = onUpdate(currentState.faqs)) else -> currentState diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/faq/FAQFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/news/faq/FAQFragment.kt index 353e48fb..2a4ed93f 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/faq/FAQFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/faq/FAQFragment.kt @@ -13,8 +13,7 @@ import com.daedan.festabook.databinding.FragmentFaqBinding import com.daedan.festabook.di.appGraph import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.news.NewsViewModel -import com.daedan.festabook.presentation.news.faq.component.FAQScreen -import com.daedan.festabook.presentation.news.notice.adapter.NewsClickListener +import com.daedan.festabook.presentation.news.faq.component.FAQScreenContainer class FAQFragment : BaseFragment() { override val layoutId: Int = R.layout.fragment_faq @@ -31,9 +30,7 @@ class FAQFragment : BaseFragment() { ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - FAQScreen(uiState = viewModel.faqUiState, onFaqClick = { faqItemUiModel -> - (requireParentFragment() as NewsClickListener).onFAQClick(faqItemUiModel) - }) + FAQScreenContainer(newsViewModel = viewModel) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt index 89482d29..395a1eaa 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt @@ -10,8 +10,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.EmptyStateScreen +import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.component.NewsItem import com.daedan.festabook.presentation.news.faq.FAQUiState import com.daedan.festabook.presentation.news.faq.model.FAQItemUiModel @@ -19,6 +21,13 @@ import timber.log.Timber private const val PADDING: Int = 8 +@Composable +fun FAQScreenContainer(newsViewModel: NewsViewModel) { + val uiState = newsViewModel.faqUiState.collectAsStateWithLifecycle() + + FAQScreen(uiState = uiState.value, onFaqClick = { faq -> newsViewModel.toggleFAQExpanded(faq) }) +} + @Composable fun FAQScreen( uiState: FAQUiState, From 8a5b801eff11f73a4a4f05b909b9346bd8ce16bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Tue, 25 Nov 2025 18:05:28 +0900 Subject: [PATCH 003/140] =?UTF-8?q?feat:=20=EB=B6=84=EC=8B=A4=EB=AC=BC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20StateFlow=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/news/NewsViewModel.kt | 24 ++++++------------- .../news/lost/LostItemFragment.kt | 16 ++----------- .../news/lost/component/LostItemScreen.kt | 18 ++++++++++++++ 3 files changed, 27 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt index a3cdd4f1..c995cb97 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt @@ -1,9 +1,5 @@ package com.daedan.festabook.presentation.news -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey @@ -46,15 +42,9 @@ class NewsViewModel( MutableStateFlow(FAQUiState.InitialLoading) val faqUiState: StateFlow = _faqUiState.asStateFlow() -// var faqUiState by mutableStateOf(FAQUiState.InitialLoading) -// private set - - var lostUiState by mutableStateOf(LostUiState.InitialLoading) - private set - - val isLostItemScreenRefreshing by derivedStateOf { - lostUiState is LostUiState.Refreshing - } + private val _lostUiState: MutableStateFlow = + MutableStateFlow(LostUiState.InitialLoading) + val lostUiState: StateFlow = _lostUiState.asStateFlow() private var noticeIdToExpand: Long? = null @@ -139,7 +129,7 @@ class NewsViewModel( fun loadAllLostItems(state: LostUiState) { viewModelScope.launch { - lostUiState = state + _lostUiState.value = state val result = lostItemRepository.getLost() val lostUiModels = @@ -150,7 +140,7 @@ class NewsViewModel( null -> LostUiModel.Guide() } } - lostUiState = LostUiState.Success(lostUiModels) + _lostUiState.value = LostUiState.Success(lostUiModels) } } @@ -191,8 +181,8 @@ class NewsViewModel( } private fun updateLostUiState(onUpdate: (List) -> List) { - val currentState = lostUiState - lostUiState = + val currentState = _lostUiState.value + _lostUiState.value = when (currentState) { is LostUiState.Success -> currentState.copy(lostItems = onUpdate(currentState.lostItems)) else -> currentState diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/LostItemFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/LostItemFragment.kt index 305c10d4..37861c3c 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/LostItemFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/lost/LostItemFragment.kt @@ -13,8 +13,7 @@ import com.daedan.festabook.databinding.FragmentLostItemBinding import com.daedan.festabook.di.appGraph import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.news.NewsViewModel -import com.daedan.festabook.presentation.news.lost.component.LostItemScreen -import com.daedan.festabook.presentation.news.notice.adapter.NewsClickListener +import com.daedan.festabook.presentation.news.lost.component.LostItemScreenContainer class LostItemFragment : BaseFragment() { override val layoutId: Int = R.layout.fragment_lost_item @@ -32,18 +31,7 @@ class LostItemFragment : BaseFragment() { ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - val newsClickListener = requireParentFragment() as NewsClickListener - LostItemScreen( - lostUiState = viewModel.lostUiState, - onLostGuideClick = { newsClickListener.onLostGuideItemClick() }, - isRefreshing = viewModel.isLostItemScreenRefreshing, - onRefresh = { - val currentUiState = viewModel.lostUiState - val oldLostItems = - if (currentUiState is LostUiState.Success) currentUiState.lostItems else emptyList() - viewModel.loadAllLostItems(LostUiState.Refreshing(oldLostItems)) - }, - ) + LostItemScreenContainer(newsViewModel = viewModel) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt index f140d804..5f09e672 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt @@ -21,11 +21,13 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.EmptyStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen import com.daedan.festabook.presentation.common.component.PULL_OFFSET_LIMIT import com.daedan.festabook.presentation.common.component.PullToRefreshContainer +import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.component.NewsItem import com.daedan.festabook.presentation.news.lost.LostUiState import com.daedan.festabook.presentation.news.lost.model.LostItemUiStatus @@ -35,6 +37,22 @@ import timber.log.Timber private const val SPAN_COUNT: Int = 2 private const val PADDING: Int = 8 +@Composable +fun LostItemScreenContainer(newsViewModel: NewsViewModel) { + val uiState = newsViewModel.lostUiState.collectAsStateWithLifecycle() + val isRefreshing = uiState.value is LostUiState.Refreshing + + LostItemScreen( + lostUiState = uiState.value, + onLostGuideClick = { newsViewModel.toggleLostGuideExpanded() }, + isRefreshing = isRefreshing, + onRefresh = { + val oldLostItems = (uiState.value as? LostUiState.Success)?.lostItems ?: emptyList() + newsViewModel.loadAllLostItems(LostUiState.Refreshing(oldLostItems)) + }, + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun LostItemScreen( From 94124c6dd5301f44f5109aca8786f29476e78bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Tue, 25 Nov 2025 19:17:02 +0900 Subject: [PATCH 004/140] =?UTF-8?q?refactor:=20=ED=95=A8=EC=88=98=EB=AA=85?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/daedan/festabook/presentation/news/NewsFragment.kt | 7 +++---- .../daedan/festabook/presentation/news/NewsViewModel.kt | 6 +++--- .../festabook/presentation/news/faq/component/FAQScreen.kt | 2 +- .../presentation/news/lost/component/LostItemScreen.kt | 2 +- .../presentation/news/notice/component/NoticeScreen.kt | 2 +- .../java/com/daedan/festabook/news/NewsViewModelTest.kt | 6 +++--- 6 files changed, 12 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt index 52866602..32a6be1c 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt @@ -12,7 +12,6 @@ import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.main.MainViewModel import com.daedan.festabook.presentation.news.adapter.NewsPagerAdapter import com.daedan.festabook.presentation.news.faq.model.FAQItemUiModel -import com.daedan.festabook.presentation.news.lost.model.LostUiModel import com.daedan.festabook.presentation.news.notice.adapter.NewsClickListener import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel import com.google.android.material.tabs.TabLayoutMediator @@ -54,15 +53,15 @@ class NewsFragment : } override fun onNoticeClick(notice: NoticeUiModel) { - newsViewModel.toggleNoticeExpanded(notice) + newsViewModel.toggleNotice(notice) } override fun onFAQClick(faqItem: FAQItemUiModel) { - newsViewModel.toggleFAQExpanded(faqItem) + newsViewModel.toggleFAQ(faqItem) } override fun onLostGuideItemClick() { - newsViewModel.toggleLostGuideExpanded() + newsViewModel.toggleLostGuide() } private fun setupNewsTabLayout() { diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt index c995cb97..17b7e97f 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt @@ -79,7 +79,7 @@ class NewsViewModel( } } - fun toggleNoticeExpanded(notice: NoticeUiModel) { + fun toggleNotice(notice: NoticeUiModel) { updateNoticeUiState { notices -> notices.map { updatedNotice -> if (notice.id == updatedNotice.id) { @@ -103,7 +103,7 @@ class NewsViewModel( loadAllNotices(NoticeUiState.Refreshing(notices)) } - fun toggleFAQExpanded(faqItem: FAQItemUiModel) { + fun toggleFAQ(faqItem: FAQItemUiModel) { updateFAQUiState { faqItems -> faqItems.map { updatedFAQItem -> if (faqItem.questionId == updatedFAQItem.questionId) { @@ -115,7 +115,7 @@ class NewsViewModel( } } - fun toggleLostGuideExpanded() { + fun toggleLostGuide() { updateLostUiState { lostUiModels -> lostUiModels.map { lostUiModel -> if (lostUiModel is LostUiModel.Guide) { diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt index 395a1eaa..4b4a1cd6 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt @@ -25,7 +25,7 @@ private const val PADDING: Int = 8 fun FAQScreenContainer(newsViewModel: NewsViewModel) { val uiState = newsViewModel.faqUiState.collectAsStateWithLifecycle() - FAQScreen(uiState = uiState.value, onFaqClick = { faq -> newsViewModel.toggleFAQExpanded(faq) }) + FAQScreen(uiState = uiState.value, onFaqClick = { faq -> newsViewModel.toggleFAQ(faq) }) } @Composable diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt index 5f09e672..34777c55 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt @@ -44,7 +44,7 @@ fun LostItemScreenContainer(newsViewModel: NewsViewModel) { LostItemScreen( lostUiState = uiState.value, - onLostGuideClick = { newsViewModel.toggleLostGuideExpanded() }, + onLostGuideClick = { newsViewModel.toggleLostGuide() }, isRefreshing = isRefreshing, onRefresh = { val oldLostItems = (uiState.value as? LostUiState.Success)?.lostItems ?: emptyList() diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt index ffdc28dd..77f924a9 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt @@ -37,7 +37,7 @@ fun NoticeScreenContainer(newsViewModel: NewsViewModel) { NoticeScreen( uiState = uiState.value, - onNoticeClick = { notice -> newsViewModel.toggleNoticeExpanded(notice) }, + onNoticeClick = { notice -> newsViewModel.toggleNotice(notice) }, isRefreshing = isRefreshing, onRefresh = { val oldNotices = diff --git a/app/src/test/java/com/daedan/festabook/news/NewsViewModelTest.kt b/app/src/test/java/com/daedan/festabook/news/NewsViewModelTest.kt index af35b531..ff45996d 100644 --- a/app/src/test/java/com/daedan/festabook/news/NewsViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/news/NewsViewModelTest.kt @@ -168,7 +168,7 @@ class NewsViewModelTest { val notice = FAKE_NOTICES.first().toUiModel() // when - newsViewModel.toggleNoticeExpanded(notice) + newsViewModel.toggleNotice(notice) advanceUntilIdle() // then @@ -194,7 +194,7 @@ class NewsViewModelTest { val faq = FAKE_FAQS.first().toUiModel() // when - newsViewModel.toggleFAQExpanded(faq) + newsViewModel.toggleFAQ(faq) advanceUntilIdle() // then @@ -253,7 +253,7 @@ class NewsViewModelTest { ) // when - newsViewModel.toggleLostGuideExpanded() + newsViewModel.toggleLostGuide() // then val actual = newsViewModel.lostUiState.getOrAwaitValue() From 06f9348e0632beb268c26c7a4f246781444eebba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Tue, 25 Nov 2025 22:29:19 +0900 Subject: [PATCH 005/140] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=ED=8D=BC?= =?UTF-8?q?=ED=8B=B0=20=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/daedan/festabook/di/FestaBookAppGraph.kt | 3 +-- .../com/daedan/festabook/presentation/news/NewsFragment.kt | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt b/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt index 80fc6739..cd93ed60 100644 --- a/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt +++ b/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt @@ -38,8 +38,7 @@ interface FestaBookAppGraph { // splashActivity @Provides - fun provideAppUpdateManager(application: Application): AppUpdateManager = - AppUpdateManagerFactory.create(application) + fun provideAppUpdateManager(application: Application): AppUpdateManager = AppUpdateManagerFactory.create(application) // logger val defaultFirebaseLogger: DefaultFirebaseLogger diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt index 32a6be1c..c803edf6 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt @@ -29,11 +29,11 @@ import dev.zacsweers.metro.binding class NewsFragment : BaseFragment(), NewsClickListener { + override val layoutId: Int = R.layout.fragment_news + @Inject override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory - override val layoutId: Int = R.layout.fragment_news - private val newsPagerAdapter by lazy { NewsPagerAdapter(this) } From d70bf78776ce3e6b957b2beca202c9be4ee372aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 26 Nov 2025 16:46:52 +0900 Subject: [PATCH 006/140] =?UTF-8?q?feat:=20Festabook=20Compose=20Shape=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/theme/FestabookShapes.kt | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/theme/FestabookShapes.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookShapes.kt b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookShapes.kt new file mode 100644 index 00000000..819b6ed0 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookShapes.kt @@ -0,0 +1,26 @@ +package com.daedan.festabook.presentation.theme + +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +data class FestabookShapes( + val radius1: CornerBasedShape = RoundedCornerShape(6.dp), + val radius2: CornerBasedShape = RoundedCornerShape(10.dp), + val radius3: CornerBasedShape = RoundedCornerShape(16.dp), + val radius4: CornerBasedShape = RoundedCornerShape(20.dp), + val radius5: CornerBasedShape = RoundedCornerShape(24.dp), + val radiusFull: CornerBasedShape = RoundedCornerShape(999.dp), +) + +val festabookShapes = FestabookShapes() + +val FestabookShapesTheme = + Shapes( + extraSmall = festabookShapes.radius1, + small = festabookShapes.radius2, + medium = festabookShapes.radius3, + large = festabookShapes.radius4, + extraLarge = festabookShapes.radius5, + ) From c2664cc487ea8d1fafedf0ee7c9a054ad322e48e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 26 Nov 2025 20:05:21 +0900 Subject: [PATCH 007/140] =?UTF-8?q?refactor:=20newsClickListener=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../festabook/presentation/news/faq/adapter/FAQViewHolder.kt | 1 - .../presentation/news/lost/adapter/LostGuideItemViewHolder.kt | 1 - .../presentation/news/notice/adapter/NoticeViewHolder.kt | 1 - 3 files changed, 3 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/faq/adapter/FAQViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/news/faq/adapter/FAQViewHolder.kt index 2114badf..7bdefd51 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/faq/adapter/FAQViewHolder.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/faq/adapter/FAQViewHolder.kt @@ -21,7 +21,6 @@ class FAQViewHolder( init { binding.root.setOnClickListener { faqItem?.let { - newsClickListener.onFAQClick(it) } ?: run { Timber.w("${this::class.java.simpleName} : FAQ 아이템이 null입니다.") } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/adapter/LostGuideItemViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/adapter/LostGuideItemViewHolder.kt index 741eeaa9..d96e756b 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/adapter/LostGuideItemViewHolder.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/lost/adapter/LostGuideItemViewHolder.kt @@ -19,7 +19,6 @@ class LostGuideItemViewHolder private constructor( init { binding.root.setOnClickListener { lostGuideItem?.let { - newsClickListener.onLostGuideItemClick() } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/adapter/NoticeViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/adapter/NoticeViewHolder.kt index 472fa804..cb31dd22 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/adapter/NoticeViewHolder.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/notice/adapter/NoticeViewHolder.kt @@ -21,7 +21,6 @@ class NoticeViewHolder( init { binding.layoutNoticeItem.setOnClickListener { noticeItem?.let { - newsClickListener.onNoticeClick(it) } ?: run { Timber.w("${this::class.java.simpleName} 공지 아이템이 null입니다.") } From 94c1993e500d7cba3f09fdcbda9f0ce9e821e840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 26 Nov 2025 20:22:19 +0900 Subject: [PATCH 008/140] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/common/component/Header.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/common/component/Header.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/Header.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/Header.kt new file mode 100644 index 00000000..2dfc1f7f --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/Header.kt @@ -0,0 +1,22 @@ +package com.daedan.festabook.presentation.common.component + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.theme.FestabookTypography + +@Composable +fun Header( + title: String, + modifier: Modifier = Modifier, + style: TextStyle = FestabookTypography.displayLarge, +) { + Text( + text = title, + style = style, + modifier = modifier.padding(top = 40.dp, bottom = 16.dp, start = 16.dp, end = 16.dp), + ) +} From faa8ed56ff533ef00f0996be9f2aee09e87a4127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 26 Nov 2025 21:23:17 +0900 Subject: [PATCH 009/140] =?UTF-8?q?feat:=20=EC=86=8C=EC=8B=9D=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20viewPager,=20tabLayout=20Compose=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/news/NewsFragment.kt | 80 ++++++------- .../presentation/news/component/NewsScreen.kt | 107 ++++++++++++++++++ .../news/faq/component/FAQScreen.kt | 11 +- .../news/lost/component/LostItemScreen.kt | 7 +- .../news/notice/component/NoticeScreen.kt | 6 +- .../presentation/theme/FestabookTheme.kt | 20 ++++ 6 files changed, 187 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt index c803edf6..cb0a9a4a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt @@ -1,7 +1,11 @@ package com.daedan.festabook.presentation.news import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModelProvider @@ -11,10 +15,8 @@ import com.daedan.festabook.di.fragment.FragmentKey import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.main.MainViewModel import com.daedan.festabook.presentation.news.adapter.NewsPagerAdapter -import com.daedan.festabook.presentation.news.faq.model.FAQItemUiModel -import com.daedan.festabook.presentation.news.notice.adapter.NewsClickListener -import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel -import com.google.android.material.tabs.TabLayoutMediator +import com.daedan.festabook.presentation.news.component.NewsScreen +import com.daedan.festabook.presentation.theme.FestabookTheme import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject @@ -26,9 +28,7 @@ import dev.zacsweers.metro.binding ) @FragmentKey(NewsFragment::class) @Inject -class NewsFragment : - BaseFragment(), - NewsClickListener { +class NewsFragment : BaseFragment() { override val layoutId: Int = R.layout.fragment_news @Inject @@ -40,39 +40,41 @@ class NewsFragment : private val newsViewModel: NewsViewModel by viewModels() private val mainViewModel: MainViewModel by viewModels({ requireActivity() }) - override fun onViewCreated( - view: View, + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - binding.lifecycleOwner = viewLifecycleOwner - setupNewsTabLayout() - mainViewModel.noticeIdToExpand.observe(viewLifecycleOwner) { - binding.vpNews.currentItem = NOTICE_TAB_INDEX + ): View = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + FestabookTheme { + NewsScreen(mainViewModel = mainViewModel, newsViewModel = newsViewModel) + } + } } - } - - override fun onNoticeClick(notice: NoticeUiModel) { - newsViewModel.toggleNotice(notice) - } - - override fun onFAQClick(faqItem: FAQItemUiModel) { - newsViewModel.toggleFAQ(faqItem) - } - override fun onLostGuideItemClick() { - newsViewModel.toggleLostGuide() - } - - private fun setupNewsTabLayout() { - binding.vpNews.adapter = newsPagerAdapter - TabLayoutMediator(binding.tlNews, binding.vpNews) { tab, position -> - val tabNameRes = NewsTab.entries[position].tabNameRes - tab.text = getString(tabNameRes) - }.attach() - } - - companion object { - private const val NOTICE_TAB_INDEX: Int = 0 - } +// override fun onViewCreated( +// view: View, +// savedInstanceState: Bundle?, +// ) { +// super.onViewCreated(view, savedInstanceState) +// binding.lifecycleOwner = viewLifecycleOwner +// setupNewsTabLayout() +// mainViewModel.noticeIdToExpand.observe(viewLifecycleOwner) { +// binding.vpNews.currentItem = NOTICE_TAB_INDEX +// } +// } +// +// private fun setupNewsTabLayout() { +// binding.vpNews.adapter = newsPagerAdapter +// TabLayoutMediator(binding.tlNews, binding.vpNews) { tab, position -> +// val tabNameRes = NewsTab.entries[position].tabNameRes +// tab.text = getString(tabNameRes) +// }.attach() +// } +// +// companion object { +// private const val NOTICE_TAB_INDEX: Int = 0 +// } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt new file mode 100644 index 00000000..1d1dc353 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt @@ -0,0 +1,107 @@ +package com.daedan.festabook.presentation.news.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.TabRowDefaults +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.component.Header +import com.daedan.festabook.presentation.main.MainViewModel +import com.daedan.festabook.presentation.news.NewsTab +import com.daedan.festabook.presentation.news.NewsViewModel +import com.daedan.festabook.presentation.news.faq.component.FAQScreenContainer +import com.daedan.festabook.presentation.news.lost.component.LostItemScreenContainer +import com.daedan.festabook.presentation.news.notice.component.NoticeScreenContainer +import com.daedan.festabook.presentation.theme.FestabookColor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun NewsScreen( + newsViewModel: NewsViewModel, + mainViewModel: MainViewModel, + modifier: Modifier = Modifier, +) { + val pageState = rememberPagerState { NewsTab.entries.size } + val scope = rememberCoroutineScope() + + Column(modifier = modifier.background(color = MaterialTheme.colorScheme.background)) { + Header(title = stringResource(R.string.news_title)) + NewsTabRow(pageState, scope) + NewsTabPage(pageState, newsViewModel) + } +} + +@Composable +private fun NewsTabRow( + pageState: PagerState, + scope: CoroutineScope, +) { + TabRow( + selectedTabIndex = pageState.currentPage, + containerColor = MaterialTheme.colorScheme.background, + contentColor = FestabookColor.black, + indicator = { tabPositions -> + TabRowDefaults.PrimaryIndicator( + color = FestabookColor.black, + width = tabPositions[pageState.currentPage].width, + modifier = Modifier.tabIndicatorOffset(currentTabPosition = tabPositions[pageState.currentPage]), + ) + }, + ) { + NewsTab.entries.forEachIndexed { index, title -> + Tab( + selected = pageState.currentPage == index, + unselectedContentColor = FestabookColor.gray500, + onClick = { scope.launch { pageState.animateScrollToPage(index) } }, + text = { Text(text = stringResource(title.tabNameRes)) }, + ) + } + } +} + +@Composable +private fun NewsTabPage( + pageState: PagerState, + newsViewModel: NewsViewModel, +) { + HorizontalPager( + state = pageState, + verticalAlignment = Alignment.Top, + ) { index -> + val tab = NewsTab.entries[index] + when (tab) { + NewsTab.NOTICE -> + NoticeScreenContainer( + newsViewModel = newsViewModel, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + NewsTab.FAQ -> + FAQScreenContainer( + newsViewModel = newsViewModel, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + NewsTab.LOST_ITEM -> + LostItemScreenContainer( + newsViewModel = newsViewModel, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt index 4b4a1cd6..f0d40605 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt @@ -22,10 +22,17 @@ import timber.log.Timber private const val PADDING: Int = 8 @Composable -fun FAQScreenContainer(newsViewModel: NewsViewModel) { +fun FAQScreenContainer( + newsViewModel: NewsViewModel, + modifier: Modifier = Modifier, +) { val uiState = newsViewModel.faqUiState.collectAsStateWithLifecycle() - FAQScreen(uiState = uiState.value, onFaqClick = { faq -> newsViewModel.toggleFAQ(faq) }) + FAQScreen( + uiState = uiState.value, + onFaqClick = { faq -> newsViewModel.toggleFAQ(faq) }, + modifier = modifier, + ) } @Composable diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt index 34777c55..9a84ffa6 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt @@ -38,7 +38,10 @@ private const val SPAN_COUNT: Int = 2 private const val PADDING: Int = 8 @Composable -fun LostItemScreenContainer(newsViewModel: NewsViewModel) { +fun LostItemScreenContainer( + newsViewModel: NewsViewModel, + modifier: Modifier = Modifier, +) { val uiState = newsViewModel.lostUiState.collectAsStateWithLifecycle() val isRefreshing = uiState.value is LostUiState.Refreshing @@ -50,6 +53,7 @@ fun LostItemScreenContainer(newsViewModel: NewsViewModel) { val oldLostItems = (uiState.value as? LostUiState.Success)?.lostItems ?: emptyList() newsViewModel.loadAllLostItems(LostUiState.Refreshing(oldLostItems)) }, + modifier = modifier, ) } @@ -74,7 +78,6 @@ fun LostItemScreen( PullToRefreshContainer( isRefreshing = isRefreshing, onRefresh = onRefresh, - modifier = modifier, ) { pullToRefreshState -> when (lostUiState) { LostUiState.InitialLoading -> LoadingStateScreen() diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt index 77f924a9..98fcd9c5 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt @@ -31,7 +31,10 @@ import timber.log.Timber private const val PADDING: Int = 8 @Composable -fun NoticeScreenContainer(newsViewModel: NewsViewModel) { +fun NoticeScreenContainer( + newsViewModel: NewsViewModel, + modifier: Modifier = Modifier, +) { val uiState = newsViewModel.noticeUiState.collectAsStateWithLifecycle() val isRefreshing = uiState.value is NoticeUiState.Refreshing @@ -44,6 +47,7 @@ fun NoticeScreenContainer(newsViewModel: NewsViewModel) { (uiState.value as? NoticeUiState.Success)?.notices ?: emptyList() newsViewModel.loadAllNotices(NoticeUiState.Refreshing(oldNotices)) }, + modifier = modifier, ) } diff --git a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt new file mode 100644 index 00000000..9d1b0e7e --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt @@ -0,0 +1,20 @@ +package com.daedan.festabook.presentation.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +private val LightColorScheme = + lightColorScheme( + background = FestabookColor.white, + ) + +@Composable +fun FestabookTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = LightColorScheme, + shapes = FestabookShapesTheme, + typography = FestabookTypography, + content = content, + ) +} From 86cc75b177ed80db9cc8eb4eae1d6c60036a5fc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 26 Nov 2025 21:28:13 +0900 Subject: [PATCH 010/140] =?UTF-8?q?feat:=20Header=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EC=A0=80=EB=B8=94=20=ED=94=84=EB=A6=AC=EB=B7=B0=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/common/component/Header.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/Header.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/Header.kt index 2dfc1f7f..d322d849 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/component/Header.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/Header.kt @@ -1,10 +1,12 @@ package com.daedan.festabook.presentation.common.component +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.daedan.festabook.presentation.theme.FestabookTypography @@ -17,6 +19,15 @@ fun Header( Text( text = title, style = style, - modifier = modifier.padding(top = 40.dp, bottom = 16.dp, start = 16.dp, end = 16.dp), + modifier = + modifier + .padding(top = 40.dp, bottom = 16.dp, start = 16.dp, end = 16.dp) + .fillMaxWidth(), ) } + +@Composable +@Preview(showBackground = true) +private fun HeaderPreview() { + Header(title = "FestaBook") +} From c452e4190d7e318b22241d8299db82eb9a6781d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 26 Nov 2025 22:11:35 +0900 Subject: [PATCH 011/140] =?UTF-8?q?refactor:=20=EC=9C=84=EC=9E=84=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/news/faq/component/FAQScreen.kt | 5 +++-- .../presentation/news/lost/component/LostItemScreen.kt | 8 ++++---- .../presentation/news/notice/component/NoticeScreen.kt | 9 +++++---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt index f0d40605..d458b02d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -26,10 +27,10 @@ fun FAQScreenContainer( newsViewModel: NewsViewModel, modifier: Modifier = Modifier, ) { - val uiState = newsViewModel.faqUiState.collectAsStateWithLifecycle() + val uiState by newsViewModel.faqUiState.collectAsStateWithLifecycle() FAQScreen( - uiState = uiState.value, + uiState = uiState, onFaqClick = { faq -> newsViewModel.toggleFAQ(faq) }, modifier = modifier, ) diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt index 9a84ffa6..d50fafe5 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt @@ -42,15 +42,15 @@ fun LostItemScreenContainer( newsViewModel: NewsViewModel, modifier: Modifier = Modifier, ) { - val uiState = newsViewModel.lostUiState.collectAsStateWithLifecycle() - val isRefreshing = uiState.value is LostUiState.Refreshing + val uiState by newsViewModel.lostUiState.collectAsStateWithLifecycle() + val isRefreshing = uiState is LostUiState.Refreshing LostItemScreen( - lostUiState = uiState.value, + lostUiState = uiState, onLostGuideClick = { newsViewModel.toggleLostGuide() }, isRefreshing = isRefreshing, onRefresh = { - val oldLostItems = (uiState.value as? LostUiState.Success)?.lostItems ?: emptyList() + val oldLostItems = (uiState as? LostUiState.Success)?.lostItems ?: emptyList() newsViewModel.loadAllLostItems(LostUiState.Refreshing(oldLostItems)) }, modifier = modifier, diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt index 98fcd9c5..fc459b23 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource @@ -35,16 +36,16 @@ fun NoticeScreenContainer( newsViewModel: NewsViewModel, modifier: Modifier = Modifier, ) { - val uiState = newsViewModel.noticeUiState.collectAsStateWithLifecycle() - val isRefreshing = uiState.value is NoticeUiState.Refreshing + val uiState by newsViewModel.noticeUiState.collectAsStateWithLifecycle() + val isRefreshing = uiState is NoticeUiState.Refreshing NoticeScreen( - uiState = uiState.value, + uiState = uiState, onNoticeClick = { notice -> newsViewModel.toggleNotice(notice) }, isRefreshing = isRefreshing, onRefresh = { val oldNotices = - (uiState.value as? NoticeUiState.Success)?.notices ?: emptyList() + (uiState as? NoticeUiState.Success)?.notices ?: emptyList() newsViewModel.loadAllNotices(NoticeUiState.Refreshing(oldNotices)) }, modifier = modifier, From 0803163c867e803540170b49b4353e45083444bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 26 Nov 2025 22:31:27 +0900 Subject: [PATCH 012/140] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=EC=8B=9C=20=ED=99=94=EB=A9=B4=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../festabook/presentation/main/MainActivity.kt | 16 ++++++++-------- .../festabook/presentation/main/MainViewModel.kt | 12 +----------- .../festabook/presentation/news/NewsViewModel.kt | 4 ++-- .../presentation/news/component/NewsScreen.kt | 14 ++++++++++++-- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt b/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt index 8df33ef2..4a01b038 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt @@ -33,6 +33,7 @@ import com.daedan.festabook.presentation.common.showToast import com.daedan.festabook.presentation.home.HomeFragment import com.daedan.festabook.presentation.home.HomeViewModel import com.daedan.festabook.presentation.news.NewsFragment +import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.placeMap.PlaceMapFragment import com.daedan.festabook.presentation.schedule.ScheduleFragment import com.daedan.festabook.presentation.setting.SettingFragment @@ -44,7 +45,6 @@ import timber.log.Timber class MainActivity : AppCompatActivity(), NotificationPermissionRequester { - @Inject override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory @@ -59,6 +59,7 @@ class MainActivity : private val mainViewModel: MainViewModel by viewModels() private val homeViewModel: HomeViewModel by viewModels() + private val newsViewModel: NewsViewModel by viewModels() private val settingViewModel: SettingViewModel by viewModels() private val notificationPermissionManager by lazy { @@ -116,25 +117,24 @@ class MainActivity : ) { grantResults.forEachIndexed { index, result -> val text = permissions[index] - when(text) { + when (text) { Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION -> { + Manifest.permission.ACCESS_COARSE_LOCATION, + -> { if (!result.isGranted()) { showNotificationDeniedSnackbar( binding.root, this, - getString(R.string.map_request_location_permission_message) + getString(R.string.map_request_location_permission_message), ) } } } - } super.onRequestPermissionsResult(requestCode, permissions, grantResults) } - override fun shouldShowPermissionRationale(permission: String): Boolean = - shouldShowRequestPermissionRationale(permission) + override fun shouldShowPermissionRationale(permission: String): Boolean = shouldShowRequestPermissionRationale(permission) override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) @@ -145,7 +145,7 @@ class MainActivity : val canNavigateToNewsScreen = intent.getBooleanExtra(KEY_CAN_NAVIGATE_TO_NEWS, false) val noticeIdToExpand = intent.getLongExtra(KEY_NOTICE_ID_TO_EXPAND, INITIALIZED_ID) - if (noticeIdToExpand != INITIALIZED_ID) mainViewModel.expandNoticeItem(noticeIdToExpand) + if (noticeIdToExpand != INITIALIZED_ID) newsViewModel.expandNotice(noticeIdToExpand) if (canNavigateToNewsScreen) { binding.bnvMenu.selectedItemId = R.id.item_menu_news diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/MainViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/main/MainViewModel.kt index 4f453bb9..a97d8f05 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/main/MainViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/main/MainViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey -import com.daedan.festabook.di.viewmodel.ViewModelScope import com.daedan.festabook.domain.repository.DeviceRepository import com.daedan.festabook.domain.repository.FestivalRepository import com.daedan.festabook.presentation.common.Event @@ -25,13 +24,8 @@ class MainViewModel @Inject constructor( private val _backPressEvent: MutableLiveData> = MutableLiveData() val backPressEvent: LiveData> get() = _backPressEvent - private val _noticeIdToExpand: MutableLiveData = MutableLiveData() - val noticeIdToExpand: LiveData = _noticeIdToExpand - private val _isFirstVisit = - MutableLiveData( - festivalRepository.getIsFirstVisit().getOrDefault(true), - ) + MutableLiveData(festivalRepository.getIsFirstVisit().getOrDefault(true)) val isFirstVisit: LiveData get() = _isFirstVisit private var lastBackPressedTime: Long = 0 @@ -90,10 +84,6 @@ class MainViewModel @Inject constructor( } } - fun expandNoticeItem(announcementId: Long) { - _noticeIdToExpand.value = announcementId - } - companion object { private const val BACK_PRESS_INTERVAL: Long = 2000L } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt index 17b7e97f..e3f8076a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt @@ -91,8 +91,8 @@ class NewsViewModel( } } - fun expandNotice(noticeId: Long) { - this.noticeIdToExpand = noticeId + fun expandNotice(noticeIdToExpand: Long) { + this.noticeIdToExpand = noticeIdToExpand val notices = when (val currentState = _noticeUiState.value) { is NoticeUiState.Refreshing -> currentState.oldNotices diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt index 1d1dc353..fd2ebb38 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt @@ -13,18 +13,21 @@ import androidx.compose.material3.TabRowDefaults import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.Header -import com.daedan.festabook.presentation.main.MainViewModel import com.daedan.festabook.presentation.news.NewsTab import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.faq.component.FAQScreenContainer import com.daedan.festabook.presentation.news.lost.component.LostItemScreenContainer +import com.daedan.festabook.presentation.news.notice.NoticeUiState import com.daedan.festabook.presentation.news.notice.component.NoticeScreenContainer import com.daedan.festabook.presentation.theme.FestabookColor import kotlinx.coroutines.CoroutineScope @@ -33,12 +36,19 @@ import kotlinx.coroutines.launch @Composable fun NewsScreen( newsViewModel: NewsViewModel, - mainViewModel: MainViewModel, modifier: Modifier = Modifier, ) { val pageState = rememberPagerState { NewsTab.entries.size } val scope = rememberCoroutineScope() + val noticeUiState by newsViewModel.noticeUiState.collectAsStateWithLifecycle() + + LaunchedEffect(noticeUiState) { + if (noticeUiState is NoticeUiState.Success) { + pageState.animateScrollToPage(NewsTab.NOTICE.ordinal) + } + } + Column(modifier = modifier.background(color = MaterialTheme.colorScheme.background)) { Header(title = stringResource(R.string.news_title)) NewsTabRow(pageState, scope) From 5a825541763818caa3bc0b03f18fbb7ed931cafe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 26 Nov 2025 22:32:22 +0900 Subject: [PATCH 013/140] =?UTF-8?q?chore:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/adapter/NewsPagerAdapter.kt | 21 ------- .../presentation/news/faq/FAQFragment.kt | 40 ------------- .../news/lost/LostItemFragment.kt | 41 ------------- .../news/notice/NoticeFragment.kt | 57 ------------------- 4 files changed, 159 deletions(-) delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/news/adapter/NewsPagerAdapter.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/news/faq/FAQFragment.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/news/lost/LostItemFragment.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/news/notice/NoticeFragment.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/adapter/NewsPagerAdapter.kt b/app/src/main/java/com/daedan/festabook/presentation/news/adapter/NewsPagerAdapter.kt deleted file mode 100644 index 9373d9f1..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/news/adapter/NewsPagerAdapter.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.daedan.festabook.presentation.news.adapter - -import androidx.fragment.app.Fragment -import androidx.viewpager2.adapter.FragmentStateAdapter -import com.daedan.festabook.presentation.news.NewsTab -import com.daedan.festabook.presentation.news.faq.FAQFragment -import com.daedan.festabook.presentation.news.lost.LostItemFragment -import com.daedan.festabook.presentation.news.notice.NoticeFragment - -class NewsPagerAdapter( - fragment: Fragment, -) : FragmentStateAdapter(fragment) { - override fun getItemCount(): Int = NewsTab.entries.size - - override fun createFragment(position: Int): Fragment = - when (NewsTab.entries[position]) { - NewsTab.NOTICE -> NoticeFragment.newInstance() - NewsTab.FAQ -> FAQFragment.newInstance() - NewsTab.LOST_ITEM -> LostItemFragment.newInstance() - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/faq/FAQFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/news/faq/FAQFragment.kt deleted file mode 100644 index 2a4ed93f..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/news/faq/FAQFragment.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.daedan.festabook.presentation.news.faq - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentFaqBinding -import com.daedan.festabook.di.appGraph -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.news.NewsViewModel -import com.daedan.festabook.presentation.news.faq.component.FAQScreenContainer - -class FAQFragment : BaseFragment() { - override val layoutId: Int = R.layout.fragment_faq - - override val defaultViewModelProviderFactory: ViewModelProvider.Factory - get() = appGraph.metroViewModelFactory - private val viewModel: NewsViewModel by viewModels({ requireParentFragment() }) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = - ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - FAQScreenContainer(newsViewModel = viewModel) - } - } - - companion object { - fun newInstance() = FAQFragment() - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/LostItemFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/LostItemFragment.kt deleted file mode 100644 index 37861c3c..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/LostItemFragment.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.daedan.festabook.presentation.news.lost - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentLostItemBinding -import com.daedan.festabook.di.appGraph -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.news.NewsViewModel -import com.daedan.festabook.presentation.news.lost.component.LostItemScreenContainer - -class LostItemFragment : BaseFragment() { - override val layoutId: Int = R.layout.fragment_lost_item - - override val defaultViewModelProviderFactory: ViewModelProvider.Factory - get() = appGraph.metroViewModelFactory - - private val viewModel: NewsViewModel by viewModels({ requireParentFragment() }) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = - ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - LostItemScreenContainer(newsViewModel = viewModel) - } - } - - companion object { - fun newInstance() = LostItemFragment() - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/NoticeFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/NoticeFragment.kt deleted file mode 100644 index 26c654ac..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/NoticeFragment.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.daedan.festabook.presentation.news.notice - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentNoticeBinding -import com.daedan.festabook.di.appGraph -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.main.MainViewModel -import com.daedan.festabook.presentation.news.NewsViewModel -import com.daedan.festabook.presentation.news.notice.component.NoticeScreenContainer - -class NoticeFragment : BaseFragment() { - override val layoutId: Int = R.layout.fragment_notice - - override val defaultViewModelProviderFactory: ViewModelProvider.Factory - get() = appGraph.metroViewModelFactory - - private val newsViewModel: NewsViewModel by viewModels({ requireParentFragment() }) - private val mainViewModel: MainViewModel by viewModels({ requireActivity() }) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = - ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - NoticeScreenContainer(newsViewModel = newsViewModel) - } - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - setupObserver() - } - - private fun setupObserver() { - mainViewModel.noticeIdToExpand.observe(viewLifecycleOwner) { noticeId -> - newsViewModel.expandNotice(noticeId) - } - } - - companion object { - fun newInstance() = NoticeFragment() - } -} From e50328fdfc02c01bd78e4f79ea6724329b53279d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 26 Nov 2025 22:33:47 +0900 Subject: [PATCH 014/140] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=BD=94=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/news/NewsFragment.kt | 34 ++----------------- 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt index cb0a9a4a..3299f19a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt @@ -13,8 +13,6 @@ import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentNewsBinding import com.daedan.festabook.di.fragment.FragmentKey import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.main.MainViewModel -import com.daedan.festabook.presentation.news.adapter.NewsPagerAdapter import com.daedan.festabook.presentation.news.component.NewsScreen import com.daedan.festabook.presentation.theme.FestabookTheme import dev.zacsweers.metro.AppScope @@ -34,11 +32,7 @@ class NewsFragment : BaseFragment() { @Inject override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory - private val newsPagerAdapter by lazy { - NewsPagerAdapter(this) - } - private val newsViewModel: NewsViewModel by viewModels() - private val mainViewModel: MainViewModel by viewModels({ requireActivity() }) + private val newsViewModel: NewsViewModel by viewModels({ requireActivity() }) override fun onCreateView( inflater: LayoutInflater, @@ -49,32 +43,8 @@ class NewsFragment : BaseFragment() { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { FestabookTheme { - NewsScreen(mainViewModel = mainViewModel, newsViewModel = newsViewModel) + NewsScreen(newsViewModel = newsViewModel) } } } - -// override fun onViewCreated( -// view: View, -// savedInstanceState: Bundle?, -// ) { -// super.onViewCreated(view, savedInstanceState) -// binding.lifecycleOwner = viewLifecycleOwner -// setupNewsTabLayout() -// mainViewModel.noticeIdToExpand.observe(viewLifecycleOwner) { -// binding.vpNews.currentItem = NOTICE_TAB_INDEX -// } -// } -// -// private fun setupNewsTabLayout() { -// binding.vpNews.adapter = newsPagerAdapter -// TabLayoutMediator(binding.tlNews, binding.vpNews) { tab, position -> -// val tabNameRes = NewsTab.entries[position].tabNameRes -// tab.text = getString(tabNameRes) -// }.attach() -// } -// -// companion object { -// private const val NOTICE_TAB_INDEX: Int = 0 -// } } From 42beb6ed574b3229ed07cf3d44533a9df4d813ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 26 Nov 2025 22:33:59 +0900 Subject: [PATCH 015/140] =?UTF-8?q?feat:=20=EC=B4=88=EA=B8=B0=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EB=B7=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../festabook/presentation/news/faq/component/FAQScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt index d458b02d..ca4d37a8 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.EmptyStateScreen +import com.daedan.festabook.presentation.common.component.LoadingStateScreen import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.component.NewsItem import com.daedan.festabook.presentation.news.faq.FAQUiState @@ -49,7 +50,7 @@ fun FAQScreen( } } - is FAQUiState.InitialLoading -> Unit + is FAQUiState.InitialLoading -> LoadingStateScreen() is FAQUiState.Success -> { if (uiState.faqs.isEmpty()) { From fce5b7fd144d404a16e31892280565c208e2edba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 26 Nov 2025 22:39:40 +0900 Subject: [PATCH 016/140] =?UTF-8?q?refactor:=20loadAllFAQs=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=8B=9C=20=EC=B4=88=EA=B8=B0=20=EB=A1=9C=EB=94=A9?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EB=AA=85=EC=8B=9C=EC=A0=81=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/daedan/festabook/presentation/news/NewsViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt index e3f8076a..dc47a55a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt @@ -50,7 +50,7 @@ class NewsViewModel( init { loadAllNotices(NoticeUiState.InitialLoading) - loadAllFAQs() + loadAllFAQs(FAQUiState.InitialLoading) loadAllLostItems(LostUiState.InitialLoading) } @@ -144,7 +144,7 @@ class NewsViewModel( } } - private fun loadAllFAQs(state: FAQUiState = FAQUiState.InitialLoading) { + private fun loadAllFAQs(state: FAQUiState) { viewModelScope.launch { _faqUiState.value = state From 74a503640e3efbba9638a098382c1e21d0ec452b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Thu, 27 Nov 2025 18:32:36 +0900 Subject: [PATCH 017/140] =?UTF-8?q?refactor:=20=EC=86=8C=EC=8B=9D=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=BB=B4=ED=8F=AC=EC=A0=80=EB=B8=94=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/news/component/NewsScreen.kt | 103 +++++------------- .../news/component/NewsTabPage.kt | 66 +++++++++++ .../presentation/news/component/NewsTabRow.kt | 44 ++++++++ .../news/faq/component/FAQScreen.kt | 14 --- .../news/lost/component/LostItemScreen.kt | 20 ---- .../news/notice/component/NoticeScreen.kt | 21 ---- 6 files changed, 136 insertions(+), 132 deletions(-) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt index fd2ebb38..54f2bb07 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt @@ -2,36 +2,21 @@ package com.daedan.festabook.presentation.news.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow -import androidx.compose.material3.TabRowDefaults -import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.Header import com.daedan.festabook.presentation.news.NewsTab import com.daedan.festabook.presentation.news.NewsViewModel -import com.daedan.festabook.presentation.news.faq.component.FAQScreenContainer -import com.daedan.festabook.presentation.news.lost.component.LostItemScreenContainer +import com.daedan.festabook.presentation.news.lost.LostUiState import com.daedan.festabook.presentation.news.notice.NoticeUiState -import com.daedan.festabook.presentation.news.notice.component.NoticeScreenContainer -import com.daedan.festabook.presentation.theme.FestabookColor -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch @Composable fun NewsScreen( @@ -42,6 +27,11 @@ fun NewsScreen( val scope = rememberCoroutineScope() val noticeUiState by newsViewModel.noticeUiState.collectAsStateWithLifecycle() + val lostUiState by newsViewModel.lostUiState.collectAsStateWithLifecycle() + val faqUiState by newsViewModel.faqUiState.collectAsStateWithLifecycle() + + val isNoticeRefreshing = noticeUiState is NoticeUiState.Refreshing + val isLostItemRefreshing = noticeUiState is NoticeUiState.Refreshing LaunchedEffect(noticeUiState) { if (noticeUiState is NoticeUiState.Success) { @@ -52,66 +42,25 @@ fun NewsScreen( Column(modifier = modifier.background(color = MaterialTheme.colorScheme.background)) { Header(title = stringResource(R.string.news_title)) NewsTabRow(pageState, scope) - NewsTabPage(pageState, newsViewModel) - } -} - -@Composable -private fun NewsTabRow( - pageState: PagerState, - scope: CoroutineScope, -) { - TabRow( - selectedTabIndex = pageState.currentPage, - containerColor = MaterialTheme.colorScheme.background, - contentColor = FestabookColor.black, - indicator = { tabPositions -> - TabRowDefaults.PrimaryIndicator( - color = FestabookColor.black, - width = tabPositions[pageState.currentPage].width, - modifier = Modifier.tabIndicatorOffset(currentTabPosition = tabPositions[pageState.currentPage]), - ) - }, - ) { - NewsTab.entries.forEachIndexed { index, title -> - Tab( - selected = pageState.currentPage == index, - unselectedContentColor = FestabookColor.gray500, - onClick = { scope.launch { pageState.animateScrollToPage(index) } }, - text = { Text(text = stringResource(title.tabNameRes)) }, - ) - } - } -} - -@Composable -private fun NewsTabPage( - pageState: PagerState, - newsViewModel: NewsViewModel, -) { - HorizontalPager( - state = pageState, - verticalAlignment = Alignment.Top, - ) { index -> - val tab = NewsTab.entries[index] - when (tab) { - NewsTab.NOTICE -> - NoticeScreenContainer( - newsViewModel = newsViewModel, - modifier = Modifier.padding(horizontal = 16.dp), - ) - - NewsTab.FAQ -> - FAQScreenContainer( - newsViewModel = newsViewModel, - modifier = Modifier.padding(horizontal = 16.dp), - ) - - NewsTab.LOST_ITEM -> - LostItemScreenContainer( - newsViewModel = newsViewModel, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } + NewsTabPage( + pageState = pageState, + noticeUiState = noticeUiState, + faqUiState = faqUiState, + lostUiState = lostUiState, + isNoticeRefreshing = isNoticeRefreshing, + isLostItemRefreshing = isLostItemRefreshing, + onNoticeRefresh = { + val oldNotices = + (noticeUiState as? NoticeUiState.Success)?.notices ?: emptyList() + newsViewModel.loadAllNotices(NoticeUiState.Refreshing(oldNotices)) + }, + onLostItemRefresh = { + val oldLostItems = (lostUiState as? LostUiState.Success)?.lostItems ?: emptyList() + newsViewModel.loadAllLostItems(LostUiState.Refreshing(oldLostItems)) + }, + onNoticeClick = { newsViewModel.toggleNotice(it) }, + onFaqClick = { newsViewModel.toggleFAQ(it) }, + onLostGuideClick = { newsViewModel.toggleLostGuide() }, + ) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt new file mode 100644 index 00000000..e116f018 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt @@ -0,0 +1,66 @@ +package com.daedan.festabook.presentation.news.component + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.news.NewsTab +import com.daedan.festabook.presentation.news.faq.FAQUiState +import com.daedan.festabook.presentation.news.faq.component.FAQScreen +import com.daedan.festabook.presentation.news.faq.model.FAQItemUiModel +import com.daedan.festabook.presentation.news.lost.LostUiState +import com.daedan.festabook.presentation.news.lost.component.LostItemScreen +import com.daedan.festabook.presentation.news.notice.NoticeUiState +import com.daedan.festabook.presentation.news.notice.component.NoticeScreen +import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel + +@Composable +fun NewsTabPage( + pageState: PagerState, + noticeUiState: NoticeUiState, + faqUiState: FAQUiState, + lostUiState: LostUiState, + onNoticeRefresh: () -> Unit, + onLostItemRefresh: () -> Unit, + isNoticeRefreshing: Boolean, + isLostItemRefreshing: Boolean, + onNoticeClick: (NoticeUiModel) -> Unit, + onFaqClick: (FAQItemUiModel) -> Unit, + onLostGuideClick: () -> Unit, +) { + HorizontalPager( + state = pageState, + verticalAlignment = Alignment.Top, + ) { index -> + val tab = NewsTab.entries[index] + when (tab) { + NewsTab.NOTICE -> + NoticeScreen( + uiState = noticeUiState, + onNoticeClick = onNoticeClick, + isRefreshing = isNoticeRefreshing, + onRefresh = onNoticeRefresh, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + NewsTab.FAQ -> + FAQScreen( + uiState = faqUiState, + onFaqClick = onFaqClick, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + NewsTab.LOST_ITEM -> + LostItemScreen( + lostUiState = lostUiState, + onLostGuideClick = onLostGuideClick, + isRefreshing = isLostItemRefreshing, + onRefresh = onLostItemRefresh, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt new file mode 100644 index 00000000..d29dd365 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt @@ -0,0 +1,44 @@ +package com.daedan.festabook.presentation.news.component + +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.TabRowDefaults +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.daedan.festabook.presentation.news.NewsTab +import com.daedan.festabook.presentation.theme.FestabookColor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun NewsTabRow( + pageState: PagerState, + scope: CoroutineScope, +) { + TabRow( + selectedTabIndex = pageState.currentPage, + containerColor = MaterialTheme.colorScheme.background, + contentColor = FestabookColor.black, + indicator = { tabPositions -> + TabRowDefaults.PrimaryIndicator( + color = FestabookColor.black, + width = tabPositions[pageState.currentPage].width, + modifier = Modifier.tabIndicatorOffset(currentTabPosition = tabPositions[pageState.currentPage]), + ) + }, + ) { + NewsTab.entries.forEachIndexed { index, title -> + Tab( + selected = pageState.currentPage == index, + unselectedContentColor = FestabookColor.gray500, + onClick = { scope.launch { pageState.animateScrollToPage(index) } }, + text = { Text(text = stringResource(title.tabNameRes)) }, + ) + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt index ca4d37a8..69e37c5c 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt @@ -23,20 +23,6 @@ import timber.log.Timber private const val PADDING: Int = 8 -@Composable -fun FAQScreenContainer( - newsViewModel: NewsViewModel, - modifier: Modifier = Modifier, -) { - val uiState by newsViewModel.faqUiState.collectAsStateWithLifecycle() - - FAQScreen( - uiState = uiState, - onFaqClick = { faq -> newsViewModel.toggleFAQ(faq) }, - modifier = modifier, - ) -} - @Composable fun FAQScreen( uiState: FAQUiState, diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt index d50fafe5..5548160f 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt @@ -37,26 +37,6 @@ import timber.log.Timber private const val SPAN_COUNT: Int = 2 private const val PADDING: Int = 8 -@Composable -fun LostItemScreenContainer( - newsViewModel: NewsViewModel, - modifier: Modifier = Modifier, -) { - val uiState by newsViewModel.lostUiState.collectAsStateWithLifecycle() - val isRefreshing = uiState is LostUiState.Refreshing - - LostItemScreen( - lostUiState = uiState, - onLostGuideClick = { newsViewModel.toggleLostGuide() }, - isRefreshing = isRefreshing, - onRefresh = { - val oldLostItems = (uiState as? LostUiState.Success)?.lostItems ?: emptyList() - newsViewModel.loadAllLostItems(LostUiState.Refreshing(oldLostItems)) - }, - modifier = modifier, - ) -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun LostItemScreen( diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt index fc459b23..2f155df8 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt @@ -31,27 +31,6 @@ import timber.log.Timber private const val PADDING: Int = 8 -@Composable -fun NoticeScreenContainer( - newsViewModel: NewsViewModel, - modifier: Modifier = Modifier, -) { - val uiState by newsViewModel.noticeUiState.collectAsStateWithLifecycle() - val isRefreshing = uiState is NoticeUiState.Refreshing - - NoticeScreen( - uiState = uiState, - onNoticeClick = { notice -> newsViewModel.toggleNotice(notice) }, - isRefreshing = isRefreshing, - onRefresh = { - val oldNotices = - (uiState as? NoticeUiState.Success)?.notices ?: emptyList() - newsViewModel.loadAllNotices(NoticeUiState.Refreshing(oldNotices)) - }, - modifier = modifier, - ) -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun NoticeScreen( From 62ab9915847811417ecb9c88068119ca2b11bfda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Thu, 27 Nov 2025 18:49:22 +0900 Subject: [PATCH 018/140] =?UTF-8?q?feat:=20=ED=94=84=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/component/NewsTabPage.kt | 20 +++++++++++++++++++ .../presentation/news/component/NewsTabRow.kt | 12 +++++++++++ 2 files changed, 32 insertions(+) diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt index e116f018..edc48973 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt @@ -3,9 +3,11 @@ package com.daedan.festabook.presentation.news.component import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.daedan.festabook.presentation.news.NewsTab import com.daedan.festabook.presentation.news.faq.FAQUiState @@ -64,3 +66,21 @@ fun NewsTabPage( } } } + +@Composable +@Preview +private fun NewsTabPagePreview() { + NewsTabPage( + pageState = rememberPagerState { 3 }, + noticeUiState = NoticeUiState.Success(emptyList(), 0), + faqUiState = FAQUiState.Success(emptyList()), + lostUiState = LostUiState.Success(emptyList()), + onNoticeRefresh = {}, + onLostItemRefresh = {}, + isNoticeRefreshing = false, + isLostItemRefreshing = false, + onNoticeClick = {}, + onFaqClick = {}, + onLostGuideClick = {}, + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt index d29dd365..6a71b097 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt @@ -1,6 +1,7 @@ package com.daedan.festabook.presentation.news.component import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Tab import androidx.compose.material3.TabRow @@ -8,8 +9,10 @@ import androidx.compose.material3.TabRowDefaults import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import com.daedan.festabook.presentation.news.NewsTab import com.daedan.festabook.presentation.theme.FestabookColor import kotlinx.coroutines.CoroutineScope @@ -42,3 +45,12 @@ fun NewsTabRow( } } } + +@Composable +@Preview +private fun NewsTabRowPreview() { + NewsTabRow( + pageState = rememberPagerState { 3 }, + scope = rememberCoroutineScope(), + ) +} From 2c57ef5b0f14a84b5bffa5aa6993efc38ed0ed0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Thu, 27 Nov 2025 18:54:39 +0900 Subject: [PATCH 019/140] =?UTF-8?q?refactor:=20modifier=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EC=9D=B8=EC=9E=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../daedan/festabook/presentation/news/component/NewsTabPage.kt | 2 ++ .../daedan/festabook/presentation/news/component/NewsTabRow.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt index edc48973..ab2fe997 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt @@ -32,10 +32,12 @@ fun NewsTabPage( onNoticeClick: (NoticeUiModel) -> Unit, onFaqClick: (FAQItemUiModel) -> Unit, onLostGuideClick: () -> Unit, + modifier: Modifier = Modifier, ) { HorizontalPager( state = pageState, verticalAlignment = Alignment.Top, + modifier = modifier, ) { index -> val tab = NewsTab.entries[index] when (tab) { diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt index 6a71b097..a8bc11b8 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.launch fun NewsTabRow( pageState: PagerState, scope: CoroutineScope, + modifier: Modifier = Modifier, ) { TabRow( selectedTabIndex = pageState.currentPage, @@ -34,6 +35,7 @@ fun NewsTabRow( modifier = Modifier.tabIndicatorOffset(currentTabPosition = tabPositions[pageState.currentPage]), ) }, + modifier = modifier, ) { NewsTab.entries.forEachIndexed { index, title -> Tab( From 839cf20fbeee9ef802f5e81e946cc2c592010c80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Thu, 27 Nov 2025 18:56:47 +0900 Subject: [PATCH 020/140] =?UTF-8?q?fix:=20=EB=B6=84=EC=8B=A4=EB=AC=BC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=ED=99=95=EC=9D=B8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../daedan/festabook/presentation/news/component/NewsScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt index 54f2bb07..728f9ee0 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt @@ -31,7 +31,7 @@ fun NewsScreen( val faqUiState by newsViewModel.faqUiState.collectAsStateWithLifecycle() val isNoticeRefreshing = noticeUiState is NoticeUiState.Refreshing - val isLostItemRefreshing = noticeUiState is NoticeUiState.Refreshing + val isLostItemRefreshing = lostUiState is LostUiState.Refreshing LaunchedEffect(noticeUiState) { if (noticeUiState is NoticeUiState.Success) { From ba5df24a3f84661674d66b2a9620fceafd1b1449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Thu, 27 Nov 2025 19:26:52 +0900 Subject: [PATCH 021/140] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20spacing=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/common/component/Header.kt | 10 +++++++--- .../presentation/news/component/NewsItem.kt | 10 +++++----- .../news/component/NewsTabPage.kt | 8 ++++---- .../news/faq/component/FAQScreen.kt | 11 ++++++---- .../news/lost/component/LostItemScreen.kt | 12 +++++++---- .../news/notice/component/NoticeScreen.kt | 11 ++++++---- .../presentation/theme/FestabookSpacing.kt | 20 +++++++++++++++++++ .../presentation/theme/FestabookTheme.kt | 18 +++++++++++------ 8 files changed, 70 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/theme/FestabookSpacing.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/Header.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/Header.kt index d322d849..1efcadd8 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/component/Header.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/Header.kt @@ -7,8 +7,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookSpacing @Composable fun Header( @@ -21,8 +21,12 @@ fun Header( style = style, modifier = modifier - .padding(top = 40.dp, bottom = 16.dp, start = 16.dp, end = 16.dp) - .fillMaxWidth(), + .padding( + top = festabookSpacing.paddingTitleHorizontal, + bottom = festabookSpacing.paddingBody4, + start = festabookSpacing.paddingScreenGutter, + end = festabookSpacing.paddingScreenGutter, + ).fillMaxWidth(), ) } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsItem.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsItem.kt index c3f98760..d002aebb 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsItem.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsItem.kt @@ -21,11 +21,11 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.cardBackground import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookSpacing private const val ICON_ROTATION_EXPANDED: Float = 180F private const val ICON_ROTATION_COLLAPSED: Float = 0F @@ -54,7 +54,7 @@ fun NewsItem( indication = null, interactionSource = null, ) { onclick() } - .padding(16.dp), + .padding(festabookSpacing.paddingBody4), ) { Row( modifier = Modifier.fillMaxWidth(), @@ -62,14 +62,14 @@ fun NewsItem( ) { if (icon != null) { icon() - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(festabookSpacing.paddingBody2)) } Text( text = title, style = FestabookTypography.titleSmall, modifier = Modifier.weight(1f), ) - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(festabookSpacing.paddingBody2)) if (createdAt != null) { Text( text = createdAt, @@ -86,7 +86,7 @@ fun NewsItem( } if (isExpanded) { - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody2)) Text(text = description) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt index ab2fe997..1fbf10a1 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt @@ -8,7 +8,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.daedan.festabook.presentation.news.NewsTab import com.daedan.festabook.presentation.news.faq.FAQUiState import com.daedan.festabook.presentation.news.faq.component.FAQScreen @@ -18,6 +17,7 @@ import com.daedan.festabook.presentation.news.lost.component.LostItemScreen import com.daedan.festabook.presentation.news.notice.NoticeUiState import com.daedan.festabook.presentation.news.notice.component.NoticeScreen import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel +import com.daedan.festabook.presentation.theme.festabookSpacing @Composable fun NewsTabPage( @@ -47,14 +47,14 @@ fun NewsTabPage( onNoticeClick = onNoticeClick, isRefreshing = isNoticeRefreshing, onRefresh = onNoticeRefresh, - modifier = Modifier.padding(horizontal = 16.dp), + modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), ) NewsTab.FAQ -> FAQScreen( uiState = faqUiState, onFaqClick = onFaqClick, - modifier = Modifier.padding(horizontal = 16.dp), + modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), ) NewsTab.LOST_ITEM -> @@ -63,7 +63,7 @@ fun NewsTabPage( onLostGuideClick = onLostGuideClick, isRefreshing = isLostItemRefreshing, onRefresh = onLostItemRefresh, - modifier = Modifier.padding(horizontal = 16.dp), + modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), ) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt index 69e37c5c..57385446 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt @@ -19,10 +19,9 @@ import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.component.NewsItem import com.daedan.festabook.presentation.news.faq.FAQUiState import com.daedan.festabook.presentation.news.faq.model.FAQItemUiModel +import com.daedan.festabook.presentation.theme.festabookSpacing import timber.log.Timber -private const val PADDING: Int = 8 - @Composable fun FAQScreen( uiState: FAQUiState, @@ -44,8 +43,12 @@ fun FAQScreen( } else { LazyColumn( modifier = modifier, - contentPadding = PaddingValues(top = PADDING.dp, bottom = PADDING.dp), - verticalArrangement = Arrangement.spacedBy(PADDING.dp), + contentPadding = + PaddingValues( + top = festabookSpacing.paddingBody2, + bottom = festabookSpacing.paddingBody2, + ), + verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody2), ) { items( items = uiState.faqs, diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt index 5548160f..6272bd0a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt @@ -32,10 +32,10 @@ import com.daedan.festabook.presentation.news.component.NewsItem import com.daedan.festabook.presentation.news.lost.LostUiState import com.daedan.festabook.presentation.news.lost.model.LostItemUiStatus import com.daedan.festabook.presentation.news.lost.model.LostUiModel +import com.daedan.festabook.presentation.theme.festabookSpacing import timber.log.Timber private const val SPAN_COUNT: Int = 2 -private const val PADDING: Int = 8 @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -116,9 +116,13 @@ private fun LostItemContent( LazyVerticalGrid( modifier = modifier, columns = GridCells.Fixed(SPAN_COUNT), - contentPadding = PaddingValues(top = PADDING.dp, bottom = PADDING.dp), - verticalArrangement = Arrangement.spacedBy(PADDING.dp), - horizontalArrangement = Arrangement.spacedBy(PADDING.dp), + contentPadding = + PaddingValues( + top = festabookSpacing.paddingBody2, + bottom = festabookSpacing.paddingBody2, + ), + verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody2), + horizontalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody2), ) { item(span = { GridItemSpan(SPAN_COUNT) }) { val guide = lostItems.firstOrNull() as? LostUiModel.Guide diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt index 2f155df8..fbc2815a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt @@ -27,10 +27,9 @@ import com.daedan.festabook.presentation.news.component.NewsItem import com.daedan.festabook.presentation.news.notice.NoticeUiState import com.daedan.festabook.presentation.news.notice.NoticeUiState.Companion.DEFAULT_POSITION import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel +import com.daedan.festabook.presentation.theme.festabookSpacing import timber.log.Timber -private const val PADDING: Int = 8 - @OptIn(ExperimentalMaterial3Api::class) @Composable fun NoticeScreen( @@ -97,8 +96,12 @@ private fun NoticeContent( LazyColumn( modifier = modifier, state = listState, - contentPadding = PaddingValues(top = PADDING.dp, bottom = PADDING.dp), - verticalArrangement = Arrangement.spacedBy(PADDING.dp), + contentPadding = + PaddingValues( + top = festabookSpacing.paddingBody2, + bottom = festabookSpacing.paddingBody2, + ), + verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody2), ) { items( items = notices, diff --git a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookSpacing.kt b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookSpacing.kt new file mode 100644 index 00000000..3d12413b --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookSpacing.kt @@ -0,0 +1,20 @@ +package com.daedan.festabook.presentation.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +data class FestabookSpacing( + val paddingScreenGutter: Dp = 16.dp, + val paddingTitleHorizontal: Dp = 40.dp, + val paddingBody1: Dp = 4.dp, + val paddingBody2: Dp = 8.dp, + val paddingBody3: Dp = 12.dp, + val paddingBody4: Dp = 16.dp, +) + +val LocalSpacing = staticCompositionLocalOf { FestabookSpacing() } + +val festabookSpacing + @Composable get() = LocalSpacing.current diff --git a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt index 9d1b0e7e..d0dd8aef 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt @@ -3,6 +3,7 @@ package com.daedan.festabook.presentation.theme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider private val LightColorScheme = lightColorScheme( @@ -11,10 +12,15 @@ private val LightColorScheme = @Composable fun FestabookTheme(content: @Composable () -> Unit) { - MaterialTheme( - colorScheme = LightColorScheme, - shapes = FestabookShapesTheme, - typography = FestabookTypography, - content = content, - ) + val spacing = FestabookSpacing() + CompositionLocalProvider( + LocalSpacing provides spacing, + ) { + MaterialTheme( + colorScheme = LightColorScheme, + shapes = FestabookShapesTheme, + typography = FestabookTypography, + content = content, + ) + } } From dde92db711c2fa9edfdb7554a74c77476ddd445b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Thu, 27 Nov 2025 19:43:51 +0900 Subject: [PATCH 022/140] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20Shape=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/component/CardBackground.kt | 24 +++++++++++-------- .../news/lost/component/LostItem.kt | 20 ++++++++-------- .../presentation/theme/FestabookShapes.kt | 15 ++++-------- .../presentation/theme/FestabookTheme.kt | 3 ++- 4 files changed, 31 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/CardBackground.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/CardBackground.kt index e1262009..cacbebad 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/component/CardBackground.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/CardBackground.kt @@ -4,38 +4,42 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookShapes @Composable fun Modifier.cardBackground( backgroundColor: Color = FestabookColor.gray100, borderStroke: Dp = 1.dp, borderColor: Color = FestabookColor.gray200, - roundedCornerShape: Dp = 16.dp, + shape: Shape = festabookShapes.radius3, ): Modifier = background( color = backgroundColor, - shape = RoundedCornerShape(roundedCornerShape), + shape = shape, ).border( width = borderStroke, color = borderColor, - shape = RoundedCornerShape(roundedCornerShape), + shape = shape, ) @Composable @Preview(showBackground = true) private fun CardBackgroundPreview() { - Box( - modifier = - Modifier - .cardBackground() - .size(120.dp), - ) + FestabookTheme { + Box( + modifier = + Modifier + .cardBackground() + .size(120.dp), + ) + } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItem.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItem.kt index 50e26352..bf3567ea 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItem.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItem.kt @@ -3,18 +3,16 @@ package com.daedan.festabook.presentation.news.lost.component import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.CoilImage import com.daedan.festabook.presentation.common.component.cardBackground - -private const val ROUNDED_CORNER_SHAPE = 16 +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookShapes @Composable fun LostItem( @@ -23,10 +21,10 @@ fun LostItem( onLostItemClick: () -> Unit = {}, ) { Card( - shape = RoundedCornerShape(ROUNDED_CORNER_SHAPE.dp), + shape = festabookShapes.radius3, modifier = modifier - .cardBackground(roundedCornerShape = ROUNDED_CORNER_SHAPE.dp) + .cardBackground() .aspectRatio(1f) .clickable(indication = null, interactionSource = null) { onLostItemClick() }, ) { @@ -41,8 +39,10 @@ fun LostItem( @Composable @Preview private fun LostItemPreview() { - LostItem( - url = "https://i.imgur.com/Zblctu7.png", - onLostItemClick = { }, - ) + FestabookTheme { + LostItem( + url = "https://i.imgur.com/Zblctu7.png", + onLostItemClick = { }, + ) + } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookShapes.kt b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookShapes.kt index 819b6ed0..8f08a185 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookShapes.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookShapes.kt @@ -2,7 +2,8 @@ package com.daedan.festabook.presentation.theme import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Shapes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.unit.dp data class FestabookShapes( @@ -14,13 +15,7 @@ data class FestabookShapes( val radiusFull: CornerBasedShape = RoundedCornerShape(999.dp), ) -val festabookShapes = FestabookShapes() +val LocalShapes = staticCompositionLocalOf { FestabookShapes() } -val FestabookShapesTheme = - Shapes( - extraSmall = festabookShapes.radius1, - small = festabookShapes.radius2, - medium = festabookShapes.radius3, - large = festabookShapes.radius4, - extraLarge = festabookShapes.radius5, - ) +val festabookShapes: FestabookShapes + @Composable get() = LocalShapes.current diff --git a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt index d0dd8aef..5a7b46bb 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt @@ -13,12 +13,13 @@ private val LightColorScheme = @Composable fun FestabookTheme(content: @Composable () -> Unit) { val spacing = FestabookSpacing() + val shapes = FestabookShapes() CompositionLocalProvider( LocalSpacing provides spacing, + LocalShapes provides shapes, ) { MaterialTheme( colorScheme = LightColorScheme, - shapes = FestabookShapesTheme, typography = FestabookTypography, content = content, ) From 134a09ec96fd93af30363ffa8b29c6a1c04d7000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Fri, 28 Nov 2025 09:48:24 +0900 Subject: [PATCH 023/140] =?UTF-8?q?refactor:=20NewsViewModelTest=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../festabook/news/NewsViewModelTest.kt | 35 ++++++------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/app/src/test/java/com/daedan/festabook/news/NewsViewModelTest.kt b/app/src/test/java/com/daedan/festabook/news/NewsViewModelTest.kt index ff45996d..e5e15183 100644 --- a/app/src/test/java/com/daedan/festabook/news/NewsViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/news/NewsViewModelTest.kt @@ -5,7 +5,6 @@ import com.daedan.festabook.domain.model.Lost import com.daedan.festabook.domain.repository.FAQRepository import com.daedan.festabook.domain.repository.LostItemRepository import com.daedan.festabook.domain.repository.NoticeRepository -import com.daedan.festabook.getOrAwaitValue import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.faq.FAQUiState import com.daedan.festabook.presentation.news.faq.model.toUiModel @@ -79,7 +78,7 @@ class NewsViewModelTest { // then val expected = FAKE_NOTICES.map { it.toUiModel() } - val actual = newsViewModel.noticeUiState + val actual = newsViewModel.noticeUiState.value coVerify { noticeRepository.fetchNotices() } assertThat(actual).isEqualTo( NoticeUiState.Success(expected, DEFAULT_POSITION), @@ -99,11 +98,11 @@ class NewsViewModelTest { ) // when - newsViewModel.loadAllLostItems() + newsViewModel.loadAllLostItems(LostUiState.InitialLoading) advanceUntilIdle() // then - val actual = newsViewModel.lostUiState.getOrAwaitValue() + val actual = newsViewModel.lostUiState.value coVerify { lostItemRepository.getLost() } assertThat(actual).isEqualTo(expected) } @@ -121,7 +120,7 @@ class NewsViewModelTest { // then val expected = NoticeUiState.Error(exception) - val actual = newsViewModel.noticeUiState + val actual = newsViewModel.noticeUiState.value coVerify { noticeRepository.fetchNotices() } assertThat(actual).isEqualTo(expected) } @@ -138,7 +137,7 @@ class NewsViewModelTest { // then val expected = FAKE_FAQS.map { it.toUiModel() } - val actual = newsViewModel.faqUiState + val actual = newsViewModel.faqUiState.value coVerify { faqRepository.getAllFAQ() } assertThat(actual).isEqualTo(FAQUiState.Success(expected)) } @@ -156,7 +155,7 @@ class NewsViewModelTest { // then val expected = FAQUiState.Error(exception) - val actual = newsViewModel.faqUiState + val actual = newsViewModel.faqUiState.value coVerify { faqRepository.getAllFAQ() } assertThat(actual).isEqualTo(expected) } @@ -183,7 +182,7 @@ class NewsViewModelTest { createdAt = LocalDateTime.of(2025, 1, 1, 0, 0, 0), ), ) - val actual = newsViewModel.noticeUiState + val actual = newsViewModel.noticeUiState.value assertThat(actual).isEqualTo(NoticeUiState.Success(expected, DEFAULT_POSITION)) } @@ -199,24 +198,10 @@ class NewsViewModelTest { // then val expected = listOf(faq.copy(isExpanded = true)) - val actual = newsViewModel.faqUiState + val actual = newsViewModel.faqUiState.value assertThat(actual).isEqualTo(FAQUiState.Success(expected)) } - @Test - fun `분실물 아이템의 클릭 이벤트를 발생시킬 수 있다`() = - runTest { - // given - val lostItem: LostUiModel.Item = mockk() - - // when - newsViewModel.lostItemClick(lostItem) - - // then - val actual = newsViewModel.lostItemClickEvent.getOrAwaitValue() - assertThat(actual.peekContent()).isEqualTo(lostItem) - } - @Test fun `처음 로드했을 때 펼처질 공지사항을 지정할 수 있다`() = runTest { @@ -233,7 +218,7 @@ class NewsViewModelTest { advanceUntilIdle() // then - val actual = newsViewModel.noticeUiState + val actual = newsViewModel.noticeUiState.value coVerify { noticeRepository.fetchNotices() } assertThat(actual).isEqualTo(NoticeUiState.Success(expected, 1)) } @@ -256,7 +241,7 @@ class NewsViewModelTest { newsViewModel.toggleLostGuide() // then - val actual = newsViewModel.lostUiState.getOrAwaitValue() + val actual = newsViewModel.lostUiState.value assertThat(actual).isEqualTo(expected) } } From 633bcc8aed70c6f2bebd639007ddceb5497391e5 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Fri, 28 Nov 2025 16:53:36 +0900 Subject: [PATCH 024/140] =?UTF-8?q?[CI/CD]=20Android=20Ci,=20CD=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Android CD 워크플로우 추가 `main` 브랜치에 코드가 푸시될 때, 자동으로 릴리스 빌드, 릴리스 노트 초안 작성, `develop` 브랜치로의 변경 사항 병합을 수행하는 `android-release-drafter.yml` 워크플로우를 추가했습니다. - **Deploy**: 릴리스 AAB(Android App Bundle)를 빌드하고 아티팩트로 업로드합니다. - **Release-Drafter**: `release-drafter`를 사용하여 릴리스 노트를 업데이트합니다. - **Reflect-Changes-Into-Develop**: `main` 브랜치의 변경 사항을 `develop` 브랜치로 자동으로 병합합니다. test: NewsViewModel 테스트 코드 수정 - `loadAllLostItems` 테스트에서 초기 상태(`LostUiState.InitialLoading`)를 명시적으로 전달하도록 수정했습니다. - 분실물 아이템 클릭 이벤트 테스트를 제거했습니다. - `lostUiState`를 검증할 때 `getOrAwaitValue()`를 사용하지 않도록 변경했습니다. feat: ktlint app 수준에서 적용 - **테스트 코드 개선** - 불필요한 테스트 파일(`ExampleInstrumentedTest.kt`)을 삭제하고, `.gitkeep` 파일을 추가했습니다. - 테스트 코드 전반의 네이밍을 수정하고, given-when-then 주석과 코드 사이에 공백을 추가하여 가독성을 높였습니다. - 다양한 ViewModel 테스트에서 불필요한 `ViewModelScope` 및 기타 import 문을 제거했습니다. - 일부 ViewModel 생성자에서 더 이상 사용되지 않는 `ViewModelProvider.Factory` 관련 코드를 정리했습니다. - **기타 리팩토링** - 사용되지 않는 import 문과 코드를 정리했습니다. - 코드 포맷팅을 통해 전반적인 코드 스타일을 통일했습니다. chore: coderabbit 세팅 `.coderabbit.yaml` 파일에서 백엔드 및 프론트엔드 관련 경로(`backend/**`, `frontend/**`)에 대한 리뷰 지침을 제거했습니다. 이제 모든 경로(`/**`)에 대해 기존 안드로이드 리뷰 지침이 공통으로 적용됩니다. feat: Android CI 워크플로우 개선 - **PR 대상 브랜치 변경:** - 기존 `main`, `develop` 브랜치에 더해 `android-prod`, `android-dev` 브랜치를 PR 대상으로 추가했습니다. - **`working-directory` 제거:** - 워크플로우 전역에 설정되어 있던 `working-directory: android`를 제거하여, 각 스텝이 프로젝트 루트에서 실행되도록 수정했습니다. - **캐시 키 경로 수정:** - Gradle 캐시 키 생성 시, `android/` 접두사가 붙은 경로를 루트 기준의 상대 경로(`**/...`)로 변경했습니다. - **`clean` 작업 제거:** - 불필요한 `./gradlew clean` 단계를 제거했습니다. - **단위 테스트 분기 처리:** - PR의 베이스 브랜치에 따라 다른 테스트 명령어를 실행하도록 수정했습니다. - `develop` 브랜치 대상 PR: `testDebugUnitTest` 실행 - `main` 브랜치 대상 PR: `testReleaseUnitTest` 실행 - **테스트 결과 경로 수정:** - `publish-unit-test-result-action`에서 사용하는 테스트 결과 XML 파일의 경로를 `android/app/build/...`에서 `app/build/...`로 수정했습니다. chore: Slack 알림 관련 워크플로우 파일 제거 - PR 생성 및 리뷰 준비 시점에 리뷰 요청을 보내는 `common-slack-notify-opened.yml` 워크플로우를 삭제했습니다. - 재리뷰 요청 시 알림을 보내는 `common-slack-notify-rerequested.yml` 워크플로우를 삭제했습니다. - 리뷰 완료(approved, changes_requested) 시점에 알림을 보내는 `common-slack-notify-submitted.yml` 워크플로우를 삭제했습니다. fix(.github): Release Drafter 워크플로우 오타 수정 및 파일 삭제 - `.github/workflows/release-drafter.yml`: 사용하지 않는 `release-drafter.yml` 워크플로우 파일을 삭제했습니다. - `.github/workflows/android-release-drafter.yml`: Git 사용자 이메일 설정에서 누락된 따옴표를 추가하여 구문 오류를 수정했습니다. fix(.github): Release Drafter 워크플로우 오타 수정 및 파일 삭제 - `.github/workflows/release-drafter.yml`: 사용하지 않는 `release-drafter.yml` 워크플로우 파일을 삭제했습니다. - `.github/workflows/android-release-drafter.yml`: Git 사용자 이메일 설정에서 누락된 따옴표를 추가하여 구문 오류를 수정했습니다. fix(.github): Release Drafter 워크플로우 오타 수정 및 파일 삭제 - `.github/workflows/release-drafter.yml`: 사용하지 않는 `release-drafter.yml` 워크플로우 파일을 삭제했습니다. - `.github/workflows/android-release-drafter.yml`: Git 사용자 이메일 설정에서 누락된 따옴표를 추가하여 구문 오류를 수정했습니다. fix(.github): Release Drafter 워크플로우 오타 수정 및 파일 삭제 - `.github/workflows/release-drafter.yml`: 사용하지 않는 `release-drafter.yml` 워크플로우 파일을 삭제했습니다. - `.github/workflows/android-release-drafter.yml`: Git 사용자 이메일 설정에서 누락된 따옴표를 추가하여 구문 오류를 수정했습니다. fix(.github): Release Drafter 워크플로우 오타 수정 및 파일 삭제 - `.github/workflows/release-drafter.yml`: 사용하지 않는 `release-drafter.yml` 워크플로우 파일을 삭제했습니다. - `.github/workflows/android-release-drafter.yml`: Git 사용자 이메일 설정에서 누락된 따옴표를 추가하여 구문 오류를 수정했습니다. fix(.github): Release Drafter 워크플로우 오타 수정 및 파일 삭제 - `.github/workflows/release-drafter.yml`: 사용하지 않는 `release-drafter.yml` 워크플로우 파일을 삭제했습니다. - `.github/workflows/android-release-drafter.yml`: Git 사용자 이메일 설정에서 누락된 따옴표를 추가하여 구문 오류를 수정했습니다. fix(.github): Release Drafter 워크플로우 오타 수정 및 파일 삭제 - `.github/workflows/release-drafter.yml`: 사용하지 않는 `release-drafter.yml` 워크플로우 파일을 삭제했습니다. - `.github/workflows/android-release-drafter.yml`: Git 사용자 이메일 설정에서 누락된 따옴표를 추가하여 구문 오류를 수정했습니다. fix(.github): Release Drafter 워크플로우 오타 수정 및 파일 삭제 - `.github/workflows/release-drafter.yml`: 사용하지 않는 `release-drafter.yml` 워크플로우 파일을 삭제했습니다. - `.github/workflows/android-release-drafter.yml`: Git 사용자 이메일 설정에서 누락된 따옴표를 추가하여 구문 오류를 수정했습니다. fix(.github): Release Drafter 워크플로우 오타 수정 및 파일 삭제 - `.github/workflows/release-drafter.yml`: 사용하지 않는 `release-drafter.yml` 워크플로우 파일을 삭제했습니다. - `.github/workflows/android-release-drafter.yml`: Git 사용자 이메일 설정에서 누락된 따옴표를 추가하여 구문 오류를 수정했습니다. fix(.github): Release Drafter 워크플로우 오타 수정 및 파일 삭제 - `.github/workflows/release-drafter.yml`: 사용하지 않는 `release-drafter.yml` 워크플로우 파일을 삭제했습니다. - `.github/workflows/android-release-drafter.yml`: Git 사용자 이메일 설정에서 누락된 따옴표를 추가하여 구문 오류를 수정했습니다. feat: 수동 배포를 위한 `workflow_dispatch` 추가 Android 릴리스 워크플로우(`android-release-drafter.yml`)에 `workflow_dispatch` 이벤트를 추가하여 GitHub Actions UI에서 수동으로 배포를 실행할 수 있도록 변경했습니다. - **수동 실행 옵션 추가:** - `track`: 배포할 Play Store 트랙 (internal, alpha, beta, production) - `status`: 업로드할 아티팩트의 릴리스 상태 (draft, inProgress, completed, halted) - `release_notes_path`: 릴리스 노트 파일 경로 (선택) - `release_name`: 릴리스 이름 재정의 (선택) - **Google Play 업로드 단계 추가:** - `r0adkll/upload-google-play` 액션을 사용하여 빌드된 AAB 파일을 Google Play Store에 업로드하는 단계를 추가했습니다. - 수동 실행 시 입력된 `track`과 `status`를 사용하고, 기본값으로는 `production`과 `draft`를 사용하도록 설정했습니다. * feat: Slack 알림 워크플로우 추가 Pull Request의 생명주기에 맞춰 Slack으로 알림을 보내는 세 가지 워크플로우를 추가했습니다. GitHub 사용자 이름을 Slack ID에 매핑하여 개인에게 멘션을 보냅니다. - **`common-slack-notify-opened.yml` (최초 리뷰 요청)** - PR이 `opened` 되거나 `ready_for_review` 상태가 될 때 실행됩니다. - 지정된 리뷰어들에게 리뷰 요청 알림을 보냅니다. - **`common-slack-notify-rerequested.yml` (재리뷰 요청)** - PR에 리뷰어가 다시 요청될 때(`review_requested`) 실행됩니다. - 해당 리뷰어에게 재리뷰 요청 알림을 보냅니다. - 이전에 리뷰 기록이 없는 최초 요청의 경우에는 알림을 보내지 않습니다. - **`common-slack-notify-submitted.yml` (리뷰 완료)** - 리뷰가 `submitted` 될 때 실행됩니다. - PR 작성자에게 리뷰가 완료되었음을 알립니다. - 리뷰 상태가 `approved`(✅) 또는 `changes_requested`(🔴)일 때만 알림을 보내고, 단순 코멘트(`commented`)는 제외합니다. --- .coderabbit.yaml | 127 ------------------ .github/workflows/android-ci.yml | 32 +++-- .github/workflows/android-release-drafter.yml | 121 +++++++++++++++++ .github/workflows/release-drafter.yml | 36 ----- app/build.gradle.kts | 1 + 5 files changed, 137 insertions(+), 180 deletions(-) delete mode 100644 .coderabbit.yaml create mode 100644 .github/workflows/android-release-drafter.yml delete mode 100644 .github/workflows/release-drafter.yml diff --git a/.coderabbit.yaml b/.coderabbit.yaml deleted file mode 100644 index fc3d7d8f..00000000 --- a/.coderabbit.yaml +++ /dev/null @@ -1,127 +0,0 @@ -# ─────────── 전역(공통) 설정 ─────────── -language: ko-KR -tone_instructions: "1. 리뷰 시에는 변경 사항의 문제점이나 한계점을 구체적으로 짚어주고, \n 2. 왜 해당 부분이 비효율적이거나 권장되지 않는지 명확한 근거와 함께 설명해주세요. \n 3. 대안을 제시할 땐, 선택지를 제안하면서 각각의 장단점도 논리적으로 설명해주세요. \n 4. 단순 지적보다는 개선 방향에 집중하고, 학습적인 관점에서 이해를 돕는 코멘트를 남겨주세요. \n 5. 지나치게 딱딱하지 않되, 논리의 흐름이 분명하게 전달되도록 해주세요." - -# ─────────── 리뷰(Reviews) 전반 ─────────── -reviews: - profile: chill - high_level_summary: true - high_level_summary_placeholder: "🤖 Code Rabbit PR 요약" - review_status: true - commit_status: true - - # 워크스루/자동화/부가 기능 - collapse_walkthrough: false - changed_files_summary: false - sequence_diagrams: false - assess_linked_issues: true - related_issues: false - related_prs: false - suggested_labels: false - auto_apply_labels: false - suggested_reviewers: false - auto_assign_reviewers: false - poem: false - - # 경로별 리뷰 지침 및 제외 폴더 - path_instructions: - - path: android/** - instructions: | - - 1. 코틀린 공식 스타일 가이드 및 팀 컨벤션을 우선적으로 반영하여, 가독성, 안전성(Null/예외처리), 테스트/유지보수 용이성, 안드로이드 특화 사항(라이프사이클, 리소스, 권한 등)에 대해 리뷰해주세요. - - 2. 최신 코틀린/안드로이드 트렌드, 주석 및 문서화, 팀 스타일 통일성도 함께 확인해 주세요. - - 3. 각 리뷰 포인트별로 문제점과 대안, 장단점을 논리적으로 제시하고, 필요한 경우 예시 코드도 추가해 주세요. - - 4. 리뷰가 너무 많아서 피로감을 줄 수 있으니, 꼭 필요한 부분에 집중해주고, 나머지는 캡션으로 설명해주세요. - - 5. 리뷰 남겨주는 부분은 해당 라인 범위의 코멘트에 작성해주세요. - - path: backend/** - instructions: | - - 1. 팀 및 공식 컨벤션, 가독성, 예외처리, 테스트/확장/유지보수성, 모듈화, API/DB/보안 설계 기준을 기반으로 리뷰해주세요. - - 2. 최신 트렌드, 불필요한 로직, 클린코드, 리팩토링, 서비스/도메인 설계, 공통 예외 처리, 확장성도 함께 확인해주세요. - - 3. 각 피드백은 문제점·대안·장단점을 짧고 논리적으로, 예시 코드가 있다면 간결히 포함해 주세요. - - 4. 팀 내 스타일 통일성도 확인해주세요. - - 5. 미작성한 테스트 코드 케이스가 있다면, 어떤 테스트가 필요한지 제안해주세요. (예: 컨트롤러는 인수 테스트, 나머지는 단위 테스트) - - 6. 리뷰 남겨주는 부분은 해당 라인 범위의 코멘트에 작성해주세요. - - path: frontend/** - instructions: | - - 우리는 백엔드 개발자 팀으로, 관리자 페이지 프론트엔드를 Vibe 코딩 방식으로 빠르게 구현했습니다. - - React에 대한 전문적인 이해도가 부족한 상태이므로, 다음과 같은 기준으로 리뷰해 주세요: - - 1. 코드 스타일이나 컴포넌트 구조 등 전반적인 구조에 대한 일반적인 피드백은 생략해 주세요. - - 2. 보안상 취약점이 될 수 있는 부분 (예: XSS, CSRF, 사용자 입력 검증 부족 등) 은 반드시 알려주세요. - - 3. 화면 상 명백하게 어색하거나 비정상적으로 동작할 수 있는 UI/UX 요소만 지적해 주세요. - - 4. 빠른 배포를 목적으로 하기 때문에, 논리상 큰 이상이 없는 부분은 코멘트하지 않으셔도 됩니다. - - 5. 실제 사용자에게 혼동을 줄 수 있는 부분(버튼 비노출, 접근 불가능 등)이 있다면 꼭 알려주세요. - - 6. 해당 PR에는 테스트 코드가 포함되지 않았으며, 테스트 커버리지나 테스트 방식에 대한 피드백은 생략해 주세요. - - 위 기준을 바탕으로 꼭 필요한 피드백 위주로 리뷰 부탁드립니다. - - # 리뷰 진행/캐시/자동화 - abort_on_close: true - disable_cache: false - - auto_review: - enabled: true - auto_incremental_review: true - base_branches: [ "android", "backend", "frontend" ] - - finishing_touches: - docstrings: - enabled: true - unit_tests: - enabled: true - -# ─────────── 채팅(Chat) 설정 ─────────── -chat: - auto_reply: true - -# ─────────── 지식 기반(Knowledge base) ─────────── -knowledge_base: - opt_out: false - - web_search: - enabled: true - - code_guidelines: - enabled: true - filePatterns: - - backend/code-style.md - - android/code-style.md - - learnings: - scope: auto - issues: - scope: local - pull_requests: - scope: local - -# ─────────── 코드 생성(Code generation) ─────────── -code_generation: - docstrings: - language: ko-KR - path_instructions: - - path: backend/** - instructions: | - - JavaDoc 공식 형식으로, 한글로 Docstring을 작성해주세요. - - 메서드 목적, 파라미터, 반환값, 예외 정보를 명확하게 기술해 주세요. - - 외부 API 등 공개 메서드는 상세히, 내부용은 핵심만 요약해 주세요. - - - path: android/** - instructions: | - - 모든 public 함수에 대해 KDoc 양식을 따라 한글로 간결하게 Docstring을 작성해주세요. - - 함수 목적, 파라미터, 반환값, 예외를 명확하게 기술해 주세요. - - 샘플 코드/사용 예시는 필요한 경우에만 포함해 주세요. - - unit_tests: - path_instructions: - - path: backend/** - instructions: | - - Controller는 인수테스트(API 엔드포인트 통합 테스트) 나머지 영역은 함수/클래스 단위의 단위 테스트 - - Given-When-Then 패턴을 적용 - -# ─────────── 코드 분석 도구(Tools) ─────────── -tools: - hadolint: - enabled: true - gitleaks: - enabled: true - sqlfluff: - enabled: true - oxc: - enabled: true diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index cddec7f9..a6a40071 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -2,20 +2,17 @@ name: Android CI on: pull_request: - # 'main' 또는 'android' 브랜치로 PR이 병합될 때 트리거 branches: - main - develop - # 'android' 디렉토리 내의 파일이 변경되었을 때만 워크플로우를 실행 - path: - - '/**' jobs: Run-PR-Test: runs-on: ubuntu-latest - defaults: - run: - working-directory: android + + permissions: + contents: write + checks: write steps: - name: Repository Checkout @@ -34,7 +31,7 @@ jobs: ~/.gradle/caches ~/.gradle/wrapper ~/.android/build-cache - key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/build.gradle.kts', 'android/**/settings.gradle.kts', 'android/**/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/gradle.properties') }} + key: ${{ runner.os }}-gradle-${{ hashFiles('**/build.gradle.kts', '**/settings.gradle.kts', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml', '**/gradle.properties') }} restore-keys: | ${{ runner.os }}-gradle- @@ -52,8 +49,7 @@ jobs: - name: Restore keystore file run: | - mkdir -p app - echo "$KEYSTORE_BASE64" | base64 --decode > ./app/festabook_appkey.jks + echo "$KEYSTORE_BASE64" | base64 --decode > app/festabook_appkey.jks echo "JKS_FILE_PATH=./app/festabook_appkey.jks" >> local.properties echo "STORE_PASSWORD=${{ secrets.STORE_PASSWORD }}" >> local.properties echo "KEY_ALIAS=${{ secrets.KEY_ALIAS }}" >> local.properties @@ -62,20 +58,22 @@ jobs: env: KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} - - name: Clean Project - run: ./gradlew clean - - name: Run ktlint run: ./gradlew ktlintCheck - - - name: Run Unit Test - run: ./gradlew test + + - name: Run Debug Unit Test (for Develop PRs) + if: github.base_ref == 'develop' + run: ./gradlew testDebugUnitTest + + - name: Run Release Unit Test (for Main PRs) + if: github.base_ref == 'main' + run: ./gradlew testReleaseUnitTest - name: Publish Unit Test Results if: always() uses: EnricoMi/publish-unit-test-result-action@v2 with: - files: android/app/build/test-results/**/TEST-*.xml + files: app/build/test-results/**/TEST-*.xml check_name: '테스트 결과 🛠️' check_run_annotations: 'none' comment_mode: 'off' diff --git a/.github/workflows/android-release-drafter.yml b/.github/workflows/android-release-drafter.yml new file mode 100644 index 00000000..b0c9b596 --- /dev/null +++ b/.github/workflows/android-release-drafter.yml @@ -0,0 +1,121 @@ +name: Android CD + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + Upload-Release-Bundle: + runs-on: ubuntu-latest + outputs: + version_code: ${{ steps.get_version.outputs.version_code }} + + steps: + - name: Repository Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Gradle cache + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + ~/.android/build-cache + key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/build.gradle.kts', 'android/**/settings.gradle.kts', 'android/**/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/gradle.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Create local.properties with BASE_URL + run: | + echo BASE_URL=\"${{ secrets.BASE_URL }}\" > local.properties + echo BASE_URL_DEV=\"${{ secrets.BASE_URL_DEV }}\" >> local.properties + echo NAVER_MAP_CLIENT_ID=\"${{ secrets.NAVER_MAP_CLIENT_ID }}\" >> local.properties + echo NAVER_MAP_STYLE_ID=\"${{ secrets.NAVER_MAP_STYLE_ID }}\" >> local.properties + + - name: Load Google Service file + env: + DATA: ${{ secrets.GOOGLE_SERVICES_JSON }} + run: echo "$DATA" > app/google-services.json + + - name: Restore keystore file + run: | + echo "$KEYSTORE_BASE64" | base64 --decode > app/festabook_appkey.jks + echo "JKS_FILE_PATH=./festabook_appkey.jks" >> local.properties + echo "STORE_PASSWORD=${{ secrets.STORE_PASSWORD }}" >> local.properties + echo "KEY_ALIAS=${{ secrets.KEY_ALIAS }}" >> local.properties + echo "KEY_PASSWORD=${{ secrets.KEY_PASSWORD }}" >> local.properties + + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + + - name: Build Release AAB + run: ./gradlew app:bundleRelease + + #versionName의 숫자만 추출합니다 ex) versionName = "v1.2.1" -> 1.2.1 + - name: Extract Version Code + id: get_version + run: | + VERSION_NAME=$(grep -oP 'versionName = "\K[^"]+' app/build.gradle.kts | head -1 | sed 's/[v ]//g') + echo "version_code=$VERSION_NAME" >> $GITHUB_OUTPUT + echo "Extracted Version: $VERSION_NAME" + + - name: Upload Release Artifact + uses: actions/upload-artifact@v4 + with: + name: app-release-bundle-${{ steps.get_version.outputs.version_code }} + path: app/build/outputs/bundle/release/app-release.aab + retention-days: 7 + + #번들이 업로드가 완료된 후 버전 태그를 작성합니다 + Release-Drafter: + runs-on: ubuntu-latest + needs: Upload-Release-Bundle + + steps: + - name: Repository Checkout + uses: actions/checkout@v4 + + #추출한 버전을 drafter에 적용합니다 + - name: Update Draft Release + uses: release-drafter/release-drafter@v6 + with: + version: ${{ needs.Upload-Release-Bundle.outputs.version_code }} + config-name: template/release-drafter.yml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # hotfix 머지 시, main의 변경사항을 develop에 자동 반영합니다 + # 번들이 업로드가 완료된 후 실행됩니다 + Reflect-Changes-Into-Develop: + runs-on: ubuntu-latest + needs: Upload-Release-Bundle + + steps: + - name: Develop Branch Checkout + uses: actions/checkout@v4 + with: + ref: develop + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Git User + run: | + git config user.name "GitHub Actions Bot" + git config user.email "actions@github.com" + + - name: Merge Main into Develop + run: | + git fetch origin main + git merge origin/main + git push origin develop \ No newline at end of file diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml deleted file mode 100644 index 6e2d19df..00000000 --- a/.github/workflows/release-drafter.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Release Drafter - -on: - push: - branches: - - release/* - - main - -permissions: - contents: write - pull-requests: write - -jobs: - update_release_draft: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Update Draft Release - if: startsWith(github.ref, 'refs/heads/release/') - uses: release-drafter/release-drafter@v6 - with: - config-name: template/release-drafter.yml - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Publish Release - if: github.ref == 'refs/heads/main' - id: drafter - uses: release-drafter/release-drafter@v6 - with: - config-name: template/release-drafter.yml - publish: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dac82a61..51201f5a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.serialization) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ktlint) id("kotlin-kapt") id("kotlin-parcelize") id("com.google.gms.google-services") From 41295e0a9474fec7bcce19f5bd1f4b644666e404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Mon, 1 Dec 2025 20:11:53 +0900 Subject: [PATCH 025/140] =?UTF-8?q?chore:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/schedule/OnBookmarkCheckedListener.kt | 5 ----- .../presentation/schedule/adapter/ScheduleAdapter.kt | 1 - 2 files changed, 6 deletions(-) delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/schedule/OnBookmarkCheckedListener.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/OnBookmarkCheckedListener.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/OnBookmarkCheckedListener.kt deleted file mode 100644 index a6e1e0eb..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/OnBookmarkCheckedListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.daedan.festabook.presentation.schedule - -fun interface OnBookmarkCheckedListener { - fun onBookmarkChecked(scheduleEventId: Long) -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleAdapter.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleAdapter.kt index c1e37207..50d6dd39 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleAdapter.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleAdapter.kt @@ -3,7 +3,6 @@ package com.daedan.festabook.presentation.schedule.adapter import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter -import com.daedan.festabook.presentation.schedule.OnBookmarkCheckedListener import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel class ScheduleAdapter : ListAdapter(DIFF_UTIL) { From bca707619f03cfc7a44e74caec8cb6056cb4795b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Mon, 1 Dec 2025 20:59:35 +0900 Subject: [PATCH 026/140] =?UTF-8?q?refactor:=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20Scaffold?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{Header.kt => FestabookTopAppBar.kt} | 6 +- .../presentation/news/component/NewsScreen.kt | 58 ++++++++++--------- 2 files changed, 34 insertions(+), 30 deletions(-) rename app/src/main/java/com/daedan/festabook/presentation/common/component/{Header.kt => FestabookTopAppBar.kt} (90%) diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/Header.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt similarity index 90% rename from app/src/main/java/com/daedan/festabook/presentation/common/component/Header.kt rename to app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt index 1efcadd8..cd09357f 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/component/Header.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt @@ -11,7 +11,7 @@ import com.daedan.festabook.presentation.theme.FestabookTypography import com.daedan.festabook.presentation.theme.festabookSpacing @Composable -fun Header( +fun FestabookTopAppBar( title: String, modifier: Modifier = Modifier, style: TextStyle = FestabookTypography.displayLarge, @@ -32,6 +32,6 @@ fun Header( @Composable @Preview(showBackground = true) -private fun HeaderPreview() { - Header(title = "FestaBook") +private fun FestabookTopAppBarPreview() { + FestabookTopAppBar(title = "FestaBook") } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt index 728f9ee0..c9b00918 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt @@ -1,9 +1,9 @@ package com.daedan.festabook.presentation.news.component -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -12,7 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.R -import com.daedan.festabook.presentation.common.component.Header +import com.daedan.festabook.presentation.common.component.FestabookTopAppBar import com.daedan.festabook.presentation.news.NewsTab import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.lost.LostUiState @@ -38,29 +38,33 @@ fun NewsScreen( pageState.animateScrollToPage(NewsTab.NOTICE.ordinal) } } - - Column(modifier = modifier.background(color = MaterialTheme.colorScheme.background)) { - Header(title = stringResource(R.string.news_title)) - NewsTabRow(pageState, scope) - NewsTabPage( - pageState = pageState, - noticeUiState = noticeUiState, - faqUiState = faqUiState, - lostUiState = lostUiState, - isNoticeRefreshing = isNoticeRefreshing, - isLostItemRefreshing = isLostItemRefreshing, - onNoticeRefresh = { - val oldNotices = - (noticeUiState as? NoticeUiState.Success)?.notices ?: emptyList() - newsViewModel.loadAllNotices(NoticeUiState.Refreshing(oldNotices)) - }, - onLostItemRefresh = { - val oldLostItems = (lostUiState as? LostUiState.Success)?.lostItems ?: emptyList() - newsViewModel.loadAllLostItems(LostUiState.Refreshing(oldLostItems)) - }, - onNoticeClick = { newsViewModel.toggleNotice(it) }, - onFaqClick = { newsViewModel.toggleFAQ(it) }, - onLostGuideClick = { newsViewModel.toggleLostGuide() }, - ) + Scaffold( + topBar = { FestabookTopAppBar(title = stringResource(R.string.news_title)) }, + modifier = modifier, + ) { innerPadding -> + Column(modifier = modifier.padding(paddingValues = innerPadding)) { + NewsTabRow(pageState, scope) + NewsTabPage( + pageState = pageState, + noticeUiState = noticeUiState, + faqUiState = faqUiState, + lostUiState = lostUiState, + isNoticeRefreshing = isNoticeRefreshing, + isLostItemRefreshing = isLostItemRefreshing, + onNoticeRefresh = { + val oldNotices = + (noticeUiState as? NoticeUiState.Success)?.notices ?: emptyList() + newsViewModel.loadAllNotices(NoticeUiState.Refreshing(oldNotices)) + }, + onLostItemRefresh = { + val oldLostItems = + (lostUiState as? LostUiState.Success)?.lostItems ?: emptyList() + newsViewModel.loadAllLostItems(LostUiState.Refreshing(oldLostItems)) + }, + onNoticeClick = { newsViewModel.toggleNotice(it) }, + onFaqClick = { newsViewModel.toggleFAQ(it) }, + onLostGuideClick = { newsViewModel.toggleLostGuide() }, + ) + } } } From a61fa9accba95fcace5f0024246057dbad8014b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Mon, 1 Dec 2025 20:59:56 +0900 Subject: [PATCH 027/140] =?UTF-8?q?style:=20=EC=BD=94=EB=93=9C=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../festabook/presentation/schedule/ScheduleFragment.kt | 9 +++++---- .../festabook/presentation/schedule/ScheduleViewModel.kt | 5 +---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt index 5acec93e..a19f1810 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt @@ -15,7 +15,6 @@ import com.daedan.festabook.logging.model.schedule.ScheduleMenuItemReClickLogDat import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.common.OnMenuItemReClickListener import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.placeDetail.PlaceDetailViewModel import com.daedan.festabook.presentation.schedule.adapter.SchedulePagerAdapter import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator @@ -42,9 +41,11 @@ class ScheduleFragment : SchedulePagerAdapter(this) } - private val viewModel: ScheduleViewModel by viewModels { ScheduleViewModel.factory( - viewModelFactory - ) } + private val viewModel: ScheduleViewModel by viewModels { + ScheduleViewModel.factory( + viewModelFactory, + ) + } override fun onViewCreated( view: View, diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt index 2495957a..3f969afb 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt @@ -4,18 +4,15 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory -import com.daedan.festabook.FestaBookApp import com.daedan.festabook.domain.repository.ScheduleRepository import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus import com.daedan.festabook.presentation.schedule.model.toUiModel import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject -import dev.zacsweers.metro.Inject import kotlinx.coroutines.launch import java.time.LocalDate @@ -96,7 +93,7 @@ class ScheduleViewModel @AssistedInject constructor( fun factory( factory: Factory, - dateId: Long = INVALID_ID + dateId: Long = INVALID_ID, ): ViewModelProvider.Factory = viewModelFactory { initializer { From 507b68f84cb8616cba3d7fe8f8a476b7671c10b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Mon, 1 Dec 2025 21:10:04 +0900 Subject: [PATCH 028/140] =?UTF-8?q?fix:=20NewsScreen=20Modifier=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20NewsTabRo?= =?UTF-8?q?wPreview=20=ED=85=8C=EB=A7=88=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/news/component/NewsScreen.kt | 2 +- .../presentation/news/component/NewsTabRow.kt | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt index c9b00918..25b65295 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt @@ -42,7 +42,7 @@ fun NewsScreen( topBar = { FestabookTopAppBar(title = stringResource(R.string.news_title)) }, modifier = modifier, ) { innerPadding -> - Column(modifier = modifier.padding(paddingValues = innerPadding)) { + Column(modifier = Modifier.padding(paddingValues = innerPadding)) { NewsTabRow(pageState, scope) NewsTabPage( pageState = pageState, diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt index a8bc11b8..8e09451f 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.daedan.festabook.presentation.news.NewsTab import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -51,8 +52,10 @@ fun NewsTabRow( @Composable @Preview private fun NewsTabRowPreview() { - NewsTabRow( - pageState = rememberPagerState { 3 }, - scope = rememberCoroutineScope(), - ) + FestabookTheme { + NewsTabRow( + pageState = rememberPagerState { 3 }, + scope = rememberCoroutineScope(), + ) + } } From e454246e3795bf9f60a8dea96a42f2494e479747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Tue, 2 Dec 2025 23:45:24 +0900 Subject: [PATCH 029/140] =?UTF-8?q?refactor:=20StateFlow=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/schedule/ScheduleViewModel.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt index 3f969afb..a35bf674 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt @@ -1,7 +1,5 @@ package com.daedan.festabook.presentation.schedule -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -13,6 +11,9 @@ import com.daedan.festabook.presentation.schedule.model.toUiModel import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import java.time.LocalDate @@ -25,13 +26,14 @@ class ScheduleViewModel @AssistedInject constructor( fun create(dateId: Long): ScheduleViewModel } - private val _scheduleEventsUiState: MutableLiveData = - MutableLiveData() - val scheduleEventsUiState: LiveData get() = _scheduleEventsUiState + private val _scheduleEventsUiState: MutableStateFlow = + MutableStateFlow(ScheduleEventsUiState.Loading) + val scheduleEventsUiState: StateFlow = + _scheduleEventsUiState.asStateFlow() - private val _scheduleDatesUiState: MutableLiveData = - MutableLiveData() - val scheduleDatesUiState: LiveData get() = _scheduleDatesUiState + private val _scheduleDatesUiState: MutableStateFlow = + MutableStateFlow(ScheduleDatesUiState.Loading) + val scheduleDatesUiState: StateFlow = _scheduleDatesUiState.asStateFlow() init { loadAllDates() From 2127ad4d3db221d864648a8a9f881cefa4554853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Tue, 2 Dec 2025 23:45:50 +0900 Subject: [PATCH 030/140] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20ComposeUI=20=EA=B8=B0=EC=B4=88=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/component/ScheduleScreen.kt | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt new file mode 100644 index 00000000..66548be7 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt @@ -0,0 +1,92 @@ +package com.daedan.festabook.presentation.schedule.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.component.FestabookTopAppBar +import com.daedan.festabook.presentation.schedule.ScheduleDatesUiState +import com.daedan.festabook.presentation.schedule.ScheduleViewModel +import com.daedan.festabook.presentation.schedule.model.ScheduleDateUiModel +import kotlinx.coroutines.CoroutineScope + +@Composable +fun ScheduleScreen( + scheduleViewModel: ScheduleViewModel, + modifier: Modifier = Modifier, +) { + val scheduleDatesUiState by scheduleViewModel.scheduleDatesUiState.collectAsStateWithLifecycle() + val scheduleEventsUiState by scheduleViewModel.scheduleEventsUiState.collectAsStateWithLifecycle() + + Scaffold( + topBar = { FestabookTopAppBar(title = stringResource(R.string.schedule_title)) }, + modifier = modifier, + ) { innerPadding -> + + when (scheduleDatesUiState) { + is ScheduleDatesUiState.Error -> { + TODO() + } + + ScheduleDatesUiState.Loading -> { + TODO() + } + + is ScheduleDatesUiState.Success -> { + val scheduleDates = (scheduleDatesUiState as ScheduleDatesUiState.Success).dates + val pageState = rememberPagerState { scheduleDates.size } + val scope = rememberCoroutineScope() + + Column(modifier = Modifier.padding(innerPadding)) { + ScheduleTabRow( + pageState = pageState, + scope = scope, + scheduleDates = scheduleDates, + ) + } + } + } + } +} + +@Composable +fun ScheduleTabRow( + pageState: PagerState, + scope: CoroutineScope, + scheduleDates: List, + modifier: Modifier = Modifier, +) { + TabRow(selectedTabIndex = pageState.currentPage, modifier = modifier) { + scheduleDates.forEachIndexed { index, scheduleDate -> + } + } +} + +@Composable +fun ScheduleTabItem( + scheduleDate: ScheduleDateUiModel, + modifier: Modifier = Modifier, +) { + Tab( + selected = TODO(), + onClick = TODO(), + text = { Text(text = scheduleDate.date) }, + ) +} + +// @Composable +// @Preview +// private fun ScheduleScreenPreview() { +// ScheduleScreen() +// } From fb6e92ee17a1f305f813234aa6b211cffb41ed7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 3 Dec 2025 20:27:27 +0900 Subject: [PATCH 031/140] =?UTF-8?q?refactor:=20NewsTabRow=20=EC=BB=A8?= =?UTF-8?q?=ED=85=90=EC=B8=A0=20=EC=83=89=EC=83=81=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../daedan/festabook/presentation/news/component/NewsTabRow.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt index 8e09451f..574d1447 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt @@ -28,7 +28,6 @@ fun NewsTabRow( TabRow( selectedTabIndex = pageState.currentPage, containerColor = MaterialTheme.colorScheme.background, - contentColor = FestabookColor.black, indicator = { tabPositions -> TabRowDefaults.PrimaryIndicator( color = FestabookColor.black, @@ -42,6 +41,7 @@ fun NewsTabRow( Tab( selected = pageState.currentPage == index, unselectedContentColor = FestabookColor.gray500, + selectedContentColor = FestabookColor.black, onClick = { scope.launch { pageState.animateScrollToPage(index) } }, text = { Text(text = stringResource(title.tabNameRes)) }, ) From c95f70a11c47f61a0d4240340d06b42aee2a37b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 3 Dec 2025 20:27:48 +0900 Subject: [PATCH 032/140] =?UTF-8?q?refactor:=20=EB=84=A4=EC=9D=B4=EB=B0=8D?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../festabook/presentation/schedule/ScheduleDatesUiState.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleDatesUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleDatesUiState.kt index 679e7e8b..37a5d782 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleDatesUiState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleDatesUiState.kt @@ -3,7 +3,7 @@ package com.daedan.festabook.presentation.schedule import com.daedan.festabook.presentation.schedule.model.ScheduleDateUiModel sealed interface ScheduleDatesUiState { - data object Loading : ScheduleDatesUiState + data object InitialLoading : ScheduleDatesUiState data class Success( val dates: List, From 6ea5b4f5a0182b7850cab985ae42eebb3289e1bc Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Wed, 3 Dec 2025 20:43:37 +0900 Subject: [PATCH 033/140] =?UTF-8?q?[Feat]=20TimeTagSpinner=20Compose=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(placeMap): 시간대 선택을 위한 TimeTagMenu 컴포저블 추가 지도 화면에서 사용될 시간대 선택 드롭다운 메뉴인 `TimeTagMenu` 컴포저블을 새로 추가했습니다. - **주요 기능:** - `ExposedDropdownMenuBox`를 사용하여 커스텀 드롭다운 메뉴를 구현했습니다. - 사용자가 시간 태그(`TimeTag`)를 선택하면 `onTimeTagClick` 콜백을 통해 선택된 값을 전달합니다. - 선택된 `TimeTag`의 이름(`title`)을 버튼 텍스트로 표시합니다. - 메뉴 아이템 클릭 시, 리플(ripple) 효과가 끝난 후 메뉴가 닫히도록 `waitForRipple` 유틸리티 함수를 사용했습니다. - **컴포저블 구성:** - `TimeTagMenu`: 드롭다운 메뉴의 전체적인 레이아웃과 상태를 관리합니다. - `TimeTagButton`: 드롭다운을 열고 현재 선택된 시간대를 표시하는 버튼입니다. - `@Preview`를 포함하여 컴포저블의 시각적 확인이 가능하도록 했습니다. * refactor(PlaceMap): 코드 포맷팅 적용 `PlaceMapFragment`의 `ComposeView` 내부 Modifier 체이닝에서 `background` 함수의 괄호 위치를 수정하여 코드 포맷을 정리했습니다. * refactor(TimeTagMenu): 상태 관리 로직을 `TimeTagMenu` 내부로 이전 `TimeTagMenu` 컴포저블의 상태 관리 방식을 리팩토링했습니다. 기존에는 외부 컴포저블(`PlaceMapFragment`)에서 `title` 상태를 관리하고 `TimeTagMenu`에 전달했지만, 이제 `TimeTagMenu`가 내부적으로 `title` 상태를 관리하도록 변경했습니다. - **`TimeTagMenu.kt` 수정:** - `title` 파라미터를 `initialTitle`로 변경하여 초기값만 받도록 수정했습니다. - `remember { mutableStateOf(initialTitle) }`를 사용하여 `title` 상태를 내부적으로 관리합니다. - 메뉴 아이템 클릭 시, `onTimeTagClick` 콜백을 호출하기 전에 내부 `title` 상태를 먼저 업데이트하도록 로직을 변경했습니다. - **`PlaceMapFragment.kt` 수정:** - `TimeTagMenu`를 호출하는 부분에서 불필요해진 `title` 상태 관리 로직(`var title by remember ...`)을 제거했습니다. - `onTimeTagClick` 람다를 단순화하여 선택된 `timeTag`를 `onTimeTagSelected` 함수에 바로 전달하도록 수정했습니다. * refactor(PlaceMap): ComposeView ID 및 관련 코드 리팩토링 지도 화면(`PlaceMapFragment`)에서 시간 태그 메뉴를 표시하는 `ComposeView`의 ID를 `cv_timeTag`에서 `cv_place_map`으로 변경하고, 관련 코드 구조를 개선했습니다. - **`fragment_place_map.xml` 수정:** - `ComposeView`의 ID를 `cv_timeTag`에서 `cv_place_map`으로 변경하여 이름의 범용성을 높였습니다. - `FragmentContainerView`의 제약 조건이 새 ID를 참조하도록 업데이트했습니다. - **`PlaceMapFragment.kt` 리팩토링:** - `setUpObserver()` 메서드에 있던 `ComposeView` 설정 로직을 `setupComposeView()`라는 별도의 메서드로 분리했습니다. - `onCreateView()`에서 `setupComposeView()`를 호출하도록 추가하여 코드의 역할과 가독성을 개선했습니다. - 변경된 `ComposeView` ID(`cv_place_map`)를 참조하도록 코드를 수정했습니다. * refactor(PlaceMap): TimeTagSpinnerAdapter 삭제 `TimeTag` 선택 메뉴가 Compose `DropdownMenu`로 교체됨에 따라, 더 이상 사용되지 않는 기존의 `TimeTagSpinnerAdapter` 파일을 삭제했습니다. 이 클래스는 `Spinner`를 커스터마이징하는 데 사용되었습니다. * refactor(PlaceMap): 지도 화면 컴포저블에 FestabookTheme 적용 및 리팩토링 지도 화면(`PlaceMapFragment`)과 시간대 선택 메뉴(`TimeTagMenu`) 컴포저블에 `FestabookTheme`를 적용하고, 디자인 시스템에 정의된 값을 사용하도록 코드를 리팩토링했습니다. - **`PlaceMapFragment.kt`** - `cvPlaceMap`의 `setContent` 블록 전체를 `FestabookTheme`로 감싸 일관된 테마를 적용했습니다. - **`TimeTagMenu.kt`** - `DropdownMenu`의 테두리 모양(`shape`)과 `DropdownMenuItem`의 텍스트 스타일(`style`)에 `festabookShapes`와 `FestabookTypography`를 사용하도록 수정했습니다. - 여백과 간격 등 하드코딩된 `dp` 값을 `festabookSpacing`에 정의된 값으로 대체했습니다. - `@Preview`에서도 `FestabookTheme`를 적용하여 실제 앱 환경과 동일한 디자인을 확인할 수 있도록 개선했습니다. * refactor(PlaceMap): title의 상태를 viewModel의 selectedTimeTag외 동기화 기존에 `Fragment`에서 콜백 인터페이스(`OnTimeTagSelectedListener`)를 통해 처리하던 시간대(`TimeTag`) 선택 로직을 `ViewModel`이 직접 상태를 관리하고 Compose UI가 이를 구독하는 단방향 데이터 흐름(UDF) 방식으로 리팩토링했습니다. - **`PlaceMapViewModel.kt` 수정:** - `LiveData`로 관리되던 `selectedTimeTag`를 `StateFlow`(`selectedTimeTagFlow`)로 변환하여 Compose 환경에서 더 효율적으로 상태를 관찰할 수 있도록 했습니다. - 시간대 선택 시 장소 선택을 해제하는 로직(`unselectPlace()`)을 `onDaySelected` 메서드 내부로 이동시켜 관련 로직을 통합했습니다. - **`PlaceMapFragment.kt` 리팩토링:** - `OnTimeTagSelectedListener` 인터페이스와 관련 콜백 메서드(`onTimeTagSelected`, `onNothingSelected`)를 제거했습니다. - `TimeTagMenu` 컴포저블의 `onTimeTagClick` 람다 내에서 `ViewModel`의 `onDaySelected`를 직접 호출하도록 변경하여 `Fragment`의 역할을 줄였습니다. - `ViewModel`의 `StateFlow`를 구독하여 `TimeTagMenu`의 제목(`title`)을 동적으로 업데이트하도록 개선했습니다. - **`TimeTagMenu.kt` 수정:** - `initialTitle` 파라미터를 `title`로 변경하고, 컴포저블 내부에서 `title`을 관리하던 `remember` 상태를 제거했습니다. - 이제 `TimeTagMenu`는 외부에서 전달받은 `title`을 그대로 표시하는 상태 비저장(Stateless) 컴포저블로 변경되었습니다. * refactor(PlaceMap): cardBackground Modifier 사용, 및 typhography 사용 시간대 선택 메뉴(`TimeTagMenu.kt`)의 코드를 리팩토링하고 디자인 시스템을 적용하여 재사용성을 높이고 일관성을 개선했습니다. - **`TimeTagMenu.kt` 수정:** - `cardBackground` 커스텀 Modifier를 사용하여 배경색, 테두리, 모양을 한 번에 설정하도록 코드를 간소화했습니다. - 기존 `FestabookTypography` 대신 `MaterialTheme.typography`를 사용하도록 변경하여 테마 시스템과의 일관성을 강화했습니다. - 하드코딩된 아이콘의 `contentDescription`을 `stringResource`를 사용하도록 수정하여 다국어 지원 및 접근성을 개선했습니다. - 더 이상 사용하지 않는 `border` import를 제거했습니다. --- .../presentation/placeMap/PlaceMapFragment.kt | 90 ++++----- .../placeMap/PlaceMapViewModel.kt | 26 ++- .../adapter/TimeTagSpinnerAdapter.kt | 72 ------- .../timeTagSpinner/component/TimeTagMenu.kt | 184 ++++++++++++++++++ .../main/res/layout/fragment_place_map.xml | 35 +--- 5 files changed, 253 insertions(+), 154 deletions(-) delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/adapter/TimeTagSpinnerAdapter.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/component/TimeTagMenu.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt index a80d71ab..773b6421 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt @@ -3,18 +3,23 @@ package com.daedan.festabook.presentation.placeMap import android.content.Context import android.os.Bundle import android.view.View -import android.widget.AdapterView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.commit import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentPlaceMapBinding import com.daedan.festabook.di.fragment.FragmentKey import com.daedan.festabook.di.mapManager.MapManagerGraph -import com.daedan.festabook.domain.model.TimeTag import com.daedan.festabook.logging.logger import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.common.OnMenuItemReClickListener @@ -31,7 +36,9 @@ import com.daedan.festabook.presentation.placeMap.placeCategory.PlaceCategoryFra import com.daedan.festabook.presentation.placeMap.placeDetailPreview.PlaceDetailPreviewFragment import com.daedan.festabook.presentation.placeMap.placeDetailPreview.PlaceDetailPreviewSecondaryFragment import com.daedan.festabook.presentation.placeMap.placeList.PlaceListFragment -import com.daedan.festabook.presentation.placeMap.timeTagSpinner.adapter.TimeTagSpinnerAdapter +import com.daedan.festabook.presentation.placeMap.timeTagSpinner.component.TimeTagMenu +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme import com.naver.maps.map.MapFragment import com.naver.maps.map.NaverMap import com.naver.maps.map.OnMapReadyCallback @@ -57,8 +64,7 @@ class PlaceMapFragment( placeDetailPreviewSecondaryFragment: PlaceDetailPreviewSecondaryFragment, mapFragment: MapFragment, ) : BaseFragment(), - OnMenuItemReClickListener, - OnTimeTagSelectedListener { + OnMenuItemReClickListener { override val layoutId: Int = R.layout.fragment_place_map @Inject @@ -88,23 +94,6 @@ class PlaceMapFragment( savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - binding.spinnerSelectTimeTag.onItemSelectedListener = - object : AdapterView.OnItemSelectedListener { - override fun onItemSelected( - parent: AdapterView<*>, - view: View?, - position: Int, - id: Long, - ) { - val item = parent.getItemAtPosition(position) as TimeTag - - onTimeTagSelected(item) - } - - override fun onNothingSelected(parent: AdapterView<*>) { - onNothingSelected() - } - } if (savedInstanceState == null) { childFragmentManager.commit { addWithSimpleTag(R.id.fcv_map_container, mapFragment) @@ -118,6 +107,7 @@ class PlaceMapFragment( } lifecycleScope.launch { setUpMapManager() + setupComposeView() setUpObserver() } binding.logger.log( @@ -140,19 +130,6 @@ class PlaceMapFragment( mapManager?.moveToPosition() } - override fun onTimeTagSelected(item: TimeTag) { - viewModel.unselectPlace() - viewModel.onDaySelected(item) - binding.logger.log( - PlaceTimeTagSelected( - baseLogData = binding.logger.getBaseLogData(), - timeTagName = item.name, - ), - ) - } - - override fun onNothingSelected() = Unit - private suspend fun setUpMapManager() { naverMap = mapFragment.getMap() naverMap.addOnLocationChangeListener { @@ -169,22 +146,39 @@ class PlaceMapFragment( } } - private fun setUpObserver() { - viewModel.timeTags.observe(viewLifecycleOwner) { timeTags -> - // 타임태그가 없는 경우 메뉴 GONE - binding.layoutMapMenu.visibility = - if (timeTags.isNullOrEmpty()) View.GONE else View.VISIBLE - - if (binding.spinnerSelectTimeTag.adapter == null) { - val adapter = TimeTagSpinnerAdapter(requireContext(), timeTags.toMutableList()) - binding.spinnerSelectTimeTag.adapter = adapter - } else { - val adapter = binding.spinnerSelectTimeTag.adapter as TimeTagSpinnerAdapter - adapter.updateItems(timeTags) - adapter.notifyDataSetChanged() + private fun setupComposeView() { + binding.cvPlaceMap.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + FestabookTheme { + val timeTags by viewModel.timeTags.collectAsStateWithLifecycle() + val title by viewModel.selectedTimeTagFlow.collectAsStateWithLifecycle() + if (timeTags.isNotEmpty()) { + TimeTagMenu( + title = title.name, + timeTags = timeTags, + onTimeTagClick = { timeTag -> + viewModel.onDaySelected(timeTag) + binding.logger.log( + PlaceTimeTagSelected( + baseLogData = binding.logger.getBaseLogData(), + timeTagName = timeTag.name, + ), + ) + }, + modifier = + Modifier + .background( + FestabookColor.white, + ).padding(horizontal = 24.dp), + ) + } + } } } + } + private fun setUpObserver() { viewModel.placeGeographies.observe(viewLifecycleOwner) { placeGeographies -> when (placeGeographies) { is PlaceListUiState.Loading -> Unit diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt index 7836f60f..0cf4241e 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt @@ -3,6 +3,7 @@ package com.daedan.festabook.presentation.placeMap import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey import com.daedan.festabook.domain.model.TimeTag @@ -21,6 +22,11 @@ import com.daedan.festabook.presentation.placeMap.model.toUiModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @ContributesIntoMap(AppScope::class) @@ -38,12 +44,19 @@ class PlaceMapViewModel @Inject constructor( val placeGeographies: LiveData>> get() = _placeGeographies - private val _timeTags = MutableLiveData>() - val timeTags: LiveData> = _timeTags + private val _timeTags = MutableStateFlow>(emptyList()) + val timeTags: StateFlow> = _timeTags.asStateFlow() private val _selectedTimeTag = MutableLiveData() val selectedTimeTag: LiveData = _selectedTimeTag + // 임시 StateFlow + val selectedTimeTagFlow: StateFlow = + _selectedTimeTag.asFlow().stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = TimeTag.EMPTY, + ) private val _selectedPlace: MutableLiveData = MutableLiveData() val selectedPlace: LiveData = _selectedPlace @@ -77,16 +90,17 @@ class PlaceMapViewModel @Inject constructor( _timeTags.value = emptyList() } - // 기본 선택값 - if (!timeTags.value.isNullOrEmpty()) { - _selectedTimeTag.value = _timeTags.value?.first() + // 기본 선택값 + if (!timeTags.value.isEmpty()) { + _selectedTimeTag.value = _timeTags.value.first() } else { - _selectedTimeTag.value = TimeTag.Companion.EMPTY + _selectedTimeTag.value = TimeTag.EMPTY } } } fun onDaySelected(item: TimeTag) { + unselectPlace() _selectedTimeTag.value = item } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/adapter/TimeTagSpinnerAdapter.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/adapter/TimeTagSpinnerAdapter.kt deleted file mode 100644 index 2d214153..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/adapter/TimeTagSpinnerAdapter.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.timeTagSpinner.adapter - -import android.content.Context -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import com.daedan.festabook.databinding.ItemSpinnerDropdownBinding -import com.daedan.festabook.databinding.ItemSpinnerSelectedBinding -import com.daedan.festabook.domain.model.TimeTag - -class TimeTagSpinnerAdapter( - context: Context, - private val items: MutableList, -) : ArrayAdapter(context, 0, items) { - override fun getView( - position: Int, - convertView: View?, - parent: ViewGroup, - ): View { - val binding: ItemSpinnerSelectedBinding - val view: View - - if (convertView == null) { - binding = - ItemSpinnerSelectedBinding.inflate( - LayoutInflater.from(context), - parent, - false, - ) - view = binding.root - view.tag = binding - } else { - view = convertView - binding = view.tag as ItemSpinnerSelectedBinding - } - - binding.tvSelectedItem.text = items[position].name - return view - } - - override fun getDropDownView( - position: Int, - convertView: View?, - parent: ViewGroup, - ): View { - val binding: ItemSpinnerDropdownBinding - val view: View - - if (convertView == null) { - binding = - ItemSpinnerDropdownBinding.inflate( - LayoutInflater.from(context), - parent, - false, - ) - view = binding.root - view.tag = binding - } else { - view = convertView - binding = view.tag as ItemSpinnerDropdownBinding - } - - binding.tvDropdownItem.text = items[position].name - return view - } - - fun updateItems(newItems: List) { - items.clear() - items.addAll(newItems) - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/component/TimeTagMenu.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/component/TimeTagMenu.kt new file mode 100644 index 00000000..657db70c --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/component/TimeTagMenu.kt @@ -0,0 +1,184 @@ +package com.daedan.festabook.presentation.placeMap.timeTagSpinner.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuBoxScope +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.presentation.common.component.cardBackground +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TimeTagMenu( + title: String, + timeTags: List, + modifier: Modifier = Modifier, + onTimeTagClick: (TimeTag) -> Unit = {}, +) { + var expanded by remember { mutableStateOf(false) } + var dropdownWidth by remember { mutableStateOf(IntSize.Zero) } + val density = LocalDensity.current + val scope = rememberCoroutineScope() + + Row( + modifier = modifier.fillMaxWidth(), + ) { + ExposedDropdownMenuBox( + modifier = + Modifier + .wrapContentSize() + .background(Color.Transparent), + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + ) { + TimeTagButton( + title = title, + onSizeDetermined = { dropdownWidth = it }, + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + offset = DpOffset(x = 0.dp, y = festabookSpacing.paddingBody2), + modifier = + Modifier + .width( + with(density) { dropdownWidth.width.toDp() }, + ).cardBackground( + backgroundColor = FestabookColor.white, + borderStroke = 2.dp, + borderColor = FestabookColor.gray300, + shape = festabookShapes.radius2, + ), + shape = festabookShapes.radius2, + ) { + timeTags.forEach { item -> + DropdownMenuItem( + text = { + Text( + text = item.name, + style = MaterialTheme.typography.bodyLarge, + ) + }, + onClick = { + scope.launch { + onTimeTagClick(item) + waitForRipple { + expanded = false + } + } + }, + ) + } + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun ExposedDropdownMenuBoxScope.TimeTagButton( + title: String, + onSizeDetermined: (IntSize) -> Unit, +) { + Row( + modifier = + Modifier + .width(140.dp) + .onGloballyPositioned { coordinates -> + onSizeDetermined(coordinates.size) + }.menuAnchor( + type = MenuAnchorType.PrimaryNotEditable, + enabled = true, + ).height(TopAppBarDefaults.MediumAppBarCollapsedHeight) // Festabook TopAppbar Size + .background(Color.Transparent) + .clickable( + onClick = {}, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = title, + style = MaterialTheme.typography.displaySmall, + ) + + Icon( + painter = painterResource(id = R.drawable.ic_chevron_down), + contentDescription = stringResource(R.string.chevron_down), + ) + } +} + +private suspend inline fun waitForRipple( + timeMillis: Long = 100, + after: () -> Unit = {}, +) { + delay(timeMillis) + after() +} + +@Composable +@Preview(showBackground = true) +private fun TimeTagMenuPreview() { + val timeTags = + listOf( + TimeTag(1, "1일차 오전"), + TimeTag(2, "오후"), + ) + var title by remember { mutableStateOf("1일차 오전") } + FestabookTheme { + TimeTagMenu( + title = title, + timeTags = timeTags, + modifier = + Modifier + .background(FestabookColor.white) + .padding(horizontal = festabookSpacing.paddingScreenGutter), + // Festabook Gutter + onTimeTagClick = { }, + ) + } +} diff --git a/app/src/main/res/layout/fragment_place_map.xml b/app/src/main/res/layout/fragment_place_map.xml index e1d7e7f6..c2df411e 100644 --- a/app/src/main/res/layout/fragment_place_map.xml +++ b/app/src/main/res/layout/fragment_place_map.xml @@ -1,17 +1,15 @@ - - - - - - - - - + app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintTop_toBottomOf="@id/cv_place_map" /> Date: Wed, 3 Dec 2025 22:19:58 +0900 Subject: [PATCH 034/140] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=83=AD=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/component/ScheduleScreen.kt | 62 +++++-------- .../schedule/component/ScheduleTabRow.kt | 87 +++++++++++++++++++ 2 files changed, 107 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt index 66548be7..8f981239 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt @@ -1,25 +1,26 @@ package com.daedan.festabook.presentation.schedule.component import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.FestabookTopAppBar import com.daedan.festabook.presentation.schedule.ScheduleDatesUiState import com.daedan.festabook.presentation.schedule.ScheduleViewModel -import com.daedan.festabook.presentation.schedule.model.ScheduleDateUiModel -import kotlinx.coroutines.CoroutineScope +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.festabookSpacing +import timber.log.Timber @Composable fun ScheduleScreen( @@ -27,7 +28,6 @@ fun ScheduleScreen( modifier: Modifier = Modifier, ) { val scheduleDatesUiState by scheduleViewModel.scheduleDatesUiState.collectAsStateWithLifecycle() - val scheduleEventsUiState by scheduleViewModel.scheduleEventsUiState.collectAsStateWithLifecycle() Scaffold( topBar = { FestabookTopAppBar(title = stringResource(R.string.schedule_title)) }, @@ -36,57 +36,35 @@ fun ScheduleScreen( when (scheduleDatesUiState) { is ScheduleDatesUiState.Error -> { - TODO() } - ScheduleDatesUiState.Loading -> { - TODO() + ScheduleDatesUiState.InitialLoading -> { } is ScheduleDatesUiState.Success -> { + Timber.d("Success호출") val scheduleDates = (scheduleDatesUiState as ScheduleDatesUiState.Success).dates val pageState = rememberPagerState { scheduleDates.size } val scope = rememberCoroutineScope() - Column(modifier = Modifier.padding(innerPadding)) { + Column( + modifier = + Modifier + .padding(innerPadding), + ) { ScheduleTabRow( pageState = pageState, scope = scope, scheduleDates = scheduleDates, ) + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody4)) + HorizontalDivider( + thickness = 1.dp, + color = FestabookColor.gray300, + modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), + ) } } } } } - -@Composable -fun ScheduleTabRow( - pageState: PagerState, - scope: CoroutineScope, - scheduleDates: List, - modifier: Modifier = Modifier, -) { - TabRow(selectedTabIndex = pageState.currentPage, modifier = modifier) { - scheduleDates.forEachIndexed { index, scheduleDate -> - } - } -} - -@Composable -fun ScheduleTabItem( - scheduleDate: ScheduleDateUiModel, - modifier: Modifier = Modifier, -) { - Tab( - selected = TODO(), - onClick = TODO(), - text = { Text(text = scheduleDate.date) }, - ) -} - -// @Composable -// @Preview -// private fun ScheduleScreenPreview() { -// ScheduleScreen() -// } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt new file mode 100644 index 00000000..288c6350 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt @@ -0,0 +1,87 @@ +package com.daedan.festabook.presentation.schedule.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.daedan.festabook.presentation.common.component.cardBackground +import com.daedan.festabook.presentation.schedule.model.ScheduleDateUiModel +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun ScheduleTabRow( + pageState: PagerState, + scope: CoroutineScope, + scheduleDates: List, + modifier: Modifier = Modifier, +) { + ScrollableTabRow( + edgePadding = festabookSpacing.paddingScreenGutter, + selectedTabIndex = pageState.currentPage, + containerColor = MaterialTheme.colorScheme.background, + indicator = { tabPositions -> + ScheduleTabIndicator(modifier = Modifier.tabIndicatorOffset(currentTabPosition = tabPositions[pageState.currentPage])) + }, + divider = {}, + modifier = modifier, + ) { + scheduleDates.forEachIndexed { index, scheduleDate -> + Tab( + selected = pageState.currentPage == index, + unselectedContentColor = FestabookColor.gray500, + selectedContentColor = MaterialTheme.colorScheme.background, + onClick = { scope.launch { pageState.animateScrollToPage(index) } }, + text = { Text(text = scheduleDate.date) }, + ) + } + } +} + +@Composable +private fun ScheduleTabIndicator(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .padding(festabookSpacing.paddingBody1) + .fillMaxSize() + .cardBackground( + backgroundColor = FestabookColor.black, + borderStroke = 0.dp, + borderColor = FestabookColor.black, + shape = festabookShapes.radius4, + ).zIndex(-1f), + ) +} + +@Preview +@Composable +private fun ScheduleTabRowPreview() { + ScheduleTabRow( + pageState = rememberPagerState { 5 }, + scope = rememberCoroutineScope(), + scheduleDates = + listOf( + ScheduleDateUiModel(1, "11/12"), + ScheduleDateUiModel(2, "11/13"), + ScheduleDateUiModel(3, "11/13"), + ScheduleDateUiModel(3, "11/13"), + ScheduleDateUiModel(3, "11/13"), + ), + ) +} From af8fa95798d4af79d64c42d3dbf991f1b1019a82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Tue, 9 Dec 2025 21:31:38 +0900 Subject: [PATCH 035/140] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=EC=B9=B4=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/component/ScheduleEventCard.kt | 103 ++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + 2 files changed, 104 insertions(+) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt new file mode 100644 index 00000000..3a315840 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt @@ -0,0 +1,103 @@ +package com.daedan.festabook.presentation.schedule.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun ScheduleEventCard( + scheduleEvent: ScheduleEventUiModel, + modifier: Modifier = Modifier, +) { + val scheduleEventText = + when (scheduleEvent.status) { + ScheduleEventUiStatus.UPCOMING -> stringResource(R.string.schedule_status_upcoming) + ScheduleEventUiStatus.ONGOING -> stringResource(R.string.schedule_status_ongoing) + ScheduleEventUiStatus.COMPLETED -> stringResource(R.string.schedule_status_completed) + } + + Column( + modifier = modifier.padding(festabookSpacing.paddingBody4), + verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody1), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody1), + ) { + Text( + text = scheduleEvent.title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f), + ) + Box( + modifier = Modifier.size(48.dp, 24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = scheduleEventText, + style = MaterialTheme.typography.bodySmall, + ) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody1), + ) { + Icon( + painter = painterResource(R.drawable.ic_clock), + contentDescription = stringResource(R.string.content_description_iv_location), + ) + Text( + text = + stringResource( + R.string.format_date, + scheduleEvent.startTime, + scheduleEvent.endTime, + ), + style = MaterialTheme.typography.bodySmall, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody1), + ) { + Icon( + painter = painterResource(R.drawable.ic_location), + contentDescription = stringResource(R.string.content_description_iv_location), + ) + Text(text = scheduleEventText, style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun ScheduleEventCardPreview() { + ScheduleEventCard( + scheduleEvent = + ScheduleEventUiModel( + id = 1, + status = ScheduleEventUiStatus.ONGOING, + startTime = "09:00", + endTime = "18:00", + title = "동아리 버스킹 공연", + location = "운동장", + ), + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cca637b8..9dd1e250 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ 현재 표시할 항목이 없습니다. + %1$s ~ %2$s From 831e0dc40b1cd3a4d6627c74c85a42c94a8eec27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Tue, 9 Dec 2025 22:36:52 +0900 Subject: [PATCH 036/140] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=95=A8=EC=88=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/adapter/ScheduleItemViewHolder.kt | 2 -- .../schedule/model/ScheduleEventUiStatus.kt | 15 ++++++--------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleItemViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleItemViewHolder.kt index be6953e3..b64caf00 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleItemViewHolder.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleItemViewHolder.kt @@ -13,7 +13,6 @@ import com.daedan.festabook.logging.model.schedule.ScheduleEventClickLogData import com.daedan.festabook.presentation.common.toPx import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus -import com.daedan.festabook.presentation.schedule.model.toKoreanString class ScheduleItemViewHolder( private val binding: ItemScheduleTabPageBinding, @@ -135,7 +134,6 @@ class ScheduleItemViewHolder( backgroundResId: Int?, ) = with(binding.tvScheduleEventStatus) { val gray050 = ContextCompat.getColor(context, R.color.gray050) - text = status.toKoreanString(context) setTextColor(textColor) gravity = if (status == ScheduleEventUiStatus.COMPLETED) Gravity.END else Gravity.CENTER backgroundResId?.let { setBackgroundResource(it) } ?: setBackgroundColor(gray050) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiStatus.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiStatus.kt index 86d9eca6..9f0892dd 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiStatus.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiStatus.kt @@ -1,17 +1,14 @@ package com.daedan.festabook.presentation.schedule.model -import android.content.Context -import com.daedan.festabook.R - enum class ScheduleEventUiStatus { UPCOMING, ONGOING, COMPLETED, } -fun ScheduleEventUiStatus.toKoreanString(context: Context): String = - when (this) { - ScheduleEventUiStatus.UPCOMING -> context.getString(R.string.schedule_status_upcoming) - ScheduleEventUiStatus.ONGOING -> context.getString(R.string.schedule_status_ongoing) - ScheduleEventUiStatus.COMPLETED -> context.getString(R.string.schedule_status_completed) - } +// fun ScheduleEventUiStatus.toKoreanString(context: Context): String = +// when (this) { +// ScheduleEventUiStatus.UPCOMING -> context.getString(R.string.schedule_status_upcoming) +// ScheduleEventUiStatus.ONGOING -> context.getString(R.string.schedule_status_ongoing) +// ScheduleEventUiStatus.COMPLETED -> context.getString(R.string.schedule_status_completed) +// } From 9b9e426edfe4abc00cc5ebf7cc84b0c43081cb82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Tue, 9 Dec 2025 22:37:43 +0900 Subject: [PATCH 037/140] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=20=ED=83=80?= =?UTF-8?q?=EC=9E=84=EB=9D=BC=EC=9D=B8=EC=97=90=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EB=90=A0=20Lottie=20Circle=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/model/LottieTimeLineCircleProps.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/schedule/model/LottieTimeLineCircleProps.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/model/LottieTimeLineCircleProps.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/model/LottieTimeLineCircleProps.kt new file mode 100644 index 00000000..d529969e --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/model/LottieTimeLineCircleProps.kt @@ -0,0 +1,14 @@ +package com.daedan.festabook.presentation.schedule.model + +import androidx.compose.ui.graphics.Color + +data class LottieTimeLineCircleProps( + val centerColor: Color, + val outerOpacity: Float, + val innerOpacity: Float, + val outerColor: Color, + val innerColor: Color, + val centerKeyPath: List = listOf("centerCircle", "**", "Fill 1"), + val outerKeyPath: List = listOf("outerWave", "**", "Fill 1"), + val innerKeyPath: List = listOf("innerWave", "**", "Fill 1"), +) From 39956b868885ac5f5303b58ce98360c450931496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 10 Dec 2025 20:33:16 +0900 Subject: [PATCH 038/140] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=EC=B9=B4=EB=93=9C=20UI=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/component/ScheduleEventCard.kt | 136 +++++++++++++++--- 1 file changed, 120 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt index 3a315840..b47f0e41 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt @@ -12,13 +12,17 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.component.cardBackground import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.festabookShapes import com.daedan.festabook.presentation.theme.festabookSpacing @Composable @@ -26,15 +30,52 @@ fun ScheduleEventCard( scheduleEvent: ScheduleEventUiModel, modifier: Modifier = Modifier, ) { - val scheduleEventText = + val scheduleEventCardProps = when (scheduleEvent.status) { - ScheduleEventUiStatus.UPCOMING -> stringResource(R.string.schedule_status_upcoming) - ScheduleEventUiStatus.ONGOING -> stringResource(R.string.schedule_status_ongoing) - ScheduleEventUiStatus.COMPLETED -> stringResource(R.string.schedule_status_completed) + ScheduleEventUiStatus.UPCOMING -> { + ScheduleEventCardProps( + cardBorderColor = FestabookColor.accentGreen, + titleColor = FestabookColor.black, + contentColor = FestabookColor.gray500, + labelText = stringResource(R.string.schedule_status_upcoming), + labelTextColor = FestabookColor.black, + labelBackgroundColor = FestabookColor.white, + labelBorderColor = FestabookColor.black, + ) + } + + ScheduleEventUiStatus.ONGOING -> { + ScheduleEventCardProps( + cardBorderColor = FestabookColor.accentBlue, + titleColor = FestabookColor.black, + contentColor = FestabookColor.gray500, + labelText = stringResource(R.string.schedule_status_ongoing), + labelTextColor = FestabookColor.white, + labelBackgroundColor = FestabookColor.black, + labelBorderColor = FestabookColor.black, + ) + } + + ScheduleEventUiStatus.COMPLETED -> { + ScheduleEventCardProps( + cardBorderColor = FestabookColor.gray400, + titleColor = FestabookColor.gray400, + contentColor = FestabookColor.gray400, + labelText = stringResource(R.string.schedule_status_completed), + labelTextColor = FestabookColor.gray400, + labelBackgroundColor = FestabookColor.white, + labelBorderColor = FestabookColor.white, + ) + } } Column( - modifier = modifier.padding(festabookSpacing.paddingBody4), + modifier = + modifier + .cardBackground( + borderColor = scheduleEventCardProps.cardBorderColor, + shape = festabookShapes.radius2, + ).padding(festabookSpacing.paddingBody4), verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody1), ) { Row( @@ -43,17 +84,10 @@ fun ScheduleEventCard( Text( text = scheduleEvent.title, style = MaterialTheme.typography.titleLarge, + color = scheduleEventCardProps.titleColor, modifier = Modifier.weight(1f), ) - Box( - modifier = Modifier.size(48.dp, 24.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = scheduleEventText, - style = MaterialTheme.typography.bodySmall, - ) - } + ScheduleEventLabel(scheduleEventCardProps) } Row( verticalAlignment = Alignment.CenterVertically, @@ -62,6 +96,7 @@ fun ScheduleEventCard( Icon( painter = painterResource(R.drawable.ic_clock), contentDescription = stringResource(R.string.content_description_iv_location), + tint = scheduleEventCardProps.contentColor, ) Text( text = @@ -71,6 +106,7 @@ fun ScheduleEventCard( scheduleEvent.endTime, ), style = MaterialTheme.typography.bodySmall, + color = scheduleEventCardProps.contentColor, ) } Row( @@ -80,15 +116,41 @@ fun ScheduleEventCard( Icon( painter = painterResource(R.drawable.ic_location), contentDescription = stringResource(R.string.content_description_iv_location), + tint = scheduleEventCardProps.contentColor, + ) + Text( + text = scheduleEvent.location, + style = MaterialTheme.typography.bodySmall, + color = scheduleEventCardProps.contentColor, ) - Text(text = scheduleEventText, style = MaterialTheme.typography.bodySmall) } } } +@Composable +private fun ScheduleEventLabel(scheduleEventCardProps: ScheduleEventCardProps) { + Box( + modifier = + Modifier + .size(48.dp, 24.dp) + .cardBackground( + backgroundColor = scheduleEventCardProps.labelBackgroundColor, + borderColor = scheduleEventCardProps.labelBorderColor, + shape = festabookShapes.radius1, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = scheduleEventCardProps.labelText, + style = MaterialTheme.typography.bodySmall, + color = scheduleEventCardProps.labelTextColor, + ) + } +} + @Composable @Preview(showBackground = true) -private fun ScheduleEventCardPreview() { +private fun OnGoingScheduleEventCardPreview() { ScheduleEventCard( scheduleEvent = ScheduleEventUiModel( @@ -101,3 +163,45 @@ private fun ScheduleEventCardPreview() { ), ) } + +@Composable +@Preview(showBackground = true) +private fun UpComingScheduleEventCardPreview() { + ScheduleEventCard( + scheduleEvent = + ScheduleEventUiModel( + id = 1, + status = ScheduleEventUiStatus.UPCOMING, + startTime = "09:00", + endTime = "18:00", + title = "동아리 버스킹 공연", + location = "운동장", + ), + ) +} + +@Composable +@Preview(showBackground = true) +private fun CompleteScheduleEventCardONGOINGPreview() { + ScheduleEventCard( + scheduleEvent = + ScheduleEventUiModel( + id = 1, + status = ScheduleEventUiStatus.COMPLETED, + startTime = "09:00", + endTime = "18:00", + title = "동아리 버스킹 공연", + location = "운동장", + ), + ) +} + +data class ScheduleEventCardProps( + val cardBorderColor: Color, + val titleColor: Color, + val contentColor: Color, + val labelText: String, + val labelTextColor: Color, + val labelBackgroundColor: Color, + val labelBorderColor: Color, +) From 5bd80699936b01be92561e42a34e248ca6f8f5a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 10 Dec 2025 21:12:42 +0900 Subject: [PATCH 039/140] =?UTF-8?q?feat:=20=EC=B9=B4=EB=93=9C=20=EB=B0=B0?= =?UTF-8?q?=EA=B2=BD=EC=83=89=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/component/ScheduleEventCard.kt | 74 ++++++++++--------- .../model/LottieTimeLineCircleProps.kt | 14 ---- .../presentation/theme/FestabookSpacing.kt | 1 + 3 files changed, 42 insertions(+), 47 deletions(-) delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/schedule/model/LottieTimeLineCircleProps.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt index b47f0e41..469375d1 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt @@ -22,6 +22,7 @@ import com.daedan.festabook.presentation.common.component.cardBackground import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme import com.daedan.festabook.presentation.theme.festabookShapes import com.daedan.festabook.presentation.theme.festabookSpacing @@ -73,6 +74,7 @@ fun ScheduleEventCard( modifier = modifier .cardBackground( + backgroundColor = MaterialTheme.colorScheme.background, borderColor = scheduleEventCardProps.cardBorderColor, shape = festabookShapes.radius2, ).padding(festabookSpacing.paddingBody4), @@ -151,49 +153,55 @@ private fun ScheduleEventLabel(scheduleEventCardProps: ScheduleEventCardProps) { @Composable @Preview(showBackground = true) private fun OnGoingScheduleEventCardPreview() { - ScheduleEventCard( - scheduleEvent = - ScheduleEventUiModel( - id = 1, - status = ScheduleEventUiStatus.ONGOING, - startTime = "09:00", - endTime = "18:00", - title = "동아리 버스킹 공연", - location = "운동장", - ), - ) + FestabookTheme { + ScheduleEventCard( + scheduleEvent = + ScheduleEventUiModel( + id = 1, + status = ScheduleEventUiStatus.ONGOING, + startTime = "09:00", + endTime = "18:00", + title = "동아리 버스킹 공연", + location = "운동장", + ), + ) + } } @Composable @Preview(showBackground = true) private fun UpComingScheduleEventCardPreview() { - ScheduleEventCard( - scheduleEvent = - ScheduleEventUiModel( - id = 1, - status = ScheduleEventUiStatus.UPCOMING, - startTime = "09:00", - endTime = "18:00", - title = "동아리 버스킹 공연", - location = "운동장", - ), - ) + FestabookTheme { + ScheduleEventCard( + scheduleEvent = + ScheduleEventUiModel( + id = 1, + status = ScheduleEventUiStatus.UPCOMING, + startTime = "09:00", + endTime = "18:00", + title = "동아리 버스킹 공연", + location = "운동장", + ), + ) + } } @Composable @Preview(showBackground = true) private fun CompleteScheduleEventCardONGOINGPreview() { - ScheduleEventCard( - scheduleEvent = - ScheduleEventUiModel( - id = 1, - status = ScheduleEventUiStatus.COMPLETED, - startTime = "09:00", - endTime = "18:00", - title = "동아리 버스킹 공연", - location = "운동장", - ), - ) + FestabookTheme { + ScheduleEventCard( + scheduleEvent = + ScheduleEventUiModel( + id = 1, + status = ScheduleEventUiStatus.COMPLETED, + startTime = "09:00", + endTime = "18:00", + title = "동아리 버스킹 공연", + location = "운동장", + ), + ) + } } data class ScheduleEventCardProps( diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/model/LottieTimeLineCircleProps.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/model/LottieTimeLineCircleProps.kt deleted file mode 100644 index d529969e..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/model/LottieTimeLineCircleProps.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.daedan.festabook.presentation.schedule.model - -import androidx.compose.ui.graphics.Color - -data class LottieTimeLineCircleProps( - val centerColor: Color, - val outerOpacity: Float, - val innerOpacity: Float, - val outerColor: Color, - val innerColor: Color, - val centerKeyPath: List = listOf("centerCircle", "**", "Fill 1"), - val outerKeyPath: List = listOf("outerWave", "**", "Fill 1"), - val innerKeyPath: List = listOf("innerWave", "**", "Fill 1"), -) diff --git a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookSpacing.kt b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookSpacing.kt index 3d12413b..bd6e6f5e 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookSpacing.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookSpacing.kt @@ -12,6 +12,7 @@ data class FestabookSpacing( val paddingBody2: Dp = 8.dp, val paddingBody3: Dp = 12.dp, val paddingBody4: Dp = 16.dp, + val paddingBody5: Dp = 20.dp, ) val LocalSpacing = staticCompositionLocalOf { FestabookSpacing() } From acd296f41e952a7abacaf60d654e87cfa9811030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 10 Dec 2025 22:15:23 +0900 Subject: [PATCH 040/140] =?UTF-8?q?fix:=20=EC=86=8C=EC=8B=9D=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=ED=8C=A8?= =?UTF-8?q?=EB=94=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../festabook/presentation/news/component/NewsScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt index 25b65295..e5282c88 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt @@ -42,7 +42,9 @@ fun NewsScreen( topBar = { FestabookTopAppBar(title = stringResource(R.string.news_title)) }, modifier = modifier, ) { innerPadding -> - Column(modifier = Modifier.padding(paddingValues = innerPadding)) { + Column( + modifier = Modifier.padding(top = innerPadding.calculateTopPadding()), + ) { NewsTabRow(pageState, scope) NewsTabPage( pageState = pageState, From 09f399f02cfe0f992f4da38e993e89fad8df0758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 10 Dec 2025 22:15:52 +0900 Subject: [PATCH 041/140] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20Compose=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/ScheduleTabPageFragment.kt | 60 ++++---- .../schedule/component/ScheduleEventItem.kt | 135 +++++++++++++++++ .../schedule/component/ScheduleScreen.kt | 20 ++- .../schedule/component/ScheduleTabPage.kt | 141 ++++++++++++++++++ 4 files changed, 320 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleTabPageFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleTabPageFragment.kt index 3bd30acb..c51d8deb 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleTabPageFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleTabPageFragment.kt @@ -11,10 +11,8 @@ import com.daedan.festabook.di.appGraph import com.daedan.festabook.logging.logger import com.daedan.festabook.logging.model.schedule.ScheduleSwipeRefreshLogData import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.common.showErrorSnackBar import com.daedan.festabook.presentation.schedule.ScheduleViewModel.Companion.INVALID_ID import com.daedan.festabook.presentation.schedule.adapter.ScheduleAdapter -import timber.log.Timber class ScheduleTabPageFragment : BaseFragment() { override val layoutId: Int = R.layout.fragment_schedule_tab_page @@ -42,44 +40,44 @@ class ScheduleTabPageFragment : BaseFragment() { binding.rvScheduleEvent.adapter = adapter (binding.rvScheduleEvent.itemAnimator as DefaultItemAnimator).supportsChangeAnimations = false - viewModel.loadScheduleByDate() +// viewModel.loadScheduleByDate() } private fun onSwipeRefreshScheduleByDateListener() { binding.srlScheduleEvent.setOnRefreshListener { binding.logger.log(ScheduleSwipeRefreshLogData(binding.logger.getBaseLogData())) - viewModel.loadScheduleByDate() +// viewModel.loadScheduleByDate() } } private fun setupObservers() { - viewModel.scheduleEventsUiState.observe(viewLifecycleOwner) { schedule -> - when (schedule) { - is ScheduleEventsUiState.Loading, - -> { - showLoadingView(isLoading = true) - showEmptyStateMessage() - } - - is ScheduleEventsUiState.Success -> { - showLoadingView(isLoading = false) - adapter.submitList(schedule.events) { - showEmptyStateMessage() - scrollToCenterOfCurrentEvent(schedule.currentEventPosition) - } - } - - is ScheduleEventsUiState.Error -> { - Timber.w( - schedule.throwable, - "ScheduleTabPageFragment: ${schedule.throwable.message}", - ) - showErrorSnackBar(schedule.throwable) - showLoadingView(isLoading = false) - showEmptyStateMessage() - } - } - } +// viewModel.scheduleEventsUiState.observe(viewLifecycleOwner) { schedule -> +// when (schedule) { +// is ScheduleEventsUiState.Loading, +// -> { +// showLoadingView(isLoading = true) +// showEmptyStateMessage() +// } +// +// is ScheduleEventsUiState.Success -> { +// showLoadingView(isLoading = false) +// adapter.submitList(schedule.events) { +// showEmptyStateMessage() +// scrollToCenterOfCurrentEvent(schedule.currentEventPosition) +// } +// } +// +// is ScheduleEventsUiState.Error -> { +// Timber.w( +// schedule.throwable, +// "ScheduleTabPageFragment: ${schedule.throwable.message}", +// ) +// showErrorSnackBar(schedule.throwable) +// showLoadingView(isLoading = false) +// showEmptyStateMessage() +// } +// } +// } } private fun showLoadingView(isLoading: Boolean) { diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt new file mode 100644 index 00000000..bc30f3ec --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt @@ -0,0 +1,135 @@ +package com.daedan.festabook.presentation.schedule.component + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.tooling.preview.Preview +import com.airbnb.lottie.LottieProperty +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition +import com.airbnb.lottie.compose.rememberLottieDynamicProperties +import com.airbnb.lottie.compose.rememberLottieDynamicProperty +import com.daedan.festabook.R +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun ScheduleEventItem( + scheduleEvent: ScheduleEventUiModel, + modifier: Modifier = Modifier, +) { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.pulse_circle)) + val progress by animateLottieCompositionAsState( + composition = composition, + iterations = LottieConstants.IterateForever, + ) + + val props = + when (scheduleEvent.status) { + ScheduleEventUiStatus.UPCOMING -> { + LottieTimeLineCircleProps( + centerColor = FestabookColor.accentGreen, + outerOpacity = 0f, + innerOpacity = 1f, + outerColor = FestabookColor.accentGreen, + innerColor = FestabookColor.accentGreen, + ) + } + + ScheduleEventUiStatus.ONGOING -> { + LottieTimeLineCircleProps( + centerColor = FestabookColor.accentBlue, + outerOpacity = 1f, + innerOpacity = 1f, + outerColor = FestabookColor.accentBlue, + innerColor = FestabookColor.accentBlue, + ) + } + + ScheduleEventUiStatus.COMPLETED -> { + LottieTimeLineCircleProps( + centerColor = FestabookColor.gray300, + outerOpacity = 0f, + innerOpacity = 0f, + outerColor = FestabookColor.gray300, + innerColor = FestabookColor.gray300, + ) + } + } + val dynamicProperties = + rememberLottieDynamicProperties( + rememberLottieDynamicProperty( + property = LottieProperty.COLOR, + value = props.centerColor.toArgb(), + *props.centerKeyPath.toTypedArray(), + ), + rememberLottieDynamicProperty( + property = LottieProperty.OPACITY, + value = (props.outerOpacity * 100).toInt(), + *props.outerKeyPath.toTypedArray(), + ), + rememberLottieDynamicProperty( + property = LottieProperty.OPACITY, + value = (props.innerOpacity * 100).toInt(), + *props.innerKeyPath.toTypedArray(), + ), + rememberLottieDynamicProperty( + property = LottieProperty.COLOR, + value = props.outerColor.toArgb(), + *props.outerKeyPath.toTypedArray(), + ), + rememberLottieDynamicProperty( + property = LottieProperty.COLOR, + value = props.innerColor.toArgb(), + *props.innerKeyPath.toTypedArray(), + ), + ) + + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + LottieAnimation( + composition = composition, + progress = { progress }, + dynamicProperties = dynamicProperties, + modifier = Modifier.size(festabookSpacing.paddingBody4 * 4) + ) + ScheduleEventCard(scheduleEvent = scheduleEvent ) + } +} + +@Composable +@Preview +private fun ScheduleEventItemPreview() { + ScheduleEventItem( + scheduleEvent = + ScheduleEventUiModel( + id = 1, + status = ScheduleEventUiStatus.ONGOING, + startTime = "9:00", + endTime = "18:00", + title = "동아리 버스킹 공연", + location = "운동장", + ), + ) +} + +data class LottieTimeLineCircleProps( + val centerColor: Color, + val outerOpacity: Float, + val innerOpacity: Float, + val outerColor: Color, + val innerColor: Color, + val centerKeyPath: List = listOf("centerCircle", "**", "Fill 1"), + val outerKeyPath: List = listOf("outerWave", "**", "Fill 1"), + val innerKeyPath: List = listOf("innerWave", "**", "Fill 1"), +) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt index 8f981239..9719d949 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier @@ -20,7 +21,6 @@ import com.daedan.festabook.presentation.schedule.ScheduleDatesUiState import com.daedan.festabook.presentation.schedule.ScheduleViewModel import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.festabookSpacing -import timber.log.Timber @Composable fun ScheduleScreen( @@ -28,6 +28,8 @@ fun ScheduleScreen( modifier: Modifier = Modifier, ) { val scheduleDatesUiState by scheduleViewModel.scheduleDatesUiState.collectAsStateWithLifecycle() + val selectedDateId by scheduleViewModel.selectedDateId.collectAsStateWithLifecycle() + val eventStates by scheduleViewModel.scheduleEventsByDate.collectAsStateWithLifecycle() Scaffold( topBar = { FestabookTopAppBar(title = stringResource(R.string.schedule_title)) }, @@ -42,15 +44,17 @@ fun ScheduleScreen( } is ScheduleDatesUiState.Success -> { - Timber.d("Success호출") val scheduleDates = (scheduleDatesUiState as ScheduleDatesUiState.Success).dates val pageState = rememberPagerState { scheduleDates.size } val scope = rememberCoroutineScope() + LaunchedEffect(pageState.currentPage) { + val selectedDateId = scheduleDates[pageState.currentPage].id + scheduleViewModel.onDateSelected(selectedDateId) + } + Column( - modifier = - Modifier - .padding(innerPadding), + modifier = Modifier.padding(top = innerPadding.calculateTopPadding()), ) { ScheduleTabRow( pageState = pageState, @@ -63,6 +67,12 @@ fun ScheduleScreen( color = FestabookColor.gray300, modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), ) + ScheduleTabPage( + pagerState = pageState, + selectedDatedId = selectedDateId, + eventStates = eventStates, + onRefresh = { }, + ) } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt new file mode 100644 index 00000000..86225ac5 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt @@ -0,0 +1,141 @@ +package com.daedan.festabook.presentation.schedule.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.common.component.EmptyStateScreen +import com.daedan.festabook.presentation.common.component.LoadingStateScreen +import com.daedan.festabook.presentation.common.component.PULL_OFFSET_LIMIT +import com.daedan.festabook.presentation.common.component.PullToRefreshContainer +import com.daedan.festabook.presentation.schedule.ScheduleEventsUiState +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookSpacing + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScheduleTabPage( + pagerState: PagerState, + selectedDatedId: Long?, + eventStates: Map, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + HorizontalPager(state = pagerState, modifier = modifier) { + val uiState = eventStates[selectedDatedId] + val isRefreshing = uiState is ScheduleEventsUiState.Refreshing + + PullToRefreshContainer( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + ) { pullToRefreshState -> + + if (uiState == null) { + EmptyStateScreen() + } else { + when (uiState) { + is ScheduleEventsUiState.Error -> { + } + + ScheduleEventsUiState.InitialLoading -> { + LoadingStateScreen() + } + + is ScheduleEventsUiState.Refreshing -> { + } + + is ScheduleEventsUiState.Success -> { + if (uiState.events.isEmpty()) { + EmptyStateScreen() + } else { + ScheduleTabContent( + scheduleEvents = uiState.events, + modifier = + Modifier + .padding(end = festabookSpacing.paddingScreenGutter) + .graphicsLayer { + translationY = + pullToRefreshState.distanceFraction * PULL_OFFSET_LIMIT + }, + ) + } + } + } + } + } + } +} + +@Composable +private fun ScheduleTabContent( + scheduleEvents: List, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + VerticalDivider( + thickness = 1.dp, + color = FestabookColor.gray300, + modifier = + Modifier + .padding(start = festabookSpacing.paddingScreenGutter + festabookSpacing.paddingBody4), + ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody5), + contentPadding = PaddingValues(vertical = festabookSpacing.paddingBody5), + ) { + items(items = scheduleEvents, key = { scheduleEvent -> scheduleEvent.id }) { + ScheduleEventItem(scheduleEvent = it) + } + } + } +} + +@Composable +@Preview(showBackground = true) +private fun ScheduleTabContentPreview() { + FestabookTheme { + ScheduleTabContent( + scheduleEvents = + listOf( + ScheduleEventUiModel( + id = 1, + status = ScheduleEventUiStatus.ONGOING, + startTime = "9:00", + endTime = "18:00", + title = "동아리 버스킹 공연", + location = "운동장", + ), + ScheduleEventUiModel( + id = 2, + status = ScheduleEventUiStatus.UPCOMING, + startTime = "9:00", + endTime = "18:00", + title = "동아리 버스킹 공연 동아리 버스킹 공연 동아리 버스킹 공연", + location = "운동장", + ), + ScheduleEventUiModel( + id = 3, + status = ScheduleEventUiStatus.COMPLETED, + startTime = "9:00", + endTime = "18:00", + title = "동아리 버스킹 공연", + location = "운동장", + ), + ), + ) + } +} From aa7d65b0c91aba8b1f4fbc2e2522011214de3a91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 10 Dec 2025 22:36:27 +0900 Subject: [PATCH 042/140] =?UTF-8?q?refactor:=20VieModel=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20DI=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../daedan/festabook/di/FestaBookAppGraph.kt | 3 - .../presentation/schedule/ScheduleFragment.kt | 174 +++++++++--------- .../schedule/ScheduleViewModel.kt | 111 +++++------ 3 files changed, 146 insertions(+), 142 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt b/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt index cd93ed60..76fd620d 100644 --- a/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt +++ b/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt @@ -9,7 +9,6 @@ import com.daedan.festabook.logging.DefaultFirebaseLogger import com.daedan.festabook.presentation.main.MainActivity import com.daedan.festabook.presentation.placeDetail.PlaceDetailActivity import com.daedan.festabook.presentation.placeMap.placeList.behavior.PlaceListBottomSheetBehavior -import com.daedan.festabook.presentation.schedule.ScheduleViewModel import com.daedan.festabook.presentation.splash.SplashActivity import com.google.android.play.core.appupdate.AppUpdateManager import com.google.android.play.core.appupdate.AppUpdateManagerFactory @@ -44,8 +43,6 @@ interface FestaBookAppGraph { val defaultFirebaseLogger: DefaultFirebaseLogger val metroViewModelFactory: MetroViewModelFactory - - val scheduleViewModelFactory: ScheduleViewModel.Factory } val Context.appGraph get() = (applicationContext as FestaBookApp).festaBookGraph diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt index a19f1810..fbb58f2d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt @@ -1,28 +1,27 @@ package com.daedan.festabook.presentation.schedule -import android.annotation.SuppressLint import android.os.Bundle import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModelProvider import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentScheduleBinding -import com.daedan.festabook.databinding.ItemScheduleTabBinding import com.daedan.festabook.di.fragment.FragmentKey import com.daedan.festabook.logging.logger import com.daedan.festabook.logging.model.schedule.ScheduleMenuItemReClickLogData import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.common.OnMenuItemReClickListener -import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.schedule.adapter.SchedulePagerAdapter -import com.google.android.material.tabs.TabLayout -import com.google.android.material.tabs.TabLayoutMediator +import com.daedan.festabook.presentation.schedule.component.ScheduleScreen +import com.daedan.festabook.presentation.theme.FestabookTheme import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import dev.zacsweers.metro.binding -import timber.log.Timber @ContributesIntoMap( scope = AppScope::class, @@ -36,94 +35,101 @@ class ScheduleFragment : override val layoutId: Int = R.layout.fragment_schedule @Inject - private lateinit var viewModelFactory: ScheduleViewModel.Factory - private val adapter: SchedulePagerAdapter by lazy { - SchedulePagerAdapter(this) - } + override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory - private val viewModel: ScheduleViewModel by viewModels { - ScheduleViewModel.factory( - viewModelFactory, - ) - } + private val viewModel: ScheduleViewModel by viewModels() - override fun onViewCreated( - view: View, + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, - ) { - binding.vpSchedule.adapter = adapter - setupObservers() - } + ): View = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + FestabookTheme { + ScheduleScreen(scheduleViewModel = viewModel) + } + } + } + +// override fun onViewCreated( +// view: View, +// savedInstanceState: Bundle?, +// ) { +// binding.vpSchedule.adapter = adapter +// setupObservers() +// } override fun onMenuItemReClick() { viewModel.loadAllDates() - viewModel.loadScheduleByDate() +// viewModel.loadScheduleByDate() binding.logger.log(ScheduleMenuItemReClickLogData(binding.logger.getBaseLogData())) } - @SuppressLint("WrongConstant") - private fun setupScheduleTabLayout(initialCurrentDateIndex: Int) { - binding.vpSchedule.offscreenPageLimit = PRELOAD_PAGE_COUNT - - TabLayoutMediator(binding.tlSchedule, binding.vpSchedule) { tab, position -> - setupScheduleTabView(tab, position) - binding.vpSchedule.setCurrentItem(initialCurrentDateIndex, false) - }.attach() - } - - private fun setupScheduleTabView( - tab: TabLayout.Tab, - position: Int, - ) { - val itemScheduleTabBinding = - ItemScheduleTabBinding.inflate( - LayoutInflater.from(requireContext()), - binding.tlSchedule, - false, - ) - tab.customView = itemScheduleTabBinding.root - - itemScheduleTabBinding.tvScheduleTabItem.text = - viewModel.scheduleDatesUiState.value - .let { - (it as? ScheduleDatesUiState.Success)?.dates?.get(position)?.date - ?: EMPTY_DATE_TEXT - } - } +// @SuppressLint("WrongConstant") +// private fun setupScheduleTabLayout(initialCurrentDateIndex: Int) { +// binding.vpSchedule.offscreenPageLimit = PRELOAD_PAGE_COUNT +// +// TabLayoutMediator(binding.tlSchedule, binding.vpSchedule) { tab, position -> +// setupScheduleTabView(tab, position) +// binding.vpSchedule.setCurrentItem(initialCurrentDateIndex, false) +// }.attach() +// } +// +// private fun setupScheduleTabView( +// tab: TabLayout.Tab, +// position: Int, +// ) { +// val itemScheduleTabBinding = +// ItemScheduleTabBinding.inflate( +// LayoutInflater.from(requireContext()), +// binding.tlSchedule, +// false, +// ) +// tab.customView = itemScheduleTabBinding.root +// +// itemScheduleTabBinding.tvScheduleTabItem.text = +// viewModel.scheduleDatesUiState.value +// .let { +// (it as? ScheduleDatesUiState.Success)?.dates?.get(position)?.date +// ?: EMPTY_DATE_TEXT +// } +// } private fun setupObservers() { - viewModel.scheduleDatesUiState.observe(viewLifecycleOwner) { scheduleDatesUiState -> - - when (scheduleDatesUiState) { - is ScheduleDatesUiState.Loading -> { - showLoadingView(isLoading = true) - } - - is ScheduleDatesUiState.Success -> { - showLoadingView(isLoading = false) - setupScheduleTabLayout(scheduleDatesUiState.initialDatePosition) - adapter.submitList(scheduleDatesUiState.dates) - } - - is ScheduleDatesUiState.Error -> { - showLoadingView(isLoading = false) - Timber.w( - scheduleDatesUiState.throwable, - "${this::class.simpleName}: ${scheduleDatesUiState.throwable.message}", - ) - showErrorSnackBar(scheduleDatesUiState.throwable) - } - } - } +// viewModel.scheduleDatesUiState.observe(viewLifecycleOwner) { scheduleDatesUiState -> +// +// when (scheduleDatesUiState) { +// is ScheduleDatesUiState.Loading -> { +// showLoadingView(isLoading = true) +// } +// +// is ScheduleDatesUiState.Success -> { +// showLoadingView(isLoading = false) +// setupScheduleTabLayout(scheduleDatesUiState.initialDatePosition) +// adapter.submitList(scheduleDatesUiState.dates) +// } +// +// is ScheduleDatesUiState.Error -> { +// showLoadingView(isLoading = false) +// Timber.w( +// scheduleDatesUiState.throwable, +// "${this::class.simpleName}: ${scheduleDatesUiState.throwable.message}", +// ) +// showErrorSnackBar(scheduleDatesUiState.throwable) +// } +// } +// } } - private fun showLoadingView(isLoading: Boolean) { - binding.lavScheduleLoading.visibility = if (isLoading) View.VISIBLE else View.GONE - binding.vpSchedule.visibility = if (isLoading) View.INVISIBLE else View.VISIBLE - } - - companion object { - private const val PRELOAD_PAGE_COUNT: Int = 2 - private const val EMPTY_DATE_TEXT: String = "" - } +// private fun showLoadingView(isLoading: Boolean) { +// binding.lavScheduleLoading.visibility = if (isLoading) View.VISIBLE else View.GONE +// binding.vpSchedule.visibility = if (isLoading) View.INVISIBLE else View.VISIBLE +// } +// +// companion object { +// private const val PRELOAD_PAGE_COUNT: Int = 2 +// private const val EMPTY_DATE_TEXT: String = "" +// } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt index a35bf674..d98d397a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt @@ -1,87 +1,99 @@ package com.daedan.festabook.presentation.schedule import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory +import com.daedan.festabook.di.viewmodel.ViewModelKey import com.daedan.festabook.domain.repository.ScheduleRepository import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus import com.daedan.festabook.presentation.schedule.model.toUiModel -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedFactory -import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.LocalDate -class ScheduleViewModel @AssistedInject constructor( +@ContributesIntoMap(AppScope::class) +@ViewModelKey(ScheduleViewModel::class) +@Inject +class ScheduleViewModel( private val scheduleRepository: ScheduleRepository, - @Assisted private val dateId: Long, ) : ViewModel() { - @AssistedFactory - interface Factory { - fun create(dateId: Long): ScheduleViewModel - } - - private val _scheduleEventsUiState: MutableStateFlow = - MutableStateFlow(ScheduleEventsUiState.Loading) - val scheduleEventsUiState: StateFlow = - _scheduleEventsUiState.asStateFlow() - private val _scheduleDatesUiState: MutableStateFlow = - MutableStateFlow(ScheduleDatesUiState.Loading) + MutableStateFlow(ScheduleDatesUiState.InitialLoading) val scheduleDatesUiState: StateFlow = _scheduleDatesUiState.asStateFlow() + private val _scheduleEventsByDate: MutableStateFlow> = + MutableStateFlow(emptyMap()) + val scheduleEventsByDate = _scheduleEventsByDate.asStateFlow() + + private val _selectedDateId: MutableStateFlow = MutableStateFlow(null) + val selectedDateId = _selectedDateId.asStateFlow() + init { loadAllDates() - if (dateId != INVALID_ID) loadScheduleByDate() } - fun loadScheduleByDate() { - if (dateId == INVALID_ID) return - if (_scheduleEventsUiState.value == ScheduleEventsUiState.Loading) return + fun onDateSelected(dateId: Long) { + _selectedDateId.value = dateId + + if (_scheduleEventsByDate.value.containsKey(dateId)) return + + loadScheduleByDate(dateId) + } + + fun loadScheduleByDate(dateId: Long) { viewModelScope.launch { - _scheduleEventsUiState.value = ScheduleEventsUiState.Loading + _scheduleEventsByDate.value[dateId]?.let { return@launch } val result = scheduleRepository.fetchScheduleEventsById(dateId) - result - .onSuccess { scheduleEvents -> - val scheduleEventUiModels = scheduleEvents.map { it.toUiModel() } - val currentEventPosition = - scheduleEventUiModels - .indexOfFirst { scheduleEvent -> scheduleEvent.status == ScheduleEventUiStatus.ONGOING } - .coerceAtLeast(FIRST_INDEX) - - _scheduleEventsUiState.value = - ScheduleEventsUiState.Success(scheduleEventUiModels, currentEventPosition) - }.onFailure { - _scheduleEventsUiState.value = - ScheduleEventsUiState.Error(it) - } + val uiState = + result.fold( + onSuccess = { scheduleEvents -> + val scheduleEventUiModels = scheduleEvents.map { it.toUiModel() } + val currentEventPosition = + scheduleEventUiModels + .indexOfFirst { + it.status == ScheduleEventUiStatus.ONGOING + }.coerceAtLeast(FIRST_INDEX) + + ScheduleEventsUiState.Success( + scheduleEventUiModels, + currentEventPosition, + ) + }, + onFailure = { ScheduleEventsUiState.Error(it) }, + ) + + _scheduleEventsByDate.update { old -> + old + (dateId to uiState) + } } } fun loadAllDates() { - if (_scheduleDatesUiState.value == ScheduleDatesUiState.Loading) return viewModelScope.launch { - _scheduleDatesUiState.value = ScheduleDatesUiState.Loading + _scheduleDatesUiState.value = ScheduleDatesUiState.InitialLoading val result = scheduleRepository.fetchAllScheduleDates() + result .onSuccess { scheduleDates -> val scheduleDateUiModels = scheduleDates.map { it.toUiModel() } val today = LocalDate.now() - val currentDatePosition = + val initialDateId = scheduleDates - .indexOfFirst { !it.date.isBefore(today) } - .let { currentIndex -> if (currentIndex == INVALID_INDEX) FIRST_INDEX else currentIndex } + .find { !it.date.isBefore(today) } + ?.id ?: scheduleDates.firstOrNull()?.id + + _selectedDateId.value = initialDateId _scheduleDatesUiState.value = - ScheduleDatesUiState.Success(scheduleDateUiModels, currentDatePosition) + ScheduleDatesUiState.Success(scheduleDateUiModels, initialDateId) }.onFailure { _scheduleDatesUiState.value = ScheduleDatesUiState.Error(it) } @@ -91,16 +103,5 @@ class ScheduleViewModel @AssistedInject constructor( companion object { const val INVALID_ID: Long = -1L private const val FIRST_INDEX: Int = 0 - private const val INVALID_INDEX: Int = -1 - - fun factory( - factory: Factory, - dateId: Long = INVALID_ID, - ): ViewModelProvider.Factory = - viewModelFactory { - initializer { - factory.create(dateId) - } - } } } From b2c369b0fa409906d103b2e1f75557c6c40170cf Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Tue, 16 Dec 2025 23:02:47 +0900 Subject: [PATCH 043/140] =?UTF-8?q?=EC=A0=84=EC=97=AD=20Ktlint=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=8F=20=EA=B8=B0=ED=83=80=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=20=EC=88=98=ED=96=89=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Ktlint Format 적용 * refactor(DI): 생성자 주입 시 @Inject 위치 조정 및 코드 정리 전반적인 코드 베이스에서 Metro DI와 관련된 생성자 주입 스타일을 통일하고 일부 코드를 정리했습니다. - **생성자 주입 스타일 통일:** - `@Inject constructor(...)` 형식으로 작성된 모든 클래스의 생성자 주입 코드를 `@Inject class ... (...)` 형식으로 변경하여 어노테이션 위치를 클래스 선언부로 통일했습니다. 이는 코드 가독성을 높이고 일관된 스타일을 유지하기 위함입니다. - 대상 파일: `DataSource`, `Repository`, `ViewModel`, `Fragment`, `Interceptor`, `Factory` 등 다수의 클래스 - **`FragmentUtil.kt` 코드 정리:** - 더 이상 사용되지 않는 `getObject(key: String)` 확장 함수를 삭제했습니다. - API 33(Android 13, UPSIDE_DOWN_CAKE) 미만 버전에서 `getSerializableExtra`, `getParcelableExtra` 등을 호출할 때 발생하는 `DEPRECATION` 경고를 억제하기 위해 `@Suppress("DEPRECATION")` 어노테이션을 추가했습니다. * refactor(DI): 프래그먼트의 의존성 주입 방식을 생성자 주입으로 변경 기존에 `@Inject` 어노테이션을 사용하여 필드로 주입받던 `ViewModelProvider.Factory` 및 기타 의존성들을 프래그먼트의 생성자를 통해 주입하도록 리팩토링했습니다. 이를 통해 의존성 주입 시점을 명확히 하고, 불변성(immutability)을 확보하여 코드의 안정성을 높였습니다. - **대상 프래그먼트:** - `HomeFragment` - `PlaceCategoryFragment` - `PlaceDetailPreviewFragment` - `PlaceDetailPreviewSecondaryFragment` - `PlaceListFragment` - `PlaceMapFragment` - `ScheduleFragment` - `SettingFragment` - **`ExploreViewModel.kt` 수정:** - `debounce` 사용을 위해 필요한 `@FlowPreview` 어노테이션을 클래스 레벨에 추가했습니다. * refactor(theme): CompositionLocal을 사용하여 테마 시스템 확장 및 리팩토링 Compose 환경의 테마 시스템(`FestabookTheme`)을 확장하여, 기존에 객체(`object`)로 정의되었던 `FestabookColor`와 `FestabookTypography`를 `CompositionLocalProvider`를 통해 주입하도록 구조를 변경했습니다. 이를 통해 테마 값에 대한 접근 방식을 통일하고 재사용성을 높였습니다. - **`FestabookTheme.kt` 수정:** - `CompositionLocalProvider`에 `LocalColor`와 `LocalTypography`를 추가하여, 하위 컴포저블에서 `FestabookColor`와 `FestabookTypography`를 통해 테마의 색상과 타이포그래피에 접근할 수 있도록 설정했습니다. - **`FestabookColor.kt` 리팩토링:** - 기존의 `object FestabookColor`를 `data class FestabookColorPalette`로 변경했습니다. - `staticCompositionLocalOf`를 사용하여 `LocalColor`를 정의하고, `@Composable` `get()` 프로퍼티인 `FestabookColor`를 통해 테마 색상에 접근하도록 수정했습니다. - **`FestabookTypography.kt` 리팩토링:** - 기존의 `val FestabookTypography`를 `FestabookTypographies`로 이름을 변경했습니다. - `staticCompositionLocalOf`로 `LocalTypography`를 정의하고, `@Composable` `get()` 프로퍼티인 `FestabookTypography`를 추가하여 테마 타이포그래피에 접근할 수 있도록 개선했습니다. - **`FestabookSpacing.kt` & `FestabookShapes.kt` 수정:** - `get()` 프로퍼티에 `@ReadOnlyComposable` 어노테이션을 추가하여 컴포지션 최적화를 개선했습니다. - **`themes.xml` 수정:** - `android:windowBackground` 값을 `@color/gray050`에서 하드코딩된 `#FAFAFA`로 변경하여, 새로운 테마 시스템의 색상 값(`white`)과 일치시켰습니다. * refactor: ktlint Compose 규칙 적용 및 코드 개선 ktlint의 Compose 관련 규칙을 프로젝트에 새로 적용하고, 이에 따라 발견된 코드 스타일 및 컨벤션 문제를 수정했습니다. - **ktlint Compose 규칙 적용:** - `gradle/libs.versions.toml`과 `app/build.gradle.kts`에 `ktlint-compose` 의존성을 추가했습니다. - `.editorconfig` 파일에 `compose_allowed_composition_locals` 설정을 추가하여 `LocalColor`, `LocalTypography` 등의 사용을 허용했습니다. - **코드 스타일 및 컨벤션 수정:** - `@Composable` 함수의 네이밍 컨벤션에 맞춰 `@Preview` 함수의 이름을 `PascalCase`에서 `private` 접근자의 `camelCase`로 변경했습니다. (`CoilImage.kt`) - `TimeTagMenu.kt`에서 의미가 불분명했던 콜백 파라미터명 `onSizeDetermined`를 `onSizeDetermine`으로 더 명확하게 수정했습니다. - `PullToRefreshContainer.kt` 컴포저블의 파라미터 순서를 Modifier가 마지막에 오도록 조정했습니다. - `LostItemScreen.kt`에서 `EmptyStateScreen` 호출 시 불필요하게 전달되던 Modifier를 제거했습니다. --- .editorconfig | 3 +- app/build.gradle.kts | 1 + .../java/com/daedan/festabook/.gitkeep | 0 .../festabook/ExampleInstrumentedTest.kt | 24 ---- .../local/DeviceLocalDataSourceImpl.kt | 3 +- .../datasource/local/FcmDataSourceImpl.kt | 3 +- .../local/FestivalLocalDataSourceImpl.kt | 3 +- ...FestivalNotificationLocalDataSourceImpl.kt | 3 +- .../remote/device/DeviceDataSourceImpl.kt | 3 +- .../remote/faq/FAQDataSourceImpl.kt | 3 +- .../remote/festival/FestivalDataSourceImpl.kt | 3 +- .../FestivalNotificationDataSourceImpl.kt | 3 +- .../remote/lineup/LineupDataSourceImpl.kt | 3 +- .../remote/lostitem/LostItemDataSourceImpl.kt | 3 +- .../remote/notice/NoticeDataSourceImpl.kt | 3 +- .../remote/place/PlaceDataSourceImpl.kt | 3 +- .../remote/schedule/ScheduleDataSourceImpl.kt | 3 +- .../data/repository/DeviceRepositoryImpl.kt | 3 +- .../data/repository/ExploreRepositoryImpl.kt | 3 +- .../data/repository/FAQRepositoryImpl.kt | 3 +- .../FestivalNotificationRepositoryImpl.kt | 3 +- .../data/repository/FestivalRepositoryImpl.kt | 3 +- .../data/repository/LostItemRepositoryImpl.kt | 3 +- .../data/repository/NoticeRepositoryImpl.kt | 3 +- .../repository/PlaceDetailRepositoryImpl.kt | 3 +- .../repository/PlaceListRepositoryImpl.kt | 3 +- .../data/repository/ScheduleRepositoryImpl.kt | 3 +- .../service/api/FestaBookAuthInterceptor.kt | 3 +- .../di/fragment/MetroFragmentFactory.kt | 3 +- .../di/viewmodel/MetroViewModelFactory.kt | 3 +- .../festabook/domain/model/PlaceCategory.kt | 2 +- .../logging/DefaultFirebaseLogger.kt | 3 +- .../logging/FirebaseAnalyticsTree.kt | 3 +- .../presentation/common/AnimationUtil.kt | 2 +- .../presentation/common/FragmentUtil.kt | 14 +- .../presentation/common/ImageUtil.kt | 12 +- .../presentation/common/PermissionUtil.kt | 1 - .../common/component/CoilImage.kt | 2 +- .../component/PullToRefreshContainer.kt | 2 +- .../presentation/error/ErrorActivity.kt | 8 +- .../presentation/explore/ExploreActivity.kt | 5 - .../presentation/explore/ExploreViewModel.kt | 6 +- .../presentation/home/HomeFragment.kt | 6 +- .../presentation/home/HomeViewModel.kt | 4 +- .../home/adapter/PosterItemViewHolder.kt | 24 ++-- .../presentation/main/MainViewModel.kt | 3 +- .../news/faq/component/FAQScreen.kt | 3 - .../news/lost/component/LostItemScreen.kt | 5 +- .../news/notice/adapter/NewsClickListener.kt | 1 - .../news/notice/component/NoticeScreen.kt | 3 - .../placeDetail/PlaceDetailActivity.kt | 1 - .../placeDetail/PlaceDetailViewModel.kt | 9 +- .../adapter/PlaceImageViewPagerAdapter.kt | 2 - .../adapter/PlaceImageViewPagerViewHolder.kt | 20 +-- .../logging/PlaceDetailImageClick.kt | 4 +- .../logging/PlaceDetailImageSwipe.kt | 2 +- .../presentation/placeMap/PlaceMapFragment.kt | 4 +- .../placeMap/PlaceMapViewModel.kt | 3 +- .../placeCategory/PlaceCategoryFragment.kt | 6 +- .../PlaceDetailPreviewFragment.kt | 8 +- .../PlaceDetailPreviewSecondaryFragment.kt | 7 +- .../placeMap/placeList/PlaceListFragment.kt | 8 +- .../placeMap/placeList/PlaceListViewModel.kt | 3 +- .../timeTagSpinner/component/TimeTagMenu.kt | 6 +- .../presentation/schedule/ScheduleFragment.kt | 16 +-- .../schedule/ScheduleViewModel.kt | 5 +- .../schedule/adapter/ScheduleAdapter.kt | 1 - .../presentation/setting/SettingFragment.kt | 7 +- .../presentation/setting/SettingViewModel.kt | 4 +- .../presentation/splash/SplashViewModel.kt | 4 +- .../presentation/theme/FestabookColor.kt | 38 +++--- .../presentation/theme/FestabookShapes.kt | 5 +- .../presentation/theme/FestabookSpacing.kt | 5 +- .../presentation/theme/FestabookTheme.kt | 15 ++- .../presentation/theme/FestabookTypography.kt | 12 +- .../util/FestabookGlobalExceptionHandler.kt | 10 +- app/src/main/res/values/themes.xml | 5 +- .../com/daedan/festabook/ExampleUnitTest.kt | 17 --- .../festabook/home/HomeViewModelTest.kt | 6 +- .../placeDetail/PlaceDetailTestFixture.kt | 13 +- .../placeList/PlaceLIstTestFixture.kt | 78 +++++------ .../placeList/PlaceListViewModelTest.kt | 40 +++--- .../placeList/PlaceMapViewModelTest.kt | 70 +++++----- .../schedule/ScheduleViewModelTest.kt | 72 ++++++----- .../festabook/setting/SettingViewModelTest.kt | 122 +++++++++--------- .../festabook/splash/SplashViewModelTest.kt | 15 +-- gradle/libs.versions.toml | 2 + 87 files changed, 435 insertions(+), 425 deletions(-) create mode 100644 app/src/androidTest/java/com/daedan/festabook/.gitkeep delete mode 100644 app/src/androidTest/java/com/daedan/festabook/ExampleInstrumentedTest.kt delete mode 100644 app/src/test/java/com/daedan/festabook/ExampleUnitTest.kt diff --git a/.editorconfig b/.editorconfig index 35fccf76..79ae79e8 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,4 @@ [*.{kt,kts}] ktlint_standard_annotation = disabled -ktlint_function_naming_ignore_when_annotated_with = Composable \ No newline at end of file +ktlint_function_naming_ignore_when_annotated_with = Composable +compose_allowed_composition_locals = LocalColor, LocalShapes, LocalSpacing, LocalTypography \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 51201f5a..589e1d91 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -148,6 +148,7 @@ android { } dependencies { + ktlintRuleset(libs.ktlint) implementation(libs.map.sdk) implementation(libs.play.services.location) implementation(libs.androidx.core.ktx) diff --git a/app/src/androidTest/java/com/daedan/festabook/.gitkeep b/app/src/androidTest/java/com/daedan/festabook/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/app/src/androidTest/java/com/daedan/festabook/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/daedan/festabook/ExampleInstrumentedTest.kt deleted file mode 100644 index f3f80a96..00000000 --- a/app/src/androidTest/java/com/daedan/festabook/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.daedan.festabook - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.daedan.festabook", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/local/DeviceLocalDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/local/DeviceLocalDataSourceImpl.kt index bfa7e409..bd01077c 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/local/DeviceLocalDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/local/DeviceLocalDataSourceImpl.kt @@ -7,7 +7,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class DeviceLocalDataSourceImpl @Inject constructor( +@Inject +class DeviceLocalDataSourceImpl( private val prefs: SharedPreferences, ) : DeviceLocalDataSource { override fun saveUuid(uuid: String) { diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/local/FcmDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/local/FcmDataSourceImpl.kt index f552787c..90bd7ff8 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/local/FcmDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/local/FcmDataSourceImpl.kt @@ -7,7 +7,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class FcmDataSourceImpl @Inject constructor( +@Inject +class FcmDataSourceImpl constructor( private val prefs: SharedPreferences, ) : FcmDataSource { override fun saveFcmToken(token: String) { diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/local/FestivalLocalDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/local/FestivalLocalDataSourceImpl.kt index ddeca35e..3718ba63 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/local/FestivalLocalDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/local/FestivalLocalDataSourceImpl.kt @@ -7,7 +7,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class FestivalLocalDataSourceImpl @Inject constructor( +@Inject +class FestivalLocalDataSourceImpl( private val prefs: SharedPreferences, ) : FestivalLocalDataSource { override fun saveFestivalId(festivalId: Long) { diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/local/FestivalNotificationLocalDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/local/FestivalNotificationLocalDataSourceImpl.kt index 0b63778b..52642426 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/local/FestivalNotificationLocalDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/local/FestivalNotificationLocalDataSourceImpl.kt @@ -7,7 +7,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class FestivalNotificationLocalDataSourceImpl @Inject constructor( +@Inject +class FestivalNotificationLocalDataSourceImpl( private val prefs: SharedPreferences, ) : FestivalNotificationLocalDataSource { override fun saveFestivalNotificationId( diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/device/DeviceDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/device/DeviceDataSourceImpl.kt index b6d6e183..9a291465 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/device/DeviceDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/device/DeviceDataSourceImpl.kt @@ -9,7 +9,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class DeviceDataSourceImpl @Inject constructor( +@Inject +class DeviceDataSourceImpl( private val deviceService: DeviceService, ) : DeviceDataSource { override suspend fun registerDevice( diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/faq/FAQDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/faq/FAQDataSourceImpl.kt index 0f494024..7d1152ff 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/faq/FAQDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/faq/FAQDataSourceImpl.kt @@ -8,7 +8,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class FAQDataSourceImpl @Inject constructor( +@Inject +class FAQDataSourceImpl( private val faqService: FAQService, ) : FAQDataSource { override suspend fun fetchAllFAQs(): ApiResult> = ApiResult.toApiResult { faqService.fetchAllFAQs() } diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalDataSourceImpl.kt index fba1fd20..921da7ed 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalDataSourceImpl.kt @@ -9,7 +9,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class FestivalDataSourceImpl @Inject constructor( +@Inject +class FestivalDataSourceImpl( private val festivalService: FestivalService, ) : FestivalDataSource { override suspend fun fetchFestival(): ApiResult = diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationDataSourceImpl.kt index cc85a095..64bfcb73 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationDataSourceImpl.kt @@ -9,7 +9,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class FestivalNotificationDataSourceImpl @Inject constructor( +@Inject +class FestivalNotificationDataSourceImpl( private val festivalNotificationService: FestivalNotificationService, ) : FestivalNotificationDataSource { override suspend fun saveFestivalNotification( diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/lineup/LineupDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/lineup/LineupDataSourceImpl.kt index 6491d8bd..e06b5fe6 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/lineup/LineupDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/lineup/LineupDataSourceImpl.kt @@ -8,7 +8,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class LineupDataSourceImpl @Inject constructor( +@Inject +class LineupDataSourceImpl( private val festivalLineupService: FestivalLineupService, ) : LineupDataSource { override suspend fun fetchLineup(): ApiResult> = diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/lostitem/LostItemDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/lostitem/LostItemDataSourceImpl.kt index 3cebfbe1..0d7c149b 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/lostitem/LostItemDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/lostitem/LostItemDataSourceImpl.kt @@ -10,7 +10,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class LostItemDataSourceImpl @Inject constructor( +@Inject +class LostItemDataSourceImpl( private val lostItemService: LostItemService, private val festivalService: FestivalService, ) : LostItemDataSource { diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/notice/NoticeDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/notice/NoticeDataSourceImpl.kt index 185443a1..f6ec0e6b 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/notice/NoticeDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/notice/NoticeDataSourceImpl.kt @@ -8,7 +8,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class NoticeDataSourceImpl @Inject constructor( +@Inject +class NoticeDataSourceImpl( private val noticeService: NoticeService, ) : NoticeDataSource { override suspend fun fetchNotices(): ApiResult = ApiResult.toApiResult { noticeService.getNotices() } diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/place/PlaceDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/place/PlaceDataSourceImpl.kt index 8096e967..a181beb0 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/place/PlaceDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/place/PlaceDataSourceImpl.kt @@ -13,7 +13,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class PlaceDataSourceImpl @Inject constructor( +@Inject +class PlaceDataSourceImpl( private val placeService: PlaceService, private val festivalService: FestivalService, ) : PlaceDataSource { diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/schedule/ScheduleDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/schedule/ScheduleDataSourceImpl.kt index d7201396..65cdb0fa 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/schedule/ScheduleDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/schedule/ScheduleDataSourceImpl.kt @@ -9,7 +9,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class ScheduleDataSourceImpl @Inject constructor( +@Inject +class ScheduleDataSourceImpl( private val scheduleService: ScheduleService, ) : ScheduleDataSource { override suspend fun fetchScheduleEventsById(eventDateId: Long): ApiResult> = diff --git a/app/src/main/java/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt index 015fd979..603e9139 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt @@ -10,7 +10,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class DeviceRepositoryImpl @Inject constructor( +@Inject +class DeviceRepositoryImpl( private val deviceDataSource: DeviceDataSource, private val deviceLocalDataSource: DeviceLocalDataSource, private val fcmDataSource: FcmDataSource, diff --git a/app/src/main/java/com/daedan/festabook/data/repository/ExploreRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/ExploreRepositoryImpl.kt index 5a503bb1..322e86d5 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/ExploreRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/ExploreRepositoryImpl.kt @@ -12,7 +12,8 @@ import dev.zacsweers.metro.Inject import timber.log.Timber @ContributesBinding(AppScope::class) -class ExploreRepositoryImpl @Inject constructor( +@Inject +class ExploreRepositoryImpl( private val festivalDataSource: FestivalDataSource, private val festivalLocalDataSource: FestivalLocalDataSource, ) : ExploreRepository { diff --git a/app/src/main/java/com/daedan/festabook/data/repository/FAQRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/FAQRepositoryImpl.kt index 907afea1..070aff6d 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/FAQRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/FAQRepositoryImpl.kt @@ -10,7 +10,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class FAQRepositoryImpl @Inject constructor( +@Inject +class FAQRepositoryImpl( private val faqDataSource: FAQDataSource, ) : FAQRepository { override suspend fun getAllFAQ(): Result> { diff --git a/app/src/main/java/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt index 5dc1c36a..4f842fa7 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt @@ -12,7 +12,8 @@ import dev.zacsweers.metro.Inject import timber.log.Timber @ContributesBinding(AppScope::class) -class FestivalNotificationRepositoryImpl @Inject constructor( +@Inject +class FestivalNotificationRepositoryImpl( private val festivalNotificationDataSource: FestivalNotificationDataSource, private val deviceLocalDataSource: DeviceLocalDataSource, private val festivalNotificationLocalDataSource: FestivalNotificationLocalDataSource, diff --git a/app/src/main/java/com/daedan/festabook/data/repository/FestivalRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/FestivalRepositoryImpl.kt index b0875789..b1cc30f3 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/FestivalRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/FestivalRepositoryImpl.kt @@ -15,7 +15,8 @@ import dev.zacsweers.metro.Inject import java.time.LocalDate @ContributesBinding(AppScope::class) -class FestivalRepositoryImpl @Inject constructor( +@Inject +class FestivalRepositoryImpl( private val festivalDataSource: FestivalDataSource, private val festivalLocalDataSource: FestivalLocalDataSource, private val lineupDataSource: LineupDataSource, diff --git a/app/src/main/java/com/daedan/festabook/data/repository/LostItemRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/LostItemRepositoryImpl.kt index c080a513..10a3356f 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/LostItemRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/LostItemRepositoryImpl.kt @@ -13,7 +13,8 @@ import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @ContributesBinding(AppScope::class) -class LostItemRepositoryImpl @Inject constructor( +@Inject +class LostItemRepositoryImpl( private val lostItemDataSource: LostItemDataSource, ) : LostItemRepository { override suspend fun getPendingLostItems(): Result> = diff --git a/app/src/main/java/com/daedan/festabook/data/repository/NoticeRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/NoticeRepositoryImpl.kt index c673e912..c2ef585d 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/NoticeRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/NoticeRepositoryImpl.kt @@ -10,7 +10,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class NoticeRepositoryImpl @Inject constructor( +@Inject +class NoticeRepositoryImpl( private val noticeDataSource: NoticeDataSource, ) : NoticeRepository { override suspend fun fetchNotices(): Result> { diff --git a/app/src/main/java/com/daedan/festabook/data/repository/PlaceDetailRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/PlaceDetailRepositoryImpl.kt index cac14b30..eb0fda18 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/PlaceDetailRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/PlaceDetailRepositoryImpl.kt @@ -10,7 +10,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class PlaceDetailRepositoryImpl @Inject constructor( +@Inject +class PlaceDetailRepositoryImpl( private val placeDataSource: PlaceDataSource, ) : PlaceDetailRepository { override suspend fun getPlaceDetail(placeId: Long): Result { diff --git a/app/src/main/java/com/daedan/festabook/data/repository/PlaceListRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/PlaceListRepositoryImpl.kt index eedca070..ac2631f7 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/PlaceListRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/PlaceListRepositoryImpl.kt @@ -14,7 +14,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class PlaceListRepositoryImpl @Inject constructor( +@Inject +class PlaceListRepositoryImpl( private val placeDataSource: PlaceDataSource, ) : PlaceListRepository { override suspend fun getTimeTags(): Result> { diff --git a/app/src/main/java/com/daedan/festabook/data/repository/ScheduleRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/ScheduleRepositoryImpl.kt index 4bb9c244..eb8a2ca7 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/ScheduleRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/ScheduleRepositoryImpl.kt @@ -11,7 +11,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class ScheduleRepositoryImpl @Inject constructor( +@Inject +class ScheduleRepositoryImpl( private val scheduleDataSource: ScheduleDataSource, ) : ScheduleRepository { override suspend fun fetchAllScheduleDates(): Result> { diff --git a/app/src/main/java/com/daedan/festabook/data/service/api/FestaBookAuthInterceptor.kt b/app/src/main/java/com/daedan/festabook/data/service/api/FestaBookAuthInterceptor.kt index acbbe8af..1bbbdc9d 100644 --- a/app/src/main/java/com/daedan/festabook/data/service/api/FestaBookAuthInterceptor.kt +++ b/app/src/main/java/com/daedan/festabook/data/service/api/FestaBookAuthInterceptor.kt @@ -9,7 +9,8 @@ import okhttp3.Response import timber.log.Timber @ContributesBinding(AppScope::class) -class FestaBookAuthInterceptor @Inject constructor( +@Inject +class FestaBookAuthInterceptor( private val festivalLocalDataSource: FestivalLocalDataSource, ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { diff --git a/app/src/main/java/com/daedan/festabook/di/fragment/MetroFragmentFactory.kt b/app/src/main/java/com/daedan/festabook/di/fragment/MetroFragmentFactory.kt index d0a3bed6..8e6d51b2 100644 --- a/app/src/main/java/com/daedan/festabook/di/fragment/MetroFragmentFactory.kt +++ b/app/src/main/java/com/daedan/festabook/di/fragment/MetroFragmentFactory.kt @@ -9,7 +9,8 @@ import dev.zacsweers.metro.Provider import kotlin.reflect.KClass @ContributesBinding(AppScope::class) -class MetroFragmentFactory @Inject constructor( +@Inject +class MetroFragmentFactory( private val creators: Map, Provider>, ) : FragmentFactory() { override fun instantiate( diff --git a/app/src/main/java/com/daedan/festabook/di/viewmodel/MetroViewModelFactory.kt b/app/src/main/java/com/daedan/festabook/di/viewmodel/MetroViewModelFactory.kt index 8f380ea3..a353511d 100644 --- a/app/src/main/java/com/daedan/festabook/di/viewmodel/MetroViewModelFactory.kt +++ b/app/src/main/java/com/daedan/festabook/di/viewmodel/MetroViewModelFactory.kt @@ -10,7 +10,8 @@ import dev.zacsweers.metro.Provider import kotlin.reflect.KClass @ContributesBinding(AppScope::class) -class MetroViewModelFactory @Inject constructor( +@Inject +class MetroViewModelFactory( private val creators: Map, Provider>, ) : ViewModelProvider.Factory { @Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS") diff --git a/app/src/main/java/com/daedan/festabook/domain/model/PlaceCategory.kt b/app/src/main/java/com/daedan/festabook/domain/model/PlaceCategory.kt index 6dbc68a2..19a83f4e 100644 --- a/app/src/main/java/com/daedan/festabook/domain/model/PlaceCategory.kt +++ b/app/src/main/java/com/daedan/festabook/domain/model/PlaceCategory.kt @@ -11,7 +11,7 @@ enum class PlaceCategory { PRIMARY, STAGE, PHOTO_BOOTH, - EXTRA + EXTRA, ; companion object { diff --git a/app/src/main/java/com/daedan/festabook/logging/DefaultFirebaseLogger.kt b/app/src/main/java/com/daedan/festabook/logging/DefaultFirebaseLogger.kt index 1319e6c7..a80e8407 100644 --- a/app/src/main/java/com/daedan/festabook/logging/DefaultFirebaseLogger.kt +++ b/app/src/main/java/com/daedan/festabook/logging/DefaultFirebaseLogger.kt @@ -16,7 +16,8 @@ import kotlinx.coroutines.tasks.await import java.time.LocalDateTime @SingleIn(AppScope::class) -class DefaultFirebaseLogger @Inject constructor( +@Inject +class DefaultFirebaseLogger( private val firebaseAnalytics: FirebaseAnalytics, private val festivalLocalDataSource: FestivalLocalDataSource, private val festivalNotificationLocalDataSource: FestivalNotificationLocalDataSource, diff --git a/app/src/main/java/com/daedan/festabook/logging/FirebaseAnalyticsTree.kt b/app/src/main/java/com/daedan/festabook/logging/FirebaseAnalyticsTree.kt index 0c8b5781..58370b89 100644 --- a/app/src/main/java/com/daedan/festabook/logging/FirebaseAnalyticsTree.kt +++ b/app/src/main/java/com/daedan/festabook/logging/FirebaseAnalyticsTree.kt @@ -10,7 +10,8 @@ import timber.log.Timber import java.util.Locale @ContributesBinding(AppScope::class) -class FirebaseAnalyticsTree @Inject constructor( +@Inject +class FirebaseAnalyticsTree( private val analytics: FirebaseAnalytics, ) : Timber.Tree() { override fun log( diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/AnimationUtil.kt b/app/src/main/java/com/daedan/festabook/presentation/common/AnimationUtil.kt index eac13b87..9dcf8677 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/AnimationUtil.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/AnimationUtil.kt @@ -26,4 +26,4 @@ fun ViewGroup.showBottomAnimation() { .setDuration(300) // 0.5초 동안 .setInterpolator(DecelerateInterpolator()) // 점점 느려지게 .start() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/FragmentUtil.kt b/app/src/main/java/com/daedan/festabook/presentation/common/FragmentUtil.kt index 8d7c3f96..cee11651 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/FragmentUtil.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/FragmentUtil.kt @@ -4,13 +4,11 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.os.Build -import android.os.Bundle import android.os.Parcelable import android.util.TypedValue import android.view.View import android.view.ViewGroup import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.os.BundleCompat.getSerializable import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.Fragment import com.daedan.festabook.R @@ -20,17 +18,11 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.snackbar.Snackbar import java.io.Serializable -inline fun Bundle.getObject(key: String): T? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - getParcelable(key, T::class.java) - } else { - getParcelable(key) as? T - } - -inline fun Intent.getSerializableCompat(key: String):T? = +inline fun Intent.getSerializableCompat(key: String): T? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { getSerializableExtra(key, T::class.java) } else { + @Suppress("DEPRECATION") getSerializableExtra(key) as? T } @@ -38,6 +30,7 @@ inline fun Intent.getObject(key: String): T? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { getParcelableExtra(key, T::class.java) } else { + @Suppress("DEPRECATION") getParcelableExtra(key) as? T } @@ -56,6 +49,7 @@ fun View.getSystemBarHeightCompat() = WindowInsetsCompat.Type.systemBars(), ).bottom } else { + @Suppress("DEPRECATION") rootWindowInsets.systemWindowInsetBottom } diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/ImageUtil.kt b/app/src/main/java/com/daedan/festabook/presentation/common/ImageUtil.kt index 9f5efba3..34e61288 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/ImageUtil.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/ImageUtil.kt @@ -30,12 +30,12 @@ fun ImageView.loadImage( } } -fun String?.convertImageUrl() = if (this != null && this.startsWith("/images/")) { - BuildConfig.FESTABOOK_URL.removeSuffix("/api/") + this -} else { - this -} - +fun String?.convertImageUrl() = + if (this != null && this.startsWith("/images/")) { + BuildConfig.FESTABOOK_URL.removeSuffix("/api/") + this + } else { + this + } fun vectorToBitmap( context: Context, diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/PermissionUtil.kt b/app/src/main/java/com/daedan/festabook/presentation/common/PermissionUtil.kt index bf71e751..7403fff8 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/PermissionUtil.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/PermissionUtil.kt @@ -1,6 +1,5 @@ package com.daedan.festabook.presentation.common -import android.Manifest import android.content.Context import android.content.Intent import android.net.Uri diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt index 880ba912..734af705 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt @@ -40,7 +40,7 @@ fun CoilImage( @Composable @Preview -fun CoilImagePreview() { +private fun CoilImagePreview() { CoilImage( url = "", contentDescription = "", diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/PullToRefreshContainer.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/PullToRefreshContainer.kt index 18bc0b80..754b825b 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/component/PullToRefreshContainer.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/PullToRefreshContainer.kt @@ -63,8 +63,8 @@ private fun PullToRefreshIndicator( isRefreshing: Boolean, onRefresh: () -> Unit, pullOffsetLimit: Float, - modifier: Modifier = Modifier, threshold: Dp, + modifier: Modifier = Modifier, ) { val indicatorSize = (pullOffsetLimit / 5).dp val centerOffset = -(threshold / 2 - indicatorSize / 2) diff --git a/app/src/main/java/com/daedan/festabook/presentation/error/ErrorActivity.kt b/app/src/main/java/com/daedan/festabook/presentation/error/ErrorActivity.kt index b27f064e..11013a0d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/error/ErrorActivity.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/error/ErrorActivity.kt @@ -42,10 +42,14 @@ class ErrorActivity : AppCompatActivity() { companion object { private const val KEY_ERROR = "error" - fun newIntent(context: Context, error: Throwable): Intent = + + fun newIntent( + context: Context, + error: Throwable, + ): Intent = Intent(context, ErrorActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) putExtra(KEY_ERROR, error) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/explore/ExploreActivity.kt b/app/src/main/java/com/daedan/festabook/presentation/explore/ExploreActivity.kt index 52658ce9..6734ed29 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/explore/ExploreActivity.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/explore/ExploreActivity.kt @@ -13,7 +13,6 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.widget.doOnTextChanged import androidx.lifecycle.ViewModelProvider -import com.daedan.festabook.FestaBookApp import com.daedan.festabook.R import com.daedan.festabook.databinding.ActivityExploreBinding import com.daedan.festabook.di.appGraph @@ -26,14 +25,10 @@ import com.daedan.festabook.presentation.explore.adapter.SearchResultAdapter import com.daedan.festabook.presentation.explore.model.SearchResultUiModel import com.daedan.festabook.presentation.main.MainActivity import com.google.android.material.textfield.TextInputLayout -import dev.zacsweers.metro.ContributesBinding -import dev.zacsweers.metro.ContributesTo -import dev.zacsweers.metro.Inject class ExploreActivity : AppCompatActivity(), OnUniversityClickListener { - override val defaultViewModelProviderFactory: ViewModelProvider.Factory get() = appGraph.metroViewModelFactory private val binding by lazy { ActivityExploreBinding.inflate(layoutInflater) } diff --git a/app/src/main/java/com/daedan/festabook/presentation/explore/ExploreViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/explore/ExploreViewModel.kt index 3b0df115..c3f92b73 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/explore/ExploreViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/explore/ExploreViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey -import com.daedan.festabook.di.viewmodel.ViewModelScope import com.daedan.festabook.domain.repository.ExploreRepository import com.daedan.festabook.presentation.common.SingleLiveData import com.daedan.festabook.presentation.explore.model.SearchResultUiModel @@ -13,6 +12,7 @@ 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.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce @@ -20,9 +20,11 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import timber.log.Timber +@OptIn(FlowPreview::class) @ContributesIntoMap(AppScope::class) @ViewModelKey(ExploreViewModel::class) -class ExploreViewModel @Inject constructor( +@Inject +class ExploreViewModel( private val exploreRepository: ExploreRepository, ) : ViewModel() { private val searchQuery = MutableStateFlow("") diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt index 2b0b8349..26ac90a6 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt @@ -31,13 +31,13 @@ import timber.log.Timber @ContributesIntoMap(scope = AppScope::class, binding = binding()) @FragmentKey(HomeFragment::class) -class HomeFragment @Inject constructor( +@Inject +class HomeFragment( private val centerItemMotionEnlarger: RecyclerView.OnScrollListener, + override val defaultViewModelProviderFactory: ViewModelProvider.Factory, ) : BaseFragment() { override val layoutId: Int = R.layout.fragment_home - @Inject - override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory private val viewModel: HomeViewModel by viewModels({ requireActivity() }) private val posterAdapter: PosterAdapter by lazy { diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt index 85fb947f..5f0e991e 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey -import com.daedan.festabook.di.viewmodel.ViewModelScope import com.daedan.festabook.domain.repository.FestivalRepository import com.daedan.festabook.presentation.common.SingleLiveData import com.daedan.festabook.presentation.home.adapter.FestivalUiState @@ -16,7 +15,8 @@ import kotlinx.coroutines.launch @ContributesIntoMap(AppScope::class) @ViewModelKey(HomeViewModel::class) -class HomeViewModel @Inject constructor( +@Inject +class HomeViewModel( private val festivalRepository: FestivalRepository, ) : ViewModel() { private val _festivalUiState = MutableLiveData() diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/adapter/PosterItemViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/home/adapter/PosterItemViewHolder.kt index a4118b66..6d78fae5 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/adapter/PosterItemViewHolder.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/adapter/PosterItemViewHolder.kt @@ -19,19 +19,19 @@ import io.getstream.photoview.dialog.PhotoViewDialog class PosterItemViewHolder( private val binding: ItemHomePosterBinding, ) : RecyclerView.ViewHolder(binding.root) { - fun bind(url: String) { - val imageDialog = PhotoViewDialog.Builder( - context = binding.root.context, - images = listOf(url), - ) { imageView, url -> - imageView.load(url.convertImageUrl()) { - crossfade(true) - } - } - .withHiddenStatusBar(false) - .withTransitionFrom(binding.ivHomePoster) - .build() + val imageDialog = + PhotoViewDialog + .Builder( + context = binding.root.context, + images = listOf(url), + ) { imageView, url -> + imageView.load(url.convertImageUrl()) { + crossfade(true) + } + }.withHiddenStatusBar(false) + .withTransitionFrom(binding.ivHomePoster) + .build() binding.ivHomePoster.loadImage(url) { transformations(RoundedCornersTransformation(20f)) diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/MainViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/main/MainViewModel.kt index a97d8f05..32c0fb4b 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/main/MainViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/main/MainViewModel.kt @@ -17,7 +17,8 @@ import timber.log.Timber @ContributesIntoMap(AppScope::class) @ViewModelKey(MainViewModel::class) -class MainViewModel @Inject constructor( +@Inject +class MainViewModel( private val deviceRepository: DeviceRepository, festivalRepository: FestivalRepository, ) : ViewModel() { diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt index 57385446..3f9908d3 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt @@ -10,12 +10,9 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.EmptyStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen -import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.component.NewsItem import com.daedan.festabook.presentation.news.faq.FAQUiState import com.daedan.festabook.presentation.news.faq.model.FAQItemUiModel diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt index 6272bd0a..6ab0e0be 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt @@ -20,14 +20,11 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.EmptyStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen import com.daedan.festabook.presentation.common.component.PULL_OFFSET_LIMIT import com.daedan.festabook.presentation.common.component.PullToRefreshContainer -import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.component.NewsItem import com.daedan.festabook.presentation.news.lost.LostUiState import com.daedan.festabook.presentation.news.lost.model.LostItemUiStatus @@ -110,7 +107,7 @@ private fun LostItemContent( ) { val isLostItemEmpty = lostItems.none { it is LostUiModel.Item } if (isLostItemEmpty) { - EmptyStateScreen(modifier = modifier) + EmptyStateScreen() } LazyVerticalGrid( diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/adapter/NewsClickListener.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/adapter/NewsClickListener.kt index 36058886..975dca7e 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/adapter/NewsClickListener.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/notice/adapter/NewsClickListener.kt @@ -1,7 +1,6 @@ package com.daedan.festabook.presentation.news.notice.adapter import com.daedan.festabook.presentation.news.faq.model.FAQItemUiModel -import com.daedan.festabook.presentation.news.lost.model.LostUiModel import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel interface NewsClickListener { diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt index fbc2815a..7de31607 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt @@ -15,14 +15,11 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.EmptyStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen import com.daedan.festabook.presentation.common.component.PULL_OFFSET_LIMIT import com.daedan.festabook.presentation.common.component.PullToRefreshContainer -import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.component.NewsItem import com.daedan.festabook.presentation.news.notice.NoticeUiState import com.daedan.festabook.presentation.news.notice.NoticeUiState.Companion.DEFAULT_POSITION diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/PlaceDetailActivity.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/PlaceDetailActivity.kt index 0012b000..f61d63c1 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/PlaceDetailActivity.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/PlaceDetailActivity.kt @@ -19,7 +19,6 @@ import com.daedan.festabook.logging.logger import com.daedan.festabook.presentation.common.getObject import com.daedan.festabook.presentation.common.showErrorSnackBar import com.daedan.festabook.presentation.news.faq.model.FAQItemUiModel -import com.daedan.festabook.presentation.news.lost.model.LostUiModel import com.daedan.festabook.presentation.news.notice.adapter.NewsClickListener import com.daedan.festabook.presentation.news.notice.adapter.NoticeAdapter import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/PlaceDetailViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/PlaceDetailViewModel.kt index 9453bf4e..e03f4d84 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/PlaceDetailViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/PlaceDetailViewModel.kt @@ -83,11 +83,10 @@ class PlaceDetailViewModel @AssistedInject constructor( factory: Factory, place: PlaceUiModel?, receivedPlaceDetail: PlaceDetailUiModel?, - ) = - viewModelFactory { - initializer { - factory.create(place, receivedPlaceDetail) - } + ) = viewModelFactory { + initializer { + factory.create(place, receivedPlaceDetail) } + } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/adapter/PlaceImageViewPagerAdapter.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/adapter/PlaceImageViewPagerAdapter.kt index 06c804f0..623e9652 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/adapter/PlaceImageViewPagerAdapter.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/adapter/PlaceImageViewPagerAdapter.kt @@ -3,9 +3,7 @@ package com.daedan.festabook.presentation.placeDetail.adapter import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter -import com.daedan.festabook.presentation.common.loadImage import com.daedan.festabook.presentation.placeDetail.model.ImageUiModel -import io.getstream.photoview.dialog.PhotoViewDialog class PlaceImageViewPagerAdapter : ListAdapter(DIFF_UTIL_CALLBACK) { override fun onCreateViewHolder( diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/adapter/PlaceImageViewPagerViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/adapter/PlaceImageViewPagerViewHolder.kt index 580e826e..8c509daf 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/adapter/PlaceImageViewPagerViewHolder.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/adapter/PlaceImageViewPagerViewHolder.kt @@ -10,14 +10,15 @@ import io.getstream.photoview.dialog.PhotoViewDialog class PlaceImageViewPagerViewHolder( private val binding: ItemPlaceImageBinding, - private val images: List + private val images: List, ) : RecyclerView.ViewHolder(binding.root) { - private val imageDialogBuilder = PhotoViewDialog.Builder( - context = binding.root.context, - images = images.map { it.url } - ) { imageView, url -> - imageView.loadImage(url) - } + private val imageDialogBuilder = + PhotoViewDialog.Builder( + context = binding.root.context, + images = images.map { it.url }, + ) { imageView, url -> + imageView.loadImage(url) + } init { binding.ivPlaceImage.setOnClickListener { @@ -34,7 +35,10 @@ class PlaceImageViewPagerViewHolder( } companion object { - fun from(parent: ViewGroup, images: List): PlaceImageViewPagerViewHolder { + fun from( + parent: ViewGroup, + images: List, + ): PlaceImageViewPagerViewHolder { val layoutInflater = LayoutInflater.from(parent.context) val binding = ItemPlaceImageBinding.inflate(layoutInflater, parent, false) return PlaceImageViewPagerViewHolder(binding, images) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/logging/PlaceDetailImageClick.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/logging/PlaceDetailImageClick.kt index 999a2bd3..55399b3a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/logging/PlaceDetailImageClick.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/logging/PlaceDetailImageClick.kt @@ -6,5 +6,5 @@ import kotlinx.parcelize.Parcelize @Parcelize data class PlaceDetailImageClick( override val baseLogData: BaseLogData.CommonLogData, - val index: Int -) : BaseLogData \ No newline at end of file + val index: Int, +) : BaseLogData diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/logging/PlaceDetailImageSwipe.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/logging/PlaceDetailImageSwipe.kt index ac40e5b8..15529d30 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/logging/PlaceDetailImageSwipe.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/logging/PlaceDetailImageSwipe.kt @@ -7,4 +7,4 @@ import kotlinx.parcelize.Parcelize data class PlaceDetailImageSwipe( override val baseLogData: BaseLogData.CommonLogData, val startIndex: Int, -) : BaseLogData \ No newline at end of file +) : BaseLogData diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt index 773b6421..53b5778b 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt @@ -63,13 +63,11 @@ class PlaceMapFragment( placeCategoryFragment: PlaceCategoryFragment, placeDetailPreviewSecondaryFragment: PlaceDetailPreviewSecondaryFragment, mapFragment: MapFragment, + override val defaultViewModelProviderFactory: ViewModelProvider.Factory, ) : BaseFragment(), OnMenuItemReClickListener { override val layoutId: Int = R.layout.fragment_place_map - @Inject - override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory - private lateinit var naverMap: NaverMap private val placeListFragment by lazy { getIfExists(placeListFragment) } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt index 0cf4241e..c20e13f6 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt @@ -31,7 +31,8 @@ import kotlinx.coroutines.launch @ContributesIntoMap(AppScope::class) @ViewModelKey(PlaceMapViewModel::class) -class PlaceMapViewModel @Inject constructor( +@Inject +class PlaceMapViewModel( private val placeListRepository: PlaceListRepository, private val placeDetailRepository: PlaceDetailRepository, ) : ViewModel() { diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt index 44db4b87..91e932cc 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt @@ -23,11 +23,11 @@ import dev.zacsweers.metro.binding @ContributesIntoMap(scope = AppScope::class, binding = binding()) @FragmentKey(PlaceCategoryFragment::class) @Inject -class PlaceCategoryFragment : BaseFragment() { +class PlaceCategoryFragment( + override val defaultViewModelProviderFactory: ViewModelProvider.Factory, +) : BaseFragment() { override val layoutId: Int = R.layout.fragment_place_category - @Inject - override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory private val viewModel: PlaceMapViewModel by viewModels({ requireParentFragment() }) override fun onViewCreated( diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.kt index 474ca061..6e7e29d1 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.kt @@ -29,13 +29,11 @@ import dev.zacsweers.metro.binding @ContributesIntoMap(scope = AppScope::class, binding = binding()) @FragmentKey(PlaceDetailPreviewFragment::class) @Inject -class PlaceDetailPreviewFragment : - BaseFragment(), +class PlaceDetailPreviewFragment( + override val defaultViewModelProviderFactory: ViewModelProvider.Factory, +) : BaseFragment(), OnMenuItemReClickListener { override val layoutId: Int = R.layout.fragment_place_detail_preview - - @Inject - override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory private val viewModel: PlaceMapViewModel by viewModels({ requireParentFragment() }) private val backPressedCallback = object : OnBackPressedCallback(false) { diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewSecondaryFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewSecondaryFragment.kt index 58fb7cca..684f6bd0 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewSecondaryFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewSecondaryFragment.kt @@ -29,13 +29,12 @@ import dev.zacsweers.metro.binding @ContributesIntoMap(scope = AppScope::class, binding = binding()) @FragmentKey(PlaceDetailPreviewSecondaryFragment::class) @Inject -class PlaceDetailPreviewSecondaryFragment : - BaseFragment(), +class PlaceDetailPreviewSecondaryFragment( + override val defaultViewModelProviderFactory: ViewModelProvider.Factory, +) : BaseFragment(), OnMenuItemReClickListener { override val layoutId: Int = R.layout.fragment_place_detail_preview_secondary - @Inject - override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory private val viewModel: PlaceMapViewModel by viewModels({ requireParentFragment() }) private val backPressedCallback = object : OnBackPressedCallback(true) { diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt index 8c17bcf2..650fb199 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt @@ -54,15 +54,13 @@ import timber.log.Timber @ContributesIntoMap(scope = AppScope::class, binding = binding()) @FragmentKey(PlaceListFragment::class) @Inject -class PlaceListFragment : - BaseFragment(), +class PlaceListFragment( + override val defaultViewModelProviderFactory: ViewModelProvider.Factory, +) : BaseFragment(), OnPlaceClickListener, OnMenuItemReClickListener, OnMapReadyCallback { override val layoutId: Int = R.layout.fragment_place_list - - @Inject - override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory private val viewModel: PlaceMapViewModel by viewModels({ requireParentFragment() }) private val childViewModel: PlaceListViewModel by viewModels() diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt index 7704b83e..95fd917f 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt @@ -19,7 +19,8 @@ import kotlinx.coroutines.launch @ContributesIntoMap(AppScope::class) @ViewModelKey(PlaceListViewModel::class) -class PlaceListViewModel @Inject constructor( +@Inject +class PlaceListViewModel( private val placeListRepository: PlaceListRepository, ) : ViewModel() { private var cachedPlaces = listOf() diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/component/TimeTagMenu.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/component/TimeTagMenu.kt index 657db70c..769f9a55 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/component/TimeTagMenu.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/component/TimeTagMenu.kt @@ -74,7 +74,7 @@ fun TimeTagMenu( ) { TimeTagButton( title = title, - onSizeDetermined = { dropdownWidth = it }, + onSizeDetermine = { dropdownWidth = it }, ) DropdownMenu( expanded = expanded, @@ -119,14 +119,14 @@ fun TimeTagMenu( @OptIn(ExperimentalMaterial3Api::class) private fun ExposedDropdownMenuBoxScope.TimeTagButton( title: String, - onSizeDetermined: (IntSize) -> Unit, + onSizeDetermine: (IntSize) -> Unit, ) { Row( modifier = Modifier .width(140.dp) .onGloballyPositioned { coordinates -> - onSizeDetermined(coordinates.size) + onSizeDetermine(coordinates.size) }.menuAnchor( type = MenuAnchorType.PrimaryNotEditable, enabled = true, diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt index 5acec93e..f8d256b7 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt @@ -15,7 +15,6 @@ import com.daedan.festabook.logging.model.schedule.ScheduleMenuItemReClickLogDat import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.common.OnMenuItemReClickListener import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.placeDetail.PlaceDetailViewModel import com.daedan.festabook.presentation.schedule.adapter.SchedulePagerAdapter import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator @@ -31,20 +30,21 @@ import timber.log.Timber ) @FragmentKey(ScheduleFragment::class) @Inject -class ScheduleFragment : - BaseFragment(), +class ScheduleFragment( + private val viewModelFactory: ScheduleViewModel.Factory, +) : BaseFragment(), OnMenuItemReClickListener { override val layoutId: Int = R.layout.fragment_schedule - @Inject - private lateinit var viewModelFactory: ScheduleViewModel.Factory private val adapter: SchedulePagerAdapter by lazy { SchedulePagerAdapter(this) } - private val viewModel: ScheduleViewModel by viewModels { ScheduleViewModel.factory( - viewModelFactory - ) } + private val viewModel: ScheduleViewModel by viewModels { + ScheduleViewModel.factory( + viewModelFactory, + ) + } override fun onViewCreated( view: View, diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt index 2495957a..3f969afb 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt @@ -4,18 +4,15 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory -import com.daedan.festabook.FestaBookApp import com.daedan.festabook.domain.repository.ScheduleRepository import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus import com.daedan.festabook.presentation.schedule.model.toUiModel import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject -import dev.zacsweers.metro.Inject import kotlinx.coroutines.launch import java.time.LocalDate @@ -96,7 +93,7 @@ class ScheduleViewModel @AssistedInject constructor( fun factory( factory: Factory, - dateId: Long = INVALID_ID + dateId: Long = INVALID_ID, ): ViewModelProvider.Factory = viewModelFactory { initializer { diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleAdapter.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleAdapter.kt index c1e37207..50d6dd39 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleAdapter.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleAdapter.kt @@ -3,7 +3,6 @@ package com.daedan.festabook.presentation.schedule.adapter import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter -import com.daedan.festabook.presentation.schedule.OnBookmarkCheckedListener import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel class ScheduleAdapter : ListAdapter(DIFF_UTIL) { diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt index 05477317..a315674b 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt @@ -35,12 +35,10 @@ import timber.log.Timber @Inject class SettingFragment( private val notificationPermissionManagerFactory: NotificationPermissionManager.Factory, + override val defaultViewModelProviderFactory: ViewModelProvider.Factory, ) : BaseFragment(), NotificationPermissionRequester { override val layoutId: Int = R.layout.fragment_setting - - @Inject - override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory private val settingViewModel: SettingViewModel by viewModels({ requireActivity() }) private val homeViewModel: HomeViewModel by viewModels({ requireActivity() }) @@ -90,8 +88,7 @@ class SettingFragment( binding.tvSettingAppVersionName.text = versionName } - override fun shouldShowPermissionRationale(permission: String): Boolean = - shouldShowRequestPermissionRationale(permission) + override fun shouldShowPermissionRationale(permission: String): Boolean = shouldShowRequestPermissionRationale(permission) private fun setupObservers() { settingViewModel.permissionCheckEvent.observe(viewLifecycleOwner) { diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt index e73dbeb6..917ad461 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey -import com.daedan.festabook.di.viewmodel.ViewModelScope import com.daedan.festabook.domain.repository.FestivalNotificationRepository import com.daedan.festabook.presentation.common.SingleLiveData import dev.zacsweers.metro.AppScope @@ -16,7 +15,8 @@ import timber.log.Timber @ContributesIntoMap(AppScope::class) @ViewModelKey(SettingViewModel::class) -class SettingViewModel @Inject constructor( +@Inject +class SettingViewModel( private val festivalNotificationRepository: FestivalNotificationRepository, ) : ViewModel() { private val _permissionCheckEvent: SingleLiveData = SingleLiveData() diff --git a/app/src/main/java/com/daedan/festabook/presentation/splash/SplashViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/splash/SplashViewModel.kt index 8f149472..e307c8a2 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/splash/SplashViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/splash/SplashViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource import com.daedan.festabook.di.viewmodel.ViewModelKey -import com.daedan.festabook.di.viewmodel.ViewModelScope import com.daedan.festabook.presentation.common.SingleLiveData import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap @@ -14,7 +13,8 @@ import timber.log.Timber @ContributesIntoMap(AppScope::class) @ViewModelKey(SplashViewModel::class) -class SplashViewModel @Inject constructor( +@Inject +class SplashViewModel( private val festivalLocalDataSource: FestivalLocalDataSource, ) : ViewModel() { private val _navigationState = SingleLiveData() diff --git a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookColor.kt b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookColor.kt index 4f870a94..da3ce460 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookColor.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookColor.kt @@ -1,21 +1,29 @@ package com.daedan.festabook.presentation.theme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color -object FestabookColor { - val black = Color(0xFF1B1B1B) - val gray800 = Color(0xFF393939) - val gray700 = Color(0xFF555555) - val gray600 = Color(0xFF717171) - val gray500 = Color(0xFF8E8E8E) - val gray400 = Color(0xFFAAAAAA) - val gray300 = Color(0xFFC6C6C6) - val gray200 = Color(0xFFE3E3E3) - val gray100 = Color(0xFFF7F7F7) - val white = Color(0xFFFAFAFA) +data class FestabookColorPalette( + val black: Color = Color(0xFF1B1B1B), + val gray800: Color = Color(0xFF393939), + val gray700: Color = Color(0xFF555555), + val gray600: Color = Color(0xFF717171), + val gray500: Color = Color(0xFF8E8E8E), + val gray400: Color = Color(0xFFAAAAAA), + val gray300: Color = Color(0xFFC6C6C6), + val gray200: Color = Color(0xFFE3E3E3), + val gray100: Color = Color(0xFFF7F7F7), + val white: Color = Color(0xFFFAFAFA), + val accentBlue: Color = Color(0xFF0094FF), + val accentGreen: Color = Color(0xFF00AB40), + val error: Color = Color(0xFFFF4B3E), +) - val accentBlue = Color(0xFF0094FF) - val accentGreen = Color(0xFF00AB40) +val LocalColor = staticCompositionLocalOf { FestabookColorPalette() } - val error = Color(0xFFFF4B3E) -} +val FestabookColor: FestabookColorPalette + @Composable + @ReadOnlyComposable + get() = LocalColor.current diff --git a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookShapes.kt b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookShapes.kt index 8f08a185..dda2359f 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookShapes.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookShapes.kt @@ -3,6 +3,7 @@ package com.daedan.festabook.presentation.theme import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.unit.dp @@ -18,4 +19,6 @@ data class FestabookShapes( val LocalShapes = staticCompositionLocalOf { FestabookShapes() } val festabookShapes: FestabookShapes - @Composable get() = LocalShapes.current + @Composable + @ReadOnlyComposable + get() = LocalShapes.current diff --git a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookSpacing.kt b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookSpacing.kt index 3d12413b..c4f00cd2 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookSpacing.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookSpacing.kt @@ -1,6 +1,7 @@ package com.daedan.festabook.presentation.theme import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -17,4 +18,6 @@ data class FestabookSpacing( val LocalSpacing = staticCompositionLocalOf { FestabookSpacing() } val festabookSpacing - @Composable get() = LocalSpacing.current + @Composable + @ReadOnlyComposable + get() = LocalSpacing.current diff --git a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt index 5a7b46bb..69353cf5 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt @@ -5,18 +5,25 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -private val LightColorScheme = - lightColorScheme( - background = FestabookColor.white, - ) +private val LightColorScheme + @Composable + get() = + lightColorScheme( + background = FestabookColor.white, + ) @Composable fun FestabookTheme(content: @Composable () -> Unit) { val spacing = FestabookSpacing() val shapes = FestabookShapes() + val color = FestabookColorPalette() + val typography = FestabookTypographies + CompositionLocalProvider( LocalSpacing provides spacing, LocalShapes provides shapes, + LocalColor provides color, + LocalTypography provides typography, ) { MaterialTheme( colorScheme = LightColorScheme, diff --git a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTypography.kt b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTypography.kt index da30f20d..1f184887 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTypography.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTypography.kt @@ -1,6 +1,9 @@ package com.daedan.festabook.presentation.theme import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -11,7 +14,7 @@ private val PretendardBold = FontFamily(Font(R.font.pretendard_bold)) private val PretendardMedium = FontFamily(Font(R.font.pretendard_medium)) private val PretendardRegular = FontFamily(Font(R.font.pretendard_regular)) -val FestabookTypography = +val FestabookTypographies = Typography( displayLarge = TextStyle( @@ -74,3 +77,10 @@ val FestabookTypography = fontSize = 10.sp, ), ) + +val LocalTypography = staticCompositionLocalOf { FestabookTypographies } + +val FestabookTypography: Typography + @Composable + @ReadOnlyComposable + get() = LocalTypography.current diff --git a/app/src/main/java/com/daedan/festabook/util/FestabookGlobalExceptionHandler.kt b/app/src/main/java/com/daedan/festabook/util/FestabookGlobalExceptionHandler.kt index 37469d21..b3cd2a45 100644 --- a/app/src/main/java/com/daedan/festabook/util/FestabookGlobalExceptionHandler.kt +++ b/app/src/main/java/com/daedan/festabook/util/FestabookGlobalExceptionHandler.kt @@ -2,18 +2,20 @@ package com.daedan.festabook.util import android.app.Application import android.os.Process -import com.daedan.festabook.logging.DefaultFirebaseLogger import com.daedan.festabook.presentation.error.ErrorActivity import kotlin.system.exitProcess class FestabookGlobalExceptionHandler( private val application: Application, ) : Thread.UncaughtExceptionHandler { - override fun uncaughtException(t: Thread, e: Throwable) { + override fun uncaughtException( + t: Thread, + e: Throwable, + ) { application.startActivity( - ErrorActivity.newIntent(application, e) + ErrorActivity.newIntent(application, e), ) Process.killProcess(Process.myPid()) exitProcess(-1) } -} \ No newline at end of file +} diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 43024fe6..ce61dbaa 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,12 +1,15 @@ + diff --git a/app/src/test/java/com/daedan/festabook/ExampleUnitTest.kt b/app/src/test/java/com/daedan/festabook/ExampleUnitTest.kt deleted file mode 100644 index e6ee1864..00000000 --- a/app/src/test/java/com/daedan/festabook/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.daedan.festabook - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/app/src/test/java/com/daedan/festabook/home/HomeViewModelTest.kt b/app/src/test/java/com/daedan/festabook/home/HomeViewModelTest.kt index 1c7ddf06..d44b7c31 100644 --- a/app/src/test/java/com/daedan/festabook/home/HomeViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/home/HomeViewModelTest.kt @@ -129,14 +129,14 @@ class HomeViewModelTest { @Test fun `스케줄 이동 이벤트를 발생시킬 수 있다`() = runTest { - //given + // given val expect = Unit - //when + // when homeViewModel.navigateToScheduleClick() advanceUntilIdle() - //then + // then val actual = homeViewModel.navigateToScheduleEvent.value assertThat(actual).isEqualTo(expect) } diff --git a/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailTestFixture.kt b/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailTestFixture.kt index 7c6e0e77..6ef17fce 100644 --- a/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailTestFixture.kt +++ b/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailTestFixture.kt @@ -38,12 +38,13 @@ val FAKE_ETC_PLACE_DETAIL = imageUrl = null, description = null, location = null, - timeTags = listOf( - TimeTag( - timeTagId = 1, - name = "테스트1" - ) - ) + timeTags = + listOf( + TimeTag( + timeTagId = 1, + name = "테스트1", + ), + ), ), notices = emptyList(), host = null, diff --git a/app/src/test/java/com/daedan/festabook/placeList/PlaceLIstTestFixture.kt b/app/src/test/java/com/daedan/festabook/placeList/PlaceLIstTestFixture.kt index c24a1c8a..c29339a6 100644 --- a/app/src/test/java/com/daedan/festabook/placeList/PlaceLIstTestFixture.kt +++ b/app/src/test/java/com/daedan/festabook/placeList/PlaceLIstTestFixture.kt @@ -16,16 +16,17 @@ val FAKE_PLACES = title = "테스트 1", description = "설명 1", location = "위치 1", - timeTags = listOf( - TimeTag( - timeTagId = 1, - name = "테스트1" + timeTags = + listOf( + TimeTag( + timeTagId = 1, + name = "테스트1", + ), + TimeTag( + timeTagId = 2, + name = "테스트2", + ), ), - TimeTag( - timeTagId = 2, - name = "테스트2" - ) - ) ), Place( id = 2, @@ -34,12 +35,13 @@ val FAKE_PLACES = title = "테스트 2", description = "설명 2", location = "위치 2", - timeTags = listOf( - TimeTag( - timeTagId = 2, - name = "테스트2" + timeTags = + listOf( + TimeTag( + timeTagId = 2, + name = "테스트2", + ), ), - ) ), ) @@ -53,12 +55,13 @@ val FAKE_PLACE_GEOGRAPHIES = longitude = 1.0, ), "푸드트럭", - timeTags = listOf( - TimeTag( - timeTagId = 1, - name = "테스트1" - ) - ) + timeTags = + listOf( + TimeTag( + timeTagId = 1, + name = "테스트1", + ), + ), ), PlaceGeography( id = 1, @@ -68,12 +71,13 @@ val FAKE_PLACE_GEOGRAPHIES = longitude = 1.0, ), "부스", - timeTags = listOf( - TimeTag( - timeTagId = 1, - name = "테스트1" - ) - ) + timeTags = + listOf( + TimeTag( + timeTagId = 1, + name = "테스트1", + ), + ), ), PlaceGeography( id = 1, @@ -83,12 +87,13 @@ val FAKE_PLACE_GEOGRAPHIES = longitude = 1.0, ), "주점", - timeTags = listOf( - TimeTag( - timeTagId = 1, - name = "테스트1" - ) - ) + timeTags = + listOf( + TimeTag( + timeTagId = 1, + name = "테스트1", + ), + ), ), ) @@ -109,7 +114,8 @@ val FAKE_ORGANIZATION_GEOGRAPHY = ), ) -val FAKE_TIME_TAG = TimeTag( - timeTagId = 1, - name = "테스트1" -) +val FAKE_TIME_TAG = + TimeTag( + timeTagId = 1, + name = "테스트1", + ) diff --git a/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt b/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt index d2fa253d..a2b098af 100644 --- a/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt @@ -31,7 +31,7 @@ class PlaceListViewModelTest { val instantTaskExecutorRule = InstantTaskExecutorRule() private val testDispatcher = StandardTestDispatcher() private lateinit var placeListRepository: PlaceListRepository - private lateinit var PlaceListViewModel: PlaceListViewModel + private lateinit var placeListViewModel: PlaceListViewModel @Before fun setup() { @@ -46,7 +46,7 @@ class PlaceListViewModelTest { Result.success( FAKE_ORGANIZATION_GEOGRAPHY, ) - PlaceListViewModel = + placeListViewModel = PlaceListViewModel( placeListRepository, ) @@ -64,12 +64,12 @@ class PlaceListViewModelTest { coEvery { placeListRepository.getPlaces() } returns Result.success(FAKE_PLACES) // when - PlaceListViewModel = PlaceListViewModel(placeListRepository) + placeListViewModel = PlaceListViewModel(placeListRepository) advanceUntilIdle() // then val expected = FAKE_PLACES.map { it.toUiModel() } - val actual = PlaceListViewModel.places.getOrAwaitValue() + val actual = placeListViewModel.places.getOrAwaitValue() coVerify { placeListRepository.getPlaces() } assertThat(actual).isEqualTo(PlaceListUiState.PlaceLoaded(expected)) } @@ -80,17 +80,17 @@ class PlaceListViewModelTest { // given val targetCategories = listOf(PlaceCategoryUiModel.FOOD_TRUCK, PlaceCategoryUiModel.BOOTH) - PlaceListViewModel.updatePlacesByTimeTag(TimeTag.EMPTY.timeTagId) + placeListViewModel.updatePlacesByTimeTag(TimeTag.EMPTY.timeTagId) // when - PlaceListViewModel.updatePlacesByCategories(targetCategories) + placeListViewModel.updatePlacesByCategories(targetCategories) // then val expected = FAKE_PLACES .filter { it.category.toUiModel() in targetCategories } .map { it.toUiModel() } - val actual = PlaceListViewModel.places.getOrAwaitValue() + val actual = placeListViewModel.places.getOrAwaitValue() assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) } @@ -100,14 +100,14 @@ class PlaceListViewModelTest { // given val targetCategories = listOf(PlaceCategoryUiModel.SMOKING_AREA, PlaceCategoryUiModel.TOILET) - PlaceListViewModel.updatePlacesByTimeTag(TimeTag.EMPTY.timeTagId) + placeListViewModel.updatePlacesByTimeTag(TimeTag.EMPTY.timeTagId) // when - PlaceListViewModel.updatePlacesByCategories(targetCategories) + placeListViewModel.updatePlacesByCategories(targetCategories) // then val expected = FAKE_PLACES.map { it.toUiModel() } - val actual = PlaceListViewModel.places.getOrAwaitValue() + val actual = placeListViewModel.places.getOrAwaitValue() assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) } @@ -117,15 +117,15 @@ class PlaceListViewModelTest { // given val targetCategories = listOf(PlaceCategoryUiModel.FOOD_TRUCK, PlaceCategoryUiModel.BOOTH) - PlaceListViewModel.updatePlacesByTimeTag(TimeTag.EMPTY.timeTagId) - PlaceListViewModel.updatePlacesByCategories(targetCategories) + placeListViewModel.updatePlacesByTimeTag(TimeTag.EMPTY.timeTagId) + placeListViewModel.updatePlacesByCategories(targetCategories) // when - PlaceListViewModel.clearPlacesFilter() + placeListViewModel.clearPlacesFilter() // then val expected = FAKE_PLACES.map { it.toUiModel() } - val actual = PlaceListViewModel.places.getOrAwaitValue() + val actual = placeListViewModel.places.getOrAwaitValue() assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) } @@ -139,10 +139,10 @@ class PlaceListViewModelTest { ) // when - PlaceListViewModel.updatePlacesByTimeTag(1) + placeListViewModel.updatePlacesByTimeTag(1) // then - val actual = PlaceListViewModel.places.getOrAwaitValue() + val actual = placeListViewModel.places.getOrAwaitValue() assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) } @@ -154,10 +154,10 @@ class PlaceListViewModelTest { val emptyTimeTag = TimeTag.EMPTY // when - PlaceListViewModel.updatePlacesByTimeTag(emptyTimeTag.timeTagId) + placeListViewModel.updatePlacesByTimeTag(emptyTimeTag.timeTagId) // then - val actual = PlaceListViewModel.places.getOrAwaitValue() + val actual = placeListViewModel.places.getOrAwaitValue() assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) } @@ -168,10 +168,10 @@ class PlaceListViewModelTest { val expected = PlaceListUiState.Complete>() // when - PlaceListViewModel.setPlacesStateComplete() + placeListViewModel.setPlacesStateComplete() // then - val actual = PlaceListViewModel.places.getOrAwaitValue() + val actual = placeListViewModel.places.getOrAwaitValue() assertThat(actual).isInstanceOf(expected::class.java) } } diff --git a/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt b/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt index 903a0739..2f8ba679 100644 --- a/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt @@ -39,7 +39,7 @@ class PlaceMapViewModelTest { private val testDispatcher = StandardTestDispatcher() private lateinit var placeListRepository: PlaceListRepository private lateinit var placeDetailRepository: PlaceDetailRepository - private lateinit var PlaceMapViewModel: PlaceMapViewModel + private lateinit var placeMapViewModel: PlaceMapViewModel @Before fun setup() { @@ -61,7 +61,7 @@ class PlaceMapViewModelTest { FAKE_TIME_TAG, ), ) - PlaceMapViewModel = + placeMapViewModel = PlaceMapViewModel( placeListRepository, placeDetailRepository, @@ -77,13 +77,13 @@ class PlaceMapViewModelTest { fun `뷰모델을 생성했을 때 전체 타임 태그와 선택된 타임 태그를 불러올 수 있다`() = runTest { // given - when - PlaceMapViewModel = + placeMapViewModel = PlaceMapViewModel(placeListRepository, placeDetailRepository) advanceUntilIdle() // then - val actualAllTimeTag = PlaceMapViewModel.timeTags.getOrAwaitValue() - val actualSelectedTimeTag = PlaceMapViewModel.selectedTimeTag.getOrAwaitValue() + val actualAllTimeTag = placeMapViewModel.timeTags.value + val actualSelectedTimeTag = placeMapViewModel.selectedTimeTag.getOrAwaitValue() assertThat(actualAllTimeTag).isEqualTo(listOf(FAKE_TIME_TAG)) assertThat(actualSelectedTimeTag).isEqualTo(FAKE_TIME_TAG) } @@ -97,12 +97,12 @@ class PlaceMapViewModelTest { } returns Result.success(emptyList()) // when - PlaceMapViewModel = PlaceMapViewModel(placeListRepository, placeDetailRepository) + placeMapViewModel = PlaceMapViewModel(placeListRepository, placeDetailRepository) advanceUntilIdle() // then - val actualAllTimeTag = PlaceMapViewModel.timeTags.getOrAwaitValue() - val actualSelectedTimeTag = PlaceMapViewModel.selectedTimeTag.getOrAwaitValue() + val actualAllTimeTag = placeMapViewModel.timeTags.value + val actualSelectedTimeTag = placeMapViewModel.selectedTimeTag.getOrAwaitValue() assertThat(actualAllTimeTag).isEqualTo(emptyList()) assertThat(actualSelectedTimeTag).isEqualTo(TimeTag.EMPTY) } @@ -117,12 +117,12 @@ class PlaceMapViewModelTest { ) // when - PlaceMapViewModel = PlaceMapViewModel(placeListRepository, placeDetailRepository) + placeMapViewModel = PlaceMapViewModel(placeListRepository, placeDetailRepository) advanceUntilIdle() // then val expected = FAKE_PLACE_GEOGRAPHIES.map { it.toUiModel() } - val actual = PlaceMapViewModel.placeGeographies.getOrAwaitValue() + val actual = placeMapViewModel.placeGeographies.getOrAwaitValue() coVerify { placeListRepository.getPlaceGeographies() } assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) } @@ -137,12 +137,12 @@ class PlaceMapViewModelTest { ) // when - PlaceMapViewModel = PlaceMapViewModel(placeListRepository, placeDetailRepository) + placeMapViewModel = PlaceMapViewModel(placeListRepository, placeDetailRepository) advanceUntilIdle() // then val expected = FAKE_ORGANIZATION_GEOGRAPHY.toUiModel() - val actual = PlaceMapViewModel.initialMapSetting.getOrAwaitValue() + val actual = placeMapViewModel.initialMapSetting.getOrAwaitValue() assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) } @@ -159,16 +159,16 @@ class PlaceMapViewModelTest { coEvery { placeListRepository.getPlaceGeographies() } returns Result.failure(exception) // when - PlaceMapViewModel = PlaceMapViewModel(placeListRepository, placeDetailRepository) + placeMapViewModel = PlaceMapViewModel(placeListRepository, placeDetailRepository) advanceUntilIdle() // then val expected2 = PlaceListUiState.Success(FAKE_ORGANIZATION_GEOGRAPHY.toUiModel()) - val actual2 = PlaceMapViewModel.initialMapSetting.getOrAwaitValue() + val actual2 = placeMapViewModel.initialMapSetting.getOrAwaitValue() val expected3 = PlaceListUiState.Error(exception) - val actual3 = PlaceMapViewModel.placeGeographies.getOrAwaitValue() + val actual3 = placeMapViewModel.placeGeographies.getOrAwaitValue() assertThat(actual2).isEqualTo(expected2) assertThat(actual3).isEqualTo(expected3) @@ -184,14 +184,14 @@ class PlaceMapViewModelTest { ) // when - PlaceMapViewModel.selectPlace(1) + placeMapViewModel.selectPlace(1) advanceUntilIdle() // then coVerify { placeDetailRepository.getPlaceDetail(1) } val expected = SelectedPlaceUiState.Success(FAKE_PLACE_DETAIL.toUiModel()) - val actual = PlaceMapViewModel.selectedPlace.getOrAwaitValue() + val actual = placeMapViewModel.selectedPlace.getOrAwaitValue() assertThat(actual).isEqualTo(expected) } @@ -205,12 +205,12 @@ class PlaceMapViewModelTest { ) // when - PlaceMapViewModel.selectPlace(1) + placeMapViewModel.selectPlace(1) advanceUntilIdle() // then val expected = SelectedPlaceUiState.Success(FAKE_ETC_PLACE_DETAIL.toUiModel()) - val actual = PlaceMapViewModel.selectedPlace.getOrAwaitValue() + val actual = placeMapViewModel.selectedPlace.getOrAwaitValue() assertThat(actual).isEqualTo(expected) } @@ -222,16 +222,16 @@ class PlaceMapViewModelTest { Result.success( FAKE_PLACE_DETAIL, ) - PlaceMapViewModel.selectPlace(1) + placeMapViewModel.selectPlace(1) advanceUntilIdle() // when - PlaceMapViewModel.unselectPlace() + placeMapViewModel.unselectPlace() advanceUntilIdle() // then val expected = SelectedPlaceUiState.Empty - val actual = PlaceMapViewModel.selectedPlace.getOrAwaitValue() + val actual = placeMapViewModel.selectedPlace.getOrAwaitValue() assertThat(actual).isEqualTo(expected) } @@ -241,11 +241,11 @@ class PlaceMapViewModelTest { // given // when - PlaceMapViewModel.onBackToInitialPositionClicked() + placeMapViewModel.onBackToInitialPositionClicked() advanceUntilIdle() // then - val actual = PlaceMapViewModel.backToInitialPositionClicked.getOrAwaitValue() + val actual = placeMapViewModel.backToInitialPositionClicked.getOrAwaitValue() assertThat(actual).isInstanceOf(Event::class.java) } @@ -256,10 +256,10 @@ class PlaceMapViewModelTest { val isExceededMaxLength = true // when - PlaceMapViewModel.setIsExceededMaxLength(isExceededMaxLength) + placeMapViewModel.setIsExceededMaxLength(isExceededMaxLength) // then - val actual = PlaceMapViewModel.isExceededMaxLength.getOrAwaitValue() + val actual = placeMapViewModel.isExceededMaxLength.getOrAwaitValue() assertThat(actual).isEqualTo(isExceededMaxLength) } @@ -270,10 +270,10 @@ class PlaceMapViewModelTest { val categories = listOf(PlaceCategoryUiModel.FOOD_TRUCK, PlaceCategoryUiModel.BOOTH) // when - PlaceMapViewModel.setSelectedCategories(categories) + placeMapViewModel.setSelectedCategories(categories) // then - val actual = PlaceMapViewModel.selectedCategories.getOrAwaitValue() + val actual = placeMapViewModel.selectedCategories.getOrAwaitValue() assertThat(actual).isEqualTo(categories) } @@ -284,11 +284,11 @@ class PlaceMapViewModelTest { val expected = Unit // when - PlaceMapViewModel.onMapViewClick() + placeMapViewModel.onMapViewClick() advanceUntilIdle() // then - val actual = PlaceMapViewModel.onMapViewClick.getOrAwaitValue() + val actual = placeMapViewModel.onMapViewClick.getOrAwaitValue() assertThat(actual.peekContent()).isEqualTo(expected) } @@ -300,15 +300,15 @@ class PlaceMapViewModelTest { placeDetailRepository.getPlaceDetail(FAKE_PLACE_DETAIL.id) } returns Result.success(FAKE_PLACE_DETAIL) val expected = FAKE_PLACE_DETAIL.toUiModel() - PlaceMapViewModel.selectPlace(FAKE_PLACE_DETAIL.id) + placeMapViewModel.selectPlace(FAKE_PLACE_DETAIL.id) advanceUntilIdle() // when - PlaceMapViewModel.onExpandedStateReached() + placeMapViewModel.onExpandedStateReached() advanceUntilIdle() // then - val actual = PlaceMapViewModel.navigateToDetail.value + val actual = placeMapViewModel.navigateToDetail.value assertThat(actual).isEqualTo(expected) } @@ -319,11 +319,11 @@ class PlaceMapViewModelTest { val expected = TimeTag(1, "테스트1") // when - PlaceMapViewModel.onDaySelected(expected) + placeMapViewModel.onDaySelected(expected) advanceUntilIdle() // then - val actual = PlaceMapViewModel.selectedTimeTag.getOrAwaitValue() + val actual = placeMapViewModel.selectedTimeTag.getOrAwaitValue() assertThat(actual).isEqualTo(expected) } } diff --git a/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt b/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt index 4f9b09d3..f6631323 100644 --- a/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt @@ -45,13 +45,13 @@ class ScheduleViewModelTest { scheduleRepository = mockk() coEvery { scheduleRepository.fetchAllScheduleDates() } returns - Result.success( - FAKE_SCHEDULE_DATES, - ) + Result.success( + FAKE_SCHEDULE_DATES, + ) coEvery { scheduleRepository.fetchScheduleEventsById(dateId) } returns - Result.success( - FAKE_SCHEDULE_EVENTS, - ) + Result.success( + FAKE_SCHEDULE_EVENTS, + ) scheduleViewModel = ScheduleViewModel(scheduleRepository, dateId) } @@ -87,9 +87,9 @@ class ScheduleViewModelTest { runTest { // given coEvery { scheduleRepository.fetchScheduleEventsById(dateId) } returns - Result.success( - FAKE_SCHEDULE_EVENTS, - ) + Result.success( + FAKE_SCHEDULE_EVENTS, + ) // when scheduleViewModel.loadScheduleByDate() @@ -109,18 +109,18 @@ class ScheduleViewModelTest { runTest { // given coEvery { scheduleRepository.fetchScheduleEventsById(dateId) } returns - Result.success( - listOf( - ScheduleEvent( - id = 1L, - status = ScheduleEventStatus.UPCOMING, - startTime = "2025-07-26T10:00:00", - endTime = "2025-07-26T11:00:00", - title = "안드로이드 스터디", - location = "서울 강남구 어딘가", - ), + Result.success( + listOf( + ScheduleEvent( + id = 1L, + status = ScheduleEventStatus.UPCOMING, + startTime = "2025-07-26T10:00:00", + endTime = "2025-07-26T11:00:00", + title = "안드로이드 스터디", + location = "서울 강남구 어딘가", ), - ) + ), + ) // when scheduleViewModel.loadScheduleByDate() @@ -150,25 +150,27 @@ class ScheduleViewModelTest { } @Test - fun `모든 날짜의 축제 정보를 불러올 수 있다`() = runTest { - //given - coEvery { scheduleRepository.fetchAllScheduleDates() } returns + fun `모든 날짜의 축제 정보를 불러올 수 있다`() = + runTest { + // given + coEvery { scheduleRepository.fetchAllScheduleDates() } returns Result.success( FAKE_SCHEDULE_DATES, ) - val expected = ScheduleDatesUiState.Success( - FAKE_SCHEDULE_DATES.map { it.toUiModel() }, - 0 - ) + val expected = + ScheduleDatesUiState.Success( + FAKE_SCHEDULE_DATES.map { it.toUiModel() }, + 0, + ) - //when - scheduleViewModel.loadAllDates() - advanceUntilIdle() + // when + scheduleViewModel.loadAllDates() + advanceUntilIdle() - //then - coVerify { scheduleRepository.fetchAllScheduleDates() } - val actual = scheduleViewModel.scheduleDatesUiState.getOrAwaitValue() - assertThat(actual).isEqualTo(expected) - } + // then + coVerify { scheduleRepository.fetchAllScheduleDates() } + val actual = scheduleViewModel.scheduleDatesUiState.getOrAwaitValue() + assertThat(actual).isEqualTo(expected) + } } diff --git a/app/src/test/java/com/daedan/festabook/setting/SettingViewModelTest.kt b/app/src/test/java/com/daedan/festabook/setting/SettingViewModelTest.kt index 72253a83..f8d28898 100644 --- a/app/src/test/java/com/daedan/festabook/setting/SettingViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/setting/SettingViewModelTest.kt @@ -20,7 +20,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test - @OptIn(ExperimentalCoroutinesApi::class) class SettingViewModelTest { @get:Rule @@ -32,7 +31,6 @@ class SettingViewModelTest { private lateinit var festivalNotificationRepository: FestivalNotificationRepository - @Before fun setUp() { Dispatchers.setMain(testDispatcher) @@ -46,67 +44,71 @@ class SettingViewModelTest { } @Test - fun `알림 허용을 클릭했을 때 알림이 허용이 안되있다면 권한 요청 이벤트를 발생시킨다`() = runTest { - //given - coEvery { festivalNotificationRepository.getFestivalNotificationIsAllow() } returns false - val expected = Unit - - //when - settingViewModel = SettingViewModel(festivalNotificationRepository) - settingViewModel.notificationAllowClick() - advanceUntilIdle() - - //then - val actual = settingViewModel.permissionCheckEvent.value - assertThat(actual).isEqualTo(expected) - } + fun `알림 허용을 클릭했을 때 알림이 허용이 안되있다면 권한 요청 이벤트를 발생시킨다`() = + runTest { + // given + coEvery { festivalNotificationRepository.getFestivalNotificationIsAllow() } returns false + val expected = Unit + + // when + settingViewModel = SettingViewModel(festivalNotificationRepository) + settingViewModel.notificationAllowClick() + advanceUntilIdle() + + // then + val actual = settingViewModel.permissionCheckEvent.value + assertThat(actual).isEqualTo(expected) + } @Test - fun `알림 허용을 클릭했을 때 알림이 허용이 되있다면 알림id를 삭제한다`() = runTest { - //given - coEvery { festivalNotificationRepository.getFestivalNotificationIsAllow() } returns true - - //when - settingViewModel = SettingViewModel(festivalNotificationRepository) - settingViewModel.notificationAllowClick() - advanceUntilIdle() - - //then - val result = settingViewModel.isAllowed.getOrAwaitValue() - coVerify { festivalNotificationRepository.setFestivalNotificationIsAllow(false) } - coVerify { festivalNotificationRepository.deleteFestivalNotification() } - assertThat(result).isFalse() - } + fun `알림 허용을 클릭했을 때 알림이 허용이 되있다면 알림id를 삭제한다`() = + runTest { + // given + coEvery { festivalNotificationRepository.getFestivalNotificationIsAllow() } returns true + + // when + settingViewModel = SettingViewModel(festivalNotificationRepository) + settingViewModel.notificationAllowClick() + advanceUntilIdle() + + // then + val result = settingViewModel.isAllowed.getOrAwaitValue() + coVerify { festivalNotificationRepository.setFestivalNotificationIsAllow(false) } + coVerify { festivalNotificationRepository.deleteFestivalNotification() } + assertThat(result).isFalse() + } @Test - fun `알림 허용을 클릭했을 때 서버에 알림 정보 저장에 실패하면 이전 상태로 원복한다`() = runTest { - //given - coEvery { festivalNotificationRepository.getFestivalNotificationIsAllow() } returns true - coEvery { festivalNotificationRepository.deleteFestivalNotification() } returns Result.failure(Throwable()) - - //when - settingViewModel = SettingViewModel(festivalNotificationRepository) - settingViewModel.notificationAllowClick() - advanceUntilIdle() - - //then - val result = settingViewModel.isAllowed.getOrAwaitValue() - coVerify { festivalNotificationRepository.setFestivalNotificationIsAllow(true) } - assertThat(result).isTrue() - } + fun `알림 허용을 클릭했을 때 서버에 알림 정보 저장에 실패하면 이전 상태로 원복한다`() = + runTest { + // given + coEvery { festivalNotificationRepository.getFestivalNotificationIsAllow() } returns true + coEvery { festivalNotificationRepository.deleteFestivalNotification() } returns Result.failure(Throwable()) + + // when + settingViewModel = SettingViewModel(festivalNotificationRepository) + settingViewModel.notificationAllowClick() + advanceUntilIdle() + + // then + val result = settingViewModel.isAllowed.getOrAwaitValue() + coVerify { festivalNotificationRepository.setFestivalNotificationIsAllow(true) } + assertThat(result).isTrue() + } @Test - fun `알림을 허용했을 때 서버에 알림 정보 삭제에 실패하면 이전 상태로 원복한다`() = runTest { - //given - coEvery { festivalNotificationRepository.saveFestivalNotification() } returns Result.failure(Throwable()) - - //when - settingViewModel.saveNotificationId() - advanceUntilIdle() - - //then - val result = settingViewModel.isAllowed.getOrAwaitValue() - coVerify { festivalNotificationRepository.setFestivalNotificationIsAllow(false) } - assertThat(result).isFalse() - } -} \ No newline at end of file + fun `알림을 허용했을 때 서버에 알림 정보 삭제에 실패하면 이전 상태로 원복한다`() = + runTest { + // given + coEvery { festivalNotificationRepository.saveFestivalNotification() } returns Result.failure(Throwable()) + + // when + settingViewModel.saveNotificationId() + advanceUntilIdle() + + // then + val result = settingViewModel.isAllowed.getOrAwaitValue() + coVerify { festivalNotificationRepository.setFestivalNotificationIsAllow(false) } + assertThat(result).isFalse() + } +} diff --git a/app/src/test/java/com/daedan/festabook/splash/SplashViewModelTest.kt b/app/src/test/java/com/daedan/festabook/splash/SplashViewModelTest.kt index ebab4bf4..a60dc184 100644 --- a/app/src/test/java/com/daedan/festabook/splash/SplashViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/splash/SplashViewModelTest.kt @@ -19,7 +19,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test - @OptIn(ExperimentalCoroutinesApi::class) class SplashViewModelTest { @get:Rule @@ -44,15 +43,15 @@ class SplashViewModelTest { @Test fun `뷰모델을 생성할 때 현재 접속한 대학교가 있다면 MainActivity로 이동한다`() = runTest { - //given + // given coEvery { festivalLocalDataSource.getFestivalId() } returns 1 val expected = NavigationState.NavigateToMain(1) - //when + // when splashViewModel = SplashViewModel(festivalLocalDataSource) advanceUntilIdle() - //then + // then val actual = splashViewModel.navigationState.value assertThat(actual).isEqualTo(expected) } @@ -60,16 +59,16 @@ class SplashViewModelTest { @Test fun `뷰모델을 생성할 때 현재 접속한 대학교가 없다면 ExploreActivity로 이동한다`() = runTest { - //given + // given coEvery { festivalLocalDataSource.getFestivalId() } returns null val expected = NavigationState.NavigateToExplore - //when + // when splashViewModel = SplashViewModel(festivalLocalDataSource) advanceUntilIdle() - //then + // then val actual = splashViewModel.navigationState.value assertThat(actual).isEqualTo(expected) } -} \ No newline at end of file +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 59f07add..d8bb01b6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ junitVersion = "1.2.1" espressoCore = "3.6.1" appcompat = "1.7.0" kotlinxCoroutinesTest = "1.10.2" +ktlintVersion = "0.5.0" loggingInterceptor = "5.1.0" lottie = "6.6.6" mapSdk = "3.22.1" @@ -59,6 +60,7 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "j androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } +ktlint = { module = "io.nlopez.compose.rules:ktlint", version.ref = "ktlintVersion" } logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" } lottie = { module = "com.airbnb.android:lottie", version.ref = "lottie" } lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = "lottie" } From f6f69ec4c8cf3ffbf1318e17307a646e4fff40b7 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:34:09 +0900 Subject: [PATCH 044/140] =?UTF-8?q?[Feat]=20PlaceCategoryFragment=20Compos?= =?UTF-8?q?e=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20(#?= =?UTF-8?q?12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(PlaceMap): NaverMap을 Compose로 마이그레이션 기존 `MapFragment`를 사용하던 지도 표시 방식을 `Compose` 환경에 맞게 `NaverMapContent` 컴포저블로 마이그레이션했습니다. 이를 통해 지도 관련 UI 로직을 `Compose` 내에서 통합 관리할 수 있도록 개선했습니다. - **`NaverMapContent.kt` 컴포저블 추가:** - `AndroidView`를 사용해 `MapView`를 래핑하는 `NaverMapContent` 컴포저블을 새로 추가했습니다. - `DisposableEffect`를 활용하여 컴포저블의 생명주기와 `MapView`의 생명주기(`onCreate`, `onStart`, `onResume`, `onPause`, `onStop`, `onDestroy`)를 동기화했습니다. - `onDispose` 블록 내에서 `MapView`의 생명주기 메서드를 순차적으로 호출하여 메모리 누수를 방지하도록 처리했습니다. - 지도 드래그 이벤트를 감지하기 위해 `pointerInput` Modifier를 사용하여 드래그 인터셉터(`dragInterceptor`)를 구현했습니다. - **`PlaceMapScreen.kt` 컴포저블 추가:** - `NaverMapContent`와 시간대 선택 메뉴인 `TimeTagMenu`를 결합한 `PlaceMapScreen` 컴포저블을 추가하여 지도 화면의 전체 레이아웃을 구성했습니다. - **`PlaceMapFragment.kt` 리팩토링:** - 기존의 `MapFragment`와 `TimeTagMenu`를 직접 관리하던 XML 및 Fragment 코드를 제거했습니다. - `ComposeView`를 통해 새로 추가된 `PlaceMapScreen`을 화면에 표시하도록 변경했습니다. - `onMapReady` 콜백을 통해 `NaverMap` 객체를 받아와 지도 관련 로직을 설정하도록 수정했습니다. * refactor: timeTagSpinner, PlaceMap을 별도의 ComposeView로 분리 기존에 NaverMap과 함께 하나의 `ComposeView`(`cv_place_map`)에서 관리되던 시간대 선택 메뉴를 별도의 `ComposeView`(`cv_time_tag_spinner`)로 분리했습니다. 이를 통해 NaverMap은 지도 관련 UI만 담당하도록 역할을 명확히 하고, 시간대 선택 메뉴의 독립성을 높였습니다. - **`fragment_place_map.xml` 레이아웃 변경** - 시간대 선택 메뉴를 위한 `ComposeView`(`cv_time_tag_spinner`)를 추가했습니다. - `cv_time_tag_spinner`에 `translationZ="1dp"` 속성을 부여하여 지도 위에 표시되도록 했습니다. - NaverMap을 담는 `ComposeView`는 `cv_place_map`으로 유지하고, 내부에서 지도만 렌더링하도록 수정했습니다. - **`PlaceMapFragment.kt` 수정** - `cv_place_map`에서는 `NaverMapContent`를 호출하여 지도 초기화(`setupMap`)만 수행하도록 변경했습니다. - 새로 추가된 `cv_time_tag_spinner`에서 `TimeTagMenu` 컴포저블을 설정하여 시간대 선택 UI를 표시합니다. - `timeTags` 상태를 관찰하여 `TimeTagMenu`에 데이터를 전달하고, 태그 선택 시 `onTimeTagSelected` 콜백을 호출하는 로직은 그대로 유지됩니다. * refactor(PlaceMap): 지도 관련 컴포저블 코드 개선 지도 화면에 사용되는 `NaverMapContent`와 `NaverMapLogo` 컴포저블의 코드를 개선하고 리팩토링했습니다. - **`NaverMapContent.kt` 수정:** - `mapView`를 `remember`로만 감싸도록 변경하여 불필요한 `by mutableStateOf` 위임을 제거했습니다. - `DisposableEffect`의 `key`에서 `context`와 `savedInstanceState`를 제거하여 불필요한 재구성을 방지했습니다. - `pointerInput` Modifier 내에서 `awaitPointerEvent` 호출 시 사용되던 불필요한 주석을 삭제했습니다. - **`NaverMapLogo.kt` 추가:** - 네이버 지도 로고(`LogoView`)를 표시하기 위한 `NaverMapLogo` 컴포저블을 새로 추가했습니다. - `AndroidView`를 사용하여 기존 뷰 시스템의 `LogoView`를 Compose에서 사용할 수 있도록 래핑했습니다. * feat(PlaceMap): 장소 카테고리 선택 Compose View 추가 장소 지도 화면에 표시될 장소의 카테고리를 선택할 수 있는 `PlaceCategoryScreen` 컴포저블을 새로 추가했습니다. 이 컴포저블은 `FilterChip`을 사용하여 '전체' 및 개별 카테고리 필터링 기능을 제공합니다. - **`PlaceCategoryScreen.kt` 신규 추가** - `Row`와 `horizontalScroll`을 사용하여 가로로 스크롤 가능한 칩 그룹을 구현했습니다. - '전체' 칩과 `PlaceCategoryUiModel`에 정의된 각 카테고리 칩들을 표시합니다. - `FilterChip`을 사용하여 `CategoryChip` 컴포저블을 구현했으며, 선택 상태에 따라 다른 스타일(배경색, 테두리)을 적용했습니다. - 각 카테고리 칩에는 `PlaceCategoryUiModel`에 정의된 아이콘과 텍스트를 표시합니다. - 칩 클릭 시 `onDisplayAllClick` 또는 `onCategoryClick` 콜백을 호출하여 선택된 상태를 외부로 전달합니다. - **`NaverMapContent.kt` 수정** - `pointerInput` Modifier 내 불필요한 주석을 삭제하여 코드를 정리했습니다. * refactor(PlaceCategory): 시설 카테고리 선택 UI를 Compose로 마이그레이션 기존의 ChipGroup과 Chip View를 사용하던 시설 카테고리 선택 UI를 Compose 기반의 `PlaceCategoryScreen`으로 전면 교체했습니다. 이를 통해 UI 로직을 선언적으로 관리하고, 코드의 재사용성과 유지보수성을 향상시켰습니다. - **`PlaceCategoryFragment.kt` 리팩토링:** - 기존의 View(`FragmentPlaceCategoryBinding`) 및 Chip 관련 로직을 모두 제거했습니다. - `onCreateView`에서 `ComposeView`를 반환하도록 변경하고, 내부에 `PlaceCategoryScreen` 컴포저블을 설정했습니다. - 카테고리 클릭(`onCategoryClick`)과 '전체보기' 클릭(`onDisplayAllClick`) 이벤트 발생 시, `PlaceMapViewModel`의 상태를 업데이트하는 로직을 구현했습니다. - 여러 카테고리를 동시에 선택할 수 있도록 콜백 로직을 `List` 타입으로 수정했습니다. - **`PlaceCategoryScreen.kt` 수정:** - 카테고리 클릭 시 단일 `PlaceCategoryUiModel`이 아닌, 선택된 카테고리 목록(`List`)을 반환하도록 `onCategoryClick` 콜백의 시그니처를 변경했습니다. - **`PlaceCategoryUiModel.kt` 수정:** - `enum`으로 정의된 카테고리들의 순서를 논리적으로 재정렬했습니다. - **`FestaBookApp.kt` 수정:** - 개발 중 전역 예외 핸들러(`setGlobalExceptionHandler`) 설정을 임시로 주석 처리했습니다. * refactor(PlaceCategory): 카테고리 상태 관리 방식을 외부 주입으로 변경 `PlaceCategoryScreen` 컴포저블의 상태 관리 방식을 개선하여, 내부에서 `remember`로 관리하던 `selectedCategories` 상태를 외부에서 주입받도록 변경했습니다. 이를 통해 컴포저블의 재사용성을 높이고, 상태 관리를 상위 컴포저블 또는 Fragment/ViewModel로 위임하여 단일 책임 원칙을 강화했습니다. - **`PlaceCategoryScreen.kt` (컴포저블)** - 내부에서 `mutableStateOf`로 관리하던 `selectedCategories` 상태를 제거했습니다. - `selectedCategories`와 `initialCategories`를 파라미터로 직접 전달받도록 시그니처를 수정했습니다. - 카테고리 클릭(`onCategoryClick`) 및 전체 보기 클릭(`onDisplayAllClick`) 콜백에서 변경된 카테고리 셋(`Set`)을 상위로 전달하도록 수정했습니다. - **`PlaceCategoryFragment.kt` (프래그먼트)** - `PlaceCategoryScreen`에 표시할 `selectedCategoriesState`를 `remember`를 통해 상태로 관리하도록 추가했습니다. - ViewModel의 `selectedTimeTag`가 변경될 때마다, `selectedCategoriesState`가 빈 상태(`emptySet()`)로 초기화되도록 `remember`의 `key`를 설정했습니다. 이는 시간대가 바뀌면 카테고리 선택도 초기화되어야 하는 비즈니스 로직을 반영합니다. - `onCategoryClick`과 `onDisplayAllClick` 콜백을 구현하여, `PlaceCategoryScreen`에서 전달받은 값으로 `selectedCategoriesState`를 업데이트하고 ViewModel의 관련 메서드를 호출하도록 수정했습니다. * refactor(PlaceMap): Develop 브랜치 반영, MapInterceptorView 제거 기존에 `Fragment`에서 콜백 인터페이스(`OnTimeTagSelectedListener`)를 통해 처리하던 시간대(`TimeTag`) 선택 로직을 `ViewModel`이 직접 상태를 관리하고 Compose UI가 이를 구독하는 단방향 데이터 흐름(UDF) 방식으로 리팩토링했습니다. - **`PlaceMapFragment.kt` 리팩토링:** - `OnTimeTagSelectedListener` 인터페이스와 관련 콜백 메서드(`onTimeTagSelected`, `onNothingSelected`)를 제거했습니다. - `TimeTagMenu` 컴포저블의 `onTimeTagClick` 람다 내에서 `ViewModel`의 `onDaySelected`를 직접 호출하도록 변경하여 `Fragment`의 역할을 줄였습니다. - `ViewModel`의 `StateFlow`(`selectedTimeTagFlow`)를 구독하여 `TimeTagMenu`의 제목(`title`)을 동적으로 업데이트하도록 개선했습니다. - 더 이상 사용되지 않는 `MapTouchEventInterceptView`를 XML 레이아웃에서 삭제했습니다. - **`PlaceMapScreen.kt` 수정:** - 외부에서 `title`을 직접 전달받도록 파라미터를 변경하여, 컴포저블이 상태 비저장(Stateless) 방식으로 동작하도록 수정했습니다. - `onTimeTagSelected` 콜백의 이름을 `onTimeTagClick`으로 변경하여 일관성을 높였습니다. * fix: 항목 없는 카테고리 선택 후, 다른 카테고리 선택시 오류 메시지가 사라지지 않는 버그 해결 `PlaceMapFragment`와 `PlaceListFragment`에서 카테고리 선택에 따른 장소 필터링 로직을 리팩토링했습니다. 기존에는 선택된 카테고리(`selectedCategories`)가 비어있는 경우와 그렇지 않은 경우를 분기 처리하여 각각 필터 초기화(`clearFilter`, `clearPlacesFilter`) 또는 필터 적용(`filterMarkersByCategories`, `updatePlacesByCategories`) 메서드를 호출했습니다. 개선된 코드에서는 이 분기 로직을 제거하고, `filterMarkersByCategories`와 `updatePlacesByCategories` 메서드만 호출하도록 변경했습니다. 이는 해당 메서드들이 빈 카테고리 목록을 인자로 받았을 때 '전체 보기'와 동일하게 동작하도록 내부 로직이 구현되어 있음을 전제로 합니다. 이를 통해 코드의 중복을 줄이고 가독성을 높였습니다. - **`PlaceMapFragment.kt` 수정:** - `selectedCategories` 관찰 시, 조건 분기 없이 `mapManager?.filterMarkersByCategories(selectedCategories)`를 직접 호출하도록 변경했습니다. - **`PlaceListFragment.kt` 수정:** - `selectedCategories` 관찰 시, 조건 분기 없이 `childViewModel.updatePlacesByCategories(selectedCategories)`를 직접 호출하도록 변경했습니다. - 장소 목록(`places`)이 비어있지 않을 경우, 에러 메시지(`tvErrorToLoadPlaceInfo`)를 숨기는 로직을 추가했습니다. * fix: 타임태그가 없을 때 카테고리 선택 시 마커가 보이지 않는 문제 해결 기존에는 선택된 카테고리(`selectedCategories`)가 비어있는 경우와 그렇지 않은 경우를 분기 처리하여 각각 필터 초기화(`clearFilter`, `clearPlacesFilter`) 또는 필터 적용(`filterMarkersByCategories`, `updatePlacesByCategories`) 메서드를 호출했습니다. 개선된 코드에서는 이 분기 로직을 제거하고, `filterMarkersByCategories`와 `updatePlacesByCategories` 메서드만 호출하도록 변경했습니다. 이는 해당 메서드들이 빈 카테고리 목록을 인자로 받았을 때 '전체 보기'와 동일하게 동작하도록 내부 로직이 구현되어 있음을 전제로 합니다. 이를 통해 코드의 중복을 줄이고 가독성을 높였습니다. - **`PlaceMapFragment.kt` 수정:** - `selectedCategories` 관찰 시, 조건 분기 없이 `mapManager?.filterMarkersByCategories(selectedCategories)`를 직접 호출하도록 변경했습니다. - **`PlaceListFragment.kt` 수정:** - `selectedCategories` 관찰 시, 조건 분기 없이 `childViewModel.updatePlacesByCategories(selectedCategories)`를 직접 호출하도록 변경했습니다. - 장소 목록(`places`)이 비어있지 않을 경우, 에러 메시지(`tvErrorToLoadPlaceInfo`)를 숨기는 로직을 추가했습니다. * fix: 카테고리 선택 후 카테고리 해제 시 마커, 한 눈에 보기가 초기화되지 않는 문제 해결 기존에는 선택된 카테고리(`selectedCategories`)가 비어있는 경우와 그렇지 않은 경우를 분기 처리하여 각각 필터 초기화(`clearFilter`, `clearPlacesFilter`) 또는 필터 적용(`filterMarkersByCategories`, `updatePlacesByCategories`) 메서드를 호출했습니다. 개선된 코드에서는 이 분기 로직을 제거하고, `filterMarkersByCategories`와 `updatePlacesByCategories` 메서드만 호출하도록 변경했습니다. 이는 해당 메서드들이 빈 카테고리 목록을 인자로 받았을 때 '전체 보기'와 동일하게 동작하도록 내부 로직이 구현되어 있음을 전제로 합니다. 이를 통해 코드의 중복을 줄이고 가독성을 높였습니다. - **`PlaceMapFragment.kt` 수정:** - `selectedCategories` 관찰 시, 조건 분기 없이 `mapManager?.filterMarkersByCategories(selectedCategories)`를 직접 호출하도록 변경했습니다. - **`PlaceListFragment.kt` 수정:** - `selectedCategories` 관찰 시, 조건 분기 없이 `childViewModel.updatePlacesByCategories(selectedCategories)`를 직접 호출하도록 변경했습니다. - 장소 목록(`places`)이 비어있지 않을 경우, 에러 메시지(`tvErrorToLoadPlaceInfo`)를 숨기는 로직을 추가했습니다. --- .../placeMap/MapTouchEventInterceptView.kt | 66 -------- .../presentation/placeMap/MapUtil.kt | 14 -- .../placeMap/OnMapDragListener.kt | 5 - .../placeMap/OnTimeTagSelectedListener.kt | 9 - .../presentation/placeMap/PlaceMapFragment.kt | 65 ++++---- .../placeMap/component/NaverMapContent.kt | 154 ++++++++++++++++++ .../placeMap/component/NaverMapLogo.kt | 18 ++ .../placeMap/component/PlaceMapScreen.kt | 47 ++++++ .../placeMap/mapManager/MapFilterManager.kt | 2 +- .../internal/MapFilterManagerImpl.kt | 21 ++- .../placeMap/model/PlaceCategoryUiModel.kt | 14 +- .../placeCategory/PlaceCategoryFragment.kt | 88 ++++++---- .../component/PlaceCategoryScreen.kt | 140 ++++++++++++++++ .../placeMap/placeList/PlaceListFragment.kt | 2 + .../main/res/layout/fragment_place_map.xml | 29 ++-- 15 files changed, 488 insertions(+), 186 deletions(-) delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/MapTouchEventInterceptView.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/MapUtil.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/OnMapDragListener.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/OnTimeTagSelectedListener.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapLogo.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/component/PlaceCategoryScreen.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapTouchEventInterceptView.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapTouchEventInterceptView.kt deleted file mode 100644 index 01c1e1a8..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapTouchEventInterceptView.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.daedan.festabook.presentation.placeMap - -import android.content.Context -import android.util.AttributeSet -import android.view.GestureDetector -import android.view.MotionEvent -import android.widget.FrameLayout - -class MapTouchEventInterceptView( - context: Context, - attrs: AttributeSet? = null, -) : FrameLayout( - context, - attrs, - ) { - private var onMapDragListener: OnMapDragListener? = null - - private var isMapDragging = false - - private val gestureDetector by lazy { - GestureDetector( - context, - object : GestureDetector.SimpleOnGestureListener() { - override fun onFling( - e1: MotionEvent?, - e2: MotionEvent, - velocityX: Float, - velocityY: Float, - ): Boolean { - if (!isMapDragging) { - onMapDragListener?.onDrag() - isMapDragging = true - } - return super.onFling(e1, e2, velocityX, velocityY) - } - - override fun onScroll( - e1: MotionEvent?, - e2: MotionEvent, - distanceX: Float, - distanceY: Float, - ): Boolean { - if ((distanceY > 0 || distanceX > 0) && !isMapDragging) { - isMapDragging = true - onMapDragListener?.onDrag() - } - return super.onScroll(e1, e2, distanceX, distanceY) - } - }, - ) - } - - override fun onInterceptTouchEvent(event: MotionEvent?): Boolean { - event?.let { - if (it.action == MotionEvent.ACTION_UP) { - isMapDragging = false - } - gestureDetector.onTouchEvent(it) - } - return false - } - - fun setOnMapDragListener(listener: OnMapDragListener) { - onMapDragListener = listener - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapUtil.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapUtil.kt deleted file mode 100644 index a193bee1..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapUtil.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.daedan.festabook.presentation.placeMap - -import com.naver.maps.map.MapFragment -import com.naver.maps.map.NaverMap -import kotlinx.coroutines.suspendCancellableCoroutine - -suspend fun MapFragment.getMap() = - suspendCancellableCoroutine { cont -> - getMapAsync { map -> - cont.resumeWith( - Result.success(map), - ) - } - } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/OnMapDragListener.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/OnMapDragListener.kt deleted file mode 100644 index aa98139c..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/OnMapDragListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.daedan.festabook.presentation.placeMap - -fun interface OnMapDragListener { - fun onDrag() -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/OnTimeTagSelectedListener.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/OnTimeTagSelectedListener.kt deleted file mode 100644 index fb5eb580..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/OnTimeTagSelectedListener.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.daedan.festabook.presentation.placeMap - -import com.daedan.festabook.domain.model.TimeTag - -interface OnTimeTagSelectedListener { - fun onTimeTagSelected(item: TimeTag) - - fun onNothingSelected() -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt index 53b5778b..8c7a65f2 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt @@ -4,6 +4,7 @@ import android.content.Context import android.os.Bundle import android.view.View import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -15,7 +16,6 @@ import androidx.fragment.app.commit import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.lifecycleScope import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentPlaceMapBinding import com.daedan.festabook.di.fragment.FragmentKey @@ -25,6 +25,7 @@ import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.common.OnMenuItemReClickListener import com.daedan.festabook.presentation.common.showErrorSnackBar import com.daedan.festabook.presentation.common.toPx +import com.daedan.festabook.presentation.placeMap.component.NaverMapContent import com.daedan.festabook.presentation.placeMap.logging.CurrentLocationChecked import com.daedan.festabook.presentation.placeMap.logging.PlaceFragmentEnter import com.daedan.festabook.presentation.placeMap.logging.PlaceMarkerClick @@ -39,7 +40,6 @@ import com.daedan.festabook.presentation.placeMap.placeList.PlaceListFragment import com.daedan.festabook.presentation.placeMap.timeTagSpinner.component.TimeTagMenu import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTheme -import com.naver.maps.map.MapFragment import com.naver.maps.map.NaverMap import com.naver.maps.map.OnMapReadyCallback import com.naver.maps.map.util.FusedLocationSource @@ -48,7 +48,6 @@ import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import dev.zacsweers.metro.binding import dev.zacsweers.metro.createGraphFactory -import kotlinx.coroutines.launch import timber.log.Timber @ContributesIntoMap( @@ -62,7 +61,6 @@ class PlaceMapFragment( placeDetailPreviewFragment: PlaceDetailPreviewFragment, placeCategoryFragment: PlaceCategoryFragment, placeDetailPreviewSecondaryFragment: PlaceDetailPreviewSecondaryFragment, - mapFragment: MapFragment, override val defaultViewModelProviderFactory: ViewModelProvider.Factory, ) : BaseFragment(), OnMenuItemReClickListener { @@ -78,8 +76,6 @@ class PlaceMapFragment( placeDetailPreviewSecondaryFragment, ) } - private val mapFragment by lazy { getIfExists(mapFragment) } - private val locationSource by lazy { FusedLocationSource(this, LOCATION_PERMISSION_REQUEST_CODE) } @@ -94,7 +90,6 @@ class PlaceMapFragment( super.onViewCreated(view, savedInstanceState) if (savedInstanceState == null) { childFragmentManager.commit { - addWithSimpleTag(R.id.fcv_map_container, mapFragment) addWithSimpleTag(R.id.fcv_place_list_container, placeListFragment) addWithSimpleTag(R.id.fcv_map_container, placeDetailPreviewFragment) addWithSimpleTag(R.id.fcv_place_category_container, placeCategoryFragment) @@ -103,11 +98,9 @@ class PlaceMapFragment( hide(placeDetailPreviewSecondaryFragment) } } - lifecycleScope.launch { - setUpMapManager() - setupComposeView() - setUpObserver() - } + + setupComposeView() + binding.logger.log( PlaceFragmentEnter( baseLogData = binding.logger.getBaseLogData(), @@ -128,29 +121,27 @@ class PlaceMapFragment( mapManager?.moveToPosition() } - private suspend fun setUpMapManager() { - naverMap = mapFragment.getMap() - naverMap.addOnLocationChangeListener { - binding.logger.log( - CurrentLocationChecked( - baseLogData = binding.logger.getBaseLogData(), - ), - ) - } - (placeListFragment as? OnMapReadyCallback)?.onMapReady(naverMap) - naverMap.locationSource = locationSource - binding.viewMapTouchEventIntercept.setOnMapDragListener { - viewModel.onMapViewClick() - } - } - private fun setupComposeView() { binding.cvPlaceMap.apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { FestabookTheme { - val timeTags by viewModel.timeTags.collectAsStateWithLifecycle() - val title by viewModel.selectedTimeTagFlow.collectAsStateWithLifecycle() + NaverMapContent( + modifier = Modifier.fillMaxSize(), + onMapDrag = { viewModel.onMapViewClick() }, + onMapReady = { setupMap(it) }, + ) { + // TODO 흩어져있는 ComposeView 통합, 추후 PlaceMapScreen 사용 + } + } + } + } + binding.cvTimeTagSpinner.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val timeTags by viewModel.timeTags.collectAsStateWithLifecycle() + val title by viewModel.selectedTimeTagFlow.collectAsStateWithLifecycle() + FestabookTheme { if (timeTags.isNotEmpty()) { TimeTagMenu( title = title.name, @@ -176,6 +167,20 @@ class PlaceMapFragment( } } + private fun setupMap(map: NaverMap) { + naverMap = map + naverMap.addOnLocationChangeListener { + binding.logger.log( + CurrentLocationChecked( + baseLogData = binding.logger.getBaseLogData(), + ), + ) + } + (placeListFragment as? OnMapReadyCallback)?.onMapReady(naverMap) + naverMap.locationSource = locationSource + setUpObserver() + } + private fun setUpObserver() { viewModel.placeGeographies.observe(viewLifecycleOwner) { placeGeographies -> when (placeGeographies) { diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt new file mode 100644 index 00000000..aa72f6ee --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt @@ -0,0 +1,154 @@ +package com.daedan.festabook.presentation.placeMap.component + +import android.content.ComponentCallbacks2 +import android.content.res.Configuration +import android.os.Bundle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.naver.maps.map.MapView +import com.naver.maps.map.NaverMap + +@Composable +fun NaverMapContent( + modifier: Modifier = Modifier, + onMapDrag: () -> Unit = {}, + onMapReady: (NaverMap) -> Unit = {}, + content: @Composable () -> Unit, +) { + val context = LocalContext.current + val mapView = remember { MapView(context) } + AndroidView( + factory = { + mapView.apply { + getMapAsync(onMapReady) + } + }, + modifier = modifier.dragInterceptor(onMapDrag), + ) + RegisterMapLifeCycle(mapView) + content() +} + +private fun Modifier.dragInterceptor(onMapDrag: () -> Unit): Modifier = + this.then( + Modifier.pointerInput(Unit) { + val touchSlop = viewConfiguration.touchSlop // 시스템이 정의한 드래그 판단 기준 거리 + awaitPointerEventScope { + while (true) { + // 1. 첫 번째 터치(Down)를 기다립니다. + val downEvent = awaitPointerEvent(pass = PointerEventPass.Initial) + val downChange = downEvent.changes.firstOrNull { it.pressed } ?: continue + + // 터치 시작 지점 저장 + val startPosition = downChange.position + var isDragEmitted = false // 이번 드래그 세션에서 콜백을 호출했는지 체크 + + // 2. 터치가 유지되는 동안(드래그 중) 계속 감시합니다. + do { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == downChange.id } + + if (change != null && change.pressed) { + // 현재 위치와 시작 위치 사이의 거리 계산 + val currentPosition = change.position + val distance = (currentPosition - startPosition).getDistance() + + // 3. 이동 거리가 touchSlop보다 크고, 아직 콜백을 안 불렀다면 호출 + if (!isDragEmitted && distance > touchSlop) { + onMapDrag() + isDragEmitted = true + } + } + } while (event.changes.any { it.pressed }) // 손을 뗄 때까지 루프 + } + } + }, + ) + +@Composable +private fun RegisterMapLifeCycle(mapView: MapView) { + val context = LocalContext.current + val lifecycle = LocalLifecycleOwner.current.lifecycle + val previousState = remember { mutableStateOf(Lifecycle.Event.ON_CREATE) } + val savedInstanceState = rememberSaveable { Bundle() } + + DisposableEffect(lifecycle, mapView) { + val mapLifecycleObserver = + mapView.lifecycleObserver( + savedInstanceState.takeUnless { it.isEmpty }, + previousState, + ) + + val callbacks = + object : ComponentCallbacks2 { + override fun onConfigurationChanged(config: Configuration) = Unit + + @Deprecated("This callback is superseded by onTrimMemory") + override fun onLowMemory() { + mapView.onLowMemory() + } + + override fun onTrimMemory(level: Int) { + mapView.onLowMemory() + } + } + + lifecycle.addObserver(mapLifecycleObserver) + context.registerComponentCallbacks(callbacks) + onDispose { + mapView.onSaveInstanceState(savedInstanceState) + lifecycle.removeObserver(mapLifecycleObserver) + context.unregisterComponentCallbacks(callbacks) + + // dispose 시점에 Lifecycle.Event가 끝까지 진행되지 않아 발생되는 + // MapView Memory Leak 수정합니다. + when (previousState.value) { + Lifecycle.Event.ON_CREATE, Lifecycle.Event.ON_STOP -> { + mapView.onDestroy() + } + + Lifecycle.Event.ON_START, Lifecycle.Event.ON_PAUSE -> { + mapView.onStop() + mapView.onDestroy() + } + + Lifecycle.Event.ON_RESUME -> { + mapView.onPause() + mapView.onStop() + mapView.onDestroy() + } + + else -> Unit + } + } + } +} + +private fun MapView.lifecycleObserver( + savedInstanceState: Bundle?, + previousState: MutableState, +): LifecycleEventObserver = + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_CREATE -> this.onCreate(savedInstanceState) + Lifecycle.Event.ON_START -> this.onStart() + Lifecycle.Event.ON_RESUME -> this.onResume() + Lifecycle.Event.ON_PAUSE -> this.onPause() + Lifecycle.Event.ON_STOP -> this.onStop() + Lifecycle.Event.ON_DESTROY -> this.onDestroy() + else -> throw IllegalStateException() + } + previousState.value = event + } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapLogo.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapLogo.kt new file mode 100644 index 00000000..4fab146c --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapLogo.kt @@ -0,0 +1,18 @@ +package com.daedan.festabook.presentation.placeMap.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import com.naver.maps.map.widget.LogoView + +@Composable +fun NaverMapLogo(modifier: Modifier = Modifier) { + val context = LocalContext.current + val logoView = remember { LogoView(context) } + AndroidView( + factory = { logoView }, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt new file mode 100644 index 00000000..3443c865 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt @@ -0,0 +1,47 @@ +package com.daedan.festabook.presentation.placeMap.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.presentation.placeMap.timeTagSpinner.component.TimeTagMenu +import com.daedan.festabook.presentation.theme.FestabookColor +import com.naver.maps.map.NaverMap + +@Composable +fun PlaceMapScreen( + timeTags: List, + title: String, + onMapReady: (NaverMap) -> Unit, + onTimeTagClick: (TimeTag) -> Unit, + modifier: Modifier = Modifier, +) { + NaverMapContent( + modifier = modifier.fillMaxSize(), + onMapReady = onMapReady, + ) { + Column( + modifier = Modifier.wrapContentSize(), + ) { + if (timeTags.isNotEmpty()) { + TimeTagMenu( + title = title, + timeTags = timeTags, + onTimeTagClick = { timeTag -> + onTimeTagClick(timeTag) + }, + modifier = + Modifier + .background( + FestabookColor.white, + ).padding(horizontal = 24.dp), + ) + } + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapFilterManager.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapFilterManager.kt index c62a9065..ddccdc17 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapFilterManager.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapFilterManager.kt @@ -20,7 +20,7 @@ interface MapFilterManager { * * @param selectedTimeTagId 필터링에 사용할 시간 태그의 ID입니다. null 또는 특정 ID가 될 수 있습니다. */ - fun filterMarkersByTimeTag(selectedTimeTagId: Long?) + fun filterMarkersByTimeTag(selectedTimeTagId: Long) /** * 모든 필터링 조건을 해제하고 마커를 초기 상태로 복원합니다. diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapFilterManagerImpl.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapFilterManagerImpl.kt index d934fbd9..1283ecb9 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapFilterManagerImpl.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapFilterManagerImpl.kt @@ -20,25 +20,32 @@ class MapFilterManagerImpl( ) : MapFilterManager { private var selectedMarker: Marker? = null - private var selectedTimeTagId: Long? = null + private var selectedTimeTagId: Long = TimeTag.EMTPY_TIME_TAG_ID override fun filterMarkersByCategories(categories: List) { markers.forEach { marker -> val place = marker.tag as? PlaceCoordinateUiModel ?: return@forEach val isSelectedMarker = marker == selectedMarker - // 필터링된 마커이거나 선택된 마커인 경우에만 보이게 처리 - marker.isVisible = - place.category in categories && - place.timeTagIds.contains(selectedTimeTagId) || - isSelectedMarker + // 필터링된 마커이거나 선택된 마커인 경우에만 보이게 처리, + // 타임태그가 없다면, 타임태그 검사 생략 + if (selectedTimeTagId == TimeTag.EMTPY_TIME_TAG_ID) { + marker.isVisible = + place.category in categories || + isSelectedMarker + } else { + marker.isVisible = + place.category in categories && + place.timeTagIds.contains(selectedTimeTagId) || + isSelectedMarker + } // 선택된 마커는 크기를 유지하고, 필터링되지 않은 마커는 원래 크기로 되돌림 markerManager.setMarkerIcon(marker, isSelectedMarker) } } - override fun filterMarkersByTimeTag(selectedTimeTagId: Long?) { + override fun filterMarkersByTimeTag(selectedTimeTagId: Long) { if (selectedTimeTagId == TimeTag.EMTPY_TIME_TAG_ID) { markers.forEach { it.isVisible = true } return diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceCategoryUiModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceCategoryUiModel.kt index bad82c67..5b63718f 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceCategoryUiModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceCategoryUiModel.kt @@ -9,14 +9,18 @@ enum class PlaceCategoryUiModel { FOOD_TRUCK, BOOTH, BAR, - TRASH_CAN, - TOILET, - SMOKING_AREA, - PRIMARY, - PARKING, + STAGE, PHOTO_BOOTH, + PRIMARY, + EXTRA, + PARKING, + TOILET, + + SMOKING_AREA, + + TRASH_CAN, ; companion object { diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt index 91e932cc..edf66503 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt @@ -1,20 +1,30 @@ package com.daedan.festabook.presentation.placeMap.placeCategory import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import androidx.core.view.children +import android.view.ViewGroup +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.asFlow +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentPlaceCategoryBinding +import com.daedan.festabook.di.appGraph import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.logging.logger import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel import com.daedan.festabook.presentation.placeMap.logging.PlaceCategoryClick import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel -import com.google.android.material.chip.Chip +import com.daedan.festabook.presentation.placeMap.placeCategory.component.PlaceCategoryScreen +import com.daedan.festabook.presentation.theme.FestabookTheme import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject @@ -30,42 +40,48 @@ class PlaceCategoryFragment( private val viewModel: PlaceMapViewModel by viewModels({ requireParentFragment() }) - override fun onViewCreated( - view: View, + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - setUpBinding() - } + ): View = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + FestabookTheme { + val initialCategories = PlaceCategoryUiModel.entries + // StateFlow로 변경 시 asFlow 제거 예정 + val timeTagChanged = + viewModel.selectedTimeTag + .asFlow() + .collectAsStateWithLifecycle(viewLifecycleOwner) + var selectedCategoriesState by remember(timeTagChanged.value) { + mutableStateOf( + emptySet(), + ) + } - private fun setUpBinding() { - binding.cgCategories.setOnCheckedStateChangeListener { group, checkedIds -> - val selectedCategories = - checkedIds.mapNotNull { - val category = group.findViewById(it).tag - category as? PlaceCategoryUiModel + PlaceCategoryScreen( + initialCategories = initialCategories, + selectedCategories = selectedCategoriesState, + onCategoryClick = { selectedCategories -> + selectedCategoriesState = selectedCategories + viewModel.unselectPlace() + viewModel.setSelectedCategories(selectedCategories.toList()) + appGraph.defaultFirebaseLogger.log( + PlaceCategoryClick( + baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), + currentCategories = selectedCategories.joinToString(",") { it.toString() }, + ), + ) + }, + onDisplayAllClick = { selectedCategories -> + selectedCategoriesState = selectedCategories + viewModel.unselectPlace() + viewModel.setSelectedCategories(initialCategories) + }, + ) } - - viewModel.unselectPlace() - viewModel.setSelectedCategories(selectedCategories) - binding.chipCategoryAll.isChecked = selectedCategories.isEmpty() - binding.logger.log( - PlaceCategoryClick( - baseLogData = binding.logger.getBaseLogData(), - currentCategories = selectedCategories.joinToString(",") { it.toString() }, - ), - ) - } - - setUpChipCategoryAllListener() - } - - private fun setUpChipCategoryAllListener() { - binding.chipCategoryAll.setOnClickListener { - binding.cgCategories.children.forEach { - val chip = (it as? Chip) ?: return@forEach - chip.isChecked = chip.id == binding.chipCategoryAll.id } } - } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/component/PlaceCategoryScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/component/PlaceCategoryScreen.kt new file mode 100644 index 00000000..723992db --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/component/PlaceCategoryScreen.kt @@ -0,0 +1,140 @@ +package com.daedan.festabook.presentation.placeMap.placeCategory.component + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.getIconId +import com.daedan.festabook.presentation.placeMap.model.getTextId +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun PlaceCategoryScreen( + modifier: Modifier = Modifier, + selectedCategories: Set = emptySet(), + onDisplayAllClick: (selectedCategories: Set) -> Unit = {}, + onCategoryClick: (selectedCategories: Set) -> Unit = {}, + initialCategories: List = PlaceCategoryUiModel.entries, +) { + val scrollState = rememberScrollState() + + Row( + modifier = + modifier + .horizontalScroll(scrollState) + .padding( + vertical = festabookSpacing.paddingBody2, + horizontal = festabookSpacing.paddingScreenGutter, + ), + horizontalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody2), + ) { + CategoryChip( + text = stringResource(R.string.map_category_all), + selected = selectedCategories.isEmpty(), + onClick = { + onDisplayAllClick(emptySet()) + }, + ) + + initialCategories.forEach { category -> + val text = stringResource(category.getTextId()) + CategoryChip( + text = text, + selected = selectedCategories.contains(category), + icon = { + Icon( + painter = painterResource(category.getIconId()), + contentDescription = text, + tint = Color.Unspecified, + modifier = Modifier.size(FilterChipDefaults.IconSize), + ) + }, + onClick = { + val newSelectedCategories = + if (selectedCategories.contains(category)) { + selectedCategories.filter { it != category } + } else { + selectedCategories + setOf(category) + } + onCategoryClick(newSelectedCategories.toSet()) + }, + ) + } + } +} + +@Composable +private fun CategoryChip( + text: String, + modifier: Modifier = Modifier, + selected: Boolean = false, + icon: @Composable (() -> Unit)? = null, + onClick: () -> Unit = {}, +) { + FilterChip( + selected = selected, + onClick = { + onClick() + }, + modifier = modifier, + label = { + Text( + text = text, + style = FestabookTypography.bodyLarge, + ) + }, + shape = festabookShapes.radiusFull, + colors = + FilterChipDefaults.filterChipColors( + containerColor = FestabookColor.white, + selectedContainerColor = FestabookColor.gray200, + labelColor = FestabookColor.black, + selectedLabelColor = FestabookColor.black, + ), + border = + FilterChipDefaults.filterChipBorder( + enabled = true, + selected = selected, + borderColor = FestabookColor.gray200, + selectedBorderColor = FestabookColor.black, + borderWidth = 2.dp, + selectedBorderWidth = 2.dp, + ), + leadingIcon = icon, + ) +} + +@Composable +@Preview(showBackground = true) +private fun CategoryChipPreview() { + FestabookTheme { + CategoryChip("전체") + } +} + +@Composable +@Preview(showBackground = true) +private fun PlaceCategoryScreenPreview() { + FestabookTheme { + PlaceCategoryScreen() + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt index 650fb199..f44724cb 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt @@ -134,6 +134,8 @@ class PlaceListFragment( placeAdapter.submitList(places.value) { if (places.value.isEmpty()) { binding.tvErrorToLoadPlaceInfo.visibility = View.VISIBLE + } else { + binding.tvErrorToLoadPlaceInfo.visibility = View.GONE } binding.rvPlaces.scrollToPosition(0) } diff --git a/app/src/main/res/layout/fragment_place_map.xml b/app/src/main/res/layout/fragment_place_map.xml index c2df411e..6734ddfe 100644 --- a/app/src/main/res/layout/fragment_place_map.xml +++ b/app/src/main/res/layout/fragment_place_map.xml @@ -10,17 +10,15 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - - + - + + + app:layout_constraintTop_toBottomOf="@id/cv_time_tag_spinner" /> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/fcv_place_category_container" /> Date: Thu, 18 Dec 2025 21:54:26 +0900 Subject: [PATCH 045/140] =?UTF-8?q?refactor:=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20UI=20=EC=83=81=ED=83=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/ScheduleDatesUiState.kt | 2 +- .../schedule/ScheduleEventsUiState.kt | 8 +- .../presentation/schedule/ScheduleFragment.kt | 80 +---------- .../schedule/ScheduleTabPageFragment.kt | 12 +- .../schedule/ScheduleViewModel.kt | 136 ++++++++++-------- .../schedule/component/ScheduleScreen.kt | 36 +++-- .../schedule/component/ScheduleTabPage.kt | 98 +++++++------ .../schedule/component/ScheduleTabRow.kt | 6 +- .../schedule/ScheduleViewModelTest.kt | 78 +++++----- 9 files changed, 213 insertions(+), 243 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleDatesUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleDatesUiState.kt index 37a5d782..ae36d6d1 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleDatesUiState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleDatesUiState.kt @@ -7,7 +7,7 @@ sealed interface ScheduleDatesUiState { data class Success( val dates: List, - val initialDatePosition: Int, + val currentDatePosition: Int, ) : ScheduleDatesUiState data class Error( diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleEventsUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleEventsUiState.kt index f576e209..2462099a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleEventsUiState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleEventsUiState.kt @@ -3,10 +3,14 @@ package com.daedan.festabook.presentation.schedule import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel sealed interface ScheduleEventsUiState { - data object Loading : ScheduleEventsUiState + data object InitialLoading : ScheduleEventsUiState + + data class Refreshing( + val oldEvents: List, + ) : ScheduleEventsUiState data class Success( - val events: List, + val eventsByDate: Map>, val currentEventPosition: Int, ) : ScheduleEventsUiState diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt index fbb58f2d..2002d52d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt @@ -12,8 +12,6 @@ import androidx.lifecycle.ViewModelProvider import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentScheduleBinding import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.logging.logger -import com.daedan.festabook.logging.model.schedule.ScheduleMenuItemReClickLogData import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.common.OnMenuItemReClickListener import com.daedan.festabook.presentation.schedule.component.ScheduleScreen @@ -53,83 +51,9 @@ class ScheduleFragment : } } -// override fun onViewCreated( -// view: View, -// savedInstanceState: Bundle?, -// ) { -// binding.vpSchedule.adapter = adapter -// setupObservers() -// } - override fun onMenuItemReClick() { - viewModel.loadAllDates() +// viewModel.loadAllDates(ScheduleUiState.InitialLoading) // viewModel.loadScheduleByDate() - binding.logger.log(ScheduleMenuItemReClickLogData(binding.logger.getBaseLogData())) +// binding.logger.log(ScheduleMenuItemReClickLogData(binding.logger.getBaseLogData())) } - -// @SuppressLint("WrongConstant") -// private fun setupScheduleTabLayout(initialCurrentDateIndex: Int) { -// binding.vpSchedule.offscreenPageLimit = PRELOAD_PAGE_COUNT -// -// TabLayoutMediator(binding.tlSchedule, binding.vpSchedule) { tab, position -> -// setupScheduleTabView(tab, position) -// binding.vpSchedule.setCurrentItem(initialCurrentDateIndex, false) -// }.attach() -// } -// -// private fun setupScheduleTabView( -// tab: TabLayout.Tab, -// position: Int, -// ) { -// val itemScheduleTabBinding = -// ItemScheduleTabBinding.inflate( -// LayoutInflater.from(requireContext()), -// binding.tlSchedule, -// false, -// ) -// tab.customView = itemScheduleTabBinding.root -// -// itemScheduleTabBinding.tvScheduleTabItem.text = -// viewModel.scheduleDatesUiState.value -// .let { -// (it as? ScheduleDatesUiState.Success)?.dates?.get(position)?.date -// ?: EMPTY_DATE_TEXT -// } -// } - - private fun setupObservers() { -// viewModel.scheduleDatesUiState.observe(viewLifecycleOwner) { scheduleDatesUiState -> -// -// when (scheduleDatesUiState) { -// is ScheduleDatesUiState.Loading -> { -// showLoadingView(isLoading = true) -// } -// -// is ScheduleDatesUiState.Success -> { -// showLoadingView(isLoading = false) -// setupScheduleTabLayout(scheduleDatesUiState.initialDatePosition) -// adapter.submitList(scheduleDatesUiState.dates) -// } -// -// is ScheduleDatesUiState.Error -> { -// showLoadingView(isLoading = false) -// Timber.w( -// scheduleDatesUiState.throwable, -// "${this::class.simpleName}: ${scheduleDatesUiState.throwable.message}", -// ) -// showErrorSnackBar(scheduleDatesUiState.throwable) -// } -// } -// } - } - -// private fun showLoadingView(isLoading: Boolean) { -// binding.lavScheduleLoading.visibility = if (isLoading) View.VISIBLE else View.GONE -// binding.vpSchedule.visibility = if (isLoading) View.INVISIBLE else View.VISIBLE -// } -// -// companion object { -// private const val PRELOAD_PAGE_COUNT: Int = 2 -// private const val EMPTY_DATE_TEXT: String = "" -// } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleTabPageFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleTabPageFragment.kt index c51d8deb..388a1102 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleTabPageFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleTabPageFragment.kt @@ -2,24 +2,22 @@ package com.daedan.festabook.presentation.schedule import android.os.Bundle import android.view.View -import androidx.fragment.app.viewModels import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentScheduleTabPageBinding -import com.daedan.festabook.di.appGraph import com.daedan.festabook.logging.logger import com.daedan.festabook.logging.model.schedule.ScheduleSwipeRefreshLogData import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.schedule.ScheduleViewModel.Companion.INVALID_ID import com.daedan.festabook.presentation.schedule.adapter.ScheduleAdapter class ScheduleTabPageFragment : BaseFragment() { override val layoutId: Int = R.layout.fragment_schedule_tab_page - private val viewModel: ScheduleViewModel by viewModels { - val dateId: Long = arguments?.getLong(KEY_DATE_ID, INVALID_ID) ?: INVALID_ID - ScheduleViewModel.factory(appGraph.scheduleViewModelFactory, dateId) - } + + // private val viewModel: ScheduleViewModel by viewModels { +// val dateId: Long = arguments?.getLong(KEY_DATE_ID, INVALID_ID) ?: INVALID_ID +// ScheduleViewModel.factory(appGraph.scheduleViewModelFactory, dateId) +// } private val adapter: ScheduleAdapter by lazy { ScheduleAdapter() } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt index d98d397a..92aa8394 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt @@ -3,7 +3,10 @@ package com.daedan.festabook.presentation.schedule import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey +import com.daedan.festabook.domain.model.ScheduleDate import com.daedan.festabook.domain.repository.ScheduleRepository +import com.daedan.festabook.presentation.schedule.model.ScheduleDateUiModel +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus import com.daedan.festabook.presentation.schedule.model.toUiModel import dev.zacsweers.metro.AppScope @@ -12,7 +15,6 @@ import dev.zacsweers.metro.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.LocalDate @@ -22,84 +24,106 @@ import java.time.LocalDate class ScheduleViewModel( private val scheduleRepository: ScheduleRepository, ) : ViewModel() { - private val _scheduleDatesUiState: MutableStateFlow = + private val _scheduleDateUiState: MutableStateFlow = MutableStateFlow(ScheduleDatesUiState.InitialLoading) - val scheduleDatesUiState: StateFlow = _scheduleDatesUiState.asStateFlow() + val scheduleDateUiState: StateFlow = _scheduleDateUiState.asStateFlow() - private val _scheduleEventsByDate: MutableStateFlow> = - MutableStateFlow(emptyMap()) - val scheduleEventsByDate = _scheduleEventsByDate.asStateFlow() - - private val _selectedDateId: MutableStateFlow = MutableStateFlow(null) - val selectedDateId = _selectedDateId.asStateFlow() + private val _scheduleEventUiState: MutableStateFlow = + MutableStateFlow(ScheduleEventsUiState.InitialLoading) + val scheduleEventUiState: StateFlow = _scheduleEventUiState.asStateFlow() init { - loadAllDates() + loadSchedules(scheduleEventUiState = ScheduleEventsUiState.InitialLoading) } - fun onDateSelected(dateId: Long) { - _selectedDateId.value = dateId - - if (_scheduleEventsByDate.value.containsKey(dateId)) return - - loadScheduleByDate(dateId) + fun onDateSelected(selectedPosition: Int) { + (_scheduleDateUiState.value as? ScheduleDatesUiState.Success)?.let { + _scheduleDateUiState.value = it.copy(currentDatePosition = selectedPosition) + } } - fun loadScheduleByDate(dateId: Long) { + fun loadSchedules( + scheduleEventUiState: ScheduleEventsUiState, + selectedDatePosition: Int? = null, + ) { viewModelScope.launch { - _scheduleEventsByDate.value[dateId]?.let { return@launch } - - val result = scheduleRepository.fetchScheduleEventsById(dateId) - val uiState = - result.fold( - onSuccess = { scheduleEvents -> - val scheduleEventUiModels = scheduleEvents.map { it.toUiModel() } - val currentEventPosition = - scheduleEventUiModels - .indexOfFirst { - it.status == ScheduleEventUiStatus.ONGOING - }.coerceAtLeast(FIRST_INDEX) + _scheduleEventUiState.value = scheduleEventUiState + val datesResult = loadAllDates(selectedDatePosition) - ScheduleEventsUiState.Success( - scheduleEventUiModels, - currentEventPosition, - ) - }, - onFailure = { ScheduleEventsUiState.Error(it) }, - ) - - _scheduleEventsByDate.update { old -> - old + (dateId to uiState) + datesResult.onSuccess { scheduleDateUiModels -> + loadEvents(scheduleDateUiModels) } } } - fun loadAllDates() { - viewModelScope.launch { - _scheduleDatesUiState.value = ScheduleDatesUiState.InitialLoading - - val result = scheduleRepository.fetchAllScheduleDates() + private suspend fun loadAllDates(selectedDatePosition: Int?): Result> { + _scheduleDateUiState.value = ScheduleDatesUiState.InitialLoading + val result = scheduleRepository.fetchAllScheduleDates() + + return result.fold( + onSuccess = { scheduleDates -> + val scheduleDateUiModels = scheduleDates.map { it.toUiModel() } + val currentDatePosition = + selectedDatePosition ?: getCurrentDatePosition(scheduleDates) + + _scheduleDateUiState.value = + ScheduleDatesUiState.Success( + dates = scheduleDateUiModels, + currentDatePosition = currentDatePosition, + ) + + Result.success(scheduleDateUiModels) + }, + onFailure = { throwable -> + _scheduleDateUiState.value = ScheduleDatesUiState.Error(throwable) + Result.failure(throwable) + }, + ) + } - result - .onSuccess { scheduleDates -> - val scheduleDateUiModels = scheduleDates.map { it.toUiModel() } - val today = LocalDate.now() + private suspend fun loadEvents(scheduleDateUiModels: List) { + val allEvents = mutableMapOf>() + scheduleDateUiModels.forEachIndexed { position, scheduleDateUiModel -> + val eventsResult = + scheduleRepository.fetchScheduleEventsById(scheduleDateUiModel.id) - val initialDateId = - scheduleDates - .find { !it.date.isBefore(today) } - ?.id ?: scheduleDates.firstOrNull()?.id + eventsResult + .onSuccess { scheduleEvents -> + val scheduleEventUiModels = scheduleEvents.map { it.toUiModel() } + allEvents[position] = scheduleEventUiModels - _selectedDateId.value = initialDateId + val currentEventPosition = + getCurrentEventPosition(scheduleEventUiModels) - _scheduleDatesUiState.value = - ScheduleDatesUiState.Success(scheduleDateUiModels, initialDateId) + _scheduleEventUiState.value = + ScheduleEventsUiState.Success( + eventsByDate = allEvents, + currentEventPosition = currentEventPosition, + ) }.onFailure { - _scheduleDatesUiState.value = ScheduleDatesUiState.Error(it) + _scheduleEventUiState.value = ScheduleEventsUiState.Error(it) } } } + private fun getCurrentEventPosition(scheduleEventUiModels: List): Int { + val currentEventPosition = + scheduleEventUiModels + .indexOfFirst { + it.status == ScheduleEventUiStatus.ONGOING + }.coerceAtLeast(FIRST_INDEX) + return currentEventPosition + } + + private fun getCurrentDatePosition(scheduleDates: List): Int { + val today = LocalDate.now() + val currentDatePosition = + scheduleDates + .indexOfFirst { !it.date.isBefore(today) } + .coerceAtLeast(FIRST_INDEX) + return currentDatePosition + } + companion object { const val INVALID_ID: Long = -1L private const val FIRST_INDEX: Int = 0 diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt index 9719d949..9af1aced 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt @@ -17,7 +17,9 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.FestabookTopAppBar +import com.daedan.festabook.presentation.common.component.LoadingStateScreen import com.daedan.festabook.presentation.schedule.ScheduleDatesUiState +import com.daedan.festabook.presentation.schedule.ScheduleEventsUiState import com.daedan.festabook.presentation.schedule.ScheduleViewModel import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.festabookSpacing @@ -27,9 +29,9 @@ fun ScheduleScreen( scheduleViewModel: ScheduleViewModel, modifier: Modifier = Modifier, ) { - val scheduleDatesUiState by scheduleViewModel.scheduleDatesUiState.collectAsStateWithLifecycle() - val selectedDateId by scheduleViewModel.selectedDateId.collectAsStateWithLifecycle() - val eventStates by scheduleViewModel.scheduleEventsByDate.collectAsStateWithLifecycle() + val scheduleDatesUiState by scheduleViewModel.scheduleDateUiState.collectAsStateWithLifecycle() + val scheduleEventsUiState by scheduleViewModel.scheduleEventUiState.collectAsStateWithLifecycle() + val isRefreshing = scheduleEventsUiState is ScheduleEventsUiState.Refreshing Scaffold( topBar = { FestabookTopAppBar(title = stringResource(R.string.schedule_title)) }, @@ -41,16 +43,20 @@ fun ScheduleScreen( } ScheduleDatesUiState.InitialLoading -> { + LoadingStateScreen() } is ScheduleDatesUiState.Success -> { - val scheduleDates = (scheduleDatesUiState as ScheduleDatesUiState.Success).dates - val pageState = rememberPagerState { scheduleDates.size } + val scheduleDatesUiStateSuccess = + scheduleDatesUiState as ScheduleDatesUiState.Success + val pageState = + rememberPagerState( + initialPage = scheduleDatesUiStateSuccess.currentDatePosition, + ) { scheduleDatesUiStateSuccess.dates.size } val scope = rememberCoroutineScope() LaunchedEffect(pageState.currentPage) { - val selectedDateId = scheduleDates[pageState.currentPage].id - scheduleViewModel.onDateSelected(selectedDateId) + scheduleViewModel.onDateSelected(pageState.currentPage) } Column( @@ -59,7 +65,7 @@ fun ScheduleScreen( ScheduleTabRow( pageState = pageState, scope = scope, - scheduleDates = scheduleDates, + dates = scheduleDatesUiStateSuccess.dates, ) Spacer(modifier = Modifier.height(festabookSpacing.paddingBody4)) HorizontalDivider( @@ -69,9 +75,17 @@ fun ScheduleScreen( ) ScheduleTabPage( pagerState = pageState, - selectedDatedId = selectedDateId, - eventStates = eventStates, - onRefresh = { }, + scheduleEventsUiState = scheduleEventsUiState, + isRefreshing = isRefreshing, + onRefresh = { + val oldEvents = + (scheduleEventsUiState as? ScheduleEventsUiState.Refreshing)?.oldEvents + ?: emptyList() + scheduleViewModel.loadSchedules( + scheduleEventUiState = ScheduleEventsUiState.Refreshing(oldEvents), + selectedDatePosition = pageState.currentPage, + ) + }, ) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt index 86225ac5..12d16fa9 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt @@ -30,50 +30,50 @@ import com.daedan.festabook.presentation.theme.festabookSpacing @Composable fun ScheduleTabPage( pagerState: PagerState, - selectedDatedId: Long?, - eventStates: Map, + scheduleEventsUiState: ScheduleEventsUiState, + isRefreshing: Boolean, onRefresh: () -> Unit, modifier: Modifier = Modifier, ) { HorizontalPager(state = pagerState, modifier = modifier) { - val uiState = eventStates[selectedDatedId] - val isRefreshing = uiState is ScheduleEventsUiState.Refreshing - PullToRefreshContainer( isRefreshing = isRefreshing, onRefresh = onRefresh, ) { pullToRefreshState -> + when (scheduleEventsUiState) { + is ScheduleEventsUiState.Error -> { + } - if (uiState == null) { - EmptyStateScreen() - } else { - when (uiState) { - is ScheduleEventsUiState.Error -> { - } - - ScheduleEventsUiState.InitialLoading -> { - LoadingStateScreen() - } + ScheduleEventsUiState.InitialLoading -> { + LoadingStateScreen() + } - is ScheduleEventsUiState.Refreshing -> { - } + is ScheduleEventsUiState.Refreshing -> { + ScheduleTabContent( + scheduleEvents = scheduleEventsUiState.oldEvents, + modifier = + Modifier + .padding(end = festabookSpacing.paddingScreenGutter) + .graphicsLayer { + translationY = + pullToRefreshState.distanceFraction * PULL_OFFSET_LIMIT + }, + ) + } - is ScheduleEventsUiState.Success -> { - if (uiState.events.isEmpty()) { - EmptyStateScreen() - } else { - ScheduleTabContent( - scheduleEvents = uiState.events, - modifier = - Modifier - .padding(end = festabookSpacing.paddingScreenGutter) - .graphicsLayer { - translationY = - pullToRefreshState.distanceFraction * PULL_OFFSET_LIMIT - }, - ) - } - } + is ScheduleEventsUiState.Success -> { + ScheduleTabContent( + scheduleEvents = + scheduleEventsUiState.eventsByDate[pagerState.currentPage] + ?: emptyList(), + modifier = + Modifier + .padding(end = festabookSpacing.paddingScreenGutter) + .graphicsLayer { + translationY = + pullToRefreshState.distanceFraction * PULL_OFFSET_LIMIT + }, + ) } } } @@ -85,20 +85,24 @@ private fun ScheduleTabContent( scheduleEvents: List, modifier: Modifier = Modifier, ) { - Box(modifier = modifier) { - VerticalDivider( - thickness = 1.dp, - color = FestabookColor.gray300, - modifier = - Modifier - .padding(start = festabookSpacing.paddingScreenGutter + festabookSpacing.paddingBody4), - ) - LazyColumn( - verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody5), - contentPadding = PaddingValues(vertical = festabookSpacing.paddingBody5), - ) { - items(items = scheduleEvents, key = { scheduleEvent -> scheduleEvent.id }) { - ScheduleEventItem(scheduleEvent = it) + if (scheduleEvents.isEmpty()) { + EmptyStateScreen(modifier = modifier) + } else { + Box(modifier = modifier) { + VerticalDivider( + thickness = 1.dp, + color = FestabookColor.gray300, + modifier = + Modifier + .padding(start = festabookSpacing.paddingScreenGutter + festabookSpacing.paddingBody4), + ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody5), + contentPadding = PaddingValues(vertical = festabookSpacing.paddingBody5), + ) { + items(items = scheduleEvents, key = { scheduleEvent -> scheduleEvent.id }) { + ScheduleEventItem(scheduleEvent = it) + } } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt index 288c6350..80f6397a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt @@ -28,7 +28,7 @@ import kotlinx.coroutines.launch fun ScheduleTabRow( pageState: PagerState, scope: CoroutineScope, - scheduleDates: List, + dates: List, modifier: Modifier = Modifier, ) { ScrollableTabRow( @@ -41,7 +41,7 @@ fun ScheduleTabRow( divider = {}, modifier = modifier, ) { - scheduleDates.forEachIndexed { index, scheduleDate -> + dates.forEachIndexed { index, scheduleDate -> Tab( selected = pageState.currentPage == index, unselectedContentColor = FestabookColor.gray500, @@ -75,7 +75,7 @@ private fun ScheduleTabRowPreview() { ScheduleTabRow( pageState = rememberPagerState { 5 }, scope = rememberCoroutineScope(), - scheduleDates = + dates = listOf( ScheduleDateUiModel(1, "11/12"), ScheduleDateUiModel(2, "11/13"), diff --git a/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt b/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt index 4f9b09d3..eaa309c4 100644 --- a/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt @@ -45,13 +45,13 @@ class ScheduleViewModelTest { scheduleRepository = mockk() coEvery { scheduleRepository.fetchAllScheduleDates() } returns - Result.success( - FAKE_SCHEDULE_DATES, - ) + Result.success( + FAKE_SCHEDULE_DATES, + ) coEvery { scheduleRepository.fetchScheduleEventsById(dateId) } returns - Result.success( - FAKE_SCHEDULE_EVENTS, - ) + Result.success( + FAKE_SCHEDULE_EVENTS, + ) scheduleViewModel = ScheduleViewModel(scheduleRepository, dateId) } @@ -67,7 +67,7 @@ class ScheduleViewModelTest { // given // when - scheduleViewModel.loadScheduleByDate() + scheduleViewModel.loadSchedules() advanceUntilIdle() // then @@ -87,12 +87,12 @@ class ScheduleViewModelTest { runTest { // given coEvery { scheduleRepository.fetchScheduleEventsById(dateId) } returns - Result.success( - FAKE_SCHEDULE_EVENTS, - ) + Result.success( + FAKE_SCHEDULE_EVENTS, + ) // when - scheduleViewModel.loadScheduleByDate() + scheduleViewModel.loadSchedules() advanceUntilIdle() // then @@ -109,21 +109,21 @@ class ScheduleViewModelTest { runTest { // given coEvery { scheduleRepository.fetchScheduleEventsById(dateId) } returns - Result.success( - listOf( - ScheduleEvent( - id = 1L, - status = ScheduleEventStatus.UPCOMING, - startTime = "2025-07-26T10:00:00", - endTime = "2025-07-26T11:00:00", - title = "안드로이드 스터디", - location = "서울 강남구 어딘가", - ), + Result.success( + listOf( + ScheduleEvent( + id = 1L, + status = ScheduleEventStatus.UPCOMING, + startTime = "2025-07-26T10:00:00", + endTime = "2025-07-26T11:00:00", + title = "안드로이드 스터디", + location = "서울 강남구 어딘가", ), - ) + ), + ) // when - scheduleViewModel.loadScheduleByDate() + scheduleViewModel.loadSchedules() advanceUntilIdle() // then @@ -150,25 +150,27 @@ class ScheduleViewModelTest { } @Test - fun `모든 날짜의 축제 정보를 불러올 수 있다`() = runTest { - //given - coEvery { scheduleRepository.fetchAllScheduleDates() } returns + fun `모든 날짜의 축제 정보를 불러올 수 있다`() = + runTest { + // given + coEvery { scheduleRepository.fetchAllScheduleDates() } returns Result.success( FAKE_SCHEDULE_DATES, ) - val expected = ScheduleDatesUiState.Success( - FAKE_SCHEDULE_DATES.map { it.toUiModel() }, - 0 - ) + val expected = + ScheduleDatesUiState.Success( + FAKE_SCHEDULE_DATES.map { it.toUiModel() }, + 0, + ) - //when - scheduleViewModel.loadAllDates() - advanceUntilIdle() + // when + scheduleViewModel.loadAllDates() + advanceUntilIdle() - //then - coVerify { scheduleRepository.fetchAllScheduleDates() } - val actual = scheduleViewModel.scheduleDatesUiState.getOrAwaitValue() - assertThat(actual).isEqualTo(expected) - } + // then + coVerify { scheduleRepository.fetchAllScheduleDates() } + val actual = scheduleViewModel.scheduleDatesUiState.getOrAwaitValue() + assertThat(actual).isEqualTo(expected) + } } From 486cad85de9263177c0b4c7509863fcb43437a80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Thu, 18 Dec 2025 22:51:16 +0900 Subject: [PATCH 046/140] =?UTF-8?q?fix:=20=EC=9D=BC=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20=EC=8B=9C=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../festabook/presentation/schedule/component/ScheduleScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt index 9af1aced..56859b65 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt @@ -79,7 +79,7 @@ fun ScheduleScreen( isRefreshing = isRefreshing, onRefresh = { val oldEvents = - (scheduleEventsUiState as? ScheduleEventsUiState.Refreshing)?.oldEvents + (scheduleEventsUiState as? ScheduleEventsUiState.Success)?.eventsByDate[pageState.currentPage] ?: emptyList() scheduleViewModel.loadSchedules( scheduleEventUiState = ScheduleEventsUiState.Refreshing(oldEvents), From 0eba44ef4d155ccf28e8e67f8598ccad1b8a28ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Thu, 18 Dec 2025 22:51:31 +0900 Subject: [PATCH 047/140] =?UTF-8?q?fix:=20ScheduleViewModel=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=A1=9C=EB=94=A9=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/ScheduleViewModel.kt | 51 ++++++++++--------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt index 92aa8394..d24d7b7b 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt @@ -33,7 +33,7 @@ class ScheduleViewModel( val scheduleEventUiState: StateFlow = _scheduleEventUiState.asStateFlow() init { - loadSchedules(scheduleEventUiState = ScheduleEventsUiState.InitialLoading) + loadSchedules() } fun onDateSelected(selectedPosition: Int) { @@ -43,15 +43,14 @@ class ScheduleViewModel( } fun loadSchedules( - scheduleEventUiState: ScheduleEventsUiState, + scheduleEventUiState: ScheduleEventsUiState = ScheduleEventsUiState.InitialLoading, selectedDatePosition: Int? = null, ) { viewModelScope.launch { - _scheduleEventUiState.value = scheduleEventUiState val datesResult = loadAllDates(selectedDatePosition) datesResult.onSuccess { scheduleDateUiModels -> - loadEvents(scheduleDateUiModels) + loadEvents(scheduleEventUiState, scheduleDateUiModels) } } } @@ -81,28 +80,32 @@ class ScheduleViewModel( ) } - private suspend fun loadEvents(scheduleDateUiModels: List) { + private fun loadEvents( + scheduleEventUiState: ScheduleEventsUiState, + scheduleDateUiModels: List, + ) { val allEvents = mutableMapOf>() scheduleDateUiModels.forEachIndexed { position, scheduleDateUiModel -> - val eventsResult = - scheduleRepository.fetchScheduleEventsById(scheduleDateUiModel.id) - - eventsResult - .onSuccess { scheduleEvents -> - val scheduleEventUiModels = scheduleEvents.map { it.toUiModel() } - allEvents[position] = scheduleEventUiModels - - val currentEventPosition = - getCurrentEventPosition(scheduleEventUiModels) - - _scheduleEventUiState.value = - ScheduleEventsUiState.Success( - eventsByDate = allEvents, - currentEventPosition = currentEventPosition, - ) - }.onFailure { - _scheduleEventUiState.value = ScheduleEventsUiState.Error(it) - } + viewModelScope.launch { + _scheduleEventUiState.value = scheduleEventUiState + val eventsResult = + scheduleRepository.fetchScheduleEventsById(scheduleDateUiModel.id) + + eventsResult + .onSuccess { scheduleEvents -> + val scheduleEventUiModels = scheduleEvents.map { it.toUiModel() } + allEvents[position] = scheduleEventUiModels + val currentEventPosition = getCurrentEventPosition(scheduleEventUiModels) + + _scheduleEventUiState.value = + ScheduleEventsUiState.Success( + eventsByDate = allEvents, + currentEventPosition = currentEventPosition, + ) + }.onFailure { + _scheduleEventUiState.value = ScheduleEventsUiState.Error(it) + } + } } } From 4fda065e6c90e337d2498e667ff5ffd88ac9d70b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Fri, 19 Dec 2025 18:50:42 +0900 Subject: [PATCH 048/140] =?UTF-8?q?refactor:=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20UI=20=EC=83=81=ED=83=9C=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/ScheduleEventsUiState.kt | 2 +- ...duleDatesUiState.kt => ScheduleUiState.kt} | 9 +- .../schedule/ScheduleViewModel.kt | 99 +++++++++++-------- .../schedule/component/ScheduleScreen.kt | 34 +++---- .../schedule/component/ScheduleTabPage.kt | 4 +- .../schedule/ScheduleViewModelTest.kt | 4 +- 6 files changed, 84 insertions(+), 68 deletions(-) rename app/src/main/java/com/daedan/festabook/presentation/schedule/{ScheduleDatesUiState.kt => ScheduleUiState.kt} (58%) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleEventsUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleEventsUiState.kt index 2462099a..df46b54a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleEventsUiState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleEventsUiState.kt @@ -10,7 +10,7 @@ sealed interface ScheduleEventsUiState { ) : ScheduleEventsUiState data class Success( - val eventsByDate: Map>, + val events: List, val currentEventPosition: Int, ) : ScheduleEventsUiState diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleDatesUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleUiState.kt similarity index 58% rename from app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleDatesUiState.kt rename to app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleUiState.kt index ae36d6d1..5b683fab 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleDatesUiState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleUiState.kt @@ -2,15 +2,16 @@ package com.daedan.festabook.presentation.schedule import com.daedan.festabook.presentation.schedule.model.ScheduleDateUiModel -sealed interface ScheduleDatesUiState { - data object InitialLoading : ScheduleDatesUiState +sealed interface ScheduleUiState { + data object InitialLoading : ScheduleUiState data class Success( val dates: List, val currentDatePosition: Int, - ) : ScheduleDatesUiState + val eventsUiStateByPosition: Map = emptyMap(), + ) : ScheduleUiState data class Error( val throwable: Throwable, - ) : ScheduleDatesUiState + ) : ScheduleUiState } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt index d24d7b7b..b998aa08 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope import java.time.LocalDate @ContributesIntoMap(AppScope::class) @@ -24,24 +25,14 @@ import java.time.LocalDate class ScheduleViewModel( private val scheduleRepository: ScheduleRepository, ) : ViewModel() { - private val _scheduleDateUiState: MutableStateFlow = - MutableStateFlow(ScheduleDatesUiState.InitialLoading) - val scheduleDateUiState: StateFlow = _scheduleDateUiState.asStateFlow() - - private val _scheduleEventUiState: MutableStateFlow = - MutableStateFlow(ScheduleEventsUiState.InitialLoading) - val scheduleEventUiState: StateFlow = _scheduleEventUiState.asStateFlow() + private val _scheduleUiState: MutableStateFlow = + MutableStateFlow(ScheduleUiState.InitialLoading) + val scheduleUiState: StateFlow = _scheduleUiState.asStateFlow() init { loadSchedules() } - fun onDateSelected(selectedPosition: Int) { - (_scheduleDateUiState.value as? ScheduleDatesUiState.Success)?.let { - _scheduleDateUiState.value = it.copy(currentDatePosition = selectedPosition) - } - } - fun loadSchedules( scheduleEventUiState: ScheduleEventsUiState = ScheduleEventsUiState.InitialLoading, selectedDatePosition: Int? = null, @@ -50,13 +41,13 @@ class ScheduleViewModel( val datesResult = loadAllDates(selectedDatePosition) datesResult.onSuccess { scheduleDateUiModels -> - loadEvents(scheduleEventUiState, scheduleDateUiModels) + loadAllEvents(scheduleEventUiState, scheduleDateUiModels) } } } private suspend fun loadAllDates(selectedDatePosition: Int?): Result> { - _scheduleDateUiState.value = ScheduleDatesUiState.InitialLoading + _scheduleUiState.value = ScheduleUiState.InitialLoading val result = scheduleRepository.fetchAllScheduleDates() return result.fold( @@ -65,8 +56,8 @@ class ScheduleViewModel( val currentDatePosition = selectedDatePosition ?: getCurrentDatePosition(scheduleDates) - _scheduleDateUiState.value = - ScheduleDatesUiState.Success( + _scheduleUiState.value = + ScheduleUiState.Success( dates = scheduleDateUiModels, currentDatePosition = currentDatePosition, ) @@ -74,41 +65,69 @@ class ScheduleViewModel( Result.success(scheduleDateUiModels) }, onFailure = { throwable -> - _scheduleDateUiState.value = ScheduleDatesUiState.Error(throwable) + _scheduleUiState.value = ScheduleUiState.Error(throwable) Result.failure(throwable) }, ) } - private fun loadEvents( + private suspend fun loadAllEvents( scheduleEventUiState: ScheduleEventsUiState, scheduleDateUiModels: List, ) { - val allEvents = mutableMapOf>() - scheduleDateUiModels.forEachIndexed { position, scheduleDateUiModel -> - viewModelScope.launch { - _scheduleEventUiState.value = scheduleEventUiState - val eventsResult = - scheduleRepository.fetchScheduleEventsById(scheduleDateUiModel.id) - - eventsResult - .onSuccess { scheduleEvents -> - val scheduleEventUiModels = scheduleEvents.map { it.toUiModel() } - allEvents[position] = scheduleEventUiModels - val currentEventPosition = getCurrentEventPosition(scheduleEventUiModels) - - _scheduleEventUiState.value = - ScheduleEventsUiState.Success( - eventsByDate = allEvents, - currentEventPosition = currentEventPosition, - ) - }.onFailure { - _scheduleEventUiState.value = ScheduleEventsUiState.Error(it) - } + supervisorScope { + scheduleDateUiModels.forEachIndexed { position, scheduleDateUiModel -> + launch { + loadEventsByPosition( + position = position, + scheduleDateUiModel = scheduleDateUiModel, + scheduleEventsUiState = scheduleEventUiState, + ) + } } } } + private suspend fun loadEventsByPosition( + position: Int, + scheduleDateUiModel: ScheduleDateUiModel, + scheduleEventsUiState: ScheduleEventsUiState, + ) { + updateEventUiState(position, scheduleEventsUiState) + + val result = + scheduleRepository.fetchScheduleEventsById(scheduleDateUiModel.id) + + result + .onSuccess { scheduleEvents -> + val uiModels = scheduleEvents.map { it.toUiModel() } + updateEventUiState( + position = position, + scheduleEventsUiState = + ScheduleEventsUiState.Success( + events = uiModels, + currentEventPosition = getCurrentEventPosition(uiModels), + ), + ) + }.onFailure { + updateEventUiState(position, ScheduleEventsUiState.Error(it)) + } + } + + private fun updateEventUiState( + position: Int, + scheduleEventsUiState: ScheduleEventsUiState, + ) { + val currentUiState = _scheduleUiState.value + if (currentUiState !is ScheduleUiState.Success) return + + _scheduleUiState.value = + currentUiState.copy( + eventsUiStateByPosition = + currentUiState.eventsUiStateByPosition + (position to scheduleEventsUiState), + ) + } + private fun getCurrentEventPosition(scheduleEventUiModels: List): Int { val currentEventPosition = scheduleEventUiModels diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt index 56859b65..c566bd76 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier @@ -18,8 +17,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.FestabookTopAppBar import com.daedan.festabook.presentation.common.component.LoadingStateScreen -import com.daedan.festabook.presentation.schedule.ScheduleDatesUiState import com.daedan.festabook.presentation.schedule.ScheduleEventsUiState +import com.daedan.festabook.presentation.schedule.ScheduleUiState import com.daedan.festabook.presentation.schedule.ScheduleViewModel import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.festabookSpacing @@ -29,35 +28,34 @@ fun ScheduleScreen( scheduleViewModel: ScheduleViewModel, modifier: Modifier = Modifier, ) { - val scheduleDatesUiState by scheduleViewModel.scheduleDateUiState.collectAsStateWithLifecycle() - val scheduleEventsUiState by scheduleViewModel.scheduleEventUiState.collectAsStateWithLifecycle() - val isRefreshing = scheduleEventsUiState is ScheduleEventsUiState.Refreshing + val scheduleUiState by scheduleViewModel.scheduleUiState.collectAsStateWithLifecycle() Scaffold( topBar = { FestabookTopAppBar(title = stringResource(R.string.schedule_title)) }, modifier = modifier, ) { innerPadding -> - when (scheduleDatesUiState) { - is ScheduleDatesUiState.Error -> { + when (scheduleUiState) { + is ScheduleUiState.Error -> { } - ScheduleDatesUiState.InitialLoading -> { + ScheduleUiState.InitialLoading -> { LoadingStateScreen() } - is ScheduleDatesUiState.Success -> { - val scheduleDatesUiStateSuccess = - scheduleDatesUiState as ScheduleDatesUiState.Success + is ScheduleUiState.Success -> { + val scheduleUiStateSuccess = + scheduleUiState as ScheduleUiState.Success val pageState = rememberPagerState( - initialPage = scheduleDatesUiStateSuccess.currentDatePosition, - ) { scheduleDatesUiStateSuccess.dates.size } + initialPage = scheduleUiStateSuccess.currentDatePosition, + ) { scheduleUiStateSuccess.dates.size } val scope = rememberCoroutineScope() - LaunchedEffect(pageState.currentPage) { - scheduleViewModel.onDateSelected(pageState.currentPage) - } + val scheduleEventsUiState = + scheduleUiStateSuccess.eventsUiStateByPosition[pageState.currentPage] + ?: ScheduleEventsUiState.Error(IllegalArgumentException()) + val isRefreshing = scheduleEventsUiState is ScheduleEventsUiState.Refreshing Column( modifier = Modifier.padding(top = innerPadding.calculateTopPadding()), @@ -65,7 +63,7 @@ fun ScheduleScreen( ScheduleTabRow( pageState = pageState, scope = scope, - dates = scheduleDatesUiStateSuccess.dates, + dates = scheduleUiStateSuccess.dates, ) Spacer(modifier = Modifier.height(festabookSpacing.paddingBody4)) HorizontalDivider( @@ -79,7 +77,7 @@ fun ScheduleScreen( isRefreshing = isRefreshing, onRefresh = { val oldEvents = - (scheduleEventsUiState as? ScheduleEventsUiState.Success)?.eventsByDate[pageState.currentPage] + (scheduleEventsUiState as? ScheduleEventsUiState.Success)?.events ?: emptyList() scheduleViewModel.loadSchedules( scheduleEventUiState = ScheduleEventsUiState.Refreshing(oldEvents), diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt index 12d16fa9..b845a20d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt @@ -63,9 +63,7 @@ fun ScheduleTabPage( is ScheduleEventsUiState.Success -> { ScheduleTabContent( - scheduleEvents = - scheduleEventsUiState.eventsByDate[pagerState.currentPage] - ?: emptyList(), + scheduleEvents = scheduleEventsUiState.events, modifier = Modifier .padding(end = festabookSpacing.paddingScreenGutter) diff --git a/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt b/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt index eaa309c4..377086c4 100644 --- a/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt @@ -5,8 +5,8 @@ import com.daedan.festabook.domain.model.ScheduleEvent import com.daedan.festabook.domain.model.ScheduleEventStatus import com.daedan.festabook.domain.repository.ScheduleRepository import com.daedan.festabook.getOrAwaitValue -import com.daedan.festabook.presentation.schedule.ScheduleDatesUiState import com.daedan.festabook.presentation.schedule.ScheduleEventsUiState +import com.daedan.festabook.presentation.schedule.ScheduleUiState import com.daedan.festabook.presentation.schedule.ScheduleViewModel import com.daedan.festabook.presentation.schedule.model.toUiModel import io.mockk.coEvery @@ -159,7 +159,7 @@ class ScheduleViewModelTest { ) val expected = - ScheduleDatesUiState.Success( + ScheduleUiState.Success( FAKE_SCHEDULE_DATES.map { it.toUiModel() }, 0, ) From a5469effdc4e0d46284d68b90bc035d855ae25aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Fri, 19 Dec 2025 20:09:33 +0900 Subject: [PATCH 049/140] =?UTF-8?q?fix:=20Schedule=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=84=B1=EB=8A=A5=20=EB=B0=8F=20UX=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/component/ScheduleScreen.kt | 13 ++--------- .../schedule/component/ScheduleTabPage.kt | 23 +++++++++++++++---- .../schedule/component/ScheduleTabRow.kt | 2 +- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt index c566bd76..7caca243 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt @@ -52,11 +52,6 @@ fun ScheduleScreen( ) { scheduleUiStateSuccess.dates.size } val scope = rememberCoroutineScope() - val scheduleEventsUiState = - scheduleUiStateSuccess.eventsUiStateByPosition[pageState.currentPage] - ?: ScheduleEventsUiState.Error(IllegalArgumentException()) - val isRefreshing = scheduleEventsUiState is ScheduleEventsUiState.Refreshing - Column( modifier = Modifier.padding(top = innerPadding.calculateTopPadding()), ) { @@ -73,12 +68,8 @@ fun ScheduleScreen( ) ScheduleTabPage( pagerState = pageState, - scheduleEventsUiState = scheduleEventsUiState, - isRefreshing = isRefreshing, - onRefresh = { - val oldEvents = - (scheduleEventsUiState as? ScheduleEventsUiState.Success)?.events - ?: emptyList() + scheduleUiState = scheduleUiStateSuccess, + onRefresh = { oldEvents -> scheduleViewModel.loadSchedules( scheduleEventUiState = ScheduleEventsUiState.Refreshing(oldEvents), selectedDatePosition = pageState.currentPage, diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt index b845a20d..1d019e93 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt @@ -20,25 +20,36 @@ import com.daedan.festabook.presentation.common.component.LoadingStateScreen import com.daedan.festabook.presentation.common.component.PULL_OFFSET_LIMIT import com.daedan.festabook.presentation.common.component.PullToRefreshContainer import com.daedan.festabook.presentation.schedule.ScheduleEventsUiState +import com.daedan.festabook.presentation.schedule.ScheduleUiState import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTheme import com.daedan.festabook.presentation.theme.festabookSpacing +private const val PRELOAD_PAGE_COUNT: Int = 2 + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ScheduleTabPage( pagerState: PagerState, - scheduleEventsUiState: ScheduleEventsUiState, - isRefreshing: Boolean, - onRefresh: () -> Unit, + scheduleUiState: ScheduleUiState.Success, + onRefresh: (List) -> Unit, modifier: Modifier = Modifier, ) { - HorizontalPager(state = pagerState, modifier = modifier) { + HorizontalPager( + state = pagerState, + modifier = modifier, + beyondViewportPageCount = PRELOAD_PAGE_COUNT, + ) { index -> + val scheduleEventsUiState = scheduleUiState.eventsUiStateByPosition[index] + val isRefreshing = scheduleEventsUiState is ScheduleEventsUiState.Refreshing + val oldEvents = + (scheduleEventsUiState as? ScheduleEventsUiState.Success)?.events ?: emptyList() + PullToRefreshContainer( isRefreshing = isRefreshing, - onRefresh = onRefresh, + onRefresh = { onRefresh(oldEvents) }, ) { pullToRefreshState -> when (scheduleEventsUiState) { is ScheduleEventsUiState.Error -> { @@ -73,6 +84,8 @@ fun ScheduleTabPage( }, ) } + + null -> {} } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt index 80f6397a..916ba06c 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt @@ -46,7 +46,7 @@ fun ScheduleTabRow( selected = pageState.currentPage == index, unselectedContentColor = FestabookColor.gray500, selectedContentColor = MaterialTheme.colorScheme.background, - onClick = { scope.launch { pageState.animateScrollToPage(index) } }, + onClick = { scope.launch { pageState.scrollToPage(index) } }, text = { Text(text = scheduleDate.date) }, ) } From 54cf5b11ec5e3f1405dbd397c8a2503f3348a0d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Fri, 19 Dec 2025 20:35:13 +0900 Subject: [PATCH 050/140] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=98=84=EC=9E=AC=20=EC=A7=84=ED=96=89=20=EC=A4=91?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=90=EB=8F=99=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/schedule/ScheduleUiState.kt | 4 ++++ .../presentation/schedule/component/ScheduleScreen.kt | 3 +-- .../schedule/component/ScheduleTabPage.kt | 11 +++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleUiState.kt index 5b683fab..05fbd98b 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleUiState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleUiState.kt @@ -14,4 +14,8 @@ sealed interface ScheduleUiState { data class Error( val throwable: Throwable, ) : ScheduleUiState + + companion object { + const val DEFAULT_POSITION: Int = 0 + } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt index 7caca243..4b6cd88d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt @@ -44,8 +44,7 @@ fun ScheduleScreen( } is ScheduleUiState.Success -> { - val scheduleUiStateSuccess = - scheduleUiState as ScheduleUiState.Success + val scheduleUiStateSuccess = scheduleUiState as ScheduleUiState.Success val pageState = rememberPagerState( initialPage = scheduleUiStateSuccess.currentDatePosition, diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt index 1d019e93..e2738ee5 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt @@ -6,11 +6,13 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.tooling.preview.Preview @@ -21,6 +23,7 @@ import com.daedan.festabook.presentation.common.component.PULL_OFFSET_LIMIT import com.daedan.festabook.presentation.common.component.PullToRefreshContainer import com.daedan.festabook.presentation.schedule.ScheduleEventsUiState import com.daedan.festabook.presentation.schedule.ScheduleUiState +import com.daedan.festabook.presentation.schedule.ScheduleUiState.Companion.DEFAULT_POSITION import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus import com.daedan.festabook.presentation.theme.FestabookColor @@ -75,6 +78,7 @@ fun ScheduleTabPage( is ScheduleEventsUiState.Success -> { ScheduleTabContent( scheduleEvents = scheduleEventsUiState.events, + currentEventPosition = scheduleEventsUiState.currentEventPosition, modifier = Modifier .padding(end = festabookSpacing.paddingScreenGutter) @@ -95,7 +99,13 @@ fun ScheduleTabPage( private fun ScheduleTabContent( scheduleEvents: List, modifier: Modifier = Modifier, + currentEventPosition: Int = DEFAULT_POSITION, ) { + val listState = rememberLazyListState() + + LaunchedEffect(Unit) { + listState.animateScrollToItem(currentEventPosition) + } if (scheduleEvents.isEmpty()) { EmptyStateScreen(modifier = modifier) } else { @@ -110,6 +120,7 @@ private fun ScheduleTabContent( LazyColumn( verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody5), contentPadding = PaddingValues(vertical = festabookSpacing.paddingBody5), + state = listState, ) { items(items = scheduleEvents, key = { scheduleEvent -> scheduleEvent.id }) { ScheduleEventItem(scheduleEvent = it) From 670a83832e23832a8f9a7d82504dba39f147cc81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Fri, 19 Dec 2025 21:16:17 +0900 Subject: [PATCH 051/140] =?UTF-8?q?refactor:=20ScheduleFragment=EC=9D=98?= =?UTF-8?q?=20=EB=A9=94=EB=89=B4=20=EC=9E=AC=ED=81=B4=EB=A6=AD=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../festabook/presentation/schedule/ScheduleFragment.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt index 2002d52d..d2e7ecd9 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt @@ -52,8 +52,6 @@ class ScheduleFragment : } override fun onMenuItemReClick() { -// viewModel.loadAllDates(ScheduleUiState.InitialLoading) -// viewModel.loadScheduleByDate() -// binding.logger.log(ScheduleMenuItemReClickLogData(binding.logger.getBaseLogData())) + viewModel.loadSchedules() } } From 12795ebbdd8ca90b91f7705474a41128a6c57ab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Fri, 19 Dec 2025 21:50:08 +0900 Subject: [PATCH 052/140] =?UTF-8?q?fix:=20=EC=9D=BC=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8(Refreshing)=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20UI=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/schedule/ScheduleUiState.kt | 4 +++ .../schedule/ScheduleViewModel.kt | 10 ++++-- .../schedule/component/ScheduleScreen.kt | 34 +++++++++++-------- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleUiState.kt index 05fbd98b..3d629c35 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleUiState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleUiState.kt @@ -5,6 +5,10 @@ import com.daedan.festabook.presentation.schedule.model.ScheduleDateUiModel sealed interface ScheduleUiState { data object InitialLoading : ScheduleUiState + data class Refreshing( + val lastSuccessState: Success, + ) : ScheduleUiState + data class Success( val dates: List, val currentDatePosition: Int, diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt index b998aa08..52e253f0 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt @@ -34,11 +34,12 @@ class ScheduleViewModel( } fun loadSchedules( + scheduleUiState: ScheduleUiState = ScheduleUiState.InitialLoading, scheduleEventUiState: ScheduleEventsUiState = ScheduleEventsUiState.InitialLoading, selectedDatePosition: Int? = null, ) { viewModelScope.launch { - val datesResult = loadAllDates(selectedDatePosition) + val datesResult = loadAllDates(scheduleUiState, selectedDatePosition) datesResult.onSuccess { scheduleDateUiModels -> loadAllEvents(scheduleEventUiState, scheduleDateUiModels) @@ -46,8 +47,11 @@ class ScheduleViewModel( } } - private suspend fun loadAllDates(selectedDatePosition: Int?): Result> { - _scheduleUiState.value = ScheduleUiState.InitialLoading + private suspend fun loadAllDates( + scheduleUiState: ScheduleUiState, + selectedDatePosition: Int?, + ): Result> { + _scheduleUiState.value = scheduleUiState val result = scheduleRepository.fetchAllScheduleDates() return result.fold( diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt index 4b6cd88d..688196ea 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt @@ -22,6 +22,7 @@ import com.daedan.festabook.presentation.schedule.ScheduleUiState import com.daedan.festabook.presentation.schedule.ScheduleViewModel import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.festabookSpacing +import timber.log.Timber @Composable fun ScheduleScreen( @@ -29,35 +30,37 @@ fun ScheduleScreen( modifier: Modifier = Modifier, ) { val scheduleUiState by scheduleViewModel.scheduleUiState.collectAsStateWithLifecycle() + val currentState = + when (scheduleUiState) { + is ScheduleUiState.Refreshing -> (scheduleUiState as ScheduleUiState.Refreshing).lastSuccessState + is ScheduleUiState.Success -> scheduleUiState + else -> scheduleUiState + } Scaffold( topBar = { FestabookTopAppBar(title = stringResource(R.string.schedule_title)) }, modifier = modifier, ) { innerPadding -> - - when (scheduleUiState) { - is ScheduleUiState.Error -> { - } - + when (currentState) { ScheduleUiState.InitialLoading -> { LoadingStateScreen() } - is ScheduleUiState.Success -> { - val scheduleUiStateSuccess = scheduleUiState as ScheduleUiState.Success + is ScheduleUiState.Error -> { + Timber.w(currentState.throwable.stackTraceToString()) + } + + else -> { + val currentStateSuccess = currentState as ScheduleUiState.Success val pageState = - rememberPagerState( - initialPage = scheduleUiStateSuccess.currentDatePosition, - ) { scheduleUiStateSuccess.dates.size } + rememberPagerState(initialPage = currentStateSuccess.currentDatePosition) { currentStateSuccess.dates.size } val scope = rememberCoroutineScope() - Column( - modifier = Modifier.padding(top = innerPadding.calculateTopPadding()), - ) { + Column(modifier = Modifier.padding(top = innerPadding.calculateTopPadding())) { ScheduleTabRow( pageState = pageState, scope = scope, - dates = scheduleUiStateSuccess.dates, + dates = currentStateSuccess.dates, ) Spacer(modifier = Modifier.height(festabookSpacing.paddingBody4)) HorizontalDivider( @@ -67,9 +70,10 @@ fun ScheduleScreen( ) ScheduleTabPage( pagerState = pageState, - scheduleUiState = scheduleUiStateSuccess, + scheduleUiState = currentStateSuccess, onRefresh = { oldEvents -> scheduleViewModel.loadSchedules( + scheduleUiState = ScheduleUiState.Refreshing(currentStateSuccess), scheduleEventUiState = ScheduleEventsUiState.Refreshing(oldEvents), selectedDatePosition = pageState.currentPage, ) From b21e5ec92578c2fb5899b6e018949f723e15f133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Fri, 19 Dec 2025 21:51:40 +0900 Subject: [PATCH 053/140] =?UTF-8?q?refactor:=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20ViewPager=20=EB=B0=8F=20=ED=83=AD=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=94=84=EB=9E=98=EA=B7=B8?= =?UTF-8?q?=EB=A8=BC=ED=8A=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/ScheduleTabPageFragment.kt | 141 ------------------ .../schedule/adapter/SchedulePagerAdapter.kt | 28 ---- 2 files changed, 169 deletions(-) delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleTabPageFragment.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/SchedulePagerAdapter.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleTabPageFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleTabPageFragment.kt deleted file mode 100644 index 388a1102..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleTabPageFragment.kt +++ /dev/null @@ -1,141 +0,0 @@ -package com.daedan.festabook.presentation.schedule - -import android.os.Bundle -import android.view.View -import androidx.recyclerview.widget.DefaultItemAnimator -import androidx.recyclerview.widget.LinearLayoutManager -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentScheduleTabPageBinding -import com.daedan.festabook.logging.logger -import com.daedan.festabook.logging.model.schedule.ScheduleSwipeRefreshLogData -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.schedule.adapter.ScheduleAdapter - -class ScheduleTabPageFragment : BaseFragment() { - override val layoutId: Int = R.layout.fragment_schedule_tab_page - - // private val viewModel: ScheduleViewModel by viewModels { -// val dateId: Long = arguments?.getLong(KEY_DATE_ID, INVALID_ID) ?: INVALID_ID -// ScheduleViewModel.factory(appGraph.scheduleViewModelFactory, dateId) -// } - private val adapter: ScheduleAdapter by lazy { - ScheduleAdapter() - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - setupObservers() - setupScheduleEventRecyclerView() - - binding.lifecycleOwner = viewLifecycleOwner - onSwipeRefreshScheduleByDateListener() - } - - private fun setupScheduleEventRecyclerView() { - binding.rvScheduleEvent.adapter = adapter - (binding.rvScheduleEvent.itemAnimator as DefaultItemAnimator).supportsChangeAnimations = - false -// viewModel.loadScheduleByDate() - } - - private fun onSwipeRefreshScheduleByDateListener() { - binding.srlScheduleEvent.setOnRefreshListener { - binding.logger.log(ScheduleSwipeRefreshLogData(binding.logger.getBaseLogData())) -// viewModel.loadScheduleByDate() - } - } - - private fun setupObservers() { -// viewModel.scheduleEventsUiState.observe(viewLifecycleOwner) { schedule -> -// when (schedule) { -// is ScheduleEventsUiState.Loading, -// -> { -// showLoadingView(isLoading = true) -// showEmptyStateMessage() -// } -// -// is ScheduleEventsUiState.Success -> { -// showLoadingView(isLoading = false) -// adapter.submitList(schedule.events) { -// showEmptyStateMessage() -// scrollToCenterOfCurrentEvent(schedule.currentEventPosition) -// } -// } -// -// is ScheduleEventsUiState.Error -> { -// Timber.w( -// schedule.throwable, -// "ScheduleTabPageFragment: ${schedule.throwable.message}", -// ) -// showErrorSnackBar(schedule.throwable) -// showLoadingView(isLoading = false) -// showEmptyStateMessage() -// } -// } -// } - } - - private fun showLoadingView(isLoading: Boolean) { - if (isLoading) { - binding.rvScheduleEvent.visibility = View.INVISIBLE - binding.viewScheduleEventTimeLine.visibility = View.INVISIBLE - binding.lavScheduleLoading.visibility = View.VISIBLE - } else { - binding.lavScheduleLoading.visibility = View.GONE - binding.viewScheduleEventTimeLine.visibility = View.VISIBLE - binding.rvScheduleEvent.visibility = View.VISIBLE - } - binding.srlScheduleEvent.isRefreshing = false - } - - private fun scrollToCenterOfCurrentEvent(position: Int) { - val recyclerView = binding.rvScheduleEvent - val layoutManager = recyclerView.layoutManager as LinearLayoutManager - layoutManager.scrollToPositionWithOffset(position, NO_OFFSET) - - recyclerView.post { - val view = layoutManager.findViewByPosition(position) - if (view != null) { - val viewTop = layoutManager.getDecoratedTop(view) - val viewHeight = view.height - val parentHeight = recyclerView.height - val dy = viewTop - ((parentHeight - viewHeight) / HALF) - - recyclerView.smoothScrollBy(NO_OFFSET, dy) - } - } - } - - private fun showEmptyStateMessage() { - val itemCount = binding.rvScheduleEvent.adapter?.itemCount ?: 0 - - if (itemCount == 0) { - binding.rvScheduleEvent.visibility = View.GONE - binding.viewScheduleEventTimeLine.visibility = View.GONE - binding.tvEmptyState.root.visibility = View.VISIBLE - } else { - binding.rvScheduleEvent.visibility = View.VISIBLE - binding.viewScheduleEventTimeLine.visibility = View.VISIBLE - binding.tvEmptyState.root.visibility = View.GONE - } - } - - companion object { - const val KEY_DATE_ID = "dateId" - private const val NO_OFFSET: Int = 0 - private const val HALF: Int = 2 - - fun newInstance(dateId: Long): ScheduleTabPageFragment { - val fragment = ScheduleTabPageFragment() - val args = - Bundle().apply { - putLong(KEY_DATE_ID, dateId) - } - fragment.arguments = args - return fragment - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/SchedulePagerAdapter.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/SchedulePagerAdapter.kt deleted file mode 100644 index ebc1d3e6..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/SchedulePagerAdapter.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.daedan.festabook.presentation.schedule.adapter - -import androidx.fragment.app.Fragment -import androidx.viewpager2.adapter.FragmentStateAdapter -import com.daedan.festabook.presentation.schedule.ScheduleTabPageFragment -import com.daedan.festabook.presentation.schedule.model.ScheduleDateUiModel - -class SchedulePagerAdapter( - fragment: Fragment, - private val items: MutableList = mutableListOf(), -) : FragmentStateAdapter(fragment) { - override fun getItemCount(): Int = items.size - - override fun createFragment(position: Int): Fragment { - val dateId: Long = items[position].id - return ScheduleTabPageFragment.newInstance(dateId) - } - - fun submitList(newItems: List) { - items.clear() - items.addAll(newItems) - notifyItemRangeChanged(FIRST_INDEX, itemCount) - } - - companion object { - private const val FIRST_INDEX: Int = 0 - } -} From 0076c56216062f9a5520b91dc811d7280fc41af7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Fri, 19 Dec 2025 21:52:34 +0900 Subject: [PATCH 054/140] =?UTF-8?q?feat:=20`ScheduleTabPage`=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EB=B0=9C=EC=83=9D=20=EC=8B=9C=20=EC=8A=A4=ED=83=9D?= =?UTF-8?q?=20=ED=8A=B8=EB=A0=88=EC=9D=B4=EC=8A=A4=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/schedule/component/ScheduleTabPage.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt index e2738ee5..42ec35c6 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt @@ -29,6 +29,7 @@ import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTheme import com.daedan.festabook.presentation.theme.festabookSpacing +import timber.log.Timber private const val PRELOAD_PAGE_COUNT: Int = 2 @@ -56,6 +57,7 @@ fun ScheduleTabPage( ) { pullToRefreshState -> when (scheduleEventsUiState) { is ScheduleEventsUiState.Error -> { + Timber.w(scheduleEventsUiState.throwable.stackTraceToString()) } ScheduleEventsUiState.InitialLoading -> { From 54562e45d19755bc428c557d4502e0faac81b079 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Fri, 19 Dec 2025 21:57:56 +0900 Subject: [PATCH 055/140] =?UTF-8?q?fix:=20=EC=A7=84=ED=96=89=20=EC=A4=91?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=BC=EC=A0=95=20=EC=9C=84=EC=B9=98=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../daedan/festabook/presentation/schedule/ScheduleViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt index 52e253f0..f697ecaf 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt @@ -136,7 +136,7 @@ class ScheduleViewModel( val currentEventPosition = scheduleEventUiModels .indexOfFirst { - it.status == ScheduleEventUiStatus.ONGOING + it.status != ScheduleEventUiStatus.COMPLETED }.coerceAtLeast(FIRST_INDEX) return currentEventPosition } From 60c862ad02d3aabaeb17bec3012f08e0f4b06335 Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:29:26 +0900 Subject: [PATCH 056/140] =?UTF-8?q?feat(home):=20HomeHeader=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EC=A0=80=EB=B8=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/home/component/HomeHeader.kt | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt new file mode 100644 index 00000000..8a564aeb --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt @@ -0,0 +1,63 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography + +@Composable +fun HomeHeader( + schoolName: String, + onExpandClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + Row( + modifier = Modifier.clickable { onExpandClick() }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = schoolName, + style = FestabookTypography.displayLarge, + color = FestabookColor.black, + ) + + Spacer(modifier = Modifier.width(4.dp)) + + Image( + painter = painterResource(id = R.drawable.ic_dropdown), + contentDescription = stringResource(id = R.string.home_navigate_to_explore_desc), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeHeaderPreview() { + HomeHeader( + schoolName = "가천대학교", + onExpandClick = {}, + ) +} From 3af132ee53b99e0647c256ce88e839abdcfef32f Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:42:14 +0900 Subject: [PATCH 057/140] =?UTF-8?q?feat(Home):=20HomeFestivalInfo=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EC=A0=80=EB=B8=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/component/HomeFestivalInfo.kt | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt new file mode 100644 index 00000000..7d852615 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt @@ -0,0 +1,52 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography + +@Composable +fun HomeFestivalInfo( + festivalName: String, + festivalDate: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + ) { + Text( + text = festivalName, + style = FestabookTypography.displayMedium, + color = FestabookColor.black, + modifier = Modifier.padding(horizontal = 20.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = festivalDate, + style = FestabookTypography.bodyLarge, + color = FestabookColor.gray500, + modifier = Modifier.padding(horizontal = 20.dp), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeFestivalInfoPreview() { + HomeFestivalInfo( + festivalName = "2025 가천 Water Festival\n: AQUA WAVE", + festivalDate = "2025년 10월 15일 - 10월 17일", + ) +} From bb90581e4fbe0cab2e797c90bea894c8ea3e5dd7 Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:51:11 +0900 Subject: [PATCH 058/140] =?UTF-8?q?feat(home):=20HomeLineupHeader=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EC=A0=80=EB=B8=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/component/HomeLineupHeader.kt | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt new file mode 100644 index 00000000..e0a9471a --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt @@ -0,0 +1,78 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography + +@Composable +fun HomeLineupHeader( + onScheduleClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.home_lineup_title), + style = FestabookTypography.displayMedium, + color = FestabookColor.black, + ) + + Row( + modifier = + Modifier + .clickable( + onClick = onScheduleClick, + ) + .padding(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.home_check_schedule_text), + style = FestabookTypography.bodySmall, + color = FestabookColor.gray400, + ) + + Spacer(modifier = Modifier.width(4.dp)) + + Icon( + painter = painterResource(id = R.drawable.ic_arrow_forward_right), + contentDescription = null, + tint = FestabookColor.gray400, + modifier = Modifier.size(12.dp), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeLineupHeaderPreview() { + HomeLineupHeader( + onScheduleClick = {}, + ) +} From 19e9842cd0e646c214080dcd15ca835df70ecb59 Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Sat, 20 Dec 2025 00:23:00 +0900 Subject: [PATCH 059/140] =?UTF-8?q?feat(home):=20HomePosterList=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EC=A0=80=EB=B8=94=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=B0=8F=20=EB=AC=B4=ED=95=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A0=80=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/component/HomePosterList.kt | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt new file mode 100644 index 00000000..1ce0af70 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt @@ -0,0 +1,112 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import com.daedan.festabook.presentation.common.component.CoilImage +import kotlin.math.absoluteValue + +@Composable +fun HomePosterList( + posterUrls: List, + modifier: Modifier = Modifier, +) { + if (posterUrls.isEmpty()) return + + // 무한 스크롤을 위한 큰 수 설정 + val initialPage = (Int.MAX_VALUE / 2) - ((Int.MAX_VALUE / 2) % posterUrls.size) + val pagerState = + rememberPagerState( + initialPage = initialPage, + pageCount = { Int.MAX_VALUE }, + ) + + val configuration = LocalConfiguration.current + val screenWidth = configuration.screenWidthDp.dp + val itemWidth = 300.dp + // 화면 중앙에 아이템이 오도록 패딩 계산 + val horizontalPadding = (screenWidth - itemWidth) / 2 + + HorizontalPager( + state = pagerState, + pageSize = PageSize.Fixed(itemWidth), + contentPadding = PaddingValues(horizontal = horizontalPadding), + pageSpacing = 12.dp, + modifier = + modifier + .fillMaxWidth() + .height(400.dp), // item_home_poster 높이 + verticalAlignment = Alignment.CenterVertically, + ) { page -> + val actualIndex = page % posterUrls.size + val imageUrl = posterUrls[actualIndex] + + // 스크롤 위치에 따른 Scale 계산 + val pageOffset = + ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction).absoluteValue + + // 중앙(0)이면 1.0f, 멀어질수록 작아짐 (최소 0.9f) + val scale = + lerp( + start = 1.0f, + stop = 0.9f, + fraction = pageOffset.coerceIn(0f, 1f), + ) + + // 투명도 조절 (중앙은 1.0, 멀어지면 약간 투명하게) + val alpha = + lerp( + start = 1.0f, + stop = 0.6f, + fraction = pageOffset.coerceIn(0f, 1f), + ) + + Box( + modifier = + Modifier + .width(itemWidth) + .height(400.dp) + .graphicsLayer { + scaleX = scale + scaleY = scale + this.alpha = alpha + } + .clip(RoundedCornerShape(10.dp)), + ) { + CoilImage( + url = imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Preview +@Composable +private fun HomePosterListPreview() { + HomePosterList( + posterUrls = + listOf( + "sample", + "sample", + "sample", + ), + ) +} \ No newline at end of file From f3c250c2b51cf9afd1474b580ecea4219f75207b Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Sat, 20 Dec 2025 13:13:18 +0900 Subject: [PATCH 060/140] =?UTF-8?q?[Feat]=20=ED=95=9C=20=EB=88=88=EC=97=90?= =?UTF-8?q?=20=EB=B3=B4=EA=B8=B0=20=ED=99=94=EB=A9=B4=20Compose=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(PlaceMap): 장소 카테고리 라벨 컴포저블 추가 장소의 카테고리를 시각적으로 표시하기 위한 `PlaceCategoryLabel` 컴포저블을 새로 추가했습니다. 이 컴포저블은 아이콘과 텍스트를 조합하여 카테고리 정보를 보여줍니다. - **`PlaceCategoryLabel.kt` 추가:** - `Card` 컴포저블을 기반으로 하여, 아이콘과 카테고리명을 `Row`로 묶어 표시합니다. - `iconColor`를 파라미터로 받아, 배경색을 해당 색상의 10% 투명도를 가진 색으로 동적으로 설정합니다. - `PlaceCategoryUiModel`을 받아 아이콘 리소스(`getIconId`)와 텍스트 리소스(`getTextId`)를 결정합니다. - `@Preview`를 추가하여 UI 컴포넌트를 시각적으로 확인할 수 있도록 했습니다. - **`PlaceListScreen.kt` 추가:** - 향후 장소 목록 화면을 구성하기 위한 비어 있는 `PlaceListScreen` 컴포저블 파일을 추가했습니다. * feat(PlaceList): 드래그 가능한 BottomSheet 컴포저블 추가 세 가지 상태(전체 확장, 절반 확장, 축소)를 가지는 `PlaceListBottomSheet` 컴포저블을 새로 추가했습니다. 이 컴포저블은 `AnchoredDraggable`을 기반으로 구현되어, 사용자가 드래그하거나 내부 컨텐츠를 스크롤할 때 BottomSheet의 높이를 조절할 수 있습니다. - **`PlaceListBottomSheet.kt` 신규 파일 추가** - `EXPANDED`, `HALF_EXPANDED`, `COLLAPSED` 세 가지 상태를 정의하는 `PlaceListBottomSheetState` enum을 추가했습니다. - `AnchoredDraggableState`를 사용하여 세 가지 상태에 대한 앵커(위치)를 설정하고, 드래그 제스처를 처리합니다. - `NestedScrollConnection`을 구현하여 BottomSheet 내부의 스크롤과 BottomSheet 자체의 드래그 동작이 자연스럽게 연동되도록 했습니다. 예를 들어, 리스트의 최상단에서 위로 스크롤하면 BottomSheet가 확장됩니다. - 기본 스타일(`shape`, `color`)과 상단에 표시되는 기본 드래그 핸들(`DefaultDragHandle`)을 `PlaceListBottomSheetDefault` 객체에 정의하여 제공합니다. - 사용자가 직접 커스텀 `dragHandle`과 `content`를 람다로 전달하여 BottomSheet의 내용을 구성할 수 있습니다. * feat(PlaceList): 장소 목록 UI를 Compose로 구현 지도 화면 하단에 표시될 장소 목록 UI(`PlaceListScreen`)를 `BottomSheet`와 함께 Compose로 구현했습니다. - **`PlaceListScreen.kt` 신규 추가** - `BottomSheet`를 사용하여 전체 장소 목록을 표시하는 `PlaceListScreen` 컴포저블을 구현했습니다. - `LazyColumn`을 사용해 장소 목록을 효율적으로 렌더링하고, 각 아이템(`PlaceListItem`)은 장소 이미지, 카테고리 라벨, 제목, 설명, 위치 정보를 포함합니다. - 목록을 아래로 당기면 "전체 장소 목록" 핸들이 나타나고, 위로 스크롤하면 장소 리스트가 표시됩니다. - **`PlaceMapScreen.kt` 수정** - `PlaceListScreen`과 `PlaceCategoryScreen`을 통합하여 `PlaceMapScreen`의 전체적인 레이아웃을 구성했습니다. - `PlaceMapScreen`의 파라미터를 추가하고, 실제 UI 구성을 담당하는 `PlaceMapContent`를 내부 private 함수로 분리했습니다. - Preview를 추가하여 UI를 쉽게 확인할 수 있도록 개선했습니다. - **`PlaceCategoryLabel.kt` 수정** - 각 카테고리별로 고유한 라벨 색상을 가지도록 `getLabelColor()`를 사용해 `iconColor`의 기본값을 설정했습니다. * feat(PlaceCategory): 카테고리별 라벨 색상 정의 함수 추가 장소 카테고리(`PlaceCategoryUiModel`)에 따라 고유한 라벨 색상을 반환하는 확장 함수 `getLabelColor()`를 추가했습니다. 이 함수는 추후 마커 라벨 등에 각 카테고리를 시각적으로 구분하는 색상을 적용하기 위해 사용됩니다. - **`PlaceCategoryUiModel.kt` 수정:** - `getLabelColor()` 확장 함수를 새로 추가했습니다. - `when` 표현식을 사용하여 각 카테고리(`BOOTH`, `FOOD_TRUCK`, `BAR`)에 해당하는 특정 `Color` 값을 반환하도록 구현했습니다. * feat(PlaceMap): 학교로 돌아가기, 현위치 버튼 Compose 마이그레이션 지도 화면 하단의 장소 목록(PlaceList) BottomSheet의 UI/UX를 개선하고, 비동기 데이터 로딩 상태를 처리하는 등 관련 로직을 전반적으로 리팩토링했습니다. - **`PlaceListScreen.kt` 주요 변경사항:** - `placesUiState`(`PlaceListUiState`)를 도입하여 장소 목록의 로딩, 성공, 에러 상태를 명시적으로 처리하도록 개선했습니다. - 로딩 중에는 `LoadingStateScreen`, 데이터가 없거나 에러 발생 시에는 `EmptyStateScreen`을 표시합니다. - 지도 영역에 현재 위치 버튼(`CurrentLocationButton`)과 현 지도 범위 벗어났을 때 초기 위치로 돌아가는 버튼(`BackToPositionButton`)을 추가했습니다. - BottomSheet의 스크롤 위치(`offset`)에 따라 버튼들의 노출 여부와 위치가 동적으로 변경되도록 `OffsetDependentBox`를 구현했습니다. - **`PlaceListBottomSheet.kt` 리팩토링:** - `AnchoredDraggableState`를 외부에서 주입받도록 변경하여 상태 관리의 유연성을 높였습니다. - BottomSheet의 높이가 동적으로 계산되도록 `layout` Modifier를 사용하여 `updateAnchors`를 호출하는 방식으로 개선했습니다. - 스크롤 오프셋 값을 외부로 전달하는 `onScroll` 콜백을 추가했습니다. - **신규 컴포저블 추가:** - `BackToPositionButton.kt`: '학교로 돌아가기' 등 특정 위치로 돌아가는 기능을 수행하는 `AssistChip` 기반 버튼입니다. - `CurrentLocationButton.kt`: Naver 지도의 `LocationButtonView`를 Compose 환경에서 사용할 수 있도록 감싼 컴포저블입니다. - `OffsetDependentBox.kt`: 컨텐츠의 Y축 위치를 외부의 `offset` 값에 따라 동적으로 조절하는 커스텀 레이아웃 컴포저블입니다. * refactor(PlaceList): ViewModel, Fragment Compose 마이그레이션 기존의 `Fragment`와 `RecyclerView` 기반으로 구현되었던 장소 목록 UI(`PlaceListFragment`)를 Jetpack Compose 기반의 `PlaceListScreen`으로 전면 마이그레이션했습니다. 이를 통해 XML 레이아웃과 `BottomSheetBehavior` 등 View 시스템에 대한 의존성을 제거하고, 선언적 UI 방식으로 전환했습니다. - **`PlaceListFragment.kt` 리팩토링:** - `onCreateView`에서 `ComposeView`를 반환하도록 변경하고, 내부에 `PlaceListScreen` 컴포저블을 설정했습니다. - 기존의 `RecyclerView.Adapter`, `BottomSheetBehavior` 관련 로직, 스켈레톤 UI 처리 등 View 바인딩 코드를 모두 제거했습니다. - `PlaceMapViewModel`과 `PlaceListViewModel`의 데이터를 `StateFlow`로 변환하여 구독하고, `PlaceListScreen`의 상태로 전달하도록 수정했습니다. - 지도 클릭 시 `BottomSheet`를 축소시키는 로직을 `LaunchedEffect` 내에서 Flow를 구독하여 처리하도록 변경했습니다. - **`PlaceMapViewModel.kt` 수정:** - `LiveData`로 관리되던 `isExceededMaxLength`와 `onMapViewClick`을 `StateFlow`와 `Flow`로 각각 변환 (`isExceededMaxLengthFlow`, `onMapViewClickFlow`) 하여 Compose에서 효율적으로 상태를 관찰할 수 있도록 했습니다. - **`PlaceListViewModel.kt` 수정:** - 장소 목록 상태(`places`)를 `LiveData`에서 `StateFlow`(`placesFlow`)로 변환하여, 하위 컴포저블이 상태 변화를 구독하도록 개선했습니다. * refactor(PlaceList): 같은 카테고리 클릭 시 깜빡이는 현상 제거 불필요해진 `Complete` UI 상태를 제거하고 관련 로직을 정리하여 장소 목록 화면의 상태 관리 코드를 간소화했습니다. - **`PlaceListUiState.kt` 수정:** - 더 이상 사용되지 않는 `Complete` 상태 클래스를 `PlaceListUiState` 실드 인터페이스에서 삭제했습니다. - **`PlaceListScreen.kt` 수정:** - `Complete` 상태를 처리하던 분기문을 제거했습니다. - 이제 `Success` 상태에서 데이터를 변수에 저장한 후, 별도의 분기 없이 바로 `PlaceListContent` 컴포저블을 호출하여 목록을 표시하도록 로직을 변경했습니다. - **`PlaceListFragment.kt` 및 `PlaceListViewModel.kt` 정리:** - `Complete` 상태로 변경하던 `setPlacesStateComplete()` 메서드 호출부와 선언부를 모두 제거하여 관련 코드를 정리했습니다. * refactor(PlaceList): 데이터 로딩 로직 및 UI 상태 처리 개선 장소 목록 화면(`PlaceListScreen`)의 데이터 로딩 및 상태 표시 로직을 리팩토링하여 코드 구조를 개선하고 사용자 경험을 향상했습니다. - **`PlaceListScreen.kt` 수정:** - `onPlaceLoad` 콜백을 `@Composable` 함수 타입으로 변경하여, `LaunchedEffect`를 통해 시간 태그(`TimeTag`) 변경 시에만 데이터 로딩(`updatePlacesByTimeTag`)이 호출되도록 수정했습니다. - 데이터 로딩 성공(`Success`) 상태이지만 목록이 비어있는 경우 `EmptyStateScreen`을 표시하도록 하여, 데이터가 없을 때의 UI를 명확히 했습니다. - `Loading` 및 `Error` 상태 화면의 Y축 오프셋 값을 하드코딩된 `(-220).dp`에서 `HALF_EXPANDED_OFFSET` 상수로 대체하여 가독성과 유지보수성을 높였습니다. - `Success` 상태에서 불필요하게 `loadedPlace` 상태 변수에 데이터를 저장하던 중간 과정을 제거하고, `placesUiState.value`를 `PlaceListContent`에 직접 전달하도록 간소화했습니다. - **`PlaceListFragment.kt` 수정:** - `PlaceListScreen`의 `onPlaceLoad`가 `@Composable`로 변경됨에 따라, `LaunchedEffect` 내에서 `selectedTimeTagFlow`를 구독하여 시간 태그가 변경될 때마다 장소 목록을 업데이트하도록 로직을 수정했습니다. - 더 이상 사용되지 않는 `timeTag` 상태 변수를 제거했습니다. * refactor(PlaceList): BottomSheet 및 RecyclerView 관련 View 시스템 코드 제거 `PlaceListFragment`의 Compose 마이그레이션이 완료됨에 따라, 기존 View 시스템에서 사용되던 관련 클래스들을 모두 삭제했습니다. - **삭제된 클래스 목록:** - **`PlaceListAdapter` 및 `PlaceViewHolder`**: 장소 목록을 표시하기 위해 `RecyclerView`에서 사용되던 어댑터와 뷰홀더를 제거했습니다. - **`PlaceListBottomSheetBehavior` 및 관련 `Behavior` 클래스**: `CoordinatorLayout`과 함께 BottomSheet의 복잡한 스크롤 및 상호작용을 처리하던 커스텀 `Behavior`들을 모두 삭제했습니다. - `PlaceListBottomSheetBehavior` - `PlaceListScrollBehavior` - `PlaceListBottomSheetFollowBehavior` - `BottomSheetFollowCallback` - `MoveToInitialPositionCallback` - **`PlaceListBottomSheetCallback`**: BottomSheet의 상태 변화를 감지하던 콜백 클래스를 제거했습니다. * refactor(PlaceList): BottomSheet를 Compose로 완전히 전환하고 XML 의존성 제거 기존 `CoordinatorLayout`과 `BottomSheetBehavior`를 사용하던 장소 목록 UI를 100% Jetpack Compose로 마이그레이션했습니다. 이를 통해 XML 레이아웃과 커스텀 Behavior에 대한 의존성을 제거하고, 상태 관리를 Compose 방식으로 통합하여 코드의 일관성과 유지보수성을 향상했습니다. - **`PlaceListBottomSheet` 리팩토링:** - `AnchoredDraggableState`를 직접 다루는 대신, 이를 래핑하는 `PlaceListBottomSheetState` 클래스를 새로 도입하여 상태 관리 로직을 캡슐화했습니다. - `rememberAnchoredState`를 `rememberPlaceListBottomSheetState`로 대체하여 BottomSheet의 상태를 보다 명확하게 생성 및 관리하도록 개선했습니다. - `PlaceListBottomSheetState` 열거형의 이름을 `PlaceListBottomSheetValue`로 변경하여 상태 값임을 명확히 했습니다. - **XML 레이아웃 및 커스텀 Behavior 제거:** - `fragment_place_list.xml`에서 `PlaceListBottomSheetBehavior`와 `PlaceListBottomSheetFollowBehavior` 등 커스텀 Behavior와 관련된 속성들을 모두 삭제했습니다. - 이에 따라 더 이상 사용되지 않는 `PlaceListBottomSheetFollowBehavior`와 관련 확장 함수(`placeListBottomSheetFollowBehavior`)를 코드에서 제거했습니다. - `FestaBookAppGraph`에서 `PlaceListBottomSheetBehavior` 관련 의존성 주입 코드를 삭제했습니다. - **컴포저블 구조 개선:** - `PlaceListScreen.kt`: - `OffsetDependentBox`의 이름을 `OffsetDependentLayout`으로 변경하여 역할을 더 명확히 했습니다. - `CurrentLocationButton`과 `BackToPositionButton`이 `OffsetDependentLayout` 내에서 `Box`로 감싸져 올바르게 배치되도록 수정했습니다. - `LazyColumn`에서 장소 목록이 비어있을 때 `EmptyStateScreen`을 `item`으로 추가하여, 목록의 다른 아이템들과 함께 스크롤될 수 있도록 개선했습니다. - `onBackToInitialPositionClicked` 콜백 함수의 이름을 `onBackToInitialPositionClick`으로 변경하여 일관성을 맞췄습니다. - `PlaceCategoryLabel.kt`: Row에 적용되던 modifier를 Card의 modifier로 이동시켜 컴포저블의 재사용성을 높였습니다. - **Fragment 로직 간소화:** - `PlaceListFragment.kt`: - `bottomSheetState.animateTo()` 호출을 `bottomSheetState.update()`로 변경하여 새로 도입된 상태 관리 API를 사용하도록 수정했습니다. - XML 레이아웃의 Behavior를 사용하던 로직을 모두 제거하고 Compose 컴포넌트로 완전히 대체했습니다. * refactor: AnchordState 추상화 기존에 `AnchoredDraggableState`를 직접 사용하던 `PlaceListBottomSheet`의 상태 관리 방식을 `PlaceListBottomSheetState`라는 새로운 상태 홀더 클래스를 도입하여 개선했습니다. 이를 통해 상태 관련 로직을 캡슐화하고, 컴포저블의 API를 더 명확하게 만들었습니다. - **`PlaceListBottomSheetState.kt` 신규 추가:** - `AnchoredDraggableState`를 래핑하는 `PlaceListBottomSheetState` 상태 홀더 클래스와 `rememberPlaceListBottomSheetState` 컴포저블 함수를 추가했습니다. - `settleImmediately`와 같은 BottomSheet의 동작 관련 로직을 상태 홀더 내부로 이동시켰습니다. - 상태를 나타내는 `enum class`의 이름을 `PlaceListBottomSheetState`에서 `PlaceListBottomSheetValue`로 변경하여, 상태 홀더 클래스와의 혼동을 방지했습니다. - **`PlaceListBottomSheet.kt` 리팩토링:** - `AnchoredDraggableState`를 직접 주입받는 대신, 새로운 `PlaceListBottomSheetState`를 받도록 수정했습니다. - 상태 업데이트, 오프셋 계산, NestedScroll 연결 등의 로직을 새로운 상태 홀더 객체를 사용하도록 변경했습니다. - **관련 컴포저블 및 프래그먼트 수정:** - `PlaceListScreen`과 `PlaceListFragment`에서 `rememberAnchoredState` 대신 `rememberPlaceListBottomSheetState`를 사용하도록 변경했습니다. - 지도 클릭 시 BottomSheet를 축소시키는 로직을 `bottomSheetState.update()`를 호출하도록 수정했습니다. - **불필요한 코드 제거:** - 더 이상 사용되지 않는 `PlaceListBottomSheetBehavior`와 `PlaceListBottomSheetFollowBehavior` 관련 XML 속성 및 의존성 주입 코드를 삭제했습니다. - 전역 예외 처리기 설정 로직을 주석 처리했습니다. (`FestaBookApp.kt`) * refactor(ViewModel): 테스트 코드에서 불필요한 테스트와 `getOrAwaitValue` 사용 제거 `PlaceListViewModelTest`와 `PlaceMapViewModelTest`에서 불필하거나 더 이상 사용되지 않는 테스트 코드를 정리하고, `StateFlow`의 값을 가져오는 방식을 개선했습니다. - **`PlaceListViewModelTest.kt` 수정:** - 장소 정보 로딩 완료 상태(`PlaceListUiState.Complete`)를 검증하던 테스트 메서드를 삭제했습니다. 이는 현재 비즈니스 로직에서 더 이상 필요하지 않은 상태 테스트로 판단되어 제거되었습니다. - 관련 `PlaceUiModel` import 구문을 제거했습니다. - **`PlaceMapViewModelTest.kt` 수정:** - `StateFlow`인 `timeTags`의 값을 검증할 때, 불필요한 `getOrAwaitValue()` 확장 함수 대신 `.value` 프로퍼티를 직접 사용하도록 변경했습니다. 이를 통해 테스트 코드를 더 간결하게 만들었습니다. * feat: 이미지 URL 변환 유틸리티 함수 추가 및 전역 예외 처리기 활성화 S3의 URL 포맷 변경에 대응하고 이미지 로딩 안정성을 높이기 위해, 이미지 URL을 변환하는 확장 함수 `convertImageUrl`을 추가했습니다. 또한, 애플리케이션 전반의 안정성을 강화하기 위해 비활성화되어 있던 전역 예외 처리기를 다시 활성화했습니다. - **`PlaceListScreen.kt` 수정:** - `CoilImage`에 이미지를 로드하기 전, `convertImageUrl()` 확장 함수를 호출하여 S3 URL을 올바른 형식으로 변환하도록 수정했습니다. - **`FestaBookApp.kt` 수정:** - `onCreate()` 메서드에서 주석 처리되어 있던 `setGlobalExceptionHandler()` 호출을 활성화하여, 앱 전체에서 발생하는 예외를 일관되게 처리하도록 변경했습니다. * fix: 타임 태그별로 필터링이 되지 않는 버그 해결 * refactor: 에러 스낵바 노출, hide 상태일 때는 onMapDrag 미수신, 빈 리스트일 때 아무것도 출력하지 않는 버그 해결 장소 목록(`PlaceList`)에서 에러가 발생했을 때 사용자에게 스낵바를 통해 피드백을 제공하는 기능을 추가하고, 데이터 상태에 따른 UI 표시 로직을 개선했습니다. - **`PlaceListScreen.kt` 수정:** - 에러 상태를 상위 컴포넌트로 전달하기 위한 `onError` 콜백 파라미터를 추가했습니다. `PlaceListUiState.Error` 상태가 되면 이 콜백이 호출됩니다. - `PlaceListUiState.Success` 상태일 때, 데이터 목록(`placesUiState.value`)이 비어있는 경우 `EmptyStateScreen`을 표시하도록 로직을 변경했습니다. - 기존에 `PlaceListContent` 내부 `item`으로 처리되던 빈 상태 UI 로직을 제거하여, 상태 분기점에서 일관되게 처리하도록 구조를 개선했습니다. - **`PlaceListFragment.kt` 수정:** - `PlaceListScreen`에 새로 추가된 `onError` 콜백을 구현하여, 에러 발생 시 `showErrorSnackBar()`를 호출해 사용자에게 에러 메시지를 표시하도록 했습니다. * fix(PlaceList): OffsetDependentLayout 빈 컨텐츠 크래시 수정 `OffsetDependentLayout` 컴포저블에 전달된 `content`가 비어있을 경우(`measurables`가 없을 때) 발생할 수 있는 런타임 예외를 방지하기 위해 안전 처리를 추가했습니다. - **`OffsetDependentLayout.kt` 수정:** - `measurables.first()` 대신 `measurables.firstOrNull()`을 사용하여 측정할 요소가 있는지 확인하도록 변경했습니다. - 요소가 없는 경우 빈 레이아웃을 반환하여 앱이 종료되지 않도록 예외 처리를 구현했습니다. * feat(CoilImage): 이미지 URL 변환 로직 통합 `CoilImage` 컴포저블 내부에서 `convertImageUrl()` 확장 함수를 직접 호출하도록 변경하여, 이미지 로딩 시 URL 변환 로직을 일관되게 적용하도록 개선했습니다. - **`CoilImage.kt` 수정:** - `url` 파라미터를 그대로 사용하던 것을 `url.convertImageUrl()`을 통해 변환된 URL을 사용하도록 수정했습니다. - **`PlaceListScreen.kt` 수정:** - `CoilImage` 호출 시 개별적으로 `convertImageUrl()`을 적용하던 코드를 제거하고 원본 URL을 전달하도록 변경했습니다. --- .../daedan/festabook/di/FestaBookAppGraph.kt | 3 - .../presentation/common/FragmentUtil.kt | 7 - .../common/component/CoilImage.kt | 3 +- .../placeMap/PlaceMapViewModel.kt | 14 + .../placeMap/component/PlaceCategoryLabel.kt | 79 +++++ .../placeMap/component/PlaceMapScreen.kt | 54 ++++ .../placeMap/model/PlaceCategoryUiModel.kt | 9 + .../placeMap/model/PlaceListUiState.kt | 2 - .../placeList/PlaceListBottomSheetCallback.kt | 28 -- .../placeMap/placeList/PlaceListFragment.kt | 208 +++++------- .../placeMap/placeList/PlaceListViewModel.kt | 15 +- .../placeList/adapter/PlaceListAdapter.kt | 38 --- .../placeList/adapter/PlaceViewHolder.kt | 33 -- .../behavior/BottomSheetFollowCallback.kt | 31 -- .../behavior/MoveToInitialPositionCallback.kt | 35 -- .../behavior/PlaceListBottomSheetBehavior.kt | 132 -------- .../PlaceListBottomSheetFollowBehavior.kt | 56 ---- .../behavior/PlaceListScrollBehavior.kt | 212 ------------ .../component/BackToPositionButton.kt | 52 +++ .../component/CurrentLocationButton.kt | 19 ++ .../component/OffsetDependentLayout.kt | 32 ++ .../component/PlaceListBottomSheet.kt | 223 +++++++++++++ .../component/PlaceListBottomSheetState.kt | 75 +++++ .../placeList/component/PlaceListScreen.kt | 304 ++++++++++++++++++ .../main/res/layout/fragment_place_list.xml | 9 +- .../placeList/PlaceListViewModelTest.kt | 15 - 26 files changed, 957 insertions(+), 731 deletions(-) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryLabel.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListBottomSheetCallback.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/adapter/PlaceListAdapter.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/adapter/PlaceViewHolder.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/BottomSheetFollowCallback.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/MoveToInitialPositionCallback.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListBottomSheetBehavior.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListBottomSheetFollowBehavior.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListScrollBehavior.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/BackToPositionButton.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/CurrentLocationButton.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/OffsetDependentLayout.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheet.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheetState.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListScreen.kt diff --git a/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt b/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt index cd93ed60..f55f15f7 100644 --- a/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt +++ b/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt @@ -8,7 +8,6 @@ import com.daedan.festabook.di.viewmodel.MetroViewModelFactory import com.daedan.festabook.logging.DefaultFirebaseLogger import com.daedan.festabook.presentation.main.MainActivity import com.daedan.festabook.presentation.placeDetail.PlaceDetailActivity -import com.daedan.festabook.presentation.placeMap.placeList.behavior.PlaceListBottomSheetBehavior import com.daedan.festabook.presentation.schedule.ScheduleViewModel import com.daedan.festabook.presentation.splash.SplashActivity import com.google.android.play.core.appupdate.AppUpdateManager @@ -34,8 +33,6 @@ interface FestaBookAppGraph { fun inject(activity: PlaceDetailActivity) - fun inject(placeListBottomSheetBehavior: PlaceListBottomSheetBehavior<*>) - // splashActivity @Provides fun provideAppUpdateManager(application: Application): AppUpdateManager = AppUpdateManagerFactory.create(application) diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/FragmentUtil.kt b/app/src/main/java/com/daedan/festabook/presentation/common/FragmentUtil.kt index cee11651..b487a170 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/FragmentUtil.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/FragmentUtil.kt @@ -8,12 +8,10 @@ import android.os.Parcelable import android.util.TypedValue import android.view.View import android.view.ViewGroup -import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.Fragment import com.daedan.festabook.R import com.daedan.festabook.data.util.ApiResultException -import com.daedan.festabook.presentation.placeMap.placeList.behavior.PlaceListBottomSheetFollowBehavior import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.snackbar.Snackbar import java.io.Serializable @@ -128,8 +126,3 @@ fun Activity.showSnackBar(msg: String) { }.setActionTextColor(getColor(R.color.blue400)) snackBar.show() } - -fun View.placeListBottomSheetFollowBehavior(): PlaceListBottomSheetFollowBehavior? { - val params = layoutParams as? CoordinatorLayout.LayoutParams - return params?.behavior as? PlaceListBottomSheetFollowBehavior -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt index 734af705..3ccfab10 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt @@ -12,6 +12,7 @@ import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.convertImageUrl @Composable fun CoilImage( @@ -26,7 +27,7 @@ fun CoilImage( ImageRequest .Builder(LocalContext.current) .apply(builder) - .data(url) + .data(url.convertImageUrl()) .crossfade(true) .build(), contentDescription = contentDescription, diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt index c20e13f6..5eebe62a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt @@ -22,6 +22,7 @@ import com.daedan.festabook.presentation.placeMap.model.toUiModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -67,6 +68,15 @@ class PlaceMapViewModel( private val _isExceededMaxLength: MutableLiveData = MutableLiveData() val isExceededMaxLength: LiveData = _isExceededMaxLength + val isExceededMaxLengthFlow: StateFlow = + _isExceededMaxLength + .asFlow() + .stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = false, + ) + private val _backToInitialPositionClicked: MutableLiveData> = MutableLiveData() val backToInitialPositionClicked: LiveData> = _backToInitialPositionClicked @@ -76,6 +86,10 @@ class PlaceMapViewModel( private val _onMapViewClick: MutableLiveData> = MutableLiveData() val onMapViewClick: LiveData> = _onMapViewClick + val onMapViewClickFlow: Flow> = + _onMapViewClick + .asFlow() + init { loadOrganizationGeography() loadTimeTags() diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryLabel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryLabel.kt new file mode 100644 index 00000000..e0315a78 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryLabel.kt @@ -0,0 +1,79 @@ +package com.daedan.festabook.presentation.placeMap.component + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.getIconId +import com.daedan.festabook.presentation.placeMap.model.getLabelColor +import com.daedan.festabook.presentation.placeMap.model.getTextId +import com.daedan.festabook.presentation.theme.festabookShapes +import kotlin.math.roundToInt + +@Composable +fun PlaceCategoryLabel( + category: PlaceCategoryUiModel, + modifier: Modifier = Modifier, + iconColor: Color = category.getLabelColor(), +) { + Card( + shape = festabookShapes.radius1, + colors = + CardColors( + containerColor = getBackgroundColor(iconColor), + contentColor = Color.Unspecified, + disabledContainerColor = getBackgroundColor(iconColor), + disabledContentColor = Color.Unspecified, + ), + modifier = modifier, + ) { + Row( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(category.getIconId()), + contentDescription = stringResource(category.getTextId()), + tint = iconColor, + modifier = Modifier.size(12.dp), + ) + Text( + modifier = Modifier.padding(start = 4.dp), + text = stringResource(category.getTextId()), + style = MaterialTheme.typography.labelMedium, + ) + } + } +} + +private fun getBackgroundColor(color: Color): Color { + // 10% 투명도를 가지게 변경 + val alpha = (MAX_ALPHA * ALPHA_RATIO).roundToInt() + return color.copy(alpha = alpha / MAX_ALPHA.toFloat()) +} + +private const val MAX_ALPHA = 255 +private const val ALPHA_RATIO = 0.10f + +@Preview(showBackground = true) +@Composable +private fun PlaceCategoryLabelPreview() { + val category = PlaceCategoryUiModel.FOOD_TRUCK + PlaceCategoryLabel( + category = category, + iconColor = Color(0xFF00AB40), + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt index 3443c865..1d3d7215 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt @@ -5,16 +5,41 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.placeCategory.component.PlaceCategoryScreen import com.daedan.festabook.presentation.placeMap.timeTagSpinner.component.TimeTagMenu import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme import com.naver.maps.map.NaverMap +@OptIn(ExperimentalMaterial3Api::class) @Composable fun PlaceMapScreen( + timeTagTitle: String, + timeTags: List, + places: List, + modifier: Modifier = Modifier, + onMapReady: (NaverMap) -> Unit = {}, + onPlaceClick: (PlaceUiModel) -> Unit = {}, + onTimeTagClick: (TimeTag) -> Unit = {}, +) { + PlaceMapContent( + title = timeTagTitle, + timeTags = timeTags, + onMapReady = onMapReady, + onTimeTagClick = onTimeTagClick, + ) +} + +@Composable +private fun PlaceMapContent( timeTags: List, title: String, onMapReady: (NaverMap) -> Unit, @@ -42,6 +67,35 @@ fun PlaceMapScreen( ).padding(horizontal = 24.dp), ) } + PlaceCategoryScreen() } } } + +@Preview(showBackground = true) +@Composable +private fun PlaceMapScreenPreview() { + FestabookTheme { + PlaceMapScreen( + timeTagTitle = "테스트", + timeTags = + listOf( + TimeTag(1, "테스트1"), + TimeTag(2, "테스트2"), + ), + places = + (0..100).map { + PlaceUiModel( + id = it.toLong(), + imageUrl = null, + title = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", + description = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", + location = "테스트테스트테스트테스트테스트테스트테스트테스트테스트", + category = PlaceCategoryUiModel.BAR, + isBookmarked = true, + timeTagId = listOf(1), + ) + }, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceCategoryUiModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceCategoryUiModel.kt index 5b63718f..064b092d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceCategoryUiModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceCategoryUiModel.kt @@ -1,5 +1,6 @@ package com.daedan.festabook.presentation.placeMap.model +import androidx.compose.ui.graphics.Color import com.daedan.festabook.R import com.daedan.festabook.domain.model.PlaceCategory import com.daedan.festabook.presentation.placeMap.mapManager.internal.OverlayImageManager @@ -65,6 +66,14 @@ val PlaceCategoryUiModel.Companion.iconResources: List R.drawable.ic_extra_selected, ) +fun PlaceCategoryUiModel.getLabelColor() = + when (this) { + PlaceCategoryUiModel.BOOTH -> Color(0xFF0094FF) + PlaceCategoryUiModel.FOOD_TRUCK -> Color(0xFF00AB40) + PlaceCategoryUiModel.BAR -> Color(0xFFFF9D00) + else -> Color.Unspecified + } + fun OverlayImageManager.getNormalIcon(category: PlaceCategoryUiModel): OverlayImage? = when (category) { PlaceCategoryUiModel.BOOTH -> getImage(R.drawable.ic_booth) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceListUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceListUiState.kt index 96740048..561e7443 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceListUiState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceListUiState.kt @@ -14,6 +14,4 @@ sealed interface PlaceListUiState { data class Error( val throwable: Throwable, ) : PlaceListUiState - - class Complete : PlaceListUiState } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListBottomSheetCallback.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListBottomSheetCallback.kt deleted file mode 100644 index 6dd7cd47..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListBottomSheetCallback.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList - -import android.view.View -import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel -import com.google.android.material.bottomsheet.BottomSheetBehavior -import timber.log.Timber - -class PlaceListBottomSheetCallback( - private val viewModel: PlaceMapViewModel, -) : BottomSheetBehavior.BottomSheetCallback() { - override fun onStateChanged( - bottomSheet: View, - newState: Int, - ) { - when (newState) { - BottomSheetBehavior.STATE_DRAGGING -> { - Timber.d("STATE_DRAGGING") - viewModel.onExpandedStateReached() - } - } - } - - override fun onSlide( - bottomSheet: View, - slideOffset: Float, - ) { - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt index f44724cb..779b2773 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt @@ -2,26 +2,30 @@ package com.daedan.festabook.presentation.placeMap.placeList import android.content.Context import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import androidx.coordinatorlayout.widget.CoordinatorLayout +import android.view.ViewGroup +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.ContextCompat import androidx.core.view.isGone import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.DefaultItemAnimator import coil3.ImageLoader import coil3.asImage import coil3.request.ImageRequest import coil3.request.ImageResult import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentPlaceListBinding +import com.daedan.festabook.di.appGraph import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.logging.logger import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.common.OnMenuItemReClickListener -import com.daedan.festabook.presentation.common.placeListBottomSheetFollowBehavior import com.daedan.festabook.presentation.common.showErrorSnackBar import com.daedan.festabook.presentation.placeDetail.PlaceDetailActivity import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel @@ -29,13 +33,11 @@ import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel import com.daedan.festabook.presentation.placeMap.logging.PlaceBackToSchoolClick import com.daedan.festabook.presentation.placeMap.logging.PlaceItemClick import com.daedan.festabook.presentation.placeMap.logging.PlaceMapButtonReClick -import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.placeList.adapter.PlaceListAdapter -import com.daedan.festabook.presentation.placeMap.placeList.behavior.BottomSheetFollowCallback -import com.daedan.festabook.presentation.placeMap.placeList.behavior.MoveToInitialPositionCallback -import com.daedan.festabook.presentation.placeMap.placeList.behavior.PlaceListBottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.daedan.festabook.presentation.placeMap.placeList.component.PlaceListBottomSheetValue +import com.daedan.festabook.presentation.placeMap.placeList.component.PlaceListScreen +import com.daedan.festabook.presentation.placeMap.placeList.component.rememberPlaceListBottomSheetState +import com.daedan.festabook.presentation.theme.FestabookTheme import com.naver.maps.map.NaverMap import com.naver.maps.map.OnMapReadyCallback import dev.zacsweers.metro.AppScope @@ -46,8 +48,8 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import timber.log.Timber @@ -64,36 +66,79 @@ class PlaceListFragment( private val viewModel: PlaceMapViewModel by viewModels({ requireParentFragment() }) private val childViewModel: PlaceListViewModel by viewModels() - private val placeAdapter by lazy { - PlaceListAdapter(this) - } + // 기존 Fragment와의 상호 운용성을 위한 임시 Flow입니다. + // Fragment -> PlaceMapScreen으로 통합 시, 제거할 예정입니다. + private val mapFlow: MutableStateFlow = MutableStateFlow(null) - private val placeListBottomSheetBehavior by lazy { - val params = binding.layoutPlaceList.layoutParams as? CoordinatorLayout.LayoutParams - params?.behavior as? PlaceListBottomSheetBehavior - } + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + super.onCreateView(inflater, container, savedInstanceState) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val places by childViewModel.placesFlow.collectAsStateWithLifecycle() + val isExceedMaxLength by viewModel.isExceededMaxLengthFlow.collectAsStateWithLifecycle() + val bottomSheetState = rememberPlaceListBottomSheetState() + val map by mapFlow.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.onMapViewClickFlow.collect { + if (isGone || !isResumed || view == null) return@collect + bottomSheetState.update(PlaceListBottomSheetValue.COLLAPSED) + } + } - private lateinit var moveToInitialPositionCallback: MoveToInitialPositionCallback + FestabookTheme { + PlaceListScreen( + placesUiState = places, + map = map, + onPlaceClick = { onPlaceClicked(it) }, + bottomSheetState = bottomSheetState, + isExceedMaxLength = isExceedMaxLength, + onPlaceLoadFinish = { places -> + preloadImages( + requireContext(), + places, + ) + }, + onPlaceLoad = { + viewModel.selectedTimeTagFlow.collect { + childViewModel.updatePlacesByTimeTag(it.timeTagId) + } + }, + onError = { + showErrorSnackBar(it.throwable) + }, + onBackToInitialPositionClick = { + viewModel.onBackToInitialPositionClicked() + appGraph.defaultFirebaseLogger.log( + PlaceBackToSchoolClick( + baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), + ), + ) + }, + ) + } + } + } override fun onViewCreated( view: View, savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - lifecycleScope.launch { - setUpPlaceAdapter() - setBehaviorCallback() - setUpObserver() - setUpBinding() - } + setUpObserver() } override fun onPlaceClicked(place: PlaceUiModel) { Timber.d("onPlaceClicked: $place") startPlaceDetailActivity(place) - binding.logger.log( + appGraph.defaultFirebaseLogger.log( PlaceItemClick( - baseLogData = binding.logger.getBaseLogData(), + baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), placeId = place.id, timeTagName = viewModel.selectedTimeTag.value?.name ?: "undefinded", category = place.category.name, @@ -103,63 +148,23 @@ class PlaceListFragment( override fun onMenuItemReClick() { if (binding.root.isGone || !isResumed || view == null) return - val layoutParams = binding.layoutPlaceList.layoutParams as? CoordinatorLayout.LayoutParams - val behavior = layoutParams?.behavior as? BottomSheetBehavior - behavior?.state = BottomSheetBehavior.STATE_HALF_EXPANDED - binding.logger.log( + lifecycleScope.launch { + viewModel.onMapViewClick() + } + appGraph.defaultFirebaseLogger.log( PlaceMapButtonReClick( - baseLogData = binding.logger.getBaseLogData(), + baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), ), ) } override fun onMapReady(naverMap: NaverMap) { - binding.lbvCurrentLocation.map = naverMap - } - - private fun setUpPlaceAdapter() { - binding.rvPlaces.adapter = placeAdapter - (binding.rvPlaces.itemAnimator as DefaultItemAnimator).supportsChangeAnimations = false + lifecycleScope.launch { + mapFlow.value = naverMap + } } private fun setUpObserver() { - childViewModel.places.observe(viewLifecycleOwner) { places -> - when (places) { - is PlaceListUiState.Loading -> showSkeleton() - is PlaceListUiState.Success -> { - preloadImages( - requireContext(), - places.value, - ) - placeAdapter.submitList(places.value) { - if (places.value.isEmpty()) { - binding.tvErrorToLoadPlaceInfo.visibility = View.VISIBLE - } else { - binding.tvErrorToLoadPlaceInfo.visibility = View.GONE - } - binding.rvPlaces.scrollToPosition(0) - } - } - - is PlaceListUiState.PlaceLoaded -> { - viewModel.selectedTimeTag.observe(viewLifecycleOwner) { timeTag -> - childViewModel.updatePlacesByTimeTag(timeTag.timeTagId) - } - } - - is PlaceListUiState.Complete -> { - hideSkeleton() - } - - is PlaceListUiState.Error -> { - hideSkeleton() - binding.tvErrorToLoadPlaceInfo.visibility = View.VISIBLE - Timber.w(places.throwable, "PlaceListFragment: ${places.throwable.message}") - showErrorSnackBar(places.throwable) - } - } - } - viewModel.navigateToDetail.observe(viewLifecycleOwner) { selectedPlace -> startPlaceDetailActivity(selectedPlace) } @@ -171,62 +176,12 @@ class PlaceListFragment( childViewModel.updatePlacesByCategories(selectedCategories) } } - - viewModel.isExceededMaxLength.observe(viewLifecycleOwner) { isExceededMaxLength -> - moveToInitialPositionCallback.setIsExceededMaxLength(isExceededMaxLength) - binding.chipBackToInitialPosition.visibility = - if (isExceededMaxLength) View.VISIBLE else View.GONE - } - - viewModel.onMapViewClick.observe(viewLifecycleOwner) { - placeListBottomSheetBehavior?.state = BottomSheetBehavior.STATE_COLLAPSED - } - } - - private fun setUpBinding() { - binding.chipBackToInitialPosition.setOnClickListener { - viewModel.onBackToInitialPositionClicked() - binding.logger.log( - PlaceBackToSchoolClick( - baseLogData = binding.logger.getBaseLogData(), - ), - ) - } - binding.rvPlaces.itemAnimator = null - } - - private fun setBehaviorCallback() { - moveToInitialPositionCallback = - MoveToInitialPositionCallback(binding.chipBackToInitialPosition.id) - - binding.lbvCurrentLocation - .placeListBottomSheetFollowBehavior() - ?.setCallback( - BottomSheetFollowCallback(binding.lbvCurrentLocation.id), - ) - - binding.chipBackToInitialPosition - .placeListBottomSheetFollowBehavior() - ?.setCallback(moveToInitialPositionCallback) } private fun startPlaceDetailActivity(place: PlaceUiModel) { viewModel.selectPlace(place.id) } - private fun showSkeleton() { - binding.tvErrorToLoadPlaceInfo.visibility = View.GONE - binding.rvPlaces.visibility = View.GONE - binding.sflScheduleSkeleton.visibility = View.VISIBLE - binding.sflScheduleSkeleton.startShimmer() - } - - private fun hideSkeleton() { - binding.rvPlaces.visibility = View.VISIBLE - binding.sflScheduleSkeleton.visibility = View.GONE - binding.sflScheduleSkeleton.stopShimmer() - } - private fun startPlaceDetailActivity(placeDetail: PlaceDetailUiModel) { Timber.d("start detail activity") val intent = PlaceDetailActivity.newIntent(requireContext(), placeDetail) @@ -276,9 +231,6 @@ class PlaceListFragment( deferredList.add(deferred) } deferredList.awaitAll() - withContext(Dispatchers.Main) { - childViewModel.setPlacesStateComplete() - } } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt index 95fd917f..184101a3 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt @@ -3,6 +3,7 @@ package com.daedan.festabook.presentation.placeMap.placeList import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey import com.daedan.festabook.domain.model.PlaceCategory @@ -15,6 +16,9 @@ import com.daedan.festabook.presentation.placeMap.model.toUiModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @ContributesIntoMap(AppScope::class) @@ -30,6 +34,13 @@ class PlaceListViewModel( MutableLiveData(PlaceListUiState.Loading()) val places: LiveData>> = _places + val placesFlow: StateFlow>> = + _places.asFlow().stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = PlaceListUiState.Loading(), + ) + init { loadAllPlaces() } @@ -77,10 +88,6 @@ class PlaceListViewModel( _places.value = PlaceListUiState.Success(cachedPlaceByTimeTag) } - fun setPlacesStateComplete() { - _places.value = PlaceListUiState.Complete() - } - private fun loadAllPlaces() { viewModelScope.launch { val result = placeListRepository.getPlaces() diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/adapter/PlaceListAdapter.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/adapter/PlaceListAdapter.kt deleted file mode 100644 index d7f3677e..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/adapter/PlaceListAdapter.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.adapter - -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.placeList.OnPlaceClickListener - -class PlaceListAdapter( - private val handler: OnPlaceClickListener, -) : ListAdapter(DIFF_UTIL) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): PlaceViewHolder = PlaceViewHolder.from(parent, handler) - - override fun onBindViewHolder( - holder: PlaceViewHolder, - position: Int, - ) { - holder.bind(getItem(position)) - } - - companion object { - private val DIFF_UTIL = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: PlaceUiModel, - newItem: PlaceUiModel, - ): Boolean = oldItem.id == newItem.id - - override fun areContentsTheSame( - oldItem: PlaceUiModel, - newItem: PlaceUiModel, - ): Boolean = oldItem == newItem - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/adapter/PlaceViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/adapter/PlaceViewHolder.kt deleted file mode 100644 index 9c3b096e..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/adapter/PlaceViewHolder.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.adapter - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.daedan.festabook.databinding.ItemPlaceListBinding -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.placeList.OnPlaceClickListener - -class PlaceViewHolder private constructor( - private val binding: ItemPlaceListBinding, - private val listener: OnPlaceClickListener, -) : RecyclerView.ViewHolder(binding.root) { - fun bind(placeUiModel: PlaceUiModel) { - binding.place = placeUiModel - binding.listener = listener - } - - companion object { - fun from( - parent: ViewGroup, - listener: OnPlaceClickListener, - ): PlaceViewHolder = - PlaceViewHolder( - ItemPlaceListBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ), - listener, - ) - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/BottomSheetFollowCallback.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/BottomSheetFollowCallback.kt deleted file mode 100644 index b39c4910..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/BottomSheetFollowCallback.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.behavior - -import android.view.View -import androidx.annotation.IdRes -import com.google.android.material.bottomsheet.BottomSheetBehavior - -open class BottomSheetFollowCallback( - @IdRes private val viewId: Int, -) : BottomSheetBehavior.BottomSheetCallback() { - private lateinit var child: View - - override fun onStateChanged( - bottomSheet: View, - newState: Int, - ) { - if (!::child.isInitialized) { - child = bottomSheet.rootView.findViewById(viewId) ?: return - } - - if (newState == BottomSheetBehavior.STATE_EXPANDED) { - child.visibility = View.GONE - } else { - child.visibility = View.VISIBLE - } - } - - override fun onSlide( - bottomSheet: View, - slideOffset: Float, - ) = Unit -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/MoveToInitialPositionCallback.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/MoveToInitialPositionCallback.kt deleted file mode 100644 index 5bfbcaab..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/MoveToInitialPositionCallback.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.behavior - -import android.view.View -import androidx.annotation.IdRes -import com.google.android.material.bottomsheet.BottomSheetBehavior - -class MoveToInitialPositionCallback( - @IdRes private val viewId: Int, -) : BottomSheetBehavior.BottomSheetCallback() { - private lateinit var child: View - private var isExceededMaxLength = true - - override fun onStateChanged( - bottomSheet: View, - newState: Int, - ) { - if (!::child.isInitialized) { - child = bottomSheet.rootView.findViewById(viewId) ?: return - } - if (newState == BottomSheetBehavior.STATE_EXPANDED || !isExceededMaxLength) { - child.visibility = View.GONE - } else { - child.visibility = View.VISIBLE - } - } - - override fun onSlide( - bottomSheet: View, - slideOffset: Float, - ) = Unit - - fun setIsExceededMaxLength(isExceededMaxLength: Boolean) { - this.isExceededMaxLength = isExceededMaxLength - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListBottomSheetBehavior.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListBottomSheetBehavior.kt deleted file mode 100644 index c1f68eb3..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListBottomSheetBehavior.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.behavior - -import android.content.Context -import android.util.AttributeSet -import android.view.MotionEvent -import android.view.View -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.recyclerview.widget.RecyclerView -import com.daedan.festabook.FestaBookApp -import com.daedan.festabook.R -import com.daedan.festabook.logging.DefaultFirebaseLogger -import com.daedan.festabook.presentation.placeMap.logging.PlaceListSwipeUp -import com.google.android.material.bottomsheet.BottomSheetBehavior -import dev.zacsweers.metro.Inject - -class PlaceListBottomSheetBehavior( - context: Context, - attrs: AttributeSet, -) : BottomSheetBehavior( - context, - attrs, - ) { - private lateinit var recyclerView: RecyclerView - private var headerRange: IntRange = 0..0 - - @Inject - private lateinit var logger: DefaultFirebaseLogger - - init { - (context.applicationContext as FestaBookApp).festaBookGraph.inject(this) - state = STATE_HALF_EXPANDED - isGestureInsetBottomIgnored = true - addBottomSheetCallback( - object : BottomSheetCallback() { - override fun onStateChanged( - bottomSheet: View, - newState: Int, - ) { - if (newState == STATE_HALF_EXPANDED && ::recyclerView.isInitialized) { - recyclerView.scrollToPosition(HEADER_POSITION) - } - if (newState == STATE_EXPANDED) { - logger.log( - PlaceListSwipeUp( - baseLogData = logger.getBaseLogData(), - ), - ) - } - } - - override fun onSlide( - bottomSheet: View, - slideOffset: Float, - ) = Unit - }, - ) - } - - override fun onLayoutChild( - parent: CoordinatorLayout, - child: V, - layoutDirection: Int, - ): Boolean { - recyclerView = child.findViewById(R.id.rv_places) - recyclerView.getChildAt(HEADER_POSITION)?.let { - headerRange = expandedOffset..(expandedOffset + it.height) - } - return super.onLayoutChild(parent, child, layoutDirection) - } - - override fun onInterceptTouchEvent( - parent: CoordinatorLayout, - child: V, - event: MotionEvent, - ): Boolean { - if (event.action == MotionEvent.ACTION_DOWN && - state == STATE_EXPANDED && - event.y.toInt() in headerRange - ) { - state = STATE_COLLAPSED - } - return super.onInterceptTouchEvent(parent, child, event) - } - - override fun onNestedScroll( - coordinatorLayout: CoordinatorLayout, - child: V, - target: View, - dxConsumed: Int, - dyConsumed: Int, - dxUnconsumed: Int, - dyUnconsumed: Int, - type: Int, - consumed: IntArray, - ) { - super.onNestedScroll( - coordinatorLayout, - child, - target, - dxConsumed, - dyConsumed, - dxUnconsumed, - dyUnconsumed, - type, - consumed, - ) - if (!recyclerView.canScrollVertically(-1)) { - state = STATE_HALF_EXPANDED - } - } - - override fun onStopNestedScroll( - coordinatorLayout: CoordinatorLayout, - child: V, - target: View, - type: Int, - ) { - super.onStopNestedScroll(coordinatorLayout, child, target, type) - - if (!recyclerView.canScrollVertically(-1) && state == STATE_EXPANDED) { - state = STATE_HALF_EXPANDED - } - } - - fun setOffset(height: Int) { - expandedOffset = height - } - - companion object { - private const val HEADER_POSITION = 0 - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListBottomSheetFollowBehavior.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListBottomSheetFollowBehavior.kt deleted file mode 100644 index d0f07b31..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListBottomSheetFollowBehavior.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.behavior - -import android.content.Context -import android.util.AttributeSet -import android.view.View -import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.google.android.material.bottomsheet.BottomSheetBehavior - -class PlaceListBottomSheetFollowBehavior( - context: Context, - attrs: AttributeSet, -) : CoordinatorLayout.Behavior( - context, - attrs, - ) { - private var currentBehavior: BottomSheetBehavior<*>? = null - private var callback: BottomSheetBehavior.BottomSheetCallback? = null - - override fun layoutDependsOn( - parent: CoordinatorLayout, - child: View, - dependency: View, - ): Boolean { - val behavior = (dependency.layoutParams as? CoordinatorLayout.LayoutParams)?.behavior - if (behavior is BottomSheetBehavior<*>) { - currentBehavior = behavior - } - return behavior is BottomSheetBehavior<*> - } - - override fun onDependentViewChanged( - parent: CoordinatorLayout, - child: View, - dependency: View, - ): Boolean { - val bottomSheetTopY = dependency.y - dependency.height - child.translationY = bottomSheetTopY - return true - } - - override fun onDetachedFromLayoutParams() { - super.onDetachedFromLayoutParams() - callback?.let { - currentBehavior?.removeBottomSheetCallback(it) - } - currentBehavior = null - } - - fun setCallback(callback: BottomSheetBehavior.BottomSheetCallback) { - this.callback?.let { - currentBehavior?.removeBottomSheetCallback(it) - } - this.callback = callback - currentBehavior?.addBottomSheetCallback(callback) - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListScrollBehavior.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListScrollBehavior.kt deleted file mode 100644 index e827b1fa..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListScrollBehavior.kt +++ /dev/null @@ -1,212 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.behavior - -import android.content.Context -import android.content.res.TypedArray -import android.util.AttributeSet -import android.view.View -import android.view.ViewGroup -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.content.withStyledAttributes -import androidx.core.view.ViewCompat -import androidx.recyclerview.widget.RecyclerView -import com.daedan.festabook.R -import com.daedan.festabook.presentation.common.canScrollUp -import com.daedan.festabook.presentation.common.getSystemBarHeightCompat -import com.daedan.festabook.presentation.common.scrollAnimation -import com.google.android.material.chip.ChipGroup - -/** - * @deprecated - * @see - * 이 클래스는 더 이상 사용되지 않으며, 향후 버전에서 제거될 예정입니다. - * 대안으로 현재 PlaceListBottomSheetBehavior이 사용되고 있습니다 - * 네이버 지도 검색 UI를 본딴 동작을 수행합니다 - * 자세한 내용은 해당 링크를 참조해주세요 - * "https://github.com/woowacourse-teams/2025-festabook/pull/174" - */ -class PlaceListScrollBehavior( - context: Context, - attrs: AttributeSet, -) : CoordinatorLayout.Behavior() { - private lateinit var attribute: Attribute - private lateinit var state: BehaviorState - private var isInitialized: Boolean = false - private lateinit var minimumHeightView: View - - init { - context.withStyledAttributes(attrs, R.styleable.PlaceListScrollBehavior) { - setAttribute() - } - } - - override fun onLayoutChild( - parent: CoordinatorLayout, - child: ConstraintLayout, - layoutDirection: Int, - ): Boolean { - minimumHeightView = parent.findViewById(R.id.cg_categories) - if (!isInitialized) { - val recyclerView: RecyclerView? = parent.findViewById(attribute.recyclerViewId) - val companionView: View? = parent.findViewById(attribute.companionViewId) - isInitialized = true - - // 기기 높이 - 시스템 바 높이 - val rootViewHeight = child.rootView.height - child.getSystemBarHeightCompat() - child.translationY = rootViewHeight - attribute.initialY - state = BehaviorState(recyclerView, companionView, rootViewHeight) - } - state.companionView.setCompanionHeight(child) - return super.onLayoutChild(parent, child, layoutDirection) - } - - override fun onStartNestedScroll( - coordinatorLayout: CoordinatorLayout, - child: ConstraintLayout, - directTargetChild: View, - target: View, - axes: Int, - type: Int, - ): Boolean = axes == ViewCompat.SCROLL_AXIS_VERTICAL - - override fun onNestedPreScroll( - coordinatorLayout: CoordinatorLayout, - child: ConstraintLayout, - target: View, - dx: Int, - dy: Int, - consumed: IntArray, - type: Int, - ) { - super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type) - state.companionView.setCompanionHeight(child) - - val isAlreadyConsumed = child.consumeIfRecyclerViewCanScrollUp(dy, consumed) - if (isAlreadyConsumed) return - child.consumeBackgroundLayoutScroll(dy, consumed) - } - - override fun onNestedScroll( - coordinatorLayout: CoordinatorLayout, - child: ConstraintLayout, - target: View, - dxConsumed: Int, - dyConsumed: Int, - dxUnconsumed: Int, - dyUnconsumed: Int, - type: Int, - consumed: IntArray, - ) { - if (dyUnconsumed == 0) { - state.companionView?.visibility = View.GONE - } - super.onNestedScroll( - coordinatorLayout, - child, - target, - dxConsumed, - dyConsumed, - dxUnconsumed, - dyUnconsumed, - type, - consumed, - ) - } - - fun setOnScrollListener(listener: (dy: Float) -> Unit) { - state = state.copy(onScrollListener = listener) - } - - private fun TypedArray.setAttribute() { - val initialY = - getDimension(R.styleable.PlaceListScrollBehavior_initialY, UNINITIALIZED_VALUE) - val minimumY = - getDimension(R.styleable.PlaceListScrollBehavior_minimumY, UNINITIALIZED_VALUE) - val recyclerViewId = - getResourceId( - R.styleable.PlaceListScrollBehavior_recyclerView, - UNINITIALIZED_VALUE.toInt(), - ) - val companionViewId = - getResourceId( - R.styleable.PlaceListScrollBehavior_companionView, - UNINITIALIZED_VALUE.toInt(), - ) - attribute = - Attribute( - initialY, - minimumY, - recyclerViewId, - companionViewId, - ) - } - - private fun View?.setCompanionHeight(child: ConstraintLayout) { - this?.apply { - y = child.translationY - height - } - } - - private fun ViewGroup.consumeBackgroundLayoutScroll( - dy: Int, - consumed: IntArray, - ) { - apply { - // 최대 높이 (0일수록 천장에 가깝고, contentAreaHeight일수록 바닥에 가까움), 즉 maxHeight 까지만 스크롤을 내릴 수 있습니다 - val maxHeight = state.rootViewHeight - attribute.minimumY - val requestedTranslationY = translationY - dy - val newTranslationY = getNewTranslationY(requestedTranslationY, maxHeight) - - // 외부 레이아웃이 스크롤이 되었을 때만 스크롤 리스너 적용 - if (requestedTranslationY in minimumHeightView.height.toFloat()..maxHeight) { - state.onScrollListener?.invoke(dy.toFloat()) - } - translationY = newTranslationY - scrollAnimation(newTranslationY) - if (newTranslationY.toInt() == minimumHeightView.height) { - consumed[1] = 0 - } else { - consumed[1] = newTranslationY.toInt() - } - } - } - - private fun ViewGroup.getNewTranslationY( - requestedTranslationY: Float, - maxHeight: Float, - ): Float = requestedTranslationY.coerceIn(minimumHeightView.height.toFloat(), maxHeight) - - private fun ViewGroup.consumeIfRecyclerViewCanScrollUp( - dy: Int, - consumed: IntArray, - ): Boolean { - state.recyclerView?.let { - // 리사이클러 뷰가 위로 스크롤 될 수 있을 때 - if (dy < 0 && it.canScrollUp()) { - state.companionView?.visibility = View.VISIBLE - consumed[1] = 0 - return true - } - } - - return false - } - - private data class Attribute( - val initialY: Float, - val minimumY: Float, - val recyclerViewId: Int, - val companionViewId: Int, - ) - - private data class BehaviorState( - val recyclerView: RecyclerView?, - val companionView: View?, - val rootViewHeight: Int, - val onScrollListener: ((dy: Float) -> Unit)? = null, - ) - - companion object { - private const val UNINITIALIZED_VALUE = 0f - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/BackToPositionButton.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/BackToPositionButton.kt new file mode 100644 index 00000000..f85864b1 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/BackToPositionButton.kt @@ -0,0 +1,52 @@ +package com.daedan.festabook.presentation.placeMap.placeList.component + +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookShapes + +@Composable +fun BackToPositionButton( + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + AssistChip( + modifier = modifier, + onClick = onClick, + label = { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + ) + }, + border = + AssistChipDefaults.assistChipBorder( + enabled = true, + borderColor = FestabookColor.black, + borderWidth = 1.dp, + ), + colors = + AssistChipDefaults.assistChipColors( + containerColor = FestabookColor.white, + ), + shape = festabookShapes.radiusFull, + ) +} + +@Preview(showBackground = true) +@Composable +private fun BackToPositionButtonPreview() { + FestabookTheme { + BackToPositionButton( + text = "학교로 돌아가기", + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/CurrentLocationButton.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/CurrentLocationButton.kt new file mode 100644 index 00000000..dd4c4c10 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/CurrentLocationButton.kt @@ -0,0 +1,19 @@ +package com.daedan.festabook.presentation.placeMap.placeList.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import com.naver.maps.map.NaverMap +import com.naver.maps.map.widget.LocationButtonView + +@Composable +fun CurrentLocationButton( + modifier: Modifier = Modifier, + map: NaverMap? = null, +) { + AndroidView( + modifier = modifier, + factory = { context -> LocationButtonView(context) }, + update = { view -> view.map = map }, + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/OffsetDependentLayout.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/OffsetDependentLayout.kt new file mode 100644 index 00000000..28aa3ba8 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/OffsetDependentLayout.kt @@ -0,0 +1,32 @@ +package com.daedan.festabook.presentation.placeMap.placeList.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import kotlin.math.roundToInt + +@Composable +fun OffsetDependentLayout( + offset: Float, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Layout( + content = content, + modifier = modifier, + ) { measurables, constraints -> + val placeable = + measurables.firstOrNull()?.measure(constraints) ?: return@Layout layout( + width = constraints.minWidth, + height = constraints.minHeight, + placementBlock = { }, + ) + + // 부모의 크기를 결정 + layout(placeable.width, placeable.height + offset.roundToInt()) { + // offset만큼 배치 + val finalYPosition = offset.roundToInt() - placeable.height + placeable.placeRelative(x = 0, y = finalYPosition) + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheet.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheet.kt new file mode 100644 index 00000000..44290d69 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheet.kt @@ -0,0 +1,223 @@ +package com.daedan.festabook.presentation.placeMap.placeList.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.theme.FestabookColor +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@Composable +fun PlaceListBottomSheet( + peekHeight: Dp, + halfExpandedRatio: Float, + modifier: Modifier = Modifier, + bottomSheetState: PlaceListBottomSheetState = + rememberPlaceListBottomSheetState( + PlaceListBottomSheetValue.HALF_EXPANDED, + ), + shape: Shape = PlaceListBottomSheetDefault.bottomSheetBackgroundShape, + color: Color = PlaceListBottomSheetDefault.bottomSheetBackgroundColor, + onStateUpdate: (PlaceListBottomSheetValue) -> Unit = {}, + onScroll: (Float) -> Unit = {}, + dragHandle: @Composable () -> Unit = {}, + content: @Composable () -> Unit, +) { + require(halfExpandedRatio in 0.0..1.0) { "halfExpandedRatio는 0과 1 사이여야 합니다." } + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val currentOnStateUpdate by rememberUpdatedState(onStateUpdate) + + LaunchedEffect(bottomSheetState.settledValue) { + currentOnStateUpdate(bottomSheetState.settledValue) + } + + val nestedScrollConnection = placeListBottomSheetNestedScrollConnection(bottomSheetState) + + Column( + modifier = + modifier + .fillMaxSize() + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + + // 실제 레이아웃 측정 시에만 앵커 설정 + if (!isLookingAhead) { + val screenHeightPx = constraints.maxHeight.toFloat() + // 3가지 앵커 높이 정의 (DP) + val halfExpandedOffsetPx = + screenHeightPx - screenHeightPx * halfExpandedRatio + val collapsedOffsetPx = with(density) { screenHeightPx - peekHeight.toPx() } + val expandedOffsetPx = 0f // 화면 최상단 + + bottomSheetState.state.updateAnchors( + newAnchors = + DraggableAnchors { + PlaceListBottomSheetValue.EXPANDED at expandedOffsetPx + PlaceListBottomSheetValue.HALF_EXPANDED at halfExpandedOffsetPx + PlaceListBottomSheetValue.COLLAPSED at collapsedOffsetPx + }, + newTarget = bottomSheetState.currentValue, + ) + // 스크롤 되었을 때 호출하는 콜백 + scope.launch { + snapshotFlow { bottomSheetState.state.requireOffset() } + .collect { currentOffset -> + onScroll(currentOffset) + } + } + } + + layout(placeable.width, placeable.height) { + placeable.place(0, 0) + } + }.nestedScroll(nestedScrollConnection) + .offset { + IntOffset( + 0, + if (bottomSheetState.offset.isNaN()) 0 else bottomSheetState.offset.roundToInt(), + ) + }.background( + color = color, + shape = shape, + ).anchoredDraggable( + state = bottomSheetState.state, + orientation = Orientation.Vertical, + ), + ) { + PlaceListBottomSheetDefault.DefaultDragHandle() + dragHandle() + content() + } +} + +/** + * PlaceListBottomSheet의 기본 스타일을 정의합니다. + * 기본적인 DragHandle 컴포저블을 정의합니다. + */ +object PlaceListBottomSheetDefault { + val bottomSheetBackgroundShape: Shape = + RoundedCornerShape( + topStart = 30.dp, + topEnd = 30.dp, + ) + + val bottomSheetBackgroundColor: Color + @Composable + get() = FestabookColor.white + + private val dragHandleVerticalPadding = 12.dp + private val dragHandleWidth = 32.dp + private val dragHandleHeight = 4.dp + + private val dragHandleCorner = + RoundedCornerShape( + percent = 50, + ) + + private val dragHandleColor + @Composable + get() = FestabookColor.gray400 + + @Composable + fun DefaultDragHandle(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .padding(vertical = dragHandleVerticalPadding) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .size(width = dragHandleWidth, height = dragHandleHeight) + .background( + color = dragHandleColor, + shape = dragHandleCorner, + ), + ) + } + } +} + +/** NestedScroll을 위한 Connection 객체를 반환합니다. + */ +private fun placeListBottomSheetNestedScrollConnection(placeListBottomSheetState: PlaceListBottomSheetState): NestedScrollConnection { + return object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset = + if (available.y < 0 && source == NestedScrollSource.UserInput) { + placeListBottomSheetState.state.dispatchRawDelta(available.y).toOffset() + } else { + Offset.Zero + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset = + if (source == NestedScrollSource.UserInput) { + placeListBottomSheetState.state.dispatchRawDelta(available.y).toOffset() + } else { + Offset.Zero + } + + override suspend fun onPostFling( + consumed: Velocity, + available: Velocity, + ): Velocity { + placeListBottomSheetState.settleImmediately(available) + return available + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val toFling = available.y + val currentOffset = placeListBottomSheetState.state.requireOffset() + val minAnchor = placeListBottomSheetState.anchors.minPosition() + return if (toFling < 0 && currentOffset > minAnchor) { + placeListBottomSheetState.settleImmediately(available) + available + } else { + Velocity.Zero + } + } + + private fun Float.toOffset() = + Offset( + x = 0f, + y = this, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheetState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheetState.kt new file mode 100644 index 00000000..b16cb7f8 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheetState.kt @@ -0,0 +1,75 @@ +package com.daedan.festabook.presentation.placeMap.placeList.component + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.animateTo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.Velocity + +class PlaceListBottomSheetState( + val state: AnchoredDraggableState, +) { + val anchors get() = state.anchors + val settledValue get() = state.settledValue + + val currentValue get() = state.currentValue + val offset get() = state.offset + + suspend fun update(newState: PlaceListBottomSheetValue) { + state.animateTo(newState) + } + + /** + anchoredState의 기본 settle() 동작은 거리 기반으로 동작합니다. + 거리 기반 동작을, 상태 기반으로 동작하도록 변경하여, 미세한 드래그에도 바텀시트가 펼쳐지도록 합니다. + */ + suspend fun settleImmediately( + available: Velocity, + animationSpec: AnimationSpec = spring(stiffness = Spring.StiffnessMediumLow), + ) { + val targetState = + if (available.y < 0) { + when (state.currentValue) { + PlaceListBottomSheetValue.EXPANDED -> state.currentValue + PlaceListBottomSheetValue.HALF_EXPANDED -> PlaceListBottomSheetValue.EXPANDED + PlaceListBottomSheetValue.COLLAPSED -> PlaceListBottomSheetValue.HALF_EXPANDED + } + } else if (available.y > 0) { + when (state.currentValue) { + PlaceListBottomSheetValue.EXPANDED -> PlaceListBottomSheetValue.HALF_EXPANDED + PlaceListBottomSheetValue.HALF_EXPANDED -> PlaceListBottomSheetValue.COLLAPSED + PlaceListBottomSheetValue.COLLAPSED -> state.currentValue + } + } else { + state.currentValue + } + + state.animateTo( + targetValue = targetState, + animationSpec = animationSpec, + ) + } +} + +enum class PlaceListBottomSheetValue { + EXPANDED, + HALF_EXPANDED, + COLLAPSED, +} + +@Composable +fun rememberPlaceListBottomSheetState( + initialState: PlaceListBottomSheetValue = PlaceListBottomSheetValue.HALF_EXPANDED, +): PlaceListBottomSheetState { + val anchoredState = + remember { + AnchoredDraggableState(initialValue = initialState) + } + + return remember(anchoredState) { + PlaceListBottomSheetState(anchoredState) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListScreen.kt new file mode 100644 index 00000000..6b73ad63 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListScreen.kt @@ -0,0 +1,304 @@ +package com.daedan.festabook.presentation.placeMap.placeList.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.component.CoilImage +import com.daedan.festabook.presentation.common.component.EmptyStateScreen +import com.daedan.festabook.presentation.common.component.LoadingStateScreen +import com.daedan.festabook.presentation.placeMap.component.PlaceCategoryLabel +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import com.naver.maps.map.NaverMap +import kotlinx.coroutines.launch + +@Composable +fun PlaceListScreen( + placesUiState: PlaceListUiState>, + modifier: Modifier = Modifier, + map: NaverMap? = null, + isExceedMaxLength: Boolean = false, + bottomSheetState: PlaceListBottomSheetState = + rememberPlaceListBottomSheetState( + PlaceListBottomSheetValue.HALF_EXPANDED, + ), + onPlaceClick: (place: PlaceUiModel) -> Unit = {}, + onPlaceLoadFinish: (places: List) -> Unit = {}, + onPlaceLoad: suspend () -> Unit = {}, + onError: (PlaceListUiState.Error>) -> Unit = {}, + onBackToInitialPositionClick: () -> Unit = {}, +) { + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + var offset by remember { mutableFloatStateOf(0f) } + val currentOnPlaceLoad by rememberUpdatedState(onPlaceLoad) + + Box(modifier = modifier.fillMaxSize()) { + OffsetDependentLayout( + modifier = Modifier.padding(horizontal = festabookSpacing.paddingBody1), + offset = offset, + ) { + Box { + CurrentLocationButton( + map = map, + ) + if (isExceedMaxLength) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + BackToPositionButton( + text = stringResource(R.string.map_back_to_initial_position), + onClick = onBackToInitialPositionClick, + ) + } + } + } + } + + PlaceListBottomSheet( + peekHeight = 70.dp, + halfExpandedRatio = 0.4f, + onStateUpdate = { + if (listState.firstVisibleItemIndex != 0) { + scope.launch { listState.scrollToItem(0) } + } + }, + onScroll = { offset = it }, + bottomSheetState = bottomSheetState, + dragHandle = { + Text( + text = stringResource(R.string.place_list_title), + style = MaterialTheme.typography.displayLarge, + modifier = + Modifier + .padding( + top = festabookSpacing.paddingBody4, + bottom = festabookSpacing.paddingBody1, + ).padding(horizontal = festabookSpacing.paddingScreenGutter), + ) + }, + ) { + when (placesUiState) { + is PlaceListUiState.Loading -> + LoadingStateScreen( + modifier = Modifier.offset(y = HALF_EXPANDED_OFFSET), + ) + + is PlaceListUiState.Error -> { + onError(placesUiState) + EmptyStateScreen( + modifier = Modifier.offset(y = HALF_EXPANDED_OFFSET), + ) + } + + is PlaceListUiState.Success -> { + onPlaceLoadFinish(placesUiState.value) + if (placesUiState.value.isEmpty()) { + EmptyStateScreen( + modifier = Modifier.offset(y = HALF_EXPANDED_OFFSET), + ) + } else { + PlaceListContent( + places = placesUiState.value, + modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), + listState = listState, + onPlaceClick = onPlaceClick, + ) + } + } + + is PlaceListUiState.PlaceLoaded -> { + LaunchedEffect(Unit) { + scope.launch { + currentOnPlaceLoad() + } + } + } + } + } + } +} + +@Composable +private fun PlaceListContent( + places: List, + modifier: Modifier = Modifier, + listState: LazyListState = rememberLazyListState(), + onPlaceClick: (PlaceUiModel) -> Unit = {}, +) { + LazyColumn( + state = listState, + modifier = modifier.fillMaxHeight(), + ) { + items( + items = places, + key = { place -> place.id }, + ) { place -> + PlaceListItem( + place = place, + onPlaceClick = onPlaceClick, + ) + } + } +} + +@Composable +private fun PlaceListItem( + place: PlaceUiModel, + modifier: Modifier = Modifier, + onPlaceClick: (PlaceUiModel) -> Unit = {}, +) { + Column( + modifier = + modifier + .padding(bottom = festabookSpacing.paddingBody3) + .clickable( + onClick = { onPlaceClick(place) }, + interactionSource = null, + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + CoilImage( + url = place.imageUrl ?: "", + contentDescription = stringResource(R.string.content_description_booth_image), + modifier = + Modifier + .size(80.dp) + .clip(festabookShapes.radius2), + ) + PlaceListItemContent( + modifier = + Modifier + .padding(start = festabookSpacing.paddingBody3) + .weight(1f), + place = place, + ) + } + HorizontalDivider( + modifier = + Modifier + .padding( + top = festabookSpacing.paddingBody4, + ), + ) + } +} + +private val HALF_EXPANDED_OFFSET = (-200).dp + +@Composable +private fun PlaceListItemContent( + place: PlaceUiModel, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + ) { + PlaceCategoryLabel( + category = place.category, + ) + Text( + modifier = Modifier.padding(top = festabookSpacing.paddingBody1), + text = place.title ?: stringResource(R.string.place_list_default_title), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + modifier = Modifier.padding(top = 2.dp), + text = + place.description + ?: stringResource(R.string.place_list_default_description), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + modifier = Modifier.padding(top = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(14.dp), + painter = painterResource(R.drawable.ic_location), + contentDescription = stringResource(R.string.content_description_iv_location), + ) + Text( + modifier = Modifier.padding(start = festabookSpacing.paddingBody1), + text = + place.location + ?: stringResource(R.string.place_list_default_location), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Preview +@Composable +private fun PlaceListScreenPreview() { + FestabookTheme { + PlaceListScreen( + placesUiState = + PlaceListUiState.Success( + (0..100).map { + PlaceUiModel( + id = it.toLong(), + imageUrl = null, + title = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", + description = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", + location = "테스트테스트테스트테스트테스트테스트테스트테스트테스트", + category = PlaceCategoryUiModel.FOOD_TRUCK, + isBookmarked = true, + timeTagId = listOf(1), + ) + }, + ), + modifier = + Modifier.padding( + horizontal = festabookSpacing.paddingScreenGutter, + ), + ) + } +} diff --git a/app/src/main/res/layout/fragment_place_list.xml b/app/src/main/res/layout/fragment_place_list.xml index 812dedb7..f79db6cc 100644 --- a/app/src/main/res/layout/fragment_place_list.xml +++ b/app/src/main/res/layout/fragment_place_list.xml @@ -1,5 +1,6 @@ + + android:layout_marginStart="8dp" /> - + app:chipStrokeWidth="1dp" /> \ No newline at end of file diff --git a/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt b/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt index a2b098af..d605eb11 100644 --- a/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt @@ -6,7 +6,6 @@ import com.daedan.festabook.domain.repository.PlaceListRepository import com.daedan.festabook.getOrAwaitValue import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.placeMap.model.toUiModel import com.daedan.festabook.presentation.placeMap.placeList.PlaceListViewModel import io.mockk.coEvery @@ -160,18 +159,4 @@ class PlaceListViewModelTest { val actual = placeListViewModel.places.getOrAwaitValue() assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) } - - @Test - fun `플레이스의 모든 정보가 로드가 완료되었을 때 이벤트를 발생시킬 수 있다`() = - runTest { - // given - val expected = PlaceListUiState.Complete>() - - // when - placeListViewModel.setPlacesStateComplete() - - // then - val actual = placeListViewModel.places.getOrAwaitValue() - assertThat(actual).isInstanceOf(expected::class.java) - } } From 72136fadbcdc364b74c951149c03b5a89f3afc5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Sun, 21 Dec 2025 20:05:09 +0900 Subject: [PATCH 061/140] =?UTF-8?q?feat:=20JUnit=205=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=B0=8F=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=99=98=EA=B2=BD=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 9 +++++++++ gradle/libs.versions.toml | 11 +++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 51201f5a..f5c3492a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -190,4 +190,13 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) debugImplementation(libs.logging.interceptor) + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testImplementation(libs.junit.jupiter.params) + testImplementation(libs.junit.platform.launcher) + testRuntimeOnly(libs.junit.vintage.engine) +} + +tasks.withType().configureEach { + useJUnitPlatform() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 59f07add..f35d4d35 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,9 @@ firebaseBom = "34.0.0" fragmentKtx = "1.8.8" kotlin = "2.2.20" coreKtx = "1.16.0" -junit = "4.13.2" +junit4 = "4.13.2" +junit5 = "5.14.1" +junitPlatformLauncher = "6.0.1" junitVersion = "1.2.1" espressoCore = "3.6.1" appcompat = "1.7.0" @@ -54,10 +56,11 @@ androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = firebase-analytics = { module = "com.google.firebase:firebase-analytics" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } firebase-crashlytics-ndk = { module = "com.google.firebase:firebase-crashlytics-ndk" } -junit = { group = "junit", name = "junit", version.ref = "junit" } +junit = { group = "junit", name = "junit", version.ref = "junit4" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit5" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" } lottie = { module = "com.airbnb.android:lottie", version.ref = "lottie" } @@ -81,6 +84,10 @@ androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "uiTooling" } +junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit5" } +junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit5" } +junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit5" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatformLauncher" } [plugins] From 722f08f11d93c7efa4037140035966c286a17946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Sun, 21 Dec 2025 20:05:19 +0900 Subject: [PATCH 062/140] =?UTF-8?q?refactor:=20ScheduleEventUiModel?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B6=81=EB=A7=88=ED=81=AC=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=ED=95=84=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/schedule/model/ScheduleEventUiModel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiModel.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiModel.kt index f22b60ca..482e5ff2 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiModel.kt @@ -10,7 +10,6 @@ data class ScheduleEventUiModel( val endTime: String, val title: String, val location: String, - val isBookmarked: Boolean = false, ) fun ScheduleEvent.toUiModel(): ScheduleEventUiModel = From 44db8ea001d903555d6c824ef2c89149514c97d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Sun, 21 Dec 2025 20:06:04 +0900 Subject: [PATCH 063/140] =?UTF-8?q?test:=20ScheduleViewModelTest=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/ScheduleViewModel.kt | 1 - .../schedule/ScheduleTestFixtures.kt | 24 +-- .../schedule/ScheduleViewModelTest.kt | 152 +++++++----------- 3 files changed, 74 insertions(+), 103 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt index f697ecaf..210a69bf 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt @@ -151,7 +151,6 @@ class ScheduleViewModel( } companion object { - const val INVALID_ID: Long = -1L private const val FIRST_INDEX: Int = 0 } } diff --git a/app/src/test/java/com/daedan/festabook/schedule/ScheduleTestFixtures.kt b/app/src/test/java/com/daedan/festabook/schedule/ScheduleTestFixtures.kt index ff92c553..2c27e4b2 100644 --- a/app/src/test/java/com/daedan/festabook/schedule/ScheduleTestFixtures.kt +++ b/app/src/test/java/com/daedan/festabook/schedule/ScheduleTestFixtures.kt @@ -3,7 +3,6 @@ package com.daedan.festabook.schedule import com.daedan.festabook.domain.model.ScheduleDate import com.daedan.festabook.domain.model.ScheduleEvent import com.daedan.festabook.domain.model.ScheduleEventStatus -import com.daedan.festabook.presentation.schedule.ScheduleEventsUiState import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus import java.time.LocalDate @@ -12,23 +11,31 @@ val FAKE_SCHEDULE_EVENTS = listOf( ScheduleEvent( id = 1L, - status = ScheduleEventStatus.UPCOMING, // 필요 시 enum 정의에 맞게 변경 - startTime = "2025-07-26T10:00:00", - endTime = "2025-07-26T11:00:00", + status = ScheduleEventStatus.ONGOING, + startTime = "10:00", + endTime = "11:00", title = "안드로이드 스터디", location = "서울 강남구 어딘가", ), ScheduleEvent( id = 1L, - status = ScheduleEventStatus.ONGOING, - startTime = "2025-07-26T10:00:00", - endTime = "2025-07-26T11:00:00", + status = ScheduleEventStatus.UPCOMING, // 필요 시 enum 정의에 맞게 변경 + startTime = "10:00", + endTime = "11:00", title = "안드로이드 스터디", location = "서울 강남구 어딘가", ), ) val FAKE_SCHEDULE_EVENTS_UI_MODELS = listOf( + ScheduleEventUiModel( + id = 1L, + status = ScheduleEventUiStatus.ONGOING, // enum이나 클래스에 맞게 수정 + startTime = "10:00", + endTime = "11:00", + title = "안드로이드 스터디", + location = "서울 강남구 어딘가", + ), ScheduleEventUiModel( id = 1L, status = ScheduleEventUiStatus.UPCOMING, // enum이나 클래스에 맞게 수정 @@ -36,12 +43,9 @@ val FAKE_SCHEDULE_EVENTS_UI_MODELS = endTime = "11:00", title = "안드로이드 스터디", location = "서울 강남구 어딘가", - isBookmarked = false, ), ) -val FAKE_SCHEDULE_EVENTS_UI_STATE = ScheduleEventsUiState.Success(FAKE_SCHEDULE_EVENTS_UI_MODELS, 1) - val FAKE_SCHEDULE_DATES = listOf( ScheduleDate(id = 1L, date = LocalDate.of(2025, 7, 26)), diff --git a/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt b/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt index 377086c4..598919cb 100644 --- a/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt @@ -1,10 +1,7 @@ package com.daedan.festabook.schedule import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.daedan.festabook.domain.model.ScheduleEvent -import com.daedan.festabook.domain.model.ScheduleEventStatus import com.daedan.festabook.domain.repository.ScheduleRepository -import com.daedan.festabook.getOrAwaitValue import com.daedan.festabook.presentation.schedule.ScheduleEventsUiState import com.daedan.festabook.presentation.schedule.ScheduleUiState import com.daedan.festabook.presentation.schedule.ScheduleViewModel @@ -19,13 +16,14 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain -import org.assertj.core.api.Assertions.assertThat -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Rule -import org.junit.Test +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.fail +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll @OptIn(ExperimentalCoroutinesApi::class) class ScheduleViewModelTest { @@ -38,139 +36,109 @@ class ScheduleViewModelTest { private lateinit var scheduleRepository: ScheduleRepository private lateinit var scheduleViewModel: ScheduleViewModel - @Before + @BeforeEach fun setUp() { Dispatchers.setMain(testDispatcher) scheduleRepository = mockk() coEvery { scheduleRepository.fetchAllScheduleDates() } returns - Result.success( - FAKE_SCHEDULE_DATES, - ) + Result.success(FAKE_SCHEDULE_DATES) coEvery { scheduleRepository.fetchScheduleEventsById(dateId) } returns - Result.success( - FAKE_SCHEDULE_EVENTS, - ) + Result.success(FAKE_SCHEDULE_EVENTS) - scheduleViewModel = ScheduleViewModel(scheduleRepository, dateId) + scheduleViewModel = ScheduleViewModel(scheduleRepository) } - @After + @AfterEach fun tearDown() { Dispatchers.resetMain() } @Test - fun `해당 날짜에 맞는 일정을 불러온다`() = + fun `ViewModel이 생성되면 해당 날짜를 불러온다`() = runTest { // given + advanceUntilIdle() // when - scheduleViewModel.loadSchedules() - advanceUntilIdle() // then - coVerify { scheduleRepository.fetchAllScheduleDates() } - coVerify { scheduleRepository.fetchScheduleEventsById(dateId) } - - val state = scheduleViewModel.scheduleEventsUiState.value - assertTrue(state is ScheduleEventsUiState.Success) - - val expected = FAKE_SCHEDULE_EVENTS.map { it.toUiModel() } - val result = (state as ScheduleEventsUiState.Success).events - assertEquals(expected, result) + val stateResult = scheduleViewModel.scheduleUiState.value + val expectedDate = FAKE_SCHEDULE_DATES.map { it.toUiModel() } + + assertAll( + { coVerify { scheduleRepository.fetchAllScheduleDates() } }, + { coVerify { scheduleRepository.fetchScheduleEventsById(dateId) } }, + { assertTrue(stateResult is ScheduleUiState.Success) }, + { assertEquals(expectedDate, (stateResult as ScheduleUiState.Success).dates) }, + ) } @Test - fun `현재 진행중인 일정의 인덱스를 불러올 수 있다`() = + fun `ViewModel이 생성되면 날짜에 해당하는 일정들을 불러온다`() = runTest { // given - coEvery { scheduleRepository.fetchScheduleEventsById(dateId) } returns - Result.success( - FAKE_SCHEDULE_EVENTS, - ) + advanceUntilIdle() // when - scheduleViewModel.loadSchedules() - advanceUntilIdle() + val state = scheduleViewModel.scheduleUiState.value // then - val state = scheduleViewModel.scheduleEventsUiState.getOrAwaitValue() - assertTrue(state is ScheduleEventsUiState.Success) + val successState = + state as? ScheduleUiState.Success ?: fail("ScheduleUiState.Success 가 아님: $state") + + val eventsState = + successState.eventsUiStateByPosition[0] as? ScheduleEventsUiState.Success + ?: fail("ScheduleEventsUiState.Success 가 아님") - val expected = 1 - val actual = (state as ScheduleEventsUiState.Success).currentEventPosition - assertThat(actual).isEqualTo(expected) + assertAll( + { coVerify { scheduleRepository.fetchAllScheduleDates() } }, + { coVerify { scheduleRepository.fetchScheduleEventsById(dateId) } }, + { assertEquals(FAKE_SCHEDULE_EVENTS_UI_MODELS, eventsState.events) }, + ) } @Test - fun `현재 진행중인 행사가 없다면 가장 첫 번쨰 일정의 인덱스를 불러온다`() = + fun `현재 진행중인 날짜의 인덱스를 불러올 수 있다`() = runTest { // given - coEvery { scheduleRepository.fetchScheduleEventsById(dateId) } returns - Result.success( - listOf( - ScheduleEvent( - id = 1L, - status = ScheduleEventStatus.UPCOMING, - startTime = "2025-07-26T10:00:00", - endTime = "2025-07-26T11:00:00", - title = "안드로이드 스터디", - location = "서울 강남구 어딘가", - ), - ), - ) + advanceUntilIdle() // when - scheduleViewModel.loadSchedules() - advanceUntilIdle() + val state = scheduleViewModel.scheduleUiState.value // then - val state = scheduleViewModel.scheduleEventsUiState.getOrAwaitValue() - assertTrue(state is ScheduleEventsUiState.Success) + val successState = + state as? ScheduleUiState.Success ?: fail("ScheduleUiState.Success 가 아님: $state") - val expected = 0 - val actual = (state as ScheduleEventsUiState.Success).currentEventPosition - assertThat(actual).isEqualTo(expected) + assertAll( + { coVerify { scheduleRepository.fetchAllScheduleDates() } }, + { coVerify { scheduleRepository.fetchScheduleEventsById(dateId) } }, + { assertEquals(0, successState.currentDatePosition) }, + ) } @Test - fun `dateId에 유효하지 않은 값을 넣고 뷰모델을 생성하면 일정을 불러오지 않는다`() = + fun `현재 진행중인 일정의 인덱스를 불러올 수 있다`() = runTest { // given - val dateId = ScheduleViewModel.INVALID_ID - - // when - scheduleViewModel = ScheduleViewModel(scheduleRepository, dateId) advanceUntilIdle() - // then - coVerify(exactly = 0) { scheduleRepository.fetchScheduleEventsById(dateId) } - } - - @Test - fun `모든 날짜의 축제 정보를 불러올 수 있다`() = - runTest { - // given - coEvery { scheduleRepository.fetchAllScheduleDates() } returns - Result.success( - FAKE_SCHEDULE_DATES, - ) - - val expected = - ScheduleUiState.Success( - FAKE_SCHEDULE_DATES.map { it.toUiModel() }, - 0, - ) - // when - scheduleViewModel.loadAllDates() - advanceUntilIdle() + val state = scheduleViewModel.scheduleUiState.value // then - coVerify { scheduleRepository.fetchAllScheduleDates() } - val actual = scheduleViewModel.scheduleDatesUiState.getOrAwaitValue() - assertThat(actual).isEqualTo(expected) + val successState = + state as? ScheduleUiState.Success ?: fail("ScheduleUiState.Success 가 아님: $state") + val eventsState = + successState.eventsUiStateByPosition[0] as? ScheduleEventsUiState.Success + ?: fail("ScheduleEventsUiState.Success 가 아님") + + assertAll( + { coVerify { scheduleRepository.fetchAllScheduleDates() } }, + { coVerify { scheduleRepository.fetchScheduleEventsById(dateId) } }, + { assertEquals(0, eventsState.currentEventPosition) }, + ) } } From 8baaf1ee010b0ab7a7afe4a38aa34abad96e23a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Mon, 22 Dec 2025 19:46:02 +0900 Subject: [PATCH 064/140] =?UTF-8?q?refactor:=20ScheduleEventCard=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20=EC=83=89?= =?UTF-8?q?=EC=83=81=20=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/component/ScheduleEventCard.kt | 110 ++++++++++-------- 1 file changed, 61 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt index 469375d1..de1ba290 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt @@ -31,53 +31,17 @@ fun ScheduleEventCard( scheduleEvent: ScheduleEventUiModel, modifier: Modifier = Modifier, ) { - val scheduleEventCardProps = - when (scheduleEvent.status) { - ScheduleEventUiStatus.UPCOMING -> { - ScheduleEventCardProps( - cardBorderColor = FestabookColor.accentGreen, - titleColor = FestabookColor.black, - contentColor = FestabookColor.gray500, - labelText = stringResource(R.string.schedule_status_upcoming), - labelTextColor = FestabookColor.black, - labelBackgroundColor = FestabookColor.white, - labelBorderColor = FestabookColor.black, - ) - } - - ScheduleEventUiStatus.ONGOING -> { - ScheduleEventCardProps( - cardBorderColor = FestabookColor.accentBlue, - titleColor = FestabookColor.black, - contentColor = FestabookColor.gray500, - labelText = stringResource(R.string.schedule_status_ongoing), - labelTextColor = FestabookColor.white, - labelBackgroundColor = FestabookColor.black, - labelBorderColor = FestabookColor.black, - ) - } - - ScheduleEventUiStatus.COMPLETED -> { - ScheduleEventCardProps( - cardBorderColor = FestabookColor.gray400, - titleColor = FestabookColor.gray400, - contentColor = FestabookColor.gray400, - labelText = stringResource(R.string.schedule_status_completed), - labelTextColor = FestabookColor.gray400, - labelBackgroundColor = FestabookColor.white, - labelBorderColor = FestabookColor.white, - ) - } - } + val scheduleEventCardColors = scheduleEventCardColors(scheduleEvent.status) Column( modifier = modifier .cardBackground( backgroundColor = MaterialTheme.colorScheme.background, - borderColor = scheduleEventCardProps.cardBorderColor, + borderColor = scheduleEventCardColors.cardBorderColor, shape = festabookShapes.radius2, - ).padding(festabookSpacing.paddingBody4), + ) + .padding(festabookSpacing.paddingBody4), verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody1), ) { Row( @@ -86,10 +50,10 @@ fun ScheduleEventCard( Text( text = scheduleEvent.title, style = MaterialTheme.typography.titleLarge, - color = scheduleEventCardProps.titleColor, + color = scheduleEventCardColors.titleColor, modifier = Modifier.weight(1f), ) - ScheduleEventLabel(scheduleEventCardProps) + ScheduleEventLabel(scheduleEvent.status) } Row( verticalAlignment = Alignment.CenterVertically, @@ -98,7 +62,7 @@ fun ScheduleEventCard( Icon( painter = painterResource(R.drawable.ic_clock), contentDescription = stringResource(R.string.content_description_iv_location), - tint = scheduleEventCardProps.contentColor, + tint = scheduleEventCardColors.contentColor, ) Text( text = @@ -108,7 +72,7 @@ fun ScheduleEventCard( scheduleEvent.endTime, ), style = MaterialTheme.typography.bodySmall, - color = scheduleEventCardProps.contentColor, + color = scheduleEventCardColors.contentColor, ) } Row( @@ -118,19 +82,20 @@ fun ScheduleEventCard( Icon( painter = painterResource(R.drawable.ic_location), contentDescription = stringResource(R.string.content_description_iv_location), - tint = scheduleEventCardProps.contentColor, + tint = scheduleEventCardColors.contentColor, ) Text( text = scheduleEvent.location, style = MaterialTheme.typography.bodySmall, - color = scheduleEventCardProps.contentColor, + color = scheduleEventCardColors.contentColor, ) } } } @Composable -private fun ScheduleEventLabel(scheduleEventCardProps: ScheduleEventCardProps) { +private fun ScheduleEventLabel(scheduleEventUiStatus: ScheduleEventUiStatus) { + val scheduleEventCardProps = scheduleEventCardColors(scheduleEventUiStatus) Box( modifier = Modifier @@ -143,13 +108,29 @@ private fun ScheduleEventLabel(scheduleEventCardProps: ScheduleEventCardProps) { contentAlignment = Alignment.Center, ) { Text( - text = scheduleEventCardProps.labelText, + text = scheduleLabelText(scheduleEventUiStatus), style = MaterialTheme.typography.bodySmall, color = scheduleEventCardProps.labelTextColor, ) } } +@Composable +private fun scheduleLabelText(status: ScheduleEventUiStatus): String = + when (status) { + ScheduleEventUiStatus.UPCOMING -> stringResource(R.string.schedule_status_upcoming) + ScheduleEventUiStatus.ONGOING -> stringResource(R.string.schedule_status_ongoing) + ScheduleEventUiStatus.COMPLETED -> stringResource(R.string.schedule_status_completed) + } + +@Composable +private fun scheduleEventCardColors(status: ScheduleEventUiStatus): ScheduleEventCardProps = + when (status) { + ScheduleEventUiStatus.UPCOMING -> ScheduleEventCardColors.upcoming + ScheduleEventUiStatus.ONGOING -> ScheduleEventCardColors.ongoing + ScheduleEventUiStatus.COMPLETED -> ScheduleEventCardColors.completed + } + @Composable @Preview(showBackground = true) private fun OnGoingScheduleEventCardPreview() { @@ -204,11 +185,42 @@ private fun CompleteScheduleEventCardONGOINGPreview() { } } +object ScheduleEventCardColors { + val upcoming = + ScheduleEventCardProps( + cardBorderColor = FestabookColor.accentGreen, + titleColor = FestabookColor.black, + contentColor = FestabookColor.gray500, + labelTextColor = FestabookColor.black, + labelBackgroundColor = FestabookColor.white, + labelBorderColor = FestabookColor.black, + ) + + val ongoing = + ScheduleEventCardProps( + cardBorderColor = FestabookColor.accentBlue, + titleColor = FestabookColor.black, + contentColor = FestabookColor.gray500, + labelTextColor = FestabookColor.white, + labelBackgroundColor = FestabookColor.black, + labelBorderColor = FestabookColor.black, + ) + + val completed = + ScheduleEventCardProps( + cardBorderColor = FestabookColor.gray400, + titleColor = FestabookColor.gray400, + contentColor = FestabookColor.gray400, + labelTextColor = FestabookColor.gray400, + labelBackgroundColor = FestabookColor.white, + labelBorderColor = FestabookColor.white, + ) +} + data class ScheduleEventCardProps( val cardBorderColor: Color, val titleColor: Color, val contentColor: Color, - val labelText: String, val labelTextColor: Color, val labelBackgroundColor: Color, val labelBorderColor: Color, From 8c77188763b76f4b52298014c8808519f719f4fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Mon, 22 Dec 2025 20:27:22 +0900 Subject: [PATCH 065/140] =?UTF-8?q?refactor:=20Lottie=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=8D=BC=ED=8B=B0=20=ED=98=B8=EC=9D=B4=EC=8A=A4=ED=8C=85=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/component/ScheduleEventItem.kt | 22 +++++----------- .../schedule/component/ScheduleTabPage.kt | 26 ++++++++++++++++++- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt index bc30f3ec..56b1321d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt @@ -1,24 +1,18 @@ package com.daedan.festabook.presentation.schedule.component import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.tooling.preview.Preview +import com.airbnb.lottie.LottieComposition import com.airbnb.lottie.LottieProperty import com.airbnb.lottie.compose.LottieAnimation -import com.airbnb.lottie.compose.LottieCompositionSpec -import com.airbnb.lottie.compose.LottieConstants -import com.airbnb.lottie.compose.animateLottieCompositionAsState -import com.airbnb.lottie.compose.rememberLottieComposition import com.airbnb.lottie.compose.rememberLottieDynamicProperties import com.airbnb.lottie.compose.rememberLottieDynamicProperty -import com.daedan.festabook.R import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus import com.daedan.festabook.presentation.theme.FestabookColor @@ -26,15 +20,11 @@ import com.daedan.festabook.presentation.theme.festabookSpacing @Composable fun ScheduleEventItem( + composition: LottieComposition?, + progress: Float, scheduleEvent: ScheduleEventUiModel, modifier: Modifier = Modifier, ) { - val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.pulse_circle)) - val progress by animateLottieCompositionAsState( - composition = composition, - iterations = LottieConstants.IterateForever, - ) - val props = when (scheduleEvent.status) { ScheduleEventUiStatus.UPCOMING -> { @@ -101,9 +91,9 @@ fun ScheduleEventItem( composition = composition, progress = { progress }, dynamicProperties = dynamicProperties, - modifier = Modifier.size(festabookSpacing.paddingBody4 * 4) + modifier = Modifier.size(festabookSpacing.paddingBody4 * 4), ) - ScheduleEventCard(scheduleEvent = scheduleEvent ) + ScheduleEventCard(scheduleEvent = scheduleEvent) } } @@ -111,6 +101,8 @@ fun ScheduleEventItem( @Preview private fun ScheduleEventItemPreview() { ScheduleEventItem( + composition = null, + progress = 1f, scheduleEvent = ScheduleEventUiModel( id = 1, diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt index 42ec35c6..bcb9d69d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt @@ -13,10 +13,17 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.airbnb.lottie.LottieComposition +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition +import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.EmptyStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen import com.daedan.festabook.presentation.common.component.PULL_OFFSET_LIMIT @@ -41,6 +48,11 @@ fun ScheduleTabPage( onRefresh: (List) -> Unit, modifier: Modifier = Modifier, ) { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.pulse_circle)) + val progress by animateLottieCompositionAsState( + composition = composition, + iterations = LottieConstants.IterateForever, + ) HorizontalPager( state = pagerState, modifier = modifier, @@ -66,6 +78,8 @@ fun ScheduleTabPage( is ScheduleEventsUiState.Refreshing -> { ScheduleTabContent( + composition = composition, + progress = progress, scheduleEvents = scheduleEventsUiState.oldEvents, modifier = Modifier @@ -79,6 +93,8 @@ fun ScheduleTabPage( is ScheduleEventsUiState.Success -> { ScheduleTabContent( + composition = composition, + progress = progress, scheduleEvents = scheduleEventsUiState.events, currentEventPosition = scheduleEventsUiState.currentEventPosition, modifier = @@ -99,6 +115,8 @@ fun ScheduleTabPage( @Composable private fun ScheduleTabContent( + composition: LottieComposition?, + progress: Float, scheduleEvents: List, modifier: Modifier = Modifier, currentEventPosition: Int = DEFAULT_POSITION, @@ -125,7 +143,11 @@ private fun ScheduleTabContent( state = listState, ) { items(items = scheduleEvents, key = { scheduleEvent -> scheduleEvent.id }) { - ScheduleEventItem(scheduleEvent = it) + ScheduleEventItem( + composition = composition, + progress = progress, + scheduleEvent = it, + ) } } } @@ -137,6 +159,8 @@ private fun ScheduleTabContent( private fun ScheduleTabContentPreview() { FestabookTheme { ScheduleTabContent( + composition = null, + progress = 1f, scheduleEvents = listOf( ScheduleEventUiModel( From 3f84cc523cb29217b751386c91431b8c359ca4de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Mon, 22 Dec 2025 21:03:18 +0900 Subject: [PATCH 066/140] =?UTF-8?q?refactor:=20Composable=20=EC=96=B4?= =?UTF-8?q?=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/schedule/component/ScheduleEventCard.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt index de1ba290..2516167d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt @@ -40,8 +40,7 @@ fun ScheduleEventCard( backgroundColor = MaterialTheme.colorScheme.background, borderColor = scheduleEventCardColors.cardBorderColor, shape = festabookShapes.radius2, - ) - .padding(festabookSpacing.paddingBody4), + ).padding(festabookSpacing.paddingBody4), verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody1), ) { Row( @@ -123,7 +122,6 @@ private fun scheduleLabelText(status: ScheduleEventUiStatus): String = ScheduleEventUiStatus.COMPLETED -> stringResource(R.string.schedule_status_completed) } -@Composable private fun scheduleEventCardColors(status: ScheduleEventUiStatus): ScheduleEventCardProps = when (status) { ScheduleEventUiStatus.UPCOMING -> ScheduleEventCardColors.upcoming From 401b44bdafbc5ee2ec9d9030fb85015b041fa677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Mon, 22 Dec 2025 21:46:37 +0900 Subject: [PATCH 067/140] =?UTF-8?q?refactor:=20ScheduleEventItem=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B0=80=EB=8F=85=EC=84=B1=20=ED=96=A5=EC=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/component/ScheduleEventItem.kt | 131 +++++++++--------- 1 file changed, 69 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt index 56b1321d..95fdec13 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt @@ -25,74 +25,17 @@ fun ScheduleEventItem( scheduleEvent: ScheduleEventUiModel, modifier: Modifier = Modifier, ) { - val props = - when (scheduleEvent.status) { - ScheduleEventUiStatus.UPCOMING -> { - LottieTimeLineCircleProps( - centerColor = FestabookColor.accentGreen, - outerOpacity = 0f, - innerOpacity = 1f, - outerColor = FestabookColor.accentGreen, - innerColor = FestabookColor.accentGreen, - ) - } - - ScheduleEventUiStatus.ONGOING -> { - LottieTimeLineCircleProps( - centerColor = FestabookColor.accentBlue, - outerOpacity = 1f, - innerOpacity = 1f, - outerColor = FestabookColor.accentBlue, - innerColor = FestabookColor.accentBlue, - ) - } - - ScheduleEventUiStatus.COMPLETED -> { - LottieTimeLineCircleProps( - centerColor = FestabookColor.gray300, - outerOpacity = 0f, - innerOpacity = 0f, - outerColor = FestabookColor.gray300, - innerColor = FestabookColor.gray300, - ) - } - } - val dynamicProperties = - rememberLottieDynamicProperties( - rememberLottieDynamicProperty( - property = LottieProperty.COLOR, - value = props.centerColor.toArgb(), - *props.centerKeyPath.toTypedArray(), - ), - rememberLottieDynamicProperty( - property = LottieProperty.OPACITY, - value = (props.outerOpacity * 100).toInt(), - *props.outerKeyPath.toTypedArray(), - ), - rememberLottieDynamicProperty( - property = LottieProperty.OPACITY, - value = (props.innerOpacity * 100).toInt(), - *props.innerKeyPath.toTypedArray(), - ), - rememberLottieDynamicProperty( - property = LottieProperty.COLOR, - value = props.outerColor.toArgb(), - *props.outerKeyPath.toTypedArray(), - ), - rememberLottieDynamicProperty( - property = LottieProperty.COLOR, - value = props.innerColor.toArgb(), - *props.innerKeyPath.toTypedArray(), - ), - ) + val props = lottieTimeLineCircleProps(scheduleEvent.status) + val dynamicProperties = rememberScheduleEventDynamicProperties(props) Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { LottieAnimation( composition = composition, - progress = { progress }, - dynamicProperties = dynamicProperties, + progress = { if (scheduleEvent.status == ScheduleEventUiStatus.COMPLETED) 0f else progress }, modifier = Modifier.size(festabookSpacing.paddingBody4 * 4), + dynamicProperties = dynamicProperties, ) + ScheduleEventCard(scheduleEvent = scheduleEvent) } } @@ -115,6 +58,70 @@ private fun ScheduleEventItemPreview() { ) } +@Composable +private fun rememberScheduleEventDynamicProperties(props: LottieTimeLineCircleProps) = + rememberLottieDynamicProperties( + rememberLottieDynamicProperty( + property = LottieProperty.COLOR, + value = props.centerColor.toArgb(), + *props.centerKeyPath.toTypedArray(), + ), + rememberLottieDynamicProperty( + property = LottieProperty.OPACITY, + value = (props.outerOpacity * 100).toInt(), + *props.outerKeyPath.toTypedArray(), + ), + rememberLottieDynamicProperty( + property = LottieProperty.OPACITY, + value = (props.innerOpacity * 100).toInt(), + *props.innerKeyPath.toTypedArray(), + ), + rememberLottieDynamicProperty( + property = LottieProperty.COLOR, + value = props.outerColor.toArgb(), + *props.outerKeyPath.toTypedArray(), + ), + rememberLottieDynamicProperty( + property = LottieProperty.COLOR, + value = props.innerColor.toArgb(), + *props.innerKeyPath.toTypedArray(), + ), + ) + +private fun lottieTimeLineCircleProps(status: ScheduleEventUiStatus): LottieTimeLineCircleProps = + when (status) { + ScheduleEventUiStatus.UPCOMING -> LottieTimeLineCircleAnimations.upcoming + ScheduleEventUiStatus.ONGOING -> LottieTimeLineCircleAnimations.ongoing + ScheduleEventUiStatus.COMPLETED -> LottieTimeLineCircleAnimations.completed + } + +object LottieTimeLineCircleAnimations { + val upcoming = + LottieTimeLineCircleProps( + centerColor = FestabookColor.accentGreen, + outerOpacity = 0f, + innerOpacity = 1f, + outerColor = FestabookColor.accentGreen, + innerColor = FestabookColor.accentGreen, + ) + val ongoing = + LottieTimeLineCircleProps( + centerColor = FestabookColor.accentBlue, + outerOpacity = 1f, + innerOpacity = 1f, + outerColor = FestabookColor.accentBlue, + innerColor = FestabookColor.accentBlue, + ) + val completed = + LottieTimeLineCircleProps( + centerColor = FestabookColor.gray300, + outerOpacity = 0f, + innerOpacity = 0f, + outerColor = FestabookColor.gray300, + innerColor = FestabookColor.gray300, + ) +} + data class LottieTimeLineCircleProps( val centerColor: Color, val outerOpacity: Float, From 35e2a385dad94170dd0cda628bf10f97c661dd4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Mon, 22 Dec 2025 22:13:29 +0900 Subject: [PATCH 068/140] =?UTF-8?q?refactor:=20`PullToRefreshContainer`=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EB=B0=A9=EC=8B=9D=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/PullToRefreshContainer.kt | 11 +++++++--- .../news/lost/component/LostItemScreen.kt | 21 +++++------------- .../news/notice/component/NoticeScreen.kt | 22 +++++-------------- .../schedule/component/ScheduleTabPage.kt | 14 +++--------- 4 files changed, 23 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/PullToRefreshContainer.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/PullToRefreshContainer.kt index 18bc0b80..3caf0bcf 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/component/PullToRefreshContainer.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/PullToRefreshContainer.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp @@ -22,7 +23,7 @@ import androidx.compose.ui.unit.dp import com.daedan.festabook.R import com.daedan.festabook.presentation.theme.FestabookColor -const val PULL_OFFSET_LIMIT = 180F +private const val PULL_OFFSET_LIMIT = 180F @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -31,7 +32,7 @@ fun PullToRefreshContainer( onRefresh: () -> Unit, modifier: Modifier = Modifier, pullOffsetLimit: Float = PULL_OFFSET_LIMIT, - content: @Composable (PullToRefreshState) -> Unit, + content: @Composable (Modifier) -> Unit, ) { val pullToRefreshState = rememberPullToRefreshState() val threshold = (pullOffsetLimit / 2).dp @@ -52,7 +53,11 @@ fun PullToRefreshContainer( }, modifier = modifier.fillMaxSize(), ) { - content(pullToRefreshState) + content( + Modifier.graphicsLayer { + translationY = pullToRefreshState.distanceFraction * pullOffsetLimit + }, + ) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt index 6272bd0a..1ea24c04 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt @@ -16,18 +16,13 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.EmptyStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen -import com.daedan.festabook.presentation.common.component.PULL_OFFSET_LIMIT import com.daedan.festabook.presentation.common.component.PullToRefreshContainer -import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.component.NewsItem import com.daedan.festabook.presentation.news.lost.LostUiState import com.daedan.festabook.presentation.news.lost.model.LostItemUiStatus @@ -58,9 +53,11 @@ fun LostItemScreen( PullToRefreshContainer( isRefreshing = isRefreshing, onRefresh = onRefresh, - ) { pullToRefreshState -> + ) { graphicsLayer -> when (lostUiState) { - LostUiState.InitialLoading -> LoadingStateScreen() + LostUiState.InitialLoading -> { + LoadingStateScreen() + } is LostUiState.Error -> { LaunchedEffect(lostUiState) { @@ -76,10 +73,7 @@ fun LostItemScreen( modifier = modifier .fillMaxSize() - .graphicsLayer { - translationY = - pullToRefreshState.distanceFraction * PULL_OFFSET_LIMIT - }, + .then(graphicsLayer), ) } @@ -91,10 +85,7 @@ fun LostItemScreen( modifier = modifier .fillMaxSize() - .graphicsLayer { - translationY = - pullToRefreshState.distanceFraction * PULL_OFFSET_LIMIT - }, + .then(graphicsLayer), ) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt index fbc2815a..fc18391e 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt @@ -9,20 +9,14 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.EmptyStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen -import com.daedan.festabook.presentation.common.component.PULL_OFFSET_LIMIT import com.daedan.festabook.presentation.common.component.PullToRefreshContainer -import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.component.NewsItem import com.daedan.festabook.presentation.news.notice.NoticeUiState import com.daedan.festabook.presentation.news.notice.NoticeUiState.Companion.DEFAULT_POSITION @@ -42,9 +36,11 @@ fun NoticeScreen( PullToRefreshContainer( isRefreshing = isRefreshing, onRefresh = onRefresh, - ) { pullToRefreshState -> + ) { graphicsLayer -> when (uiState) { - NoticeUiState.InitialLoading -> LoadingStateScreen() + NoticeUiState.InitialLoading -> { + LoadingStateScreen() + } is NoticeUiState.Error -> { LaunchedEffect(uiState) { @@ -56,10 +52,7 @@ fun NoticeScreen( NoticeContent( notices = uiState.oldNotices, onNoticeClick = onNoticeClick, - modifier = - modifier.graphicsLayer { - translationY = pullToRefreshState.distanceFraction * PULL_OFFSET_LIMIT - }, + modifier = modifier.then(graphicsLayer), ) } @@ -68,10 +61,7 @@ fun NoticeScreen( notices = uiState.notices, expandPosition = uiState.expandPosition, onNoticeClick = onNoticeClick, - modifier = - modifier.graphicsLayer { - translationY = pullToRefreshState.distanceFraction * PULL_OFFSET_LIMIT - }, + modifier = modifier.then(graphicsLayer), ) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt index bcb9d69d..5fb8bf5d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt @@ -15,7 +15,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.airbnb.lottie.LottieComposition @@ -26,7 +25,6 @@ import com.airbnb.lottie.compose.rememberLottieComposition import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.EmptyStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen -import com.daedan.festabook.presentation.common.component.PULL_OFFSET_LIMIT import com.daedan.festabook.presentation.common.component.PullToRefreshContainer import com.daedan.festabook.presentation.schedule.ScheduleEventsUiState import com.daedan.festabook.presentation.schedule.ScheduleUiState @@ -66,7 +64,7 @@ fun ScheduleTabPage( PullToRefreshContainer( isRefreshing = isRefreshing, onRefresh = { onRefresh(oldEvents) }, - ) { pullToRefreshState -> + ) { graphicsLayer -> when (scheduleEventsUiState) { is ScheduleEventsUiState.Error -> { Timber.w(scheduleEventsUiState.throwable.stackTraceToString()) @@ -84,10 +82,7 @@ fun ScheduleTabPage( modifier = Modifier .padding(end = festabookSpacing.paddingScreenGutter) - .graphicsLayer { - translationY = - pullToRefreshState.distanceFraction * PULL_OFFSET_LIMIT - }, + .then(graphicsLayer), ) } @@ -100,10 +95,7 @@ fun ScheduleTabPage( modifier = Modifier .padding(end = festabookSpacing.paddingScreenGutter) - .graphicsLayer { - translationY = - pullToRefreshState.distanceFraction * PULL_OFFSET_LIMIT - }, + .then(graphicsLayer), ) } From c73796eef6ce716b783f6b3fe6d6ea6d8ff02335 Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Mon, 22 Dec 2025 22:46:56 +0900 Subject: [PATCH 069/140] =?UTF-8?q?feat(home):=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=95=84=ED=8B=B0=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=EC=97=85=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EC=A0=80=EB=B8=94=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/component/HomeArtistItem.kt | 70 ++++++++ .../home/component/HomeLineupItem.kt | 150 ++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt new file mode 100644 index 00000000..478baa80 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt @@ -0,0 +1,70 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.common.component.CoilImage +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography + +@Composable +fun HomeArtistItem( + artistName: String, + artistImageUrl: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.width(68.dp), + ) { + CoilImage( + url = artistImageUrl, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(HomeArtistItem.ArtistImage) + .border(1.dp, FestabookColor.gray300, HomeArtistItem.ArtistImage), + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = artistName, + style = FestabookTypography.labelLarge, + color = FestabookColor.gray700, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +object HomeArtistItem { + val ArtistImage = RoundedCornerShape( + topStartPercent = 50, + topEndPercent = 50, + bottomEndPercent = 50, + bottomStartPercent = 5, + ) +} + +@Preview +@Composable +private fun HomeArtistItemPreview() { + HomeArtistItem( + artistName = "실리카겔", + artistImageUrl = "sample", + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt new file mode 100644 index 00000000..af9efc0b --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt @@ -0,0 +1,150 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.home.LineUpItemOfDayUiModel +import com.daedan.festabook.presentation.home.LineupItemUiModel +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography +import java.time.LocalDate +import java.time.LocalDateTime + +@Composable +fun HomeLineupItem( + uiModel: LineUpItemOfDayUiModel, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + ) { + // 날짜 + 배지 영역 + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${uiModel.date.monthValue}.${uiModel.date.dayOfMonth}", + style = FestabookTypography.titleLarge, + color = FestabookColor.black, + ) + + if (uiModel.isDDay) { + Spacer(modifier = Modifier.width(6.dp)) + Box( + modifier = + Modifier + .clip(RoundedCornerShape(20.dp)) + .background(FestabookColor.black) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = stringResource(id = R.string.home_is_d_day), + style = FestabookTypography.labelSmall, + color = FestabookColor.white, + ) + } + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + Box( + modifier = + Modifier + .padding(horizontal = 16.dp) + .width(75.dp) + .height(1.dp) + .background(FestabookColor.gray700), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 아티스트 가로 리스트 + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(uiModel.lineupItems) { item -> + HomeArtistItem( + artistName = item.name, + artistImageUrl = item.imageUrl, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeLineupItemPreview() { + HomeLineupItem( + uiModel = + LineUpItemOfDayUiModel( + id = 1L, + date = LocalDate.now(), + isDDay = true, + lineupItems = + listOf( + LineupItemUiModel( + id = 1, + name = "실리카겔", + imageUrl = "sample", + performanceAt = LocalDateTime.now(), + ), + LineupItemUiModel( + id = 2, + name = "한로로", + imageUrl = "sample", + performanceAt = LocalDateTime.now(), + ), + LineupItemUiModel( + id = 3, + name = "실리카겔", + imageUrl = "sample", + performanceAt = LocalDateTime.now(), + ), + LineupItemUiModel( + id = 4, + name = "한로로", + imageUrl = "sample", + performanceAt = LocalDateTime.now(), + ), + LineupItemUiModel( + id = 5, + name = "실리카겔", + imageUrl = "sample", + performanceAt = LocalDateTime.now(), + ), + LineupItemUiModel( + id = 6, + name = "한로로", + imageUrl = "sample", + performanceAt = LocalDateTime.now(), + ), + ), + ), + ) +} From 4f864cbde61531245e9d317c51dbc69c574e0864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Mon, 22 Dec 2025 22:59:10 +0900 Subject: [PATCH 070/140] =?UTF-8?q?refactor:=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=ED=83=AD=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C?= =?UTF-8?q?=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=95=A0=EB=8B=88=EB=A9=94?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../festabook/presentation/schedule/component/ScheduleTabRow.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt index 916ba06c..80f6397a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt @@ -46,7 +46,7 @@ fun ScheduleTabRow( selected = pageState.currentPage == index, unselectedContentColor = FestabookColor.gray500, selectedContentColor = MaterialTheme.colorScheme.background, - onClick = { scope.launch { pageState.scrollToPage(index) } }, + onClick = { scope.launch { pageState.animateScrollToPage(index) } }, text = { Text(text = scheduleDate.date) }, ) } From c76703e56ccd8bfa4cc00d5832627c459da51891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Mon, 22 Dec 2025 23:38:31 +0900 Subject: [PATCH 071/140] =?UTF-8?q?fix:=20=EC=9D=BC=EC=A0=95=20=ED=83=AD?= =?UTF-8?q?=20=EB=B9=88=20=ED=99=94=EB=A9=B4=20=EC=8A=A4=ED=81=AC=EB=A1=A4?= =?UTF-8?q?=20=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/component/ScheduleTabPage.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt index 5fb8bf5d..91054638 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt @@ -3,12 +3,15 @@ package com.daedan.festabook.presentation.schedule.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable @@ -114,12 +117,18 @@ private fun ScheduleTabContent( currentEventPosition: Int = DEFAULT_POSITION, ) { val listState = rememberLazyListState() + val scrollState = rememberScrollState() LaunchedEffect(Unit) { listState.animateScrollToItem(currentEventPosition) } if (scheduleEvents.isEmpty()) { - EmptyStateScreen(modifier = modifier) + EmptyStateScreen( + modifier = + modifier + .fillMaxSize() + .verticalScroll(scrollState), + ) } else { Box(modifier = modifier) { VerticalDivider( From 7079e2c4b3af4a475a677590756b569e51aed6ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Tue, 23 Dec 2025 20:27:06 +0900 Subject: [PATCH 072/140] =?UTF-8?q?refactor:=20=EC=84=A0=ED=83=9D=EC=A0=81?= =?UTF-8?q?=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=EB=A5=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../festabook/presentation/schedule/ScheduleViewModel.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt index 210a69bf..f806124a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt @@ -141,8 +141,10 @@ class ScheduleViewModel( return currentEventPosition } - private fun getCurrentDatePosition(scheduleDates: List): Int { - val today = LocalDate.now() + private fun getCurrentDatePosition( + scheduleDates: List, + today: LocalDate = LocalDate.now(), + ): Int { val currentDatePosition = scheduleDates .indexOfFirst { !it.date.isBefore(today) } From af60091347a4a1c8f06d12b8e14e8014d39039c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Tue, 23 Dec 2025 21:15:19 +0900 Subject: [PATCH 073/140] =?UTF-8?q?refactor:=20=ED=95=A8=EC=88=98=EB=AA=85?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../festabook/presentation/schedule/ScheduleFragment.kt | 2 +- .../festabook/presentation/schedule/ScheduleViewModel.kt | 4 ++-- .../presentation/schedule/component/ScheduleScreen.kt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt index d2e7ecd9..6081cc44 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt @@ -52,6 +52,6 @@ class ScheduleFragment : } override fun onMenuItemReClick() { - viewModel.loadSchedules() + viewModel.loadAllSchedules() } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt index f806124a..9c58abe3 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt @@ -30,10 +30,10 @@ class ScheduleViewModel( val scheduleUiState: StateFlow = _scheduleUiState.asStateFlow() init { - loadSchedules() + loadAllSchedules() } - fun loadSchedules( + fun loadAllSchedules( scheduleUiState: ScheduleUiState = ScheduleUiState.InitialLoading, scheduleEventUiState: ScheduleEventsUiState = ScheduleEventsUiState.InitialLoading, selectedDatePosition: Int? = null, diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt index 688196ea..e53b1961 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt @@ -72,7 +72,7 @@ fun ScheduleScreen( pagerState = pageState, scheduleUiState = currentStateSuccess, onRefresh = { oldEvents -> - scheduleViewModel.loadSchedules( + scheduleViewModel.loadAllSchedules( scheduleUiState = ScheduleUiState.Refreshing(currentStateSuccess), scheduleEventUiState = ScheduleEventsUiState.Refreshing(oldEvents), selectedDatePosition = pageState.currentPage, From e0ffb6f027e1be8d6d8ed805f22b69acb9b249d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 24 Dec 2025 00:26:48 +0900 Subject: [PATCH 074/140] =?UTF-8?q?refactor:=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=ED=94=84?= =?UTF-8?q?=EB=A6=AC=EB=A1=9C=EB=94=A9=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/schedule/ScheduleFragment.kt | 2 +- .../schedule/ScheduleViewModel.kt | 64 ++++++++++++++----- .../schedule/component/ScheduleScreen.kt | 7 +- .../schedule/component/ScheduleTabPage.kt | 3 +- 4 files changed, 57 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt index 6081cc44..d2e7ecd9 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt @@ -52,6 +52,6 @@ class ScheduleFragment : } override fun onMenuItemReClick() { - viewModel.loadAllSchedules() + viewModel.loadSchedules() } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt index 9c58abe3..01c808bc 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt @@ -30,19 +30,22 @@ class ScheduleViewModel( val scheduleUiState: StateFlow = _scheduleUiState.asStateFlow() init { - loadAllSchedules() + loadSchedules() } - fun loadAllSchedules( + fun loadSchedules( scheduleUiState: ScheduleUiState = ScheduleUiState.InitialLoading, scheduleEventUiState: ScheduleEventsUiState = ScheduleEventsUiState.InitialLoading, selectedDatePosition: Int? = null, + preloadCount: Int = PRELOAD_PAGE_COUNT, ) { viewModelScope.launch { val datesResult = loadAllDates(scheduleUiState, selectedDatePosition) - datesResult.onSuccess { scheduleDateUiModels -> - loadAllEvents(scheduleEventUiState, scheduleDateUiModels) + if (datesResult.isSuccess) { + val currentPosition = + (_scheduleUiState.value as ScheduleUiState.Success).currentDatePosition + loadEventsInRange(currentPosition, scheduleEventUiState, preloadCount) } } } @@ -75,18 +78,32 @@ class ScheduleViewModel( ) } - private suspend fun loadAllEvents( - scheduleEventUiState: ScheduleEventsUiState, - scheduleDateUiModels: List, + fun loadEventsInRange( + currentPosition: Int, + scheduleEventUiState: ScheduleEventsUiState = ScheduleEventsUiState.InitialLoading, + preloadCount: Int = PRELOAD_PAGE_COUNT, ) { - supervisorScope { - scheduleDateUiModels.forEachIndexed { position, scheduleDateUiModel -> - launch { - loadEventsByPosition( - position = position, - scheduleDateUiModel = scheduleDateUiModel, - scheduleEventsUiState = scheduleEventUiState, - ) + (_scheduleUiState.value as? ScheduleUiState.Success)?.dates?.let { scheduleDates -> + val range = + getPreloadRange( + totalPageSize = scheduleDates.size, + currentPosition = currentPosition, + preloadCount = preloadCount, + ) + viewModelScope.launch { + supervisorScope { + range.forEach { position -> + if (isEventLoaded(position)) return@forEach + + val scheduleDateUiModel = scheduleDates[position] + launch { + loadEventsByPosition( + position = position, + scheduleDateUiModel = scheduleDateUiModel, + scheduleEventsUiState = scheduleEventUiState, + ) + } + } } } } @@ -152,7 +169,24 @@ class ScheduleViewModel( return currentDatePosition } + private fun getPreloadRange( + totalPageSize: Int, + preloadCount: Int, + currentPosition: Int, + ): IntRange { + val start = (currentPosition - preloadCount).coerceAtLeast(FIRST_INDEX) + val end = (currentPosition + preloadCount).coerceAtMost(totalPageSize - 1) + return start..end + } + + private fun isEventLoaded(position: Int): Boolean { + val currentScheduleUiState = _scheduleUiState.value + if (currentScheduleUiState !is ScheduleUiState.Success) return false + return currentScheduleUiState.eventsUiStateByPosition[position] is ScheduleEventsUiState.Success + } + companion object { private const val FIRST_INDEX: Int = 0 + const val PRELOAD_PAGE_COUNT: Int = 2 } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt index e53b1961..adeae6fa 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier @@ -55,6 +56,9 @@ fun ScheduleScreen( val pageState = rememberPagerState(initialPage = currentStateSuccess.currentDatePosition) { currentStateSuccess.dates.size } val scope = rememberCoroutineScope() + LaunchedEffect(pageState.currentPage) { + scheduleViewModel.loadEventsInRange(currentPosition = pageState.currentPage) + } Column(modifier = Modifier.padding(top = innerPadding.calculateTopPadding())) { ScheduleTabRow( @@ -72,10 +76,11 @@ fun ScheduleScreen( pagerState = pageState, scheduleUiState = currentStateSuccess, onRefresh = { oldEvents -> - scheduleViewModel.loadAllSchedules( + scheduleViewModel.loadSchedules( scheduleUiState = ScheduleUiState.Refreshing(currentStateSuccess), scheduleEventUiState = ScheduleEventsUiState.Refreshing(oldEvents), selectedDatePosition = pageState.currentPage, + preloadCount = 0, ) }, ) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt index 91054638..956ae688 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt @@ -32,6 +32,7 @@ import com.daedan.festabook.presentation.common.component.PullToRefreshContainer import com.daedan.festabook.presentation.schedule.ScheduleEventsUiState import com.daedan.festabook.presentation.schedule.ScheduleUiState import com.daedan.festabook.presentation.schedule.ScheduleUiState.Companion.DEFAULT_POSITION +import com.daedan.festabook.presentation.schedule.ScheduleViewModel.Companion.PRELOAD_PAGE_COUNT import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus import com.daedan.festabook.presentation.theme.FestabookColor @@ -39,8 +40,6 @@ import com.daedan.festabook.presentation.theme.FestabookTheme import com.daedan.festabook.presentation.theme.festabookSpacing import timber.log.Timber -private const val PRELOAD_PAGE_COUNT: Int = 2 - @OptIn(ExperimentalMaterial3Api::class) @Composable fun ScheduleTabPage( From 45f2a760dcb1be89ab66f5ad40911d368a21cdd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Wed, 24 Dec 2025 00:41:29 +0900 Subject: [PATCH 075/140] =?UTF-8?q?fix:=20NewsScreen=20=EB=82=B4=20Pager?= =?UTF-8?q?=20=EA=B5=AC=EC=84=B1=20=EC=9A=94=EC=86=8C=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../daedan/festabook/presentation/news/component/NewsScreen.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt index e5282c88..e420ba76 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt @@ -1,6 +1,7 @@ package com.daedan.festabook.presentation.news.component import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.Scaffold @@ -66,6 +67,7 @@ fun NewsScreen( onNoticeClick = { newsViewModel.toggleNotice(it) }, onFaqClick = { newsViewModel.toggleFAQ(it) }, onLostGuideClick = { newsViewModel.toggleLostGuide() }, + modifier = Modifier.fillMaxSize(), ) } } From 9ef8418688be4c25a7a42360d9aee3612801914a Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Fri, 26 Dec 2025 17:40:17 +0900 Subject: [PATCH 076/140] =?UTF-8?q?feat(home):=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20Compose=20=EC=A0=84=ED=99=98=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/home/HomeFragment.kt | 175 ++-------------- .../presentation/home/HomeViewModel.kt | 25 ++- .../presentation/home/component/HomeHeader.kt | 9 +- .../presentation/home/component/HomeScreen.kt | 196 ++++++++++++++++++ .../presentation/main/MainActivity.kt | 16 +- .../presentation/setting/SettingFragment.kt | 38 ++-- 6 files changed, 269 insertions(+), 190 deletions(-) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt index 2b0b8349..ed10622d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt @@ -1,184 +1,49 @@ package com.daedan.festabook.presentation.home import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import androidx.fragment.app.Fragment +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModelProvider -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.PagerSnapHelper import androidx.recyclerview.widget.RecyclerView import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentHomeBinding import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.logging.logger -import com.daedan.festabook.logging.model.home.ExploreClickLogData -import com.daedan.festabook.logging.model.home.HomeViewLogData -import com.daedan.festabook.logging.model.home.ScheduleClickLogData import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.common.formatFestivalPeriod -import com.daedan.festabook.presentation.common.showErrorSnackBar import com.daedan.festabook.presentation.explore.ExploreActivity -import com.daedan.festabook.presentation.home.adapter.CenterItemMotionEnlarger -import com.daedan.festabook.presentation.home.adapter.FestivalUiState -import com.daedan.festabook.presentation.home.adapter.LineUpItemOfDayAdapter -import com.daedan.festabook.presentation.home.adapter.PosterAdapter +import com.daedan.festabook.presentation.home.component.HomeScreen import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import dev.zacsweers.metro.binding -import timber.log.Timber -@ContributesIntoMap(scope = AppScope::class, binding = binding()) +@ContributesIntoMap(scope = AppScope::class, binding = binding()) @FragmentKey(HomeFragment::class) -class HomeFragment @Inject constructor( - private val centerItemMotionEnlarger: RecyclerView.OnScrollListener, -) : BaseFragment() { +class HomeFragment @Inject constructor() : BaseFragment() { override val layoutId: Int = R.layout.fragment_home @Inject override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory private val viewModel: HomeViewModel by viewModels({ requireActivity() }) - private val posterAdapter: PosterAdapter by lazy { - PosterAdapter() - } - - private val lineupOfDayAdapter: LineUpItemOfDayAdapter by lazy { - LineUpItemOfDayAdapter() - } - - override fun onViewCreated( - view: View, + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - binding.lifecycleOwner = viewLifecycleOwner - setupObservers() - setupAdapters() - setupNavigateToScheduleButton() - setupNavigateToExploreButton() - } - - private fun setupNavigateToExploreButton() { - binding.layoutTitleWithIcon.setOnClickListener { - binding.logger.log(ExploreClickLogData(binding.logger.getBaseLogData())) - - startActivity(ExploreActivity.newIntent(requireContext())) - } - } - - private fun setupNavigateToScheduleButton() { - binding.btnNavigateToSchedule.setOnClickListener { - binding.logger.log( - ScheduleClickLogData( - baseLogData = binding.logger.getBaseLogData(), - ), - ) - - viewModel.navigateToScheduleClick() - } - } - - private fun setupObservers() { - viewModel.festivalUiState.observe(viewLifecycleOwner) { festivalUiState -> - when (festivalUiState) { - is FestivalUiState.Loading -> {} - is FestivalUiState.Success -> handleSuccessState(festivalUiState) - is FestivalUiState.Error -> { - showErrorSnackBar(festivalUiState.throwable) - Timber.w( - festivalUiState.throwable, - "HomeFragment: ${festivalUiState.throwable.message}", - ) - } - } - } - viewModel.lineupUiState.observe(viewLifecycleOwner) { lineupUiState -> - when (lineupUiState) { - is LineupUiState.Loading -> {} - is LineupUiState.Success -> { - lineupOfDayAdapter.submitList(lineupUiState.lineups.getLineupItems()) - } - - is LineupUiState.Error -> { - showErrorSnackBar(lineupUiState.throwable) - Timber.w( - lineupUiState.throwable, - "HomeFragment: ${lineupUiState.throwable.message}", - ) - } - } - } - } - - private fun setupAdapters() { - binding.rvHomePoster.adapter = posterAdapter - binding.rvHomeLineup.adapter = lineupOfDayAdapter - attachSnapHelper() - addScrollEffectListener() - } - - private fun handleSuccessState(festivalUiState: FestivalUiState.Success) { - binding.tvHomeOrganizationTitle.text = - festivalUiState.organization.universityName - binding.tvHomeFestivalTitle.text = - festivalUiState.organization.festival.festivalName - binding.tvHomeFestivalDate.text = - formatFestivalPeriod( - festivalUiState.organization.festival.startDate, - festivalUiState.organization.festival.endDate, - ) - - val posterUrls = - festivalUiState.organization.festival.festivalImages - .sortedBy { it.sequence } - .map { it.imageUrl } - - if (posterUrls.isNotEmpty()) { - posterAdapter.submitList(posterUrls) { - scrollToInitialPosition(posterUrls.size) + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + HomeScreen( + viewModel = viewModel, + onNavigateToExplore = { + startActivity(ExploreActivity.newIntent(requireContext())) + }, + ) } } - binding.logger.log( - HomeViewLogData( - baseLogData = binding.logger.getBaseLogData(), - universityName = festivalUiState.organization.universityName, - festivalId = festivalUiState.organization.id, - ), - ) - } - - private fun attachSnapHelper() { - PagerSnapHelper().attachToRecyclerView(binding.rvHomePoster) - } - - private fun scrollToInitialPosition(size: Int) { - val safeMaxValue = Int.MAX_VALUE / INFINITE_SCROLL_SAFETY_FACTOR - val initialPosition = safeMaxValue - (safeMaxValue % size) - - val layoutManager = binding.rvHomePoster.layoutManager as? LinearLayoutManager ?: return - - val itemWidth = resources.getDimensionPixelSize(R.dimen.poster_item_width) - val offset = (binding.rvHomePoster.width / 2) - (itemWidth / 2) - - layoutManager.scrollToPositionWithOffset(initialPosition, offset) - - binding.rvHomePoster.post { - (centerItemMotionEnlarger as CenterItemMotionEnlarger).expandCenterItem(binding.rvHomePoster) - } - } - - private fun addScrollEffectListener() { - binding.rvHomePoster.addOnScrollListener(centerItemMotionEnlarger) - } - - override fun onDestroyView() { - binding.rvHomePoster.clearOnScrollListeners() - super.onDestroyView() - } - - companion object { - private const val INFINITE_SCROLL_SAFETY_FACTOR = 4 } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt index 85fb947f..64caaff1 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt @@ -1,17 +1,19 @@ package com.daedan.festabook.presentation.home -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.di.viewmodel.ViewModelScope import com.daedan.festabook.domain.repository.FestivalRepository -import com.daedan.festabook.presentation.common.SingleLiveData import com.daedan.festabook.presentation.home.adapter.FestivalUiState import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @ContributesIntoMap(AppScope::class) @@ -19,14 +21,15 @@ import kotlinx.coroutines.launch class HomeViewModel @Inject constructor( private val festivalRepository: FestivalRepository, ) : ViewModel() { - private val _festivalUiState = MutableLiveData() - val festivalUiState: LiveData get() = _festivalUiState + private val _festivalUiState = MutableStateFlow(FestivalUiState.Loading) + val festivalUiState: StateFlow = _festivalUiState.asStateFlow() - private val _lineupUiState = MutableLiveData() - val lineupUiState: LiveData get() = _lineupUiState + private val _lineupUiState = MutableStateFlow(LineupUiState.Loading) + val lineupUiState: StateFlow = _lineupUiState.asStateFlow() - private val _navigateToScheduleEvent: SingleLiveData = SingleLiveData() - val navigateToScheduleEvent: LiveData get() = _navigateToScheduleEvent + private val _navigateToScheduleEvent = + MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val navigateToScheduleEvent: SharedFlow = _navigateToScheduleEvent.asSharedFlow() init { loadFestival() @@ -48,7 +51,7 @@ class HomeViewModel @Inject constructor( } fun navigateToScheduleClick() { - _navigateToScheduleEvent.setValue(Unit) + _navigateToScheduleEvent.tryEmit(Unit) } private fun loadLineup() { diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt index 8a564aeb..836e48c8 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt @@ -15,8 +15,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.daedan.festabook.R import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTypography @@ -31,7 +33,7 @@ fun HomeHeader( modifier = modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 12.dp), + .padding(horizontal = 16.dp) ) { Row( modifier = Modifier.clickable { onExpandClick() }, @@ -39,7 +41,10 @@ fun HomeHeader( ) { Text( text = schoolName, - style = FestabookTypography.displayLarge, + style = FestabookTypography.displayLarge.copy( + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeight = 34.sp + ), color = FestabookColor.black, ) diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt new file mode 100644 index 00000000..e9e5aeca --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt @@ -0,0 +1,196 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.common.formatFestivalPeriod +import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.home.LineUpItemGroupUiModel +import com.daedan.festabook.presentation.home.LineupItemUiModel +import com.daedan.festabook.presentation.home.adapter.FestivalUiState +import com.daedan.festabook.domain.model.Festival +import com.daedan.festabook.domain.model.Organization +import com.daedan.festabook.domain.model.Poster +import com.daedan.festabook.presentation.home.LineupUiState +import com.daedan.festabook.presentation.theme.FestabookColor +import kotlinx.coroutines.flow.collectLatest +import java.time.LocalDate +import java.time.LocalDateTime + +@Composable +fun HomeScreen( + viewModel: HomeViewModel, + onNavigateToExplore: () -> Unit, + modifier: Modifier = Modifier, +) { + val festivalUiState by viewModel.festivalUiState.collectAsState() + val lineupUiState by viewModel.lineupUiState.collectAsState() + + FestivalOverview( + festivalUiState = festivalUiState, + lineupUiState = lineupUiState, + onNavigateToExplore = onNavigateToExplore, + onNavigateToSchedule = viewModel::navigateToScheduleClick, + modifier = modifier, + ) +} + +@Composable +fun FestivalOverview( + festivalUiState: FestivalUiState, + lineupUiState: LineupUiState, + onNavigateToExplore: () -> Unit, + onNavigateToSchedule: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = Color.White, + ) { + LazyColumn( + modifier = + Modifier.fillMaxSize() + ) { + // 헤더 (학교 이름) + item { + if (festivalUiState is FestivalUiState.Success) { + HomeHeader( + schoolName = festivalUiState.organization.universityName, + onExpandClick = onNavigateToExplore, + modifier = Modifier.padding(top = 40.dp), + ) + } + } + + // 포스터 리스트 + item { + if (festivalUiState is FestivalUiState.Success) { + val posterUrls = + festivalUiState.organization.festival.festivalImages + .sortedBy { it.sequence } + .map { it.imageUrl } + + HomePosterList( + posterUrls = posterUrls, + modifier = Modifier.padding(vertical = 12.dp), + ) + } + } + + // 축제 정보 + item { + if (festivalUiState is FestivalUiState.Success) { + val festival = festivalUiState.organization.festival + HomeFestivalInfo( + festivalName = festival.festivalName, + festivalDate = + formatFestivalPeriod( + festival.startDate, + festival.endDate, + ), + modifier = Modifier.padding(top = 16.dp), + ) + } + } + + + // 구분선 + item { + if (festivalUiState is FestivalUiState.Success) { + HorizontalDivider( + thickness = 4.dp, + color = FestabookColor.gray200, + modifier = + Modifier + .padding(top = 16.dp), + ) + } + } + + // 라인업 헤더 + item { + HomeLineupHeader( + onScheduleClick = onNavigateToSchedule, + ) + } + + // 라인업 리스트 + when (lineupUiState) { + is LineupUiState.Success -> { + val lineups = lineupUiState.lineups.getLineupItems() + items(lineups) { lineupItem -> + HomeLineupItem(uiModel = lineupItem) + } + } + + is LineupUiState.Loading -> { + // 로딩 시 동작 논의 후 추가 + } + + is LineupUiState.Error -> { + // 에러 표시 + } + } + + // 하단 여백 추가 + item { + Box(modifier = Modifier.padding(bottom = 60.dp)) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun FestivalOverviewPreview() { + val sampleFestival = + Organization( + id = 1, + universityName = "가천대학교", + festival = + Festival( + festivalName = "2025 가천 Water Festival\n: AQUA WAVE", + startDate = LocalDate.now(), + endDate = LocalDate.now().plusDays(2), + festivalImages = + listOf( + Poster(1, "sample", 1), + Poster(2, "sample", 2), + ), + ), + ) + + val sampleLineups = + LineUpItemGroupUiModel( + group = + mapOf( + LocalDate.now() to + listOf( + LineupItemUiModel(1, "sample", "실리카겔", LocalDateTime.now()), + LineupItemUiModel(2, "sample", "아이유", LocalDateTime.now()), + ), + LocalDate.now().plusDays(1) to + listOf( + LineupItemUiModel(3, "sample", "뉴진스", LocalDateTime.now()), + ), + ), + ) + + FestivalOverview( + festivalUiState = FestivalUiState.Success(sampleFestival), + lineupUiState = LineupUiState.Success(sampleLineups), + onNavigateToExplore = {}, + onNavigateToSchedule = {}, + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt b/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt index 8df33ef2..17e2b8cd 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt @@ -19,7 +19,10 @@ import androidx.fragment.app.FragmentFactory import androidx.fragment.app.add import androidx.fragment.app.commit import androidx.fragment.app.commitNow +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.daedan.festabook.R import com.daedan.festabook.databinding.ActivityMainBinding import com.daedan.festabook.di.appGraph @@ -39,6 +42,8 @@ import com.daedan.festabook.presentation.setting.SettingFragment import com.daedan.festabook.presentation.setting.SettingViewModel import com.google.android.material.dialog.MaterialAlertDialogBuilder import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import timber.log.Timber class MainActivity : @@ -158,8 +163,13 @@ class MainActivity : if (isDoublePress) finish() else showToast(getString(R.string.back_press_exit_message)) } } - homeViewModel.navigateToScheduleEvent.observe(this) { - binding.bnvMenu.selectedItemId = R.id.item_menu_schedule + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + homeViewModel.navigateToScheduleEvent.collectLatest { + binding.bnvMenu.selectedItemId = R.id.item_menu_schedule + } + } } mainViewModel.isFirstVisit.observe(this) { isFirstVisit -> @@ -298,4 +308,4 @@ class MainActivity : flags = Intent.FLAG_ACTIVITY_SINGLE_TOP } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt index 05477317..50119653 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt @@ -112,25 +112,25 @@ class SettingFragment( binding.btnNoticeAllow.isEnabled = !loading } - homeViewModel.festivalUiState.observe(viewLifecycleOwner) { state -> - when (state) { - is FestivalUiState.Error -> { - showErrorSnackBar(state.throwable) - Timber.w( - state.throwable, - "${this::class.simpleName}: ${state.throwable.message}", - ) - } - - FestivalUiState.Loading -> { - binding.tvSettingCurrentUniversityNotice.text = "" - } - - is FestivalUiState.Success -> { - binding.tvSettingCurrentUniversity.text = state.organization.universityName - } - } - } +// homeViewModel.festivalUiState.observe(viewLifecycleOwner) { state -> +// when (state) { +// is FestivalUiState.Error -> { +// showErrorSnackBar(state.throwable) +// Timber.w( +// state.throwable, +// "${this::class.simpleName}: ${state.throwable.message}", +// ) +// } +// +// FestivalUiState.Loading -> { +// binding.tvSettingCurrentUniversityNotice.text = "" +// } +// +// is FestivalUiState.Success -> { +// binding.tvSettingCurrentUniversity.text = state.organization.universityName +// } +// } +// } } private fun setupServicePolicyClickListener() { From 920ea5cb630c0b1e28372f979a055b09c55bea7c Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Sat, 27 Dec 2025 17:06:51 +0900 Subject: [PATCH 077/140] =?UTF-8?q?refactor(home):=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=BB=B4=ED=8F=AC=EC=A0=80=EB=B8=94=20=EB=B0=8F=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/daedan/festabook/presentation/home/HomeFragment.kt | 4 ++-- .../presentation/home/component/HomeFestivalInfo.kt | 2 -- .../festabook/presentation/home/component/HomeHeader.kt | 1 - .../presentation/home/component/HomeLineupHeader.kt | 4 +--- .../festabook/presentation/home/component/HomeScreen.kt | 5 ++--- 5 files changed, 5 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt index ed10622d..fa548e63 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt @@ -6,9 +6,9 @@ import android.view.View import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModelProvider -import androidx.recyclerview.widget.RecyclerView import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentHomeBinding import com.daedan.festabook.di.fragment.FragmentKey @@ -20,7 +20,7 @@ import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import dev.zacsweers.metro.binding -@ContributesIntoMap(scope = AppScope::class, binding = binding()) +@ContributesIntoMap(scope = AppScope::class, binding = binding()) @FragmentKey(HomeFragment::class) class HomeFragment @Inject constructor() : BaseFragment() { override val layoutId: Int = R.layout.fragment_home diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt index 7d852615..50989193 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt @@ -5,11 +5,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.daedan.festabook.presentation.theme.FestabookColor diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt index 836e48c8..379b644c 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt @@ -12,7 +12,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.PlatformTextStyle diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt index e0a9471a..da16b702 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt @@ -1,7 +1,6 @@ package com.daedan.festabook.presentation.home.component import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -12,7 +11,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -32,7 +30,7 @@ fun HomeLineupHeader( modifier = modifier .fillMaxWidth() - .padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 16.dp), + .padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt index e9e5aeca..aaf0ea71 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt @@ -24,7 +24,6 @@ import com.daedan.festabook.domain.model.Organization import com.daedan.festabook.domain.model.Poster import com.daedan.festabook.presentation.home.LineupUiState import com.daedan.festabook.presentation.theme.FestabookColor -import kotlinx.coroutines.flow.collectLatest import java.time.LocalDate import java.time.LocalDateTime @@ -47,7 +46,7 @@ fun HomeScreen( } @Composable -fun FestivalOverview( +private fun FestivalOverview( festivalUiState: FestivalUiState, lineupUiState: LineupUiState, onNavigateToExplore: () -> Unit, @@ -56,7 +55,7 @@ fun FestivalOverview( ) { Scaffold( modifier = modifier.fillMaxSize(), - containerColor = Color.White, + containerColor = Color.White ) { LazyColumn( modifier = From ff5e151d30aa06c487cc5c3023a77b1b025dcdd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Sat, 27 Dec 2025 22:42:28 +0900 Subject: [PATCH 078/140] =?UTF-8?q?refactor:=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20Typography=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/component/ScheduleEventCard.kt | 77 +++++++++---------- .../schedule/component/ScheduleEventItem.kt | 60 +++++++-------- 2 files changed, 68 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt index 2516167d..b3a7a2a1 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt @@ -23,6 +23,7 @@ import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography import com.daedan.festabook.presentation.theme.festabookShapes import com.daedan.festabook.presentation.theme.festabookSpacing @@ -48,7 +49,7 @@ fun ScheduleEventCard( ) { Text( text = scheduleEvent.title, - style = MaterialTheme.typography.titleLarge, + style = FestabookTypography.titleLarge, color = scheduleEventCardColors.titleColor, modifier = Modifier.weight(1f), ) @@ -70,7 +71,7 @@ fun ScheduleEventCard( scheduleEvent.startTime, scheduleEvent.endTime, ), - style = MaterialTheme.typography.bodySmall, + style = FestabookTypography.bodySmall, color = scheduleEventCardColors.contentColor, ) } @@ -85,7 +86,7 @@ fun ScheduleEventCard( ) Text( text = scheduleEvent.location, - style = MaterialTheme.typography.bodySmall, + style = FestabookTypography.bodySmall, color = scheduleEventCardColors.contentColor, ) } @@ -108,7 +109,7 @@ private fun ScheduleEventLabel(scheduleEventUiStatus: ScheduleEventUiStatus) { ) { Text( text = scheduleLabelText(scheduleEventUiStatus), - style = MaterialTheme.typography.bodySmall, + style = FestabookTypography.bodySmall, color = scheduleEventCardProps.labelTextColor, ) } @@ -122,11 +123,41 @@ private fun scheduleLabelText(status: ScheduleEventUiStatus): String = ScheduleEventUiStatus.COMPLETED -> stringResource(R.string.schedule_status_completed) } +@Composable private fun scheduleEventCardColors(status: ScheduleEventUiStatus): ScheduleEventCardProps = when (status) { - ScheduleEventUiStatus.UPCOMING -> ScheduleEventCardColors.upcoming - ScheduleEventUiStatus.ONGOING -> ScheduleEventCardColors.ongoing - ScheduleEventUiStatus.COMPLETED -> ScheduleEventCardColors.completed + ScheduleEventUiStatus.UPCOMING -> { + ScheduleEventCardProps( + cardBorderColor = FestabookColor.accentGreen, + titleColor = FestabookColor.black, + contentColor = FestabookColor.gray500, + labelTextColor = FestabookColor.black, + labelBackgroundColor = FestabookColor.white, + labelBorderColor = FestabookColor.black, + ) + } + + ScheduleEventUiStatus.ONGOING -> { + ScheduleEventCardProps( + cardBorderColor = FestabookColor.accentBlue, + titleColor = FestabookColor.black, + contentColor = FestabookColor.gray500, + labelTextColor = FestabookColor.white, + labelBackgroundColor = FestabookColor.black, + labelBorderColor = FestabookColor.black, + ) + } + + ScheduleEventUiStatus.COMPLETED -> { + ScheduleEventCardProps( + cardBorderColor = FestabookColor.gray400, + titleColor = FestabookColor.gray400, + contentColor = FestabookColor.gray400, + labelTextColor = FestabookColor.gray400, + labelBackgroundColor = FestabookColor.white, + labelBorderColor = FestabookColor.white, + ) + } } @Composable @@ -183,38 +214,6 @@ private fun CompleteScheduleEventCardONGOINGPreview() { } } -object ScheduleEventCardColors { - val upcoming = - ScheduleEventCardProps( - cardBorderColor = FestabookColor.accentGreen, - titleColor = FestabookColor.black, - contentColor = FestabookColor.gray500, - labelTextColor = FestabookColor.black, - labelBackgroundColor = FestabookColor.white, - labelBorderColor = FestabookColor.black, - ) - - val ongoing = - ScheduleEventCardProps( - cardBorderColor = FestabookColor.accentBlue, - titleColor = FestabookColor.black, - contentColor = FestabookColor.gray500, - labelTextColor = FestabookColor.white, - labelBackgroundColor = FestabookColor.black, - labelBorderColor = FestabookColor.black, - ) - - val completed = - ScheduleEventCardProps( - cardBorderColor = FestabookColor.gray400, - titleColor = FestabookColor.gray400, - contentColor = FestabookColor.gray400, - labelTextColor = FestabookColor.gray400, - labelBackgroundColor = FestabookColor.white, - labelBorderColor = FestabookColor.white, - ) -} - data class ScheduleEventCardProps( val cardBorderColor: Color, val titleColor: Color, diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt index 95fdec13..6824f2c7 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt @@ -88,39 +88,39 @@ private fun rememberScheduleEventDynamicProperties(props: LottieTimeLineCirclePr ), ) +@Composable private fun lottieTimeLineCircleProps(status: ScheduleEventUiStatus): LottieTimeLineCircleProps = when (status) { - ScheduleEventUiStatus.UPCOMING -> LottieTimeLineCircleAnimations.upcoming - ScheduleEventUiStatus.ONGOING -> LottieTimeLineCircleAnimations.ongoing - ScheduleEventUiStatus.COMPLETED -> LottieTimeLineCircleAnimations.completed - } + ScheduleEventUiStatus.UPCOMING -> { + LottieTimeLineCircleProps( + centerColor = FestabookColor.accentGreen, + outerOpacity = 0f, + innerOpacity = 1f, + outerColor = FestabookColor.accentGreen, + innerColor = FestabookColor.accentGreen, + ) + } -object LottieTimeLineCircleAnimations { - val upcoming = - LottieTimeLineCircleProps( - centerColor = FestabookColor.accentGreen, - outerOpacity = 0f, - innerOpacity = 1f, - outerColor = FestabookColor.accentGreen, - innerColor = FestabookColor.accentGreen, - ) - val ongoing = - LottieTimeLineCircleProps( - centerColor = FestabookColor.accentBlue, - outerOpacity = 1f, - innerOpacity = 1f, - outerColor = FestabookColor.accentBlue, - innerColor = FestabookColor.accentBlue, - ) - val completed = - LottieTimeLineCircleProps( - centerColor = FestabookColor.gray300, - outerOpacity = 0f, - innerOpacity = 0f, - outerColor = FestabookColor.gray300, - innerColor = FestabookColor.gray300, - ) -} + ScheduleEventUiStatus.ONGOING -> { + LottieTimeLineCircleProps( + centerColor = FestabookColor.accentBlue, + outerOpacity = 1f, + innerOpacity = 1f, + outerColor = FestabookColor.accentBlue, + innerColor = FestabookColor.accentBlue, + ) + } + + ScheduleEventUiStatus.COMPLETED -> { + LottieTimeLineCircleProps( + centerColor = FestabookColor.gray300, + outerOpacity = 0f, + innerOpacity = 0f, + outerColor = FestabookColor.gray300, + innerColor = FestabookColor.gray300, + ) + } + } data class LottieTimeLineCircleProps( val centerColor: Color, From 3e3d13cda12c7295fd88c64014be821b6c7326f9 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Sun, 28 Dec 2025 14:27:05 +0900 Subject: [PATCH 079/140] =?UTF-8?q?[Feat]=20PlaceDetailPreviewFragment,=20?= =?UTF-8?q?PlaceDetailSecondPrevewFragment=20Compose=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(PlaceDetail): 장소 상세 미리보기 카드 UI 구현 지도에서 특정 장소를 선택했을 때 하단에 표시되는 장소 상세 정보 미리보기 카드(`PlaceDetailPreviewScreen`)를 새로 구현했습니다. 이 카드는 선택된 장소의 핵심 정보를 요약하여 보여줍니다. - **`PlaceDetailPreviewScreen.kt` 신규 컴포저블 추가:** - 지도에서 선택된 장소(`SelectedPlaceUiState`)의 상세 정보를 표시하는 카드 UI를 구현했습니다. - 장소의 카테고리, 이름, 운영 시간, 위치, 주최자, 대표 이미지를 표시합니다. - 카드가 나타날 때 부드러운 애니메이션(`fadeIn`, `slideInVertically`) 효과를 적용했습니다. - 장소 상세 정보의 상태(`Loading`, `Success`, `Error`, `Empty`)에 따라 분기 처리하도록 설계했습니다. - **`URLText.kt` 신규 컴포저블 추가:** - `Text` 컴포저블을 확장하여, 문자열 내의 URL을 자동으로 감지하고 클릭 가능한 링크로 만들어주는 `URLText`를 구현했습니다. - 링크는 밑줄 스타일과 함께 지정된 색상(`gray500`)으로 표시됩니다. - **`strings.xml` 리소스 추가:** - 미리보기 카드에 사용될 아이콘(운영 시간, 주최자)의 접근성을 위한 콘텐츠 설명 문자열을 추가했습니다. * refactor(PlaceDetailPreview): Fragment Compose 마이그레이션 기존의 `View` 시스템 기반으로 구현되었던 장소 상세 정보 미리보기 UI(`PlaceDetailPreviewFragment`)를 Jetpack Compose 기반의 `PlaceDetailPreviewScreen`으로 전면 마이그레이션했습니다. 이를 통해 XML 레이아웃 의존성을 제거하고, 선언적 UI 방식으로 전환하여 코드의 가독성과 유지보수성을 향상시켰습니다. - **`PlaceDetailPreviewFragment.kt` 리팩토링:** - `onCreateView`에서 `ComposeView`를 반환하도록 변경하고, 내부에 `PlaceDetailPreviewScreen` 컴포저블을 설정했습니다. - 기존의 `View` 바인딩, `showBottomAnimation`, UI 업데이트 로직(`updateSelectedPlaceUi`) 등을 모두 제거했습니다. - `ViewModel`의 `LiveData`를 `StateFlow`로 변환하여 `collectAsStateWithLifecycle`로 상태를 구독하도록 수정했습니다. - 화면 클릭, 에러 처리, 비어있는 상태 처리 로직을 `PlaceDetailPreviewScreen`의 콜백으로 위임했습니다. - `onBackPressedCallback`의 활성화 상태를 `LaunchedEffect` 내에서 관리하도록 변경했습니다. - **`PlaceDetailPreviewScreen.kt` (컴포저블) 수정:** - 기존의 `AnimatedVisibility`를 제거하고, `Animatable`과 `graphicsLayer`를 사용한 커스텀 애니메이션을 구현하여 UI가 나타날 때 아래에서 위로 올라오며 서서히 나타나는 효과를 적용했습니다. - 장소 설명(`description`) 텍스트에 최대 두 줄 제한과 `Ellipsis`(줄임표)를 적용했습니다. - **`PlaceMapViewModel.kt` 수정:** - 기존의 `selectedPlace` `LiveData`를 `selectedPlaceFlow`라는 `StateFlow`로 변환하여 Compose 환경에서 상태를 효율적으로 관찰할 수 있도록 했습니다. * refactor(PlaceDetailPreview): 애니메이션 로직 분리 및 Secondary Preview 추가 기존 `PlaceDetailPreviewScreen`에 포함되어 있던 입장/퇴장 애니메이션 로직을 `PreviewAnimatableBox` 컴포저블로 분리하여 재사용성을 높였습니다. 또한, 장소 이름과 카테고리 아이콘만 간결하게 표시하는 `PlaceDetailPreviewSecondaryScreen`을 새로 추가했습니다. - **`PreviewAnimatableBox.kt` 신규 추가:** - `visible` 상태에 따라 Y축 이동(`translationY`) 및 투명도(`alpha`) 애니메이션을 처리하는 `Box` 래퍼 컴포저블을 구현했습니다. - 배경, 테두리, 모양 등 UI 속성을 파라미터로 받아 커스터마이징할 수 있습니다. - **`PlaceDetailPreviewScreen.kt` 리팩토링:** - 기존에 직접 구현되어 있던 `Animatable`, `LaunchedEffect`를 사용한 애니메이션 코드를 제거했습니다. - 새로 추가된 `PreviewAnimatableBox`를 사용하여 UI와 애니메이션 로직을 분리하고 코드를 간소화했습니다. - **`PlaceDetailPreviewSecondaryScreen.kt` 신규 추가:** - 지도 위에서 선택된 장소의 아이콘과 이름만 표시하는 간단한 미리보기 화면을 구현했습니다. - 이 화면 역시 `PreviewAnimatableBox`를 사용하여 애니메이션 효과를 적용합니다. - **`strings.xml` 수정:** - 카테고리 마커 아이콘에 대한 접근성을 위해 `content_description_iv_category_marker` 문자열 리소스를 추가했습니다. * refactor(PlaceDetailPreview): PlaceDetailPreviewSecondaryFragment Compose 마이그레이션 기존 View 시스템 기반의 `PlaceDetailPreviewSecondaryFragment`를 Jetpack Compose로 마이그레이션하여 UI 구현 방식을 변경했습니다. - **`PlaceDetailPreviewSecondaryFragment.kt` 수정:** - `onViewCreated` 및 ViewBinding 관련 로직을 제거하고, `onCreateView`에서 `ComposeView`를 반환하도록 변경했습니다. - `PlaceDetailPreviewSecondaryScreen` 컴포저블을 사용하여 UI를 구성하고, `viewModel.selectedPlaceFlow`를 `collectAsStateWithLifecycle`로 구독하여 상태를 전달했습니다. - 뒤로 가기 콜백(`OnBackPressedCallback`) 활성화 로직을 `LaunchedEffect` 내에서 처리하도록 변경했습니다. - 클릭 시 발생하는 로그 기록 로직(`PlacePreviewClick`)을 컴포저블의 `onClick` 콜백으로 이동시켰습니다. - **`PlaceDetailPreviewSecondaryScreen.kt` 수정:** - 컴포넌트 전체에 클릭 이벤트를 지원하기 위해 `onClick` 파라미터를 추가하고 `clickable` Modifier를 적용했습니다. - `PreviewAnimatableBox`에 `fillMaxWidth` Modifier와 `shape`(`radius2`) 설정을 추가하여 UI 레이아웃을 개선했습니다. --- .../presentation/common/component/URLText.kt | 112 +++++++++ .../placeMap/PlaceMapViewModel.kt | 9 + .../PlaceDetailPreviewFragment.kt | 136 ++++++----- .../PlaceDetailPreviewSecondaryFragment.kt | 114 ++++++---- .../component/PlaceDetailPreviewScreen.kt | 215 ++++++++++++++++++ .../PlaceDetailPreviewSecondaryScreen.kt | 124 ++++++++++ .../component/PreviewAnimatableBox.kt | 67 ++++++ app/src/main/res/values/strings.xml | 3 + 8 files changed, 669 insertions(+), 111 deletions(-) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/common/component/URLText.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewScreen.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewSecondaryScreen.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PreviewAnimatableBox.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/URLText.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/URLText.kt new file mode 100644 index 00000000..f57d5776 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/URLText.kt @@ -0,0 +1,112 @@ +package com.daedan.festabook.presentation.common.component + +import android.util.Patterns +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +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.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import com.daedan.festabook.presentation.theme.FestabookColor + +@Composable +fun URLText( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + inlineContent: Map = mapOf(), + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current, +) { + val uriHandler = LocalUriHandler.current + var layoutResult by remember { mutableStateOf(null) } + val linkedText = + buildAnnotatedString { + append(text) + val urlPattern = Patterns.WEB_URL + val matcher = urlPattern.matcher(text) + while (matcher.find()) { + addStyle( + style = + SpanStyle( + color = FestabookColor.gray500, + textDecoration = TextDecoration.Underline, + ), + start = matcher.start(), + end = matcher.end(), + ) + addStringAnnotation( + tag = "URL", + annotation = matcher.group(), + start = matcher.start(), + end = matcher.end(), + ) + } + } + Text( + text = linkedText, + modifier = + modifier.pointerInput(Unit) { + detectTapGestures { + layoutResult?.let { result -> + val position = result.getOffsetForPosition(it) + linkedText + .getStringAnnotations("URL", position, position) + .firstOrNull() + ?.let { annotation -> + uriHandler.openUri(annotation.item) + } + } + } + }, + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + inlineContent = inlineContent, + onTextLayout = { + layoutResult = it + onTextLayout(it) + }, + style = style, + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt index 5eebe62a..5cefacf3 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt @@ -62,6 +62,15 @@ class PlaceMapViewModel( private val _selectedPlace: MutableLiveData = MutableLiveData() val selectedPlace: LiveData = _selectedPlace + val selectedPlaceFlow: StateFlow = + _selectedPlace + .asFlow() + .stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = SelectedPlaceUiState.Loading, + ) + private val _navigateToDetail = SingleLiveData() val navigateToDetail: LiveData = _navigateToDetail diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.kt index 6e7e29d1..cf57845a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.kt @@ -1,26 +1,38 @@ package com.daedan.festabook.presentation.placeMap.placeDetailPreview import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import androidx.activity.OnBackPressedCallback +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentPlaceDetailPreviewBinding import com.daedan.festabook.di.fragment.FragmentKey import com.daedan.festabook.logging.logger import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.common.OnMenuItemReClickListener -import com.daedan.festabook.presentation.common.loadImage -import com.daedan.festabook.presentation.common.setFormatDate -import com.daedan.festabook.presentation.common.showBottomAnimation import com.daedan.festabook.presentation.common.showErrorSnackBar import com.daedan.festabook.presentation.placeDetail.PlaceDetailActivity import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel import com.daedan.festabook.presentation.placeMap.logging.PlacePreviewClick import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState +import com.daedan.festabook.presentation.placeMap.placeDetailPreview.component.PlaceDetailPreviewScreen +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookSpacing import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject @@ -42,13 +54,70 @@ class PlaceDetailPreviewFragment( } } + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + return ComposeView(requireContext()).apply { + super.onCreateView(inflater, container, savedInstanceState) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + FestabookTheme { + val placeDetailUiState by viewModel.selectedPlaceFlow.collectAsStateWithLifecycle() + val visible = placeDetailUiState is SelectedPlaceUiState.Success + + LaunchedEffect(placeDetailUiState) { + backPressedCallback.isEnabled = true + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { + PlaceDetailPreviewScreen( + placeUiState = placeDetailUiState, + visible = visible, + modifier = + Modifier + .padding( + vertical = festabookSpacing.paddingBody4, + horizontal = festabookSpacing.paddingScreenGutter, + ), + onClick = { selectedPlace -> + if (selectedPlace !is SelectedPlaceUiState.Success) return@PlaceDetailPreviewScreen + startPlaceDetailActivity(selectedPlace.value) + binding.logger.log( + PlacePreviewClick( + baseLogData = binding.logger.getBaseLogData(), + placeName = + selectedPlace.value.place.title + ?: "undefined", + timeTag = + viewModel.selectedTimeTag.value?.name + ?: "undefined", + category = selectedPlace.value.place.category.name, + ), + ) + }, + onError = { selectedPlace -> + showErrorSnackBar(selectedPlace.throwable) + }, + onEmpty = { + backPressedCallback.isEnabled = false + }, + ) + } + } + } + } + } + override fun onViewCreated( view: View, savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - setUpObserver() - setupBinding() setUpBackPressedCallback() } @@ -63,63 +132,6 @@ class PlaceDetailPreviewFragment( ) } - private fun setupBinding() { - binding.layoutSelectedPlace.setOnClickListener { - val selectedPlaceState = viewModel.selectedPlace.value - if (selectedPlaceState is SelectedPlaceUiState.Success) { - startPlaceDetailActivity(selectedPlaceState.value) - binding.logger.log( - PlacePreviewClick( - baseLogData = binding.logger.getBaseLogData(), - placeName = selectedPlaceState.value.place.title ?: "undefined", - timeTag = viewModel.selectedTimeTag.value?.name ?: "undefined", - category = selectedPlaceState.value.place.category.name, - ), - ) - } - } - } - - private fun setUpObserver() { - viewModel.selectedPlace.observe(viewLifecycleOwner) { selectedPlace -> - backPressedCallback.isEnabled = true - binding.layoutSelectedPlace.visibility = - if (selectedPlace == SelectedPlaceUiState.Empty) View.GONE else View.VISIBLE - - when (selectedPlace) { - is SelectedPlaceUiState.Loading -> Unit - is SelectedPlaceUiState.Success -> { - binding.layoutSelectedPlace.showBottomAnimation() - updateSelectedPlaceUi(selectedPlace.value) - } - - is SelectedPlaceUiState.Error -> showErrorSnackBar(selectedPlace.throwable) - is SelectedPlaceUiState.Empty -> backPressedCallback.isEnabled = false - } - } - } - - private fun updateSelectedPlaceUi(selectedPlace: PlaceDetailUiModel) { - with(binding) { - layoutSelectedPlace.visibility = View.VISIBLE - tvSelectedPlaceTitle.text = - selectedPlace.place.title ?: getString(R.string.place_list_default_title) - tvSelectedPlaceLocation.text = - selectedPlace.place.location ?: getString(R.string.place_list_default_location) - setFormatDate( - binding.tvSelectedPlaceTime, - selectedPlace.startTime, - selectedPlace.endTime, - ) - tvSelectedPlaceHost.text = - selectedPlace.host ?: getString(R.string.place_detail_default_host) - tvSelectedPlaceDescription.text = selectedPlace.place.description - ?: getString(R.string.place_list_default_description) - cvPlaceCategory.setCategory(selectedPlace.place.category) - ivSelectedPlaceImage.loadImage(selectedPlace.featuredImage) - } - } - private fun startPlaceDetailActivity(placeDetail: PlaceDetailUiModel) { startActivity(PlaceDetailActivity.newIntent(requireContext(), placeDetail)) } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewSecondaryFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewSecondaryFragment.kt index 684f6bd0..48edaeb1 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewSecondaryFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewSecondaryFragment.kt @@ -1,26 +1,36 @@ package com.daedan.festabook.presentation.placeMap.placeDetailPreview import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import androidx.activity.OnBackPressedCallback +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModelProvider -import coil3.load +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentPlaceDetailPreviewSecondaryBinding +import com.daedan.festabook.di.appGraph import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.logging.logger import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.common.OnMenuItemReClickListener -import com.daedan.festabook.presentation.common.showBottomAnimation import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel import com.daedan.festabook.presentation.placeMap.logging.PlacePreviewClick import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState -import com.daedan.festabook.presentation.placeMap.model.getIconId -import com.daedan.festabook.presentation.placeMap.model.getTextId +import com.daedan.festabook.presentation.placeMap.placeDetailPreview.component.PlaceDetailPreviewSecondaryScreen +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookSpacing import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject @@ -43,56 +53,62 @@ class PlaceDetailPreviewSecondaryFragment( } } - override fun onViewCreated( - view: View, + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - setUpObserver() - setUpBackPressedCallback() - } - - override fun onMenuItemReClick() { - viewModel.unselectPlace() - } + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val placeDetailUiState by viewModel.selectedPlaceFlow.collectAsStateWithLifecycle() + val visible = placeDetailUiState is SelectedPlaceUiState.Success - private fun setUpBackPressedCallback() { - requireActivity().onBackPressedDispatcher.addCallback( - viewLifecycleOwner, - backPressedCallback, - ) - } - - private fun setUpObserver() { - viewModel.selectedPlace.observe(viewLifecycleOwner) { selectedPlace -> - backPressedCallback.isEnabled = true - when (selectedPlace) { - is SelectedPlaceUiState.Success -> { - binding.layoutSelectedPlace.visibility = View.VISIBLE - binding.layoutSelectedPlace.showBottomAnimation() - updateSelectedPlaceUi(selectedPlace.value) - binding.logger.log( - PlacePreviewClick( - baseLogData = binding.logger.getBaseLogData(), - placeName = selectedPlace.value.place.title ?: "undefined", - timeTag = viewModel.selectedTimeTag.value?.name ?: "undefined", - category = selectedPlace.value.place.category.name, - ), - ) + LaunchedEffect(placeDetailUiState) { + backPressedCallback.isEnabled = true } - is SelectedPlaceUiState.Error -> showErrorSnackBar(selectedPlace.throwable) - is SelectedPlaceUiState.Loading -> Unit - is SelectedPlaceUiState.Empty -> backPressedCallback.isEnabled = false + FestabookTheme { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { + PlaceDetailPreviewSecondaryScreen( + visible = visible, + placeUiState = placeDetailUiState, + modifier = + Modifier + .padding( + vertical = festabookSpacing.paddingBody4, + horizontal = festabookSpacing.paddingScreenGutter, + ), + onError = { + showErrorSnackBar(it.throwable) + }, + onEmpty = { + backPressedCallback.isEnabled = false + }, + onClick = { + if (it !is SelectedPlaceUiState.Success) return@PlaceDetailPreviewSecondaryScreen + appGraph.defaultFirebaseLogger.log( + PlacePreviewClick( + baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), + placeName = it.value.place.title ?: "undefined", + timeTag = + viewModel.selectedTimeTag.value?.name + ?: "undefined", + category = it.value.place.category.name, + ), + ) + }, + ) + } + } } } } - private fun updateSelectedPlaceUi(selectedPlace: PlaceDetailUiModel) { - with(binding) { - ivSecondaryCategoryItem.load(selectedPlace.place.category.getIconId()) - tvSelectedPlaceTitle.text = - selectedPlace.place.title ?: getString(selectedPlace.place.category.getTextId()) - } + override fun onMenuItemReClick() { + viewModel.unselectPlace() } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewScreen.kt new file mode 100644 index 00000000..0e4c1018 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewScreen.kt @@ -0,0 +1,215 @@ +package com.daedan.festabook.presentation.placeMap.placeDetailPreview.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.component.CoilImage +import com.daedan.festabook.presentation.common.component.URLText +import com.daedan.festabook.presentation.common.convertImageUrl +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.component.PlaceCategoryLabel +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun PlaceDetailPreviewScreen( + placeUiState: SelectedPlaceUiState, + modifier: Modifier = Modifier, + visible: Boolean = false, + onClick: (SelectedPlaceUiState) -> Unit = {}, + onError: (SelectedPlaceUiState.Error) -> Unit = {}, + onEmpty: () -> Unit = {}, +) { + PreviewAnimatableBox( + visible = visible, + modifier = + modifier + .wrapContentSize() + .clickable { onClick(placeUiState) }, + ) { + when (placeUiState) { + is SelectedPlaceUiState.Loading -> Unit + is SelectedPlaceUiState.Success -> { + PlaceDetailPreviewContent(placeDetail = placeUiState.value) + } + + is SelectedPlaceUiState.Error -> onError(placeUiState) + is SelectedPlaceUiState.Empty -> onEmpty() + } + } +} + +@Composable +private fun PlaceDetailPreviewContent( + placeDetail: PlaceDetailUiModel, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier.padding( + horizontal = festabookSpacing.paddingScreenGutter, + vertical = 20.dp, + ), + ) { + PlaceCategoryLabel( + category = placeDetail.place.category, + ) + + Row(modifier = Modifier.wrapContentSize()) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + modifier = + Modifier + .padding(top = festabookSpacing.paddingBody1), + style = FestabookTypography.displaySmall, + text = + placeDetail.place.title + ?: stringResource(R.string.place_list_default_title), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Row( + modifier = Modifier.padding(top = festabookSpacing.paddingBody3), + ) { + Icon( + painter = painterResource(R.drawable.ic_place_detail_clock), + contentDescription = stringResource(R.string.content_description_iv_clock), + ) + + Text( + modifier = Modifier.padding(start = festabookSpacing.paddingBody1), + text = formattedDate(placeDetail.startTime, placeDetail.endTime), + style = FestabookTypography.bodySmall, + color = FestabookColor.gray500, + ) + } + + Row( + modifier = Modifier.padding(top = festabookSpacing.paddingBody1), + ) { + Icon( + painter = painterResource(R.drawable.ic_location), + contentDescription = stringResource(R.string.content_description_iv_location), + ) + + Text( + modifier = Modifier.padding(start = festabookSpacing.paddingBody1), + text = + placeDetail.place.location + ?: stringResource(R.string.place_list_default_location), + style = FestabookTypography.bodySmall, + color = FestabookColor.gray500, + ) + } + + Row( + modifier = Modifier.padding(top = festabookSpacing.paddingBody1), + ) { + Icon( + painter = painterResource(R.drawable.ic_place_detail_host), + contentDescription = stringResource(R.string.content_description_iv_host), + ) + + Text( + modifier = Modifier.padding(start = festabookSpacing.paddingBody1), + text = + placeDetail.host + ?: stringResource(R.string.place_detail_default_host), + style = FestabookTypography.bodySmall, + color = FestabookColor.gray500, + ) + } + } + + CoilImage( + modifier = + Modifier + .size(88.dp) + .clip(festabookShapes.radius2), + url = placeDetail.place.imageUrl.convertImageUrl() ?: "", + contentDescription = stringResource(R.string.content_description_booth_image), + ) + } + + URLText( + modifier = Modifier.padding(top = festabookSpacing.paddingBody3), + text = + placeDetail.place.description + ?: stringResource(R.string.place_list_default_description), + style = FestabookTypography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun formattedDate( + startTime: String?, + endTime: String?, +): String = + if (startTime == null && endTime == null) { + stringResource(R.string.place_detail_default_time) + } else { + listOf(startTime, endTime).joinToString(" ~ ") + } + +@Preview +@Composable +private fun PlaceDetailPreviewScreenPreview() { + FestabookTheme { + PlaceDetailPreviewScreen( + modifier = + Modifier + .padding(festabookSpacing.paddingScreenGutter), + placeUiState = + SelectedPlaceUiState.Success( + value = FAKE_PLACE_DETAIL, + ), + ) + } +} + +private val FAKE_PLACE = + PlaceUiModel( + id = 1, + imageUrl = null, + category = PlaceCategoryUiModel.FOOD_TRUCK, + title = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", + description = "https://onlyfor-me-blog.tistory.com/1190", + location = null, + isBookmarked = false, + timeTagId = listOf(1), + ) + +private val FAKE_PLACE_DETAIL = + PlaceDetailUiModel( + place = FAKE_PLACE, + notices = listOf(), + host = null, + startTime = null, + endTime = null, + images = listOf(), + ) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewSecondaryScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewSecondaryScreen.kt new file mode 100644 index 00000000..f6a3ba2e --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewSecondaryScreen.kt @@ -0,0 +1,124 @@ +package com.daedan.festabook.presentation.placeMap.placeDetailPreview.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState +import com.daedan.festabook.presentation.placeMap.model.getIconId +import com.daedan.festabook.presentation.placeMap.model.getTextId +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun PlaceDetailPreviewSecondaryScreen( + placeUiState: SelectedPlaceUiState, + modifier: Modifier = Modifier, + onError: (SelectedPlaceUiState.Error) -> Unit = {}, + onEmpty: () -> Unit = {}, + onClick: (SelectedPlaceUiState) -> Unit = {}, + visible: Boolean = false, +) { + PreviewAnimatableBox( + visible = visible, + modifier = + modifier + .fillMaxWidth() + .clickable { + onClick(placeUiState) + }, + shape = festabookShapes.radius2, + ) { + when (placeUiState) { + is SelectedPlaceUiState.Loading -> Unit + is SelectedPlaceUiState.Error -> onError(placeUiState) + is SelectedPlaceUiState.Empty -> onEmpty() + is SelectedPlaceUiState.Success -> { + Row( + modifier = + Modifier.padding( + horizontal = festabookSpacing.paddingBody4, + vertical = festabookSpacing.paddingBody3, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = + painterResource( + placeUiState.value.place.category + .getIconId(), + ), + tint = Color.Unspecified, + contentDescription = stringResource(R.string.content_description_iv_category_marker), + ) + + Text( + modifier = Modifier.padding(start = festabookSpacing.paddingBody2), + text = + placeUiState.value.place.title + ?: stringResource( + placeUiState.value.place.category + .getTextId(), + ), + style = FestabookTypography.displaySmall, + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PlaceDetailPreviewSecondaryScreenPreview() { + FestabookTheme { + PlaceDetailPreviewSecondaryScreen( + visible = true, + modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), + placeUiState = + SelectedPlaceUiState.Success( + FAKE_PLACE_DETAIL, + ), + ) + } +} + +private val FAKE_PLACE = + PlaceUiModel( + id = 1, + imageUrl = null, + category = PlaceCategoryUiModel.TOILET, + title = "테스트테스", + description = "https://onlyfor-me-blog.tistory.com/1190", + location = null, + isBookmarked = false, + timeTagId = listOf(1), + ) + +private val FAKE_PLACE_DETAIL = + PlaceDetailUiModel( + place = FAKE_PLACE, + notices = listOf(), + host = null, + startTime = null, + endTime = null, + images = listOf(), + ) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PreviewAnimatableBox.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PreviewAnimatableBox.kt new file mode 100644 index 00000000..0d478d97 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PreviewAnimatableBox.kt @@ -0,0 +1,67 @@ +package com.daedan.festabook.presentation.placeMap.placeDetailPreview.component + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.common.component.cardBackground +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.festabookShapes +import kotlinx.coroutines.launch + +@Composable +fun PreviewAnimatableBox( + visible: Boolean, + modifier: Modifier = Modifier, + backgroundColor: Color = FestabookColor.white, + borderColor: Color = FestabookColor.gray200, + shape: Shape = festabookShapes.radius5, + borderStroke: Dp = 1.dp, + content: @Composable BoxScope.() -> Unit = {}, +) { + val offsetY = remember { Animatable(120f) } + val alpha = remember { Animatable(0.3f) } + + LaunchedEffect(visible) { + if (visible) { + launch { + offsetY.animateTo( + targetValue = 0f, + animationSpec = tween(300), + ) + } + launch { + alpha.animateTo(1f, animationSpec = tween(300)) + } + } else { + // 나갈 때 애니메이션 (위에서 아래로 + 페이드아웃) + launch { offsetY.snapTo(120f) } + launch { alpha.snapTo(0.3f) } + } + } + + Box( + modifier = + modifier + .graphicsLayer { + translationY = offsetY.value + this.alpha = alpha.value + }.cardBackground( + backgroundColor = backgroundColor, + borderColor = borderColor, + shape = shape, + borderStroke = borderStroke, + ), + ) { + content() + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cca637b8..398b9f64 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -91,7 +91,10 @@ 공지 아이콘 고정핀 아이콘 위치 아이콘 + 카테고리 아이콘 부스 이미지 + 운영 시간 아이콘 + 호스트 아이콘 공지사항이 없습니다 새로고침 플로팅 지도 버튼 From e5f1ba35d8e66dfbaf3071a379fba5e17092dcbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Sun, 28 Dec 2025 21:55:05 +0900 Subject: [PATCH 080/140] =?UTF-8?q?refactor:=20Lottie=20=EC=95=A0=EB=8B=88?= =?UTF-8?q?=EB=A9=94=EC=9D=B4=EC=85=98=20=EB=A1=9C=EC=A7=81=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/component/ScheduleEventItem.kt | 12 +++++++++--- .../schedule/component/ScheduleTabPage.kt | 11 ----------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt index 6824f2c7..afed2f0c 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt @@ -3,6 +3,7 @@ package com.daedan.festabook.presentation.schedule.component import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -11,6 +12,8 @@ import androidx.compose.ui.tooling.preview.Preview import com.airbnb.lottie.LottieComposition import com.airbnb.lottie.LottieProperty import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.animateLottieCompositionAsState import com.airbnb.lottie.compose.rememberLottieDynamicProperties import com.airbnb.lottie.compose.rememberLottieDynamicProperty import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel @@ -21,17 +24,21 @@ import com.daedan.festabook.presentation.theme.festabookSpacing @Composable fun ScheduleEventItem( composition: LottieComposition?, - progress: Float, scheduleEvent: ScheduleEventUiModel, modifier: Modifier = Modifier, ) { val props = lottieTimeLineCircleProps(scheduleEvent.status) val dynamicProperties = rememberScheduleEventDynamicProperties(props) + val progress by animateLottieCompositionAsState( + composition = composition, + iterations = LottieConstants.IterateForever, + isPlaying = scheduleEvent.status != ScheduleEventUiStatus.COMPLETED, + ) Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { LottieAnimation( composition = composition, - progress = { if (scheduleEvent.status == ScheduleEventUiStatus.COMPLETED) 0f else progress }, + progress = { progress }, modifier = Modifier.size(festabookSpacing.paddingBody4 * 4), dynamicProperties = dynamicProperties, ) @@ -45,7 +52,6 @@ fun ScheduleEventItem( private fun ScheduleEventItemPreview() { ScheduleEventItem( composition = null, - progress = 1f, scheduleEvent = ScheduleEventUiModel( id = 1, diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt index 956ae688..87348162 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt @@ -22,8 +22,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.airbnb.lottie.LottieComposition import com.airbnb.lottie.compose.LottieCompositionSpec -import com.airbnb.lottie.compose.LottieConstants -import com.airbnb.lottie.compose.animateLottieCompositionAsState import com.airbnb.lottie.compose.rememberLottieComposition import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.EmptyStateScreen @@ -49,10 +47,6 @@ fun ScheduleTabPage( modifier: Modifier = Modifier, ) { val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.pulse_circle)) - val progress by animateLottieCompositionAsState( - composition = composition, - iterations = LottieConstants.IterateForever, - ) HorizontalPager( state = pagerState, modifier = modifier, @@ -79,7 +73,6 @@ fun ScheduleTabPage( is ScheduleEventsUiState.Refreshing -> { ScheduleTabContent( composition = composition, - progress = progress, scheduleEvents = scheduleEventsUiState.oldEvents, modifier = Modifier @@ -91,7 +84,6 @@ fun ScheduleTabPage( is ScheduleEventsUiState.Success -> { ScheduleTabContent( composition = composition, - progress = progress, scheduleEvents = scheduleEventsUiState.events, currentEventPosition = scheduleEventsUiState.currentEventPosition, modifier = @@ -110,7 +102,6 @@ fun ScheduleTabPage( @Composable private fun ScheduleTabContent( composition: LottieComposition?, - progress: Float, scheduleEvents: List, modifier: Modifier = Modifier, currentEventPosition: Int = DEFAULT_POSITION, @@ -145,7 +136,6 @@ private fun ScheduleTabContent( items(items = scheduleEvents, key = { scheduleEvent -> scheduleEvent.id }) { ScheduleEventItem( composition = composition, - progress = progress, scheduleEvent = it, ) } @@ -160,7 +150,6 @@ private fun ScheduleTabContentPreview() { FestabookTheme { ScheduleTabContent( composition = null, - progress = 1f, scheduleEvents = listOf( ScheduleEventUiModel( From 0ce44cf0554d2ab89bb6e63d36f93ae16528fd37 Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Mon, 29 Dec 2025 00:58:43 +0900 Subject: [PATCH 081/140] =?UTF-8?q?refactor(Home):=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20UI=20=EC=BB=B4=ED=8F=AC=EC=A6=88=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81=20=EB=B0=8F=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 홈 화면을 구성하는 주요 컴포저블(`HomeHeader`, `HomePosterList` 등)의 파라미터 네이밍을 정리하고, 디자인 시스템 및 커스텀 Modifier를 적용하여 코드 일관성을 높였습니다. - **`HomeHeader.kt` 수정:** - 파라미터 이름을 `schoolName`에서 `universityName`으로 변경하여 도메인 모델과의 일관성을 맞췄습니다. - 드롭다운 아이콘을 `Image`에서 `Icon`으로 변경하고, `FestabookColor`와 고정 크기(24.dp)를 적용했습니다. - **`HomePosterList.kt` 수정:** - 포스터 카드에 커스텀 Modifier인 `cardBackground`를 적용하여 배경 스타일을 개선했습니다. - **`HomeScreen.kt` 수정:** - `HomeHeader` 호출 시 변경된 파라미터 명(`universityName`)을 반영했습니다. - 리스트 하단의 여백 처리를 위해 의미상 더 적절한 `Spacer`를 사용하도록 변경했습니다. - **`HomeLineupHeader.kt` 수정:** - 일정 클릭 영역(`Row`)에서 불필요한 `padding(4.dp)`을 제거하여 레이아웃을 정돈했습니다. --- .../presentation/home/component/HomeHeader.kt | 15 +++++++++------ .../home/component/HomeLineupHeader.kt | 3 +-- .../presentation/home/component/HomePosterList.kt | 4 +++- .../presentation/home/component/HomeScreen.kt | 5 +++-- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt index 379b644c..5677ce19 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt @@ -1,13 +1,14 @@ package com.daedan.festabook.presentation.home.component -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -24,7 +25,7 @@ import com.daedan.festabook.presentation.theme.FestabookTypography @Composable fun HomeHeader( - schoolName: String, + universityName: String, onExpandClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -39,7 +40,7 @@ fun HomeHeader( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = schoolName, + text = universityName, style = FestabookTypography.displayLarge.copy( platformStyle = PlatformTextStyle(includeFontPadding = false), lineHeight = 34.sp @@ -49,9 +50,11 @@ fun HomeHeader( Spacer(modifier = Modifier.width(4.dp)) - Image( + Icon( painter = painterResource(id = R.drawable.ic_dropdown), - contentDescription = stringResource(id = R.string.home_navigate_to_explore_desc), + tint = FestabookColor.black, + contentDescription = stringResource(R.string.home_navigate_to_explore_desc), + modifier = Modifier.size(24.dp) ) } } @@ -61,7 +64,7 @@ fun HomeHeader( @Composable private fun HomeHeaderPreview() { HomeHeader( - schoolName = "가천대학교", + universityName = "가천대학교", onExpandClick = {}, ) } diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt index da16b702..3679a4a0 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt @@ -45,8 +45,7 @@ fun HomeLineupHeader( Modifier .clickable( onClick = onScheduleClick, - ) - .padding(4.dp), + ), verticalAlignment = Alignment.CenterVertically, ) { Text( diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt index 1ce0af70..fd8b8c0e 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import com.daedan.festabook.presentation.common.component.CoilImage +import com.daedan.festabook.presentation.common.component.cardBackground import kotlin.math.absoluteValue @Composable @@ -87,7 +88,8 @@ fun HomePosterList( scaleY = scale this.alpha = alpha } - .clip(RoundedCornerShape(10.dp)), + .cardBackground(roundedCornerShape = 10.dp) + .clip(RoundedCornerShape(10.dp)) ) { CoilImage( url = imageUrl, diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt index aaf0ea71..f98f657b 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt @@ -1,6 +1,7 @@ package com.daedan.festabook.presentation.home.component import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -65,7 +66,7 @@ private fun FestivalOverview( item { if (festivalUiState is FestivalUiState.Success) { HomeHeader( - schoolName = festivalUiState.organization.universityName, + universityName = festivalUiState.organization.universityName, onExpandClick = onNavigateToExplore, modifier = Modifier.padding(top = 40.dp), ) @@ -144,7 +145,7 @@ private fun FestivalOverview( // 하단 여백 추가 item { - Box(modifier = Modifier.padding(bottom = 60.dp)) + Spacer(modifier = Modifier.padding(bottom = 60.dp)) } } } From e0c981e63f970a9ba50e25591240b5e3bda5b21f Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Mon, 29 Dec 2025 01:08:18 +0900 Subject: [PATCH 082/140] =?UTF-8?q?refactor(Home):=20LineupUiState=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../daedan/festabook/presentation/home/HomeViewModel.kt | 6 ++---- .../daedan/festabook/presentation/home/LineupUiState.kt | 2 +- .../festabook/presentation/home/component/HomeScreen.kt | 8 +++++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt index 64caaff1..6dbb1c1d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt @@ -61,10 +61,8 @@ class HomeViewModel @Inject constructor( val result = festivalRepository.getLineUpGroupByDate() result .onSuccess { lineups -> - _lineupUiState.value = - LineupUiState.Success( - lineups.toUiModel(), - ) + val lineupItems = lineups.toUiModel().getLineupItems() + _lineupUiState.value = LineupUiState.Success(lineupItems) }.onFailure { _lineupUiState.value = LineupUiState.Error(it) } diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/LineupUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/home/LineupUiState.kt index f2b84299..dcfc03e8 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/LineupUiState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/LineupUiState.kt @@ -4,7 +4,7 @@ sealed interface LineupUiState { data object Loading : LineupUiState data class Success( - val lineups: LineUpItemGroupUiModel, + val lineups: List, ) : LineupUiState data class Error( diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt index f98f657b..fe8e790b 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt @@ -128,8 +128,10 @@ private fun FestivalOverview( // 라인업 리스트 when (lineupUiState) { is LineupUiState.Success -> { - val lineups = lineupUiState.lineups.getLineupItems() - items(lineups) { lineupItem -> + items( + items = lineupUiState.lineups, + key = { it.id }, + ) { lineupItem -> HomeLineupItem(uiModel = lineupItem) } } @@ -189,7 +191,7 @@ private fun FestivalOverviewPreview() { FestivalOverview( festivalUiState = FestivalUiState.Success(sampleFestival), - lineupUiState = LineupUiState.Success(sampleLineups), + lineupUiState = LineupUiState.Success(sampleLineups.getLineupItems()), onNavigateToExplore = {}, onNavigateToSchedule = {}, ) From a9d84b4f70d0df6c8daac327aec9d2413f5c56d0 Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Mon, 29 Dec 2025 01:17:54 +0900 Subject: [PATCH 083/140] =?UTF-8?q?refactor(HomeLineupItem):=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=EC=98=81=EC=97=AD=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EB=B0=8F=20=EB=94=94=EB=B0=94=EC=9D=B4=EB=8D=94=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/component/HomeLineupItem.kt | 68 ++++++++++--------- 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt index af9efc0b..bbb106dd 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -14,6 +15,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -39,44 +42,47 @@ fun HomeLineupItem( modifier = modifier.fillMaxWidth(), ) { // 날짜 + 배지 영역 - Row( - modifier = Modifier.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = Modifier.padding(horizontal = 16.dp).width(IntrinsicSize.Max) ) { - Text( - text = "${uiModel.date.monthValue}.${uiModel.date.dayOfMonth}", - style = FestabookTypography.titleLarge, - color = FestabookColor.black, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "${uiModel.date.monthValue}.${uiModel.date.dayOfMonth}", + style = FestabookTypography.titleLarge, + color = FestabookColor.black, + ) - if (uiModel.isDDay) { - Spacer(modifier = Modifier.width(6.dp)) - Box( - modifier = - Modifier - .clip(RoundedCornerShape(20.dp)) - .background(FestabookColor.black) - .padding(horizontal = 6.dp, vertical = 2.dp), - ) { - Text( - text = stringResource(id = R.string.home_is_d_day), - style = FestabookTypography.labelSmall, - color = FestabookColor.white, - ) + if (uiModel.isDDay) { + Spacer(modifier = Modifier.width(6.dp)) + Box( + modifier = + Modifier + .clip(RoundedCornerShape(20.dp)) + .background(FestabookColor.black) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = stringResource(id = R.string.home_is_d_day), + style = FestabookTypography.labelSmall, + color = FestabookColor.white, + ) + } } } + Spacer(modifier = Modifier.height(4.dp)) + + HorizontalDivider( + thickness = 1.dp, + color = FestabookColor.gray700, + modifier = Modifier.fillMaxWidth(), + ) + } - Spacer(modifier = Modifier.height(4.dp)) - Box( - modifier = - Modifier - .padding(horizontal = 16.dp) - .width(75.dp) - .height(1.dp) - .background(FestabookColor.gray700), - ) Spacer(modifier = Modifier.height(8.dp)) From f849f6f084e299637cd3c9f23d6ebb1d19cdb746 Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Mon, 29 Dec 2025 01:26:59 +0900 Subject: [PATCH 084/140] =?UTF-8?q?refactor(Home):=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=AC=B8=EC=9E=90=EC=97=B4=20=EB=A6=AC=EC=86=8C?= =?UTF-8?q?=EC=8A=A4=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EC=A0=91=EA=B7=BC?= =?UTF-8?q?=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/home/component/HomeArtistItem.kt | 2 +- .../presentation/home/component/HomeLineupHeader.kt | 4 ++-- app/src/main/res/layout/fragment_home.xml | 2 +- app/src/main/res/values/strings.xml | 9 ++++++--- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt index 478baa80..7d451cca 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt @@ -51,7 +51,7 @@ fun HomeArtistItem( } } -object HomeArtistItem { +private object HomeArtistItem { val ArtistImage = RoundedCornerShape( topStartPercent = 50, topEndPercent = 50, diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt index 3679a4a0..11b4d092 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt @@ -49,7 +49,7 @@ fun HomeLineupHeader( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(R.string.home_check_schedule_text), + text = stringResource(R.string.home_navigate_to_schedule_text), style = FestabookTypography.bodySmall, color = FestabookColor.gray400, ) @@ -58,7 +58,7 @@ fun HomeLineupHeader( Icon( painter = painterResource(id = R.drawable.ic_arrow_forward_right), - contentDescription = null, + contentDescription = stringResource(R.string.home_navigate_to_schedule_desc), tint = FestabookColor.gray400, modifier = Modifier.size(12.dp), ) diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index e0b46feb..76e048fc 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -113,7 +113,7 @@ android:background="@color/transparent" android:paddingHorizontal="16dp" android:paddingVertical="4dp" - android:text="@string/home_check_schedule_text" + android:text="@string/home_navigate_to_schedule_text" android:textColor="@color/gray400" app:icon="@drawable/ic_arrow_forward_right" app:iconGravity="end" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cca637b8..5c45fca2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,11 @@ poster_image 오늘 + 일정 화면으로 이동하는 버튼 + 일정 확인하기 + 축제 라인업 + 탐색 화면으로 이동하는 버튼 + 한 눈에 보기 @@ -123,8 +128,7 @@ 알림 받기 다음에 item_lineup_image - 일정 확인하기 - 축제 라인업 + 뒤로가기를 한 번 더 누르면 종료됩니다. @@ -135,7 +139,6 @@ 알림 새로운 소식이 있습니다. - 탐색 화면으로 이동하는 버튼 탐색 화면 닫기 버튼 From 1e98ebba8522b723b2f6bf85e77d8ba0323cb5a9 Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:52:07 +0900 Subject: [PATCH 085/140] =?UTF-8?q?refactor(HomeViewModel):=20`@Inject`=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/daedan/festabook/presentation/home/HomeViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt index 6dbb1c1d..99ebae04 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt @@ -18,7 +18,8 @@ import kotlinx.coroutines.launch @ContributesIntoMap(AppScope::class) @ViewModelKey(HomeViewModel::class) -class HomeViewModel @Inject constructor( +@Inject +class HomeViewModel( private val festivalRepository: FestivalRepository, ) : ViewModel() { private val _festivalUiState = MutableStateFlow(FestivalUiState.Loading) From 9427a8f06a348b85a7bc63252bd40e4a8f17c99c Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:02:40 +0900 Subject: [PATCH 086/140] =?UTF-8?q?refactor(MainActivity):=20BottomNavigat?= =?UTF-8?q?ionView=EC=9D=98=20=EC=A4=91=EB=B3=B5=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=20=EB=B0=8F=20flow=20=EC=88=98=EC=A7=91=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/daedan/festabook/presentation/main/MainActivity.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt b/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt index 17e2b8cd..283e07f4 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt @@ -166,8 +166,10 @@ class MainActivity : lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - homeViewModel.navigateToScheduleEvent.collectLatest { - binding.bnvMenu.selectedItemId = R.id.item_menu_schedule + homeViewModel.navigateToScheduleEvent.collect { + if (binding.bnvMenu.selectedItemId != R.id.item_menu_schedule) { + binding.bnvMenu.selectedItemId = R.id.item_menu_schedule + } } } } From c8325fe5954c59fcec4e41518c837a702639e9d0 Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:03:34 +0900 Subject: [PATCH 087/140] =?UTF-8?q?refactor(HomeFestivalInfo):=20Column=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80=20padding=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B0=9C=EB=B3=84=20Text=20padding=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../festabook/presentation/home/component/HomeFestivalInfo.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt index 50989193..37e15d6d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt @@ -20,13 +20,12 @@ fun HomeFestivalInfo( modifier: Modifier = Modifier, ) { Column( - modifier = modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth().padding(horizontal = 20.dp), ) { Text( text = festivalName, style = FestabookTypography.displayMedium, color = FestabookColor.black, - modifier = Modifier.padding(horizontal = 20.dp), ) Spacer(modifier = Modifier.height(8.dp)) @@ -35,7 +34,6 @@ fun HomeFestivalInfo( text = festivalDate, style = FestabookTypography.bodyLarge, color = FestabookColor.gray500, - modifier = Modifier.padding(horizontal = 20.dp), ) } } From dba3acbf5e894fc8f342290ce602b70824ea58c0 Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Wed, 7 Jan 2026 20:38:39 +0900 Subject: [PATCH 088/140] =?UTF-8?q?build:=20Landscapist=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 3 +++ gradle/libs.versions.toml | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dac82a61..75bbf326 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -181,6 +181,9 @@ dependencies { implementation(libs.ui.tooling) implementation(libs.androidx.material3) implementation(libs.photoview.dialog) + implementation(libs.landscapist.coil3) + implementation(libs.landscapist.placeholder) + implementation(libs.landscapist.zoomable) testImplementation(libs.junit) testImplementation(libs.mockk) testImplementation(libs.androidx.core.testing) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 59f07add..2b6cfc74 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,9 @@ junitVersion = "1.2.1" espressoCore = "3.6.1" appcompat = "1.7.0" kotlinxCoroutinesTest = "1.10.2" +landscapistCoil3 = "2.8.2" +landscapistPlaceholder = "2.8.2" +landscapistZoomable = "2.8.2" loggingInterceptor = "5.1.0" lottie = "6.6.6" mapSdk = "3.22.1" @@ -59,6 +62,9 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "j androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } +landscapist-coil3 = { module = "com.github.skydoves:landscapist-coil3", version.ref = "landscapistCoil3" } +landscapist-placeholder = { module = "com.github.skydoves:landscapist-placeholder", version.ref = "landscapistPlaceholder" } +landscapist-zoomable = { module = "com.github.skydoves:landscapist-zoomable", version.ref = "landscapistZoomable" } logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" } lottie = { module = "com.airbnb.android:lottie", version.ref = "lottie" } lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = "lottie" } From db9268696e9a42fb1bf8560a26d9bcd0b1808380 Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Wed, 7 Jan 2026 20:39:10 +0900 Subject: [PATCH 089/140] =?UTF-8?q?feat(common):=20FestabookImage=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EC=BB=B4=ED=8F=AC=EC=A0=80=EB=B8=94=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/component/FestabookImage.kt | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookImage.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookImage.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookImage.kt new file mode 100644 index 00000000..8815053b --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookImage.kt @@ -0,0 +1,160 @@ +package com.daedan.festabook.presentation.common.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil3.request.ImageRequest +import com.daedan.festabook.BuildConfig +import com.daedan.festabook.presentation.theme.FestabookColor +import com.skydoves.landscapist.ImageOptions +import com.skydoves.landscapist.coil3.CoilImage +import com.skydoves.landscapist.components.rememberImageComponent +import com.skydoves.landscapist.placeholder.shimmer.Shimmer +import com.skydoves.landscapist.placeholder.shimmer.ShimmerPlugin +import com.skydoves.landscapist.zoomable.ZoomablePlugin +import com.skydoves.landscapist.zoomable.rememberZoomableState +import com.daedan.festabook.R + +@Composable +fun FestabookImage( + modifier: Modifier = Modifier, + imageUrl: String?, + contentDescription: String? = null, + contentScale: ContentScale = ContentScale.Crop, + isZoomable: Boolean = false, + enablePopUp: Boolean = false, + builder: ImageRequest.Builder.() -> Unit = {}, +) { + val context = LocalContext.current + val zoomableState = rememberZoomableState() + val convertedUrl = imageUrl.convertImageUrl() + + var isPopUpOpen by remember { mutableStateOf(false) } + + Box( + modifier = modifier.then( + if (enablePopUp) Modifier.clickable { isPopUpOpen = true } + else Modifier + ) + ) { + CoilImage( + imageRequest = { + ImageRequest.Builder(context) + .data(convertedUrl) + .apply(builder) + .build() + + }, + modifier = modifier.fillMaxSize(), + imageOptions = ImageOptions( + contentScale = contentScale, + alignment = Alignment.Center, + contentDescription = contentDescription + ), + component = rememberImageComponent { + +ShimmerPlugin( + Shimmer.Flash( + baseColor = FestabookColor.gray100.copy(alpha = 0.5f), + highlightColor = FestabookColor.gray200.copy(alpha = 0.3f)), + ) + if (isZoomable) { + +ZoomablePlugin(state = zoomableState) + } + }, + failure = { + Image( + painter = painterResource(id = R.drawable.img_fallback), + contentDescription = "fallback_image", + modifier = Modifier.align(Alignment.Center), + contentScale = contentScale + ) + } + ) + } + if (isPopUpOpen && enablePopUp) { + FestabookImageZoomPopup( + imageUrl = imageUrl, + onDismiss = { isPopUpOpen = false } + ) + } +} + +@Composable +private fun FestabookImageZoomPopup( + imageUrl: String?, + onDismiss: () -> Unit +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(FestabookColor.black.copy(alpha = 0.8f)) + ) { + FestabookImage( + imageUrl = imageUrl, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + isZoomable = true, + enablePopUp = false + ) + + IconButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(16.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "close the popup", + tint = FestabookColor.white, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun FestabookImageTestPreview() { + FestabookImage( + imageUrl = "" + ) +} +@Preview(showBackground = true) +@Composable +fun DiaplogPreview() { + FestabookImageZoomPopup( + imageUrl = "" + ) { } +} + +fun String?.convertImageUrl() = if (this != null && this.startsWith("/images/")) { + BuildConfig.FESTABOOK_URL.removeSuffix("/api/") + this +} else { + this +} \ No newline at end of file From aec51d3143d32e2c6574bc87df921b9d24488a7e Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Wed, 7 Jan 2026 21:06:59 +0900 Subject: [PATCH 090/140] =?UTF-8?q?feat(home):=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20UI=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20`Festaboo?= =?UTF-8?q?kImage`=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/component/HomePosterList.kt | 7 +- .../presentation/home/component/HomeScreen.kt | 145 ++++++++++-------- 2 files changed, 86 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt index fd8b8c0e..84fd80a7 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import com.daedan.festabook.presentation.common.component.CoilImage +import com.daedan.festabook.presentation.common.component.FestabookImage import com.daedan.festabook.presentation.common.component.cardBackground import kotlin.math.absoluteValue @@ -91,10 +92,10 @@ fun HomePosterList( .cardBackground(roundedCornerShape = 10.dp) .clip(RoundedCornerShape(10.dp)) ) { - CoilImage( - url = imageUrl, - contentDescription = null, + FestabookImage( + imageUrl = imageUrl, modifier = Modifier.fillMaxSize(), + enablePopUp = true ) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt index fe8e790b..8082e85e 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt @@ -1,6 +1,7 @@ package com.daedan.festabook.presentation.home.component import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -8,13 +9,16 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.presentation.common.component.LoadingStateScreen import com.daedan.festabook.presentation.common.formatFestivalPeriod import com.daedan.festabook.presentation.home.HomeViewModel import com.daedan.festabook.presentation.home.LineUpItemGroupUiModel @@ -34,48 +38,66 @@ fun HomeScreen( onNavigateToExplore: () -> Unit, modifier: Modifier = Modifier, ) { - val festivalUiState by viewModel.festivalUiState.collectAsState() - val lineupUiState by viewModel.lineupUiState.collectAsState() + val festivalUiState by viewModel.festivalUiState.collectAsStateWithLifecycle() + val lineupUiState by viewModel.lineupUiState.collectAsStateWithLifecycle() - FestivalOverview( - festivalUiState = festivalUiState, - lineupUiState = lineupUiState, - onNavigateToExplore = onNavigateToExplore, - onNavigateToSchedule = viewModel::navigateToScheduleClick, - modifier = modifier, - ) + when (val state = festivalUiState) { + is FestivalUiState.Loading -> { + LoadingStateScreen(modifier = modifier) + } + + is FestivalUiState.Error -> { + Box(modifier = modifier.fillMaxSize()) { + Text( + text = "데이터를 불러오는데 실패했습니다.", + modifier = Modifier.align(Alignment.Center), + ) + } + } + + is FestivalUiState.Success -> { + FestivalOverview( + festivalUiState = state, + lineupUiState = lineupUiState, + onNavigateToExplore = onNavigateToExplore, + onNavigateToSchedule = viewModel::navigateToScheduleClick, + modifier = modifier, + ) + } + } } @Composable private fun FestivalOverview( - festivalUiState: FestivalUiState, + festivalUiState: FestivalUiState.Success, lineupUiState: LineupUiState, onNavigateToExplore: () -> Unit, onNavigateToSchedule: () -> Unit, modifier: Modifier = Modifier, ) { + val universityName = festivalUiState.organization.universityName + Scaffold( modifier = modifier.fillMaxSize(), containerColor = Color.White - ) { - LazyColumn( - modifier = - Modifier.fillMaxSize() + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() ) { - // 헤더 (학교 이름) - item { - if (festivalUiState is FestivalUiState.Success) { - HomeHeader( - universityName = festivalUiState.organization.universityName, - onExpandClick = onNavigateToExplore, - modifier = Modifier.padding(top = 40.dp), - ) - } - } - - // 포스터 리스트 - item { - if (festivalUiState is FestivalUiState.Success) { + HomeHeader( + universityName = universityName, + onExpandClick = onNavigateToExplore, + modifier = Modifier.padding(top = 40.dp, bottom = 12.dp), + ) + + LazyColumn( + modifier = + Modifier + .fillMaxSize() + ) { + // 포스터 리스트 + item { val posterUrls = festivalUiState.organization.festival.festivalImages .sortedBy { it.sequence } @@ -86,11 +108,9 @@ private fun FestivalOverview( modifier = Modifier.padding(vertical = 12.dp), ) } - } - // 축제 정보 - item { - if (festivalUiState is FestivalUiState.Success) { + // 축제 정보 + item { val festival = festivalUiState.organization.festival HomeFestivalInfo( festivalName = festival.festivalName, @@ -102,12 +122,9 @@ private fun FestivalOverview( modifier = Modifier.padding(top = 16.dp), ) } - } - - // 구분선 - item { - if (festivalUiState is FestivalUiState.Success) { + // 구분선 + item { HorizontalDivider( thickness = 4.dp, color = FestabookColor.gray200, @@ -115,39 +132,40 @@ private fun FestivalOverview( Modifier .padding(top = 16.dp), ) + } - } - // 라인업 헤더 - item { - HomeLineupHeader( - onScheduleClick = onNavigateToSchedule, - ) - } + // 라인업 헤더 + item { + HomeLineupHeader( + onScheduleClick = onNavigateToSchedule, + ) + } - // 라인업 리스트 - when (lineupUiState) { - is LineupUiState.Success -> { - items( - items = lineupUiState.lineups, - key = { it.id }, - ) { lineupItem -> - HomeLineupItem(uiModel = lineupItem) + // 라인업 리스트 + when (lineupUiState) { + is LineupUiState.Success -> { + items( + items = lineupUiState.lineups, + key = { it.id }, + ) { lineupItem -> + HomeLineupItem(uiModel = lineupItem) + } } - } - is LineupUiState.Loading -> { - // 로딩 시 동작 논의 후 추가 - } + is LineupUiState.Loading -> { + // 로딩 시 동작 논의 후 추가 + } - is LineupUiState.Error -> { - // 에러 표시 + is LineupUiState.Error -> { + // 에러 표시 + } } - } - // 하단 여백 추가 - item { - Spacer(modifier = Modifier.padding(bottom = 60.dp)) + // 하단 여백 추가 + item { + Spacer(modifier = Modifier.padding(bottom = 60.dp)) + } } } } @@ -196,3 +214,4 @@ private fun FestivalOverviewPreview() { onNavigateToSchedule = {}, ) } + From 14522f3d23255207331ab7757e7e6d9f22d5c3cb Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Wed, 7 Jan 2026 23:04:31 +0900 Subject: [PATCH 091/140] =?UTF-8?q?style(MainActivity,=20HomeFragment,=20e?= =?UTF-8?q?tc):=20=EC=BD=94=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EA=B0=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/home/HomeFragment.kt | 10 +++--- .../presentation/home/component/HomeHeader.kt | 13 ++++---- .../presentation/home/component/HomeScreen.kt | 33 +++++++++---------- .../presentation/main/MainActivity.kt | 33 ++++++++++++++----- .../presentation/setting/SettingFragment.kt | 1 - 5 files changed, 53 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt index c49774d2..6fefc37c 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt @@ -35,9 +35,12 @@ class HomeFragment( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + ): View = + ComposeView(requireContext()).apply { + setViewCompositionStrategy( + ViewCompositionStrategy + .DisposeOnViewTreeLifecycleDestroyed, + ) setContent { HomeScreen( viewModel = viewModel, @@ -47,5 +50,4 @@ class HomeFragment( ) } } - } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt index 5677ce19..1d9450de 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt @@ -33,7 +33,7 @@ fun HomeHeader( modifier = modifier .fillMaxWidth() - .padding(horizontal = 16.dp) + .padding(horizontal = 16.dp), ) { Row( modifier = Modifier.clickable { onExpandClick() }, @@ -41,10 +41,11 @@ fun HomeHeader( ) { Text( text = universityName, - style = FestabookTypography.displayLarge.copy( - platformStyle = PlatformTextStyle(includeFontPadding = false), - lineHeight = 34.sp - ), + style = + FestabookTypography.displayLarge.copy( + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeight = 34.sp, + ), color = FestabookColor.black, ) @@ -54,7 +55,7 @@ fun HomeHeader( painter = painterResource(id = R.drawable.ic_dropdown), tint = FestabookColor.black, contentDescription = stringResource(R.string.home_navigate_to_explore_desc), - modifier = Modifier.size(24.dp) + modifier = Modifier.size(24.dp), ) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt index 8082e85e..0214a9c3 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt @@ -18,16 +18,16 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.domain.model.Festival +import com.daedan.festabook.domain.model.Organization +import com.daedan.festabook.domain.model.Poster import com.daedan.festabook.presentation.common.component.LoadingStateScreen import com.daedan.festabook.presentation.common.formatFestivalPeriod import com.daedan.festabook.presentation.home.HomeViewModel import com.daedan.festabook.presentation.home.LineUpItemGroupUiModel import com.daedan.festabook.presentation.home.LineupItemUiModel -import com.daedan.festabook.presentation.home.adapter.FestivalUiState -import com.daedan.festabook.domain.model.Festival -import com.daedan.festabook.domain.model.Organization -import com.daedan.festabook.domain.model.Poster import com.daedan.festabook.presentation.home.LineupUiState +import com.daedan.festabook.presentation.home.adapter.FestivalUiState import com.daedan.festabook.presentation.theme.FestabookColor import java.time.LocalDate import java.time.LocalDateTime @@ -79,11 +79,12 @@ private fun FestivalOverview( Scaffold( modifier = modifier.fillMaxSize(), - containerColor = Color.White + containerColor = Color.White, ) { innerPadding -> Column( - modifier = Modifier - .fillMaxSize() + modifier = + Modifier + .fillMaxSize(), ) { HomeHeader( universityName = universityName, @@ -94,7 +95,7 @@ private fun FestivalOverview( LazyColumn( modifier = Modifier - .fillMaxSize() + .fillMaxSize(), ) { // 포스터 리스트 item { @@ -132,7 +133,6 @@ private fun FestivalOverview( Modifier .padding(top = 16.dp), ) - } // 라인업 헤더 @@ -196,14 +196,14 @@ private fun FestivalOverviewPreview() { group = mapOf( LocalDate.now() to - listOf( - LineupItemUiModel(1, "sample", "실리카겔", LocalDateTime.now()), - LineupItemUiModel(2, "sample", "아이유", LocalDateTime.now()), - ), + listOf( + LineupItemUiModel(1, "sample", "실리카겔", LocalDateTime.now()), + LineupItemUiModel(2, "sample", "아이유", LocalDateTime.now()), + ), LocalDate.now().plusDays(1) to - listOf( - LineupItemUiModel(3, "sample", "뉴진스", LocalDateTime.now()), - ), + listOf( + LineupItemUiModel(3, "sample", "뉴진스", LocalDateTime.now()), + ), ), ) @@ -214,4 +214,3 @@ private fun FestivalOverviewPreview() { onNavigateToSchedule = {}, ) } - diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt b/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt index 020e37f1..528af417 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt @@ -43,7 +43,6 @@ import com.daedan.festabook.presentation.setting.SettingFragment import com.daedan.festabook.presentation.setting.SettingViewModel import com.google.android.material.dialog.MaterialAlertDialogBuilder import dev.zacsweers.metro.Inject -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import timber.log.Timber @@ -227,19 +226,27 @@ class MainActivity : private fun onMenuItemClick() { binding.bnvMenu.setOnItemSelectedListener { icon -> when (icon.itemId) { - R.id.item_menu_home -> switchFragment(HomeFragment::class.java, TAG_HOME_FRAGMENT) - R.id.item_menu_schedule -> + R.id.item_menu_home -> { + switchFragment(HomeFragment::class.java, TAG_HOME_FRAGMENT) + } + + R.id.item_menu_schedule -> { switchFragment( ScheduleFragment::class.java, TAG_SCHEDULE_FRAGMENT, ) + } + + R.id.item_menu_news -> { + switchFragment(NewsFragment::class.java, TAG_NEWS_FRAGMENT) + } - R.id.item_menu_news -> switchFragment(NewsFragment::class.java, TAG_NEWS_FRAGMENT) - R.id.item_menu_setting -> + R.id.item_menu_setting -> { switchFragment( SettingFragment::class.java, TAG_SETTING_FRAGMENT, ) + } } true } @@ -254,14 +261,22 @@ class MainActivity : private fun onMenuItemReClick() { binding.bnvMenu.setOnItemReselectedListener { icon -> when (icon.itemId) { - R.id.item_menu_home -> Unit + R.id.item_menu_home -> { + Unit + } + R.id.item_menu_schedule -> { val fragment = supportFragmentManager.findFragmentByTag(TAG_SCHEDULE_FRAGMENT) if (fragment is OnMenuItemReClickListener) fragment.onMenuItemReClick() } - R.id.item_menu_news -> Unit - R.id.item_menu_setting -> Unit + R.id.item_menu_news -> { + Unit + } + + R.id.item_menu_setting -> { + Unit + } } } } @@ -310,4 +325,4 @@ class MainActivity : flags = Intent.FLAG_ACTIVITY_SINGLE_TOP } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt index c24afadf..783a4a79 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt @@ -20,7 +20,6 @@ import com.daedan.festabook.presentation.common.showErrorSnackBar import com.daedan.festabook.presentation.common.showNotificationDeniedSnackbar import com.daedan.festabook.presentation.common.showSnackBar import com.daedan.festabook.presentation.home.HomeViewModel -import com.daedan.festabook.presentation.home.adapter.FestivalUiState import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject From 88cbab00a92ee0fbb39736acc138fe289824cb90 Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Wed, 7 Jan 2026 23:15:36 +0900 Subject: [PATCH 092/140] =?UTF-8?q?style(HomeArtistItem,=20HomeLineupItem,?= =?UTF-8?q?=20HomePosterList,=20FestabookImage):=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=ED=8F=AC=EB=A7=B7=ED=8C=85=20=EB=B0=8F=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로젝트 전반의 UI 컴포저블 코드를 정리하고, 불필요한 임포트 제거 및 일관된 코드 스타일을 적용했습니다. - **`FestabookImage.kt`:** - `modifier`와 `imageUrl` 파라미터의 순서를 변경하고 코드 가독성을 위해 들여쓰기를 조정했습니다. - `CoilImage` 내부의 `modifier` 설정을 `Modifier.fillMaxSize()`로 단순화했습니다. - `FestabookImageZoomPopup` 및 미리보기(Preview) 함수의 접근 제어자를 `private`으로 수정했습니다. - 불필요한 빈 줄을 제거하고 다중 라인 호출 구조를 정돈했습니다. - **`HomePosterList.kt`:** - 더 이상 사용되지 않는 `CoilImage` 임포트를 제거했습니다. - `Modifier` 체이닝의 줄바꿈과 쉼표 누락 등 스타일을 수정했습니다. - **`HomeLineupItem.kt` & `HomeArtistItem.kt`:** - 사용하지 않는 `DividerDefaults` 임포트를 제거했습니다. - `RoundedCornerShape` 정의 및 컴포저블 내 불필요한 공백을 제거하여 코드 포맷을 정돈했습니다. --- .../common/component/FestabookImage.kt | 101 ++++++++++-------- .../home/component/HomeArtistItem.kt | 15 +-- .../home/component/HomeLineupItem.kt | 8 +- .../home/component/HomePosterList.kt | 10 +- 4 files changed, 70 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookImage.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookImage.kt index 8815053b..734f6163 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookImage.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookImage.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import coil3.request.ImageRequest import com.daedan.festabook.BuildConfig +import com.daedan.festabook.R import com.daedan.festabook.presentation.theme.FestabookColor import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.coil3.CoilImage @@ -34,12 +35,11 @@ import com.skydoves.landscapist.placeholder.shimmer.Shimmer import com.skydoves.landscapist.placeholder.shimmer.ShimmerPlugin import com.skydoves.landscapist.zoomable.ZoomablePlugin import com.skydoves.landscapist.zoomable.rememberZoomableState -import com.daedan.festabook.R @Composable fun FestabookImage( - modifier: Modifier = Modifier, imageUrl: String?, + modifier: Modifier = Modifier, contentDescription: String? = null, contentScale: ContentScale = ContentScale.Crop, isZoomable: Boolean = false, @@ -53,49 +53,56 @@ fun FestabookImage( var isPopUpOpen by remember { mutableStateOf(false) } Box( - modifier = modifier.then( - if (enablePopUp) Modifier.clickable { isPopUpOpen = true } - else Modifier - ) + modifier = + modifier.then( + if (enablePopUp) { + Modifier.clickable { isPopUpOpen = true } + } else { + Modifier + }, + ), ) { CoilImage( imageRequest = { - ImageRequest.Builder(context) + ImageRequest + .Builder(context) .data(convertedUrl) .apply(builder) .build() - - }, - modifier = modifier.fillMaxSize(), - imageOptions = ImageOptions( - contentScale = contentScale, - alignment = Alignment.Center, - contentDescription = contentDescription - ), - component = rememberImageComponent { - +ShimmerPlugin( - Shimmer.Flash( - baseColor = FestabookColor.gray100.copy(alpha = 0.5f), - highlightColor = FestabookColor.gray200.copy(alpha = 0.3f)), - ) - if (isZoomable) { - +ZoomablePlugin(state = zoomableState) - } }, + modifier = Modifier.fillMaxSize(), + imageOptions = + ImageOptions( + contentScale = contentScale, + alignment = Alignment.Center, + contentDescription = contentDescription, + ), + component = + rememberImageComponent { + +ShimmerPlugin( + Shimmer.Flash( + baseColor = FestabookColor.gray100.copy(alpha = 0.5f), + highlightColor = FestabookColor.gray200.copy(alpha = 0.3f), + ), + ) + if (isZoomable) { + +ZoomablePlugin(state = zoomableState) + } + }, failure = { Image( painter = painterResource(id = R.drawable.img_fallback), contentDescription = "fallback_image", modifier = Modifier.align(Alignment.Center), - contentScale = contentScale + contentScale = contentScale, ) - } + }, ) } if (isPopUpOpen && enablePopUp) { FestabookImageZoomPopup( imageUrl = imageUrl, - onDismiss = { isPopUpOpen = false } + onDismiss = { isPopUpOpen = false }, ) } } @@ -103,30 +110,32 @@ fun FestabookImage( @Composable private fun FestabookImageZoomPopup( imageUrl: String?, - onDismiss: () -> Unit + onDismiss: () -> Unit, ) { Dialog( onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) + properties = DialogProperties(usePlatformDefaultWidth = false), ) { Box( - modifier = Modifier - .fillMaxSize() - .background(FestabookColor.black.copy(alpha = 0.8f)) + modifier = + Modifier + .fillMaxSize() + .background(FestabookColor.black.copy(alpha = 0.8f)), ) { FestabookImage( imageUrl = imageUrl, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit, isZoomable = true, - enablePopUp = false + enablePopUp = false, ) IconButton( onClick = onDismiss, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(16.dp) + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(16.dp), ) { Icon( imageVector = Icons.Default.Close, @@ -140,21 +149,23 @@ private fun FestabookImageZoomPopup( @Preview(showBackground = true) @Composable -fun FestabookImageTestPreview() { +private fun FestabookImageTestPreview() { FestabookImage( - imageUrl = "" + imageUrl = "", ) } + @Preview(showBackground = true) @Composable -fun DiaplogPreview() { +private fun DiaplogPreview() { FestabookImageZoomPopup( - imageUrl = "" + imageUrl = "", ) { } } -fun String?.convertImageUrl() = if (this != null && this.startsWith("/images/")) { - BuildConfig.FESTABOOK_URL.removeSuffix("/api/") + this -} else { - this -} \ No newline at end of file +fun String?.convertImageUrl() = + if (this != null && this.startsWith("/images/")) { + BuildConfig.FESTABOOK_URL.removeSuffix("/api/") + this + } else { + this + } diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt index 7d451cca..0c3cc779 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt @@ -52,12 +52,13 @@ fun HomeArtistItem( } private object HomeArtistItem { - val ArtistImage = RoundedCornerShape( - topStartPercent = 50, - topEndPercent = 50, - bottomEndPercent = 50, - bottomStartPercent = 5, - ) + val ArtistImage = + RoundedCornerShape( + topStartPercent = 50, + topEndPercent = 50, + bottomEndPercent = 50, + bottomStartPercent = 5, + ) } @Preview @@ -67,4 +68,4 @@ private fun HomeArtistItemPreview() { artistName = "실리카겔", artistImageUrl = "sample", ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt index bbb106dd..9efdb59d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.DividerDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -43,7 +42,7 @@ fun HomeLineupItem( ) { // 날짜 + 배지 영역 Column( - modifier = Modifier.padding(horizontal = 16.dp).width(IntrinsicSize.Max) + modifier = Modifier.padding(horizontal = 16.dp).width(IntrinsicSize.Max), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -79,11 +78,8 @@ fun HomeLineupItem( color = FestabookColor.gray700, modifier = Modifier.fillMaxWidth(), ) - } - - Spacer(modifier = Modifier.height(8.dp)) // 아티스트 가로 리스트 @@ -98,7 +94,7 @@ fun HomeLineupItem( ) } } - + Spacer(modifier = Modifier.height(16.dp)) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt index 84fd80a7..b7c910f9 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt @@ -19,7 +19,6 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp -import com.daedan.festabook.presentation.common.component.CoilImage import com.daedan.festabook.presentation.common.component.FestabookImage import com.daedan.festabook.presentation.common.component.cardBackground import kotlin.math.absoluteValue @@ -88,14 +87,13 @@ fun HomePosterList( scaleX = scale scaleY = scale this.alpha = alpha - } - .cardBackground(roundedCornerShape = 10.dp) - .clip(RoundedCornerShape(10.dp)) + }.cardBackground(roundedCornerShape = 10.dp) + .clip(RoundedCornerShape(10.dp)), ) { FestabookImage( imageUrl = imageUrl, modifier = Modifier.fillMaxSize(), - enablePopUp = true + enablePopUp = true, ) } } @@ -112,4 +110,4 @@ private fun HomePosterListPreview() { "sample", ), ) -} \ No newline at end of file +} From 85477d6f51637e7679f2493e00cfb1eb2ec37511 Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Wed, 7 Jan 2026 23:37:52 +0900 Subject: [PATCH 093/140] =?UTF-8?q?refactor(Home):=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20Fragment=20=EC=A3=BC=EC=9E=85=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../daedan/festabook/presentation/home/HomeFragment.kt | 8 +++----- .../presentation/home/component/HomePosterList.kt | 6 +++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt index 6fefc37c..24d4a0e4 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt @@ -22,13 +22,11 @@ import dev.zacsweers.metro.binding @ContributesIntoMap(scope = AppScope::class, binding = binding()) @FragmentKey(HomeFragment::class) -@Inject -class HomeFragment( - private val centerItemMotionEnlarger: RecyclerView.OnScrollListener, - override val defaultViewModelProviderFactory: ViewModelProvider.Factory, -) : BaseFragment() { +class HomeFragment @Inject constructor() : BaseFragment() { override val layoutId: Int = R.layout.fragment_home + @Inject + override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory private val viewModel: HomeViewModel by viewModels({ requireActivity() }) override fun onCreateView( diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt index b7c910f9..4e9bda6f 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PageSize import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -21,6 +20,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import com.daedan.festabook.presentation.common.component.FestabookImage import com.daedan.festabook.presentation.common.component.cardBackground +import com.daedan.festabook.presentation.theme.festabookShapes import kotlin.math.absoluteValue @Composable @@ -87,8 +87,8 @@ fun HomePosterList( scaleX = scale scaleY = scale this.alpha = alpha - }.cardBackground(roundedCornerShape = 10.dp) - .clip(RoundedCornerShape(10.dp)), + }.cardBackground(shape = festabookShapes.radius2) + .clip(festabookShapes.radius2), ) { FestabookImage( imageUrl = imageUrl, From 69775f6950c2f678d6d676d0cbf93b9def6b0f4b Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Thu, 8 Jan 2026 00:01:46 +0900 Subject: [PATCH 094/140] =?UTF-8?q?test(HomeViewModelTest):=20LiveData?= =?UTF-8?q?=EB=A5=BC=20StateFlow=EB=A1=9C=20=EC=A0=84=ED=99=98=ED=95=A8?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../festabook/home/HomeViewModelTest.kt | 74 +++++++++++-------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/app/src/test/java/com/daedan/festabook/home/HomeViewModelTest.kt b/app/src/test/java/com/daedan/festabook/home/HomeViewModelTest.kt index d44b7c31..d1a7d1b9 100644 --- a/app/src/test/java/com/daedan/festabook/home/HomeViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/home/HomeViewModelTest.kt @@ -2,18 +2,18 @@ package com.daedan.festabook.home import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.daedan.festabook.domain.repository.FestivalRepository -import com.daedan.festabook.getOrAwaitValue import com.daedan.festabook.presentation.home.HomeViewModel -import com.daedan.festabook.presentation.home.LineUpItemGroupUiModel +import com.daedan.festabook.presentation.home.LineUpItemOfDayUiModel import com.daedan.festabook.presentation.home.LineupUiState import com.daedan.festabook.presentation.home.adapter.FestivalUiState -import com.daedan.festabook.presentation.home.adapter.FestivalUiState.Loading import com.daedan.festabook.presentation.home.toUiModel import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -64,80 +64,94 @@ class HomeViewModelTest { advanceUntilIdle() // then - val actual = homeViewModel.festivalUiState.getOrAwaitValue() - assertThat(actual).isEqualTo(expect) + val actual = homeViewModel.festivalUiState.value + assertThat(actual).isInstanceOf(FestivalUiState.Success::class.java) + assertThat((actual as FestivalUiState.Success).organization).isEqualTo(FAKE_ORGANIZATION) } @Test fun `연예인 정보를 불러올 수 있다`() = runTest { // given - val expect = - LineupUiState.Success( - LineUpItemGroupUiModel( - mapOf( - FAKE_LINEUP[0].performanceAt.toLocalDate() to FAKE_LINEUP.map { it.toUiModel() }, - ), + val expectedLineup = + listOf( + LineUpItemOfDayUiModel( + id = 0, + date = FAKE_LINEUP[0].performanceAt.toLocalDate(), + isDDay = false, + lineupItems = FAKE_LINEUP.map { it.toUiModel() }, ), ) // when - HomeViewModel(festivalRepository) advanceUntilIdle() // then - val actual = homeViewModel.lineupUiState.getOrAwaitValue() - assertThat(actual).isEqualTo(expect) + val actual = homeViewModel.lineupUiState.value + assertThat(actual).isInstanceOf(LineupUiState.Success::class.java) + + val actualItems = (actual as LineupUiState.Success).lineups + assertThat(actualItems) + .usingRecursiveComparison() + .ignoringFields("id") + .isEqualTo(expectedLineup) } @Test fun `축제 정보를 불러오는 동안은 Loading 상태로 전환한다`() = runTest { // given - var wasLoadingState = false - homeViewModel.festivalUiState.observeForever { state -> - if (state == Loading) { - wasLoadingState = true + val results = mutableListOf() + val job = + launch(UnconfinedTestDispatcher()) { + homeViewModel.festivalUiState.collect { results.add(it) } } - } // when homeViewModel.loadFestival() - advanceUntilIdle() // then - assertThat(wasLoadingState).isTrue() + testScheduler.runCurrent() + assertThat(results).contains(FestivalUiState.Loading) + + advanceUntilIdle() + assertThat(results.last()).isInstanceOf(FestivalUiState.Success::class.java) + + job.cancel() } @Test fun `축제 정보를 불러오는 데 실패하면 Error 상태로 전환한다`() = runTest { // given - val exception = Throwable("test") + val exception = Throwable("Network Error") coEvery { festivalRepository.getFestivalInfo() } returns Result.failure(exception) - // when + // when: 정보를 불러옴 homeViewModel.loadFestival() advanceUntilIdle() // then - val expect = FestivalUiState.Error(exception) - val actual = homeViewModel.festivalUiState.getOrAwaitValue() - assertThat(actual).isEqualTo(expect) + val actual = homeViewModel.festivalUiState.value + assertThat(actual).isInstanceOf(FestivalUiState.Error::class.java) } @Test fun `스케줄 이동 이벤트를 발생시킬 수 있다`() = runTest { // given - val expect = Unit + val events = mutableListOf() + val job = + launch(UnconfinedTestDispatcher()) { + homeViewModel.navigateToScheduleEvent.collect { events.add(it) } + } // when homeViewModel.navigateToScheduleClick() - advanceUntilIdle() // then - val actual = homeViewModel.navigateToScheduleEvent.value - assertThat(actual).isEqualTo(expect) + assertThat(events).hasSize(1) + + job.cancel() } } From b2cccb4f0600869b4bb00ab62abe3a2f6d9e9e35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Fri, 9 Jan 2026 23:33:12 +0900 Subject: [PATCH 095/140] =?UTF-8?q?refactor:=20Coil3=20SingletonImageLoade?= =?UTF-8?q?r=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/daedan/festabook/FestaBookApp.kt | 25 ++++++++++++++++++- .../common/component/CoilImage.kt | 6 ----- .../placeMap/placeList/PlaceListFragment.kt | 25 ++++--------------- 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/FestaBookApp.kt b/app/src/main/java/com/daedan/festabook/FestaBookApp.kt index 8675a955..21914fae 100644 --- a/app/src/main/java/com/daedan/festabook/FestaBookApp.kt +++ b/app/src/main/java/com/daedan/festabook/FestaBookApp.kt @@ -2,6 +2,13 @@ package com.daedan.festabook import android.app.Application import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.ContextCompat +import coil3.Image +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.SingletonImageLoader +import coil3.asImage +import coil3.request.crossfade import com.daedan.festabook.data.datasource.local.DeviceLocalDataSource import com.daedan.festabook.data.datasource.local.FcmDataSource import com.daedan.festabook.di.FestaBookAppGraph @@ -17,7 +24,9 @@ import dev.zacsweers.metro.createGraphFactory import timber.log.Timber import java.util.UUID -class FestaBookApp : Application() { +class FestaBookApp : + Application(), + SingletonImageLoader.Factory { val festaBookGraph: FestaBookAppGraph by lazy { createGraphFactory().create(this) } @@ -34,6 +43,12 @@ class FestaBookApp : Application() { @Inject private lateinit var fcmDataSource: FcmDataSource + private val defaultImage: Image? by lazy { + ContextCompat + .getDrawable(this, R.drawable.img_fallback) + ?.asImage() + } + override fun onCreate() { super.onCreate() setGlobalExceptionHandler() @@ -51,6 +66,14 @@ class FestaBookApp : Application() { Timber.w("FestabookApp: onLowMemory 호출됨") } + override fun newImageLoader(context: PlatformContext): ImageLoader = + ImageLoader + .Builder(context) + .crossfade(true) + .fallback(defaultImage) + .error(defaultImage) + .build() + private fun sendUnsentReports() { Firebase.crashlytics.sendUnsentReports() } diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt index 3ccfab10..4ce5b82d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt @@ -6,12 +6,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import coil3.compose.AsyncImage import coil3.request.ImageRequest -import coil3.request.crossfade -import com.daedan.festabook.R import com.daedan.festabook.presentation.common.convertImageUrl @Composable @@ -28,13 +25,10 @@ fun CoilImage( .Builder(LocalContext.current) .apply(builder) .data(url.convertImageUrl()) - .crossfade(true) .build(), contentDescription = contentDescription, contentScale = contentScale, placeholder = ColorPainter(Color.LightGray), - fallback = painterResource(R.drawable.img_fallback), - error = painterResource(R.drawable.img_fallback), modifier = modifier, ) } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt index 779b2773..65552ff5 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt @@ -9,15 +9,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.core.content.ContextCompat import androidx.core.view.isGone import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope -import coil3.ImageLoader -import coil3.asImage +import coil3.imageLoader import coil3.request.ImageRequest import coil3.request.ImageResult import com.daedan.festabook.R @@ -99,10 +97,7 @@ class PlaceListFragment( bottomSheetState = bottomSheetState, isExceedMaxLength = isExceedMaxLength, onPlaceLoadFinish = { places -> - preloadImages( - requireContext(), - places, - ) + preloadImages(requireContext(), places) }, onPlaceLoad = { viewModel.selectedTimeTagFlow.collect { @@ -194,14 +189,8 @@ class PlaceListFragment( places: List, maxSize: Int = 20, ) { - val imageLoader = ImageLoader(context) + val imageLoader = context.imageLoader val deferredList = mutableListOf>() - val defaultImage = - ContextCompat - .getDrawable( - requireContext(), - R.drawable.img_fallback, - )?.asImage() lifecycleScope.launch(Dispatchers.IO) { places @@ -214,18 +203,14 @@ class PlaceListFragment( ImageRequest .Builder(context) .data(place.imageUrl) - .error { - defaultImage - }.fallback { - defaultImage - }.build() + .build() runCatching { withTimeout(2000) { imageLoader.execute(request) } }.onFailure { - imageLoader.shutdown() + Timber.d("preload 실패") }.getOrNull() } deferredList.add(deferred) From 882f58ddcd635e710252814b058f596689315f83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Sat, 10 Jan 2026 00:39:43 +0900 Subject: [PATCH 096/140] =?UTF-8?q?refactor:Context=20=EC=B0=B8=EC=A1=B0?= =?UTF-8?q?=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../festabook/presentation/common/component/CoilImage.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt index 4ce5b82d..35677f34 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt @@ -5,9 +5,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import com.daedan.festabook.presentation.common.convertImageUrl @@ -22,7 +22,7 @@ fun CoilImage( AsyncImage( model = ImageRequest - .Builder(LocalContext.current) + .Builder(LocalPlatformContext.current) .apply(builder) .data(url.convertImageUrl()) .build(), From ccb23b4a629817803ba6e4e907fa10e58b60d875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=EB=AF=BC?= Date: Mon, 12 Jan 2026 23:26:33 +0900 Subject: [PATCH 097/140] =?UTF-8?q?refactor:=20=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20URL=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=EB=A1=9C=EC=A7=81=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/placeMap/placeList/PlaceListFragment.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt index 65552ff5..6a082eb3 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt @@ -24,6 +24,7 @@ import com.daedan.festabook.di.appGraph import com.daedan.festabook.di.fragment.FragmentKey import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.common.OnMenuItemReClickListener +import com.daedan.festabook.presentation.common.convertImageUrl import com.daedan.festabook.presentation.common.showErrorSnackBar import com.daedan.festabook.presentation.placeDetail.PlaceDetailActivity import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel @@ -202,7 +203,7 @@ class PlaceListFragment( val request = ImageRequest .Builder(context) - .data(place.imageUrl) + .data(place.imageUrl.convertImageUrl()) .build() runCatching { From d4a4ff4231a40c637ded5c883727db988128efa9 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:05:29 +0900 Subject: [PATCH 098/140] =?UTF-8?q?[Feat]=20=EC=84=A4=EC=A0=95=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20Compose=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(Setting): 설정 화면 UI 구현 앱 설정 화면(`SettingScreen`)을 Jetpack Compose로 신규 구현했습니다. 사용자가 대학 소식 알림을 설정하고, 앱 정보(버전, 약관, 문의하기)를 확인할 수 있는 UI를 구성했습니다. - **`SettingScreen.kt` 추가:** - `Scaffold`와 공통 `Header` 컴포넌트를 사용하여 전체 화면 레이아웃을 구성했습니다. - `SubscriptionContent` 컴포저블을 통해 대학 이름 표시 및 알림 구독 상태를 제어하는 `Switch` UI를 구현했습니다. - `AppInfoContent` 컴포저블을 통해 앱 버전 정보 표시, 이용 약관 및 문의하기 버튼을 리스트 형태로 구현했습니다. - `HorizontalDivider`를 사용하여 콘텐츠 섹션을 시각적으로 구분하고, `FestabookTheme` 디자인 시스템을 적용했습니다. * refactor(SettingScreen): UI 상태 관리 구조 변경 및 구독 스위치 개선 설정 화면의 데이터를 `FestivalUiState`로 관리하도록 변경하고, 구독 스위치의 활성화 제어 기능을 추가했습니다. 또한 상단 앱바 컴포넌트를 교체하여 UI 일관성을 맞췄습니다. - **`SettingScreen.kt` 수정:** - 컴포저블 파라미터를 `universityName` 문자열 대신 `FestivalUiState`로 변경하여, 상태(`Success`, `Error`)에 따라 콘텐츠 표시 및 에러 처리를 수행하도록 구조를 개편했습니다. - `LaunchedEffect`를 추가하여 `UiState.Error` 발생 시 `onError` 콜백이 호출되도록 로직을 추가했습니다. - 기존 `Header` 컴포넌트를 `FestabookTopAppBar`로 교체하고, 전체 화면 배경색을 `White`로 지정했습니다. - `SubscriptionContent`에 `isSubscribeEnabled` 파라미터를 추가하여 스위치의 `enabled` 속성을 제어하고, 비활성화 상태일 때의 트랙 색상을 정의했습니다. * refactor(Setting): SettingViewModel StateFlow 및 SharedFlow 전환 `SettingViewModel`의 데이터 흐름을 개선하기 위해 기존 `LiveData` 기반의 상태 관리를 Coroutines Flow API로 마이그레이션했습니다. - **`SettingViewModel.kt` 수정:** - UI 상태(`isAllowed`, `isLoading`)를 관리하던 `MutableLiveData`를 `MutableStateFlow`로 교체했습니다. - 일회성 이벤트(`permissionCheckEvent`, `error`, `success`) 처리를 위해 사용하던 `SingleLiveData`를 `MutableSharedFlow`로 변경했습니다. - 데이터 발행 방식을 `setValue`에서 `tryEmit`으로 수정하고, `Boolean?` 타입 체크 로직을 Flow의 `value` 접근 방식으로 간소화했습니다. * refactor(Setting): 설정 화면 Compose 마이그레이션 기존 XML 및 DataBinding 기반의 설정 화면을 Jetpack Compose로 마이그레이션했습니다. - **`SettingFragment.kt` 리팩토링:** - `onCreateView`에서 `ComposeView`를 반환하도록 변경하고, `SettingScreen` 컴포저블을 통해 UI를 렌더링하도록 수정했습니다. - `DataBinding` 및 `View` 관련 로직을 제거하고, `collectAsStateWithLifecycle`을 사용하여 `ViewModel`의 상태를 구독하도록 변경했습니다. - 권한 요청 및 URL 이동 등의 사이드 이펙트를 처리하기 위해 `ObserveAsEvents`를 적용했습니다. - **`ObserveAsEvents.kt` 추가:** - Compose 환경에서 `Flow` 기반의 이벤트를 생명주기에 맞춰 안전하게 수집할 수 있는 유틸리티 함수를 추가했습니다. * test(Setting): SettingViewModelTest 상태 검증 로직 수정 `SettingViewModel` 테스트 코드에서 `getOrAwaitValue` 확장 함수 의존성을 제거하고, `StateFlow` 및 `SharedFlow` 특성에 맞는 검증 로직으로 변경했습니다. - **`SettingViewModelTest.kt` 수정:** - 불필요해진 `getOrAwaitValue` import 구문을 제거했습니다. - `permissionCheckEvent` 검증 시 `value` 대신 `replayCache.first()`를 사용하여 이벤트를 확인하도록 수정했습니다. - `isAllowed` 상태 검증 시 `getOrAwaitValue()` 호출을 `value` 프로퍼티 접근으로 대체했습니다. - 일부 코드의 줄바꿈 포맷팅을 수정했습니다. * refactor(Setting): 설정 화면 컴포저블 분리 및 최적화 `SettingScreen`의 `AppInfoContent`를 `AppVersionInfo`와 `AppInfoButton` 컴포저블로 분리하여 재사용성을 높이고 코드 구조를 개선했습니다. 또한, 불필요한 재계산을 방지하기 위해 `screenWidthDp` 값을 `remember`로 감싸 성능을 최적화했습니다. - **`SettingScreen.kt` 수정:** - **컴포저블 분리:** 기존 `AppInfoContent` 내부의 앱 버전 표시 로직을 `AppVersionInfo` 컴포저블로, 이용약관 및 문의하기 버튼 로직을 `AppInfoButton` 컴포저블로 각각 추출했습니다. - **성능 최적화:** `screenWidthDp` 값을 계산하는 로직을 `remember` 블록으로 감싸, 리컴포지션 시 불필요한 재계산을 방지하도록 수정했습니다. * refactor(Setting): 공통 스위치 컴포넌트(`FestabookSwitch`) 적용 설정 화면(`SettingScreen`)에서 사용되던 `Switch` 컴포저블을 공통 UI 컴포넌트인 `FestabookSwitch`로 교체하여 코드의 일관성과 재사용성을 높였습니다. - **`SettingScreen.kt` 수정:** - 기존 `material3.Switch`를 `FestabookSwitch`로 대체했습니다. - `SwitchDefaults`를 통해 직접 지정하던 색상 관련 로직을 제거하고, 공통 컴포넌트에 위임하여 코드를 간소화했습니다. - 불필요해진 `Switch` 및 `SwitchDefaults` import 구문을 삭제했습니다. * fix(Setting): Null-safe 뷰 접근 로직으로 수정 `SettingFragment`에서 `Snackbar`를 표시할 때 발생할 수 있는 잠재적인 `NullPointerException`을 방지하기 위해, `view!!`를 통한 강제 언래핑 대신 `requireView()`를 사용하도록 수정했습니다. 이 변경으로 인해 뷰가 null일 경우 안전하게 예외를 발생시켜 문제를 조기에 발견할 수 있습니다. - **`SettingFragment.kt` 수정:** - `showNotificationDeniedSnackbar` 호출 시 인자로 `view!!` 대신 `requireView()`를 전달하도록 변경했습니다. * refactor(Setting): replay = 0으로 변경 및 emit으로 이벤트 전달 `SettingViewModel` 내 `SharedFlow`의 `replay` 캐시를 제거하고, 이벤트 발행 방식을 `tryEmit`에서 `emit`으로 변경하여 이벤트 처리의 안정성을 개선했습니다. - **`SettingViewModel.kt` 수정:** - `_permissionCheckEvent`, `_error`, `_success` `MutableSharedFlow` 초기화 시 `replay = 1` 옵션을 제거했습니다. - `tryEmit`을 사용하던 이벤트 발행 로직을 `viewModelScope.launch` 내에서 `emit`을 호출하는 방식으로 변경하여, 구독자가 이벤트를 놓치지 않도록 수정했습니다. (예: `notificationAllowClick`, `saveNotificationId`, `deleteNotificationId`) * fix(Setting): 설정 화면 불필요한 배경색 코드 제거 `SettingScreen`의 `AppInfoContent` 컴포저블에 적용되었던 불필요한 배경색(`Modifier.background`) 코드를 제거했습니다. - **`SettingScreen.kt` 수정:** - `AppInfoContent`에 중복으로 적용되어 있던 `background` Modifier를 삭제하여 코드를 정리했습니다. * test(Setting): 테스트 코드의 Flow 이벤트 및 상태 검증 방식 개선 `SettingViewModelTest`에서 `SharedFlow`로 관리되는 일회성 이벤트를 보다 안정적으로 검증하기 위해 `observeEvent` 확장 함수를 도입하고, 여러 개의 검증을 하나의 그룹으로 묶는 `assertAll`을 적용했습니다. - **`FlowExtension.kt` 추가:** - `TestScope` 내에서 Flow 이벤트를 비동기적으로 수집하고 타임아웃을 적용하여 안정적으로 값을 기다릴 수 있는 `observeEvent`, `observeMultipleEvent` 유틸리티 함수를 추가했습니다. - **`SettingViewModelTest.kt` 수정:** - `permissionCheckEvent` 검증 시 `replayCache.first()`를 직접 사용하는 대신, 새로 추가된 `observeEvent` 함수를 호출하여 이벤트를 기다리도록 변경했습니다. - 여러 `coVerify`와 `assertThat` 호출을 `assertAll` 블록으로 그룹화하여 테스트의 가독성과 원자성을 높였습니다. --- .../common/component/FestabookSwitch.kt | 33 ++ .../common/component/ObserveAsEvents.kt | 27 ++ .../presentation/setting/SettingFragment.kt | 140 ++++----- .../presentation/setting/SettingViewModel.kt | 50 +-- .../setting/component/SettingScreen.kt | 296 ++++++++++++++++++ .../com/daedan/festabook/FlowExtension.kt | 41 +++ .../festabook/setting/SettingViewModelTest.kt | 53 ++-- 7 files changed, 520 insertions(+), 120 deletions(-) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookSwitch.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/common/component/ObserveAsEvents.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/setting/component/SettingScreen.kt create mode 100644 app/src/test/java/com/daedan/festabook/FlowExtension.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookSwitch.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookSwitch.kt new file mode 100644 index 00000000..dd80e51e --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookSwitch.kt @@ -0,0 +1,33 @@ +package com.daedan.festabook.presentation.common.component + +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.daedan.festabook.presentation.theme.FestabookColor + +@Composable +fun FestabookSwitch( + enabled: Boolean, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Switch( + enabled = enabled, + modifier = modifier.wrapContentSize(), + checked = checked, + onCheckedChange = onCheckedChange, + colors = + SwitchDefaults.colors().copy( + checkedBorderColor = Color.Transparent, + uncheckedBorderColor = Color.Transparent, + disabledCheckedTrackColor = FestabookColor.black, + disabledUncheckedTrackColor = FestabookColor.gray200, + checkedTrackColor = FestabookColor.black, + uncheckedTrackColor = FestabookColor.gray200, + ), + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/ObserveAsEvents.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/ObserveAsEvents.kt new file mode 100644 index 00000000..86fffb35 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/ObserveAsEvents.kt @@ -0,0 +1,27 @@ +package com.daedan.festabook.presentation.common.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +// MVI 리팩토링 PR에도 동일한 코드가 사용되어 +// 머지 시 해당 부분 제거하여 충돌을 해결하겠습니다. +@Composable +fun ObserveAsEvents( + flow: Flow, + onEvent: suspend (T) -> Unit, +) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(flow, lifecycleOwner.lifecycle) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + withContext(Dispatchers.Main.immediate) { + flow.collect(onEvent) + } + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt index 783a4a79..9fce8f9d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt @@ -2,13 +2,20 @@ package com.daedan.festabook.presentation.setting import android.content.Intent import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.net.toUri import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.BuildConfig import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentSettingBinding @@ -16,10 +23,12 @@ import com.daedan.festabook.di.fragment.FragmentKey import com.daedan.festabook.presentation.NotificationPermissionManager import com.daedan.festabook.presentation.NotificationPermissionRequester import com.daedan.festabook.presentation.common.BaseFragment +import com.daedan.festabook.presentation.common.component.ObserveAsEvents import com.daedan.festabook.presentation.common.showErrorSnackBar import com.daedan.festabook.presentation.common.showNotificationDeniedSnackbar import com.daedan.festabook.presentation.common.showSnackBar import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.setting.component.SettingScreen import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject @@ -58,7 +67,7 @@ class SettingFragment( onPermissionGranted() } else { Timber.d("Notification permission denied") - showNotificationDeniedSnackbar(binding.root, requireContext()) + showNotificationDeniedSnackbar(requireView(), requireContext()) onPermissionDenied() } } @@ -69,87 +78,60 @@ class SettingFragment( override fun onPermissionDenied() = Unit - override fun onViewCreated( - view: View, + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - setupBindings() - - setupNoticeAllowButtonClickListener() - setupServicePolicyClickListener() - setupContactUsButtonClickListener() - setupObservers() - } - - private fun setupBindings() { - val versionName = BuildConfig.VERSION_NAME - binding.tvSettingAppVersionName.text = versionName - } - - override fun shouldShowPermissionRationale(permission: String): Boolean = shouldShowRequestPermissionRationale(permission) - - private fun setupObservers() { - settingViewModel.permissionCheckEvent.observe(viewLifecycleOwner) { - notificationPermissionManager.requestNotificationPermission( - requireContext(), - ) - } - settingViewModel.isAllowed.observe(viewLifecycleOwner) { - binding.btnNoticeAllow.isChecked = it - } - settingViewModel.success.observe(viewLifecycleOwner) { - requireActivity().showSnackBar(getString(R.string.setting_notice_enabled)) - } - settingViewModel.error.observe(viewLifecycleOwner) { throwable -> - showErrorSnackBar(throwable) - } - settingViewModel.isLoading.observe(viewLifecycleOwner) { loading -> - binding.btnNoticeAllow.isEnabled = !loading - } - -// homeViewModel.festivalUiState.observe(viewLifecycleOwner) { state -> -// when (state) { -// is FestivalUiState.Error -> { -// showErrorSnackBar(state.throwable) -// Timber.w( -// state.throwable, -// "${this::class.simpleName}: ${state.throwable.message}", -// ) -// } -// -// FestivalUiState.Loading -> { -// binding.tvSettingCurrentUniversityNotice.text = "" -// } -// -// is FestivalUiState.Success -> { -// binding.tvSettingCurrentUniversity.text = state.organization.universityName -// } -// } -// } - } - - private fun setupServicePolicyClickListener() { - binding.tvSettingServicePolicy.setOnClickListener { - val intent = Intent(Intent.ACTION_VIEW, POLICY_URL.toUri()) - startActivity(intent) - } - } - - private fun setupContactUsButtonClickListener() { - binding.tvSettingContactUs.setOnClickListener { - val intent = Intent(Intent.ACTION_VIEW, CONTACT_US_URL.toUri()) - startActivity(intent) + ): View = + ComposeView(requireContext()).apply { + super.onCreateView(inflater, container, savedInstanceState) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val festival by homeViewModel.festivalUiState.collectAsStateWithLifecycle() + val isUniversitySubscribed by settingViewModel.isAllowed.collectAsStateWithLifecycle() + val isSubscribedLoading by settingViewModel.isLoading.collectAsStateWithLifecycle() + val context = LocalContext.current + + ObserveAsEvents(flow = settingViewModel.permissionCheckEvent) { + notificationPermissionManager.requestNotificationPermission(context) + } + + ObserveAsEvents(flow = settingViewModel.successFlow) { + requireActivity().showSnackBar(getString(R.string.setting_notice_enabled)) + } + + ObserveAsEvents(flow = settingViewModel.error) { + showErrorSnackBar(it) + } + + SettingScreen( + festivalUiState = festival, + isUniversitySubscribed = isUniversitySubscribed, + appVersion = BuildConfig.VERSION_NAME, + isSubscribeEnabled = !isSubscribedLoading, + onSubscribeClick = { + settingViewModel.notificationAllowClick() + }, + onPolicyClick = { + val intent = Intent(Intent.ACTION_VIEW, POLICY_URL.toUri()) + startActivity(intent) + }, + onContactUsClick = { + val intent = Intent(Intent.ACTION_VIEW, CONTACT_US_URL.toUri()) + startActivity(intent) + }, + onError = { + showErrorSnackBar(it.throwable) + Timber.w( + it.throwable, + "${this::class.simpleName}: ${it.throwable.message}", + ) + }, + ) + } } - } - private fun setupNoticeAllowButtonClickListener() { - binding.btnNoticeAllow.setOnClickListener { - // 기본적으로 클릭했을 때 checked되는 기능 무효화 - binding.btnNoticeAllow.isChecked = !binding.btnNoticeAllow.isChecked - settingViewModel.notificationAllowClick() - } - } + override fun shouldShowPermissionRationale(permission: String): Boolean = shouldShowRequestPermissionRationale(permission) companion object { private const val POLICY_URL: String = diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt index 917ad461..3bf2061f 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt @@ -1,15 +1,20 @@ package com.daedan.festabook.presentation.setting import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey import com.daedan.festabook.domain.repository.FestivalNotificationRepository -import com.daedan.festabook.presentation.common.SingleLiveData import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import timber.log.Timber @@ -19,27 +24,33 @@ import timber.log.Timber class SettingViewModel( private val festivalNotificationRepository: FestivalNotificationRepository, ) : ViewModel() { - private val _permissionCheckEvent: SingleLiveData = SingleLiveData() - val permissionCheckEvent: LiveData get() = _permissionCheckEvent + private val _permissionCheckEvent: MutableSharedFlow = + MutableSharedFlow() + val permissionCheckEvent: SharedFlow = _permissionCheckEvent.asSharedFlow() private val _isAllowed = - MutableLiveData( + MutableStateFlow( festivalNotificationRepository.getFestivalNotificationIsAllow(), ) - val isAllowed: LiveData get() = _isAllowed + val isAllowed: StateFlow = _isAllowed.asStateFlow() - private val _error: SingleLiveData = SingleLiveData() - val error: LiveData get() = _error + private val _error: MutableSharedFlow = + MutableSharedFlow() + val error: SharedFlow = _error.asSharedFlow() - private val _isLoading: MutableLiveData = MutableLiveData(false) - val isLoading: LiveData get() = _isLoading + private val _isLoading: MutableStateFlow = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() - private val _success: SingleLiveData = SingleLiveData() - val success: LiveData get() = _success + private val _success: MutableSharedFlow = + MutableSharedFlow() + val success: LiveData = _success.asLiveData() + val successFlow = _success.asSharedFlow() fun notificationAllowClick() { - if (_isAllowed.value == false) { - _permissionCheckEvent.setValue(Unit) + if (!_isAllowed.value) { + viewModelScope.launch { + _permissionCheckEvent.emit(Unit) + } } else { deleteNotificationId() } @@ -54,21 +65,22 @@ class SettingViewModel( } fun saveNotificationId() { - if (_isLoading.value == true) return + if (_isLoading.value) return _isLoading.value = true // Optimistic UI 적용, 요청 실패 시 원복 saveNotificationIsAllowed(true) updateNotificationIsAllowed(true) - _success.setValue(Unit) viewModelScope.launch { + _success.emit(Unit) + val result = festivalNotificationRepository.saveFestivalNotification() result .onFailure { - _error.setValue(it) + _error.emit(it) saveNotificationIsAllowed(false) updateNotificationIsAllowed(false) Timber.e(it, "${this::class.java.simpleName} NotificationId 저장 실패") @@ -79,7 +91,7 @@ class SettingViewModel( } private fun deleteNotificationId() { - if (_isLoading.value == true) return + if (_isLoading.value) return _isLoading.value = true // Optimistic UI 적용, 요청 실패 시 원복 @@ -92,7 +104,7 @@ class SettingViewModel( result .onFailure { - _error.setValue(it) + _error.emit(it) saveNotificationIsAllowed(true) updateNotificationIsAllowed(true) Timber.e(it, "${this::class.java.simpleName} NotificationId 삭제 실패") diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/component/SettingScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/component/SettingScreen.kt new file mode 100644 index 00000000..202f3120 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/component/SettingScreen.kt @@ -0,0 +1,296 @@ +package com.daedan.festabook.presentation.setting.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.domain.model.Festival +import com.daedan.festabook.domain.model.Organization +import com.daedan.festabook.presentation.common.component.FestabookSwitch +import com.daedan.festabook.presentation.common.component.FestabookTopAppBar +import com.daedan.festabook.presentation.home.adapter.FestivalUiState +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookSpacing +import java.time.LocalDate + +@Composable +fun SettingScreen( + festivalUiState: FestivalUiState, + isUniversitySubscribed: Boolean, + appVersion: String, + isSubscribeEnabled: Boolean, + modifier: Modifier = Modifier, + onSubscribeClick: (Boolean) -> Unit = {}, + onPolicyClick: () -> Unit = {}, + onContactUsClick: () -> Unit = {}, + onError: (FestivalUiState.Error) -> Unit = {}, +) { + val windowInfo = LocalWindowInfo.current + val density = LocalDensity.current + val screenWidthDp = + remember { + with(density) { + windowInfo.containerSize.width.toDp() + } + } + + val currentOnError by rememberUpdatedState(onError) + + LaunchedEffect(festivalUiState) { + when (festivalUiState) { + is FestivalUiState.Error -> currentOnError(festivalUiState) + else -> Unit + } + } + + Scaffold( + topBar = { + FestabookTopAppBar( + title = stringResource(R.string.setting_title), + ) + }, + modifier = modifier, + ) { innerPadding -> + Column( + modifier = + Modifier + .fillMaxSize() + .background(color = FestabookColor.white) + .padding(horizontal = festabookSpacing.paddingScreenGutter) + .padding(innerPadding), + ) { + when (festivalUiState) { + is FestivalUiState.Success -> { + SubscriptionContent( + universityName = festivalUiState.organization.universityName, + isUniversitySubscribed = isUniversitySubscribed, + onSubscribeClick = onSubscribeClick, + isSubscribeEnabled = isSubscribeEnabled, + ) + } + + else -> Unit + } + + HorizontalDivider( + modifier = + Modifier + .requiredWidth(screenWidthDp) + .padding(vertical = 20.dp), + color = FestabookColor.gray100, + thickness = festabookSpacing.paddingBody2, + ) + + AppInfoContent( + appVersion = appVersion, + onPolicyClick = onPolicyClick, + onContactUsClick = onContactUsClick, + ) + } + } +} + +@Composable +private fun SubscriptionContent( + universityName: String, + isUniversitySubscribed: Boolean, + isSubscribeEnabled: Boolean, + onSubscribeClick: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = stringResource(R.string.setting_notice_title), + style = FestabookTypography.bodyMedium, + modifier = Modifier.padding(top = 20.dp), + ) + + Row( + modifier = Modifier.wrapContentSize(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(R.string.setting_current_university_notice), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = FestabookTypography.titleMedium, + modifier = + Modifier.padding( + top = festabookSpacing.paddingBody3, + ), + ) + + Text( + text = universityName, + style = FestabookTypography.bodyMedium, + modifier = Modifier.padding(vertical = festabookSpacing.paddingBody1), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = FestabookColor.gray500, + ) + } + + FestabookSwitch( + enabled = isSubscribeEnabled, + checked = isUniversitySubscribed, + onCheckedChange = onSubscribeClick, + ) + } + } +} + +@Composable +private fun AppInfoContent( + appVersion: String, + onPolicyClick: () -> Unit, + onContactUsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = stringResource(R.string.setting_app_info_title), + modifier = Modifier.padding(vertical = festabookSpacing.paddingBody3), + style = FestabookTypography.bodyMedium, + ) + + AppVersionInfo( + appVersion = appVersion, + ) + + AppInfoButton( + text = stringResource(R.string.setting_service_policy), + onClick = onPolicyClick, + ) + AppInfoButton( + text = stringResource(R.string.setting_contact_us), + onClick = onContactUsClick, + ) + } +} + +@Composable +private fun AppVersionInfo( + appVersion: String, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .fillMaxWidth() + .padding(vertical = festabookSpacing.paddingBody3), + ) { + Text( + text = stringResource(R.string.setting_app_version), + style = FestabookTypography.titleMedium, + ) + + Text( + text = appVersion, + style = FestabookTypography.bodyMedium, + ) + } +} + +@Composable +private fun AppInfoButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val windowInfo = LocalWindowInfo.current + val density = LocalDensity.current + val screenWidthDp = + remember { + with(density) { + windowInfo.containerSize.width.toDp() + } + } + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .requiredWidth(screenWidthDp) + .clickable { + onClick() + }.padding( + horizontal = festabookSpacing.paddingScreenGutter, + vertical = festabookSpacing.paddingBody3, + ), + ) { + Text( + text = text, + style = FestabookTypography.titleMedium, + ) + + Icon( + painter = painterResource(R.drawable.ic_arrow_forward_right), + contentDescription = stringResource(R.string.move), + tint = Color.Unspecified, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun SettingScreenPreview() { + FestabookTheme { + var isSubscribed by remember { mutableStateOf(false) } + SettingScreen( + festivalUiState = + FestivalUiState.Success( + Organization( + id = 1, + universityName = "성균관대학교 인문사회과학철학문학자연캠퍼스 인문사회과학철학문학자연캠퍼스", + festival = + Festival( + festivalName = "성균관대학교 축제축제축제축제축제축제축제축제축제축제축제축제", + festivalImages = listOf(), + startDate = LocalDate.of(2026, 1, 1), + endDate = LocalDate.of(2026, 2, 1), + ), + ), + ), + isUniversitySubscribed = isSubscribed, + onSubscribeClick = { isSubscribed = !isSubscribed }, + appVersion = "v1.0.0", + isSubscribeEnabled = true, + ) + } +} diff --git a/app/src/test/java/com/daedan/festabook/FlowExtension.kt b/app/src/test/java/com/daedan/festabook/FlowExtension.kt new file mode 100644 index 00000000..16f9d7bf --- /dev/null +++ b/app/src/test/java/com/daedan/festabook/FlowExtension.kt @@ -0,0 +1,41 @@ +package com.daedan.festabook + +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.timeout +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +fun TestScope.observeEvent(flow: Flow): Deferred { + val event = + backgroundScope.async { + withTimeout(3000) { + flow.first() + } + } + advanceUntilIdle() + return event +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +fun TestScope.observeMultipleEvent( + flow: Flow, + result: MutableList, +) { + backgroundScope.launch(UnconfinedTestDispatcher()) { + flow + .timeout(3.seconds) + .collect { + result.add(it) + } + } +} diff --git a/app/src/test/java/com/daedan/festabook/setting/SettingViewModelTest.kt b/app/src/test/java/com/daedan/festabook/setting/SettingViewModelTest.kt index f8d28898..fdec631a 100644 --- a/app/src/test/java/com/daedan/festabook/setting/SettingViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/setting/SettingViewModelTest.kt @@ -1,8 +1,7 @@ package com.daedan.festabook.setting -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.daedan.festabook.domain.repository.FestivalNotificationRepository -import com.daedan.festabook.getOrAwaitValue +import com.daedan.festabook.observeEvent import com.daedan.festabook.presentation.setting.SettingViewModel import io.mockk.coEvery import io.mockk.coVerify @@ -17,14 +16,11 @@ import kotlinx.coroutines.test.setMain import org.assertj.core.api.Assertions.assertThat import org.junit.After import org.junit.Before -import org.junit.Rule import org.junit.Test +import org.junit.jupiter.api.Assertions.assertAll @OptIn(ExperimentalCoroutinesApi::class) class SettingViewModelTest { - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - private val testDispatcher = StandardTestDispatcher() private lateinit var settingViewModel: SettingViewModel @@ -48,16 +44,17 @@ class SettingViewModelTest { runTest { // given coEvery { festivalNotificationRepository.getFestivalNotificationIsAllow() } returns false - val expected = Unit + settingViewModel = SettingViewModel(festivalNotificationRepository) // 먼저 생성 + val event = observeEvent(settingViewModel.permissionCheckEvent) // when - settingViewModel = SettingViewModel(festivalNotificationRepository) settingViewModel.notificationAllowClick() advanceUntilIdle() // then - val actual = settingViewModel.permissionCheckEvent.value - assertThat(actual).isEqualTo(expected) + val actual = event.await() + advanceUntilIdle() + assertThat(actual).isEqualTo(Unit) } @Test @@ -72,10 +69,12 @@ class SettingViewModelTest { advanceUntilIdle() // then - val result = settingViewModel.isAllowed.getOrAwaitValue() - coVerify { festivalNotificationRepository.setFestivalNotificationIsAllow(false) } - coVerify { festivalNotificationRepository.deleteFestivalNotification() } - assertThat(result).isFalse() + val result = settingViewModel.isAllowed.value + assertAll( + { coVerify { festivalNotificationRepository.setFestivalNotificationIsAllow(false) } }, + { coVerify { festivalNotificationRepository.deleteFestivalNotification() } }, + { assertThat(result).isFalse() }, + ) } @Test @@ -83,7 +82,10 @@ class SettingViewModelTest { runTest { // given coEvery { festivalNotificationRepository.getFestivalNotificationIsAllow() } returns true - coEvery { festivalNotificationRepository.deleteFestivalNotification() } returns Result.failure(Throwable()) + coEvery { festivalNotificationRepository.deleteFestivalNotification() } returns + Result.failure( + Throwable(), + ) // when settingViewModel = SettingViewModel(festivalNotificationRepository) @@ -91,24 +93,31 @@ class SettingViewModelTest { advanceUntilIdle() // then - val result = settingViewModel.isAllowed.getOrAwaitValue() - coVerify { festivalNotificationRepository.setFestivalNotificationIsAllow(true) } - assertThat(result).isTrue() + val result = settingViewModel.isAllowed.value + assertAll( + { coVerify { festivalNotificationRepository.setFestivalNotificationIsAllow(true) } }, + { assertThat(result).isTrue() }, + ) } @Test fun `알림을 허용했을 때 서버에 알림 정보 삭제에 실패하면 이전 상태로 원복한다`() = runTest { // given - coEvery { festivalNotificationRepository.saveFestivalNotification() } returns Result.failure(Throwable()) + coEvery { festivalNotificationRepository.saveFestivalNotification() } returns + Result.failure( + Throwable(), + ) // when settingViewModel.saveNotificationId() advanceUntilIdle() // then - val result = settingViewModel.isAllowed.getOrAwaitValue() - coVerify { festivalNotificationRepository.setFestivalNotificationIsAllow(false) } - assertThat(result).isFalse() + val result = settingViewModel.isAllowed.value + assertAll( + { coVerify { festivalNotificationRepository.setFestivalNotificationIsAllow(false) } }, + { assertThat(result).isFalse() }, + ) } } From a3ec48f0e4f6e4087ea9f266b5888af1cee936db Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:29:17 +0900 Subject: [PATCH 099/140] =?UTF-8?q?[Feat]=20PlaceMap=EC=9D=98=20=ED=95=98?= =?UTF-8?q?=EC=9C=84=20Fragment=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20MVI=20?= =?UTF-8?q?=EC=95=84=ED=82=A4=ED=85=8D=EC=B3=90=20=EC=A0=81=EC=9A=A9=20(#2?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(PlaceMap): 뒤로 가기 처리 로직 Compose BackHandler로 마이그레이션 기존 `Fragment`에서 `OnBackPressedCallback`을 통해 처리하던 장소 상세 미리보기(`PlaceDetailPreview`) 화면의 뒤로 가기 로직을 Compose의 `BackHandler`로 마이그레이션했습니다. 이를 통해 View 시스템에 대한 의존성을 줄이고 Compose 내부에서 상태에 따라 뒤로 가기 이벤트를 더 직관적으로 제어하도록 개선했습니다. - **`PlaceDetailPreviewFragment.kt` 리팩토링:** - `OnBackPressedCallback` 객체 생성 및 등록, 생명주기 관련 코드를 모두 제거했습니다. - `LaunchedEffect`를 통해 `backPressedCallback`을 활성화하던 로직을 제거했습니다. - `PlaceDetailPreviewScreen` 컴포저블에 `onBackPress` 콜백을 추가하여, 뒤로 가기 이벤트 발생 시 `viewModel.unselectPlace()`를 호출하도록 연결했습니다. - **`PlaceDetailPreviewScreen.kt` 수정:** - `BackHandler`를 도입하여 `visible` 상태가 `true`일 때만 뒤로 가기 이벤트를 가로채고 `onBackPress`를 실행하도록 구현했습니다. - 더 이상 불필요해진 `onEmpty` 파라미터를 제거하고 `onBackPress` 파라미터를 추가했습니다. * refactor(PlaceMap): UI 상태 관리 클래스 일반화 및 구조 개선 특정 모델(`PlaceDetailUiModel`)에 종속적이던 `SelectedPlaceUiState`를 제거하고, 제네릭을 지원하는 `PlaceUiState`로 대체하여 상태 관리 클래스의 범용성을 확보했습니다. 아울러 일부 컴포넌트의 패키지 위치를 정리했습니다. - **`PlaceUiState.kt` 도입:** - 기존 `SelectedPlaceUiState.kt`를 삭제하고, `Loading`, `Empty`, `Success`, `Error` 상태를 가지는 제네릭 실드 인터페이스 `PlaceUiState`를 새로 정의했습니다. - 기존 `Success` 상태에 포함되어 있던 `isSecondary` 로직을 `PlaceUiState.Success`의 확장 프로퍼티로 분리하여 구현했습니다. - **ViewModel 및 UI 로직 수정:** - `PlaceMapViewModel`에서 관리하는 `selectedPlace`의 타입을 `SelectedPlaceUiState`에서 `PlaceUiState`로 변경했습니다. - 이에 따라 `PlaceMapFragment`, `PlaceDetailPreviewFragment`, `PlaceDetailPreviewScreen` 등 관련 UI 컴포넌트에서 상태를 처리하는 분기문과 데이터 참조 코드를 수정했습니다. - **`TimeTagMenu.kt` 패키지 이동:** - `TimeTagMenu` 컴포저블의 위치를 `timeTagSpinner/component` 패키지에서 `placeMap/component`로 이동하여 구조를 단순화했습니다. - **테스트 코드 수정:** - `PlaceMapViewModelTest`의 검증 로직을 `PlaceUiState`를 사용하도록 변경했습니다. * refactor(PlaceMap): TimeTag 상태 관리 StateFlow 및 UiState로 마이그레이션 `PlaceMapViewModel`에서 관리하던 시간 태그(`TimeTag`) 관련 데이터를 `LiveData` 및 단순 리스트에서 `StateFlow`로 변경했습니다. 이를 통해 UI 계층에서 데이터의 로딩, 성공, 빈 상태를 일관되게 처리하도록 개선했습니다. - **`PlaceMapViewModel.kt` 리팩토링:** - `timeTags`와 `selectedTimeTag`를 `StateFlow` 타입으로 변환했습니다. - 기존에 `LiveData`를 Flow로 변환하여 사용하던 임시 프로퍼티 `selectedTimeTagFlow`를 제거했습니다. - 데이터 로드 성공 및 실패 시 `PlaceUiState.Success` 또는 `PlaceUiState.Empty`를 방출하도록 로직을 수정했습니다. - **Fragment 및 UI 로직 수정:** - **`PlaceMapFragment.kt`, `PlaceListFragment.kt`**: `LiveData` 관찰 코드를 `lifecycleScope` 내에서 `StateFlow`를 수집(`collect`)하는 방식으로 변경했습니다. `PlaceUiState` 상태에 따라 지도 마커를 필터링하거나 장소 목록을 갱신하도록 분기 처리를 추가했습니다. - **`PlaceDetailPreviewFragment.kt`**: 상세 미리보기 클릭 시, 선택된 시간 태그가 유효한 상태(`Success`)인지 확인하는 가드 절을 추가하고, 로깅 시 안전하게 데이터를 참조하도록 수정했습니다. - **Compose 컴포넌트 수정:** - **`TimeTagMenu.kt`**: `PlaceUiState`를 인자로 받아 상태가 `Success`일 때만 메뉴를 표시하도록 래퍼 컴포저블을 구현하고, 실제 렌더링 로직은 `TimeTagContent`로 분리했습니다. - **`PlaceMapScreen.kt`**: 변경된 ViewModel 상태 구조에 맞춰 파라미터 타입을 `PlaceUiState`로 수정하고 하위 컴포넌트에 전달하도록 변경했습니다. * refactor(PlaceMap): ViewModel LiveData -> Flow 마이그레이션 `PlaceMapViewModel`과 `PlaceListViewModel`에서 사용하던 `LiveData`와 `SingleLiveData`를 모두 제거하고, `StateFlow`와 `SharedFlow`로 전면 교체했습니다. 이를 통해 불필요한 Flow 변환 코드를 제거하고 상태 관리 방식을 통일했습니다. - **`PlaceMapViewModel.kt` 수정:** - UI 상태(`initialMapSetting`, `placeGeographies`, `selectedPlace`, `isExceededMaxLength`, `selectedCategories`)를 `LiveData`에서 `StateFlow`로 변경했습니다. - 일회성 이벤트(`navigateToDetail`, `backToInitialPositionClicked`, `onMapViewClick`)를 `SingleLiveData`에서 `SharedFlow`로 변경하고 `tryEmit`을 사용하여 이벤트를 발행하도록 수정했습니다. - 기존에 `LiveData`를 `Flow`로 변환하여 제공하던 중복 프로퍼티들(`selectedPlaceFlow`, `isExceededMaxLengthFlow`, `onMapViewClickFlow`)을 제거했습니다. - 메뉴 아이템 재클릭 이벤트를 처리하기 위한 `onMenuItemReClick` 함수와 `SharedFlow`를 추가했습니다. - **`PlaceListViewModel.kt` 수정:** - 장소 목록 상태(`places`)를 `LiveData`에서 `StateFlow`로 변경했습니다. - 기존에 `places`를 변환하여 노출하던 `placesFlow`를 제거하고, `places` 프로퍼티가 직접 `StateFlow`를 반환하도록 수정했습니다. * refactor(PlaceMap): Fragment 트랜잭션 제거 및 Compose UI로 전면 전환 `PlaceMapFragment` 내부에서 `ChildFragmentManager`를 통해 관리하던 하위 Fragment들(`PlaceListFragment`, `PlaceCategoryFragment`, `PlaceDetailPreviewFragment` 등)을 제거하고, 순수 Jetpack Compose 기반의 `PlaceMapScreen`으로 통합하여 구조를 단순화했습니다. - **`PlaceMapFragment.kt` 리팩토링:** - 복잡한 Fragment 트랜잭션 로직을 제거하고, `ComposeView`에서 `PlaceMapScreen`을 통해 전체 UI를 구성하도록 변경했습니다. - `PlaceMapViewModel`과 `PlaceListViewModel`의 데이터를 `collectAsStateWithLifecycle`로 구독하고, `LaunchedEffect`를 사용하여 `MapManager` 초기화, 마커 필터링, 로깅, 네비게이션 등의 사이드 이펙트를 처리하도록 재구현했습니다. - 이미지 프리로딩(`preloadImages`) 로직을 Fragment 내부로 이동시켰습니다. - **`PlaceMapScreen.kt` 및 컴포넌트 재구성:** - `PlaceCategoryScreen`, `PlaceListScreen`, `PlaceDetailPreviewScreen` 등 개별 컴포저블을 조합하여 하나의 화면을 구성하도록 구조를 변경했습니다. - `NaverMapContent`가 `NaverMap` 인스턴스를 상위로 전달하도록 수정하여, `PlaceMapFragment` 레벨에서 지도 객체를 제어할 수 있게 개선했습니다. - 장소 상세 미리보기(`Preview`) 노출 여부에 따라 `PlaceListScreen`의 투명도를 조절하는 UI 로직을 추가했습니다. - **`PlaceListScreen.kt` 수정:** - BottomSheet 상태(`bottomSheetState`)에 따라 현재 위치 버튼 등 지도 컨트롤 UI의 노출 여부를 제어하는 로직을 추가했습니다. - **기타 변경사항:** - `PlaceDetailPreviewSecondaryScreen`에 `BackHandler`를 추가하여 뒤로가기 동작을 Compose 내에서 처리하도록 했습니다. - `PlaceMapViewModel`의 프로퍼티 명 변경(`selectedPlaceFlow` -> `selectedPlace`)을 반영했습니다. * refactor(PlaceMap): View 시스템 코드 제거 및 패키지 구조 통합 장소 지도(`PlaceMap`) 화면의 Compose 마이그레이션이 완료됨에 따라, 더 이상 사용하지 않는 기존 Fragment 및 XML 레이아웃 코드를 삭제했습니다. 또한, 기능별로 분산되어 있던 Compose 컴포넌트와 관련 클래스들을 `placeMap` 패키지 하위로 통합하여 구조를 단순화했습니다. - **Legacy 코드 삭제:** - `PlaceListFragment`, `PlaceCategoryFragment`, `PlaceDetailPreviewFragment` 등 기존 View 기반의 Fragment 클래스들을 삭제했습니다. - `fragment_place_list.xml`, `item_place_list.xml` 등 관련 XML 레이아웃 리소스와 바인딩 코드를 제거했습니다. - **패키지 구조 재편:** - `placeList`, `placeCategory`, `placeDetailPreview` 등 하위 패키지에 흩어져 있던 컴포저블(`PlaceListScreen`, `PlaceCategoryScreen` 등)과 `PlaceListViewModel`을 `presentation.placeMap` 및 `presentation.placeMap.component` 패키지로 이동하여 접근성을 높였습니다. - `PlaceMapFragment` 및 `PlaceMapScreen`에서 변경된 패키지 경로를 반영하도록 import 구문을 수정했습니다. - **`CategoryView` 이동:** - 장소 상세 화면(`PlaceDetailActivity`)에서 여전히 사용되는 커스텀 뷰 `CategoryView`를 `presentation.placeDetail` 패키지로 이동하고, `activity_place_detail.xml`에서 해당 뷰를 참조하도록 수정했습니다. * refactor(PlaceMap): MVI 아키텍처 도입 및 상태 관리 리팩토링 지도 화면(`PlaceMap`)에 MVI(Model-View-Intent) 패턴을 적용하여 단방향 데이터 흐름(UDF) 구조로 리팩토링했습니다. 개별적으로 관리되던 상태들을 하나의 UI State로 통합하고, 사용자 상호작용과 일회성 이벤트를 명확히 정의하여 코드의 유지보수성을 높였습니다. - **MVI 아키텍처 구성요소 추가:** - **`PlaceMapAction`:** 지도 준비, 태그 클릭, 마커 클릭 등 사용자의 의도(Intent)를 정의하는 Sealed Interface를 추가했습니다. - **`PlaceMapEvent`:** 스낵바 표시, 화면 이동, 마커 초기화 등 일회성 부수 효과(Side Effect)를 정의하는 Sealed Interface를 추가했습니다. - **`PlaceMapUiState`:** 기존의 분산된 `StateFlow`들을 통합 관리하기 위한 단일 상태 데이터 클래스를 도입했습니다. - **`PlaceMapViewModel.kt` 리팩토링:** - `onPlaceMapAction` 메서드를 통해 모든 액션을 처리하도록 로직을 중앙화했습니다. - 개별 `StateFlow` 변수들을 `_uiState`(`PlaceMapUiState`)와 `_uiEvent`(`Channel`)로 대체했습니다. - 로깅 및 데이터 로드 로직을 `Action` 처리 블록 내부로 이동시켰습니다. - **UI 상태 클래스 재정의:** - 기존 `PlaceUiState`를 삭제하고, 범용적인 로딩 상태 관리를 위한 `LoadState`로 대체했습니다. - `PlaceListUiState` 파일명을 `ListLoadState`로 변경하고 관련 참조를 수정했습니다. - **View 레이어 수정 (`PlaceMapFragment`, `PlaceMapScreen`):** - `PlaceMapScreen`의 파라미터를 개별 상태 대신 `uiState`와 `onAction` 콜백으로 단순화했습니다. - Fragment에서 `ObserveAsEvents` 유틸리티를 사용하여 `SharedFlow`/`Channel` 이벤트를 생명주기에 맞춰 안전하게 수집하도록 변경했습니다. - Compose View 내부에서 `NaverMap`의 초기화를 돕는 `rememberNaverMap` 및 `await` 확장 함수를 추가했습니다. - **테스트 코드 수정:** - 변경된 상태 클래스(`LoadState`, `ListLoadState`)에 맞춰 `PlaceListViewModelTest` 및 `PlaceMapViewModelTest`의 검증 로직을 수정했습니다. * refactor(PlaceMap): 지도 제어 이벤트 분리 및 핸들러 리팩토링 `PlaceMapViewModel`에서 통합 관리되던 단일 이벤트 채널을 UI 관련 이벤트와 지도 제어 이벤트로 분리하고, 비대해진 `PlaceMapFragment`의 로직을 전용 핸들러와 델리게이트로 위임하여 구조를 개선했습니다. - **이벤트 및 ViewModel 분리:** - 기존 `PlaceMapEvent`에서 지도 조작 관련 이벤트를 신규 인터페이스인 `MapControlEvent`(`InitMap`, `SetMarkerByTimeTag` 등)로 분리했습니다. - `PlaceMapViewModel`이 UI 사이드 이펙트를 위한 `placeMapUiEvent`와 지도 제어를 위한 `mapControlUiEvent` 두 개의 Flow를 노출하도록 수정했습니다. - **핸들러(Handler) 클래스 도입:** - `PlaceMapFragment` 내부의 방대한 `when` 분기문을 제거하고, 역할을 분리한 전용 핸들러 클래스를 구현했습니다. - **`MapControlEventHandler`**: `NaverMap` 및 `MapManager`를 직접 조작하는 지도 로직을 담당합니다. - **`PlaceMapEventHandler`**: 스낵바 표시, 화면 이동(Navigation), 이미지 프리로드 등 Android UI 관련 로직을 담당합니다. - **상태 관리 위임(Delegate) 적용:** - `NaverMap` 객체와 `MapManager`의 상태 관리를 위해 `MapDelegate`와 `MapManagerDelegate`를 새로 추가했습니다. - 기존 `MapState` 클래스를 삭제하고 `MapDelegate`로 대체하였으며, `PlaceMapScreen` 및 `NaverMapContent`가 이를 참조하도록 변경했습니다. * fix(MapFilterManager): 타임태그 없는 경우 마커 필터링 오류 수정 타임태그가 선택되지 않은(`EMTPY_TIME_TAG_ID`) 상태에서 필터를 초기화할 때, 모든 마커가 숨겨지는 문제를 수정했습니다. - **`MapFilterManagerImpl.kt` 수정:** - `clearFilter()` 메서드에서 `selectedTimeTagId`가 비어있는 경우, 타임태그 일치 여부를 검사하지 않고 모든 마커를 보이도록(`isVisible = true`) 로직을 개선했습니다. * refactor(PlaceMap): 비대해진 ViewModel 3개의 Handler로 분리 비대해진 `PlaceMapViewModel`의 책임을 분산하기 위해 Action 처리 로직을 기능별 핸들러로 위임하고, 관련 패키지 구조를 `intent` 중심으로 재구성했습니다. - **Action 및 Handler 세분화:** - 단일 `PlaceMapAction`을 `SelectAction`(장소 선택 및 상호작용), `FilterAction`(필터링 및 데이터 로드), `MapEventAction`(지도 제어 이벤트)으로 분리했습니다. - 각 Action을 전담하여 처리하는 `SelectActionHandler`, `FilterActionHandler`, `MapEventActionHandler`를 도입하여 ViewModel의 로직을 분리했습니다. - **패키지 구조 재편:** - `viewmodel` 패키지에 혼재되어 있던 클래스들을 `intent` 패키지 하위의 `action`, `event`, `state`로 이동하여 구조를 명확히 했습니다. - `PlaceMapUiState`, `LoadState`, `ListLoadState` 등의 상태 클래스와 `MapControlEvent` 등의 이벤트 클래스가 해당 패키지로 이동되었습니다. - **UI 계층 수정:** - `PlaceMapScreen` 및 `PlaceMapFragment`에서 ViewModel로 이벤트를 전달할 때, 기존의 포괄적인 `PlaceMapAction` 대신 구체화된 Action(`SelectAction.OnPlaceClick` 등)을 사용하도록 호출부를 수정했습니다. * refactor(PlaceMap): ActionHandler 의존성 주입 리팩토링 (Metro) `PlaceMapViewModel` 내부에서 수동으로 생성하던 ActionHandler 객체들을 Metro 프레임워크를 사용한 의존성 주입 방식으로 변경했습니다. 이를 위해 별도의 핸들러 그래프를 정의하고, 뷰모델의 런타임 의존성을 그래프를 통해 주입받도록 구조를 개선했습니다. - **`PlaceMapViewModel.kt` 수정:** - `MapEventActionHandler`, `FilterActionHandler`, `SelectActionHandler`를 직접 인스턴스화하던 코드를 제거했습니다. - `PlaceMapHandlerGraph.Factory`를 이용해 `handlerGraph`를 생성하고, 필요한 `Channel`, `StateFlow`, Scope 등을 주입했습니다. - `onPlaceMapAction`에서 개별 핸들러 변수 대신 `handlerGraph`를 통해 핸들러를 호출하도록 변경했습니다. - **ActionHandler 클래스 수정:** - `SelectActionHandler`, `MapEventActionHandler`, `FilterActionHandler`에 `@Inject` 어노테이션을 추가했습니다. - `FilterActionHandler`의 생성자 파라미터에 `@CachedPlaces`, `@CachedPlaceByTimeTag` 한정자(Qualifier)를 적용하여 의존성을 명확히 했습니다. - **DI 구성요소 추가 (`di/placeMapHandler`):** - `PlaceMapHandlerGraph`: 핸들러 인스턴스를 제공하고, 런타임 의존성을 주입받는 팩토리 인터페이스를 정의했습니다. - `CachedPlaces`, `CachedPlaceByTimeTag`: 동일한 타입의 `StateFlow` 의존성을 구분하기 위한 Qualifier 어노테이션을 추가했습니다. * refactor(PlaceMap): Intent Handler 구조 표준화 및 패키지 정리 `PlaceMap` 화면의 사용자 액션(Action)과 이벤트(Event)를 처리하는 핸들러 클래스들을 `intent.handler` 패키지로 통합하고, 공통 인터페이스를 도입하여 구조를 표준화했습니다. - **인터페이스 정의:** - `ActionHandler` 및 `EventHandler` 인터페이스를 새로 정의하여 핸들러의 구현 규약을 통일했습니다. - **패키지 이동 및 구현 수정:** - 기존 `intent.action`과 `intent.event` 패키지에 있던 `SelectActionHandler`, `MapEventActionHandler`, `FilterActionHandler`, `PlaceMapEventHandler`, `MapControlEventHandler`를 `intent.handler` 패키지로 이동했습니다. - 각 핸들러 클래스가 `ActionHandler` 또는 `EventHandler` 인터페이스를 구현(`override`)하도록 로직을 수정했습니다. - **의존성 주입(DI) 설정:** - `SelectActionHandler`, `MapEventActionHandler`, `FilterActionHandler`에 `@ContributesBinding(PlaceMapViewModelScope::class)`을 추가하여 의존성 주입 설정을 보강했습니다. * refactor(PlaceMap): 하드코딩된 UI 수치를 Theme Spacing으로 추출 및 적용 지도(PlaceMap) 기능 내 컴포넌트들에 산재되어 있던 하드코딩된 치수(dp)와 비율 값들을 `FestabookSpacing`의 확장 프로퍼티로 정의하고 적용하여 코드의 유지보수성을 높였습니다. - **`Styles.kt` 추가:** - `FestabookSpacing`의 확장 프로퍼티를 담은 `Styles.kt`를 생성했습니다. - `previewVerticalPadding`, `placeListImageSize`, `placeListBottomSheetPeekHeight` 등 특정 컴포넌트에서 사용되는 UI 상수를 한곳에 정의했습니다. - **`PlaceListScreen.kt` 수정:** - `PlaceListBottomSheet`의 `peekHeight`와 `halfExpandedRatio`, 그리고 리스트 아이템의 이미지 크기를 새로 정의한 상수로 대체했습니다. - **`PlaceDetailPreviewScreen.kt` 수정:** - 화면의 수직 패딩과 장소 이미지 크기에 하드코딩된 값 대신 `festabookSpacing` 확장 프로퍼티를 적용했습니다. - **기타 컴포넌트 수정:** - `PlaceCategoryLabel.kt`: 텍스트 패딩 값을 `festabookSpacing.paddingBody1`으로 변경했습니다. - `TimeTagMenu.kt`: 메뉴의 너비 값을 `timeTagButtonWidth` 상수로 변경했습니다. * test(PlaceMap): ActionHandler 테스트 작성 `PlaceMapViewModel`의 비대해진 로직을 분리하기 위해 ActionHandler 패턴을 도입하고, 상태 관리 방식을 `StateFlow`로 전면 개편했습니다. - **`PlaceMapViewModel.kt` 리팩토링:** - `PlaceMapHandlerGraph.Factory`를 주입받아 로직을 `SelectActionHandler`, `FilterActionHandler`, `MapEventActionHandler`로 위임하도록 구조를 변경했습니다. - 기존 `LiveData` 기반의 상태 관리를 `StateFlow`(`uiState`)와 `Channel`(`mapControlUiEvent`, `placeMapUiEvent`)로 전환했습니다. - **ActionHandler 테스트 코드 추가:** - `SelectActionHandlerTest`: 플레이스 선택/해제, 상세 정보 조회, 타임태그 클릭 등의 로직 테스트를 작성했습니다. - `FilterActionHandlerTest`: 카테고리 및 타임태그 기반의 장소 필터링 로직 테스트를 작성했습니다. - `MapEventActionHandlerTest`: 지도 초기화, 초기 위치 복귀, 드래그 이벤트 처리 로직 테스트를 작성했습니다. - **테스트 및 유틸리티 정비:** - `PlaceMapViewModelTest`: 핸들러 위임 동작 및 `StateFlow` 상태 변화를 검증하도록 테스트를 수정했습니다. - `PlaceListViewModelTest`를 삭제하고, 관련 픽스처 및 테스트 파일들을 `placeList`에서 `placeMap` 패키지로 이동했습니다. - Flow 기반 테스트를 지원하기 위한 `observeEvent` 확장 함수(`FlowExtensions.kt`)를 추가했습니다. * fix(FestaBookApp): 전역 예외 처리 핸들러 활성화 주석 처리되어 있던 `setGlobalExceptionHandler()` 메서드 호출을 복구하여, 앱 실행 중 발생하는 예외를 전역적으로 처리하는 로직을 다시 활성화했습니다. - **`FestaBookApp.kt` 수정:** - `onCreate` 메서드 내에서 주석으로 막혀있던 `setGlobalExceptionHandler()` 호출부의 주석을 해제했습니다. * refactor(PlaceMap): 컴포저블 사이드 이펙트 when절 -> 상단으로 분리 UI 컴포지션 단계에서 발생하던 부수 효과(Side Effect)를 `LaunchedEffect`로 이동시켜 안정성을 높이고, 불필요한 에러 및 빈 상태 콜백을 제거하여 코드를 간소화했습니다. - **`PlaceListScreen.kt` 리팩토링:** - UI 렌더링 로직(`when`) 내에서 직접 호출되던 `onPlaceLoadFinish`와 `onPlaceLoad` 콜백을 `LaunchedEffect` 블록으로 이동했습니다. 이를 통해 상태 변화(`placesUiState`)에 따라 부수 효과가 안전하게 실행되도록 개선했습니다. - 사용하지 않는 `onError` 파라미터와 관련 호출 코드를 삭제했습니다. - **장소 상세 미리보기 화면 수정 (`PlaceDetailPreviewScreen`, `PlaceDetailPreviewSecondaryScreen`):** - `LoadState.Error` 및 `LoadState.Empty` 상태를 처리하기 위해 존재했던 `onError`, `onEmpty` 콜백 파라미터를 제거했습니다. - `Success` 상태가 아닐 경우 별도의 처리 없이 빈 화면을 유지하도록 `when` 분기문을 간소화했습니다. * refactor(PlaceMap): MapControlEventHandler uiState에 getter 적용 * test(PlaceMap): Flow 테스트 유틸리티 개선 및 에러 핸들링 테스트 추가 Flow 기반의 단위 테스트 편의성을 위해 유틸리티 함수를 개선하고, `PlaceMapViewModel`의 데이터 로딩 실패 시나리오에 대한 테스트 케이스를 추가했습니다. - **`FlowExtensions.kt` 수정:** - `observeEvent`에 타임아웃(`3.seconds`)을 적용하여 테스트가 무한 대기하는 현상을 방지했습니다. - Flow에서 발생하는 여러 개의 이벤트를 리스트로 수집하여 검증할 수 있는 `observeMultipleEvent` 확장 함수를 추가했습니다. - **`PlaceMapViewModelTest.kt` 테스트 추가:** - 장소 데이터(`getPlaces`)나 지리 정보(`getPlaceGeographies`) 로딩 실패 시, `PlaceMapEvent.ShowErrorSnackBar` 이벤트가 정상적으로 발행되는지 확인하는 테스트 케이스 2종을 추가했습니다. - **핸들러 테스트 리팩토링:** - `FilterActionHandlerTest.kt` 및 `MapEventActionHandlerTest.kt`에서 수동으로 코루틴을 실행하여 이벤트를 수집하던 로직을 `observeMultipleEvent`로 대체하여 가독성을 높였습니다. - `FilterActionHandlerTest`에서 필터 카테고리 클릭 시 `UnselectMarker` 이벤트와 `FilterMapByCategory` 이벤트가 순서대로 발생하는지 `containsExactly`를 통해 명확히 검증하도록 수정했습니다. * refactor(PlaceMap): ListLoadState 구조 개선 및 마커 선택 동시성 문제시 일관성 보장 `ListLoadState`의 제네릭 타입을 공변적으로 변경하고, `Loading` 및 `Error` 상태를 단순화하여 상태 관리 구조를 개선했습니다. 또한, 장소 선택 시 마커 이벤트에 전달되는 데이터의 일관성을 확보했습니다. - **`ListLoadState.kt` 리팩토링:** - `ListLoadState` 인터페이스를 공변(`out T`)으로 변경했습니다. - `Loading`을 `class`에서 `data object`로 변경하여 싱글톤으로 관리되도록 하고, 불필요한 제네릭 타입을 `Nothing`으로 처리했습니다. - `Error` 상태 또한 제네릭 타입 `T`를 제거하고 `ListLoadState`을 구현하도록 수정했습니다. - **`PlaceMapUiState.kt` 수정:** - `places` 필드의 초기값을 변경된 구조에 맞춰 `ListLoadState.Loading()` 생성자 호출에서 `ListLoadState.Loading` 객체 참조로 수정했습니다. - **`SelectActionHandler.kt` 수정:** - 장소 상세 정보 로드 성공 시, 새로운 `LoadState.Success` 객체를 변수에 할당하여 상태 업데이트와 `MapControlEvent.SelectMarker` 이벤트 전송에 동일한 객체를 사용하도록 변경했습니다. 이를 통해 `uiState.value` 참조 시점 차이로 인한 데이터 불일치 문제를 방지했습니다. * refactor(PlaceMap): await 확장 함수에 타임아웃 처리 추가 `PlaceMapUiState`의 특정 상태 변화를 기다리는 `await` 확장 함수에 타임아웃 로직을 도입하여, 상태가 변경되지 않을 경우 무한 대기하는 문제를 방지하도록 개선했습니다. - **`StateExt.kt` 수정:** - `StateFlow.await` 함수에 `timeout` 파라미터(기본값 3초)를 추가했습니다. - 내부 로직을 `withTimeout` 블록으로 감싸, 지정된 시간 내에 조건에 맞는 상태가 수집되지 않으면 타임아웃이 발생하도록 변경했습니다. * fix(PlaceMap): 이벤트 채널 설정 수정 및 테스트 리소스 해제 로직 추가 `PlaceMapViewModel`의 이벤트 채널 생성 로직을 수정하고, 단위 테스트에서 코루틴 디스패처가 올바르게 해제되도록 개선했습니다. 또한 테스트 픽스처의 파일명을 적절하게 변경했습니다. - **`PlaceMapViewModel.kt` 수정:** - `_placeMapUiEvent`와 `_mapControlUiEvent` 채널 생성 시 설정되어 있던 `onBufferOverflow = BufferOverflow.DROP_OLDEST` 옵션을 제거하여 기본 채널 동작을 따르도록 변경했습니다. - **테스트 코드 개선:** - `SelectActionHandlerTest.kt` 및 `MapEventActionHandlerTest.kt`에 `@After` 어노테이션이 달린 `tearDown` 함수를 추가했습니다. - 각 테스트 종료 시 `Dispatchers.resetMain()`을 호출하여 메인 디스패처 설정을 초기화하도록 수정했습니다. - `PlaceLIstTestFixture.kt`의 파일명을 `PlaceMapTestFixture.kt`로 변경하여 오타를 수정하고 맥락에 맞게 네이밍을 개선했습니다. * refactor(test): observeEvent timeout 처리 로직 변경 테스트 유틸리티인 `observeEvent` 함수에서 Flow의 데이터를 수집할 때 사용하던 타임아웃 처리 방식을 변경했습니다. - **`FlowExtensions.kt` 수정:** - `flow.timeout(3.seconds).first()` 연산자 체인 대신, `withTimeout(3000)` 블록 내부에서 `flow.first()`를 호출하도록 로직을 수정했습니다. * fix(PlaceMap): 필터 카테고리 선택 획장함수 수정 장소 지도 필터에서 1차 카테고리 선택 여부를 판단하는 로직의 오류를 수정했습니다. - **`FilterActionHandler.kt` 수정:** - 기존 `any` 연산자를 사용하여 2차 카테고리가 아닌 요소가 하나라도 있는지 검사하던 로직을, 선택된 카테고리 중 2차 카테고리가 **하나도 없는지**를 확인하는 `none` 연산자로 변경했습니다. - 이를 통해 1차 카테고리만 선택되었을 때의 조건을 정확하게 판단하도록 수정했습니다. * refactor(PlaceMap): Event를 SideEffect로 네이밍 변경 `PlaceMap` 기능의 MVI 패턴에서 `Event`로 사용되던 클래스와 변수명을 `SideEffect`로 변경하여, UI 상태 변경과 직접적인 관련이 없는 일회성 액션(화면 이동, 스낵바 표시 등)의 역할을 더 명확하게 표현하도록 수정했습니다. - **`PlaceMapEvent`, `MapControlEvent` 이름 변경:** - `PlaceMapEvent.kt` -> `PlaceMapSideEffect.kt` - `MapControlEvent.kt` -> `MapControlSideEffect.kt` - 클래스 내부의 인터페이스 이름도 `...SideEffect`로 함께 변경했습니다. - **관련 변수명 변경:** - `ViewModel`, `Fragment`, `Handler`, 테스트 코드 등에서 `placeMapUiEvent`, `mapControlUiEvent`와 같이 사용되던 변수명을 `placeMapSideEffect`, `mapControlSideEffect`로 일괄 수정했습니다. * refactor(PlaceMap): MVI 구조의 Action/SideEffect 용어를 Event/SideEffect로 변경 장소 지도 화면의 MVI(Model-View-Intent) 아키텍처에서 사용되던 용어를 보다 명확하게 개선했습니다. 사용자 상호작용을 나타내는 `Action`을 `Event`로, UI 상태 변경 외의 부수 효과를 나타내는 `Event`를 `SideEffect`로 변경하여 역할 구분을 명확히 했습니다. - **용어 변경:** - `PlaceMapAction` -> `PlaceMapEvent` - `FilterAction` -> `FilterEvent` - `SelectAction` -> `SelectEvent` - `MapEventAction` -> `MapControlEvent` - `...ActionHandler` -> `...EventHandler` - **SideEffect 분리:** - 기존 `MapEventActionHandler`의 로직을 `MapControlEventHandler`와 `MapControlSideEffectHandler`로 분리했습니다. - `MapControlEventHandler`: UI 이벤트를 받아 `SideEffect`를 발생시키는 역할 - `MapControlSideEffectHandler`: 발생된 `SideEffect`를 실제로 처리하는 역할 - `PlaceMapEventHandler`는 `PlaceMapSideEffectHandler`로 이름을 변경하여 `SideEffect` 처리 책임이 명확히 드러나도록 수정했습니다. - **클래스 및 인터페이스 구조 변경:** - `ActionHandler` 인터페이스를 삭제하고 `EventHandler`와 `SideEffectHandler` 인터페이스를 도입하여 역할을 분리했습니다. - ViewModel에서는 `onPlaceMapAction`을 `onPlaceMapEvent`로 변경하고, 각 이벤트 타입에 맞는 `EventHandler`를 호출하도록 수정했습니다. * refactor(PlaceMap): 이벤트 핸들러 생성자에 `EventHandlerContext` 도입 장소 지도(PlaceMap) 기능의 이벤트 핸들러(`FilterEventHandler`, `MapControlEventHandler`, `SelectEventHandler`)들이 공통으로 사용하던 의존성들을 `EventHandlerContext` 데이터 클래스로 묶어 관리하도록 구조를 개선했습니다. 이전에는 각 핸들러가 `uiState`, `onUpdateState`, `scope`, `sideEffect` 채널 등 여러 개의 파라미터를 개별적으로 주입받았으나, 이제는 `EventHandlerContext` 객체 하나만 주입받도록 변경하여 코드의 간결성과 확장성을 높였습니다. - **`EventHandlerContext.kt` 추가:** - `uiState`, `onUpdateState`, `mapControlSideEffect`, `placeMapSideEffect`, `scope` 등 이벤트 핸들러에서 공통으로 사용하는 의존성을 포함하는 `EventHandlerContext` 데이터 클래스를 정의했습니다. - **`PlaceMapViewModel.kt` 및 `PlaceMapHandlerGraph.kt` 수정:** - `PlaceMapViewModel`에서 `handlerGraphFactory`를 통해 핸들러 그래프를 생성할 때, 여러 개의 인자를 전달하는 대신 `EventHandlerContext` 객체를 생성하여 한 번에 전달하도록 수정했습니다. - **이벤트 핸들러(`*EventHandler.kt`) 및 테스트 코드 수정:** - 각 이벤트 핸들러의 생성자가 `EventHandlerContext`를 주입받도록 변경하고, 내부 로직에서 `context` 객체를 통해 필요한 의존성에 접근하도록 수정했습니다. - 관련 테스트 코드들도 변경된 생성자 구조에 맞게 수정되었습니다. * refactor(PlaceMap): 이벤트 핸들러 동시성 문제 해결 장소 지도 화면(`PlaceMap`)의 이벤트 처리 로직을 리팩토링했습니다. 이벤트 핸들러 자체에서 `suspend`를 제거하고, 핸들러 내부의 비동기 작업들을 개별적으로 `CoroutineScope.launch` 블록으로 감싸도록 수정했습니다. 이를 통해 이벤트 발생 시점과 비동기 작업 실행을 분리하여 코드의 명확성을 높였습니다. - **`EventHandler.kt` 수정:** - `EventHandler` 인터페이스의 `invoke` 함수에서 `suspend` 키워드를 제거했습니다. - **`FilterEventHandler.kt`, `MapControlEventHandler.kt`, `SelectEventHandler.kt` 수정:** - 각 핸들러의 `invoke` 함수에서 `suspend` 키워드를 제거했습니다. - Side Effect 전송, 로깅, 상태 업데이트 등 비동기 처리가 필요한 로직을 `context.scope.launch` 블록으로 감쌌습니다. - **`PlaceMapViewModel.kt` 수정:** - 이벤트 핸들러 호출 부분을 감싸고 있던 `viewModelScope.launch`를 제거했습니다. - **`MapEventActionHandlerTest.kt`, `FilterActionHandlerTest.kt` 수정:** - 테스트 시 `mockk`로 주입하던 `scope`를 실제 테스트 디스패처를 사용하는 `CoroutineScope(testDispatcher)`로 변경하여 테스트 환경을 개선했습니다. --- .../di/mapManager/MapManagerBindings.kt | 4 +- .../placeMapHandler/CachedPlaceByTimeTag.kt | 6 + .../di/placeMapHandler/CachedPlaces.kt | 6 + .../placeMapHandler/PlaceMapHandlerGraph.kt | 25 ++ .../placeMapHandler/PlaceMapViewModelScope.kt | 3 + .../presentation/common/ObserveEvent.kt | 25 ++ .../placeList => placeDetail}/CategoryView.kt | 2 +- .../presentation/placeMap/PlaceMapFragment.kt | 326 ++++++----------- .../placeMap/PlaceMapViewModel.kt | 269 +++++++------- .../component/BackToPositionButton.kt | 2 +- .../component/CurrentLocationButton.kt | 2 +- .../placeMap/component/NaverMapContent.kt | 28 +- .../component/OffsetDependentLayout.kt | 2 +- .../placeMap/component/PlaceCategoryLabel.kt | 3 +- .../component/PlaceCategoryScreen.kt | 2 +- .../component/PlaceDetailPreviewScreen.kt | 37 +- .../PlaceDetailPreviewSecondaryScreen.kt | 36 +- .../component/PlaceListBottomSheet.kt | 2 +- .../component/PlaceListBottomSheetState.kt | 2 +- .../component/PlaceListScreen.kt | 81 ++--- .../placeMap/component/PlaceMapScreen.kt | 170 +++++---- .../component/PreviewAnimatableBox.kt | 2 +- .../presentation/placeMap/component/Styles.kt | 25 ++ .../component/TimeTagMenu.kt | 30 +- .../placeMap/intent/event/FilterEvent.kt | 11 + .../placeMap/intent/event/MapControlEvent.kt | 15 + .../placeMap/intent/event/PlaceMapEvent.kt | 3 + .../placeMap/intent/event/SelectEvent.kt | 27 ++ .../placeMap/intent/handler/EventHandler.kt | 10 + .../intent/handler/EventHandlerContext.kt | 20 ++ .../intent/handler/FilterEventHandler.kt | 139 ++++++++ .../intent/handler/MapControlEventHandler.kt | 69 ++++ .../handler/MapControlSideEffectHandler.kt | 126 +++++++ .../handler/PlaceMapSideEffectHandler.kt | 57 +++ .../intent/handler/SelectEventHandler.kt | 148 ++++++++ .../intent/handler/SideEffectHandler.kt | 5 + .../intent/sideEffect/MapControlSideEffect.kt | 34 ++ .../intent/sideEffect/PlaceMapSideEffect.kt | 27 ++ .../placeMap/intent/state/ListLoadState.kt | 19 + .../placeMap/intent/state/LoadState.kt | 20 ++ .../placeMap/intent/state/MapDelegate.kt | 30 ++ .../intent/state/MapManagerDelegate.kt | 15 + .../placeMap/intent/state/PlaceMapUiState.kt | 38 ++ .../placeMap/intent/state/StateExt.kt | 31 ++ .../{ => listener}/MapClickListener.kt | 2 +- .../{ => listener}/MapClickListenerImpl.kt | 10 +- .../{ => listener}/OnCameraChangeListener.kt | 2 +- .../placeMap/mapManager/MapCameraManager.kt | 2 +- .../placeMap/mapManager/MapManager.kt | 2 +- .../internal/MapCameraManagerImpl.kt | 2 +- .../internal/MapFilterManagerImpl.kt | 7 +- .../internal/MapMarkerManagerImpl.kt | 2 +- .../placeMap/model/PlaceListUiState.kt | 17 - .../placeMap/model/SelectedPlaceUiState.kt | 19 - .../placeCategory/PlaceCategoryFragment.kt | 87 ----- .../PlaceDetailPreviewFragment.kt | 138 -------- .../PlaceDetailPreviewSecondaryFragment.kt | 114 ------ .../placeList/OnPlaceClickListener.kt | 7 - .../placeMap/placeList/PlaceListFragment.kt | 222 ------------ .../placeMap/placeList/PlaceListViewModel.kt | 104 ------ .../main/res/layout/activity_place_detail.xml | 10 +- .../res/layout/fragment_place_category.xml | 253 -------------- .../layout/fragment_place_detail_preview.xml | 152 -------- ...ragment_place_detail_preview_secondary.xml | 48 --- .../main/res/layout/fragment_place_list.xml | 128 ------- app/src/main/res/layout/item_place_list.xml | 118 ------- .../res/layout/item_place_list_skeleton.xml | 54 --- .../com/daedan/festabook/FlowExtensions.kt | 41 +++ .../placeDetail/PlaceDetailTestFixture.kt | 2 +- .../placeDetail/PlaceDetailViewModelTest.kt | 2 +- .../placeList/PlaceListViewModelTest.kt | 162 --------- .../placeList/PlaceMapViewModelTest.kt | 329 ------------------ .../PlaceMapTestFixture.kt} | 59 +++- .../placeMap/PlaceMapViewModelTest.kt | 290 +++++++++++++++ .../handler/FilterActionHandlerTest.kt | 238 +++++++++++++ .../handler/MapEventActionHandlerTest.kt | 166 +++++++++ .../handler/SelectActionHandlerTest.kt | 244 +++++++++++++ 77 files changed, 2478 insertions(+), 2489 deletions(-) create mode 100644 app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaceByTimeTag.kt create mode 100644 app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaces.kt create mode 100644 app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapHandlerGraph.kt create mode 100644 app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapViewModelScope.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/common/ObserveEvent.kt rename app/src/main/java/com/daedan/festabook/presentation/{placeMap/placeList => placeDetail}/CategoryView.kt (97%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeList => }/component/BackToPositionButton.kt (95%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeList => }/component/CurrentLocationButton.kt (87%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeList => }/component/OffsetDependentLayout.kt (93%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeCategory => }/component/PlaceCategoryScreen.kt (98%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeDetailPreview => }/component/PlaceDetailPreviewScreen.kt (87%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeDetailPreview => }/component/PlaceDetailPreviewSecondaryScreen.kt (80%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeList => }/component/PlaceListBottomSheet.kt (99%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeList => }/component/PlaceListBottomSheetState.kt (97%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeList => }/component/PlaceListScreen.kt (83%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeDetailPreview => }/component/PreviewAnimatableBox.kt (96%) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/component/Styles.kt rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{timeTagSpinner => }/component/TimeTagMenu.kt (88%) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/FilterEvent.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/MapControlEvent.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/PlaceMapEvent.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/SelectEvent.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/EventHandler.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/EventHandlerContext.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/FilterEventHandler.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlEventHandler.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlSideEffectHandler.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/PlaceMapSideEffectHandler.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/SelectEventHandler.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/SideEffectHandler.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/sideEffect/MapControlSideEffect.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/sideEffect/PlaceMapSideEffect.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/ListLoadState.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/LoadState.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapDelegate.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapManagerDelegate.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/PlaceMapUiState.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/StateExt.kt rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{ => listener}/MapClickListener.kt (80%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{ => listener}/MapClickListenerImpl.kt (58%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{ => listener}/OnCameraChangeListener.kt (61%) delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceListUiState.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/model/SelectedPlaceUiState.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewSecondaryFragment.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/OnPlaceClickListener.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt delete mode 100644 app/src/main/res/layout/fragment_place_category.xml delete mode 100644 app/src/main/res/layout/fragment_place_detail_preview.xml delete mode 100644 app/src/main/res/layout/fragment_place_detail_preview_secondary.xml delete mode 100644 app/src/main/res/layout/fragment_place_list.xml delete mode 100644 app/src/main/res/layout/item_place_list.xml delete mode 100644 app/src/main/res/layout/item_place_list_skeleton.xml create mode 100644 app/src/test/java/com/daedan/festabook/FlowExtensions.kt delete mode 100644 app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt delete mode 100644 app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt rename app/src/test/java/com/daedan/festabook/{placeList/PlaceLIstTestFixture.kt => placeMap/PlaceMapTestFixture.kt} (65%) create mode 100644 app/src/test/java/com/daedan/festabook/placeMap/PlaceMapViewModelTest.kt create mode 100644 app/src/test/java/com/daedan/festabook/placeMap/handler/FilterActionHandlerTest.kt create mode 100644 app/src/test/java/com/daedan/festabook/placeMap/handler/MapEventActionHandlerTest.kt create mode 100644 app/src/test/java/com/daedan/festabook/placeMap/handler/SelectActionHandlerTest.kt diff --git a/app/src/main/java/com/daedan/festabook/di/mapManager/MapManagerBindings.kt b/app/src/main/java/com/daedan/festabook/di/mapManager/MapManagerBindings.kt index 4687ae9b..8df85db6 100644 --- a/app/src/main/java/com/daedan/festabook/di/mapManager/MapManagerBindings.kt +++ b/app/src/main/java/com/daedan/festabook/di/mapManager/MapManagerBindings.kt @@ -1,8 +1,8 @@ package com.daedan.festabook.di.mapManager -import com.daedan.festabook.presentation.placeMap.MapClickListener -import com.daedan.festabook.presentation.placeMap.MapClickListenerImpl import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.listener.MapClickListener +import com.daedan.festabook.presentation.placeMap.listener.MapClickListenerImpl import com.daedan.festabook.presentation.placeMap.mapManager.internal.OverlayImageManager import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.iconResources diff --git a/app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaceByTimeTag.kt b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaceByTimeTag.kt new file mode 100644 index 00000000..a9517516 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaceByTimeTag.kt @@ -0,0 +1,6 @@ +package com.daedan.festabook.di.placeMapHandler + +import dev.zacsweers.metro.Qualifier + +@Qualifier +annotation class CachedPlaceByTimeTag diff --git a/app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaces.kt b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaces.kt new file mode 100644 index 00000000..26f64913 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaces.kt @@ -0,0 +1,6 @@ +package com.daedan.festabook.di.placeMapHandler + +import dev.zacsweers.metro.Qualifier + +@Qualifier +annotation class CachedPlaces diff --git a/app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapHandlerGraph.kt b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapHandlerGraph.kt new file mode 100644 index 00000000..f55ac9de --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapHandlerGraph.kt @@ -0,0 +1,25 @@ +package com.daedan.festabook.di.placeMapHandler + +import com.daedan.festabook.presentation.placeMap.intent.handler.EventHandlerContext +import com.daedan.festabook.presentation.placeMap.intent.handler.FilterEventHandler +import com.daedan.festabook.presentation.placeMap.intent.handler.MapControlEventHandler +import com.daedan.festabook.presentation.placeMap.intent.handler.SelectEventHandler +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.GraphExtension +import dev.zacsweers.metro.Provides + +@GraphExtension(PlaceMapViewModelScope::class) +interface PlaceMapHandlerGraph { + val filterEventHandler: FilterEventHandler + val selectEventHandler: SelectEventHandler + val mapControlEventHandler: MapControlEventHandler + + @ContributesTo(AppScope::class) + @GraphExtension.Factory + interface Factory { + fun create( + @Provides context: EventHandlerContext, + ): PlaceMapHandlerGraph + } +} diff --git a/app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapViewModelScope.kt b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapViewModelScope.kt new file mode 100644 index 00000000..cbf9f4b6 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapViewModelScope.kt @@ -0,0 +1,3 @@ +package com.daedan.festabook.di.placeMapHandler + +abstract class PlaceMapViewModelScope private constructor() diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/ObserveEvent.kt b/app/src/main/java/com/daedan/festabook/presentation/common/ObserveEvent.kt new file mode 100644 index 00000000..f5e67fb1 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/common/ObserveEvent.kt @@ -0,0 +1,25 @@ +package com.daedan.festabook.presentation.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +@Composable +fun ObserveAsEvents( + flow: Flow, + onEvent: suspend (T) -> Unit, +) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(flow, lifecycleOwner.lifecycle) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + withContext(Dispatchers.Main.immediate) { + flow.collect(onEvent) + } + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/CategoryView.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/CategoryView.kt similarity index 97% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/CategoryView.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeDetail/CategoryView.kt index 2b1e0add..8546f3f1 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/CategoryView.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/CategoryView.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.placeList +package com.daedan.festabook.presentation.placeDetail import android.content.Context import android.util.AttributeSet diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt index 8c7a65f2..563b3752 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt @@ -2,52 +2,56 @@ package com.daedan.festabook.presentation.placeMap import android.content.Context import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding +import android.view.ViewGroup import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentTransaction -import androidx.fragment.app.commit import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.request.ImageResult import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentPlaceMapBinding +import com.daedan.festabook.di.appGraph import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.di.mapManager.MapManagerGraph import com.daedan.festabook.logging.logger import com.daedan.festabook.presentation.common.BaseFragment +import com.daedan.festabook.presentation.common.ObserveAsEvents import com.daedan.festabook.presentation.common.OnMenuItemReClickListener +import com.daedan.festabook.presentation.common.convertImageUrl import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.common.toPx -import com.daedan.festabook.presentation.placeMap.component.NaverMapContent -import com.daedan.festabook.presentation.placeMap.logging.CurrentLocationChecked +import com.daedan.festabook.presentation.placeDetail.PlaceDetailActivity +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.component.PlaceMapScreen +import com.daedan.festabook.presentation.placeMap.component.rememberPlaceListBottomSheetState +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent +import com.daedan.festabook.presentation.placeMap.intent.handler.MapControlSideEffectHandler +import com.daedan.festabook.presentation.placeMap.intent.handler.PlaceMapSideEffectHandler +import com.daedan.festabook.presentation.placeMap.intent.state.MapDelegate +import com.daedan.festabook.presentation.placeMap.intent.state.MapManagerDelegate import com.daedan.festabook.presentation.placeMap.logging.PlaceFragmentEnter -import com.daedan.festabook.presentation.placeMap.logging.PlaceMarkerClick -import com.daedan.festabook.presentation.placeMap.logging.PlaceTimeTagSelected -import com.daedan.festabook.presentation.placeMap.mapManager.MapManager -import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState -import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState -import com.daedan.festabook.presentation.placeMap.placeCategory.PlaceCategoryFragment -import com.daedan.festabook.presentation.placeMap.placeDetailPreview.PlaceDetailPreviewFragment -import com.daedan.festabook.presentation.placeMap.placeDetailPreview.PlaceDetailPreviewSecondaryFragment -import com.daedan.festabook.presentation.placeMap.placeList.PlaceListFragment -import com.daedan.festabook.presentation.placeMap.timeTagSpinner.component.TimeTagMenu -import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.theme.FestabookTheme -import com.naver.maps.map.NaverMap -import com.naver.maps.map.OnMapReadyCallback import com.naver.maps.map.util.FusedLocationSource import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import dev.zacsweers.metro.binding -import dev.zacsweers.metro.createGraphFactory +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import timber.log.Timber @ContributesIntoMap( @@ -57,235 +61,127 @@ import timber.log.Timber @FragmentKey(PlaceMapFragment::class) @Inject class PlaceMapFragment( - placeListFragment: PlaceListFragment, - placeDetailPreviewFragment: PlaceDetailPreviewFragment, - placeCategoryFragment: PlaceCategoryFragment, - placeDetailPreviewSecondaryFragment: PlaceDetailPreviewSecondaryFragment, override val defaultViewModelProviderFactory: ViewModelProvider.Factory, ) : BaseFragment(), OnMenuItemReClickListener { override val layoutId: Int = R.layout.fragment_place_map - private lateinit var naverMap: NaverMap - - private val placeListFragment by lazy { getIfExists(placeListFragment) } - private val placeDetailPreviewFragment by lazy { getIfExists(placeDetailPreviewFragment) } - private val placeCategoryFragment by lazy { getIfExists(placeCategoryFragment) } - private val placeDetailPreviewSecondaryFragment by lazy { - getIfExists( - placeDetailPreviewSecondaryFragment, - ) - } private val locationSource by lazy { FusedLocationSource(this, LOCATION_PERMISSION_REQUEST_CODE) } - private var mapManager: MapManager? = null - - private val viewModel: PlaceMapViewModel by viewModels() + private val placeMapViewModel: PlaceMapViewModel by viewModels() - override fun onViewCreated( - view: View, + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - if (savedInstanceState == null) { - childFragmentManager.commit { - addWithSimpleTag(R.id.fcv_place_list_container, placeListFragment) - addWithSimpleTag(R.id.fcv_map_container, placeDetailPreviewFragment) - addWithSimpleTag(R.id.fcv_place_category_container, placeCategoryFragment) - addWithSimpleTag(R.id.fcv_map_container, placeDetailPreviewSecondaryFragment) - hide(placeDetailPreviewFragment) - hide(placeDetailPreviewSecondaryFragment) - } - } - - setupComposeView() - + ): View { + super.onCreateView(inflater, container, savedInstanceState) binding.logger.log( PlaceFragmentEnter( baseLogData = binding.logger.getBaseLogData(), ), ) - } - - override fun onMenuItemReClick() { - val childFragments = - listOf( - placeListFragment, - placeDetailPreviewFragment, - placeCategoryFragment, - ) - childFragments.forEach { fragment -> - (fragment as? OnMenuItemReClickListener)?.onMenuItemReClick() - } - mapManager?.moveToPosition() - } - - private fun setupComposeView() { - binding.cvPlaceMap.apply { + return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - FestabookTheme { - NaverMapContent( - modifier = Modifier.fillMaxSize(), - onMapDrag = { viewModel.onMapViewClick() }, - onMapReady = { setupMap(it) }, - ) { - // TODO 흩어져있는 ComposeView 통합, 추후 PlaceMapScreen 사용 + val uiState by placeMapViewModel.uiState.collectAsStateWithLifecycle() + val density = LocalDensity.current + val bottomSheetState = rememberPlaceListBottomSheetState() + val mapDelegate = remember { MapDelegate() } + val mapManagerDelegate = remember { MapManagerDelegate() } + val mapControlSideEffectHandler = + remember { + MapControlSideEffectHandler( + initialPadding = with(density) { 254.dp.toPx() }.toInt(), + logger = appGraph.defaultFirebaseLogger, + locationSource = locationSource, + viewModel = placeMapViewModel, + mapDelegate = mapDelegate, + mapManagerDelegate = mapManagerDelegate, + ) } - } - } - } - binding.cvTimeTagSpinner.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val timeTags by viewModel.timeTags.collectAsStateWithLifecycle() - val title by viewModel.selectedTimeTagFlow.collectAsStateWithLifecycle() - FestabookTheme { - if (timeTags.isNotEmpty()) { - TimeTagMenu( - title = title.name, - timeTags = timeTags, - onTimeTagClick = { timeTag -> - viewModel.onDaySelected(timeTag) - binding.logger.log( - PlaceTimeTagSelected( - baseLogData = binding.logger.getBaseLogData(), - timeTagName = timeTag.name, - ), - ) - }, - modifier = - Modifier - .background( - FestabookColor.white, - ).padding(horizontal = 24.dp), + val placeMapSideEffectHandler = + remember { + PlaceMapSideEffectHandler( + mapManagerDelegate = mapManagerDelegate, + bottomSheetState = bottomSheetState, + viewModel = placeMapViewModel, + logger = appGraph.defaultFirebaseLogger, + onStartPlaceDetail = { startPlaceDetailActivity(it.placeDetail.value) }, + onPreloadImages = { preloadImages(requireContext(), it.places) }, + onShowErrorSnackBar = { showErrorSnackBar(it.error.throwable) }, ) } - } - } - } - } - - private fun setupMap(map: NaverMap) { - naverMap = map - naverMap.addOnLocationChangeListener { - binding.logger.log( - CurrentLocationChecked( - baseLogData = binding.logger.getBaseLogData(), - ), - ) - } - (placeListFragment as? OnMapReadyCallback)?.onMapReady(naverMap) - naverMap.locationSource = locationSource - setUpObserver() - } - private fun setUpObserver() { - viewModel.placeGeographies.observe(viewLifecycleOwner) { placeGeographies -> - when (placeGeographies) { - is PlaceListUiState.Loading -> Unit - is PlaceListUiState.Success -> { - mapManager?.setupMarker(placeGeographies.value) - viewModel.selectedTimeTag.observe(viewLifecycleOwner) { selectedTimeTag -> - mapManager?.filterMarkersByTimeTag(selectedTimeTag.timeTagId) - } + ObserveAsEvents(flow = placeMapViewModel.mapControlSideEffect) { event -> + mapControlSideEffectHandler(event) } - is PlaceListUiState.Error -> { - Timber.w( - placeGeographies.throwable, - "PlaceListFragment: ${placeGeographies.throwable.message}", - ) - showErrorSnackBar(placeGeographies.throwable) + ObserveAsEvents(flow = placeMapViewModel.placeMapSideEffect) { event -> + placeMapSideEffectHandler(event) } - else -> Unit - } - } - - viewModel.initialMapSetting.observe(viewLifecycleOwner) { initialMapSetting -> - if (initialMapSetting !is PlaceListUiState.Success) return@observe - if (mapManager == null) { - val graph = - createGraphFactory().create( - naverMap, - initialMapSetting.value, - viewModel, - getInitialPadding(requireContext()), + FestabookTheme { + PlaceMapScreen( + uiState = uiState, + onEvent = { placeMapViewModel.onPlaceMapEvent(it) }, + bottomSheetState = bottomSheetState, + mapDelegate = mapDelegate, ) - mapManager = graph.mapManager - mapManager?.setupBackToInitialPosition { isExceededMaxLength -> - viewModel.setIsExceededMaxLength(isExceededMaxLength) } } } + } - viewModel.backToInitialPositionClicked.observe(viewLifecycleOwner) { - mapManager?.moveToPosition() - } - - viewModel.selectedCategories.observe(viewLifecycleOwner) { selectedCategories -> - if (selectedCategories.isEmpty()) { - mapManager?.clearFilter() - } else { - mapManager?.filterMarkersByCategories(selectedCategories) - } - } + override fun onMenuItemReClick() { + placeMapViewModel.onPlaceMapEvent(SelectEvent.UnSelectPlace) + placeMapViewModel.onMenuItemReClicked() + } - viewModel.selectedPlace.observe(viewLifecycleOwner) { selectedPlace -> - childFragmentManager.commit { - setReorderingAllowed(true) + private fun startPlaceDetailActivity(placeDetail: PlaceDetailUiModel) { + Timber.d("start detail activity") + val intent = PlaceDetailActivity.newIntent(requireContext(), placeDetail) + startActivity(intent) + } - when (selectedPlace) { - is SelectedPlaceUiState.Success -> { - mapManager?.selectMarker(selectedPlace.value.place.id) - if (selectedPlace.isSecondary) { - hide(placeListFragment) - hide(placeDetailPreviewFragment) - show(placeDetailPreviewSecondaryFragment) - } else { - hide(placeListFragment) - hide(placeDetailPreviewSecondaryFragment) - show(placeDetailPreviewFragment) + // OOM 주의 !! 추후 페이징 처리 및 chunk 단위로 나눠서 로드합니다 + private fun preloadImages( + context: Context, + places: List, + maxSize: Int = 20, + ) { + val imageLoader = context.imageLoader + val deferredList = mutableListOf>() + + lifecycleScope.launch(Dispatchers.IO) { + places + .take(maxSize) + .filterNotNull() + .forEach { place -> + val deferred = + async { + val request = + ImageRequest + .Builder(context) + .data(place.imageUrl.convertImageUrl()) + .build() + + runCatching { + withTimeout(2000) { + imageLoader.execute(request) + } + }.onFailure { + Timber.d("preload 실패") + }.getOrNull() } - binding.logger.log( - PlaceMarkerClick( - baseLogData = binding.logger.getBaseLogData(), - placeId = selectedPlace.value.place.id, - timeTagName = viewModel.selectedTimeTag.value?.name ?: "undefined", - category = selectedPlace.value.place.category.name, - ), - ) - } - - is SelectedPlaceUiState.Empty -> { - mapManager?.unselectMarker() - hide(placeDetailPreviewFragment) - hide(placeDetailPreviewSecondaryFragment) - show(placeListFragment) - } - - else -> Unit + deferredList.add(deferred) } - } + deferredList.awaitAll() } } - @Suppress("UNCHECKED_CAST") - private fun getIfExists(fragment: T): T = - childFragmentManager.findFragmentByTag(fragment::class.simpleName) as? T ?: fragment - - private fun FragmentTransaction.addWithSimpleTag( - containerViewId: Int, - fragment: Fragment, - ) { - add(containerViewId, fragment, fragment::class.simpleName) - } - companion object { private const val LOCATION_PERMISSION_REQUEST_CODE = 1234 - - private fun getInitialPadding(context: Context): Int = 254.toPx(context) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt index 5cefacf3..d25c4da6 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt @@ -1,33 +1,39 @@ package com.daedan.festabook.presentation.placeMap -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope +import com.daedan.festabook.di.placeMapHandler.PlaceMapHandlerGraph import com.daedan.festabook.di.viewmodel.ViewModelKey -import com.daedan.festabook.domain.model.TimeTag -import com.daedan.festabook.domain.repository.PlaceDetailRepository import com.daedan.festabook.domain.repository.PlaceListRepository -import com.daedan.festabook.presentation.common.Event -import com.daedan.festabook.presentation.common.SingleLiveData -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel -import com.daedan.festabook.presentation.placeDetail.model.toUiModel -import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.intent.event.FilterEvent +import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent +import com.daedan.festabook.presentation.placeMap.intent.event.PlaceMapEvent +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent +import com.daedan.festabook.presentation.placeMap.intent.handler.EventHandlerContext +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.MapControlSideEffect +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.ListLoadState +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.intent.state.await import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState -import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.placeMap.model.toUiModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @ContributesIntoMap(AppScope::class) @@ -35,73 +41,56 @@ import kotlinx.coroutines.launch @Inject class PlaceMapViewModel( private val placeListRepository: PlaceListRepository, - private val placeDetailRepository: PlaceDetailRepository, + handlerGraphFactory: PlaceMapHandlerGraph.Factory, ) : ViewModel() { - private val _initialMapSetting: MutableLiveData> = - MutableLiveData() - val initialMapSetting: LiveData> = _initialMapSetting - - private val _placeGeographies: MutableLiveData>> = - MutableLiveData() - val placeGeographies: LiveData>> - get() = _placeGeographies - - private val _timeTags = MutableStateFlow>(emptyList()) - val timeTags: StateFlow> = _timeTags.asStateFlow() - - private val _selectedTimeTag = MutableLiveData() - val selectedTimeTag: LiveData = _selectedTimeTag - - // 임시 StateFlow - val selectedTimeTagFlow: StateFlow = - _selectedTimeTag.asFlow().stateIn( - scope = viewModelScope, - started = SharingStarted.Lazily, - initialValue = TimeTag.EMPTY, - ) - private val _selectedPlace: MutableLiveData = MutableLiveData() - val selectedPlace: LiveData = _selectedPlace - - val selectedPlaceFlow: StateFlow = - _selectedPlace - .asFlow() - .stateIn( - scope = viewModelScope, - started = SharingStarted.Lazily, - initialValue = SelectedPlaceUiState.Loading, + private val cachedPlaces = MutableStateFlow(listOf()) + private val cachedPlaceByTimeTag = MutableStateFlow>(emptyList()) + + private val _uiState = MutableStateFlow(PlaceMapUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _placeMapSideEffect = Channel() + val placeMapSideEffect: Flow = _placeMapSideEffect.receiveAsFlow() + + private val _mapControlSideEffect = Channel() + val mapControlSideEffect: Flow = _mapControlSideEffect.receiveAsFlow() + + private val handlerGraph = + handlerGraphFactory + .create( + EventHandlerContext( + mapControlSideEffect = _mapControlSideEffect, + placeMapSideEffect = _placeMapSideEffect, + uiState = uiState, + cachedPlaces = cachedPlaces, + cachedPlaceByTimeTag = cachedPlaceByTimeTag, + onUpdateCachedPlace = { cachedPlaceByTimeTag.tryEmit(it) }, + onUpdateState = { _uiState.update(it) }, + scope = viewModelScope, + ), ) - private val _navigateToDetail = SingleLiveData() - val navigateToDetail: LiveData = _navigateToDetail - - private val _isExceededMaxLength: MutableLiveData = MutableLiveData() - val isExceededMaxLength: LiveData = _isExceededMaxLength - - val isExceededMaxLengthFlow: StateFlow = - _isExceededMaxLength - .asFlow() - .stateIn( - scope = viewModelScope, - started = SharingStarted.Lazily, - initialValue = false, - ) - - private val _backToInitialPositionClicked: MutableLiveData> = MutableLiveData() - val backToInitialPositionClicked: LiveData> = _backToInitialPositionClicked - - private val _selectedCategories: MutableLiveData> = MutableLiveData() - val selectedCategories: LiveData> = _selectedCategories - - private val _onMapViewClick: MutableLiveData> = MutableLiveData() - val onMapViewClick: LiveData> = _onMapViewClick - - val onMapViewClickFlow: Flow> = - _onMapViewClick - .asFlow() - init { loadOrganizationGeography() loadTimeTags() + loadAllPlaces() + observeErrorEvent() + } + + fun onPlaceMapEvent(event: PlaceMapEvent) { + when (event) { + is FilterEvent -> handlerGraph.filterEventHandler(event) + is MapControlEvent -> handlerGraph.mapControlEventHandler(event) + is SelectEvent -> handlerGraph.selectEventHandler(event) + } + } + + fun onMenuItemReClicked() { + _placeMapSideEffect.trySend( + PlaceMapSideEffect.MenuItemReClicked( + uiState.value.isPlacePreviewVisible || uiState.value.isPlaceSecondaryPreviewVisible, + ), + ) } private fun loadTimeTags() { @@ -109,80 +98,96 @@ class PlaceMapViewModel( placeListRepository .getTimeTags() .onSuccess { timeTags -> - _timeTags.value = timeTags + _uiState.update { + it.copy( + timeTags = LoadState.Success(timeTags), + ) + } }.onFailure { - _timeTags.value = emptyList() + _uiState.update { + it.copy( + timeTags = LoadState.Empty, + ) + } } // 기본 선택값 - if (!timeTags.value.isEmpty()) { - _selectedTimeTag.value = _timeTags.value.first() - } else { - _selectedTimeTag.value = TimeTag.EMPTY - } - } - } - - fun onDaySelected(item: TimeTag) { - unselectPlace() - _selectedTimeTag.value = item - } - - fun selectPlace(placeId: Long) { - viewModelScope.launch { - _selectedPlace.value = SelectedPlaceUiState.Loading - placeDetailRepository - .getPlaceDetail(placeId = placeId) - .onSuccess { - _selectedPlace.value = SelectedPlaceUiState.Success(it.toUiModel()) - }.onFailure { - _selectedPlace.value = SelectedPlaceUiState.Error(it) + val timeTags = uiState.value.timeTags + val selectedTimeTag = + if (timeTags is LoadState.Success && timeTags.value.isNotEmpty()) { + LoadState.Success( + timeTags.value.first(), + ) + } else { + LoadState.Empty } - } - } - - fun unselectPlace() { - _selectedPlace.value = SelectedPlaceUiState.Empty - } + _uiState.update { + it.copy(selectedTimeTag = selectedTimeTag) + } - fun onExpandedStateReached() { - val currentPlace = _selectedPlace.value.let { it as? SelectedPlaceUiState.Success }?.value - if (currentPlace != null) { - _navigateToDetail.setValue(currentPlace) + val placeGeographies = + uiState.await>> { it.placeGeographies } + _mapControlSideEffect.send( + MapControlSideEffect.SetMarkerByTimeTag( + placeGeographies = placeGeographies.value, + selectedTimeTag = selectedTimeTag, + isInitial = true, + ), + ) } } - fun onBackToInitialPositionClicked() { - _backToInitialPositionClicked.value = Event(Unit) - } - - fun setIsExceededMaxLength(isExceededMaxLength: Boolean) { - _isExceededMaxLength.value = isExceededMaxLength - } - - fun setSelectedCategories(categories: List) { - _selectedCategories.value = categories - } - - fun onMapViewClick() { - _onMapViewClick.value = Event(Unit) - } - private fun loadOrganizationGeography() { viewModelScope.launch { placeListRepository.getOrganizationGeography().onSuccess { organizationGeography -> - _initialMapSetting.value = - PlaceListUiState.Success(organizationGeography.toUiModel()) + _uiState.update { + it.copy(initialMapSetting = LoadState.Success(organizationGeography.toUiModel())) + } } launch { placeListRepository .getPlaceGeographies() .onSuccess { placeGeographies -> - _placeGeographies.value = - PlaceListUiState.Success(placeGeographies.map { it.toUiModel() }) - }.onFailure { - _placeGeographies.value = PlaceListUiState.Error(it) + _uiState.update { + it.copy( + placeGeographies = LoadState.Success(placeGeographies.map { it.toUiModel() }), + ) + } + }.onFailure { item -> + _uiState.update { + it.copy(placeGeographies = LoadState.Error(item)) + } + } + } + } + } + + private fun loadAllPlaces() { + viewModelScope.launch { + val result = placeListRepository.getPlaces() + result + .onSuccess { places -> + val placeUiModels = places.map { it.toUiModel() } + cachedPlaces.tryEmit(placeUiModels) + _uiState.update { it.copy(places = ListLoadState.PlaceLoaded(placeUiModels)) } + }.onFailure { error -> + _uiState.update { it.copy(places = ListLoadState.Error(error)) } + } + } + } + + @OptIn(FlowPreview::class) + private fun observeErrorEvent() { + viewModelScope.launch { + launch { + uiState + .map { it.hasAnyError } + .distinctUntilChanged() + .filterIsInstance() + .debounce(1000) + .collect { + _placeMapSideEffect.send(PlaceMapSideEffect.ShowErrorSnackBar(it)) } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/BackToPositionButton.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/BackToPositionButton.kt similarity index 95% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/BackToPositionButton.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/BackToPositionButton.kt index f85864b1..676e2ddc 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/BackToPositionButton.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/BackToPositionButton.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.placeList.component +package com.daedan.festabook.presentation.placeMap.component import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/CurrentLocationButton.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/CurrentLocationButton.kt similarity index 87% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/CurrentLocationButton.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/CurrentLocationButton.kt index dd4c4c10..e91d9363 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/CurrentLocationButton.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/CurrentLocationButton.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.placeList.component +package com.daedan.festabook.presentation.placeMap.component import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt index aa72f6ee..ed43be57 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt @@ -5,6 +5,7 @@ import android.content.res.Configuration import android.os.Bundle import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -17,28 +18,31 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import com.daedan.festabook.presentation.placeMap.intent.state.MapDelegate import com.naver.maps.map.MapView import com.naver.maps.map.NaverMap +import kotlinx.coroutines.suspendCancellableCoroutine @Composable fun NaverMapContent( modifier: Modifier = Modifier, + mapDelegate: MapDelegate = MapDelegate(), onMapDrag: () -> Unit = {}, onMapReady: (NaverMap) -> Unit = {}, - content: @Composable () -> Unit, + content: @Composable (NaverMap?) -> Unit, ) { val context = LocalContext.current val mapView = remember { MapView(context) } + LaunchedEffect(mapView) { + val naverMap = mapView.getMapAndRunCallback(onMapReady) + mapDelegate.initMap(naverMap) + } AndroidView( - factory = { - mapView.apply { - getMapAsync(onMapReady) - } - }, + factory = { mapView }, modifier = modifier.dragInterceptor(onMapDrag), ) RegisterMapLifeCycle(mapView) - content() + content(mapDelegate.value) } private fun Modifier.dragInterceptor(onMapDrag: () -> Unit): Modifier = @@ -152,3 +156,13 @@ private fun MapView.lifecycleObserver( } previousState.value = event } + +private suspend fun MapView.getMapAndRunCallback(onMapReady: (NaverMap) -> Unit = {}): NaverMap = + suspendCancellableCoroutine { continuation -> + getMapAsync { map -> + onMapReady(map) + continuation.resumeWith( + Result.success(map), + ) + } + } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/OffsetDependentLayout.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/OffsetDependentLayout.kt similarity index 93% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/OffsetDependentLayout.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/OffsetDependentLayout.kt index 28aa3ba8..5202e4a3 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/OffsetDependentLayout.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/OffsetDependentLayout.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.placeList.component +package com.daedan.festabook.presentation.placeMap.component import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryLabel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryLabel.kt index e0315a78..cd521881 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryLabel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryLabel.kt @@ -21,6 +21,7 @@ import com.daedan.festabook.presentation.placeMap.model.getIconId import com.daedan.festabook.presentation.placeMap.model.getLabelColor import com.daedan.festabook.presentation.placeMap.model.getTextId import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing import kotlin.math.roundToInt @Composable @@ -51,7 +52,7 @@ fun PlaceCategoryLabel( modifier = Modifier.size(12.dp), ) Text( - modifier = Modifier.padding(start = 4.dp), + modifier = Modifier.padding(start = festabookSpacing.paddingBody1), text = stringResource(category.getTextId()), style = MaterialTheme.typography.labelMedium, ) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/component/PlaceCategoryScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryScreen.kt similarity index 98% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/component/PlaceCategoryScreen.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryScreen.kt index 723992db..fc551b8f 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/component/PlaceCategoryScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryScreen.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.placeCategory.component +package com.daedan.festabook.presentation.placeMap.component import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt similarity index 87% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewScreen.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt index 0e4c1018..caff6471 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt @@ -1,5 +1,6 @@ -package com.daedan.festabook.presentation.placeMap.placeDetailPreview.component +package com.daedan.festabook.presentation.placeMap.component +import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -15,16 +16,14 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.CoilImage import com.daedan.festabook.presentation.common.component.URLText import com.daedan.festabook.presentation.common.convertImageUrl import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel -import com.daedan.festabook.presentation.placeMap.component.PlaceCategoryLabel +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTheme import com.daedan.festabook.presentation.theme.FestabookTypography @@ -33,28 +32,28 @@ import com.daedan.festabook.presentation.theme.festabookSpacing @Composable fun PlaceDetailPreviewScreen( - placeUiState: SelectedPlaceUiState, + selectedPlace: LoadState, modifier: Modifier = Modifier, visible: Boolean = false, - onClick: (SelectedPlaceUiState) -> Unit = {}, - onError: (SelectedPlaceUiState.Error) -> Unit = {}, - onEmpty: () -> Unit = {}, + onClick: (LoadState) -> Unit = {}, + onBackPress: () -> Unit = {}, ) { + BackHandler(enabled = visible) { + onBackPress() + } PreviewAnimatableBox( visible = visible, modifier = modifier .wrapContentSize() - .clickable { onClick(placeUiState) }, + .clickable { onClick(selectedPlace) }, ) { - when (placeUiState) { - is SelectedPlaceUiState.Loading -> Unit - is SelectedPlaceUiState.Success -> { - PlaceDetailPreviewContent(placeDetail = placeUiState.value) + when (selectedPlace) { + is LoadState.Success -> { + PlaceDetailPreviewContent(placeDetail = selectedPlace.value) } - is SelectedPlaceUiState.Error -> onError(placeUiState) - is SelectedPlaceUiState.Empty -> onEmpty() + else -> Unit } } } @@ -68,7 +67,7 @@ private fun PlaceDetailPreviewContent( modifier = modifier.padding( horizontal = festabookSpacing.paddingScreenGutter, - vertical = 20.dp, + vertical = festabookSpacing.previewVerticalPadding, ), ) { PlaceCategoryLabel( @@ -146,7 +145,7 @@ private fun PlaceDetailPreviewContent( CoilImage( modifier = Modifier - .size(88.dp) + .size(festabookSpacing.previewImageSize) .clip(festabookShapes.radius2), url = placeDetail.place.imageUrl.convertImageUrl() ?: "", contentDescription = stringResource(R.string.content_description_booth_image), @@ -184,8 +183,8 @@ private fun PlaceDetailPreviewScreenPreview() { modifier = Modifier .padding(festabookSpacing.paddingScreenGutter), - placeUiState = - SelectedPlaceUiState.Success( + selectedPlace = + LoadState.Success( value = FAKE_PLACE_DETAIL, ), ) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewSecondaryScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt similarity index 80% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewSecondaryScreen.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt index f6a3ba2e..26660464 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewSecondaryScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt @@ -1,5 +1,6 @@ -package com.daedan.festabook.presentation.placeMap.placeDetailPreview.component +package com.daedan.festabook.presentation.placeMap.component +import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -17,9 +18,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.daedan.festabook.R import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState import com.daedan.festabook.presentation.placeMap.model.getIconId import com.daedan.festabook.presentation.placeMap.model.getTextId import com.daedan.festabook.presentation.theme.FestabookTheme @@ -29,28 +30,27 @@ import com.daedan.festabook.presentation.theme.festabookSpacing @Composable fun PlaceDetailPreviewSecondaryScreen( - placeUiState: SelectedPlaceUiState, + selectedPlace: LoadState, modifier: Modifier = Modifier, - onError: (SelectedPlaceUiState.Error) -> Unit = {}, - onEmpty: () -> Unit = {}, - onClick: (SelectedPlaceUiState) -> Unit = {}, + onClick: (LoadState) -> Unit = {}, + onBackPress: () -> Unit = {}, visible: Boolean = false, ) { + BackHandler(enabled = visible) { + onBackPress() + } PreviewAnimatableBox( visible = visible, modifier = modifier .fillMaxWidth() .clickable { - onClick(placeUiState) + onClick(selectedPlace) }, shape = festabookShapes.radius2, ) { - when (placeUiState) { - is SelectedPlaceUiState.Loading -> Unit - is SelectedPlaceUiState.Error -> onError(placeUiState) - is SelectedPlaceUiState.Empty -> onEmpty() - is SelectedPlaceUiState.Success -> { + when (selectedPlace) { + is LoadState.Success -> { Row( modifier = Modifier.padding( @@ -63,7 +63,7 @@ fun PlaceDetailPreviewSecondaryScreen( modifier = Modifier.size(24.dp), painter = painterResource( - placeUiState.value.place.category + selectedPlace.value.place.category .getIconId(), ), tint = Color.Unspecified, @@ -73,15 +73,17 @@ fun PlaceDetailPreviewSecondaryScreen( Text( modifier = Modifier.padding(start = festabookSpacing.paddingBody2), text = - placeUiState.value.place.title + selectedPlace.value.place.title ?: stringResource( - placeUiState.value.place.category + selectedPlace.value.place.category .getTextId(), ), style = FestabookTypography.displaySmall, ) } } + + else -> Unit } } } @@ -93,8 +95,8 @@ private fun PlaceDetailPreviewSecondaryScreenPreview() { PlaceDetailPreviewSecondaryScreen( visible = true, modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), - placeUiState = - SelectedPlaceUiState.Success( + selectedPlace = + LoadState.Success( FAKE_PLACE_DETAIL, ), ) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheet.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListBottomSheet.kt similarity index 99% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheet.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListBottomSheet.kt index 44290d69..0089b872 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheet.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListBottomSheet.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.placeList.component +package com.daedan.festabook.presentation.placeMap.component import androidx.compose.foundation.background import androidx.compose.foundation.gestures.DraggableAnchors diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheetState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListBottomSheetState.kt similarity index 97% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheetState.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListBottomSheetState.kt index b16cb7f8..3cdf12fe 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheetState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListBottomSheetState.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.placeList.component +package com.daedan.festabook.presentation.placeMap.component import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.Spring diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt similarity index 83% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListScreen.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt index 6b73ad63..6b32fb17 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.placeList.component +package com.daedan.festabook.presentation.placeMap.component import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -39,9 +39,8 @@ import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.CoilImage import com.daedan.festabook.presentation.common.component.EmptyStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen -import com.daedan.festabook.presentation.placeMap.component.PlaceCategoryLabel +import com.daedan.festabook.presentation.placeMap.intent.state.ListLoadState import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.theme.FestabookTheme import com.daedan.festabook.presentation.theme.festabookShapes @@ -51,10 +50,10 @@ import kotlinx.coroutines.launch @Composable fun PlaceListScreen( - placesUiState: PlaceListUiState>, + placesUiState: ListLoadState>, modifier: Modifier = Modifier, map: NaverMap? = null, - isExceedMaxLength: Boolean = false, + isExceededMaxLength: Boolean = false, bottomSheetState: PlaceListBottomSheetState = rememberPlaceListBottomSheetState( PlaceListBottomSheetValue.HALF_EXPANDED, @@ -62,40 +61,52 @@ fun PlaceListScreen( onPlaceClick: (place: PlaceUiModel) -> Unit = {}, onPlaceLoadFinish: (places: List) -> Unit = {}, onPlaceLoad: suspend () -> Unit = {}, - onError: (PlaceListUiState.Error>) -> Unit = {}, onBackToInitialPositionClick: () -> Unit = {}, ) { val listState = rememberLazyListState() val scope = rememberCoroutineScope() var offset by remember { mutableFloatStateOf(0f) } val currentOnPlaceLoad by rememberUpdatedState(onPlaceLoad) + val currentOnPlaceLoadFinish by rememberUpdatedState(onPlaceLoadFinish) + + LaunchedEffect(placesUiState) { + when (placesUiState) { + is ListLoadState.PlaceLoaded -> launch { currentOnPlaceLoad() } + is ListLoadState.Success -> currentOnPlaceLoadFinish(placesUiState.value) + else -> Unit + } + } Box(modifier = modifier.fillMaxSize()) { - OffsetDependentLayout( - modifier = Modifier.padding(horizontal = festabookSpacing.paddingBody1), - offset = offset, - ) { - Box { - CurrentLocationButton( - map = map, - ) - if (isExceedMaxLength) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - BackToPositionButton( - text = stringResource(R.string.map_back_to_initial_position), - onClick = onBackToInitialPositionClick, - ) + if (bottomSheetState.currentValue != PlaceListBottomSheetValue.EXPANDED) { + OffsetDependentLayout( + modifier = + Modifier + .padding(horizontal = festabookSpacing.paddingBody1), + offset = offset, + ) { + Box { + CurrentLocationButton( + map = map, + ) + if (isExceededMaxLength) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + BackToPositionButton( + text = stringResource(R.string.map_back_to_initial_position), + onClick = onBackToInitialPositionClick, + ) + } } } } } PlaceListBottomSheet( - peekHeight = 70.dp, - halfExpandedRatio = 0.4f, + peekHeight = festabookSpacing.placeListBottomSheetPeekHeight, + halfExpandedRatio = festabookSpacing.placeListBottomSheetHalfRatio, onStateUpdate = { if (listState.firstVisibleItemIndex != 0) { scope.launch { listState.scrollToItem(0) } @@ -117,20 +128,18 @@ fun PlaceListScreen( }, ) { when (placesUiState) { - is PlaceListUiState.Loading -> + is ListLoadState.Loading -> LoadingStateScreen( modifier = Modifier.offset(y = HALF_EXPANDED_OFFSET), ) - is PlaceListUiState.Error -> { - onError(placesUiState) + is ListLoadState.Error -> { EmptyStateScreen( modifier = Modifier.offset(y = HALF_EXPANDED_OFFSET), ) } - is PlaceListUiState.Success -> { - onPlaceLoadFinish(placesUiState.value) + is ListLoadState.Success -> { if (placesUiState.value.isEmpty()) { EmptyStateScreen( modifier = Modifier.offset(y = HALF_EXPANDED_OFFSET), @@ -145,13 +154,7 @@ fun PlaceListScreen( } } - is PlaceListUiState.PlaceLoaded -> { - LaunchedEffect(Unit) { - scope.launch { - currentOnPlaceLoad() - } - } - } + is ListLoadState.PlaceLoaded -> Unit } } } @@ -203,7 +206,7 @@ private fun PlaceListItem( contentDescription = stringResource(R.string.content_description_booth_image), modifier = Modifier - .size(80.dp) + .size(festabookSpacing.placeListImageSize) .clip(festabookShapes.radius2), ) PlaceListItemContent( @@ -281,7 +284,7 @@ private fun PlaceListScreenPreview() { FestabookTheme { PlaceListScreen( placesUiState = - PlaceListUiState.Success( + ListLoadState.Success( (0..100).map { PlaceUiModel( id = it.toLong(), diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt index 1d3d7215..cc93e1ae 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt @@ -1,101 +1,121 @@ package com.daedan.festabook.presentation.placeMap.component import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.daedan.festabook.domain.model.TimeTag -import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.placeCategory.component.PlaceCategoryScreen -import com.daedan.festabook.presentation.placeMap.timeTagSpinner.component.TimeTagMenu +import androidx.compose.ui.draw.alpha +import com.daedan.festabook.presentation.placeMap.intent.event.FilterEvent +import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent +import com.daedan.festabook.presentation.placeMap.intent.event.PlaceMapEvent +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.MapDelegate +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState import com.daedan.festabook.presentation.theme.FestabookColor -import com.daedan.festabook.presentation.theme.FestabookTheme -import com.naver.maps.map.NaverMap +import com.daedan.festabook.presentation.theme.festabookSpacing -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PlaceMapScreen( - timeTagTitle: String, - timeTags: List, - places: List, - modifier: Modifier = Modifier, - onMapReady: (NaverMap) -> Unit = {}, - onPlaceClick: (PlaceUiModel) -> Unit = {}, - onTimeTagClick: (TimeTag) -> Unit = {}, -) { - PlaceMapContent( - title = timeTagTitle, - timeTags = timeTags, - onMapReady = onMapReady, - onTimeTagClick = onTimeTagClick, - ) -} - -@Composable -private fun PlaceMapContent( - timeTags: List, - title: String, - onMapReady: (NaverMap) -> Unit, - onTimeTagClick: (TimeTag) -> Unit, + uiState: PlaceMapUiState, + onEvent: (PlaceMapEvent) -> Unit, + bottomSheetState: PlaceListBottomSheetState, + mapDelegate: MapDelegate, modifier: Modifier = Modifier, ) { NaverMapContent( modifier = modifier.fillMaxSize(), - onMapReady = onMapReady, - ) { + mapDelegate = mapDelegate, + onMapReady = { onEvent(MapControlEvent.OnMapReady) }, + onMapDrag = { onEvent(MapControlEvent.OnMapDrag) }, + ) { naverMap -> Column( modifier = Modifier.wrapContentSize(), ) { - if (timeTags.isNotEmpty()) { - TimeTagMenu( - title = title, - timeTags = timeTags, - onTimeTagClick = { timeTag -> - onTimeTagClick(timeTag) - }, + TimeTagMenu( + timeTagsState = uiState.timeTags, + selectedTimeTagState = uiState.selectedTimeTag, + onTimeTagClick = { timeTag -> + onEvent(SelectEvent.OnTimeTagClick(timeTag)) + }, + modifier = + Modifier + .background( + FestabookColor.white, + ).padding(horizontal = festabookSpacing.timeTagHorizontalPadding), + ) + PlaceCategoryScreen( + initialCategories = uiState.initialCategories, + selectedCategories = uiState.selectedCategories, + onCategoryClick = { onEvent(FilterEvent.OnCategoryClick(it)) }, + onDisplayAllClick = { onEvent(FilterEvent.OnCategoryClick(it)) }, + ) + + Box( + modifier = Modifier.fillMaxSize(), + ) { + NaverMapLogo( modifier = - Modifier - .background( - FestabookColor.white, - ).padding(horizontal = 24.dp), + Modifier.padding( + horizontal = festabookSpacing.paddingScreenGutter, + ), ) - } - PlaceCategoryScreen() - } - } -} -@Preview(showBackground = true) -@Composable -private fun PlaceMapScreenPreview() { - FestabookTheme { - PlaceMapScreen( - timeTagTitle = "테스트", - timeTags = - listOf( - TimeTag(1, "테스트1"), - TimeTag(2, "테스트2"), - ), - places = - (0..100).map { - PlaceUiModel( - id = it.toLong(), - imageUrl = null, - title = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", - description = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", - location = "테스트테스트테스트테스트테스트테스트테스트테스트테스트", - category = PlaceCategoryUiModel.BAR, - isBookmarked = true, - timeTagId = listOf(1), + PlaceListScreen( + modifier = + Modifier.alpha( + if (uiState.selectedPlace is LoadState.Empty) { + 1f + } else { + 0f + }, + ), + placesUiState = uiState.places, + map = naverMap, + onPlaceClick = { onEvent(SelectEvent.OnPlaceClick(it.id)) }, + bottomSheetState = bottomSheetState, + isExceededMaxLength = uiState.isExceededMaxLength, + onPlaceLoadFinish = { onEvent(MapControlEvent.OnPlaceLoadFinish(it)) }, + onPlaceLoad = { onEvent(FilterEvent.OnPlaceLoad) }, + onBackToInitialPositionClick = { onEvent(MapControlEvent.OnBackToInitialPositionClick) }, + ) + + if (uiState.isPlacePreviewVisible) { + PlaceDetailPreviewScreen( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding( + vertical = festabookSpacing.paddingBody4, + horizontal = festabookSpacing.paddingScreenGutter, + ), + selectedPlace = uiState.selectedPlace, + visible = true, + onClick = { onEvent(SelectEvent.OnPlacePreviewClick(it)) }, + onBackPress = { onEvent(SelectEvent.OnBackPress) }, ) - }, - ) + } + + if (uiState.isPlaceSecondaryPreviewVisible) { + PlaceDetailPreviewSecondaryScreen( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding( + vertical = festabookSpacing.paddingBody4, + horizontal = festabookSpacing.paddingScreenGutter, + ), + selectedPlace = uiState.selectedPlace, + visible = true, + onBackPress = { onEvent(SelectEvent.OnBackPress) }, + ) + } + } + } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PreviewAnimatableBox.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PreviewAnimatableBox.kt similarity index 96% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PreviewAnimatableBox.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PreviewAnimatableBox.kt index 0d478d97..495dbbe4 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PreviewAnimatableBox.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PreviewAnimatableBox.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.placeDetailPreview.component +package com.daedan.festabook.presentation.placeMap.component import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/Styles.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/Styles.kt new file mode 100644 index 00000000..c4aa9679 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/Styles.kt @@ -0,0 +1,25 @@ +package com.daedan.festabook.presentation.placeMap.component + +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.theme.FestabookSpacing + +val FestabookSpacing.previewVerticalPadding + get() = 20.dp + +val FestabookSpacing.timeTagHorizontalPadding + get() = 24.dp + +val FestabookSpacing.previewImageSize + get() = 88.dp + +val FestabookSpacing.placeListImageSize + get() = 80.dp + +val FestabookSpacing.placeListBottomSheetPeekHeight + get() = 70.dp + +val FestabookSpacing.placeListBottomSheetHalfRatio + get() = 0.4f + +val FestabookSpacing.timeTagButtonWidth + get() = 140.dp diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/component/TimeTagMenu.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt similarity index 88% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/component/TimeTagMenu.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt index 769f9a55..5b535f8d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/component/TimeTagMenu.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.timeTagSpinner.component +package com.daedan.festabook.presentation.placeMap.component import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp import com.daedan.festabook.R import com.daedan.festabook.domain.model.TimeTag import com.daedan.festabook.presentation.common.component.cardBackground +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTheme import com.daedan.festabook.presentation.theme.festabookShapes @@ -51,6 +52,29 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun TimeTagMenu( + timeTagsState: LoadState>, + selectedTimeTagState: LoadState, + modifier: Modifier = Modifier, + onTimeTagClick: (TimeTag) -> Unit = {}, +) { + when (timeTagsState) { + is LoadState.Success -> { + if (selectedTimeTagState !is LoadState.Success) return + TimeTagContent( + title = selectedTimeTagState.value.name, + timeTags = timeTagsState.value, + modifier = modifier, + onTimeTagClick = onTimeTagClick, + ) + } + + else -> Unit + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TimeTagContent( title: String, timeTags: List, modifier: Modifier = Modifier, @@ -124,7 +148,7 @@ private fun ExposedDropdownMenuBoxScope.TimeTagButton( Row( modifier = Modifier - .width(140.dp) + .width(festabookSpacing.timeTagButtonWidth) .onGloballyPositioned { coordinates -> onSizeDetermine(coordinates.size) }.menuAnchor( @@ -170,7 +194,7 @@ private fun TimeTagMenuPreview() { ) var title by remember { mutableStateOf("1일차 오전") } FestabookTheme { - TimeTagMenu( + TimeTagContent( title = title, timeTags = timeTags, modifier = diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/FilterEvent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/FilterEvent.kt new file mode 100644 index 00000000..93129f0a --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/FilterEvent.kt @@ -0,0 +1,11 @@ +package com.daedan.festabook.presentation.placeMap.intent.event + +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel + +sealed interface FilterEvent : PlaceMapEvent { + data class OnCategoryClick( + val categories: Set, + ) : FilterEvent + + data object OnPlaceLoad : FilterEvent +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/MapControlEvent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/MapControlEvent.kt new file mode 100644 index 00000000..2b4364c7 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/MapControlEvent.kt @@ -0,0 +1,15 @@ +package com.daedan.festabook.presentation.placeMap.intent.event + +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel + +sealed interface MapControlEvent : PlaceMapEvent { + data object OnMapReady : MapControlEvent + + data object OnMapDrag : MapControlEvent + + data class OnPlaceLoadFinish( + val places: List, + ) : MapControlEvent + + data object OnBackToInitialPositionClick : MapControlEvent +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/PlaceMapEvent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/PlaceMapEvent.kt new file mode 100644 index 00000000..f5a5c7cb --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/PlaceMapEvent.kt @@ -0,0 +1,3 @@ +package com.daedan.festabook.presentation.placeMap.intent.event + +sealed interface PlaceMapEvent diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/SelectEvent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/SelectEvent.kt new file mode 100644 index 00000000..0a98f437 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/SelectEvent.kt @@ -0,0 +1,27 @@ +package com.daedan.festabook.presentation.placeMap.intent.event + +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState + +sealed interface SelectEvent : PlaceMapEvent { + data class OnPlaceClick( + val placeId: Long, + ) : SelectEvent + + data class OnPlacePreviewClick( + val place: LoadState, + ) : SelectEvent + + data object UnSelectPlace : SelectEvent + + data class ExceededMaxLength( + val isExceededMaxLength: Boolean, + ) : SelectEvent + + data class OnTimeTagClick( + val timeTag: TimeTag, + ) : SelectEvent + + data object OnBackPress : SelectEvent +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/EventHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/EventHandler.kt new file mode 100644 index 00000000..149a5c79 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/EventHandler.kt @@ -0,0 +1,10 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +import kotlinx.coroutines.flow.StateFlow + +interface EventHandler { + val uiState: StateFlow + val onUpdateState: ((before: STATE) -> STATE) -> Unit + + operator fun invoke(event: ACTION) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/EventHandlerContext.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/EventHandlerContext.kt new file mode 100644 index 00000000..2dfb2aeb --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/EventHandlerContext.kt @@ -0,0 +1,20 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.MapControlSideEffect +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.StateFlow + +data class EventHandlerContext( + val uiState: StateFlow, + val onUpdateState: ((PlaceMapUiState) -> PlaceMapUiState) -> Unit, + val mapControlSideEffect: Channel, + val scope: CoroutineScope, + val cachedPlaces: StateFlow>, + val cachedPlaceByTimeTag: StateFlow>, + val onUpdateCachedPlace: (List) -> Unit, + val placeMapSideEffect: Channel, +) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/FilterEventHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/FilterEventHandler.kt new file mode 100644 index 00000000..cd52b1dd --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/FilterEventHandler.kt @@ -0,0 +1,139 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +import com.daedan.festabook.di.placeMapHandler.PlaceMapViewModelScope +import com.daedan.festabook.domain.model.PlaceCategory +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.logging.DefaultFirebaseLogger +import com.daedan.festabook.presentation.placeMap.intent.event.FilterEvent +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.MapControlSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.ListLoadState +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.intent.state.await +import com.daedan.festabook.presentation.placeMap.logging.PlaceCategoryClick +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.model.toUiModel +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +@Inject +@ContributesBinding(PlaceMapViewModelScope::class) +class FilterEventHandler( + private val context: EventHandlerContext, + private val logger: DefaultFirebaseLogger, +) : EventHandler { + override val uiState: StateFlow = context.uiState + override val onUpdateState = context.onUpdateState + + override operator fun invoke(event: FilterEvent) { + when (event) { + is FilterEvent.OnCategoryClick -> { + context.scope.launch { + uiState.await> { it.places } + unselectPlace() + updatePlacesByCategories(event.categories.toList()) + + onUpdateState.invoke { + it.copy(selectedCategories = event.categories) + } + + context.mapControlSideEffect.send(MapControlSideEffect.FilterMapByCategory(event.categories.toList())) + + logger.log( + PlaceCategoryClick( + baseLogData = logger.getBaseLogData(), + currentCategories = event.categories.joinToString(",") { it.toString() }, + ), + ) + } + } + + is FilterEvent.OnPlaceLoad -> { + context.scope.launch { + val selectedTimeTag = + uiState + .map { it.selectedTimeTag } + .distinctUntilChanged() + .first() + + when (selectedTimeTag) { + is LoadState.Success -> { + updatePlacesByTimeTag(selectedTimeTag.value.timeTagId) + } + + is LoadState.Empty -> { + updatePlacesByTimeTag(TimeTag.EMTPY_TIME_TAG_ID) + } + + else -> Unit + } + } + } + } + } + + private fun unselectPlace() { + onUpdateState.invoke { it.copy(selectedPlace = LoadState.Empty) } + context.mapControlSideEffect.trySend(MapControlSideEffect.UnselectMarker) + } + + fun updatePlacesByTimeTag(timeTagId: Long) { + val filteredPlaces = + if (timeTagId == TimeTag.EMTPY_TIME_TAG_ID) { + context.cachedPlaces.value + } else { + filterPlacesByTimeTag(timeTagId) + } + onUpdateState.invoke { + it.copy(places = ListLoadState.Success(filteredPlaces)) + } + context.onUpdateCachedPlace(filteredPlaces) + } + + private fun updatePlacesByCategories(category: List) { + if (category.isEmpty()) { + clearPlacesFilter() + return + } + + val secondaryCategories = + PlaceCategory.SECONDARY_CATEGORIES.map { + it.toUiModel() + } + val primaryCategoriesSelected = category.none { it in secondaryCategories } + + if (!primaryCategoriesSelected) { + clearPlacesFilter() + return + } + + val filteredPlaces = + context.cachedPlaceByTimeTag.value + .filter { place -> + place.category in category + } + onUpdateState.invoke { + it.copy(places = ListLoadState.Success(filteredPlaces)) + } + } + + private fun filterPlacesByTimeTag(timeTagId: Long): List { + val filteredPlaces = + context.cachedPlaces.value.filter { place -> + place.timeTagId.contains(timeTagId) + } + return filteredPlaces + } + + private fun clearPlacesFilter() { + onUpdateState.invoke { + it.copy(places = ListLoadState.Success(context.cachedPlaceByTimeTag.value)) + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlEventHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlEventHandler.kt new file mode 100644 index 00000000..ab35d2be --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlEventHandler.kt @@ -0,0 +1,69 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +import com.daedan.festabook.di.placeMapHandler.PlaceMapViewModelScope +import com.daedan.festabook.logging.DefaultFirebaseLogger +import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.MapControlSideEffect +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.intent.state.await +import com.daedan.festabook.presentation.placeMap.logging.PlaceBackToSchoolClick +import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +@Inject +@ContributesBinding(PlaceMapViewModelScope::class) +class MapControlEventHandler( + private val context: EventHandlerContext, + private val logger: DefaultFirebaseLogger, +) : EventHandler { + override val uiState: StateFlow = context.uiState + override val onUpdateState = context.onUpdateState + + override operator fun invoke(event: MapControlEvent) { + when (event) { + is MapControlEvent.OnMapReady -> { + context.scope.launch { + context.mapControlSideEffect.send(MapControlSideEffect.InitMap) + val setting = + uiState.await> { it.initialMapSetting } + context.mapControlSideEffect.send(MapControlSideEffect.InitMapManager(setting.value)) + } + } + + is MapControlEvent.OnPlaceLoadFinish -> + context.scope.launch { + context.placeMapSideEffect.send( + PlaceMapSideEffect.PreloadImages( + event.places, + ), + ) + } + + is MapControlEvent.OnBackToInitialPositionClick -> { + context.scope.launch { + logger.log( + PlaceBackToSchoolClick( + baseLogData = logger.getBaseLogData(), + ), + ) + context.mapControlSideEffect.send(MapControlSideEffect.BackToInitialPosition) + } + } + + is MapControlEvent.OnMapDrag -> { + context.scope.launch { + context.placeMapSideEffect.send( + PlaceMapSideEffect.MapViewDrag( + uiState.value.isPlacePreviewVisible || uiState.value.isPlaceSecondaryPreviewVisible, + ), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlSideEffectHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlSideEffectHandler.kt new file mode 100644 index 00000000..da6a199e --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlSideEffectHandler.kt @@ -0,0 +1,126 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +import com.daedan.festabook.di.mapManager.MapManagerGraph +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.logging.DefaultFirebaseLogger +import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.MapControlSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.MapDelegate +import com.daedan.festabook.presentation.placeMap.intent.state.MapManagerDelegate +import com.daedan.festabook.presentation.placeMap.logging.CurrentLocationChecked +import com.daedan.festabook.presentation.placeMap.logging.PlaceMarkerClick +import com.daedan.festabook.presentation.placeMap.mapManager.MapManager +import com.naver.maps.map.LocationSource +import dev.zacsweers.metro.createGraphFactory + +class MapControlSideEffectHandler( + private val initialPadding: Int, + private val logger: DefaultFirebaseLogger, + private val locationSource: LocationSource, + private val viewModel: PlaceMapViewModel, + private val mapDelegate: MapDelegate, + private val mapManagerDelegate: MapManagerDelegate, +) : SideEffectHandler { + private val uiState get() = viewModel.uiState.value + private val mapManager: MapManager? get() = mapManagerDelegate.value + + override suspend operator fun invoke(event: MapControlSideEffect) { + when (event) { + is MapControlSideEffect.InitMap -> { + val naverMap = mapDelegate.await() + naverMap.addOnLocationChangeListener { + logger.log( + CurrentLocationChecked( + baseLogData = logger.getBaseLogData(), + ), + ) + } + naverMap.locationSource = locationSource + } + + is MapControlSideEffect.InitMapManager -> { + val naverMap = mapDelegate.await() + if (mapManager == null) { + val graph = + createGraphFactory().create( + naverMap, + event.initialMapSetting, + viewModel, + initialPadding, + ) + mapManagerDelegate.init(graph.mapManager) + mapManager?.setupBackToInitialPosition { isExceededMaxLength -> + viewModel.onPlaceMapEvent( + SelectEvent.ExceededMaxLength(isExceededMaxLength), + ) + } + } + } + + is MapControlSideEffect.BackToInitialPosition -> { + mapManager?.moveToPosition() + } + + is MapControlSideEffect.SetMarkerByTimeTag -> { + if (event.isInitial) { + mapManager?.setupMarker(event.placeGeographies) + } + + when (val selectedTimeTag = event.selectedTimeTag) { + is LoadState.Success -> { + mapManager?.filterMarkersByTimeTag( + selectedTimeTag.value.timeTagId, + ) + } + + is LoadState.Empty -> { + mapManager?.filterMarkersByTimeTag(TimeTag.EMTPY_TIME_TAG_ID) + } + + else -> Unit + } + } + + is MapControlSideEffect.FilterMapByCategory -> { + val selectedCategories = event.selectedCategories + if (selectedCategories.isEmpty()) { + mapManager?.clearFilter() + } else { + mapManager?.filterMarkersByCategories(selectedCategories) + } + } + + is MapControlSideEffect.SelectMarker -> { + when (val place = event.placeDetail) { + is LoadState.Success -> { + mapManager?.selectMarker(place.value.place.id) + + val currentTimeTag = uiState.selectedTimeTag + val timeTagName = + if (currentTimeTag is LoadState.Success) { + currentTimeTag.value.name + } else { + "undefined" + } + logger.log( + PlaceMarkerClick( + baseLogData = logger.getBaseLogData(), + placeId = place.value.place.id, + timeTagName = timeTagName, + category = place.value.place.category.name, + ), + ) + } + + else -> Unit + } + } + + is MapControlSideEffect.UnselectMarker -> { + mapManager?.unselectMarker() + } + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/PlaceMapSideEffectHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/PlaceMapSideEffectHandler.kt new file mode 100644 index 00000000..99d97fee --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/PlaceMapSideEffectHandler.kt @@ -0,0 +1,57 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +import com.daedan.festabook.logging.DefaultFirebaseLogger +import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.component.PlaceListBottomSheetState +import com.daedan.festabook.presentation.placeMap.component.PlaceListBottomSheetValue +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.MapManagerDelegate +import com.daedan.festabook.presentation.placeMap.logging.PlaceMapButtonReClick +import com.daedan.festabook.presentation.placeMap.mapManager.MapManager + +class PlaceMapSideEffectHandler( + private val mapManagerDelegate: MapManagerDelegate, + private val bottomSheetState: PlaceListBottomSheetState, + private val viewModel: PlaceMapViewModel, + private val logger: DefaultFirebaseLogger, + // 안드로이드 종속적인 액션은 외부에서 주입 + // TODO Compose로 전환 시, 콜백이 아닌 Compose State 주입 + private val onPreloadImages: (PlaceMapSideEffect.PreloadImages) -> Unit, + private val onStartPlaceDetail: (PlaceMapSideEffect.StartPlaceDetail) -> Unit, + private val onShowErrorSnackBar: (PlaceMapSideEffect.ShowErrorSnackBar) -> Unit, +) : SideEffectHandler { + private val mapManager: MapManager? get() = mapManagerDelegate.value + + override suspend operator fun invoke(event: PlaceMapSideEffect) { + when (event) { + is PlaceMapSideEffect.PreloadImages -> { + onPreloadImages(event) + } + + is PlaceMapSideEffect.MenuItemReClicked -> { + mapManager?.moveToPosition() + if (!event.isPreviewVisible) return + viewModel.onPlaceMapEvent(SelectEvent.UnSelectPlace) + logger.log( + PlaceMapButtonReClick( + baseLogData = logger.getBaseLogData(), + ), + ) + } + + is PlaceMapSideEffect.StartPlaceDetail -> { + onStartPlaceDetail(event) + } + + is PlaceMapSideEffect.ShowErrorSnackBar -> { + onShowErrorSnackBar(event) + } + + is PlaceMapSideEffect.MapViewDrag -> { + if (event.isPreviewVisible) return + bottomSheetState.update(PlaceListBottomSheetValue.COLLAPSED) + } + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/SelectEventHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/SelectEventHandler.kt new file mode 100644 index 00000000..6502d8b6 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/SelectEventHandler.kt @@ -0,0 +1,148 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +import com.daedan.festabook.di.placeMapHandler.PlaceMapViewModelScope +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.domain.repository.PlaceDetailRepository +import com.daedan.festabook.logging.DefaultFirebaseLogger +import com.daedan.festabook.presentation.placeDetail.model.toUiModel +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.MapControlSideEffect +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.intent.state.await +import com.daedan.festabook.presentation.placeMap.logging.PlaceItemClick +import com.daedan.festabook.presentation.placeMap.logging.PlacePreviewClick +import com.daedan.festabook.presentation.placeMap.logging.PlaceTimeTagSelected +import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +@Inject +@ContributesBinding(PlaceMapViewModelScope::class) +class SelectEventHandler( + private val context: EventHandlerContext, + private val filterActionHandler: FilterEventHandler, + private val logger: DefaultFirebaseLogger, + private val placeDetailRepository: PlaceDetailRepository, +) : EventHandler { + override val uiState: StateFlow = context.uiState + override val onUpdateState = context.onUpdateState + + override operator fun invoke(event: SelectEvent) { + when (event) { + is SelectEvent.OnPlaceClick -> { + selectPlace(event.placeId) + } + + is SelectEvent.UnSelectPlace -> { + unselectPlace() + } + + is SelectEvent.ExceededMaxLength -> { + onUpdateState.invoke { + it.copy( + isExceededMaxLength = event.isExceededMaxLength, + ) + } + } + + is SelectEvent.OnTimeTagClick -> { + onDaySelected(event.timeTag) + filterActionHandler.updatePlacesByTimeTag(event.timeTag.timeTagId) + logger.log( + PlaceTimeTagSelected( + baseLogData = logger.getBaseLogData(), + timeTagName = event.timeTag.name, + ), + ) + } + + is SelectEvent.OnPlacePreviewClick -> { + val selectedTimeTag = uiState.value.selectedTimeTag + val selectedPlace = event.place + if (selectedPlace is LoadState.Success && + selectedTimeTag is LoadState.Success + ) { + context.scope.launch { + context.placeMapSideEffect.send(PlaceMapSideEffect.StartPlaceDetail(event.place)) + logger.log( + PlacePreviewClick( + baseLogData = logger.getBaseLogData(), + placeName = + selectedPlace.value.place.title + ?: "undefined", + timeTag = selectedTimeTag.value.name, + category = selectedPlace.value.place.category.name, + ), + ) + } + } + } + + is SelectEvent.OnBackPress -> { + unselectPlace() + } + } + } + + private fun selectPlace(placeId: Long) { + context.scope.launch { + onUpdateState.invoke { it.copy(selectedPlace = LoadState.Loading) } + placeDetailRepository + .getPlaceDetail(placeId = placeId) + .onSuccess { item -> + val newSelectedPlace = LoadState.Success(item.toUiModel()) + + onUpdateState.invoke { + it.copy(selectedPlace = newSelectedPlace) + } + context.mapControlSideEffect.send( + MapControlSideEffect.SelectMarker( + newSelectedPlace, + ), + ) + val selectedTimeTag = uiState.value.selectedTimeTag + val timeTagName = + if (selectedTimeTag is LoadState.Success) selectedTimeTag.value.name else "undefined" + logger.log( + PlaceItemClick( + baseLogData = logger.getBaseLogData(), + placeId = placeId, + timeTagName = timeTagName, + category = item.place.category.name, + ), + ) + }.onFailure { item -> + onUpdateState.invoke { + it.copy(selectedPlace = LoadState.Error(item)) + } + } + } + } + + private fun unselectPlace() { + onUpdateState.invoke { it.copy(selectedPlace = LoadState.Empty) } + context.mapControlSideEffect.trySend(MapControlSideEffect.UnselectMarker) + } + + private fun onDaySelected(item: TimeTag) { + unselectPlace() + onUpdateState.invoke { + it.copy(selectedTimeTag = LoadState.Success(item)) + } + context.scope.launch { + val placeGeographies = + uiState.await>> { it.placeGeographies } + context.mapControlSideEffect.send( + MapControlSideEffect.SetMarkerByTimeTag( + placeGeographies = placeGeographies.value, + selectedTimeTag = LoadState.Success(item), + isInitial = false, + ), + ) + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/SideEffectHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/SideEffectHandler.kt new file mode 100644 index 00000000..6aa77182 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/SideEffectHandler.kt @@ -0,0 +1,5 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +interface SideEffectHandler { + suspend operator fun invoke(event: EVENT) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/sideEffect/MapControlSideEffect.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/sideEffect/MapControlSideEffect.kt new file mode 100644 index 00000000..6ec25e0a --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/sideEffect/MapControlSideEffect.kt @@ -0,0 +1,34 @@ +package com.daedan.festabook.presentation.placeMap.intent.sideEffect + +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel + +sealed interface MapControlSideEffect { + data object InitMap : MapControlSideEffect + + data class InitMapManager( + val initialMapSetting: InitialMapSettingUiModel, + ) : MapControlSideEffect + + data object BackToInitialPosition : MapControlSideEffect + + data class SetMarkerByTimeTag( + val placeGeographies: List, + val selectedTimeTag: LoadState, + val isInitial: Boolean, + ) : MapControlSideEffect + + data class FilterMapByCategory( + val selectedCategories: List, + ) : MapControlSideEffect + + data class SelectMarker( + val placeDetail: LoadState, + ) : MapControlSideEffect + + data object UnselectMarker : MapControlSideEffect +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/sideEffect/PlaceMapSideEffect.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/sideEffect/PlaceMapSideEffect.kt new file mode 100644 index 00000000..bf630d3a --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/sideEffect/PlaceMapSideEffect.kt @@ -0,0 +1,27 @@ +package com.daedan.festabook.presentation.placeMap.intent.sideEffect + +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel + +sealed interface PlaceMapSideEffect { + data class StartPlaceDetail( + val placeDetail: LoadState.Success, + ) : PlaceMapSideEffect + + data class PreloadImages( + val places: List, + ) : PlaceMapSideEffect + + data class ShowErrorSnackBar( + val error: LoadState.Error, + ) : PlaceMapSideEffect + + data class MenuItemReClicked( + val isPreviewVisible: Boolean, + ) : PlaceMapSideEffect + + data class MapViewDrag( + val isPreviewVisible: Boolean, + ) : PlaceMapSideEffect +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/ListLoadState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/ListLoadState.kt new file mode 100644 index 00000000..1c305b1e --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/ListLoadState.kt @@ -0,0 +1,19 @@ +package com.daedan.festabook.presentation.placeMap.intent.state + +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel + +sealed interface ListLoadState { + data object Loading : ListLoadState + + data class Success( + val value: T, + ) : ListLoadState + + data class PlaceLoaded( + val value: List, + ) : ListLoadState> + + data class Error( + val throwable: Throwable, + ) : ListLoadState +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/LoadState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/LoadState.kt new file mode 100644 index 00000000..a29a9081 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/LoadState.kt @@ -0,0 +1,20 @@ +package com.daedan.festabook.presentation.placeMap.intent.state + +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel + +sealed interface LoadState { + data object Loading : LoadState + + data object Empty : LoadState + + data class Success( + val value: T, + ) : LoadState + + data class Error( + val throwable: Throwable, + ) : LoadState +} + +val LoadState.Success.isSecondary get() = value.place.category in PlaceCategoryUiModel.Companion.SECONDARY_CATEGORIES diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapDelegate.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapDelegate.kt new file mode 100644 index 00000000..051f4b98 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapDelegate.kt @@ -0,0 +1,30 @@ +package com.daedan.festabook.presentation.placeMap.intent.state + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import com.naver.maps.map.NaverMap +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class MapDelegate { + var value: NaverMap? by mutableStateOf(null) + private set + + fun initMap(map: NaverMap) { + value = map + } + + suspend fun await(timeout: Duration = 3.seconds): NaverMap = + withTimeout(timeout) { + snapshotFlow { value } + .distinctUntilChanged() + .filterNotNull() + .first() + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapManagerDelegate.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapManagerDelegate.kt new file mode 100644 index 00000000..947ee52e --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapManagerDelegate.kt @@ -0,0 +1,15 @@ +package com.daedan.festabook.presentation.placeMap.intent.state + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.daedan.festabook.presentation.placeMap.mapManager.MapManager + +class MapManagerDelegate { + var value: MapManager? by mutableStateOf(null) + private set + + fun init(manager: MapManager) { + value = manager + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/PlaceMapUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/PlaceMapUiState.kt new file mode 100644 index 00000000..95b9d657 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/PlaceMapUiState.kt @@ -0,0 +1,38 @@ +package com.daedan.festabook.presentation.placeMap.intent.state + +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel + +data class PlaceMapUiState( + val initialMapSetting: LoadState = LoadState.Loading, + val placeGeographies: LoadState> = LoadState.Loading, + val timeTags: LoadState> = LoadState.Empty, + val selectedTimeTag: LoadState = LoadState.Empty, + val selectedPlace: LoadState = LoadState.Empty, + val places: ListLoadState> = ListLoadState.Loading, + val isExceededMaxLength: Boolean = false, + val selectedCategories: Set = emptySet(), + val initialCategories: List = PlaceCategoryUiModel.entries, +) { + val isPlacePreviewVisible: Boolean = + (selectedPlace is LoadState.Success && !selectedPlace.isSecondary) + + val isPlaceSecondaryPreviewVisible: Boolean = + (selectedPlace is LoadState.Success && selectedPlace.isSecondary) + + val hasAnyError: LoadState<*>? + get() = + listOf( + initialMapSetting, + placeGeographies, + timeTags, + selectedTimeTag, + selectedPlace, + if (places is ListLoadState.Error) LoadState.Error(places.throwable) else LoadState.Empty, + ).filterIsInstance() + .firstOrNull() +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/StateExt.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/StateExt.kt new file mode 100644 index 00000000..74cdbf20 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/StateExt.kt @@ -0,0 +1,31 @@ +package com.daedan.festabook.presentation.placeMap.intent.state + +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@OptIn(FlowPreview::class) +suspend inline fun StateFlow.await( + timeout: Duration = 3.seconds, + onTimeout: (Throwable) -> Unit = {}, + crossinline selector: (PlaceMapUiState) -> Any?, +): R = + try { + withTimeout(timeout) { + this@await + .map { selector(it) } + .distinctUntilChanged() + .filterIsInstance() + .first() + } + } catch (e: TimeoutCancellationException) { + onTimeout(e) + throw e + } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapClickListener.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListener.kt similarity index 80% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/MapClickListener.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListener.kt index d7c1de71..534af28a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapClickListener.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListener.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap +package com.daedan.festabook.presentation.placeMap.listener import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapClickListenerImpl.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListenerImpl.kt similarity index 58% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/MapClickListenerImpl.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListenerImpl.kt index 2e902eb3..15cfa3c9 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapClickListenerImpl.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListenerImpl.kt @@ -1,5 +1,7 @@ -package com.daedan.festabook.presentation.placeMap +package com.daedan.festabook.presentation.placeMap.listener +import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import timber.log.Timber @@ -11,12 +13,14 @@ class MapClickListenerImpl( category: PlaceCategoryUiModel, ): Boolean { Timber.d("Marker CLick : placeID: $placeId categoty: $category") - viewModel.selectPlace(placeId) + viewModel.onPlaceMapEvent( + SelectEvent.OnPlaceClick(placeId), + ) return true } override fun onMapClickListener() { Timber.d("Map CLick") - viewModel.unselectPlace() + viewModel.onPlaceMapEvent(SelectEvent.UnSelectPlace) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/OnCameraChangeListener.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/OnCameraChangeListener.kt similarity index 61% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/OnCameraChangeListener.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/OnCameraChangeListener.kt index 6f8c2a5d..a953f3e7 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/OnCameraChangeListener.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/OnCameraChangeListener.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap +package com.daedan.festabook.presentation.placeMap.listener fun interface OnCameraChangeListener { fun onCameraChanged(isExceededMaxLength: Boolean) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapCameraManager.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapCameraManager.kt index 5ff9986e..426f99c6 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapCameraManager.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapCameraManager.kt @@ -1,6 +1,6 @@ package com.daedan.festabook.presentation.placeMap.mapManager -import com.daedan.festabook.presentation.placeMap.OnCameraChangeListener +import com.daedan.festabook.presentation.placeMap.listener.OnCameraChangeListener import com.naver.maps.geometry.LatLng interface MapCameraManager { diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapManager.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapManager.kt index 3a9b73ff..6b81594f 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapManager.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapManager.kt @@ -4,7 +4,7 @@ import androidx.core.content.ContextCompat import com.daedan.festabook.BuildConfig import com.daedan.festabook.R import com.daedan.festabook.presentation.common.toPx -import com.daedan.festabook.presentation.placeMap.MapClickListener +import com.daedan.festabook.presentation.placeMap.listener.MapClickListener import com.daedan.festabook.presentation.placeMap.model.CoordinateUiModel import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel import com.daedan.festabook.presentation.placeMap.model.toLatLng diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapCameraManagerImpl.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapCameraManagerImpl.kt index a55ca399..92807e2b 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapCameraManagerImpl.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapCameraManagerImpl.kt @@ -1,7 +1,7 @@ package com.daedan.festabook.presentation.placeMap.mapManager.internal import com.daedan.festabook.di.mapManager.PlaceMapScope -import com.daedan.festabook.presentation.placeMap.OnCameraChangeListener +import com.daedan.festabook.presentation.placeMap.listener.OnCameraChangeListener import com.daedan.festabook.presentation.placeMap.mapManager.MapCameraManager import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel import com.daedan.festabook.presentation.placeMap.model.toLatLng diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapFilterManagerImpl.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapFilterManagerImpl.kt index 1283ecb9..18435685 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapFilterManagerImpl.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapFilterManagerImpl.kt @@ -64,7 +64,12 @@ class MapFilterManagerImpl( override fun clearFilter() { markers.forEach { marker -> val place = marker.tag as? PlaceCoordinateUiModel ?: return@forEach - marker.isVisible = place.timeTagIds.contains(selectedTimeTagId) + // 타임태그가 없다면, 타임태그 검사 생략 + if (selectedTimeTagId == TimeTag.EMTPY_TIME_TAG_ID) { + marker.isVisible = true + } else { + marker.isVisible = place.timeTagIds.contains(selectedTimeTagId) + } val isSelectedMarker = marker == selectedMarker diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapMarkerManagerImpl.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapMarkerManagerImpl.kt index 8a7e2d12..597fd4cc 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapMarkerManagerImpl.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapMarkerManagerImpl.kt @@ -1,7 +1,7 @@ package com.daedan.festabook.presentation.placeMap.mapManager.internal import com.daedan.festabook.di.mapManager.PlaceMapScope -import com.daedan.festabook.presentation.placeMap.MapClickListener +import com.daedan.festabook.presentation.placeMap.listener.MapClickListener import com.daedan.festabook.presentation.placeMap.mapManager.MapCameraManager import com.daedan.festabook.presentation.placeMap.mapManager.MapMarkerManager import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceListUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceListUiState.kt deleted file mode 100644 index 561e7443..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceListUiState.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.model - -sealed interface PlaceListUiState { - class Loading : PlaceListUiState - - data class Success( - val value: T, - ) : PlaceListUiState - - data class PlaceLoaded( - val value: List, - ) : PlaceListUiState> - - data class Error( - val throwable: Throwable, - ) : PlaceListUiState -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/SelectedPlaceUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/SelectedPlaceUiState.kt deleted file mode 100644 index fbb9bc6e..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/SelectedPlaceUiState.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.model - -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel - -sealed interface SelectedPlaceUiState { - data object Loading : SelectedPlaceUiState - - data object Empty : SelectedPlaceUiState - - data class Success( - val value: PlaceDetailUiModel, - ) : SelectedPlaceUiState { - val isSecondary = value.place.category in PlaceCategoryUiModel.SECONDARY_CATEGORIES - } - - data class Error( - val throwable: Throwable, - ) : SelectedPlaceUiState -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt deleted file mode 100644 index edf66503..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeCategory - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.asFlow -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentPlaceCategoryBinding -import com.daedan.festabook.di.appGraph -import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel -import com.daedan.festabook.presentation.placeMap.logging.PlaceCategoryClick -import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel -import com.daedan.festabook.presentation.placeMap.placeCategory.component.PlaceCategoryScreen -import com.daedan.festabook.presentation.theme.FestabookTheme -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesIntoMap -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.binding - -@ContributesIntoMap(scope = AppScope::class, binding = binding()) -@FragmentKey(PlaceCategoryFragment::class) -@Inject -class PlaceCategoryFragment( - override val defaultViewModelProviderFactory: ViewModelProvider.Factory, -) : BaseFragment() { - override val layoutId: Int = R.layout.fragment_place_category - - private val viewModel: PlaceMapViewModel by viewModels({ requireParentFragment() }) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = - ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - FestabookTheme { - val initialCategories = PlaceCategoryUiModel.entries - // StateFlow로 변경 시 asFlow 제거 예정 - val timeTagChanged = - viewModel.selectedTimeTag - .asFlow() - .collectAsStateWithLifecycle(viewLifecycleOwner) - var selectedCategoriesState by remember(timeTagChanged.value) { - mutableStateOf( - emptySet(), - ) - } - - PlaceCategoryScreen( - initialCategories = initialCategories, - selectedCategories = selectedCategoriesState, - onCategoryClick = { selectedCategories -> - selectedCategoriesState = selectedCategories - viewModel.unselectPlace() - viewModel.setSelectedCategories(selectedCategories.toList()) - appGraph.defaultFirebaseLogger.log( - PlaceCategoryClick( - baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), - currentCategories = selectedCategories.joinToString(",") { it.toString() }, - ), - ) - }, - onDisplayAllClick = { selectedCategories -> - selectedCategoriesState = selectedCategories - viewModel.unselectPlace() - viewModel.setSelectedCategories(initialCategories) - }, - ) - } - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.kt deleted file mode 100644 index cf57845a..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeDetailPreview - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.OnBackPressedCallback -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentPlaceDetailPreviewBinding -import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.logging.logger -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.common.OnMenuItemReClickListener -import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.placeDetail.PlaceDetailActivity -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel -import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel -import com.daedan.festabook.presentation.placeMap.logging.PlacePreviewClick -import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState -import com.daedan.festabook.presentation.placeMap.placeDetailPreview.component.PlaceDetailPreviewScreen -import com.daedan.festabook.presentation.theme.FestabookTheme -import com.daedan.festabook.presentation.theme.festabookSpacing -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesIntoMap -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.binding - -@ContributesIntoMap(scope = AppScope::class, binding = binding()) -@FragmentKey(PlaceDetailPreviewFragment::class) -@Inject -class PlaceDetailPreviewFragment( - override val defaultViewModelProviderFactory: ViewModelProvider.Factory, -) : BaseFragment(), - OnMenuItemReClickListener { - override val layoutId: Int = R.layout.fragment_place_detail_preview - private val viewModel: PlaceMapViewModel by viewModels({ requireParentFragment() }) - private val backPressedCallback = - object : OnBackPressedCallback(false) { - override fun handleOnBackPressed() { - viewModel.unselectPlace() - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - return ComposeView(requireContext()).apply { - super.onCreateView(inflater, container, savedInstanceState) - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - FestabookTheme { - val placeDetailUiState by viewModel.selectedPlaceFlow.collectAsStateWithLifecycle() - val visible = placeDetailUiState is SelectedPlaceUiState.Success - - LaunchedEffect(placeDetailUiState) { - backPressedCallback.isEnabled = true - } - - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.BottomCenter, - ) { - PlaceDetailPreviewScreen( - placeUiState = placeDetailUiState, - visible = visible, - modifier = - Modifier - .padding( - vertical = festabookSpacing.paddingBody4, - horizontal = festabookSpacing.paddingScreenGutter, - ), - onClick = { selectedPlace -> - if (selectedPlace !is SelectedPlaceUiState.Success) return@PlaceDetailPreviewScreen - startPlaceDetailActivity(selectedPlace.value) - binding.logger.log( - PlacePreviewClick( - baseLogData = binding.logger.getBaseLogData(), - placeName = - selectedPlace.value.place.title - ?: "undefined", - timeTag = - viewModel.selectedTimeTag.value?.name - ?: "undefined", - category = selectedPlace.value.place.category.name, - ), - ) - }, - onError = { selectedPlace -> - showErrorSnackBar(selectedPlace.throwable) - }, - onEmpty = { - backPressedCallback.isEnabled = false - }, - ) - } - } - } - } - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - setUpBackPressedCallback() - } - - override fun onMenuItemReClick() { - viewModel.unselectPlace() - } - - private fun setUpBackPressedCallback() { - requireActivity().onBackPressedDispatcher.addCallback( - viewLifecycleOwner, - backPressedCallback, - ) - } - - private fun startPlaceDetailActivity(placeDetail: PlaceDetailUiModel) { - startActivity(PlaceDetailActivity.newIntent(requireContext(), placeDetail)) - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewSecondaryFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewSecondaryFragment.kt deleted file mode 100644 index 48edaeb1..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewSecondaryFragment.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeDetailPreview - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.OnBackPressedCallback -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentPlaceDetailPreviewSecondaryBinding -import com.daedan.festabook.di.appGraph -import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.common.OnMenuItemReClickListener -import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel -import com.daedan.festabook.presentation.placeMap.logging.PlacePreviewClick -import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState -import com.daedan.festabook.presentation.placeMap.placeDetailPreview.component.PlaceDetailPreviewSecondaryScreen -import com.daedan.festabook.presentation.theme.FestabookTheme -import com.daedan.festabook.presentation.theme.festabookSpacing -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesIntoMap -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.binding - -@ContributesIntoMap(scope = AppScope::class, binding = binding()) -@FragmentKey(PlaceDetailPreviewSecondaryFragment::class) -@Inject -class PlaceDetailPreviewSecondaryFragment( - override val defaultViewModelProviderFactory: ViewModelProvider.Factory, -) : BaseFragment(), - OnMenuItemReClickListener { - override val layoutId: Int = R.layout.fragment_place_detail_preview_secondary - - private val viewModel: PlaceMapViewModel by viewModels({ requireParentFragment() }) - private val backPressedCallback = - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - viewModel.unselectPlace() - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val placeDetailUiState by viewModel.selectedPlaceFlow.collectAsStateWithLifecycle() - val visible = placeDetailUiState is SelectedPlaceUiState.Success - - LaunchedEffect(placeDetailUiState) { - backPressedCallback.isEnabled = true - } - - FestabookTheme { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.BottomCenter, - ) { - PlaceDetailPreviewSecondaryScreen( - visible = visible, - placeUiState = placeDetailUiState, - modifier = - Modifier - .padding( - vertical = festabookSpacing.paddingBody4, - horizontal = festabookSpacing.paddingScreenGutter, - ), - onError = { - showErrorSnackBar(it.throwable) - }, - onEmpty = { - backPressedCallback.isEnabled = false - }, - onClick = { - if (it !is SelectedPlaceUiState.Success) return@PlaceDetailPreviewSecondaryScreen - appGraph.defaultFirebaseLogger.log( - PlacePreviewClick( - baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), - placeName = it.value.place.title ?: "undefined", - timeTag = - viewModel.selectedTimeTag.value?.name - ?: "undefined", - category = it.value.place.category.name, - ), - ) - }, - ) - } - } - } - } - } - - override fun onMenuItemReClick() { - viewModel.unselectPlace() - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/OnPlaceClickListener.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/OnPlaceClickListener.kt deleted file mode 100644 index 9ab3aaf8..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/OnPlaceClickListener.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList - -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel - -fun interface OnPlaceClickListener { - fun onPlaceClicked(place: PlaceUiModel) -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt deleted file mode 100644 index 6a082eb3..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt +++ /dev/null @@ -1,222 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList - -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.core.view.isGone -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.lifecycleScope -import coil3.imageLoader -import coil3.request.ImageRequest -import coil3.request.ImageResult -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentPlaceListBinding -import com.daedan.festabook.di.appGraph -import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.common.OnMenuItemReClickListener -import com.daedan.festabook.presentation.common.convertImageUrl -import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.placeDetail.PlaceDetailActivity -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel -import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel -import com.daedan.festabook.presentation.placeMap.logging.PlaceBackToSchoolClick -import com.daedan.festabook.presentation.placeMap.logging.PlaceItemClick -import com.daedan.festabook.presentation.placeMap.logging.PlaceMapButtonReClick -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.placeList.component.PlaceListBottomSheetValue -import com.daedan.festabook.presentation.placeMap.placeList.component.PlaceListScreen -import com.daedan.festabook.presentation.placeMap.placeList.component.rememberPlaceListBottomSheetState -import com.daedan.festabook.presentation.theme.FestabookTheme -import com.naver.maps.map.NaverMap -import com.naver.maps.map.OnMapReadyCallback -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesIntoMap -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.binding -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout -import timber.log.Timber - -@ContributesIntoMap(scope = AppScope::class, binding = binding()) -@FragmentKey(PlaceListFragment::class) -@Inject -class PlaceListFragment( - override val defaultViewModelProviderFactory: ViewModelProvider.Factory, -) : BaseFragment(), - OnPlaceClickListener, - OnMenuItemReClickListener, - OnMapReadyCallback { - override val layoutId: Int = R.layout.fragment_place_list - private val viewModel: PlaceMapViewModel by viewModels({ requireParentFragment() }) - private val childViewModel: PlaceListViewModel by viewModels() - - // 기존 Fragment와의 상호 운용성을 위한 임시 Flow입니다. - // Fragment -> PlaceMapScreen으로 통합 시, 제거할 예정입니다. - private val mapFlow: MutableStateFlow = MutableStateFlow(null) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = - ComposeView(requireContext()).apply { - super.onCreateView(inflater, container, savedInstanceState) - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val places by childViewModel.placesFlow.collectAsStateWithLifecycle() - val isExceedMaxLength by viewModel.isExceededMaxLengthFlow.collectAsStateWithLifecycle() - val bottomSheetState = rememberPlaceListBottomSheetState() - val map by mapFlow.collectAsStateWithLifecycle() - - LaunchedEffect(Unit) { - viewModel.onMapViewClickFlow.collect { - if (isGone || !isResumed || view == null) return@collect - bottomSheetState.update(PlaceListBottomSheetValue.COLLAPSED) - } - } - - FestabookTheme { - PlaceListScreen( - placesUiState = places, - map = map, - onPlaceClick = { onPlaceClicked(it) }, - bottomSheetState = bottomSheetState, - isExceedMaxLength = isExceedMaxLength, - onPlaceLoadFinish = { places -> - preloadImages(requireContext(), places) - }, - onPlaceLoad = { - viewModel.selectedTimeTagFlow.collect { - childViewModel.updatePlacesByTimeTag(it.timeTagId) - } - }, - onError = { - showErrorSnackBar(it.throwable) - }, - onBackToInitialPositionClick = { - viewModel.onBackToInitialPositionClicked() - appGraph.defaultFirebaseLogger.log( - PlaceBackToSchoolClick( - baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), - ), - ) - }, - ) - } - } - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - setUpObserver() - } - - override fun onPlaceClicked(place: PlaceUiModel) { - Timber.d("onPlaceClicked: $place") - startPlaceDetailActivity(place) - appGraph.defaultFirebaseLogger.log( - PlaceItemClick( - baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), - placeId = place.id, - timeTagName = viewModel.selectedTimeTag.value?.name ?: "undefinded", - category = place.category.name, - ), - ) - } - - override fun onMenuItemReClick() { - if (binding.root.isGone || !isResumed || view == null) return - lifecycleScope.launch { - viewModel.onMapViewClick() - } - appGraph.defaultFirebaseLogger.log( - PlaceMapButtonReClick( - baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), - ), - ) - } - - override fun onMapReady(naverMap: NaverMap) { - lifecycleScope.launch { - mapFlow.value = naverMap - } - } - - private fun setUpObserver() { - viewModel.navigateToDetail.observe(viewLifecycleOwner) { selectedPlace -> - startPlaceDetailActivity(selectedPlace) - } - - viewModel.selectedCategories.observe(viewLifecycleOwner) { selectedCategories -> - if (selectedCategories.isEmpty()) { - childViewModel.clearPlacesFilter() - } else { - childViewModel.updatePlacesByCategories(selectedCategories) - } - } - } - - private fun startPlaceDetailActivity(place: PlaceUiModel) { - viewModel.selectPlace(place.id) - } - - private fun startPlaceDetailActivity(placeDetail: PlaceDetailUiModel) { - Timber.d("start detail activity") - val intent = PlaceDetailActivity.newIntent(requireContext(), placeDetail) - startActivity(intent) - } - - // OOM 주의 !! 추후 페이징 처리 및 chunk 단위로 나눠서 로드합니다 - private fun preloadImages( - context: Context, - places: List, - maxSize: Int = 20, - ) { - val imageLoader = context.imageLoader - val deferredList = mutableListOf>() - - lifecycleScope.launch(Dispatchers.IO) { - places - .take(maxSize) - .filterNotNull() - .forEach { place -> - val deferred = - async { - val request = - ImageRequest - .Builder(context) - .data(place.imageUrl.convertImageUrl()) - .build() - - runCatching { - withTimeout(2000) { - imageLoader.execute(request) - } - }.onFailure { - Timber.d("preload 실패") - }.getOrNull() - } - deferredList.add(deferred) - } - deferredList.awaitAll() - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt deleted file mode 100644 index 184101a3..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.asFlow -import androidx.lifecycle.viewModelScope -import com.daedan.festabook.di.viewmodel.ViewModelKey -import com.daedan.festabook.domain.model.PlaceCategory -import com.daedan.festabook.domain.model.TimeTag -import com.daedan.festabook.domain.repository.PlaceListRepository -import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.model.toUiModel -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesIntoMap -import dev.zacsweers.metro.Inject -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -@ContributesIntoMap(AppScope::class) -@ViewModelKey(PlaceListViewModel::class) -@Inject -class PlaceListViewModel( - private val placeListRepository: PlaceListRepository, -) : ViewModel() { - private var cachedPlaces = listOf() - private var cachedPlaceByTimeTag: List = emptyList() - - private val _places: MutableLiveData>> = - MutableLiveData(PlaceListUiState.Loading()) - val places: LiveData>> = _places - - val placesFlow: StateFlow>> = - _places.asFlow().stateIn( - scope = viewModelScope, - started = SharingStarted.Lazily, - initialValue = PlaceListUiState.Loading(), - ) - - init { - loadAllPlaces() - } - - fun updatePlacesByCategories(category: List) { - val secondaryCategories = - PlaceCategory.SECONDARY_CATEGORIES.map { - it.toUiModel() - } - val primaryCategoriesSelected = category.any { it !in secondaryCategories } - - if (!primaryCategoriesSelected) { - clearPlacesFilter() - return - } - val filteredPlaces = - cachedPlaceByTimeTag - .filter { place -> - place.category in category - } - _places.value = PlaceListUiState.Success(filteredPlaces) - } - - private fun filterPlacesByTimeTag(timeTagId: Long): List { - val filteredPlaces = - cachedPlaces.filter { place -> - place.timeTagId.contains(timeTagId) - } - return filteredPlaces - } - - fun updatePlacesByTimeTag(timeTagId: Long) { - val filteredPlaces = - if (timeTagId == TimeTag.EMTPY_TIME_TAG_ID) { - cachedPlaces - } else { - filterPlacesByTimeTag(timeTagId) - } - - _places.value = PlaceListUiState.Success(filteredPlaces) - cachedPlaceByTimeTag = filteredPlaces - } - - fun clearPlacesFilter() { - _places.value = PlaceListUiState.Success(cachedPlaceByTimeTag) - } - - private fun loadAllPlaces() { - viewModelScope.launch { - val result = placeListRepository.getPlaces() - result - .onSuccess { places -> - val placeUiModels = places.map { it.toUiModel() } - cachedPlaces = placeUiModels - _places.value = PlaceListUiState.PlaceLoaded(placeUiModels) - }.onFailure { - _places.value = PlaceListUiState.Error(it) - } - } - } -} diff --git a/app/src/main/res/layout/activity_place_detail.xml b/app/src/main/res/layout/activity_place_detail.xml index 48cf7190..6a3835af 100644 --- a/app/src/main/res/layout/activity_place_detail.xml +++ b/app/src/main/res/layout/activity_place_detail.xml @@ -64,7 +64,7 @@ app:ci_animator="@animator/scale_with_alpha" app:layout_constraintBottom_toBottomOf="@id/vp_place_images" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent"/> + app:layout_constraintEnd_toEndOf="parent" /> + app:layout_constraintTop_toTopOf="@id/vp_place_images" /> - + app:layout_constraintTop_toBottomOf="@id/vp_place_images" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_place_detail_preview.xml b/app/src/main/res/layout/fragment_place_detail_preview.xml deleted file mode 100644 index 52844bbc..00000000 --- a/app/src/main/res/layout/fragment_place_detail_preview.xml +++ /dev/null @@ -1,152 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_place_detail_preview_secondary.xml b/app/src/main/res/layout/fragment_place_detail_preview_secondary.xml deleted file mode 100644 index c25892c8..00000000 --- a/app/src/main/res/layout/fragment_place_detail_preview_secondary.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_place_list.xml b/app/src/main/res/layout/fragment_place_list.xml deleted file mode 100644 index f79db6cc..00000000 --- a/app/src/main/res/layout/fragment_place_list.xml +++ /dev/null @@ -1,128 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_place_list.xml b/app/src/main/res/layout/item_place_list.xml deleted file mode 100644 index 13f3cdae..00000000 --- a/app/src/main/res/layout/item_place_list.xml +++ /dev/null @@ -1,118 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_place_list_skeleton.xml b/app/src/main/res/layout/item_place_list_skeleton.xml deleted file mode 100644 index 2af06106..00000000 --- a/app/src/main/res/layout/item_place_list_skeleton.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/test/java/com/daedan/festabook/FlowExtensions.kt b/app/src/test/java/com/daedan/festabook/FlowExtensions.kt new file mode 100644 index 00000000..16f9d7bf --- /dev/null +++ b/app/src/test/java/com/daedan/festabook/FlowExtensions.kt @@ -0,0 +1,41 @@ +package com.daedan.festabook + +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.timeout +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +fun TestScope.observeEvent(flow: Flow): Deferred { + val event = + backgroundScope.async { + withTimeout(3000) { + flow.first() + } + } + advanceUntilIdle() + return event +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +fun TestScope.observeMultipleEvent( + flow: Flow, + result: MutableList, +) { + backgroundScope.launch(UnconfinedTestDispatcher()) { + flow + .timeout(3.seconds) + .collect { + result.add(it) + } + } +} diff --git a/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailTestFixture.kt b/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailTestFixture.kt index 6ef17fce..dd77ddbc 100644 --- a/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailTestFixture.kt +++ b/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailTestFixture.kt @@ -6,7 +6,7 @@ import com.daedan.festabook.domain.model.PlaceDetail import com.daedan.festabook.domain.model.PlaceDetailImage import com.daedan.festabook.domain.model.TimeTag import com.daedan.festabook.news.FAKE_NOTICES -import com.daedan.festabook.placeList.FAKE_PLACES +import com.daedan.festabook.placeMap.FAKE_PLACES import java.time.LocalTime val FAKE_PLACE_DETAIL = diff --git a/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailViewModelTest.kt b/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailViewModelTest.kt index 0f64bfb2..c072a468 100644 --- a/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailViewModelTest.kt @@ -4,7 +4,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.daedan.festabook.domain.repository.PlaceDetailRepository import com.daedan.festabook.getOrAwaitValue import com.daedan.festabook.news.FAKE_NOTICES -import com.daedan.festabook.placeList.FAKE_PLACES +import com.daedan.festabook.placeMap.FAKE_PLACES import com.daedan.festabook.presentation.news.notice.model.toUiModel import com.daedan.festabook.presentation.placeDetail.PlaceDetailViewModel import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiState diff --git a/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt b/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt deleted file mode 100644 index d605eb11..00000000 --- a/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt +++ /dev/null @@ -1,162 +0,0 @@ -package com.daedan.festabook.placeList - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.daedan.festabook.domain.model.TimeTag -import com.daedan.festabook.domain.repository.PlaceListRepository -import com.daedan.festabook.getOrAwaitValue -import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState -import com.daedan.festabook.presentation.placeMap.model.toUiModel -import com.daedan.festabook.presentation.placeMap.placeList.PlaceListViewModel -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.assertj.core.api.Assertions.assertThat -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class PlaceListViewModelTest { - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - private val testDispatcher = StandardTestDispatcher() - private lateinit var placeListRepository: PlaceListRepository - private lateinit var placeListViewModel: PlaceListViewModel - - @Before - fun setup() { - Dispatchers.setMain(testDispatcher) - placeListRepository = mockk() - coEvery { placeListRepository.getPlaces() } returns Result.success(FAKE_PLACES) - coEvery { placeListRepository.getPlaceGeographies() } returns - Result.success( - FAKE_PLACE_GEOGRAPHIES, - ) - coEvery { placeListRepository.getOrganizationGeography() } returns - Result.success( - FAKE_ORGANIZATION_GEOGRAPHY, - ) - placeListViewModel = - PlaceListViewModel( - placeListRepository, - ) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `뷰모델을 생성했을 때 모든 플레이스 정보를 불러올 수 있다`() = - runTest { - // given - coEvery { placeListRepository.getPlaces() } returns Result.success(FAKE_PLACES) - - // when - placeListViewModel = PlaceListViewModel(placeListRepository) - advanceUntilIdle() - - // then - val expected = FAKE_PLACES.map { it.toUiModel() } - val actual = placeListViewModel.places.getOrAwaitValue() - coVerify { placeListRepository.getPlaces() } - assertThat(actual).isEqualTo(PlaceListUiState.PlaceLoaded(expected)) - } - - @Test - fun `선택된 카테고리를 전달하면 해당 카테고리의 플레이스만 필터링 할 수 있다`() = - runTest { - // given - val targetCategories = - listOf(PlaceCategoryUiModel.FOOD_TRUCK, PlaceCategoryUiModel.BOOTH) - placeListViewModel.updatePlacesByTimeTag(TimeTag.EMPTY.timeTagId) - - // when - placeListViewModel.updatePlacesByCategories(targetCategories) - - // then - val expected = - FAKE_PLACES - .filter { it.category.toUiModel() in targetCategories } - .map { it.toUiModel() } - val actual = placeListViewModel.places.getOrAwaitValue() - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) - } - - @Test - fun `선택된 카테고리가 부스, 주점, 푸드트럭에 해당되지 않을 때 전체 목록을 불러온다`() = - runTest { - // given - val targetCategories = - listOf(PlaceCategoryUiModel.SMOKING_AREA, PlaceCategoryUiModel.TOILET) - placeListViewModel.updatePlacesByTimeTag(TimeTag.EMPTY.timeTagId) - - // when - placeListViewModel.updatePlacesByCategories(targetCategories) - - // then - val expected = FAKE_PLACES.map { it.toUiModel() } - val actual = placeListViewModel.places.getOrAwaitValue() - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) - } - - @Test - fun `필터링을 해제하면 전체 목록을 반환한다`() = - runTest { - // given - val targetCategories = - listOf(PlaceCategoryUiModel.FOOD_TRUCK, PlaceCategoryUiModel.BOOTH) - placeListViewModel.updatePlacesByTimeTag(TimeTag.EMPTY.timeTagId) - placeListViewModel.updatePlacesByCategories(targetCategories) - - // when - placeListViewModel.clearPlacesFilter() - - // then - val expected = FAKE_PLACES.map { it.toUiModel() } - val actual = placeListViewModel.places.getOrAwaitValue() - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) - } - - @Test - fun `타임 태그를 기준으로 필터링 할 수 있다`() = - runTest { - // given - val expected = - listOf( - FAKE_PLACES.first().toUiModel(), - ) - - // when - placeListViewModel.updatePlacesByTimeTag(1) - - // then - val actual = placeListViewModel.places.getOrAwaitValue() - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) - } - - @Test - fun `타임 태그가 없을 때 전체 목록을 반환한다`() = - runTest { - // given - val expected = FAKE_PLACES.map { it.toUiModel() } - val emptyTimeTag = TimeTag.EMPTY - - // when - placeListViewModel.updatePlacesByTimeTag(emptyTimeTag.timeTagId) - - // then - val actual = placeListViewModel.places.getOrAwaitValue() - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) - } -} diff --git a/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt b/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt deleted file mode 100644 index 2f8ba679..00000000 --- a/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt +++ /dev/null @@ -1,329 +0,0 @@ -package com.daedan.festabook.placeList - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.daedan.festabook.domain.model.TimeTag -import com.daedan.festabook.domain.repository.PlaceDetailRepository -import com.daedan.festabook.domain.repository.PlaceListRepository -import com.daedan.festabook.getOrAwaitValue -import com.daedan.festabook.placeDetail.FAKE_ETC_PLACE_DETAIL -import com.daedan.festabook.placeDetail.FAKE_PLACE_DETAIL -import com.daedan.festabook.presentation.common.Event -import com.daedan.festabook.presentation.placeDetail.model.toUiModel -import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel -import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState -import com.daedan.festabook.presentation.placeMap.model.toUiModel -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.assertj.core.api.Assertions.assertThat -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class PlaceMapViewModelTest { - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - private val testDispatcher = StandardTestDispatcher() - private lateinit var placeListRepository: PlaceListRepository - private lateinit var placeDetailRepository: PlaceDetailRepository - private lateinit var placeMapViewModel: PlaceMapViewModel - - @Before - fun setup() { - Dispatchers.setMain(testDispatcher) - placeListRepository = mockk() - placeDetailRepository = mockk() - coEvery { placeListRepository.getPlaces() } returns Result.success(FAKE_PLACES) - coEvery { placeListRepository.getPlaceGeographies() } returns - Result.success( - FAKE_PLACE_GEOGRAPHIES, - ) - coEvery { placeListRepository.getOrganizationGeography() } returns - Result.success( - FAKE_ORGANIZATION_GEOGRAPHY, - ) - coEvery { placeListRepository.getTimeTags() } returns - Result.success( - listOf( - FAKE_TIME_TAG, - ), - ) - placeMapViewModel = - PlaceMapViewModel( - placeListRepository, - placeDetailRepository, - ) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `뷰모델을 생성했을 때 전체 타임 태그와 선택된 타임 태그를 불러올 수 있다`() = - runTest { - // given - when - placeMapViewModel = - PlaceMapViewModel(placeListRepository, placeDetailRepository) - advanceUntilIdle() - - // then - val actualAllTimeTag = placeMapViewModel.timeTags.value - val actualSelectedTimeTag = placeMapViewModel.selectedTimeTag.getOrAwaitValue() - assertThat(actualAllTimeTag).isEqualTo(listOf(FAKE_TIME_TAG)) - assertThat(actualSelectedTimeTag).isEqualTo(FAKE_TIME_TAG) - } - - @Test - fun `뷰모델을 생성했을 때 타임 태그가 없다면 빈 리스트와 Empty타임 태그를 불러온다`() = - runTest { - // given - coEvery { - placeListRepository.getTimeTags() - } returns Result.success(emptyList()) - - // when - placeMapViewModel = PlaceMapViewModel(placeListRepository, placeDetailRepository) - advanceUntilIdle() - - // then - val actualAllTimeTag = placeMapViewModel.timeTags.value - val actualSelectedTimeTag = placeMapViewModel.selectedTimeTag.getOrAwaitValue() - assertThat(actualAllTimeTag).isEqualTo(emptyList()) - assertThat(actualSelectedTimeTag).isEqualTo(TimeTag.EMPTY) - } - - @Test - fun `뷰모델을 생성했을 때 모든 플레이스의 지도 좌표 정보를 불러올 수 있다`() = - runTest { - // given - coEvery { placeListRepository.getPlaceGeographies() } returns - Result.success( - FAKE_PLACE_GEOGRAPHIES, - ) - - // when - placeMapViewModel = PlaceMapViewModel(placeListRepository, placeDetailRepository) - advanceUntilIdle() - - // then - val expected = FAKE_PLACE_GEOGRAPHIES.map { it.toUiModel() } - val actual = placeMapViewModel.placeGeographies.getOrAwaitValue() - coVerify { placeListRepository.getPlaceGeographies() } - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) - } - - @Test - fun `뷰모델을 생성했을 때 초기 학교 지리 정보를 불러올 수 있다`() = - runTest { - // given - coEvery { placeListRepository.getOrganizationGeography() } returns - Result.success( - FAKE_ORGANIZATION_GEOGRAPHY, - ) - - // when - placeMapViewModel = PlaceMapViewModel(placeListRepository, placeDetailRepository) - advanceUntilIdle() - - // then - val expected = FAKE_ORGANIZATION_GEOGRAPHY.toUiModel() - val actual = placeMapViewModel.initialMapSetting.getOrAwaitValue() - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) - } - - @Test - fun `뷰모델을 생성했을 때 정보 로드에 실패하면 독립적으로 에러 상태를 표시한다`() = - runTest { - // given - val exception = Throwable("테스트") - coEvery { placeListRepository.getPlaces() } returns Result.failure(exception) - coEvery { placeListRepository.getOrganizationGeography() } returns - Result.success( - FAKE_ORGANIZATION_GEOGRAPHY, - ) - coEvery { placeListRepository.getPlaceGeographies() } returns Result.failure(exception) - - // when - placeMapViewModel = PlaceMapViewModel(placeListRepository, placeDetailRepository) - advanceUntilIdle() - - // then - val expected2 = - PlaceListUiState.Success(FAKE_ORGANIZATION_GEOGRAPHY.toUiModel()) - val actual2 = placeMapViewModel.initialMapSetting.getOrAwaitValue() - - val expected3 = PlaceListUiState.Error(exception) - val actual3 = placeMapViewModel.placeGeographies.getOrAwaitValue() - - assertThat(actual2).isEqualTo(expected2) - assertThat(actual3).isEqualTo(expected3) - } - - @Test - fun `플레이스의 아이디와 카테고리가 있으면 플레이스 상세를 선택할 수 있다`() = - runTest { - // given - coEvery { placeDetailRepository.getPlaceDetail(1) } returns - Result.success( - FAKE_PLACE_DETAIL, - ) - - // when - placeMapViewModel.selectPlace(1) - advanceUntilIdle() - - // then - coVerify { placeDetailRepository.getPlaceDetail(1) } - - val expected = SelectedPlaceUiState.Success(FAKE_PLACE_DETAIL.toUiModel()) - val actual = placeMapViewModel.selectedPlace.getOrAwaitValue() - assertThat(actual).isEqualTo(expected) - } - - @Test - fun `카테고리가 기타시설일 떄에도 플레이스 상세를 선택할 수 있다`() = - runTest { - // given - coEvery { placeDetailRepository.getPlaceDetail(1) } returns - Result.success( - FAKE_ETC_PLACE_DETAIL, - ) - - // when - placeMapViewModel.selectPlace(1) - advanceUntilIdle() - - // then - val expected = SelectedPlaceUiState.Success(FAKE_ETC_PLACE_DETAIL.toUiModel()) - val actual = placeMapViewModel.selectedPlace.getOrAwaitValue() - assertThat(actual).isEqualTo(expected) - } - - @Test - fun `플레이스 상세 선택을 해제할 수 있다`() = - runTest { - // given - coEvery { placeDetailRepository.getPlaceDetail(1) } returns - Result.success( - FAKE_PLACE_DETAIL, - ) - placeMapViewModel.selectPlace(1) - advanceUntilIdle() - - // when - placeMapViewModel.unselectPlace() - advanceUntilIdle() - - // then - val expected = SelectedPlaceUiState.Empty - val actual = placeMapViewModel.selectedPlace.getOrAwaitValue() - assertThat(actual).isEqualTo(expected) - } - - @Test - fun `초기 위치로 돌아가기 버튼 클릭 시 이벤트가 방출된다`() = - runTest { - // given - - // when - placeMapViewModel.onBackToInitialPositionClicked() - advanceUntilIdle() - - // then - val actual = placeMapViewModel.backToInitialPositionClicked.getOrAwaitValue() - assertThat(actual).isInstanceOf(Event::class.java) - } - - @Test - fun `학교로 돌아가기 버튼이 나타나지 않는 임계값을 넣을 수 있다`() = - runTest { - // given - val isExceededMaxLength = true - - // when - placeMapViewModel.setIsExceededMaxLength(isExceededMaxLength) - - // then - val actual = placeMapViewModel.isExceededMaxLength.getOrAwaitValue() - assertThat(actual).isEqualTo(isExceededMaxLength) - } - - @Test - fun `선택된 카테고리 값을 넣을 수 있다`() = - runTest { - // given - val categories = listOf(PlaceCategoryUiModel.FOOD_TRUCK, PlaceCategoryUiModel.BOOTH) - - // when - placeMapViewModel.setSelectedCategories(categories) - - // then - val actual = placeMapViewModel.selectedCategories.getOrAwaitValue() - assertThat(actual).isEqualTo(categories) - } - - @Test - fun `지도를 클릭했을 때 이벤트를 발생시킬수 있다`() = - runTest { - // given - val expected = Unit - - // when - placeMapViewModel.onMapViewClick() - advanceUntilIdle() - - // then - val actual = placeMapViewModel.onMapViewClick.getOrAwaitValue() - assertThat(actual.peekContent()).isEqualTo(expected) - } - - @Test - fun `현재 플레이스를 선택 후, 플레이스 상세로 이벤트를 발생시킬 수 있다`() = - runTest { - // given - coEvery { - placeDetailRepository.getPlaceDetail(FAKE_PLACE_DETAIL.id) - } returns Result.success(FAKE_PLACE_DETAIL) - val expected = FAKE_PLACE_DETAIL.toUiModel() - placeMapViewModel.selectPlace(FAKE_PLACE_DETAIL.id) - advanceUntilIdle() - - // when - placeMapViewModel.onExpandedStateReached() - advanceUntilIdle() - - // then - val actual = placeMapViewModel.navigateToDetail.value - assertThat(actual).isEqualTo(expected) - } - - @Test - fun `타임태그가 선택되었음을 알리는 이벤트를 발생시킬 수 있다`() = - runTest { - // given - val expected = TimeTag(1, "테스트1") - - // when - placeMapViewModel.onDaySelected(expected) - advanceUntilIdle() - - // then - val actual = placeMapViewModel.selectedTimeTag.getOrAwaitValue() - assertThat(actual).isEqualTo(expected) - } -} diff --git a/app/src/test/java/com/daedan/festabook/placeList/PlaceLIstTestFixture.kt b/app/src/test/java/com/daedan/festabook/placeMap/PlaceMapTestFixture.kt similarity index 65% rename from app/src/test/java/com/daedan/festabook/placeList/PlaceLIstTestFixture.kt rename to app/src/test/java/com/daedan/festabook/placeMap/PlaceMapTestFixture.kt index c29339a6..954f8724 100644 --- a/app/src/test/java/com/daedan/festabook/placeList/PlaceLIstTestFixture.kt +++ b/app/src/test/java/com/daedan/festabook/placeMap/PlaceMapTestFixture.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.placeList +package com.daedan.festabook.placeMap import com.daedan.festabook.domain.model.Coordinate import com.daedan.festabook.domain.model.OrganizationGeography @@ -6,6 +6,8 @@ import com.daedan.festabook.domain.model.Place import com.daedan.festabook.domain.model.PlaceCategory import com.daedan.festabook.domain.model.PlaceGeography import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.presentation.placeMap.model.CoordinateUiModel +import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel val FAKE_PLACES = listOf( @@ -45,6 +47,44 @@ val FAKE_PLACES = ), ) +val FAKE_PLACES_CATEGORY_FIXTURE = + listOf( + Place( + id = 1, + imageUrl = null, + category = PlaceCategory.FOOD_TRUCK, + title = "테스트 1", + description = "설명 1", + location = "위치 1", + timeTags = + listOf( + TimeTag( + timeTagId = 1, + name = "테스트1", + ), + TimeTag( + timeTagId = 2, + name = "테스트2", + ), + ), + ), + Place( + id = 2, + imageUrl = null, + category = PlaceCategory.BAR, + title = "테스트 2", + description = "설명 2", + location = "위치 2", + timeTags = + listOf( + TimeTag( + timeTagId = 2, + name = "테스트2", + ), + ), + ), + ) + val FAKE_PLACE_GEOGRAPHIES = listOf( PlaceGeography( @@ -119,3 +159,20 @@ val FAKE_TIME_TAG = timeTagId = 1, name = "테스트1", ) + +val FAKE_INITIAL_MAP_SETTING = + InitialMapSettingUiModel( + zoom = 10, + initialCenter = + CoordinateUiModel( + latitude = 10.0, + longitude = 10.0, + ), + border = + listOf( + CoordinateUiModel( + latitude = 10.0, + longitude = 10.0, + ), + ), + ) diff --git a/app/src/test/java/com/daedan/festabook/placeMap/PlaceMapViewModelTest.kt b/app/src/test/java/com/daedan/festabook/placeMap/PlaceMapViewModelTest.kt new file mode 100644 index 00000000..b555d87b --- /dev/null +++ b/app/src/test/java/com/daedan/festabook/placeMap/PlaceMapViewModelTest.kt @@ -0,0 +1,290 @@ +package com.daedan.festabook.placeMap + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.daedan.festabook.di.placeMapHandler.PlaceMapHandlerGraph +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.domain.repository.PlaceListRepository +import com.daedan.festabook.observeEvent +import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.intent.event.FilterEvent +import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.ListLoadState +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.model.toUiModel +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PlaceMapViewModelTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + private val testDispatcher = StandardTestDispatcher() + + private val handlerGraphFactory = mockk(relaxed = true) + private lateinit var placeListRepository: PlaceListRepository + private lateinit var placeMapViewModel: PlaceMapViewModel + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + placeListRepository = mockk(relaxed = true) + coEvery { placeListRepository.getPlaces() } returns Result.success(FAKE_PLACES) + coEvery { placeListRepository.getPlaceGeographies() } returns + Result.success( + FAKE_PLACE_GEOGRAPHIES, + ) + coEvery { placeListRepository.getOrganizationGeography() } returns + Result.success( + FAKE_ORGANIZATION_GEOGRAPHY, + ) + coEvery { placeListRepository.getTimeTags() } returns + Result.success( + listOf( + FAKE_TIME_TAG, + ), + ) + + placeMapViewModel = + PlaceMapViewModel( + placeListRepository, + handlerGraphFactory, + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `뷰모델을 생성했을 때 전체 타임태그, 선택된 타임태그를 불러올 수 있다`() = + runTest { + // given - when + placeMapViewModel = + PlaceMapViewModel(placeListRepository, handlerGraphFactory) + advanceUntilIdle() + + // then + val uiState = placeMapViewModel.uiState.value + val actualAllTimeTag = uiState.timeTags + val actualSelectedTimeTag = uiState.selectedTimeTag + assertThat(actualAllTimeTag).isEqualTo( + LoadState.Success( + listOf(FAKE_TIME_TAG), + ), + ) + assertThat(actualSelectedTimeTag).isEqualTo( + LoadState.Success(FAKE_TIME_TAG), + ) + } + + @Test + fun `뷰모델을 생성했을 때 모든 플레이스 정보를 불러올 수 있다`() = + runTest { + // given + coEvery { placeListRepository.getPlaces() } returns Result.success(FAKE_PLACES) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + advanceUntilIdle() + + // then + val expected = FAKE_PLACES.map { it.toUiModel() } + val uiState = placeMapViewModel.uiState.value + val actual = uiState.places + coVerify { placeListRepository.getPlaces() } + assertThat(actual).isEqualTo(ListLoadState.PlaceLoaded(expected)) + } + + @Test + fun `뷰모델을 생성했을 때 타임 태그가 없다면 빈 리스트와 Empty타임 태그를 불러온다`() = + runTest { + // given + coEvery { + placeListRepository.getTimeTags() + } returns Result.success(emptyList()) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + advanceUntilIdle() + + // then + val uiState = placeMapViewModel.uiState.value + val actualAllTimeTag = uiState.timeTags + val actualSelectedTimeTag = uiState.selectedTimeTag + assertThat(actualAllTimeTag).isEqualTo( + LoadState.Success(emptyList()), + ) + assertThat(actualSelectedTimeTag).isEqualTo( + LoadState.Empty, + ) + } + + @Test + fun `뷰모델을 생성했을 때 모든 플레이스의 지도 좌표 정보를 불러올 수 있다`() = + runTest { + // given + coEvery { placeListRepository.getPlaceGeographies() } returns + Result.success( + FAKE_PLACE_GEOGRAPHIES, + ) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + advanceUntilIdle() + + // then + val expected = FAKE_PLACE_GEOGRAPHIES.map { it.toUiModel() } + val uiState = placeMapViewModel.uiState.value + val actual = uiState.placeGeographies + coVerify { placeListRepository.getPlaceGeographies() } + assertThat(actual).isEqualTo(LoadState.Success(expected)) + } + + @Test + fun `뷰모델을 생성했을 때 초기 학교 지리 정보를 불러올 수 있다`() = + runTest { + // given + coEvery { placeListRepository.getOrganizationGeography() } returns + Result.success( + FAKE_ORGANIZATION_GEOGRAPHY, + ) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + advanceUntilIdle() + + // then + val expected = FAKE_ORGANIZATION_GEOGRAPHY.toUiModel() + val uiState = placeMapViewModel.uiState.value + val actual = uiState.initialMapSetting + assertThat(actual).isEqualTo(LoadState.Success(expected)) + } + + @Test + fun `뷰모델을 생성했을 때 정보 로드에 실패하면 독립적으로 에러 상태를 표시한다`() = + runTest { + // given + val exception = Throwable("테스트") + coEvery { placeListRepository.getPlaces() } returns Result.failure(exception) + coEvery { placeListRepository.getOrganizationGeography() } returns + Result.success( + FAKE_ORGANIZATION_GEOGRAPHY, + ) + coEvery { placeListRepository.getPlaceGeographies() } returns Result.failure(exception) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + advanceUntilIdle() + + // then + val uiState = placeMapViewModel.uiState.value + val expected2 = + LoadState.Success(FAKE_ORGANIZATION_GEOGRAPHY.toUiModel()) + val actual2 = uiState.initialMapSetting + + val expected3 = LoadState.Error(exception) + val actual3 = uiState.placeGeographies + + assertThat(actual2).isEqualTo(expected2) + assertThat(actual3).isEqualTo(expected3) + } + + @Test + fun `특정 액션을 받으면 액션 핸들러가 호출된다`() = + runTest { + // given + val fakeHandlerGraph = mockk(relaxed = true) + coEvery { + handlerGraphFactory.create(any()) + } returns fakeHandlerGraph + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + placeMapViewModel.onPlaceMapEvent(SelectEvent.UnSelectPlace) + placeMapViewModel.onPlaceMapEvent(FilterEvent.OnPlaceLoad) + placeMapViewModel.onPlaceMapEvent(MapControlEvent.OnMapDrag) + advanceUntilIdle() + + // then + coVerify(exactly = 1) { fakeHandlerGraph.filterEventHandler } + coVerify(exactly = 1) { fakeHandlerGraph.selectEventHandler } + coVerify(exactly = 1) { fakeHandlerGraph.mapControlEventHandler } + } + + @Test + fun `메뉴 아이템 재클릭 이벤트를 발송할 수 있다`() = + runTest { + // given + val event = observeEvent(placeMapViewModel.placeMapSideEffect) + + // when + placeMapViewModel.onMenuItemReClicked() + val result = event.await() + advanceUntilIdle() + + // then + assertThat(result).isInstanceOf(PlaceMapSideEffect.MenuItemReClicked::class.java) + } + + @Test + fun `LoadState가 하나라도 에러가 있다면 에러 이벤트를 발송할 수 있다`() = + runTest { + // given + val throwable = Throwable() + coEvery { placeListRepository.getPlaceGeographies() } returns Result.failure(throwable) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + val event = observeEvent(placeMapViewModel.placeMapSideEffect) + advanceUntilIdle() + + // then + val result = event.await() + advanceUntilIdle() + + assertThat(result).isEqualTo( + PlaceMapSideEffect.ShowErrorSnackBar( + LoadState.Error(throwable), + ), + ) + } + + @Test + fun `ListLoadState가 하나라도 에러가 있다면 에러 이벤트를 발송할 수 있다`() = + runTest { + // given + val throwable = Throwable() + coEvery { placeListRepository.getPlaces() } returns Result.failure(throwable) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + val event = observeEvent(placeMapViewModel.placeMapSideEffect) + advanceUntilIdle() + + // then + val result = event.await() + advanceUntilIdle() + + assertThat(result).isEqualTo( + PlaceMapSideEffect.ShowErrorSnackBar( + LoadState.Error(throwable), + ), + ) + } +} diff --git a/app/src/test/java/com/daedan/festabook/placeMap/handler/FilterActionHandlerTest.kt b/app/src/test/java/com/daedan/festabook/placeMap/handler/FilterActionHandlerTest.kt new file mode 100644 index 00000000..d2304b67 --- /dev/null +++ b/app/src/test/java/com/daedan/festabook/placeMap/handler/FilterActionHandlerTest.kt @@ -0,0 +1,238 @@ +package com.daedan.festabook.placeMap.handler + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.observeMultipleEvent +import com.daedan.festabook.placeMap.FAKE_PLACES_CATEGORY_FIXTURE +import com.daedan.festabook.placeMap.FAKE_TIME_TAG +import com.daedan.festabook.presentation.placeMap.intent.event.FilterEvent +import com.daedan.festabook.presentation.placeMap.intent.handler.EventHandlerContext +import com.daedan.festabook.presentation.placeMap.intent.handler.FilterEventHandler +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.MapControlSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.ListLoadState +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.model.toUiModel +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class FilterActionHandlerTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + private val testDispatcher = StandardTestDispatcher() + + private lateinit var filterActionHandler: FilterEventHandler + + private lateinit var cachedPlaces: MutableStateFlow> + + private lateinit var uiState: MutableStateFlow + + private val cachedPlaceByTimeTag = + MutableStateFlow(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + + private val mapControlUiEvent: Channel = + Channel( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + uiState = MutableStateFlow(PlaceMapUiState()) + cachedPlaces = MutableStateFlow(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + + filterActionHandler = + FilterEventHandler( + EventHandlerContext( + uiState = uiState, + mapControlSideEffect = mapControlUiEvent, + onUpdateState = { uiState.update(it) }, + onUpdateCachedPlace = { cachedPlaceByTimeTag.tryEmit(it) }, + cachedPlaces = cachedPlaces, + cachedPlaceByTimeTag = cachedPlaceByTimeTag, + scope = CoroutineScope(testDispatcher), + placeMapSideEffect = mockk(relaxed = true), + ), + logger = mockk(relaxed = true), + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `선택된 카테고리 값을 선택하면 카테고리 필터 이벤트가 방출되고, 카테고리를 필터링 할 수 있다`() = + runTest { + // given + val categories = setOf(PlaceCategoryUiModel.BOOTH) + val eventResult = mutableListOf() + observeMultipleEvent(mapControlUiEvent.consumeAsFlow(), eventResult) + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { it.copy(places = places) } + + // when + filterActionHandler(FilterEvent.OnCategoryClick(categories)) + + // then + advanceUntilIdle() + + assertThat( + uiState.value.selectedCategories, + ).isEqualTo( + categories, + ) + + assertThat(eventResult).containsExactly( + MapControlSideEffect.UnselectMarker, + MapControlSideEffect.FilterMapByCategory(categories.toList()), + ) + assertThat(uiState.value.places).isEqualTo( + ListLoadState.Success(emptyList()), + ) + } + + @Test + fun `선택된 카테고리가 부스, 주점, 푸드트럭에 해당되지 않을 때 전체 목록을 불러온다`() = + runTest { + // given + val targetCategories = + setOf(PlaceCategoryUiModel.SMOKING_AREA, PlaceCategoryUiModel.TOILET) + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { it.copy(places = places) } + + // when + filterActionHandler(FilterEvent.OnCategoryClick(targetCategories)) + advanceUntilIdle() + + // then + val expected = + ListLoadState.Success( + FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }, + ) + val actual = uiState.value.places + assertThat(actual).isEqualTo(expected) + } + + @Test + fun `기타 카테고리만 선택되었다면 전체 목록을 불러온다`() = + runTest { + // given + val targetCategories = + setOf(PlaceCategoryUiModel.TOILET, PlaceCategoryUiModel.SMOKING_AREA) + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { it.copy(places = places) } + + // when + filterActionHandler(FilterEvent.OnCategoryClick(targetCategories)) + advanceUntilIdle() + + // then + val expected = + ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + val actual = uiState.value.places + assertThat(actual).isEqualTo(expected) + } + + @Test + fun `필터링을 해제하면 전체 목록을 반환한다`() = + runTest { + // given + val targetCategories = + setOf(PlaceCategoryUiModel.FOOD_TRUCK, PlaceCategoryUiModel.BOOTH) + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { it.copy(places = places) } + filterActionHandler(FilterEvent.OnCategoryClick(targetCategories)) + + // when + filterActionHandler(FilterEvent.OnCategoryClick(emptySet())) + + // then + val expected = FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() } + val actual = uiState.value.places + assertThat(actual).isEqualTo(ListLoadState.Success(expected)) + } + + @Test + fun `타임 태그를 기준으로 필터링 할 수 있다`() = + runTest { + // given + val expected = + listOf( + FAKE_PLACES_CATEGORY_FIXTURE.first().toUiModel(), + ) + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { it.copy(places = places) } + + // when + filterActionHandler.updatePlacesByTimeTag(FAKE_TIME_TAG.timeTagId) + + // then + val actual = uiState.value.places + assertThat(actual).isEqualTo(ListLoadState.Success(expected)) + } + + @Test + fun `타임 태그가 없을 때 전체 목록을 반환한다`() = + runTest { + // given + val expected = FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() } + val emptyTimeTag = TimeTag.EMPTY + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { it.copy(places = places) } + + // when + filterActionHandler.updatePlacesByTimeTag(emptyTimeTag.timeTagId) + advanceUntilIdle() + + // then + val actual = uiState.value.places + assertThat(actual).isEqualTo(ListLoadState.Success(expected)) + } + + @Test + fun `플레이스가 로드가 완료되었을 때 선택된 타임 태그로 필터링할 수 있다`() = + runTest { + // given + val expected = + listOf( + FAKE_PLACES_CATEGORY_FIXTURE.first().toUiModel(), + ) + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { + it.copy( + places = places, + selectedTimeTag = LoadState.Success(FAKE_TIME_TAG), + ) + } + + // when + filterActionHandler(FilterEvent.OnPlaceLoad) + advanceUntilIdle() + + // then + val actual = uiState.value.places + assertThat(actual).isEqualTo(ListLoadState.Success(expected)) + } +} diff --git a/app/src/test/java/com/daedan/festabook/placeMap/handler/MapEventActionHandlerTest.kt b/app/src/test/java/com/daedan/festabook/placeMap/handler/MapEventActionHandlerTest.kt new file mode 100644 index 00000000..10691d5e --- /dev/null +++ b/app/src/test/java/com/daedan/festabook/placeMap/handler/MapEventActionHandlerTest.kt @@ -0,0 +1,166 @@ +package com.daedan.festabook.placeMap.handler + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.daedan.festabook.observeEvent +import com.daedan.festabook.observeMultipleEvent +import com.daedan.festabook.placeMap.FAKE_INITIAL_MAP_SETTING +import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent +import com.daedan.festabook.presentation.placeMap.intent.handler.EventHandlerContext +import com.daedan.festabook.presentation.placeMap.intent.handler.MapControlEventHandler +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.MapControlSideEffect +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MapEventActionHandlerTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + private val testDispatcher = StandardTestDispatcher() + private lateinit var mapEventActionHandler: MapControlEventHandler + + private lateinit var uiState: MutableStateFlow + + private val mapControlUiEvent: Channel = + Channel( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + private val placeMapUiEvent: Channel = + Channel( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + uiState = MutableStateFlow(PlaceMapUiState()) + mapEventActionHandler = + MapControlEventHandler( + EventHandlerContext( + uiState = uiState, + onUpdateState = { uiState.update(it) }, + mapControlSideEffect = mapControlUiEvent, + placeMapSideEffect = placeMapUiEvent, + scope = CoroutineScope(testDispatcher), + cachedPlaces = mockk(relaxed = true), + cachedPlaceByTimeTag = mockk(relaxed = true), + onUpdateCachedPlace = mockk(relaxed = true), + ), + logger = mockk(relaxed = true), + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `초기 위치로 돌아가기 버튼 클릭 시 이벤트가 방출된다`() = + runTest { + // given + val eventResult = observeEvent(mapControlUiEvent.receiveAsFlow()) + advanceUntilIdle() + + // when + mapEventActionHandler(MapControlEvent.OnBackToInitialPositionClick) + + val event = eventResult.await() + advanceUntilIdle() + + // then + assertThat(event).isEqualTo(MapControlSideEffect.BackToInitialPosition) + } + + @Test + fun `지도가 준비되었을 때 지도 관련 로직 초기화 이벤트를 방출할 수 있다`() = + runTest { + // given + val eventResult = mutableListOf() + observeMultipleEvent(mapControlUiEvent.receiveAsFlow(), eventResult) + + val initialSetting = FAKE_INITIAL_MAP_SETTING + uiState.update { + it.copy(initialMapSetting = LoadState.Success(initialSetting)) + } + + // when + mapEventActionHandler(MapControlEvent.OnMapReady) + advanceUntilIdle() + + // then + assertThat(eventResult).containsExactly( + MapControlSideEffect.InitMap, + MapControlSideEffect.InitMapManager(initialSetting), + ) + } + + @Test + fun `플레이스 로딩이 완료되었을 때 프리로드 이미지 이벤트를 방출할 수 있다`() = + runTest { + // given + val eventResult = observeEvent(placeMapUiEvent.receiveAsFlow()) + advanceUntilIdle() + + // when + mapEventActionHandler(MapControlEvent.OnPlaceLoadFinish(emptyList())) + + // then + val event = eventResult.await() + advanceUntilIdle() + assertThat(event).isEqualTo(PlaceMapSideEffect.PreloadImages(emptyList())) + } + + @Test + fun `초기 위치로 돌아갔을 때 방출할 수 있다`() = + runTest { + // given + val eventResult = observeEvent(mapControlUiEvent.receiveAsFlow()) + advanceUntilIdle() + + // when + mapEventActionHandler(MapControlEvent.OnBackToInitialPositionClick) + val event = eventResult.await() + advanceUntilIdle() + + // then + assertThat(event).isEqualTo(MapControlSideEffect.BackToInitialPosition) + } + + @Test + fun `지도가 드래그 되었을 때 이벤트를 방출할 수 있다`() = + runTest { + // given + val eventResult = observeEvent(placeMapUiEvent.receiveAsFlow()) + advanceUntilIdle() + + // when + mapEventActionHandler(MapControlEvent.OnMapDrag) + + // then + val event = eventResult.await() + advanceUntilIdle() + + assertThat(event).isEqualTo(PlaceMapSideEffect.MapViewDrag(false)) + } +} diff --git a/app/src/test/java/com/daedan/festabook/placeMap/handler/SelectActionHandlerTest.kt b/app/src/test/java/com/daedan/festabook/placeMap/handler/SelectActionHandlerTest.kt new file mode 100644 index 00000000..472be353 --- /dev/null +++ b/app/src/test/java/com/daedan/festabook/placeMap/handler/SelectActionHandlerTest.kt @@ -0,0 +1,244 @@ +package com.daedan.festabook.placeMap.handler + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.domain.repository.PlaceDetailRepository +import com.daedan.festabook.observeEvent +import com.daedan.festabook.placeDetail.FAKE_ETC_PLACE_DETAIL +import com.daedan.festabook.placeDetail.FAKE_PLACE_DETAIL +import com.daedan.festabook.placeMap.FAKE_TIME_TAG +import com.daedan.festabook.presentation.placeDetail.model.toUiModel +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent +import com.daedan.festabook.presentation.placeMap.intent.handler.EventHandlerContext +import com.daedan.festabook.presentation.placeMap.intent.handler.SelectEventHandler +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.MapControlSideEffect +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SelectActionHandlerTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + private val testDispatcher = StandardTestDispatcher() + private lateinit var selectActionHandler: SelectEventHandler + + private lateinit var uiState: MutableStateFlow + + private lateinit var placeDetailRepository: PlaceDetailRepository + + private val mapControlUiEvent: Channel = + Channel( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + private val placeMapUiEvent: Channel = + Channel( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + placeDetailRepository = mockk() + uiState = MutableStateFlow(PlaceMapUiState()) + + selectActionHandler = + SelectEventHandler( + EventHandlerContext( + mapControlSideEffect = mapControlUiEvent, + placeMapSideEffect = placeMapUiEvent, + uiState = uiState, + onUpdateState = { uiState.update(it) }, + scope = CoroutineScope(testDispatcher), + cachedPlaces = mockk(relaxed = true), + cachedPlaceByTimeTag = mockk(relaxed = true), + onUpdateCachedPlace = mockk(relaxed = true), + ), + logger = mockk(relaxed = true), + filterActionHandler = mockk(relaxed = true), + placeDetailRepository = placeDetailRepository, + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `플레이스의 아이디와 카테고리가 있으면 플레이스 상세를 선택할 수 있다`() = + runTest { + // given + coEvery { placeDetailRepository.getPlaceDetail(1) } returns + Result.success( + FAKE_PLACE_DETAIL, + ) + val eventResult = observeEvent(mapControlUiEvent.receiveAsFlow()) + + // when + selectActionHandler(SelectEvent.OnPlaceClick(1)) + advanceUntilIdle() + + // then + coVerify { placeDetailRepository.getPlaceDetail(1) } + + val event = eventResult.await() + advanceUntilIdle() + + val expected = LoadState.Success(FAKE_PLACE_DETAIL.toUiModel()) + val actual = uiState.value.selectedPlace + assertThat(actual).isEqualTo(expected) + assertThat(event).isEqualTo(MapControlSideEffect.SelectMarker(expected)) + } + + @Test + fun `카테고리가 기타시설일 떄에도 플레이스 상세를 선택할 수 있다`() = + runTest { + // given + coEvery { placeDetailRepository.getPlaceDetail(1) } returns + Result.success( + FAKE_ETC_PLACE_DETAIL, + ) + val eventResult = observeEvent(mapControlUiEvent.receiveAsFlow()) + + // when + selectActionHandler(SelectEvent.OnPlaceClick(1)) + advanceUntilIdle() + + // then + val event = eventResult.await() + advanceUntilIdle() + + val expected = LoadState.Success(FAKE_ETC_PLACE_DETAIL.toUiModel()) + val actual = uiState.value.selectedPlace + assertThat(actual).isEqualTo(expected) + assertThat(event).isEqualTo(MapControlSideEffect.SelectMarker(expected)) + } + + @Test + fun `플레이스 상세 선택을 해제할 수 있다`() = + runTest { + // given + coEvery { placeDetailRepository.getPlaceDetail(1) } returns + Result.success( + FAKE_PLACE_DETAIL, + ) + selectActionHandler(SelectEvent.OnPlaceClick(1)) + val eventResult = observeEvent(mapControlUiEvent.receiveAsFlow()) + advanceUntilIdle() + + // when + selectActionHandler(SelectEvent.UnSelectPlace) + advanceUntilIdle() + + // then + val event = eventResult.await() + advanceUntilIdle() + + val expected = LoadState.Empty + val actual = uiState.value.selectedPlace + assertThat(actual).isEqualTo(expected) + assertThat(event).isEqualTo(MapControlSideEffect.UnselectMarker) + } + + @Test + fun `학교로 돌아가기 버튼이 나타나지 않는 임계값을 넣을 수 있다`() = + runTest { + // given + val isExceededMaxLength = true + + // when + selectActionHandler(SelectEvent.ExceededMaxLength(isExceededMaxLength)) + advanceUntilIdle() + + // then + assertThat(uiState.value.isExceededMaxLength).isEqualTo(isExceededMaxLength) + } + + @Test + fun `현재 플레이스를 선택 후, 플레이스 상세로 이벤트를 발생시킬 수 있다`() = + runTest { + // given + coEvery { + placeDetailRepository.getPlaceDetail(FAKE_PLACE_DETAIL.id) + } returns Result.success(FAKE_PLACE_DETAIL) + + val eventResult = observeEvent(placeMapUiEvent.receiveAsFlow()) + val expected = LoadState.Success(FAKE_PLACE_DETAIL.toUiModel()) + uiState.update { + it.copy( + selectedPlace = expected, + selectedTimeTag = LoadState.Success(FAKE_TIME_TAG), + ) + } + + // when + selectActionHandler( + SelectEvent.OnPlacePreviewClick(expected), + ) + advanceUntilIdle() + + // then + val event = eventResult.await() + advanceUntilIdle() + + assertThat(event).isEqualTo( + PlaceMapSideEffect.StartPlaceDetail(expected), + ) + } + + @Test + fun `타임태그가 선택되었음을 알리는 이벤트를 발생시킬 수 있다`() = + runTest { + // given + val expected = TimeTag(1, "테스트1") + + // when + selectActionHandler(SelectEvent.OnTimeTagClick(expected)) + advanceUntilIdle() + + // then + val actual = uiState.value.selectedTimeTag + assertThat(actual).isEqualTo( + LoadState.Success(expected), + ) + } + + @Test + fun `뒤로가기가 클릭되었을 때 선택 해제 이벤트를 발생시킬 수 있다`() = + runTest { + // given + val eventResult = observeEvent(mapControlUiEvent.receiveAsFlow()) + + // when + selectActionHandler(SelectEvent.OnBackPress) + + // then + val event = eventResult.await() + advanceUntilIdle() + + assertThat(event).isEqualTo(MapControlSideEffect.UnselectMarker) + } +} From 9e8cc52cce25f82559e715a0ea625f205ab8bd98 Mon Sep 17 00:00:00 2001 From: YongJun Jung <95472545+oungsi2000@users.noreply.github.com> Date: Tue, 13 Jan 2026 21:45:46 +0900 Subject: [PATCH 100/140] =?UTF-8?q?[Feat]=20PlaceDetailActivity=20Compose?= =?UTF-8?q?=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20(#2?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(PlaceDetail): 장소 상세 화면 UI 구현 및 이미지 줌 기능 추가 장소의 상세 정보를 표시하는 화면을 구현하고, 이미지 슬라이더 및 확대/축소 기능을 적용했습니다. 또한, 긴 설명 텍스트를 펼쳐볼 수 있도록 기존 텍스트 컴포넌트를 개선했습니다. - **`PlaceDetailScreen.kt` 추가:** - 장소 이미지, 제목, 운영 시간, 위치, 호스트 정보를 표시하는 상세 화면 UI를 구현했습니다. - `Landscapist` 라이브러리를 활용하여 이미지 페이징 및 핀치 줌(Zoomable) 기능이 포함된 이미지 상세 보기 다이얼로그를 구성했습니다. - 설명 텍스트 클릭 시 내용이 확장되거나 축소되는 애니메이션 인터랙션을 추가했습니다. - **`URLText.kt` 수정:** - 텍스트 영역 클릭 이벤트를 처리할 수 있도록 `onClick` 파라미터와 탭 제스처 감지 로직을 추가했습니다. - **빌드 설정 수정:** - 이미지 로딩 및 줌 기능을 위해 `landscapist-coil3`, `landscapist-zoomable` 라이브러리 의존성을 추가했습니다. * fix(URLText): 유효하지 않은 링크 클릭되는 버그 해결 `URLText` 컴포넌트에서 URL 클릭 시 부모의 클릭 이벤트가 함께 발생하는 문제를 해결하고, URL 탐지 정규식을 변경했습니다. - **`URLText.kt` 수정:** - Android의 `Patterns.WEB_URL` 의존성을 제거하고, 한글 등을 포함할 수 있는 커스텀 정규식(`WEB_REGEX`)으로 교체하여 Kotlin Regex API를 사용하도록 변경했습니다. - `detectTapGestures` 내부 로직을 수정하여, URL 영역을 클릭했을 때는 `uriHandler.openUri`만 실행되고, 그 외 영역을 클릭했을 때만 `onClick` 콜백이 호출되도록 개선했습니다. * refactor(PlaceDetail): Compose 마이그레이션 및 StateFlow 전환 기존 XML 및 DataBinding 기반의 장소 상세 화면을 Jetpack Compose로 마이그레이션하고, 상태 관리 로직을 개선했습니다. - **`PlaceDetailActivity.kt` 리팩토링:** - `AppCompatActivity`를 `ComponentActivity`로 변경하고, `setContent` 블록 내에서 `PlaceDetailScreen`을 호출하도록 수정했습니다. - 기존의 `ActivityPlaceDetailBinding`, `ViewPager2`, `Adapter` 등 View 시스템 관련 코드를 모두 제거했습니다. - **`PlaceDetailViewModel.kt` 수정:** - `LiveData`로 관리되던 `placeDetail` 상태를 `StateFlow`로 변경하여 데이터 스트림을 개선했습니다. - 장소 상세 정보의 이미지가 비어있을 경우, 기본 `ImageUiModel`을 리스트에 포함하도록 로직을 추가했습니다. - **`PlaceDetailScreen.kt` 수정:** - 컴포저블의 파라미터를 `PlaceDetailUiModel`에서 `PlaceDetailUiState`로 변경했습니다. - `UiState`의 상태(`Success`, `Loading`, `Error`)에 따라 `PlaceDetailContent`, `LoadingStateScreen`, `EmptyStateScreen`을 분기하여 렌더링하도록 구조를 변경했습니다. * test: PlaceDetailViewModelTest 상태 검증 방식 변경 `PlaceDetailViewModel` 테스트 코드에서 `getOrAwaitValue` 유틸리티 함수 대신 `value` 프로퍼티를 직접 참조하는 방식으로 검증 로직을 단순화했습니다. - **`PlaceDetailViewModelTest.kt` 수정:** - 불필요해진 `getOrAwaitValue` import 구문을 제거했습니다. - `placeDetail`의 상태를 검증하는 모든 테스트 케이스에서 `getOrAwaitValue()` 호출을 `value` 프로퍼티 접근으로 대체했습니다. * refactor(PlaceDetail): 공통 이미지 컴포넌트 적용 및 레거시 코드 제거(Adapter) 장소 상세 화면의 이미지 로딩 로직을 공통 컴포넌트(`FestabookImage`)로 교체하여 코드를 간소화하고, 더 이상 사용되지 않는 View 기반 어댑터 코드를 정리했습니다. - **`PlaceDetailScreen.kt` 수정:** - `Landscapist` 라이브러리의 `CoilImage`를 직접 호출하던 코드를 제거하고, `FestabookImage` 컴포넌트로 대체했습니다. - 이미지 상세 보기 다이얼로그에 `isZoomable = true` 옵션을 적용하여 줌 기능을 유지했습니다. - **미사용 코드 삭제:** - Compose 마이그레이션 후 불필요해진 `PlaceImageViewPagerAdapter.kt` 및 `PlaceImageViewPagerViewHolder.kt` 파일을 삭제했습니다. * fix(PlaceDetail): scrollable->verticalScroll로 변경 장소 상세 화면의 스크롤이 올바르게 동작하도록 수정자를 변경하고, 프리뷰용 더미 텍스트를 일부 수정했습니다. - **`PlaceDetailScreen.kt` 수정:** - `Column`에 사용된 `scrollable` 수정자를 `verticalScroll`로 변경하여 세로 스크롤이 정상적으로 작동하도록 개선했습니다. - 프리뷰에 사용되는 `uiState`의 설명(`description`) 텍스트 내 URL 오타(`htt` -> `http`) 및 띄어쓰기를 수정했습니다. * refactor(PlaceDetail): 장소 상세 정보 UI 컴포넌트 분리 장소 상세 정보(위치, 운영 시간 등)를 표시하는 UI 로직을 `PlaceDetailInfo` 컴포저블로 분리하여 코드 구조를 개선했습니다. - **`PlaceDetailScreen.kt` 수정:** - 기존 `PlaceDetailContent` 내부에 있던 장소 정보(주소, 시간, 전화번호) 관련 `Row` 및 `Text` 컴포저블들을 `PlaceDetailInfo`라는 새로운 컴포저블 함수로 추출했습니다. - `PlaceDetailContent`는 `PlaceDetailInfo`를 호출하도록 변경하여 코드의 가독성과 재사용성을 높였습니다. * fix: 이미지 페이지와 dialog page의 불일치 문제 해결, 사이드이펙트 LaunchedEffect로 변경 장소 상세 화면의 이미지 페이저(`Pager`)와 다이얼로그 페이저 간의 페이지 상태를 동기화하고, 상태 관리 로직을 개선했습니다. - **`PlaceDetailScreen.kt` 수정:** - 이미지 페이저의 `PagerState`를 `PlaceDetailScreen`의 `Success` 상태 분기 내에서 생성하도록 변경하여, 다이얼로그와 메인 화면 페이저가 동일한 상태를 공유하도록 수정했습니다. - `PlaceDetailImageDialog`는 외부에서 생성된 `pagerState`를 받도록 변경하여, 다이얼로그가 열릴 때 현재 페이지를 정확히 표시하도록 개선했습니다. - 메인 화면 페이저(`PlaceDetailImageContent`)의 페이지 변경 시 `scrollToPage`를 호출하도록 수정하고, 페이지가 완전히 변경되었을 때(`settledPage`)만 `onPageUpdate` 콜백이 호출되도록 `LaunchedEffect`를 추가했습니다. 이로써 불필요한 재구성을 줄였습니다. * refactor(PlaceDetail): ViewModel 생성자 어노테이션 수정 `PlaceDetailViewModel`의 `@AssistedInject` 어노테이션 위치를 생성자에서 클래스 선언부로 이동하여 코드 스타일을 일관성 있게 수정했습니다. * refactor(PlaceDetail): PlaceDetailViewModel 초기화 로직 리팩토링 `PlaceDetailViewModel`의 `init` 블록에서 `if` 조건문을 `let` 스코프 함수로 변경하여 코드를 간소화하고 가독성을 개선했습니다. - **`PlaceDetailViewModel.kt` 수정:** - `receivedPlaceDetail` 및 `place` 객체의 null 체크 로직을 `?.let` 블록으로 대체했습니다. - `let` 블록 내에서 삼항 연산자를 사용하여 이미지가 비어있을 경우 기본 이미지 모델을 추가하는 로직을 간결하게 수정했습니다. * refactor: currentPage 제거 및 R.string.format_date 사용 장소 상세 화면(`PlaceDetailScreen`)의 이미지 페이저 상태 관리 로직을 개선하고, 불필요한 코드를 정리했습니다. - **`PlaceDetailScreen.kt` 수정:** - 이미지 상세 보기 다이얼로그(`ImageDetailDialog`)가 열릴 때, 페이저의 현재 페이지를 유지하기 위해 사용하던 `currentPage` 상태 변수를 제거했습니다. `rememberPagerState`는 상태를 자동으로 유지하므로 불필요한 로직이었습니다. - 장소 운영 시간 표시를 위해 하드코딩된 문자열 `" ~ "` 대신, 형식 문자열 리소스(`R.string.format_date`)를 사용하도록 변경하여 코드의 일관성을 높였습니다. * feat: backToPreviousButton contentDescription 추가 장소 상세 화면의 뒤로가기 버튼에 콘텐츠 설명(`contentDescription`)을 추가하여 접근성을 개선했습니다. - **`PlaceDetailScreen.kt` 수정:** - 뒤로가기 버튼(`Image`)의 `contentDescription`에 `stringResource`를 사용하여 "상세화면 나가기 아이콘"이라는 설명을 추가했습니다. - **`strings.xml` 추가:** - `content_description_exit_place_detail` 문자열 리소스를 새로 추가했습니다. * style(PlaceDetail): 뒤로가기 버튼 패딩 순서 수정 및 불필요한 패딩 제거 장소 상세 화면의 UI 코드를 정리하여 스타일 일관성을 개선했습니다. - **`PlaceDetailScreen.kt` 수정:** - 뒤로가기 버튼(`IcBackToPrevious`)의 `padding` Modifier 적용 순서를 변경하여 Z-Index가 올바르게 작동하도록 수정했습니다. - `IcBackToPrevious` 컴포저블 내부에 적용되었던 불필요한 `padding(top)` 속성을 제거했습니다. * test(PlaceDetail): PlaceDetailViewModelTest에서 InstantTaskExecutorRule 제거 `PlaceDetailViewModelTest` 테스트 코드에서 더 이상 필요하지 않은 `InstantTaskExecutorRule` 관련 코드를 삭제했습니다. - **`PlaceDetailViewModelTest.kt` 수정:** - 불필요해진 `InstantTaskExecutorRule` import 구문을 제거했습니다. - `@get:Rule` 어노테이션과 함께 선언된 `instantTaskExecutorRule` 프로퍼티를 삭제했습니다. * test(PlaceDetail): PlaceDetailViewModelTest 검증 로직 안정성 강화 `PlaceDetailViewModel`의 단위 테스트에서 UI 상태를 검증하는 로직을 더 안전하게 수정했습니다. - **`PlaceDetailViewModelTest.kt` 수정:** - `PlaceDetailUiState.Success` 상태를 검증할 때, `as`를 사용한 강제 타입 캐스팅 대신 `as?`와 `fail` 함수를 조합하여 사용하도록 변경했습니다. 이를 통해 테스트가 예기치 않은 상태(예: `Error`)로 인해 실패할 경우, 더 명확한 실패 메시지를 제공하도록 개선했습니다. * refactor(PlaceDetail): 정보 항목 UI 리팩토링 및 설명 기본값 확장 장소 상세 화면의 정보 표시 UI를 개선하고 사용자 경험을 위해 상세 설명 섹션의 초기 상태를 변경했습니다. - **`PlaceDetailScreen.kt` 수정:** - 시간, 위치, 주최자 정보를 표시하는 중복된 `Row`와 `Icon`, `Text` 컴포저블을 `PlaceDetailInfoItem`이라는 새로운 컴포저블로 추출하여 코드 중복을 제거하고 재사용성을 높였습니다. - 장소 상세 설명(`PlaceDetailDescriptionSection`)이 기본적으로 펼쳐진 상태로 보이도록 `isDescriptionExpand`의 초기값을 `true`로 변경했습니다. * feat(PlaceDetail): 상태 표시줄과 겹치지 않도록 UI 수정 장소 상세 화면의 뒤로가기 버튼이 상태 표시줄(Status Bar) 영역과 겹쳐 보이는 문제를 해결하고, 전체 화면(Edge-to-Edge) UI의 스타일을 명확하게 설정했습니다. - **`PlaceDetailActivity.kt` 수정:** - `enableEdgeToEdge` 호출 시 `SystemBarStyle.light`를 적용하여, 스크롤 시 상태 표시줄의 배경(scrim) 색상을 흰색으로 지정했습니다. - 기존에 `setContent` 이전에 호출되던 `enableEdgeToEdge`를 `setContent` 내부로 이동시켜 UI 상태와 함께 관리되도록 수정했습니다. - **`PlaceDetailScreen.kt` 수정:** - 뒤로가기 버튼(`BackToPreviousButton`)에 `windowInsetsPadding(WindowInsets.statusBars)` Modifier를 추가하여, 상태 표시줄 높이만큼의 패딩을 동적으로 적용했습니다. 이로 인해 버튼이 상태 표시줄 아래에 올바르게 배치됩니다. * fix(Common): URL 정규식 검증 로직 개선 URL을 감지하는 정규식(`WEB_REGEX`)의 정확도를 높였습니다. URL 끝에 포함될 수 없는 마침표(.), 쉼표(,), 콜론(:), 세미콜론(;)과 같은 문자가 URL의 일부로 잘못 인식되는 문제를 해결했습니다. - **`URLText.kt` 수정:** - 기존 정규식의 마지막에 부정형 후방탐색 `(? Unit = {}, color: Color = Color.Unspecified, fontSize: TextUnit = TextUnit.Unspecified, fontStyle: FontStyle? = null, @@ -53,23 +53,21 @@ fun URLText( val linkedText = buildAnnotatedString { append(text) - val urlPattern = Patterns.WEB_URL - val matcher = urlPattern.matcher(text) - while (matcher.find()) { + WEB_REGEX.findAll(text).forEach { result -> addStyle( style = SpanStyle( color = FestabookColor.gray500, textDecoration = TextDecoration.Underline, ), - start = matcher.start(), - end = matcher.end(), + start = result.range.first, + end = result.range.last + 1, ) addStringAnnotation( tag = "URL", - annotation = matcher.group(), - start = matcher.start(), - end = matcher.end(), + annotation = result.value, + start = result.range.first, + end = result.range.last + 1, ) } } @@ -85,7 +83,7 @@ fun URLText( .firstOrNull() ?.let { annotation -> uriHandler.openUri(annotation.item) - } + } ?: onClick() } } }, @@ -110,3 +108,6 @@ fun URLText( style = style, ) } + +private val WEB_REGEX = + """(https?|ftp|file)://[a-zA-Z0-9+&@#/%?=~_|!:,.;]+(? - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - insets - } - - setUpBinding() - setUpObserver() - Timber.d("detailActivity : ${viewModel.placeDetail.value}") - } - - private fun setUpBinding() { - binding.lifecycleOwner = this - binding.rvPlaceNotice.adapter = noticeAdapter - binding.vpPlaceImages.adapter = placeImageAdapter - binding.tvLocation.setExpandedWhenClicked() - binding.tvHost.setExpandedWhenClicked() - binding.ivBackToPrevious.setOnClickListener { - finish() - } - } - - private fun setUpObserver() { - viewModel.placeDetail.observe(this) { result -> - - when (result) { - is PlaceDetailUiState.Error -> { - Timber.w(result.throwable, "PlaceDetailActivity: ${result.throwable.message}") - showErrorSnackBar(result.throwable) - } - - is PlaceDetailUiState.Loading -> { - showSkeleton() - Timber.d("Loading") - } - - is PlaceDetailUiState.Success -> { - hideSkeleton() - loadPlaceDetail(result.placeDetail) - } - } - } - } - - private fun loadPlaceDetail(placeDetail: PlaceDetailUiModel) { - binding.placeDetail = placeDetail - - if (placeDetail.images.isEmpty()) { - placeImageAdapter.submitList( - listOf(ImageUiModel()), + setContent { + enableEdgeToEdge( + statusBarStyle = + SystemBarStyle.light( + scrim = FestabookColor.white.toArgb(), + darkScrim = FestabookColor.white.toArgb(), + ), + ) + val placeDetailUiState by viewModel.placeDetail.collectAsStateWithLifecycle() + PlaceDetailScreen( + uiState = placeDetailUiState, + onBackToPreviousClick = { finish() }, ) - } else { - placeImageAdapter.submitList(placeDetail.images) - binding.clImageIndicator.setViewPager(binding.vpPlaceImages) - } - binding.vpPlaceImages.registerOnPageChangeCallback( - object : ViewPager2.OnPageChangeCallback() { - override fun onPageScrolled( - position: Int, - positionOffset: Float, - positionOffsetPixels: Int, - ) { - binding.logger.log( - PlaceDetailImageSwipe( - baseLogData = binding.logger.getBaseLogData(), - startIndex = position, - ), - ) - } - }, - ) - // 임시로 곰지사항을 보이지 않게 하였습니다. 추후 복구 예정입니다 -// if (placeDetail.notices.isEmpty()) { -// binding.rvPlaceNotice.visibility = View.GONE -// binding.tvNoNoticeDescription.visibility = View.VISIBLE -// } else { -// noticeAdapter.submitList(placeDetail.notices) -// } - } - - private fun showSkeleton() { - binding.layoutContent.visibility = View.GONE - binding.sflScheduleSkeleton.visibility = View.VISIBLE - binding.sflScheduleSkeleton.startShimmer() - } - - private fun hideSkeleton() { - binding.layoutContent.visibility = View.VISIBLE - binding.sflScheduleSkeleton.visibility = View.GONE - binding.sflScheduleSkeleton.stopShimmer() - } - - private fun TextView.setExpandedWhenClicked(defaultMaxLines: Int = DEFAULT_MAX_LINES) { - setOnClickListener { - maxLines = - if (maxLines == defaultMaxLines) { - Integer.MAX_VALUE - } else { - defaultMaxLines - } } - } - override fun onNoticeClick(notice: NoticeUiModel) { - viewModel.toggleNoticeExpanded(notice) + Timber.d("detailActivity : ${viewModel.placeDetail.value}") } - override fun onFAQClick(faqItem: FAQItemUiModel) = Unit - - override fun onLostGuideItemClick() = Unit - companion object { - private const val DEFAULT_MAX_LINES = 1 private const val KEY_PLACE_UI_MODEL = "placeUiModel" private const val KEY_PLACE_DETAIL_UI_MODEL = "placeDetailUiModel" diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/PlaceDetailViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/PlaceDetailViewModel.kt index e03f4d84..f19439dc 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/PlaceDetailViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/PlaceDetailViewModel.kt @@ -1,13 +1,12 @@ package com.daedan.festabook.presentation.placeDetail -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.daedan.festabook.domain.repository.PlaceDetailRepository import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel +import com.daedan.festabook.presentation.placeDetail.model.ImageUiModel import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiState import com.daedan.festabook.presentation.placeDetail.model.toUiModel @@ -15,9 +14,12 @@ import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -class PlaceDetailViewModel @AssistedInject constructor( +@AssistedInject +class PlaceDetailViewModel( private val placeDetailRepository: PlaceDetailRepository, @Assisted private val place: PlaceUiModel?, @Assisted private val receivedPlaceDetail: PlaceDetailUiModel?, @@ -31,17 +33,18 @@ class PlaceDetailViewModel @AssistedInject constructor( } private val _placeDetail = - MutableLiveData( + MutableStateFlow( PlaceDetailUiState.Loading, ) - val placeDetail: LiveData = _placeDetail + val placeDetail: StateFlow = _placeDetail init { - if (receivedPlaceDetail != null) { - _placeDetail.value = PlaceDetailUiState.Success(receivedPlaceDetail) - } else if (place != null) { - loadPlaceDetail(place.id) + receivedPlaceDetail?.let { + val placeDetailUiModel = + if (it.images.isEmpty()) it.copy(images = listOf(ImageUiModel())) else it + _placeDetail.value = PlaceDetailUiState.Success(placeDetailUiModel) } + place?.let { loadPlaceDetail(it.id) } } fun loadPlaceDetail(placeId: Long) { @@ -49,10 +52,14 @@ class PlaceDetailViewModel @AssistedInject constructor( val result = placeDetailRepository.getPlaceDetail(placeId) result .onSuccess { placeDetail -> + val placeDetailUiModel = + if (placeDetail.sortedImages.isEmpty()) { + placeDetail.toUiModel().copy(images = listOf(ImageUiModel())) + } else { + placeDetail.toUiModel() + } _placeDetail.value = - PlaceDetailUiState.Success( - placeDetail.toUiModel(), - ) + PlaceDetailUiState.Success(placeDetailUiModel) }.onFailure { _placeDetail.value = PlaceDetailUiState.Error(it) } @@ -60,7 +67,7 @@ class PlaceDetailViewModel @AssistedInject constructor( } fun toggleNoticeExpanded(notice: NoticeUiModel) { - val currentState = _placeDetail.value ?: return + val currentState = _placeDetail.value if (currentState !is PlaceDetailUiState.Success) return _placeDetail.value = currentState.copy( diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/adapter/PlaceImageViewPagerAdapter.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/adapter/PlaceImageViewPagerAdapter.kt deleted file mode 100644 index 623e9652..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/adapter/PlaceImageViewPagerAdapter.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.daedan.festabook.presentation.placeDetail.adapter - -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import com.daedan.festabook.presentation.placeDetail.model.ImageUiModel - -class PlaceImageViewPagerAdapter : ListAdapter(DIFF_UTIL_CALLBACK) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): PlaceImageViewPagerViewHolder = PlaceImageViewPagerViewHolder.from(parent, currentList) - - override fun onBindViewHolder( - holder: PlaceImageViewPagerViewHolder, - position: Int, - ) { - holder.bind(getItem(position)) - } - - companion object { - private val DIFF_UTIL_CALLBACK = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: ImageUiModel, - newItem: ImageUiModel, - ): Boolean = oldItem.id == newItem.id - - override fun areContentsTheSame( - oldItem: ImageUiModel, - newItem: ImageUiModel, - ): Boolean = oldItem == newItem - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/adapter/PlaceImageViewPagerViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/adapter/PlaceImageViewPagerViewHolder.kt deleted file mode 100644 index 8c509daf..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/adapter/PlaceImageViewPagerViewHolder.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.daedan.festabook.presentation.placeDetail.adapter - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.daedan.festabook.databinding.ItemPlaceImageBinding -import com.daedan.festabook.presentation.common.loadImage -import com.daedan.festabook.presentation.placeDetail.model.ImageUiModel -import io.getstream.photoview.dialog.PhotoViewDialog - -class PlaceImageViewPagerViewHolder( - private val binding: ItemPlaceImageBinding, - private val images: List, -) : RecyclerView.ViewHolder(binding.root) { - private val imageDialogBuilder = - PhotoViewDialog.Builder( - context = binding.root.context, - images = images.map { it.url }, - ) { imageView, url -> - imageView.loadImage(url) - } - - init { - binding.ivPlaceImage.setOnClickListener { - imageDialogBuilder - .withHiddenStatusBar(false) - .withStartPosition(bindingAdapterPosition) - .build() - .show() - } - } - - fun bind(imageUiModel: ImageUiModel) { - binding.image = imageUiModel - } - - companion object { - fun from( - parent: ViewGroup, - images: List, - ): PlaceImageViewPagerViewHolder { - val layoutInflater = LayoutInflater.from(parent.context) - val binding = ItemPlaceImageBinding.inflate(layoutInflater, parent, false) - return PlaceImageViewPagerViewHolder(binding, images) - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/component/PlaceDetailScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/component/PlaceDetailScreen.kt new file mode 100644 index 00000000..0efd70f7 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/component/PlaceDetailScreen.kt @@ -0,0 +1,432 @@ +package com.daedan.festabook.presentation.placeDetail.component + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.zIndex +import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.component.EmptyStateScreen +import com.daedan.festabook.presentation.common.component.FestabookImage +import com.daedan.festabook.presentation.common.component.LoadingStateScreen +import com.daedan.festabook.presentation.common.component.URLText +import com.daedan.festabook.presentation.placeDetail.model.ImageUiModel +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiState +import com.daedan.festabook.presentation.placeMap.component.PlaceCategoryLabel +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun PlaceDetailScreen( + uiState: PlaceDetailUiState, + onBackToPreviousClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + var isDialogOpen by remember { mutableStateOf(false) } + + when (uiState) { + is PlaceDetailUiState.Success -> { + val pagerState = + rememberPagerState( + pageCount = { uiState.placeDetail.images.size }, + ) + + PlaceDetailImageDialog( + isDialogOpen = isDialogOpen, + onDismissRequest = { isDialogOpen = false }, + pagerState = pagerState, + images = uiState.placeDetail.images, + ) + + Column( + modifier = + modifier.verticalScroll(scrollState), + ) { + PlaceDetailImageContent( + images = uiState.placeDetail.images, + onBackToPreviousClick = onBackToPreviousClick, + onPageUpdate = { pagerState.scrollToPage(it) }, + modifier = + Modifier + .clickable { isDialogOpen = true } + .fillMaxWidth(), + ) + + PlaceDetailContent(placeDetail = uiState.placeDetail) + } + } + + is PlaceDetailUiState.Loading -> { + LoadingStateScreen() + } + + is PlaceDetailUiState.Error -> { + EmptyStateScreen() + } + } +} + +@Composable +private fun PlaceDetailImageDialog( + isDialogOpen: Boolean, + pagerState: PagerState, + images: List, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + if (isDialogOpen) { + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { + Box( + modifier = + modifier + .fillMaxSize() + .background(FestabookColor.black.copy(alpha = 0.8f)), + ) { + HorizontalPager( + state = pagerState, + verticalAlignment = Alignment.CenterVertically, + beyondViewportPageCount = 5, + ) { page -> + + FestabookImage( + imageUrl = images[page].url, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + isZoomable = true, + enablePopUp = false, + ) + } + + IconButton( + onClick = onDismissRequest, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(16.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "close the popup", + tint = FestabookColor.white, + ) + } + } + } + } +} + +@Composable +private fun PlaceDetailImageContent( + images: List, + onBackToPreviousClick: (() -> Unit), + modifier: Modifier = Modifier, + onPageUpdate: suspend (page: Int) -> Unit = {}, +) { + val pagerState = rememberPagerState(pageCount = { images.size }) + val currentOnPageUpdate by rememberUpdatedState(onPageUpdate) + LaunchedEffect(pagerState.settledPage) { + currentOnPageUpdate(pagerState.settledPage) + } + + Box(modifier = modifier) { + BackToPreviousButton( + modifier = + Modifier + .windowInsetsPadding(WindowInsets.statusBars) + .padding( + top = festabookSpacing.paddingBody4, + start = festabookSpacing.paddingScreenGutter, + ).zIndex(1f), + onClick = onBackToPreviousClick, + ) + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + beyondViewportPageCount = 5, + ) { page -> + FestabookImage( + modifier = + Modifier + .fillMaxWidth() + .height(240.dp), + imageUrl = images[page].url, + ) + } + + PagerIndicator( + pagerState = pagerState, + modifier = + Modifier + .height(24.dp) + .align(Alignment.BottomCenter), + ) + } +} + +@Composable +private fun PlaceDetailContent( + placeDetail: PlaceDetailUiModel, + modifier: Modifier = Modifier, +) { + var isDescriptionExpand by remember { mutableStateOf(true) } + + Column( + modifier = modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), + ) { + PlaceCategoryLabel( + modifier = Modifier.padding(top = 24.dp), + category = placeDetail.place.category, + ) + + Text( + modifier = Modifier.padding(top = festabookSpacing.paddingBody2), + text = placeDetail.place.title ?: stringResource(R.string.place_list_default_title), + style = FestabookTypography.displayMedium, + ) + + PlaceDetailInfo(placeDetail = placeDetail) + + URLText( + modifier = + Modifier + .animateContentSize( + animationSpec = + spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMedium, + ), + ).padding( + top = festabookSpacing.paddingBody3, + ), + onClick = { + isDescriptionExpand = !isDescriptionExpand + }, + text = + placeDetail.place.description + ?: stringResource(R.string.place_list_default_description), + style = FestabookTypography.bodySmall, + maxLines = + if (isDescriptionExpand) { + Int.MAX_VALUE + } else { + 1 + }, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun PlaceDetailInfo( + placeDetail: PlaceDetailUiModel, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + PlaceDetailInfoItem( + modifier = Modifier.padding(top = festabookSpacing.paddingBody4), + painter = painterResource(R.drawable.ic_place_detail_clock), + contentDescription = stringResource(R.string.content_description_iv_clock), + text = formattedDate(placeDetail.startTime, placeDetail.endTime), + ) + + PlaceDetailInfoItem( + modifier = Modifier.padding(top = festabookSpacing.paddingBody1), + painter = painterResource(R.drawable.ic_location), + contentDescription = stringResource(R.string.content_description_iv_location), + text = + placeDetail.place.location + ?: stringResource(R.string.place_list_default_location), + ) + + PlaceDetailInfoItem( + modifier = Modifier.padding(top = festabookSpacing.paddingBody1), + painter = painterResource(R.drawable.ic_place_detail_host), + contentDescription = stringResource(R.string.content_description_iv_host), + text = + placeDetail.host + ?: stringResource(R.string.place_detail_default_host), + ) + } +} + +@Composable +private fun PlaceDetailInfoItem( + painter: Painter, + contentDescription: String, + text: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + ) { + Icon( + painter = painter, + contentDescription = contentDescription, + ) + + Text( + modifier = Modifier.padding(start = festabookSpacing.paddingBody1), + text = text, + style = FestabookTypography.bodySmall, + color = FestabookColor.gray500, + ) + } +} + +@Composable +private fun BackToPreviousButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Image( + modifier = + modifier + .size(30.dp) + .clickable { onClick() }, + painter = painterResource(id = R.drawable.btn_back_to_previous), + contentDescription = stringResource(R.string.content_description_exit_place_detail), + ) +} + +@Composable +private fun PagerIndicator( + pagerState: PagerState, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(pagerState.pageCount) { iteration -> + val isSelected = pagerState.currentPage == iteration + val color = if (isSelected) FestabookColor.black else FestabookColor.gray300 + val size by animateDpAsState(targetValue = if (isSelected) 10.dp else 8.dp) + + Box( + modifier = + Modifier + .padding(4.dp) + .clip(CircleShape) + .background(color) + .size(size), + ) + } + } +} + +@Composable +private fun formattedDate( + startTime: String?, + endTime: String?, +): String = + if (startTime == null && endTime == null) { + stringResource(R.string.place_detail_default_time) + } else { + stringResource(R.string.format_date, startTime.toString(), endTime.toString()) + } + +@Preview(showBackground = true) +@Composable +private fun PlaceDetailScreenPreview() { + FestabookTheme { + PlaceDetailScreen( + onBackToPreviousClick = {}, + uiState = + PlaceDetailUiState.Success( + placeDetail = + PlaceDetailUiModel( + place = + PlaceUiModel( + id = 1, + imageUrl = null, + title = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", + description = + "테스트테스트테스트테스트테스트테스.트테스트.테스트테스트테스트테스트//테스트테스트테스트테스트테스" + + "트테스트테스트테스트http://i1.sndcdn.com/art 트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테" + + "스트테스트테스트테스트https://i.ytimg.com/vi/Wr8egRRLU28/maxresdefault.com테스트테스트테스트테스트" + + "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", + location = "테스트테스트테스트테스트테스트테스트테스트테스트테스트", + category = PlaceCategoryUiModel.FOOD_TRUCK, + isBookmarked = true, + timeTagId = listOf(1), + ), + notices = emptyList(), + host = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", + startTime = "09:00", + endTime = "18:00", + images = + listOf( + ImageUiModel( + id = 1, + url = "https://i1.sndcdn.com/artworks-AIxlEDn4gNDBnNJj-qHUnyA-t500x500.jpg", + ), + ImageUiModel( + id = 2, + url = "https://i.ytimg.com/vi/Wr8egRRLU28/maxresdefault.jpg", + ), + ), + ), + ), + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index acf2688d..b19b7015 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -101,6 +101,7 @@ 부스 이미지 운영 시간 아이콘 호스트 아이콘 + 상세화면 나가기 아이콘 공지사항이 없습니다 새로고침 플로팅 지도 버튼 diff --git a/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailViewModelTest.kt b/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailViewModelTest.kt index c072a468..7ef8b7c0 100644 --- a/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailViewModelTest.kt @@ -1,8 +1,6 @@ package com.daedan.festabook.placeDetail -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.daedan.festabook.domain.repository.PlaceDetailRepository -import com.daedan.festabook.getOrAwaitValue import com.daedan.festabook.news.FAKE_NOTICES import com.daedan.festabook.placeMap.FAKE_PLACES import com.daedan.festabook.presentation.news.notice.model.toUiModel @@ -23,13 +21,11 @@ import kotlinx.coroutines.test.setMain import org.assertj.core.api.Assertions.assertThat import org.junit.After import org.junit.Before -import org.junit.Rule import org.junit.Test +import org.junit.jupiter.api.fail @OptIn(ExperimentalCoroutinesApi::class) class PlaceDetailViewModelTest { - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() private val testDispatcher = StandardTestDispatcher() private lateinit var placeDetailRepository: PlaceDetailRepository private lateinit var placeDetailViewModel: PlaceDetailViewModel @@ -86,7 +82,7 @@ class PlaceDetailViewModelTest { // then val expected = PlaceDetailUiState.Error(exception) - val actual = placeDetailViewModel.placeDetail.getOrAwaitValue() + val actual = placeDetailViewModel.placeDetail.value coVerify { placeDetailRepository.getPlaceDetail(FAKE_PLACES.first().id) } assertThat(actual).isEqualTo(expected) } @@ -104,7 +100,7 @@ class PlaceDetailViewModelTest { // then coVerify(exactly = 0) { placeDetailRepository.getPlaceDetail(any()) } - val actual = placeDetailViewModel.placeDetail.getOrAwaitValue() + val actual = placeDetailViewModel.placeDetail.value assertThat(actual).isEqualTo( PlaceDetailUiState.Success(expected), ) @@ -127,9 +123,11 @@ class PlaceDetailViewModelTest { // then val actual = placeDetailViewModel.placeDetail - .getOrAwaitValue() - .let { it as PlaceDetailUiState.Success } - .placeDetail + .value + .let { + (it as? PlaceDetailUiState.Success) + ?: fail { "PlaceDetailUiState 가 성공 상태가 아님" } + }.placeDetail .notices assertThat(actual).isEqualTo(expected) From 144d4a1a674a6161def5e80557968bf8a733cf73 Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Sun, 18 Jan 2026 01:51:11 +0900 Subject: [PATCH 101/140] =?UTF-8?q?feat(Splash):=20SplashUiState=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../festabook/presentation/splash/SplashUiState.kt | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/splash/SplashUiState.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/splash/SplashUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/splash/SplashUiState.kt new file mode 100644 index 00000000..cab9e727 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/splash/SplashUiState.kt @@ -0,0 +1,9 @@ +package com.daedan.festabook.presentation.splash + +sealed interface SplashUiState { + data object Loading : SplashUiState + data object ShowUpdateDialog : SplashUiState + data object ShowNetworkErrorDialog : SplashUiState + data class NavigateToMain(val festivalId: Long) : SplashUiState + data object NavigateToExplore : SplashUiState +} From 183fd2f6a9ffe560202270a3b0d9ee5302ae9e7c Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Sun, 18 Jan 2026 01:51:25 +0900 Subject: [PATCH 102/140] =?UTF-8?q?feat(Splash):=20=EC=8A=A4=ED=94=8C?= =?UTF-8?q?=EB=9E=98=EC=8B=9C=20=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=A0=20=EB=8B=A4=EC=9D=B4=EC=96=BC?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=BB=B4=ED=8F=AC=EC=A0=80=EB=B8=94=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../splash/component/SplashDialogs.kt | 132 ++++++++++++++++++ app/src/main/res/values/strings.xml | 4 +- 2 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/splash/component/SplashDialogs.kt diff --git a/app/src/main/java/com/daedan/festabook/presentation/splash/component/SplashDialogs.kt b/app/src/main/java/com/daedan/festabook/presentation/splash/component/SplashDialogs.kt new file mode 100644 index 00000000..b7f60c18 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/splash/component/SplashDialogs.kt @@ -0,0 +1,132 @@ +package com.daedan.festabook.presentation.splash.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.daedan.festabook.R +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes + +@Composable +fun UpdateDialog(onConfirm: () -> Unit) { + SplashInfoDialog( + title = stringResource(id = R.string.update_notice_title), + message = stringResource(id = R.string.update_notice_message), + buttonText = stringResource(id = R.string.update_notice_confirm), + iconResId = R.drawable.ic_alarm, + confirmButtonColor = FestabookColor.accentBlue, + onConfirm = onConfirm, + ) +} + +@Composable +fun NetworkErrorDialog(onConfirm: () -> Unit) { + SplashInfoDialog( + title = stringResource(id = R.string.update_failed_title), + message = stringResource(id = R.string.update_failed_message), + buttonText = stringResource(id = R.string.update_failed_confirm), + confirmButtonColor = FestabookColor.gray400, + onConfirm = onConfirm, + ) +} + +@Composable +private fun SplashInfoDialog( + title: String, + message: String, + buttonText: String, + @DrawableRes iconResId: Int? = null, + confirmButtonColor: Color, + onConfirm: () -> Unit, +) { + Dialog(onDismissRequest = {}) { + Column( + modifier = + Modifier + .background( + color = FestabookColor.white, + shape = festabookShapes.radius4, + ).padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + iconResId?.let { + Image( + painter = painterResource(id = it), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + Text( + text = title, + style = FestabookTypography.displaySmall, + textAlign = TextAlign.Center, + color = FestabookColor.gray800, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = message, + style = FestabookTypography.bodyMedium, + textAlign = TextAlign.Center, + color = FestabookColor.gray800, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = onConfirm, + modifier = + Modifier + .fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = confirmButtonColor, + contentColor = FestabookColor.white, + ), + shape = festabookShapes.radiusFull, + ) { + Text(text = buttonText) + } + } + } +} + +@Preview +@Composable +private fun UpdateDialogPreview() { + FestabookTheme { + UpdateDialog(onConfirm = {}) + } +} + +@Preview +@Composable +private fun NetworkErrorDialogPreview() { + FestabookTheme { + NetworkErrorDialog(onConfirm = {}) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b19b7015..7deb229a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -148,10 +148,10 @@ 신규 버전 출시 안내 - 새로운 버전이 출시되었습니다.\n 더 나은 사용을 위해 앱을 업데이트해 주세요 + 새로운 버전이 출시되었어요.\n 더 나은 사용을 위해 앱을 업데이트해 주세요. 업데이트 서버 통신 실패 - 앱 서버에 접속할 수 없습니다. 다시 시도해주세요 + 앱 서버에 접속할 수 없습니다. 다시 시도해주세요. 종료 홈화면으로 돌아가기 축제 검색 From e7b5f9e6564ce615e3468a0e49d3f3b3ff651f0c Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Mon, 19 Jan 2026 00:11:08 +0900 Subject: [PATCH 103/140] =?UTF-8?q?refactor(Splash):=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=ED=8F=AC=EB=A7=B7=ED=8C=85=20=EB=B0=8F=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../daedan/festabook/presentation/splash/SplashUiState.kt | 8 +++++++- .../presentation/splash/component/SplashDialogs.kt | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/splash/SplashUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/splash/SplashUiState.kt index cab9e727..603ef5a5 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/splash/SplashUiState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/splash/SplashUiState.kt @@ -2,8 +2,14 @@ package com.daedan.festabook.presentation.splash sealed interface SplashUiState { data object Loading : SplashUiState + data object ShowUpdateDialog : SplashUiState + data object ShowNetworkErrorDialog : SplashUiState - data class NavigateToMain(val festivalId: Long) : SplashUiState + + data class NavigateToMain( + val festivalId: Long, + ) : SplashUiState + data object NavigateToExplore : SplashUiState } diff --git a/app/src/main/java/com/daedan/festabook/presentation/splash/component/SplashDialogs.kt b/app/src/main/java/com/daedan/festabook/presentation/splash/component/SplashDialogs.kt index b7f60c18..388ec319 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/splash/component/SplashDialogs.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/splash/component/SplashDialogs.kt @@ -56,8 +56,8 @@ private fun SplashInfoDialog( title: String, message: String, buttonText: String, - @DrawableRes iconResId: Int? = null, confirmButtonColor: Color, + @DrawableRes iconResId: Int? = null, onConfirm: () -> Unit, ) { Dialog(onDismissRequest = {}) { From 4f36432494a850c2e2f0d7fbce0e89a376abb852 Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Mon, 19 Jan 2026 00:55:17 +0900 Subject: [PATCH 104/140] =?UTF-8?q?refactor(Splash):=20ViewModel=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=EB=A5=BC=20LiveData?= =?UTF-8?q?=EC=97=90=EC=84=9C=20StateFlow=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/splash/SplashViewModel.kt | 33 ++++++----- .../festabook/splash/SplashViewModelTest.kt | 55 +++++++++++++------ 2 files changed, 57 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/splash/SplashViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/splash/SplashViewModel.kt index e307c8a2..051089f6 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/splash/SplashViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/splash/SplashViewModel.kt @@ -1,14 +1,14 @@ package com.daedan.festabook.presentation.splash -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource import com.daedan.festabook.di.viewmodel.ViewModelKey -import com.daedan.festabook.presentation.common.SingleLiveData import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import timber.log.Timber @ContributesIntoMap(AppScope::class) @@ -17,25 +17,30 @@ import timber.log.Timber class SplashViewModel( private val festivalLocalDataSource: FestivalLocalDataSource, ) : ViewModel() { - private val _navigationState = SingleLiveData() - val navigationState: LiveData = _navigationState + private val _uiState = MutableStateFlow(SplashUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() - private val _isValidationComplete = MutableLiveData(false) - val isValidationComplete: LiveData = _isValidationComplete - - init { - checkFestivalId() + fun handleVersionCheckResult(result: Result) { + result + .onSuccess { isUpdateAvailable -> + if (isUpdateAvailable) { + _uiState.value = SplashUiState.ShowUpdateDialog + } else { + checkFestivalId() + } + }.onFailure { + _uiState.value = SplashUiState.ShowNetworkErrorDialog + } } private fun checkFestivalId() { val festivalId = festivalLocalDataSource.getFestivalId() - Timber.d("festival ID : $festivalId") + Timber.d("현재 접속중인 festival ID : $festivalId") if (festivalId == null) { - _navigationState.setValue(NavigationState.NavigateToExplore) + _uiState.value = SplashUiState.NavigateToExplore } else { - _navigationState.setValue(NavigationState.NavigateToMain(festivalId)) + _uiState.value = SplashUiState.NavigateToMain(festivalId) } - _isValidationComplete.value = true } } diff --git a/app/src/test/java/com/daedan/festabook/splash/SplashViewModelTest.kt b/app/src/test/java/com/daedan/festabook/splash/SplashViewModelTest.kt index a60dc184..9f76cadb 100644 --- a/app/src/test/java/com/daedan/festabook/splash/SplashViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/splash/SplashViewModelTest.kt @@ -2,14 +2,13 @@ package com.daedan.festabook.splash import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource -import com.daedan.festabook.presentation.splash.NavigationState +import com.daedan.festabook.presentation.splash.SplashUiState import com.daedan.festabook.presentation.splash.SplashViewModel -import io.mockk.coEvery +import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain @@ -41,34 +40,56 @@ class SplashViewModelTest { } @Test - fun `뷰모델을 생성할 때 현재 접속한 대학교가 있다면 MainActivity로 이동한다`() = + fun `앱 업데이트가 있다면 업데이트 다이얼로그를 표시한다`() = runTest { // given - coEvery { festivalLocalDataSource.getFestivalId() } returns 1 - val expected = NavigationState.NavigateToMain(1) + val updateResult = Result.success(true) // when - splashViewModel = SplashViewModel(festivalLocalDataSource) - advanceUntilIdle() + splashViewModel.handleVersionCheckResult(updateResult) // then - val actual = splashViewModel.navigationState.value - assertThat(actual).isEqualTo(expected) + assertThat(splashViewModel.uiState.value).isEqualTo(SplashUiState.ShowUpdateDialog) } @Test - fun `뷰모델을 생성할 때 현재 접속한 대학교가 없다면 ExploreActivity로 이동한다`() = + fun `앱 업데이트 확인에 실패하면 네트워크 에러 다이얼로그를 표시한다`() = runTest { // given - coEvery { festivalLocalDataSource.getFestivalId() } returns null - val expected = NavigationState.NavigateToExplore + val updateResult = Result.failure(Exception("Network Error")) // when - splashViewModel = SplashViewModel(festivalLocalDataSource) - advanceUntilIdle() + splashViewModel.handleVersionCheckResult(updateResult) // then - val actual = splashViewModel.navigationState.value - assertThat(actual).isEqualTo(expected) + assertThat(splashViewModel.uiState.value).isEqualTo(SplashUiState.ShowNetworkErrorDialog) + } + + @Test + fun `앱 업데이트가 없고 접속한 대학교가 있다면 MainActivity로 이동한다`() = + runTest { + // given + every { festivalLocalDataSource.getFestivalId() } returns 1L + val updateResult = Result.success(false) + + // when + splashViewModel.handleVersionCheckResult(updateResult) + + // then + assertThat(splashViewModel.uiState.value).isEqualTo(SplashUiState.NavigateToMain(1L)) + } + + @Test + fun `앱 업데이트가 없고 접속한 대학교가 없다면 ExploreActivity로 이동한다`() = + runTest { + // given + every { festivalLocalDataSource.getFestivalId() } returns null + val updateResult = Result.success(false) + + // when + splashViewModel.handleVersionCheckResult(updateResult) + + // then + assertThat(splashViewModel.uiState.value).isEqualTo(SplashUiState.NavigateToExplore) } } From 4ff90b8a403305c28775cd37fd6a3e8cc6083e5a Mon Sep 17 00:00:00 2001 From: Dongjoo Seo <83579348+etama123@users.noreply.github.com> Date: Mon, 19 Jan 2026 00:55:51 +0900 Subject: [PATCH 105/140] =?UTF-8?q?refactor(Splash):=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=9D=84=20Compose=EB=A1=9C=20=EC=A0=84=ED=99=98=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 2 +- .../presentation/splash/SplashActivity.kt | 109 ++++++++++-------- .../main/res/drawable/logo_festabook_icon.png | Bin 0 -> 29211 bytes app/src/main/res/values/themes.xml | 12 +- .../com/daedan/festabook/FlowExtension.kt | 41 ------- 5 files changed, 65 insertions(+), 99 deletions(-) create mode 100644 app/src/main/res/drawable/logo_festabook_icon.png delete mode 100644 app/src/test/java/com/daedan/festabook/FlowExtension.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 490c19f9..9ae5a1e3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,7 +23,7 @@ + android:theme="@style/Theme.Festabook.Splash"> diff --git a/app/src/main/java/com/daedan/festabook/presentation/splash/SplashActivity.kt b/app/src/main/java/com/daedan/festabook/presentation/splash/SplashActivity.kt index 3ab5fd01..68906707 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/splash/SplashActivity.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/splash/SplashActivity.kt @@ -2,33 +2,37 @@ package com.daedan.festabook.presentation.splash import android.content.Intent import android.os.Bundle +import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.FestaBookApp -import com.daedan.festabook.R import com.daedan.festabook.presentation.explore.ExploreActivity import com.daedan.festabook.presentation.main.MainActivity +import com.daedan.festabook.presentation.splash.component.NetworkErrorDialog +import com.daedan.festabook.presentation.splash.component.UpdateDialog +import com.daedan.festabook.presentation.theme.FestabookTheme import dev.zacsweers.metro.Inject -import kotlinx.coroutines.launch class SplashActivity : AppCompatActivity() { private val viewModel: SplashViewModel by viewModels() - private val launcher by lazy { + + private val updateResultLauncher = registerForActivityResult( ActivityResultContracts.StartIntentSenderForResult(), ) { result -> if (result.resultCode == RESULT_OK) { - setupObserver() + viewModel.handleVersionCheckResult(Result.success(false)) } else { - exitDialog().show() + viewModel.handleVersionCheckResult(Result.failure(Exception("Update failed"))) } } - } @Inject override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory @@ -36,59 +40,64 @@ class SplashActivity : AppCompatActivity() { @Inject private lateinit var appVersionManagerFactory: AppVersionManager.Factory - private val appVersionManager by lazy { appVersionManagerFactory.create(launcher) } + private val appVersionManager by lazy { appVersionManagerFactory.create(updateResultLauncher) } override fun onCreate(savedInstanceState: Bundle?) { - installSplashScreen().setKeepOnScreenCondition { - viewModel.isValidationComplete.value != true - } - enableEdgeToEdge() - super.onCreate(savedInstanceState) (application as FestaBookApp).festaBookGraph.inject(this) - setContentView(R.layout.activity_splash) - checkIsAppUpdateAvailable { - setupObserver() - } - } - private fun checkIsAppUpdateAvailable(onSuccess: () -> Unit) { - if (!isNetworkConnected()) { - exitDialog().show() - return + val splashScreen = installSplashScreen() + super.onCreate(savedInstanceState) + + splashScreen.setKeepOnScreenCondition { + viewModel.uiState.value is SplashUiState.Loading } - lifecycleScope.launch { - appVersionManager - .getIsAppUpdateAvailable() - .onSuccess { isUpdateAvailable -> - if (isUpdateAvailable) { - updateDialog { - appVersionManager.updateApp() - }.show() - } else { - onSuccess() - } - }.onFailure { - exitDialog().show() + enableEdgeToEdge() + + setContent { + FestabookTheme { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + // 앱 실행 시 즉시 앱 버전 업데이트의 필요 유무 확인 + val result = appVersionManager.getIsAppUpdateAvailable() + viewModel.handleVersionCheckResult(result) } - } - } - private fun setupObserver() { - viewModel.navigationState.observe(this) { state -> - when (state) { - is NavigationState.NavigateToExplore -> { - // ExploreActivity로 이동 - val intent = Intent(this@SplashActivity, ExploreActivity::class.java) - startActivity(intent) - finish() + LaunchedEffect(uiState) { + when (val state = uiState) { + is SplashUiState.NavigateToExplore -> { + startActivity(Intent(this@SplashActivity, ExploreActivity::class.java)) + finish() + } + + is SplashUiState.NavigateToMain -> { + val intent = + Intent(this@SplashActivity, MainActivity::class.java).apply { + putExtra("festivalId", state.festivalId) + } + startActivity(intent) + finish() + } + + else -> {} + } } - is NavigationState.NavigateToMain -> { - // MainActivity로 이동 - val intent = Intent(this@SplashActivity, MainActivity::class.java) - startActivity(intent) - finish() + when (uiState) { + is SplashUiState.ShowUpdateDialog -> { + UpdateDialog( + onConfirm = { appVersionManager.updateApp() }, + ) + } + + is SplashUiState.ShowNetworkErrorDialog -> { + NetworkErrorDialog( + onConfirm = { finish() }, + ) + } + + else -> {} } } } diff --git a/app/src/main/res/drawable/logo_festabook_icon.png b/app/src/main/res/drawable/logo_festabook_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9685e44dcd6d4288e6db12091a32117f45592878 GIT binary patch literal 29211 zcmeEt`Cn4`_x}aO(iST-%}gq@MJLV5+{I=ZEn936FgKX2sVukD+yQNxYD#IdEKO{z zRB&r?U!Y0DEX5Yma)ClcO+_U|1n&L5p!fH``20}#5bwRO*Xx|~JkRqy=UzUr-*bVU zi5>(&3wH0?c?g2E!H?R|TpjR1U1@b5eCS5(IuZpz^9WAIB<)FIDbp^`S! zA@$#%*s*U11U;e8Q~rU0pvbb_J9qfRp+QQ|+qSKgVw&^4*d@#Ct{x1;z4-5Ve z4u56@w|EGeAqpsp(G_&u>|mZ}7jv{HQqr=i6qk;H%oKxNGI^;T6hUOIs@G$NFX-bw zkvg(CiIT{P&P@|jHM}~R?D{rHsJ9md(QaNrjn(18#H!`pZDdEcwo(IzpniIoLb)GC zoidTf_dH3Q?hkgF2)|C9{BFDzB^>bqN$Rr8@y0~Ee;cL}SmKfRAc|zBs+K)vlGry) zQ!2-v31)-jQe6KtH0RHz(Dx^N;54Syt4WSX#Vst~{zQN86trTp7v}XLhl8c-QdnQ=;$l zZG!6JSpWPzT#gb3-hd#QJ~@21%H^D0swv6li}M#!MyohC2_HhW36#*P!+GK$%#_^q zn<$JL^LAJzjjBoJOR5rVpw8dW5Y&6#y1B&T8!=^y*-e@H!WYj}4B3=T)s3lGymv=1 zLKe@iICaq9&3no@W_L=-uek|q+gX9i+Mk*=W>;ysg8A26s7SqC=jUPuA792k(T9`x zW004_L+sy?mdqh%T*<{pUr_+EgzT zRH43$zprYmA4XdJu_5t(*iK5O^ewBSRZ=gWswJOeS^m-BofL#m6%=|&Sqgd7xg83^ z#$*|s=piTWPH|NbY8o3qoj6i|`xpcn>OxS}yO3k`^Xe)+zOqCWEtAsI(WO11wdH<) zSmlzEUzui}D=kk5lT~@mAK63~cr9^R%Al?z5xAkDf~`kM$rY|t##q&`F;wz9IH&C+ zs{FGt-Gt3%*x22FEuWvRN2`}eKlX75Q1v~x&A4D<3CT5|d8s)?;)TD>kj2tvI-(xM z0I$}tELMdkuz9t;>@~K~c+zeNat}WJ+zDHGB5~meQF1V2NPLX5IbXZT&C{q`3?;2I zs=1*Yo2q>;b=`d-gt@c#cD_raxFpDi+EA>jyfqS@)-L9C<2Ob#*iMg`I}6}fEU!!c zOSHmo?t+AIYIh>xQ_dLu63e6W3e!u`^08``>@Dw27fWD8SlHONqxVz&IazHq-Kxhn z(1J6pswLOX?hTwIpXu7V2G<%ao_Nx-dOdpbM6<+yB^DRw777WiAp9vimD1s7~=qEC8(XZ*2aS}MFM^H9zXZY^;C17zjnTt ze-hrD`dPkJiycRunGw<1)Uj^%BbKo-&bG{y&Xx~k5$W8~ZRvNpJh2b2IJMi=d^##I z7N_$cKM~LM-nyeq?-NmiSkqs#_hb|drDF-Z(~3LLLN{QLs;gnzj&t~q8`5?}Pu*i( zpDgZk@9n@y1MPO~pI+(}6G_Qa!;N{QgJS{=tD;ll zdMnG5!xu4bqT)ZYoz7`b<7QsyH|YO3QO3?+$|yf#w>kX84RY9b4^9-+U|1#&O}wEO zFR(ObTcj+ml2o8fOrn(_-=C9s%7FoYtza%faz z?o~2X*|?bER_E)wIgHEOJKqOEMiJv#knBo8|@ADp+gX7b+{g5MtgeOC%O87ShSoUP}S);$72AmFA9{4RjK{re$YJ z$A}*=1iFZc;eSs{u8#aeR>R^|W%y)HUsdkWfn4ruLr{LDnDMmuplX;xY!j^PNK@)R z84AeQY25W^E;5wQFzKN_K6;cqy(07QylI_TDRRX@kF7Vu>UjM@>su5{T)nF(R+W|3TRoqHC}J%J%v>kOII>L=DFt=wilQV`Ds8bb_$J2POS9h%DLzS9-`rqA^uie3;<~H`M<%Erd!8h1~an z0Q%55yPoxhgqe8qvD)gZBvs`qlZuUZAakdwGcU0w{o>-*VOsT;7b7-h>rn?V{S(`< zMp(%{r;+%cvG^WEY}fRNme3Bs$3mMqqrWZuCQ{D&E(`1#2<_QL>1NQ2r<+2f{vx&W zE-t!bnf*HD?$S$V*|aKG0coFp^N0C0vP?RAU#qrkKem&(aL~;F(%kAa4|fpX4$#E! zXgKc@rFZ+C_$#_|cHlgh60yd~n1SfjKkN<(rj}f)antfIeI~1yxvapragP-zV%A^e3)E-mf|lXmc9}u zG%#IPI&Ye_?r(WjoY^%9@>N?cqK1PTBlS?dHX;XNrX@?Xf}bPnaq`zJe}`vBR&Q45 zUS*szY>(AVEnW=sxBXSz_cDh}S_0*+2Mc-7_fF+f$zIVz=e2f;MAn@=y0}951qV;i zGiIbkt!v%)>iJl}FXOU3hlo)s*_rQ;&H5};4R=Gdd%y=TUPB!JNmCN?lybWY^h}5k z&U;?L!1RhyF>`>a^^DHhr8hqjJ!388r6jwY0r(aWt1koUTR=WQ-7BB)Oam z%fbaGChecU%1tryjF_hiUXBx%fBsF#K;#Tk&wf@R+CLAm-FVGF+y8^!7Tuo@k2OS= zf3ck4ak(@cwyRY@R~&7}vQQp}*$0PhF42~VpIBk=>czV~qU+?k4#lXu=A7V|$+C{&Iu*jmPW zof2l^j$hr5nFTlyegjzReAkK@9vMgP=Bff;BL^@l$8qAYKDKwpEd4J|w)N&4X#AR7 zrg5_s>TNL767Zz8z)W*0y&#`{)yt3^Y6EVMg7pY~rFz#L9zBJCgn^|gvIHGZ-gEZq zyydv9M-5bD&b@S4>nv$86!hVfF-;a`)zOVX0&lXr%c=MUo8Ja=w@`u`kq_RuWGFp2 zDc|$gFzxXue2On>Y5yvAtv)9hEVhm)Pj$KdL#&X;S36UG`AW(_xT43l-*Bl^Vb$4h zVLFOue(U#tEi_RzaXk=}nc5TUTg>AUy=Ix+@HUQy%~R^KIPSiaR`K6UyTqux?6r|plDMF%qEJgG+!l&Sb;``dmK zXnZAz-rj2F2=2~kNzP4KY@V`*evs;r?=reR49g_DmFjG!8_j6BsH4jnkC9ccKt5ff zW^2`n?FI&sxex`GGEPsfV{WMbEmZpCsNyQ=D$ z=u0m+iwsatMg#@8Y{4$5@47NBs2N2WLy=*e#(O0@4VB?Nq%@`YDa@a??LaQ#lwY+X zf*e2F8=&IXQgco-H=^&pqtfgF432-Q@PLWt3OUUDfYjj(PrjT4wRV258T*XQ!gXRGUQI5U~&Ys8x}X<&g!-eHj$u7uj4 zoP1+%#$)_3a)pcIlax)QTXav-#fhzz#b!}Kkub7|IPb3b#%BXz*HH>}Odryi)#XS| zR#)0EH<(36J~0^ZkNnaBh4h?<5N)6v|R7t;Z!!?&JFHib4PThoHFBVFuj+-GSG6+clPdPmE*DeRZ0C1Mzmd&h9;} zBQtNu;-qkwPS=U;H&AS4zl~s*UL%*OJ0|j`&VFDaB+pzN^;N7JE*%${L36;YvY(?Z zbOzqnT@=z{7%r`-!^Qq!?4Z^$=*x{}2Nb1g!XbTt`79x&JIJ-2%FJA4Tq!!SPB)GU z^AI^sACV!jU^{BjdHBeJYX)d=r_<`Yt>tOptvDWz|47Z0ag&7c-6t5y)65Y;5@MeQ zQFUgwj*C_QTu$+IT;(`E4XbZc%7l)h$vUw4guRyhu6A~%BFJK83QAosNQKtB_5H5Y1x7OVnDNUvR z55a2tsaq;)lsr{pmZJYJ&fpK62V5F*br6sOZCK>Vq3*{Yaz8430(ORoUjo7u*d}MZ zppI!>!|Fddv-k|VRtaG#26nO3|mwp2l7Z2M;^fz(@ zh)Td_!k5hvel!g~j;r(rm#Ptoe5d3GPV&7)DOLq6K47O(eCAQ6Lw+EZf3_pF0#(}v z9DNCzc*=c7fGDVl+g;=H@ZCiPXvAf~s!~0OlLc5)=W{(+tO|LETd#GN1A>BNF=O;2 z>Wc5CaTZB6d}JL4{!UeuhF^ubPm!4_7S!pta1jIodVBYlNn|vAKN9c1cd7E55j$?C z@20m|4r}osGF{0|HvC~F5r_6aH7F=H5NAGf&#GXe2P@DU%Q{O=7kKp+m zMu(r@et!JVH&MreiJO?!zTZ9KL-_q0Z)06K1f8 zh+1bWR-1o!O7SB|91I1i;qUqw<8R|Wt_372ppK!yQ6{#%K#cp5TDHPDPi6XPrY-Fl zXYbsrU5SbhAo9lbk#T+c5~zB$@E18_H;wNFNdwmELgVW67~Xk!7E6C~9Qn;`igy@b zIz!pFzVx;taiKPx{dpH!IorXe%qXQi?ARYZuazPkEXr#`@LHS@N6zwb;s*d~qfJ`S zTK1nlr+(#&o70NwZJJ z3_bWyIxTZ8q~T~muZ`Z~heq3D6g)#zwXV?gtMkhn$14UyOzr^lCu?`|3ebliP=27gKCYuKA1LvDhZVBeXo5b9xbG*K80>?}X zc6iT_{r(sQX_AF`;(53_Sw|UFqGXwKr+9F zf5f^KRh=2U<~b&9*nq8cS_XlK-7#_wp>{>9B=_K$u0I_mY*i&Py>uVq5ye)U(McI~ z#M2kG%KyGIa}qA`1eOCyE8sv!U-+fVG=Ah^{sdL2{0uVIMzFXW2z!>rPKcRDjFD)& z+l5ng#(~#uShv~peXjqyMbKdgvbLE6^P4IA=k{HPjg|U<(b9udrai_JSPLYTTE*zp zA~SE$58F+W_Wgm@97o*}&$qpgah$j`hHB)5zNdYSl8zNR9EQHdAM0(Cwi)-(#ktIn zRQ@k&+@QS~8c>8|rsF^22zpn;kSKwgCt@kRNTvj50;2@gGE3;NVKcpO=aWWUR#%Po zx}RIxwQyKEz46X8wRUg@3(~L%(Wi``znEvz3cp%HRo4<`wBP~)>XIzjhE~1F+o*tv z`hn8W1L41u7+K{CE2xo+XV4~0JcA~~wUNBf3=3HJB#{91LDaCUoF(n^l&Fw<5ISHd zaapZjuZ>%&->{8a_$?UAKRIcCPo)b#IQQ2^DBha7AKiEgh5v6=nEe7M7qFHHl)nYS zZ#tW?w3*AZKqyKP7JKeB?v3)r7akRsUQ9G`-T8M&A=~8!V~G~fZeTR79)V&#?F_L-x^u#VeI+=a$_GleiK^7cbvJV{6_`-H2bU=P-7~$!Kp(Cmehg8{zk<@urxGGX#`mOIJNN=l`4y+ z%ElD}1V(j==N^Xs4r_Fqqu@V%G>d{p%u&$z)&9lhW0dE5N-=Lkwd>I#WG>9rFB7S4Hx;)%+p~&B46OT$9i>Xe;C$r zTOM6c8o7;r%hejs)B_iUc-xOAthmEV5Seb&`IFM; zkCVEJ&!IW7AQj~l8aKuse;SD4Eb@d!4Qhg=@AC)8bp-}WRCucMVq)h@stGWAD!Ls|X^pY=sY1y8+Z{H#Ben@sj zA>0D?W9s<1vCmM%C0$y^!Q@+FnWexDsOWNp z^P$HXwHYskrAY{$Oe)s-NhW!_7WT5zJNa<+vRF#m=eZGAVeF|fc04j3)}4r1fW)(n<=F{F1Dlvv`B*V(jhsf_`_|66R(Ki{5I zA0Mr_br&i;2pVc32E$T!20yxSWC!>v$?~l%@Q8hGgE;>BcQD7r6+=zD)i_H zK!!~#Kykj?8%h8~b3NW}(!YlhrKn6_$zAyej110(|G@S#0CK*y@C+Ugva=W+P!bKb zKq*-PLL=M9{tMnhr$&W0=kS0i@b#wmm|)F7C0jMBH4dBpfuSuI@g%doxdL>Y9+T-NiRk`Z5WV=h#6AFsA)u?JP2$(N=d zfJ>#gI;w00HQs73c&<20CQa$IPho|pK(EHdAb#f=yUOi6IfI)fM0)!Xb&j|Sznl}n z)1y5>yYrPcMD&?f#9o3GZdY&fW8ja0SAG)|{3MpXyo-Ja)(7_v!jQekgYLh1%*4|E z`FVgh->iJbO7?#Pw{m6HNaYV~l7p-Wy0i~@T=J2fB_UZX^3jHh)O+%^cUqP;+r)?q z=#`zvDurYHNbnF&__9j2ly-{$8M#gC)k8sD0DATpnM{z<{%JY|#UqRFAxCEQ-Lez+ zqcUq~^?uW(rH9Hs35#S!GoT-gsSzei11~NIuJ2reqFRTp0=1u z>jH?7-EBF}<2LSeFG3sBaX_58n7BoT1^3lK)b)r;rSg5SR0|pICQQvKm_{AA2|*5b zt;-nn+AW|&5UA~OUJz$xcdf4QK&ygx9BxsddN%>^ zH1JPLz$^YF(UvQzvqSl%EvF>EkL$}K|I(waZW9TZK`kHd5cvBbbw#v4*D7-Kw)O~N zZZjPR&xnB85bT&J#})e4Ovz@m%h$Ms>Bu0!aTGv=M|{wF2+jq zXaT**Ew8mG9A^~VZ=vO!@u*Ymx>A!KQhi%oT0DvZ04Ec%K|$DFS5M?(X~QhDILM@i z<7SWU291y2W6RxX`fGJ2CnkUvL7}w9Q(0e0W-+xVwA`i0SGO_OS?ZoLH(ory*bIUy z4})Be@<~asD~g*!CN*u~1_@&;dEFo9mhn^?5yx%esRa@t9%w{f?n+gdoYz*wuc4 zoPl^%q`#RV3k_G*D_(Q^c#dI`BQS0k#7XXch8AXpH^j%RIIpV5ABflc(4E7}f_?xv zsCzX0-?J4DhOLi^;NmXJGD-T~R(K#(S$zUc23$~g)%e$*t*Ca3b*!jA?JRPgWKAll zs1Q_Q|KjDR7XFYvc``yZY!<5me_f5drBSDxWZ4MPe~07-$s&0hp}aZnhDGAWK^Ib* z)F)@zwR}Av-W)YG9M=wPZ*a1{&D{UQ{M?iYYZ8fPoAl7RO{xjl^$zqAS~Mo^WkGx6 z|6-d=Ft~W}>&(WbN-YgRy3PEXz;CDqU|;j6FTY{+cIlf+d@hhv6t6BwLAbp|E-JwK zg{;zsgqW;EsE_%u9`BU^G!qYfh_=E<>diXHir-PA_eo^y|40?(Kh`H`ouG3;=P-iz z4tb#jd_J)hy*c`D)_^L+$^G_uWXu5yQrq)3^LoDZ?gMFkywOCEvMpe~5(%O1)#;A< zLDfbqAt7bV6_d-#H2HHC_WW$IGy|kh9Psn|1XMyD!&6>6uQ%?U$xT#ZkUf2xm<-iy zM1htG9Y?7$0+g-8)d0%VCHveLRG{GyrXi#NaMgX%?#nGLWfG1q6o<-VrU5VK+78l7 z9V?OXi@2mJqkbd;C;J7#dH`%%C9U4*&0yYx+Wdi+s}o3f?mSpj$N&79t=A@c(xb9G z$y2a;(UFNAE6Dq`s>GP*dQcU^{4IiIY7cF;g3emt-4@dFLEh_JbY5XSBdJk|I|j!} zZ)o*gqSj@WJcjz#y1T%00BLwx51h-c)evI#oeUeWzX3%x-6+ahw`zw*dm!hRj8MnA z4EkbGRHEGhS9U_Z@0cJB<}$q9ss%%WrQorNM5u8I?D7TID*f1LL@$1f=@TyidX$

PC3UVcY)Mcg5G zu*Nu4dfz}nYD(_WP*r5@U==CIMjsf}%DJ<+4pB=>Q&MZRyi#szj*46s{}7ixuJ5>f z5koe?#j+#A(iNa7p;r7)_Pu*ty(#G7D-p9Aju-F?A)%v_myr5r&r^!y@@ra(>!!`- z2>$&tjjFxv=;u=lXD{NzX^xY)d_52l(rqnDAf_qlXr%Jvl>g;^SbolID+9}Wfu?HC ze4*nEBWhM!K&^D*)KQP)qqaIcW6QJH@l!9mSdRl@T79J3XiOv8PRPA=3k^yOdrnRF z!gFI=3nx`pH^6EZE7|^zg-$*nN+(tS;87Yfn&wL}sVuhMDf<1)FRDsC$ORi*!WJT` z*W%ug5ce4NJti<2NP5dA=fWX7*&$0aWhD83K3eWM=bzll5h6WTF266n4*OonFnF#Z zY1{r>Bnb2wxYC|9LZCvzB=`e$*4qTGpCfmvW#03RUvtV%>Ngm}-Z;Nt&bK7H0;X_J^(c1S`}6WE{IDN}NN(g%cw&>NfJpzWB%dx)01b zG)H8-ZUaI^YH}=r?tVI2;Ao^Kpt;vQuTKX>_@SU2`aw8+h^?P@`kr6$tEXqbPe4R4 z^)$XL>_}?MHZbxLh2XMHtB81CzKXvj1*+Q(!ue%az^+%rDDy)RK>5P%kIflnmQ@VQ zI{ysCvfmo6y){3~zi`_0!tkeJo%DTjt} zL;|$W4uJ%TMaBfKN=;t6$cDcIIrrNY8?&)~I#nrRVI8j6V#vyX7rtZTK>SCP5&9xi zxd85>CdN&CYkJAWQkBEok*m4Xkt|)HRUz~7cFQ1v9F(^Eo+zUOd|rjn@Mh&saFEBi zO2aV}yZn-USzb(LBh`x2k%-aAF57*MKMH(S=$Z^vt|I?2n{deu?H(x<2dqSXxyk5y zcuGa&1|s847A zeQr4YUJ;I=U zZ3p;;X$;S<*d2y@6co}H_a0m!S{9Mkp{zT%y9$yfmmbSX`s zqEX$xT0C)$I6xD7+xyNUGh6%ksDa}2k5FZoK|4|0!;eDEq=Rhp_i*Z2ew=0O!*e_I z$_RLGJpCeG%RP;V`lNQ@k-J$Yvb$M&5n0v|lG{Fk7J6#~QR+h5x#>!qtMqV`)()db zP#2R-vAS%+La+7Le7Rx(14ajJ1fAPsj2`195R~A3+6;SJkC=$wxC;*^Q&hLh&0zc?A)qX<88 z+G#%++<+po94;_d^hnxXv{gfN;Ay}3NM+JPhkINQ2Mc#cOLrTdMBqd^*NO`MfdAhq zo-454UfFVx^%n%u(=rHuXU`o(tHL&NW<=SVMn7kve@b#?06!$&9cp-{H)w^AU=P3e zsn&%(_rs7c<$2#OMt82Kf03MG)>ux(_F~ix;oNtd1eL>1JsAa-o|wU&r`@Ea?3=|u z@qYT&XsJiRS@a8xrF(HmcZbD6Vlry2pT;&O{HB4V>=h8fDBm_k`0rA3DBmFlA805m zoh^q}0dLw!L`NHC*pM2Oa03lXW)}|;CX^-yrL2x9Gab#7Z9S(%7y5?R#IU*rO&i>c zEd_MCbW2J+wfD~-ShYvCXG#wSW8CY~QV`1>o2yr028)>m--a!vkuj!_y5qUxtf6~% z!&Kdi3znP;=0D6~t@isjMbgP+r~$~P?ag`nBBMiwomAYgPaxBlhw(G&LajIwlR0R0 zr=4~oKE-i>K`^>4|JQE+3g1nO=W@KJKmZ2u$jg;|emRSGdg^;_=`T*h^G+{RyxJf8Dk$FA8A0wuIaaHSG zZmd-}A_2dHed?#Ox{_G}=k1vyl$GkP;!yYnl=(lRwsMR|Vp~xt4HQ1eI#FYZv(v+j zkNJoL79dcL2hjOEB6l*s?;ZVbDVZH|mpztQx0f6%a5UDe@&4J1!^M*@{=6j=^gVTE ztkYF|_;BgZ3|e-AGPHMYWALp4boZYU`BRf2qj4>DAzrnkaw&P?j3`n)XzTM-k2A{D ze&N7vOI}j))2h>L8%GDqnA%WP;Qq(wcKZNjSUqR;i;vN*z+cH$&9RYsW!^c2^5NYbXV&mL_R;yNL2CX-+=6#4eIQ;8U;daL-Uo8BHd!C}z?4YyEj=3!h-~Vo-(Z7&{ zOE}7T&(aXl1@Rl1E}nL!he!W45DrcM2>kqPC(H5|4N``cofxC$RMt2xqczmAzn3o2 z{ehZR->f~&120v|-hzSQefam=!u>O3&TO>0@+~{uAT*6aSXbJOY=TBi%F)7od@n10 zXr(1*?P?7q`3`=1SmJ@yUbotV0){XY@}$F-g_d9S)Uf1Rbv$_}{tSLO#Hp~ZV(6m8 z1X`s!bOt?;i}5Kn_e!1UT4cs>oykqKlyc`_95>v9BA z6j_#hn@)mTGl=qX=w}wf>mr>w;>Tk{bAaT}Iu1`p@~a72?wW+U06)&SlXb6apx6;y zj2Cy-xq3G-6P_j=({-m=s3|IQIscj`^QuzIWuJX1I}3dP%$Y?l)KPPYZD%*0BHpVh z9})E4AV6XMUUvI2m&9lOS8M$pcku3t+0nbw@U?ovQ#INlvAy@j$Hal^qQLzRZ!P8n zuep08m$sZ->Rvkh4d>GXi$uen{O_m$FdFIp2R^l?rXYI<@~I9F1$FsZ$9QiJ2YQqm z2hzg+I`w^x9o;9U6ysj%bg}oWHK2Fui7YhC3Kf-IO*#tcbmNw^S$x=L+}{#PCfD{NX+3by?#M4r;oT__SF?7VN|Q2RI4dM7HO^41S=JR%P; zB^hW?yL&_WFLEs7slREbbVO0h-Qzk#}g(g zZ%Q<`{iVp-jkp73U*Tvdp3tvqtJhd7t@!W5)jRNY3~CMFbJOc6JzmhygkRx-h%n;J zSQGKXSK28YEV-Cf@Q=82IJ-_yX`%fS*4xvB+;w>VCH56>TK;Qu1#1=Qsi8*Ey5MjF zYqN_OwXb&ZMqL!GYjnYQaRpjEbV7dMe>&m&nrB{%jPIl4oD*AAcx~thfaS2~dLy;3 zXnZL&7!w7HL)~P(Uv@1L{-Z|hKu(S`%#F#HDiMd3oZmNcD2@;OiYvBAw94qfje~i= z_m9Ag{_?`WTy%F>`PGn0?{Y(TZ;-KcdM^3!pL{t)gThb5jJ;JH{SW`mP}6-rU$UBP zHc<+AvG1GRA4<3_IfkgUKaCgnZ}B{zeGn?_CX2b&`==ItpZQLO&*LOz&pSd(KDrS| z-m;F-IhxG3py$HLN5eh~I2B0^ruzuBUbLi=m0@?+KoNMgdo?=3)h~SN7L5)dd%#xD zUckGB59~a1rs^IkxcBEBE)1`E=CqEdqU)wwZiG0er7rPr3g9>wruMhs6wUk{=hvWK zn_fw3!X5|dx+9Vxf?{sCqv93!f#``t6uAAobqDv zJLQpt;nbzVQ_RIkJ&HXB@Et$^pacCQ)1*Ry}W&SZ;{*LK5?(};&Hi{fdf;E%BGj~ zN%HbX9wneZ2i{0*sLwmOx8D8>3R!X~->c@_R8zsS#Wkw!%txz&;1V6i&lFL@yw zo+`zT4aKq*0QK{?-1w6!wWmrswmz3>A(qP^*TbOGmz#CoW$+x$Ta=YMf`tI8ledgiy0wmdCht36{KCqe))7$! zphW@-F7m~I)O$8=wTF<-c@Qng8j-Z!e{yIQj=DstrS55M_sQFLI~N@nOAxn*gi$*( zVpoXN+ZYO%}S3 z7ea;#fex_}2jrrO6fr9k#=ol;13Wj|Q-v6hwt-dqEJ^uLUHHM9oNMx);q&P&XX(Yc&>^si- zCQ7PJ2#O8pmVp;o+>|3HaEVa*D57E`vhjSJ>eSY2JuBAgvzU(yo$^C5{GO$S@zklf zh7-MaB(VyXz(8{-zs~2>(%8Vr*4fAq10KR}AsYiZ{VV7l$l+w}ba!-wTRM-fke*=R z)I)-xwV@4nAhRTWDk7sI!|>jMYi#&kvesSgkQLrUk3i>5mt;o!HUqU~UG zwDbhmn2C-9=fgH!fbT!T_!lu zFuj?VH4)W6z`6m{4YirRHHFn}C_y9mLQ1yIwS1juRPuZ{coD;MfGK+;*@~?-{#$A+ z;2SGrQ=(iNyyiAW?MzC0;C!d4{%5{!hzV(&hvwY!C zM|W&4K}%c`2!YwJgl;-2zEm_}J5I;hrfimQuguIiJ^RMN*k*g0PK|jDOCu*Hx(S%p zm&F!89NW_wTi4H>R163yy}ZjbFcd2lx0YJ{Y%uu_dVCf&BxN}RX8kPhBQD68_YQuL zCB>1pu19~1Jd8imt}^3H_`IN)?PGBJS2>3<#1E}kmE54qw-&Xf$XzP+#%kAk*nbgN zESwZ)X@jN@fP-_HI}(yi#h=BCk+`oe254#gZ`lg0Sji2Q%72>=i$#{GYl83wB84iP z`7``lJhj;s#gtD=O_#FL2jD{I6HVMe{gjZ0u5$(>E#2)lktIuMa^Yo;SS z?Y<;agE>9b>l^kLZEI$~%(<=#@%l|B7YWF$H2}E5%6s33{J@bS#6nk60GH-Uz%+WD z4_LXG!~!oK@UqM=aweK+ZtKuTIoUVS@zM1mop)GQC*gRu6Sdgwbj)6(5u!pW*(?$B zmNf|;Fo6!L7DZxLZSQ__{F&_{dYXbcZ;T%R8-L`Pq2*hB>QObAt62tl`nc4*& z?}9HJoN`!i5moM9GQU~(we{*nbUIxG*VX&oU(0wlDX#A~{aNPOH@}EBi@%rmqk8Q* zyQ7~r@rbVLZ>xCXR-UcEc^&H81)zvUSlhNsJXGp8(p!)1D7PW5Yh~0qy}o5|jlUuJ z&Y8g_*YMwRXH_Dfg6!zQC5tu5;arPRW4yl|kJ@ZblvneA4{s2r?Ne4*KNg*ClOu9t z;S9lC4)C!`Z(MYm+f_I`0NxXJVvG(smU;b6pi3RM%z;yX4pMaI?m^E_ymz2~-e=>F z;dZ?(Mh#mxUbCqX9XcQ10!5fP3WMx0SGoP86Xl@EuXos`#F^25Y-f3Cf?&+l$6`s1 zl;je|lP~+}-G^dq0|Pnd@>G14+@Wnk(sEXOn)?n;T72IMMGID(JUC=<6U8z^eIVFe z%MQ3&O0-x<%o`27#!eVhx)Kf-)rd*EK3#~ z#aWGOE42@=1cGhU!HlH0QW;ASTt0fc@u74%r|aDk6XD7N>k^{*aejfa?pkRM`^582 zK}F&#ahD+nhO)jD?}n!_c&nd<qRgd(xAlrDnw}z^?q!KK!b~P*HNSezx+N?_)LG1-^5ij=Ppjv%l3!P? zw>S~?-colF!@u4dlKc(qlh__u6=v7|507rt<|xD(^^1-=_3~arMjytLz(BNLtAe4B zMlrcoiS$jxH!|(+KPnf7Rc72 ziCwZB9Ki}*MEUjvsw07zA1lg2w`cG@CaT7ajXp&LvDT7elPF2w9sl5+4&T%jyN4_> z#f*OiWs2#6n0JgA6*OJ43d-JNs1zTZPU_wyKF^Qz->oe+rA2MVK*D28IZ#D9{%Zf- zDcjrZ6i>C|sNsRirKcz>p%Tz#6z(J(FArqXif{ZO%&a*$?K`?P+6vPrfeH3hC`Ub} zVvuPx7PXsGm)!hfNB3R8m zqdu*&JK&zXqgy$acsvaN&3@LOwBO2=j8izs2O#tQ$5!*&R=!|8rM`f&J^3PY3Y#UF z`6LrKLc%ZL`-Nkl=FK?FRwir`c24W4CL*U)JY8+5WDi)pkg%`5!sM^e80|Ufp{zoZ z+v6-tA$WTb^f)5cupE&I*mNQh^eVTkhD-^hKvo+&n1*tiX{V3sr4A>7N%RJe=GHnN(+#X3y>J1od)>eQ1woGr0I&icSohR zRQeyaF8v+O;f}ICFm3FF?Q)^w{~zrVK{1Wf1V+m37LdO?GZN9psyXgzgG|;Tb)_{p zpVuf9l+9&IseFUAI3q}{B%J?l65n(79WEcdxCtHP~YoUH|wfXY)S ztQiROU)fNb0o=#87+qH0!tKTyXt!a0p6r2e=R{=38afgmFF-}Os*etz zHWIq2zueAbZZ_{zGjfZK_;aD3Um0r6Jo&5Rn&(t49wOwWa;kj>nWu~!9)n-7^H~<-PRKLw}skgJ0x}b4tD~;SRK4Y5UbgX*7tjHX&X`T z>ESLFe#?py=9?bg-`B*!QQ5@G1}U@?+<5&~trV9fofwono-={ZaE)~2;4R$w&;dZf zc7VNdDh4J?ripY6D#9$Wp)`^G;kFh=W9q7~oz&(X_0a!o@7n*8yxac`XdcQ#mb1*n zX3k}strdAlMc1CSYLD6!uskknTUcV52Sg5PPvt7DHLI0LTU#20Sk3SVSQeE_+Kg-| zLY^p0NC`Q^_j6-C|HAi|=a<`;FTCOAbA7J&^}eppb-hK`Rfj8OxYsEz4vbV7R0cWe z*7jyWy8d=+@-FK>(<6@Or`r}gFkt>KZ<}jWw&0pBzVYb|uC{WKR>>deBnVmr@4QB( zO2%>btY(Mm)6KVAM|*AgsY96<=+C}6Zx9IBa64~qF3mpknxjC92nBD%;MC$GJqPsQ z^@I9+-v0DTHH#dO7fN0%>{umJpL1d%ku7iqc)>pdkJ!GB2cCN<-m6C?CshoO5I4sb zd^C#%NNMd*y!RV5H8k%l3W~f5YhUr;6B{)%8BYm;6~YJ(--atVpmKnUm zBDaJ2J#I0&tDe73JK4Y6a+b>0x}W2GIng(fg^FEUCX+k*M(c=$e?R)po>jXSJVDe=);DI>@6y)6f)! zU$iL^Isz|LNSN8T?$b8z%JA9*vsFa4?XzXw$3nTwz@b$!H5g;CqCT00ocT6Te1AA?O+(f)QGNrg zSGFf$lo>R7O~9Vtq`GATfU>Ki>6Z{)!IXz0sYXKxvcaABJ`e=HS^UKi(2%x)e034a;N_E(Y-nmfPd0<7Cv8}>ilnWB~I#qFvLw7II zCMfBgc}Vyi&`EN95WsD#S8UAPBj{QgaVMR$AW?ywg@>L?ROvly-F$jrd0uF$%+~3K zz#wKlqAaEN{1RmDLzi{$JBvklp^Pfx0L2^_<`Y*7(LO4+kY6%1G%4Iy5NP4iY=~LN z>uVMV8ofCu%Tjs5{mpFYZs#Zw^$5)Q8?cmU5$&A6p_{IX05Pue;$Cc(U-^) zNEk6D#Hx?-Bz&UaAS7&tjqn&uEEUNWg_#`VHDlctB+~x9%L)SNt#R@v z`EA){BMy#OeOE!r;xA!_UgyP<+dwj z9pF8=dMP)A&T>TnPqByEa}Uk<3A*~FS+*CZCT8~ax%H8 z_?j*B!QnE~%P%Y%tC`AGt#_KWMHwfuRODI~A;9A2B}-63!&R>msjg$75iEo~w(gNU zr?Y*8oSq7V=*>^G0BPZ-xYtQ&TS$ffM?3fk%W9$q2g2 zET|bhFM{2|zI6B%bllMSf}C=ch`0UjDyGv-JnCJ2>2MC>6egwNlNQnIyYu7|+SdA1grE*=hH&kJSYegG0Ff zJh|Wg6Ig!%IBo~6oRrD+9Buio&;9UQ7~|P^3wAo@9Rp}6irD|% zLeJh^>Y_r-gUreN(6?q!J#HQ|8TK`oKzZ)$R*Qjuk_ye(!JzaTS-PD2g}&|gZbtsu z`|TV^rdz^@h95Fyo%%d4J(Cg5U9%|Ypyo)B*;yQrb+i7m)rLP^v)|b-fiR36Q!>ah9hw2RI8~0e zc1oh;sfu|hQF4u1@q6?QRq1~jJr)wnTAH`Q+014!zl=3h^%{E4QTLEtP2wnV5VshpXad z%nfh!d#nhuULG9D#?Aljtv|hqEc&&S!E_uULMYL|e2i6tND&%!-v?6yuP?p}k)-(RH>ky%jzJK@= zfXuK#F4q11f=jWM?g87Ze)|IwJ6ITXdBO#BrvsR+jm14#qQi+EiYVj2D*0Pp^D)uF zBD9?}UA*aAirdu{aj!bAY<_GwH}^4?;QB9(iEDJSf9`U8nQbrw^w+fFB8(El@7@+8)S5Qe3YL)s{d!QeF<3hD@!K1DuruV zDVmYzZl=ofO20z)9wh-%B3bl$;5g=1*+}Jm!8bTV z(%v4i6FgpwN-fYbLC4}fdcpHmV4IwkS#XJ~gidliyhZRe2GKycZ;B&g9Cd@rlp%wQc%0nz?< z*Qf16kaY91^om5N5uDTjB3T1ya*4F?5(h&WKtr$Wv`q3J3qC0J+qIs(LDg0doPplIaIIRbL$rJVo@znQt}co7 zn_idGfcKbq_Yp>CxyF|DqKvV^$nWS$q)<_U<0&9Pf@OVw2~BvTKVQmIu7`^9~_8 z(6G-$yr3q!3g<0KGv6kU5}COL)sjD$xLcmHEVDuKb;fGXBLnOJy4w7WJf+PW+x8Mc zDJc(s%Qw0TCTQHySM6V9f!Uh0Hnp`bc1X9B0Z!KB!r>!UVkpUfDrsh}f`EH-^|ED; z*70SX$1Dj-nK#@XVY~fm{-cL;GaQ|TtH;!~M4YwzH~EvP+3>un`)^sT^P(-Zf3t&b zAG3Cm59AxbE%}*g5yB#Wd!FF1BQdjPhUlSmfVz8L63IoRXt9+I$bqY-}} zDUWLV(Uw~&l!OG>-8ZZCXBly~@Ow-;6n=P0`p^U(vnwzFIVRi?yKDFboq=hXSQ8 zJ3UwbLwG~zBkmVKP*1`_3TyQlsY7&8<}&=1Y#70E?^WpL&J?Ye$9SxK~9-*y*_ z?hej->>CeC` zRY$8aNC!Lq{UTY*P)=nn_w2oN?$(Xq7izu&xbr;w+e##qJ9lED(2^JQM)MWEh7wW% zwY&$rW5Z@EVV89?^*OmxV^!$r02G58Q7>*6uwCUJ{7am_buxm^g|qzK!#rh=TP%%QyU=`-r1Z&2Q_5l>DF!SJgWBMe7nYU= z^rbS%Pi6Wv9H>B22$mF%@BS?U&$o7akZ*1mQIH40pbsN2M*1l@hTsx8J_=a*BG1%vPS+zK^Ya0h?j(Q-QWG+WS zjj&k5sp8FfB>t+}RLS6tXV}S?j>uUik%-|Ii4Q=)Q~g{2t~XZ6u6Ng~v37jFPPF!8 z-17ed2{^SVdx7xjOF>C)LFOuZ4(`6EV42`LhUn}!pro_|agdBoZ5i*X6n12)Ukh zktWu)O)LN)Z_nxrwz!b`9hx?~LW&at(LP58?lY3Nev88_`HbU)PG_TWaN8W-S1tDk;O8}HKV9QP#=76V7V)zWe$uF ztj#6)Ynnn{bG=Z~{X2yFaI&cuD#kk?xB9=;$KmhkA89WH9&C-w(mn+blWKNXsOdvY z(_sj1qH;-oXg&0g-$!Ty&iPn2T(^^aNFNduRboMj2>y&y+c5g=ld@+{ej76k*%K`i z4M8-^>Vy9{=aaUyA2T0S#h%b?#py@0_cvE7(e~Rao}C+5q9)b2aA#1W8qjKVO1vU; znX|nH8HWojOxM{uc}QgVVTm)HHh3Heb2`0yWQXY&^nxxl!F7?FZO4kd-1nUQGI@it zg$f-Z8pTrl>iocR3uHf?_%f_M ztlIOgXoQyL4TbZg9-z;c-ZiDK5&pdV%>}0b z$_Pyh`72sf=BgNQ?oWRb@BjOtS0-tqd1OLnd`7v69cvzvOlcze+Dp07^pQ?_KP&T2 zor04=Qf6fH6-m}5qy3#@m}%MGiFFD8yev1SHEY0RA@wUk|05332p!s{GfoAoTcT@ub0QUOciIG(8)mN#3%+IS~e0RN+cJ z8~I4tJg#QSZW#U8;v2?~fOe|4pKQ9e_$%|VDKhT$`fi)Yw9Q9iTTr9a66p0){t}Ax z$Ez)4>ebk69;wD*kxa}LSdAu=rs#rfUC}S+f!z|3N89J5sfl0dO_Db46Dwl-|U{u{M~qUgZq0tWTUbr-rKp5&ckI1behrr zNu)v9e0Xug-p-9@{O)o4JMSvhORJf2`Cs|o5uaNSY3Q7`DD(GFdpZ3_@bpV6wYLr% zw7988SXox2qx4cE605PTwe*${J$$2}ve`Ts zO}`xyu@Fmg=jT82*Kx|tey>G3^!*Bzdie{dx7^^9pHP3}PGHocuU{|rymJMiiDoa4 z9Yo7)=@g)}78BE>#4vq#QXAy2s21L~PEmV_xOQQRC9Gwatld07;MP=_RE1S$Q9q02 zIK4VzpTrV_9u`DHif=$La2ehDI_fj*MynwG*pO&9Bmts$M+Sx|V41O*m>E>}jp$CP*{FP~yuqmxxJK&CZf%0y+7ju+cz=U&e)6RUQpE%2s^AlN+g_GH zr&Rx;XyLe)n<;xO55M&|$gzdBEl)eWWm+@o*U78~Voz!@Q@L*t|MDg(Zz=5NiH?Kp zfv1b3gP|RAbS7`hyzc&<`wYKFctlZr%Bbsq^tFbc+b?#y>W9@AWF%JkW3w*J@lb^D z0sZjcM1aj-GBF;TKcN{FB3OMr*KV7Q(!XFi7^2avzCxYN6WsR*O)I)ax|@8_9zIN zrYHuA1+LE&GiEY7AY@KtdTfXHK|J?7c7rZbq8Nw_<&%bF7TQ#VL6;U3fZud5y0Wr% zPi5s{y}6UQ!g`!FGCVOlnxHMJGtG1)4EdmoxHlB}pHHdr(N$_6X5ZvdwL}V}ET_N$ z)DucF-RbS27E6wCIv*jdMr7`BT~ytJpbyo_=onV&UN!QL-$!@0K5mH?(9UEv46PYh zf6nw%HlBIqS-)fRK0_zxBF}UcO-(){ED~(Al9H(^^LEJuPBdn7M`P~)s4CP_dIf~| z;sb&Ntt>z=J|sxjnv&yeA!VOpZKbuDY}Yr0Xvenh&AOr4RAy6Y>amYRR9t{G2x4`| z#eBe5D49QSmJ5@{6x{Mxn`=HH`!KV$Mu{^;D_ap$$8A=u*+V_evz#wdFHtLGFZX8h zC|n>Se_VXL)9t1gG}SBKIPFdQoKoqq|G^OJXVFh7OKWM(o^O7{|0-9DP=?l6p zTyR&}V%|1xY5Xb5X@#Nng6b?TFFm^c*?XuN@>fAt_bF}e{ED7$degZX?G)9(Jp@Us zJ1>eJi)P#S`!Y+#fDYSi-4=t5E#bFdX*cy@Owl|x&26Q@X2CV32O-)R{76`vD~A&k zY|k@-ulerg`V%gh*}ugN)h)5ziTzmJgGhTEgks58pqDTK|1)5KKD;2O=+yx2bd*sR z9Y(4agW`|)0;&B9N;+|srZ7kFz&}VZjO4e8&s9I{GVzvdIxkT)%y41|$rk!w)GCQ> zl|;A9>6Jv$!)ra6S(@f(F<7P*KLK4!cq5i^gdbKNygQ;flH+X_$%FjQS0C-7|2}tz zVniRg`Zb04i0ADm632AMww4HRC;Bb?spuJND5YAOqdf-%&qckm-D*CkUt01oHIrMC zVI>K4q-%=i@d=!G8}7taq&id&loE5saELZAp5QTCv#P`i&z?d^^VuV;&p-RhVN_(W z=p1?s5mTKZ5uTZo154zCzRqJ?XE-v_gsv6i+(RGWk*y~x`X$N7Il`$+?<2G~>6hw% z*z|i5c~hUoLZUK5PKa$Ev#1*I&m}qp#`CUvxNc#lPf7~;;E99*^E%s22D#mEmc6&FKWUQZ%V$wDExUI6Hx-Ifr6Q?Cy7Wd# zLc)yNmxs13G)uIvGTM`MxdrsPG=+4epz)HauB|`CZQ#O0v}4)S(-Ev#(O9*{sa$#G zsIm=_1=8)(vwZvWe0g6H|J_ckQ?-Wh4vN+q=5(u7!pByZ6D% zf@9K~nyjKuyd}m!K0nngZT=HA=2wVki|Pf_Aep!uJdef9RFH`sH;Fik_0@VNPD{(M zs9qhK>9ZEmGu#aA*^7;c@c9rFlhRBq?u;Svp3&F3N36u{EZ+6Hp)O^SSW}PAp+)L# z2iyDqs`RAyvDJ!RYXZ-5h4R#G3obl$FVCn(LwE-un^b6|-V>awHde3f(?BsKO36oL zZO?-XHT{Ic*Cm_vL|MqitNyEGZdD0R*LsQj!_uzSoh}yT6<=U$!k}ZOTGgbo@{p8h zRYlj;l@^OE+o+c>Xp$2I2gHuhdSJLS|G3E|;-dM_57gD8MB(yu8}0g%A%(8+J8!a& zOWOu3ktucEi}|T>Xba{sq5pbGWVV((l#W4jJAMD=mT5~!7jkIBHR_?4ls`-{wN-tV z#tW)Dp$C!&P_bvgs{e=eeBVvq_t?0eK7N1BNUQA1f)8V!o3RCARR&2%c@%NCeX>zo zF9^=pU#Uxc`mbM<#cUl&0bd^kH;-j39W8A8MZSG0zfp0Yd{;8v2d!lm`QO5e&Z0L6 zGA7cJnr)%o9mb!NH;i|LVEOAZNZ&DDUHwef34}m0McelF6}0u&QwhwG3$gj%H8I74 z^&55TJ)osXD7GOp3~fvex -