diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 82965f2..9416bb3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -183,6 +183,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/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 0000000..734f616 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookImage.kt @@ -0,0 +1,171 @@ +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.R +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 + +@Composable +fun FestabookImage( + imageUrl: String?, + modifier: Modifier = Modifier, + 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 +private fun FestabookImageTestPreview() { + FestabookImage( + imageUrl = "", + ) +} + +@Preview(showBackground = true) +@Composable +private fun DiaplogPreview() { + FestabookImageZoomPopup( + imageUrl = "", + ) { } +} + +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/HomeFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt index 26ac90a..24d4a0e 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,51 @@ package com.daedan.festabook.presentation.home 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 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()) @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() }) - 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(), - ), + ): View = + ComposeView(requireContext()).apply { + setViewCompositionStrategy( + ViewCompositionStrategy + .DisposeOnViewTreeLifecycleDestroyed, ) - - 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}", - ) - } + setContent { + HomeScreen( + viewModel = viewModel, + onNavigateToExplore = { + startActivity(ExploreActivity.newIntent(requireContext())) + }, + ) } } - } - - 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) - } - } - 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 5f0e991..99ebae0 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,16 +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.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 +22,15 @@ import kotlinx.coroutines.launch class HomeViewModel( 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 +52,7 @@ class HomeViewModel( } fun navigateToScheduleClick() { - _navigateToScheduleEvent.setValue(Unit) + _navigateToScheduleEvent.tryEmit(Unit) } private fun loadLineup() { @@ -58,10 +62,8 @@ class HomeViewModel( 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 f2b8429..dcfc03e 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/HomeArtistItem.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt new file mode 100644 index 0000000..0c3cc77 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt @@ -0,0 +1,71 @@ +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, + ) + } +} + +private object HomeArtistItem { + val ArtistImage = + RoundedCornerShape( + topStartPercent = 50, + topEndPercent = 50, + bottomEndPercent = 50, + bottomStartPercent = 5, + ) +} + +@Preview +@Composable +private fun HomeArtistItemPreview() { + HomeArtistItem( + artistName = "실리카겔", + artistImageUrl = "sample", + ) +} 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 0000000..37e15d6 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt @@ -0,0 +1,48 @@ +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.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.FestabookTypography + +@Composable +fun HomeFestivalInfo( + festivalName: String, + festivalDate: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth().padding(horizontal = 20.dp), + ) { + Text( + text = festivalName, + style = FestabookTypography.displayMedium, + color = FestabookColor.black, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = festivalDate, + style = FestabookTypography.bodyLarge, + color = FestabookColor.gray500, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeFestivalInfoPreview() { + HomeFestivalInfo( + festivalName = "2025 가천 Water Festival\n: AQUA WAVE", + festivalDate = "2025년 10월 15일 - 10월 17일", + ) +} 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 0000000..1d9450d --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt @@ -0,0 +1,71 @@ +package com.daedan.festabook.presentation.home.component + +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 +import androidx.compose.ui.Modifier +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 + +@Composable +fun HomeHeader( + universityName: String, + onExpandClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Row( + modifier = Modifier.clickable { onExpandClick() }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = universityName, + style = + FestabookTypography.displayLarge.copy( + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeight = 34.sp, + ), + color = FestabookColor.black, + ) + + Spacer(modifier = Modifier.width(4.dp)) + + Icon( + painter = painterResource(id = R.drawable.ic_dropdown), + tint = FestabookColor.black, + contentDescription = stringResource(R.string.home_navigate_to_explore_desc), + modifier = Modifier.size(24.dp), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeHeaderPreview() { + HomeHeader( + 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 new file mode 100644 index 0000000..11b4d09 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt @@ -0,0 +1,75 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.clickable +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.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(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, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.home_navigate_to_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 = stringResource(R.string.home_navigate_to_schedule_desc), + tint = FestabookColor.gray400, + modifier = Modifier.size(12.dp), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeLineupHeaderPreview() { + HomeLineupHeader( + onScheduleClick = {}, + ) +} 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 0000000..9efdb59 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt @@ -0,0 +1,152 @@ +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.IntrinsicSize +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.HorizontalDivider +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(), + ) { + // 날짜 + 배지 영역 + Column( + modifier = Modifier.padding(horizontal = 16.dp).width(IntrinsicSize.Max), + ) { + 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, + ) + } + } + } + Spacer(modifier = Modifier.height(4.dp)) + + HorizontalDivider( + thickness = 1.dp, + color = FestabookColor.gray700, + modifier = Modifier.fillMaxWidth(), + ) + } + + 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(), + ), + ), + ), + ) +} 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 0000000..4e9bda6 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt @@ -0,0 +1,113 @@ +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.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.FestabookImage +import com.daedan.festabook.presentation.common.component.cardBackground +import com.daedan.festabook.presentation.theme.festabookShapes +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 + }.cardBackground(shape = festabookShapes.radius2) + .clip(festabookShapes.radius2), + ) { + FestabookImage( + imageUrl = imageUrl, + modifier = Modifier.fillMaxSize(), + enablePopUp = true, + ) + } + } +} + +@Preview +@Composable +private fun HomePosterListPreview() { + HomePosterList( + posterUrls = + listOf( + "sample", + "sample", + "sample", + ), + ) +} 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 0000000..0214a9c --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt @@ -0,0 +1,216 @@ +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 +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.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.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.LineupUiState +import com.daedan.festabook.presentation.home.adapter.FestivalUiState +import com.daedan.festabook.presentation.theme.FestabookColor +import java.time.LocalDate +import java.time.LocalDateTime + +@Composable +fun HomeScreen( + viewModel: HomeViewModel, + onNavigateToExplore: () -> Unit, + modifier: Modifier = Modifier, +) { + val festivalUiState by viewModel.festivalUiState.collectAsStateWithLifecycle() + val lineupUiState by viewModel.lineupUiState.collectAsStateWithLifecycle() + + 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.Success, + lineupUiState: LineupUiState, + onNavigateToExplore: () -> Unit, + onNavigateToSchedule: () -> Unit, + modifier: Modifier = Modifier, +) { + val universityName = festivalUiState.organization.universityName + + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = Color.White, + ) { innerPadding -> + Column( + modifier = + Modifier + .fillMaxSize(), + ) { + 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 } + .map { it.imageUrl } + + HomePosterList( + posterUrls = posterUrls, + modifier = Modifier.padding(vertical = 12.dp), + ) + } + + // 축제 정보 + item { + val festival = festivalUiState.organization.festival + HomeFestivalInfo( + festivalName = festival.festivalName, + festivalDate = + formatFestivalPeriod( + festival.startDate, + festival.endDate, + ), + modifier = Modifier.padding(top = 16.dp), + ) + } + + // 구분선 + item { + HorizontalDivider( + thickness = 4.dp, + color = FestabookColor.gray200, + modifier = + Modifier + .padding(top = 16.dp), + ) + } + + // 라인업 헤더 + item { + HomeLineupHeader( + onScheduleClick = onNavigateToSchedule, + ) + } + + // 라인업 리스트 + when (lineupUiState) { + is LineupUiState.Success -> { + items( + items = lineupUiState.lineups, + key = { it.id }, + ) { lineupItem -> + HomeLineupItem(uiModel = lineupItem) + } + } + + is LineupUiState.Loading -> { + // 로딩 시 동작 논의 후 추가 + } + + is LineupUiState.Error -> { + // 에러 표시 + } + } + + // 하단 여백 추가 + item { + Spacer(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.getLineupItems()), + 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 4a01b03..528af41 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 @@ -40,6 +43,7 @@ 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.launch import timber.log.Timber class MainActivity : @@ -158,8 +162,15 @@ 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.collect { + if (binding.bnvMenu.selectedItemId != R.id.item_menu_schedule) { + binding.bnvMenu.selectedItemId = R.id.item_menu_schedule + } + } + } } mainViewModel.isFirstVisit.observe(this) { isFirstVisit -> @@ -215,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_setting -> + R.id.item_menu_news -> { + switchFragment(NewsFragment::class.java, TAG_NEWS_FRAGMENT) + } + + R.id.item_menu_setting -> { switchFragment( SettingFragment::class.java, TAG_SETTING_FRAGMENT, ) + } } true } @@ -242,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 + } } } } 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 a315674..783a4a7 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 @@ -109,25 +108,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() { diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index e0b46fe..76e048f 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 63f6484..acf2688 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,6 +17,11 @@ poster_image 오늘 + 일정 화면으로 이동하는 버튼 + 일정 확인하기 + 축제 라인업 + 탐색 화면으로 이동하는 버튼 + 한 눈에 보기 @@ -127,8 +132,7 @@ 알림 받기 다음에 item_lineup_image - 일정 확인하기 - 축제 라인업 + 뒤로가기를 한 번 더 누르면 종료됩니다. @@ -139,7 +143,6 @@ 알림 새로운 소식이 있습니다. - 탐색 화면으로 이동하는 버튼 탐색 화면 닫기 버튼 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 d44b7c3..d1a7d1b 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() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 178b55b..2f9f357 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,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" ktlintVersion = "0.5.0" loggingInterceptor = "5.1.0" lottie = "6.6.6" @@ -63,6 +66,9 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co 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" } +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" } 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" }