From 521f0aff8b92e380242a7ca918e25247ed57eea1 Mon Sep 17 00:00:00 2001 From: "Jihee.Han" Date: Fri, 27 Feb 2026 23:52:32 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[NDGL-124]=20feat:=20CommonErrorView=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 --- .../com/yapp/ndgl/core/ui/CommonErrorView.kt | 67 +++++++++++++++++++ core/ui/src/main/res/values/strings.xml | 3 + .../home/search/TemplateSearchScreen.kt | 46 +------------ feature/home/src/main/res/values/strings.xml | 2 - 4 files changed, 72 insertions(+), 46 deletions(-) create mode 100644 core/ui/src/main/java/com/yapp/ndgl/core/ui/CommonErrorView.kt diff --git a/core/ui/src/main/java/com/yapp/ndgl/core/ui/CommonErrorView.kt b/core/ui/src/main/java/com/yapp/ndgl/core/ui/CommonErrorView.kt new file mode 100644 index 00000000..18f353e6 --- /dev/null +++ b/core/ui/src/main/java/com/yapp/ndgl/core/ui/CommonErrorView.kt @@ -0,0 +1,67 @@ +package com.yapp.ndgl.core.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.yapp.ndgl.core.ui.theme.NDGLTheme + +@Composable +fun CommonErrorView( + modifier: Modifier = Modifier, + title: String? = null, + description: String? = null, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 165.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(R.drawable.img_empty_browser), + contentDescription = null, + modifier = Modifier.size(100.dp), + ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = title ?: stringResource(R.string.common_error_title), + color = NDGLTheme.colors.black500, + textAlign = TextAlign.Center, + style = NDGLTheme.typography.subtitleMdSemiBold, + ) + Text( + text = description ?: stringResource(R.string.common_error_description), + color = NDGLTheme.colors.black400, + textAlign = TextAlign.Center, + style = NDGLTheme.typography.bodyLgRegular, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun CommonErrorViewPreview() { + NDGLTheme { + CommonErrorView( + modifier = Modifier + ) + } +} diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 2207fe15..5f067cb4 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -15,6 +15,9 @@ 다시 시도 전체 + 알 수 없는 오류입니다 + 정보를 불러올 수 없어요 + 인터넷 연결 확인 후 다시 시도해 주세요 서비스 이용 전\n반드시 확인해주세요. diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/search/TemplateSearchScreen.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/search/TemplateSearchScreen.kt index d947dac2..3b8b4354 100644 --- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/search/TemplateSearchScreen.kt +++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/search/TemplateSearchScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.yapp.ndgl.core.ui.CommonErrorView import com.yapp.ndgl.core.ui.designsystem.NDGLCTAButton import com.yapp.ndgl.core.ui.designsystem.NDGLCTAButtonAttr import com.yapp.ndgl.core.ui.designsystem.NDGLSearchNavigationBar @@ -118,7 +119,7 @@ private fun TemplateSearchScreen( ) } - TemplateSearchState.SearchResult.Error -> item { ErrorView() } + TemplateSearchState.SearchResult.Error -> item { CommonErrorView() } } } @@ -208,41 +209,6 @@ private fun EmptyResultView() { } } -@Composable -private fun ErrorView() { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 165.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image( - painter = painterResource(CoreR.drawable.img_empty_browser), - contentDescription = null, - modifier = Modifier.size(100.dp), - ) - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource(R.string.home_template_search_error_title), - color = NDGLTheme.colors.black500, - textAlign = TextAlign.Center, - style = NDGLTheme.typography.subtitleMdSemiBold, - ) - Text( - text = stringResource(R.string.home_template_search_error_description), - color = NDGLTheme.colors.black400, - textAlign = TextAlign.Center, - style = NDGLTheme.typography.bodyLgRegular, - ) - } - } -} - @Preview(showBackground = true) @Composable private fun InitialEmptyViewPreview() { @@ -259,14 +225,6 @@ private fun EmptyResultViewPreview() { } } -@Preview(showBackground = true) -@Composable -private fun ErrorViewPreview() { - NDGLTheme { - ErrorView() - } -} - @Preview(showBackground = true) @Composable private fun TemplateSearchScreenFilledPreview() { diff --git a/feature/home/src/main/res/values/strings.xml b/feature/home/src/main/res/values/strings.xml index c4cdd8d3..0d5e6cfd 100644 --- a/feature/home/src/main/res/values/strings.xml +++ b/feature/home/src/main/res/values/strings.xml @@ -26,8 +26,6 @@ 좋아하는 유튜버나 가고 싶은\n여행지를 검색할 수 있어요 검색 결과가 없어요 철자를 확인하거나\n다른 키워드로 검색해보세요. - 정보를 불러올 수 없어요 - 인터넷 연결 확인 후 다시 시도해 주세요 설정 From 7420bfc2752e1051b2aa66fc089010c7a7ce9925 Mon Sep 17 00:00:00 2001 From: "Jihee.Han" Date: Fri, 27 Feb 2026 23:22:34 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[NDGL-124]=20feat:=20=EC=9D=B8=EA=B8=B0=20?= =?UTF-8?q?=EC=97=AC=ED=96=89=20=EB=AA=A9=EB=A1=9D=20=EB=8D=94=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=A4=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=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 --- .../com/yapp/ndgl/core/ui/CommonErrorView.kt | 2 +- .../repository/TravelTemplateRepository.kt | 7 +- .../home/popular/PopularTravelListContract.kt | 61 ++-- .../home/popular/PopularTravelListScreen.kt | 270 +++++++++++++++--- .../popular/PopularTravelListViewModel.kt | 117 ++++++-- 5 files changed, 384 insertions(+), 73 deletions(-) diff --git a/core/ui/src/main/java/com/yapp/ndgl/core/ui/CommonErrorView.kt b/core/ui/src/main/java/com/yapp/ndgl/core/ui/CommonErrorView.kt index 18f353e6..9c93ef44 100644 --- a/core/ui/src/main/java/com/yapp/ndgl/core/ui/CommonErrorView.kt +++ b/core/ui/src/main/java/com/yapp/ndgl/core/ui/CommonErrorView.kt @@ -61,7 +61,7 @@ fun CommonErrorView( private fun CommonErrorViewPreview() { NDGLTheme { CommonErrorView( - modifier = Modifier + modifier = Modifier, ) } } diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/TravelTemplateRepository.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/TravelTemplateRepository.kt index 602253b6..7bcfa65c 100644 --- a/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/TravelTemplateRepository.kt +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/TravelTemplateRepository.kt @@ -18,13 +18,14 @@ import javax.inject.Singleton class TravelTemplateRepository @Inject constructor( private val travelTemplateApi: TravelTemplateApi, ) { - suspend fun getAllPopularTravelTemplates(): PopularTravelTemplates { - return travelTemplateApi.getPopularTravelTemplates().getData() + suspend fun getAllPopularTravelTemplates(page: Int = 0): PopularTravelTemplates { + return travelTemplateApi.getPopularTravelTemplates(page = page).getData() } - suspend fun getPopularTravelTemplates(travelProgramId: Long): PopularTravelTemplates { + suspend fun getPopularTravelTemplates(travelProgramId: Long, page: Int = 0): PopularTravelTemplates { return travelTemplateApi.getPopularTravelTemplates( travelProgramId = travelProgramId, + page = page, ).getData() } diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListContract.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListContract.kt index 582b6bf6..9737aaf6 100644 --- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListContract.kt +++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListContract.kt @@ -1,6 +1,7 @@ package com.yapp.ndgl.feature.home.popular import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import com.yapp.ndgl.core.base.UiIntent import com.yapp.ndgl.core.base.UiSideEffect import com.yapp.ndgl.core.base.UiState @@ -9,32 +10,50 @@ import com.yapp.ndgl.feature.home.model.TravelProgramTab import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.persistentMapOf - -@Immutable -data class PopularTravelListState( - val travelProgramTabs: ImmutableList = persistentListOf(), - val selectedTabIndex: Int = 0, - val allPopularTravels: ImmutableList = persistentListOf(), - val popularTravelsByProgram: ImmutableMap> = persistentMapOf(), -) : UiState { - val selectedProgramTravels: ImmutableList by lazy { - val selectTab = travelProgramTabs.getOrElse(selectedTabIndex) { TravelProgramTab.All } - - when (selectTab) { - TravelProgramTab.All -> allPopularTravels - is TravelProgramTab.Custom -> popularTravelsByProgram.getOrDefault( - selectTab.programId, - persistentListOf(), - ) + +@Stable +sealed class PopularTravelListState : UiState { + data object Loading : PopularTravelListState() + + @Immutable + data class Success( + val travelProgramTabs: ImmutableList, + val allPopularTravels: ImmutableList, + val popularTravelsByProgram: ImmutableMap>, + val selectedTabIndex: Int = 0, + ) : PopularTravelListState() { + val selectedProgramTravels: ImmutableList by lazy { + val selectTab = travelProgramTabs.getOrElse(selectedTabIndex) { TravelProgramTab.All } + + when (selectTab) { + TravelProgramTab.All -> allPopularTravels + is TravelProgramTab.Custom -> popularTravelsByProgram.getOrDefault( + selectTab.programId, + persistentListOf(), + ) + } + } + + sealed interface PopularTravelListItem { + data class Travel( + val travelContent: TravelContent, + ) : PopularTravelListItem + + data class Loading( + val nextPage: Int, + ) : PopularTravelListItem } } + + data object Error : PopularTravelListState() } sealed interface PopularTravelListIntent : UiIntent { data object ClickSearchTravelTemplate : PopularTravelListIntent data class SelectPopularTravelTab(val index: Int) : PopularTravelListIntent data class ClickTravel(val travelId: Long, val days: Int) : PopularTravelListIntent + data class LoadMore(val nextPage: Int) : PopularTravelListIntent + data object ClickRetry : PopularTravelListIntent } sealed interface PopularTravelListSideEffect : UiSideEffect { @@ -43,4 +62,10 @@ sealed interface PopularTravelListSideEffect : UiSideEffect { val travelId: Long, val days: Int, ) : PopularTravelListSideEffect + + data class ShowSnackBar(val type: Type) : PopularTravelListSideEffect { + enum class Type { + ERR_UNKNOWN, + } + } } diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListScreen.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListScreen.kt index ad558016..345313d0 100644 --- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListScreen.kt +++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListScreen.kt @@ -2,6 +2,7 @@ package com.yapp.ndgl.feature.home.popular 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.fillMaxSize @@ -10,24 +11,45 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.yapp.ndgl.core.ui.CommonErrorView +import com.yapp.ndgl.core.ui.designsystem.NDGLCTAButton +import com.yapp.ndgl.core.ui.designsystem.NDGLCTAButtonAttr import com.yapp.ndgl.core.ui.designsystem.NDGLChipTab import com.yapp.ndgl.core.ui.designsystem.NDGLChipTabAttr import com.yapp.ndgl.core.ui.designsystem.NDGLNavigationBar import com.yapp.ndgl.core.ui.designsystem.NDGLNavigationBarAttr import com.yapp.ndgl.core.ui.designsystem.NDGLNavigationIcon +import com.yapp.ndgl.core.ui.designsystem.NDGLSnackbar import com.yapp.ndgl.core.ui.theme.NDGLTheme +import com.yapp.ndgl.data.travel.model.ProgramType import com.yapp.ndgl.feature.home.R import com.yapp.ndgl.feature.home.component.TravelTemplate +import com.yapp.ndgl.feature.home.model.TravelContent import com.yapp.ndgl.feature.home.model.TravelProgramTab +import com.yapp.ndgl.feature.home.popular.PopularTravelListSideEffect.ShowSnackBar.Type +import com.yapp.ndgl.feature.home.popular.PopularTravelListState.Success.PopularTravelListItem import com.yapp.ndgl.feature.home.util.toIconRes +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch import com.yapp.ndgl.core.ui.R as CoreR @Composable @@ -39,8 +61,13 @@ internal fun PopularTravelListRoute( ) { val state by viewModel.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + val errUnknownMessage = stringResource(CoreR.string.common_err_unknown) + PopularTravelListScreen( state = state, + snackbarHostState = snackbarHostState, goBack = { goBack() }, onSearchClick = { viewModel.onIntent(PopularTravelListIntent.ClickSearchTravelTemplate) @@ -51,6 +78,12 @@ internal fun PopularTravelListRoute( onTravelClick = { travelId, days -> viewModel.onIntent(PopularTravelListIntent.ClickTravel(travelId, days)) }, + onLoadMore = { nextPage -> + viewModel.onIntent(PopularTravelListIntent.LoadMore(nextPage)) + }, + onRetryClick = { + viewModel.onIntent(PopularTravelListIntent.ClickRetry) + }, ) viewModel.collectSideEffect { sideEffect -> @@ -60,6 +93,15 @@ internal fun PopularTravelListRoute( sideEffect.travelId, sideEffect.days, ) + + is PopularTravelListSideEffect.ShowSnackBar -> { + val message = when (sideEffect.type) { + Type.ERR_UNKNOWN -> errUnknownMessage + } + coroutineScope.launch { + snackbarHostState.showSnackbar(message) + } + } } } } @@ -67,10 +109,13 @@ internal fun PopularTravelListRoute( @Composable private fun PopularTravelListScreen( state: PopularTravelListState, + snackbarHostState: SnackbarHostState, goBack: () -> Unit, onSearchClick: () -> Unit, onTabSelected: (Int) -> Unit, onTravelClick: (Long, Int) -> Unit, + onLoadMore: (Int) -> Unit, + onRetryClick: () -> Unit, ) { Scaffold( topBar = { @@ -91,51 +136,208 @@ private fun PopularTravelListScreen( }, ) }, + snackbarHost = { + SnackbarHost(snackbarHostState) { data -> + NDGLSnackbar( + modifier = Modifier.padding(bottom = 24.dp), + snackbarData = data, + ) + } + }, ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - NDGLChipTab( - tabs = state.travelProgramTabs.map { tab -> - when (tab) { - TravelProgramTab.All -> NDGLChipTabAttr.Tab( - tag = "All", - name = stringResource(CoreR.string.common_all), - ) - - is TravelProgramTab.Custom -> NDGLChipTabAttr.Tab( - tag = tab.programId.toString(), - name = tab.name, - leadingIcon = tab.type.toIconRes(), - ) - } - }.toPersistentList(), - selectedIndex = state.selectedTabIndex, + when (state) { + PopularTravelListState.Loading -> Unit + is PopularTravelListState.Success -> PopularTravelListContent( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + state = state, onTabSelected = onTabSelected, - modifier = Modifier.padding(start = 24.dp, top = 20.dp), + onTravelClick = onTravelClick, + onLoadMore = onLoadMore, + ) + + PopularTravelListState.Error -> ErrorView( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + onRetryClick = onRetryClick, ) + } + } +} + +@Composable +private fun PopularTravelListContent( + modifier: Modifier, + state: PopularTravelListState.Success, + onTabSelected: (Int) -> Unit, + onTravelClick: (Long, Int) -> Unit, + onLoadMore: (Int) -> Unit, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + NDGLChipTab( + tabs = state.travelProgramTabs.map { tab -> + when (tab) { + TravelProgramTab.All -> NDGLChipTabAttr.Tab( + tag = "All", + name = stringResource(CoreR.string.common_all), + ) + + is TravelProgramTab.Custom -> NDGLChipTabAttr.Tab( + tag = tab.programId.toString(), + name = tab.name, + leadingIcon = tab.type.toIconRes(), + ) + } + }.toPersistentList(), + selectedIndex = state.selectedTabIndex, + onTabSelected = onTabSelected, + modifier = Modifier.padding(start = 24.dp, top = 20.dp), + ) - LazyColumn( - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(top = 12.dp, bottom = 80.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items( - items = state.selectedProgramTravels, - key = { travelTemplate -> travelTemplate.travelId }, - ) { travel -> - TravelTemplate( - travel = travel, + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(top = 12.dp, bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = state.selectedProgramTravels, + key = { item -> + when (item) { + is PopularTravelListItem.Travel -> item.travelContent.travelId + is PopularTravelListItem.Loading -> "loading_${item.nextPage}" + } + }, + ) { item -> + when (item) { + is PopularTravelListItem.Travel -> TravelTemplate( + travel = item.travelContent, onTravelTemplateClick = onTravelClick, modifier = Modifier .fillMaxWidth() .padding(horizontal = 20.dp), ) + + is PopularTravelListItem.Loading -> LoadingItem( + nextPage = item.nextPage, + onLoadMore = onLoadMore, + ) } } } } } + +@Composable +private fun LoadingItem( + nextPage: Int, + onLoadMore: (Int) -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = NDGLTheme.colors.green500) + } + + LaunchedEffect(Unit) { + onLoadMore(nextPage) + } +} + +@Composable +private fun ErrorView( + modifier: Modifier, + onRetryClick: () -> Unit, +) { + val scrollState = rememberScrollState() + + Column(modifier = modifier) { + CommonErrorView( + modifier = Modifier + .verticalScroll(scrollState) + .weight(1f), + ) + NDGLCTAButton( + type = NDGLCTAButtonAttr.Type.PRIMARY, + size = NDGLCTAButtonAttr.Size.LARGE, + status = NDGLCTAButtonAttr.Status.ACTIVE, + label = stringResource(CoreR.string.common_retry), + onClick = onRetryClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + ) + } +} + +@Preview +@Composable +private fun PopularTravelListScreenPreview() { + val travelContent = TravelContent( + travelId = 1L, + title = "Sample Travel", + country = "US", + countryName = "미국", + city = "뉴욕", + nights = 2, + days = 3, + programName = "Sample Program", + programType = ProgramType.YOUTUBE, + thumbnail = "", + ) + + val state = PopularTravelListState.Success( + travelProgramTabs = persistentListOf( + TravelProgramTab.All, + TravelProgramTab.Custom(1L, "Youtube", ProgramType.YOUTUBE), + ), + allPopularTravels = persistentListOf( + PopularTravelListItem.Travel(travelContent), + PopularTravelListItem.Travel(travelContent.copy(travelId = 2L)), + PopularTravelListItem.Travel(travelContent.copy(travelId = 3L)), + ), + popularTravelsByProgram = persistentMapOf(), + ) + + NDGLTheme { + PopularTravelListScreen( + state = state, + snackbarHostState = remember { SnackbarHostState() }, + goBack = {}, + onSearchClick = {}, + onTabSelected = {}, + onTravelClick = { _, _ -> }, + onLoadMore = {}, + onRetryClick = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun LoadingItemPreview() { + NDGLTheme { + LoadingItem( + nextPage = 1, + onLoadMore = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ErrorViewPreview() { + NDGLTheme { + ErrorView( + modifier = Modifier.fillMaxSize(), + onRetryClick = {}, + ) + } +} diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListViewModel.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListViewModel.kt index 20d5600c..7c5fc18b 100644 --- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListViewModel.kt +++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListViewModel.kt @@ -9,6 +9,8 @@ import com.yapp.ndgl.data.travel.repository.TravelProgramRepository import com.yapp.ndgl.data.travel.repository.TravelTemplateRepository import com.yapp.ndgl.feature.home.model.TravelContent import com.yapp.ndgl.feature.home.model.TravelProgramTab +import com.yapp.ndgl.feature.home.popular.PopularTravelListSideEffect.ShowSnackBar.Type +import com.yapp.ndgl.feature.home.popular.PopularTravelListState.Success.PopularTravelListItem import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -17,7 +19,6 @@ import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch -import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -25,8 +26,11 @@ class PopularTravelListViewModel @Inject constructor( private val travelProgramRepository: TravelProgramRepository, private val travelTemplateRepository: TravelTemplateRepository, ) : BaseViewModel( - initialState = PopularTravelListState(), + initialState = PopularTravelListState.Loading, ) { + @Volatile + private var isLoadingMore = false + init { loadPopularTemplates() } @@ -37,8 +41,7 @@ class PopularTravelListViewModel @Inject constructor( .onSuccess { programs -> loadPopularTemplatesByPrograms(programs = programs) }.onFailure { - // FIXME: 에러 뷰 - Timber.d("fail to load popular $it") + reduce { PopularTravelListState.Error } } } } @@ -69,22 +72,22 @@ class PopularTravelListViewModel @Inject constructor( } }.toImmutableList() - val allTravels = allTemplateDeferred.await() - ?.content - ?.map { it.toTravelContent() } - ?.toImmutableList() - ?: persistentListOf() + val allResult = allTemplateDeferred.await() + val allTravels = buildList { + allResult?.content?.forEach { add(PopularTravelListItem.Travel(it.toTravelContent())) } + if (allResult?.hasNext == true) add(PopularTravelListItem.Loading(nextPage = 1)) + }.toImmutableList() - val travelsByProgram = mutableMapOf>() + val travelsByProgram = mutableMapOf>() popularTemplateDeferred.awaitAll().forEach { (program, result) -> - travelsByProgram[program.id] = result?.content - ?.map { it.toTravelContent() } - ?.toImmutableList() - ?: persistentListOf() + travelsByProgram[program.id] = buildList { + result?.content?.forEach { add(PopularTravelListItem.Travel(it.toTravelContent())) } + if (result?.hasNext == true) add(PopularTravelListItem.Loading(nextPage = 1)) + }.toImmutableList() } reduce { - copy( + PopularTravelListState.Success( travelProgramTabs = tabs, allPopularTravels = allTravels, popularTravelsByProgram = travelsByProgram.toImmutableMap(), @@ -93,6 +96,73 @@ class PopularTravelListViewModel @Inject constructor( } } + private fun loadMore(nextPage: Int) { + if (isLoadingMore) return + + val currentUiState = state.value + if ((currentUiState is PopularTravelListState.Success).not()) return + + isLoadingMore = true + + val selectedTab = currentUiState + .travelProgramTabs + .getOrElse(currentUiState.selectedTabIndex, { TravelProgramTab.All }) + + viewModelScope.launch { + suspendRunCatching { + when (selectedTab) { + TravelProgramTab.All -> travelTemplateRepository.getAllPopularTravelTemplates( + page = nextPage, + ) + + is TravelProgramTab.Custom -> travelTemplateRepository.getPopularTravelTemplates( + travelProgramId = selectedTab.programId, + page = nextPage, + ) + } + }.onSuccess { templates -> + val newItems = buildList { + templates.content.forEach { + add(PopularTravelListItem.Travel(it.toTravelContent())) + } + if (templates.hasNext) { + add(PopularTravelListItem.Loading(nextPage = nextPage + 1)) + } + } + reduce { + if ((this is PopularTravelListState.Success).not()) return@reduce this + + when (selectedTab) { + TravelProgramTab.All -> copy( + allPopularTravels = allPopularTravels.appendNextPage(newItems) + .toImmutableList(), + ) + + is TravelProgramTab.Custom -> { + val current = + popularTravelsByProgram[selectedTab.programId] ?: persistentListOf() + copy( + popularTravelsByProgram = popularTravelsByProgram + .toMutableMap() + .apply { + set( + selectedTab.programId, + current.appendNextPage(newItems).toImmutableList(), + ) + } + .toImmutableMap(), + ) + } + } + } + }.onFailure { + postSideEffect(PopularTravelListSideEffect.ShowSnackBar(type = Type.ERR_UNKNOWN)) + } + + isLoadingMore = false + } + } + private fun TravelTemplateSummary.toTravelContent() = TravelContent( travelId = id, title = title, @@ -106,11 +176,21 @@ class PopularTravelListViewModel @Inject constructor( thumbnail = thumbnail ?: "", ) + private fun ImmutableList.appendNextPage( + newItems: List, + ) = (if (lastOrNull() is PopularTravelListItem.Loading) dropLast(1) else this) + newItems + override suspend fun handleIntent(intent: PopularTravelListIntent) { when (intent) { PopularTravelListIntent.ClickSearchTravelTemplate -> postNavigateToSearchTravelTemplate() is PopularTravelListIntent.SelectPopularTravelTab -> selectTab(intent.index) - is PopularTravelListIntent.ClickTravel -> postNavigateToTravelTemplate(intent.travelId, intent.days) + is PopularTravelListIntent.ClickTravel -> postNavigateToTravelTemplate( + intent.travelId, + intent.days, + ) + + is PopularTravelListIntent.LoadMore -> loadMore(intent.nextPage) + PopularTravelListIntent.ClickRetry -> loadPopularTemplates() } } @@ -119,7 +199,10 @@ class PopularTravelListViewModel @Inject constructor( } private fun selectTab(index: Int) { - reduce { copy(selectedTabIndex = index) } + reduce { + if ((this is PopularTravelListState.Success).not()) return@reduce this + copy(selectedTabIndex = index) + } } private fun postNavigateToTravelTemplate(travelId: Long, days: Int) {