diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/ErrorStateScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/ErrorStateScreen.kt new file mode 100644 index 00000000..8c83fff1 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/ErrorStateScreen.kt @@ -0,0 +1,46 @@ +package com.daedan.festabook.presentation.common.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +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.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.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun ErrorStateScreen(modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.ic_fail_load), + contentDescription = stringResource(R.string.content_description_iv_fail_load), + modifier = Modifier.size(48.dp), + ) + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody2)) + Text( + text = stringResource(R.string.error_fail_to_load_info), + style = FestabookTypography.bodyLarge, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ErrorStateScreenPreview() { + ErrorStateScreen() +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/LoadingStateScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/LoadingStateScreen.kt index 741d403a..a1305503 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/component/LoadingStateScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/LoadingStateScreen.kt @@ -12,11 +12,15 @@ import com.airbnb.lottie.compose.rememberLottieComposition import com.daedan.festabook.R @Composable -fun LoadingStateScreen(modifier: Modifier = Modifier) { +fun LoadingStateScreen( + modifier: Modifier = Modifier, + isPlaying: Boolean = true, +) { val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.loading)) val progress by animateLottieCompositionAsState( composition = composition, iterations = LottieConstants.IterateForever, + isPlaying = isPlaying, ) LottieAnimation( composition = composition, 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 73d6a093..a7e3566d 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 @@ -17,9 +17,11 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +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.domain.model.Festival import com.daedan.festabook.domain.model.Organization import com.daedan.festabook.domain.model.Poster @@ -64,7 +66,7 @@ fun HomeScreen( is FestivalUiState.Error -> { Box(modifier = modifier.fillMaxSize()) { Text( - text = "데이터를 불러오는데 실패했습니다.", + text = stringResource(R.string.error_fail_to_load_info), modifier = Modifier.align(Alignment.Center), ) } 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 df46b54a..7c2301d7 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 @@ -2,19 +2,22 @@ package com.daedan.festabook.presentation.schedule import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel -sealed interface ScheduleEventsUiState { - data object InitialLoading : ScheduleEventsUiState +data class ScheduleEventsUiState( + val content: Content, + val isRefreshing: Boolean = false, +) { + sealed interface Content { + data object InitialLoading : Content - data class Refreshing( - val oldEvents: List, - ) : ScheduleEventsUiState + data class Success( + val events: List, + val currentEventPosition: Int, + ) : Content { + val isEventsEmpty get() = events.isEmpty() + } - data class Success( - val events: List, - val currentEventPosition: Int, - ) : ScheduleEventsUiState - - data class Error( - val throwable: Throwable, - ) : ScheduleEventsUiState + data class Error( + val throwable: Throwable, + ) : Content + } } 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 3d629c35..18bc51ef 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 @@ -2,22 +2,22 @@ package com.daedan.festabook.presentation.schedule import com.daedan.festabook.presentation.schedule.model.ScheduleDateUiModel -sealed interface ScheduleUiState { - data object InitialLoading : ScheduleUiState +data class ScheduleUiState( + val content: Content, +) { + sealed interface Content { + data object InitialLoading : Content - data class Refreshing( - val lastSuccessState: Success, - ) : ScheduleUiState + data class Success( + val dates: List, + val currentDatePosition: Int, + val eventsUiStateByPosition: Map = emptyMap(), + ) : Content - data class Success( - val dates: List, - val currentDatePosition: Int, - val eventsUiStateByPosition: Map = emptyMap(), - ) : ScheduleUiState - - data class Error( - val throwable: Throwable, - ) : ScheduleUiState + data class Error( + val throwable: Throwable, + ) : Content + } companion object { const val DEFAULT_POSITION: Int = 0 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 01c808bc..2f70902c 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,7 +16,6 @@ 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) @@ -26,7 +25,9 @@ class ScheduleViewModel( private val scheduleRepository: ScheduleRepository, ) : ViewModel() { private val _scheduleUiState: MutableStateFlow = - MutableStateFlow(ScheduleUiState.InitialLoading) + MutableStateFlow( + ScheduleUiState(content = ScheduleUiState.Content.InitialLoading), + ) val scheduleUiState: StateFlow = _scheduleUiState.asStateFlow() init { @@ -34,8 +35,8 @@ class ScheduleViewModel( } fun loadSchedules( - scheduleUiState: ScheduleUiState = ScheduleUiState.InitialLoading, - scheduleEventUiState: ScheduleEventsUiState = ScheduleEventsUiState.InitialLoading, + scheduleUiState: ScheduleUiState = ScheduleUiState(content = ScheduleUiState.Content.InitialLoading), + scheduleEventUiState: ScheduleEventsUiState = ScheduleEventsUiState(ScheduleEventsUiState.Content.InitialLoading), selectedDatePosition: Int? = null, preloadCount: Int = PRELOAD_PAGE_COUNT, ) { @@ -44,7 +45,7 @@ class ScheduleViewModel( if (datesResult.isSuccess) { val currentPosition = - (_scheduleUiState.value as ScheduleUiState.Success).currentDatePosition + (_scheduleUiState.value.content as ScheduleUiState.Content.Success).currentDatePosition loadEventsInRange(currentPosition, scheduleEventUiState, preloadCount) } } @@ -64,15 +65,19 @@ class ScheduleViewModel( selectedDatePosition ?: getCurrentDatePosition(scheduleDates) _scheduleUiState.value = - ScheduleUiState.Success( - dates = scheduleDateUiModels, - currentDatePosition = currentDatePosition, + ScheduleUiState( + content = + ScheduleUiState.Content.Success( + dates = scheduleDateUiModels, + currentDatePosition = currentDatePosition, + ), ) Result.success(scheduleDateUiModels) }, onFailure = { throwable -> - _scheduleUiState.value = ScheduleUiState.Error(throwable) + _scheduleUiState.value = + ScheduleUiState(content = ScheduleUiState.Content.Error(throwable)) Result.failure(throwable) }, ) @@ -80,10 +85,10 @@ class ScheduleViewModel( fun loadEventsInRange( currentPosition: Int, - scheduleEventUiState: ScheduleEventsUiState = ScheduleEventsUiState.InitialLoading, + scheduleEventUiState: ScheduleEventsUiState = ScheduleEventsUiState(ScheduleEventsUiState.Content.InitialLoading), preloadCount: Int = PRELOAD_PAGE_COUNT, ) { - (_scheduleUiState.value as? ScheduleUiState.Success)?.dates?.let { scheduleDates -> + (_scheduleUiState.value.content as? ScheduleUiState.Content.Success)?.dates?.let { scheduleDates -> val range = getPreloadRange( totalPageSize = scheduleDates.size, @@ -91,18 +96,16 @@ class ScheduleViewModel( 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, - ) - } + range.forEach { position -> + if (isEventLoaded(position)) return@forEach + + val scheduleDateUiModel = scheduleDates[position] + launch { + loadEventsByPosition( + position = position, + scheduleDateUiModel = scheduleDateUiModel, + scheduleEventsUiState = scheduleEventUiState, + ) } } } @@ -125,13 +128,21 @@ class ScheduleViewModel( updateEventUiState( position = position, scheduleEventsUiState = - ScheduleEventsUiState.Success( - events = uiModels, - currentEventPosition = getCurrentEventPosition(uiModels), + ScheduleEventsUiState( + content = + ScheduleEventsUiState.Content.Success( + events = uiModels, + currentEventPosition = getCurrentEventPosition(uiModels), + ), ), ) }.onFailure { - updateEventUiState(position, ScheduleEventsUiState.Error(it)) + updateEventUiState( + position, + ScheduleEventsUiState( + content = ScheduleEventsUiState.Content.Error(it), + ), + ) } } @@ -139,13 +150,16 @@ class ScheduleViewModel( position: Int, scheduleEventsUiState: ScheduleEventsUiState, ) { - val currentUiState = _scheduleUiState.value - if (currentUiState !is ScheduleUiState.Success) return + val currentState = _scheduleUiState.value + val content = currentState.content as? ScheduleUiState.Content.Success ?: return _scheduleUiState.value = - currentUiState.copy( - eventsUiStateByPosition = - currentUiState.eventsUiStateByPosition + (position to scheduleEventsUiState), + currentState.copy( + content = + content + .copy( + eventsUiStateByPosition = content.eventsUiStateByPosition + (position to scheduleEventsUiState), + ), ) } @@ -180,9 +194,13 @@ class ScheduleViewModel( } private fun isEventLoaded(position: Int): Boolean { - val currentScheduleUiState = _scheduleUiState.value - if (currentScheduleUiState !is ScheduleUiState.Success) return false - return currentScheduleUiState.eventsUiStateByPosition[position] is ScheduleEventsUiState.Success + val currentScheduleUiStateContent = _scheduleUiState.value.content + if (currentScheduleUiStateContent !is ScheduleUiState.Content.Success) return false + + val currentScheduleEventsUiStateContent = + currentScheduleUiStateContent.eventsUiStateByPosition[position]?.content ?: return false + + return currentScheduleEventsUiStateContent is ScheduleEventsUiState.Content.Success } companion object { 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 deleted file mode 100644 index 50d6dd39..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleAdapter.kt +++ /dev/null @@ -1,36 +0,0 @@ -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.model.ScheduleEventUiModel - -class ScheduleAdapter : ListAdapter(DIFF_UTIL) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): ScheduleItemViewHolder = ScheduleItemViewHolder.from(parent) - - override fun onBindViewHolder( - holder: ScheduleItemViewHolder, - position: Int, - ) { - holder.bind(getItem(position), itemCount) - } - - companion object { - private val DIFF_UTIL = - object : - DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: ScheduleEventUiModel, - newItem: ScheduleEventUiModel, - ): Boolean = oldItem.id == newItem.id - - override fun areContentsTheSame( - oldItem: ScheduleEventUiModel, - newItem: ScheduleEventUiModel, - ): Boolean = oldItem == newItem - } - } -} 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 deleted file mode 100644 index b64caf00..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleItemViewHolder.kt +++ /dev/null @@ -1,153 +0,0 @@ -package com.daedan.festabook.presentation.schedule.adapter - -import android.content.Context -import android.view.Gravity -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.RecyclerView -import com.daedan.festabook.R -import com.daedan.festabook.databinding.ItemScheduleTabPageBinding -import com.daedan.festabook.logging.logger -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 - -class ScheduleItemViewHolder( - private val binding: ItemScheduleTabPageBinding, -) : RecyclerView.ViewHolder(binding.root) { - private var scheduleEventItem: ScheduleEventUiModel? = null - - init { - binding.clScheduleEventCard.setOnClickListener { - scheduleEventItem?.let { - binding.logger.log( - ScheduleEventClickLogData( - binding.logger.getBaseLogData(), - it.id, - it.title, - ), - ) - } - } - } - - fun bind( - scheduleEventItem: ScheduleEventUiModel, - itemCount: Int, - ) { - this.scheduleEventItem = scheduleEventItem - setupBottomMargin(itemCount) - binding.scheduleEvent = scheduleEventItem - setupEventViewByStatus(scheduleEventItem.status) - } - - private fun setupBottomMargin(itemCount: Int) { - val layoutParams = itemView.layoutParams as ViewGroup.MarginLayoutParams - if (layoutPosition == itemCount - 1) { - layoutParams.bottomMargin = BOTTOM_MARGIN.toPx(binding.clScheduleEventCard.context) - } else { - layoutParams.bottomMargin = 0 - } - } - - private fun setupEventViewByStatus(status: ScheduleEventUiStatus) { - val context = binding.root.context - val gray050 = ContextCompat.getColor(context, R.color.gray050) - val gray400 = ContextCompat.getColor(context, R.color.gray400) - val gray500 = ContextCompat.getColor(context, R.color.gray500) - val gray900 = ContextCompat.getColor(context, R.color.gray900) - - when (status) { - ScheduleEventUiStatus.COMPLETED -> { - val borderColor = R.drawable.bg_stroke_gray400_radius_10dp - binding.clScheduleEventCard.setBackgroundResource(borderColor) - setupScheduleEventStatusText( - context = context, - status = status, - textColor = gray400, - backgroundResId = null, - ) - setupScheduleEventContentsColor( - titleColor = gray400, - timeColor = gray400, - locationColor = gray400, - ) - } - - ScheduleEventUiStatus.ONGOING -> { - val borderColor = R.drawable.bg_stroke_blue400_radius_10dp - binding.clScheduleEventCard.setBackgroundResource(borderColor) - setupScheduleEventStatusText( - context = context, - status = status, - textColor = gray050, - backgroundResId = R.drawable.bg_gray900_radius_6dp, - ) - setupScheduleEventContentsColor( - titleColor = gray900, - timeColor = gray500, - locationColor = gray500, - ) - } - - ScheduleEventUiStatus.UPCOMING -> { - val borderColor = R.drawable.bg_stroke_green400_radius_10dp - binding.clScheduleEventCard.setBackgroundResource(borderColor) - setupScheduleEventStatusText( - context = context, - status = status, - textColor = gray900, - backgroundResId = R.drawable.bg_stroke_gray900_radius_6dp, - ) - setupScheduleEventContentsColor( - titleColor = gray900, - timeColor = gray500, - locationColor = gray500, - ) - binding.tvScheduleEventStatus.layoutParams = - binding.tvScheduleEventStatus.layoutParams.apply { - width = UPCOMING_TEXT_WIDTH.toPx(context) - height = UPCOMING_TEXT_HEIGHT.toPx(context) - } - } - } - } - - private fun setupScheduleEventContentsColor( - titleColor: Int, - timeColor: Int, - locationColor: Int, - ) { - binding.tvScheduleEventTitle.setTextColor(titleColor) - binding.ivScheduleEventLocation.setColorFilter(locationColor) - binding.tvScheduleEventLocation.setTextColor(locationColor) - binding.ivScheduleEventClock.setColorFilter(timeColor) - binding.tvScheduleEventTime.setTextColor(timeColor) - } - - private fun setupScheduleEventStatusText( - context: Context, - status: ScheduleEventUiStatus, - textColor: Int, - backgroundResId: Int?, - ) = with(binding.tvScheduleEventStatus) { - val gray050 = ContextCompat.getColor(context, R.color.gray050) - setTextColor(textColor) - gravity = if (status == ScheduleEventUiStatus.COMPLETED) Gravity.END else Gravity.CENTER - backgroundResId?.let { setBackgroundResource(it) } ?: setBackgroundColor(gray050) - } - - companion object { - private const val UPCOMING_TEXT_WIDTH = 36 - private const val UPCOMING_TEXT_HEIGHT = 24 - private const val BOTTOM_MARGIN = 20 - - fun from(parent: ViewGroup): ScheduleItemViewHolder { - val inflater = LayoutInflater.from(parent.context) - val binding = ItemScheduleTabPageBinding.inflate(inflater, parent, false) - return ScheduleItemViewHolder(binding) - } - } -} 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 588a2322..40008ba2 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,6 +17,7 @@ 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.ErrorStateScreen import com.daedan.festabook.presentation.common.component.FestabookTopAppBar import com.daedan.festabook.presentation.common.component.LoadingStateScreen import com.daedan.festabook.presentation.schedule.ScheduleEventsUiState @@ -34,22 +35,14 @@ fun ScheduleScreen( ) { val scheduleUiState by scheduleViewModel.scheduleUiState.collectAsStateWithLifecycle() val currentOnShowErrorSnackbar by rememberUpdatedState(onShowErrorSnackbar) - val currentState = - when (scheduleUiState) { - is ScheduleUiState.Refreshing -> (scheduleUiState as ScheduleUiState.Refreshing).lastSuccessState - is ScheduleUiState.Success -> scheduleUiState - else -> scheduleUiState - } - LaunchedEffect(currentState) { - when (currentState) { - is ScheduleUiState.Error -> { - currentOnShowErrorSnackbar(currentState.throwable) + LaunchedEffect(scheduleUiState.content) { + when (val scheduleUiStateContent = scheduleUiState.content) { + is ScheduleUiState.Content.Error -> { + currentOnShowErrorSnackbar(scheduleUiStateContent.throwable) } - else -> { - Unit - } + else -> {} } } @@ -57,19 +50,19 @@ fun ScheduleScreen( topBar = { FestabookTopAppBar(title = stringResource(R.string.schedule_title)) }, modifier = modifier, ) { innerPadding -> - when (currentState) { - ScheduleUiState.InitialLoading -> { - LoadingStateScreen() + when (val scheduleContent = scheduleUiState.content) { + is ScheduleUiState.Content.Error -> { + Timber.w(scheduleContent.throwable.stackTraceToString()) + ErrorStateScreen() } - is ScheduleUiState.Error -> { - Timber.w(currentState.throwable.stackTraceToString()) + ScheduleUiState.Content.InitialLoading -> { + LoadingStateScreen() } - else -> { - val currentStateSuccess = currentState as ScheduleUiState.Success + is ScheduleUiState.Content.Success -> { val pageState = - rememberPagerState(initialPage = currentStateSuccess.currentDatePosition) { currentStateSuccess.dates.size } + rememberPagerState(initialPage = scheduleContent.currentDatePosition) { scheduleContent.dates.size } val scope = rememberCoroutineScope() LaunchedEffect(pageState.currentPage) { scheduleViewModel.loadEventsInRange(currentPosition = pageState.currentPage) @@ -79,7 +72,7 @@ fun ScheduleScreen( ScheduleTabRow( pageState = pageState, scope = scope, - dates = currentStateSuccess.dates, + dates = scheduleContent.dates, ) Spacer(modifier = Modifier.height(festabookSpacing.paddingBody4)) HorizontalDivider( @@ -89,11 +82,15 @@ fun ScheduleScreen( ) ScheduleTabPage( pagerState = pageState, - scheduleUiState = currentStateSuccess, - onRefresh = { oldEvents -> + scheduleContent = scheduleContent, + onRefresh = { currentEventsContent -> scheduleViewModel.loadSchedules( - scheduleUiState = ScheduleUiState.Refreshing(currentStateSuccess), - scheduleEventUiState = ScheduleEventsUiState.Refreshing(oldEvents), + scheduleUiState = ScheduleUiState(content = scheduleContent), + scheduleEventUiState = + ScheduleEventsUiState( + content = currentEventsContent, + isRefreshing = true, + ), 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 87348162..133384de 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 @@ -1,5 +1,6 @@ package com.daedan.festabook.presentation.schedule.component +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -25,6 +26,7 @@ import com.airbnb.lottie.compose.LottieCompositionSpec 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.ErrorStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen import com.daedan.festabook.presentation.common.component.PullToRefreshContainer import com.daedan.festabook.presentation.schedule.ScheduleEventsUiState @@ -42,58 +44,53 @@ import timber.log.Timber @Composable fun ScheduleTabPage( pagerState: PagerState, - scheduleUiState: ScheduleUiState.Success, - onRefresh: (List) -> Unit, + scheduleContent: ScheduleUiState.Content.Success, + onRefresh: (ScheduleEventsUiState.Content) -> Unit, modifier: Modifier = Modifier, ) { val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.pulse_circle)) + val scrollState = rememberScrollState() 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() + val scheduleEventsUiState = + scheduleContent.eventsUiStateByPosition[index] ?: return@HorizontalPager PullToRefreshContainer( - isRefreshing = isRefreshing, - onRefresh = { onRefresh(oldEvents) }, + isRefreshing = scheduleEventsUiState.isRefreshing, + onRefresh = { onRefresh(scheduleEventsUiState.content) }, ) { graphicsLayer -> - when (scheduleEventsUiState) { - is ScheduleEventsUiState.Error -> { - Timber.w(scheduleEventsUiState.throwable.stackTraceToString()) - } - - ScheduleEventsUiState.InitialLoading -> { - LoadingStateScreen() - } - - is ScheduleEventsUiState.Refreshing -> { - ScheduleTabContent( - composition = composition, - scheduleEvents = scheduleEventsUiState.oldEvents, + when (val content = scheduleEventsUiState.content) { + is ScheduleEventsUiState.Content.Error -> { + Timber.w(content.throwable.stackTraceToString()) + ErrorStateScreen( modifier = Modifier + .fillMaxSize() .padding(end = festabookSpacing.paddingScreenGutter) - .then(graphicsLayer), + .then(graphicsLayer) + .verticalScroll(scrollState), ) } - is ScheduleEventsUiState.Success -> { + is ScheduleEventsUiState.Content.InitialLoading -> { + LoadingStateScreen() + } + + is ScheduleEventsUiState.Content.Success -> { ScheduleTabContent( + scrollState = scrollState, composition = composition, - scheduleEvents = scheduleEventsUiState.events, - currentEventPosition = scheduleEventsUiState.currentEventPosition, + scheduleEventsContent = content, + currentEventPosition = content.currentEventPosition, modifier = Modifier .padding(end = festabookSpacing.paddingScreenGutter) .then(graphicsLayer), ) } - - null -> {} } } } @@ -101,18 +98,18 @@ fun ScheduleTabPage( @Composable private fun ScheduleTabContent( + scrollState: ScrollState, composition: LottieComposition?, - scheduleEvents: List, + scheduleEventsContent: ScheduleEventsUiState.Content.Success, modifier: Modifier = Modifier, currentEventPosition: Int = DEFAULT_POSITION, ) { val listState = rememberLazyListState() - val scrollState = rememberScrollState() LaunchedEffect(Unit) { listState.animateScrollToItem(currentEventPosition) } - if (scheduleEvents.isEmpty()) { + if (scheduleEventsContent.isEventsEmpty) { EmptyStateScreen( modifier = modifier @@ -133,7 +130,10 @@ private fun ScheduleTabContent( contentPadding = PaddingValues(vertical = festabookSpacing.paddingBody5), state = listState, ) { - items(items = scheduleEvents, key = { scheduleEvent -> scheduleEvent.id }) { + items( + items = scheduleEventsContent.events, + key = { scheduleEvent -> scheduleEvent.id }, + ) { ScheduleEventItem( composition = composition, scheduleEvent = it, @@ -149,33 +149,38 @@ private fun ScheduleTabContent( private fun ScheduleTabContentPreview() { FestabookTheme { ScheduleTabContent( + scrollState = rememberScrollState(), composition = null, - 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 = "운동장", - ), + scheduleEventsContent = + ScheduleEventsUiState.Content.Success( + events = + 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 = "운동장", + ), + ), + currentEventPosition = 0, ), ) } diff --git a/app/src/main/res/drawable/ic_fail_load.xml b/app/src/main/res/drawable/ic_fail_load.xml new file mode 100644 index 00000000..1a8009f6 --- /dev/null +++ b/app/src/main/res/drawable/ic_fail_load.xml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7deb229a..3a698b5c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -114,6 +114,7 @@ 북마크 festabook + 로딩 실패 아이콘 확인 @@ -121,7 +122,7 @@ 잘못된 요청입니다 현재 연결이 불안정합니다. 잠시 후 다시 시도해주세요 알 수 없는 에러가 발생했습니다 잠시 후 다시 시도해주세요 - 정보를 불러오는데 실패했습니다 + 축제를 불러올 수 없어요 알림 권한 필요 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 598919cb..a05f3441 100644 --- a/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt @@ -64,14 +64,19 @@ class ScheduleViewModelTest { // when // then - val stateResult = scheduleViewModel.scheduleUiState.value + val stateResult = scheduleViewModel.scheduleUiState.value.content 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) }, + { assertTrue(stateResult is ScheduleUiState.Content.Success) }, + { + assertEquals( + expectedDate, + (stateResult as ScheduleUiState.Content.Success).dates, + ) + }, ) } @@ -82,14 +87,15 @@ class ScheduleViewModelTest { advanceUntilIdle() // when - val state = scheduleViewModel.scheduleUiState.value + val state = scheduleViewModel.scheduleUiState.value.content // then val successState = - state as? ScheduleUiState.Success ?: fail("ScheduleUiState.Success 가 아님: $state") + state as? ScheduleUiState.Content.Success + ?: fail("ScheduleUiState.Success 가 아님: $state") val eventsState = - successState.eventsUiStateByPosition[0] as? ScheduleEventsUiState.Success + successState.eventsUiStateByPosition[0]?.content as? ScheduleEventsUiState.Content.Success ?: fail("ScheduleEventsUiState.Success 가 아님") assertAll( @@ -106,11 +112,12 @@ class ScheduleViewModelTest { advanceUntilIdle() // when - val state = scheduleViewModel.scheduleUiState.value + val state = scheduleViewModel.scheduleUiState.value.content // then val successState = - state as? ScheduleUiState.Success ?: fail("ScheduleUiState.Success 가 아님: $state") + state as? ScheduleUiState.Content.Success + ?: fail("ScheduleUiState.Success 가 아님: $state") assertAll( { coVerify { scheduleRepository.fetchAllScheduleDates() } }, @@ -126,13 +133,14 @@ class ScheduleViewModelTest { advanceUntilIdle() // when - val state = scheduleViewModel.scheduleUiState.value + val state = scheduleViewModel.scheduleUiState.value.content // then val successState = - state as? ScheduleUiState.Success ?: fail("ScheduleUiState.Success 가 아님: $state") + state as? ScheduleUiState.Content.Success + ?: fail("ScheduleUiState.Success 가 아님: $state") val eventsState = - successState.eventsUiStateByPosition[0] as? ScheduleEventsUiState.Success + successState.eventsUiStateByPosition[0]?.content as? ScheduleEventsUiState.Content.Success ?: fail("ScheduleEventsUiState.Success 가 아님") assertAll(