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 2b0b834..fa548e6 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt @@ -1,184 +1,49 @@ package com.daedan.festabook.presentation.home import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import 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) -class HomeFragment @Inject constructor( - private val centerItemMotionEnlarger: RecyclerView.OnScrollListener, -) : BaseFragment() { +class HomeFragment @Inject constructor() : BaseFragment() { override val layoutId: Int = R.layout.fragment_home @Inject override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory private val viewModel: HomeViewModel by viewModels({ requireActivity() }) - private val posterAdapter: PosterAdapter by lazy { - PosterAdapter() - } - - private val lineupOfDayAdapter: LineUpItemOfDayAdapter by lazy { - LineUpItemOfDayAdapter() - } - - override fun onViewCreated( - view: View, + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - binding.lifecycleOwner = viewLifecycleOwner - setupObservers() - setupAdapters() - setupNavigateToScheduleButton() - setupNavigateToExploreButton() - } - - private fun setupNavigateToExploreButton() { - binding.layoutTitleWithIcon.setOnClickListener { - binding.logger.log(ExploreClickLogData(binding.logger.getBaseLogData())) - - startActivity(ExploreActivity.newIntent(requireContext())) - } - } - - private fun setupNavigateToScheduleButton() { - binding.btnNavigateToSchedule.setOnClickListener { - binding.logger.log( - ScheduleClickLogData( - baseLogData = binding.logger.getBaseLogData(), - ), - ) - - viewModel.navigateToScheduleClick() - } - } - - private fun setupObservers() { - viewModel.festivalUiState.observe(viewLifecycleOwner) { festivalUiState -> - when (festivalUiState) { - is FestivalUiState.Loading -> {} - is FestivalUiState.Success -> handleSuccessState(festivalUiState) - is FestivalUiState.Error -> { - showErrorSnackBar(festivalUiState.throwable) - Timber.w( - festivalUiState.throwable, - "HomeFragment: ${festivalUiState.throwable.message}", - ) - } - } - } - viewModel.lineupUiState.observe(viewLifecycleOwner) { lineupUiState -> - when (lineupUiState) { - is LineupUiState.Loading -> {} - is LineupUiState.Success -> { - lineupOfDayAdapter.submitList(lineupUiState.lineups.getLineupItems()) - } - - is LineupUiState.Error -> { - showErrorSnackBar(lineupUiState.throwable) - Timber.w( - lineupUiState.throwable, - "HomeFragment: ${lineupUiState.throwable.message}", - ) - } - } - } - } - - private fun setupAdapters() { - binding.rvHomePoster.adapter = posterAdapter - binding.rvHomeLineup.adapter = lineupOfDayAdapter - attachSnapHelper() - addScrollEffectListener() - } - - private fun handleSuccessState(festivalUiState: FestivalUiState.Success) { - binding.tvHomeOrganizationTitle.text = - festivalUiState.organization.universityName - binding.tvHomeFestivalTitle.text = - festivalUiState.organization.festival.festivalName - binding.tvHomeFestivalDate.text = - formatFestivalPeriod( - festivalUiState.organization.festival.startDate, - festivalUiState.organization.festival.endDate, - ) - - val posterUrls = - festivalUiState.organization.festival.festivalImages - .sortedBy { it.sequence } - .map { it.imageUrl } - - if (posterUrls.isNotEmpty()) { - posterAdapter.submitList(posterUrls) { - scrollToInitialPosition(posterUrls.size) + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + HomeScreen( + viewModel = viewModel, + onNavigateToExplore = { + startActivity(ExploreActivity.newIntent(requireContext())) + }, + ) } } - binding.logger.log( - HomeViewLogData( - baseLogData = binding.logger.getBaseLogData(), - universityName = festivalUiState.organization.universityName, - festivalId = festivalUiState.organization.id, - ), - ) - } - - private fun attachSnapHelper() { - PagerSnapHelper().attachToRecyclerView(binding.rvHomePoster) - } - - private fun scrollToInitialPosition(size: Int) { - val safeMaxValue = Int.MAX_VALUE / INFINITE_SCROLL_SAFETY_FACTOR - val initialPosition = safeMaxValue - (safeMaxValue % size) - - val layoutManager = binding.rvHomePoster.layoutManager as? LinearLayoutManager ?: return - - val itemWidth = resources.getDimensionPixelSize(R.dimen.poster_item_width) - val offset = (binding.rvHomePoster.width / 2) - (itemWidth / 2) - - layoutManager.scrollToPositionWithOffset(initialPosition, offset) - - binding.rvHomePoster.post { - (centerItemMotionEnlarger as CenterItemMotionEnlarger).expandCenterItem(binding.rvHomePoster) - } - } - - private fun addScrollEffectListener() { - binding.rvHomePoster.addOnScrollListener(centerItemMotionEnlarger) - } - - override fun onDestroyView() { - binding.rvHomePoster.clearOnScrollListeners() - super.onDestroyView() - } - - companion object { - private const val INFINITE_SCROLL_SAFETY_FACTOR = 4 } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt index 85fb947..6dbb1c1 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt @@ -1,17 +1,19 @@ package com.daedan.festabook.presentation.home -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey -import com.daedan.festabook.di.viewmodel.ViewModelScope import com.daedan.festabook.domain.repository.FestivalRepository -import com.daedan.festabook.presentation.common.SingleLiveData import com.daedan.festabook.presentation.home.adapter.FestivalUiState import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @ContributesIntoMap(AppScope::class) @@ -19,14 +21,15 @@ import kotlinx.coroutines.launch class HomeViewModel @Inject constructor( private val festivalRepository: FestivalRepository, ) : ViewModel() { - private val _festivalUiState = MutableLiveData() - val festivalUiState: LiveData get() = _festivalUiState + private val _festivalUiState = MutableStateFlow(FestivalUiState.Loading) + val festivalUiState: StateFlow = _festivalUiState.asStateFlow() - private val _lineupUiState = MutableLiveData() - val lineupUiState: LiveData get() = _lineupUiState + private val _lineupUiState = MutableStateFlow(LineupUiState.Loading) + val lineupUiState: StateFlow = _lineupUiState.asStateFlow() - private val _navigateToScheduleEvent: SingleLiveData = SingleLiveData() - val navigateToScheduleEvent: LiveData get() = _navigateToScheduleEvent + private val _navigateToScheduleEvent = + MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val navigateToScheduleEvent: SharedFlow = _navigateToScheduleEvent.asSharedFlow() init { loadFestival() @@ -48,7 +51,7 @@ class HomeViewModel @Inject constructor( } fun navigateToScheduleClick() { - _navigateToScheduleEvent.setValue(Unit) + _navigateToScheduleEvent.tryEmit(Unit) } private fun loadLineup() { @@ -58,10 +61,8 @@ class HomeViewModel @Inject constructor( val result = festivalRepository.getLineUpGroupByDate() result .onSuccess { lineups -> - _lineupUiState.value = - LineupUiState.Success( - lineups.toUiModel(), - ) + val lineupItems = lineups.toUiModel().getLineupItems() + _lineupUiState.value = LineupUiState.Success(lineupItems) }.onFailure { _lineupUiState.value = LineupUiState.Error(it) } diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/LineupUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/home/LineupUiState.kt index 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..7d451cc --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt @@ -0,0 +1,70 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.common.component.CoilImage +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography + +@Composable +fun HomeArtistItem( + artistName: String, + artistImageUrl: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.width(68.dp), + ) { + CoilImage( + url = artistImageUrl, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(HomeArtistItem.ArtistImage) + .border(1.dp, FestabookColor.gray300, HomeArtistItem.ArtistImage), + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = artistName, + style = FestabookTypography.labelLarge, + color = FestabookColor.gray700, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private object HomeArtistItem { + val ArtistImage = RoundedCornerShape( + topStartPercent = 50, + topEndPercent = 50, + bottomEndPercent = 50, + bottomStartPercent = 5, + ) +} + +@Preview +@Composable +private fun HomeArtistItemPreview() { + HomeArtistItem( + artistName = "실리카겔", + artistImageUrl = "sample", + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt new file mode 100644 index 0000000..5098919 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt @@ -0,0 +1,50 @@ +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(), + ) { + Text( + text = festivalName, + style = FestabookTypography.displayMedium, + color = FestabookColor.black, + modifier = Modifier.padding(horizontal = 20.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = festivalDate, + style = FestabookTypography.bodyLarge, + color = FestabookColor.gray500, + modifier = Modifier.padding(horizontal = 20.dp), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeFestivalInfoPreview() { + HomeFestivalInfo( + festivalName = "2025 가천 Water Festival\n: AQUA WAVE", + festivalDate = "2025년 10월 15일 - 10월 17일", + ) +} 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..5677ce1 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt @@ -0,0 +1,70 @@ +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..bbb106d --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt @@ -0,0 +1,156 @@ +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.DividerDefaults +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..fd8b8c0 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt @@ -0,0 +1,114 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import com.daedan.festabook.presentation.common.component.CoilImage +import com.daedan.festabook.presentation.common.component.cardBackground +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(roundedCornerShape = 10.dp) + .clip(RoundedCornerShape(10.dp)) + ) { + CoilImage( + url = imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Preview +@Composable +private fun HomePosterListPreview() { + HomePosterList( + posterUrls = + listOf( + "sample", + "sample", + "sample", + ), + ) +} \ No newline at end of file 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..fe8e790 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt @@ -0,0 +1,198 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.common.formatFestivalPeriod +import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.home.LineUpItemGroupUiModel +import com.daedan.festabook.presentation.home.LineupItemUiModel +import com.daedan.festabook.presentation.home.adapter.FestivalUiState +import com.daedan.festabook.domain.model.Festival +import com.daedan.festabook.domain.model.Organization +import com.daedan.festabook.domain.model.Poster +import com.daedan.festabook.presentation.home.LineupUiState +import com.daedan.festabook.presentation.theme.FestabookColor +import java.time.LocalDate +import java.time.LocalDateTime + +@Composable +fun HomeScreen( + viewModel: HomeViewModel, + onNavigateToExplore: () -> Unit, + modifier: Modifier = Modifier, +) { + val festivalUiState by viewModel.festivalUiState.collectAsState() + val lineupUiState by viewModel.lineupUiState.collectAsState() + + FestivalOverview( + festivalUiState = festivalUiState, + lineupUiState = lineupUiState, + onNavigateToExplore = onNavigateToExplore, + onNavigateToSchedule = viewModel::navigateToScheduleClick, + modifier = modifier, + ) +} + +@Composable +private fun FestivalOverview( + festivalUiState: FestivalUiState, + lineupUiState: LineupUiState, + onNavigateToExplore: () -> Unit, + onNavigateToSchedule: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = Color.White + ) { + LazyColumn( + modifier = + Modifier.fillMaxSize() + ) { + // 헤더 (학교 이름) + item { + if (festivalUiState is FestivalUiState.Success) { + HomeHeader( + universityName = festivalUiState.organization.universityName, + onExpandClick = onNavigateToExplore, + modifier = Modifier.padding(top = 40.dp), + ) + } + } + + // 포스터 리스트 + item { + if (festivalUiState is FestivalUiState.Success) { + val posterUrls = + festivalUiState.organization.festival.festivalImages + .sortedBy { it.sequence } + .map { it.imageUrl } + + HomePosterList( + posterUrls = posterUrls, + modifier = Modifier.padding(vertical = 12.dp), + ) + } + } + + // 축제 정보 + item { + if (festivalUiState is FestivalUiState.Success) { + val festival = festivalUiState.organization.festival + HomeFestivalInfo( + festivalName = festival.festivalName, + festivalDate = + formatFestivalPeriod( + festival.startDate, + festival.endDate, + ), + modifier = Modifier.padding(top = 16.dp), + ) + } + } + + + // 구분선 + item { + if (festivalUiState is FestivalUiState.Success) { + HorizontalDivider( + thickness = 4.dp, + color = FestabookColor.gray200, + modifier = + Modifier + .padding(top = 16.dp), + ) + } + } + + // 라인업 헤더 + item { + HomeLineupHeader( + onScheduleClick = onNavigateToSchedule, + ) + } + + // 라인업 리스트 + when (lineupUiState) { + is LineupUiState.Success -> { + 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 8df33ef..17e2b8c 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt @@ -19,7 +19,10 @@ import androidx.fragment.app.FragmentFactory import androidx.fragment.app.add import androidx.fragment.app.commit import androidx.fragment.app.commitNow +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.daedan.festabook.R import com.daedan.festabook.databinding.ActivityMainBinding import com.daedan.festabook.di.appGraph @@ -39,6 +42,8 @@ import com.daedan.festabook.presentation.setting.SettingFragment import com.daedan.festabook.presentation.setting.SettingViewModel import com.google.android.material.dialog.MaterialAlertDialogBuilder import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import timber.log.Timber class MainActivity : @@ -158,8 +163,13 @@ class MainActivity : if (isDoublePress) finish() else showToast(getString(R.string.back_press_exit_message)) } } - homeViewModel.navigateToScheduleEvent.observe(this) { - binding.bnvMenu.selectedItemId = R.id.item_menu_schedule + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + homeViewModel.navigateToScheduleEvent.collectLatest { + binding.bnvMenu.selectedItemId = R.id.item_menu_schedule + } + } } mainViewModel.isFirstVisit.observe(this) { isFirstVisit -> @@ -298,4 +308,4 @@ class MainActivity : flags = Intent.FLAG_ACTIVITY_SINGLE_TOP } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt index 0547731..5011965 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt @@ -112,25 +112,25 @@ class SettingFragment( binding.btnNoticeAllow.isEnabled = !loading } - homeViewModel.festivalUiState.observe(viewLifecycleOwner) { state -> - when (state) { - is FestivalUiState.Error -> { - showErrorSnackBar(state.throwable) - Timber.w( - state.throwable, - "${this::class.simpleName}: ${state.throwable.message}", - ) - } - - FestivalUiState.Loading -> { - binding.tvSettingCurrentUniversityNotice.text = "" - } - - is FestivalUiState.Success -> { - binding.tvSettingCurrentUniversity.text = state.organization.universityName - } - } - } +// homeViewModel.festivalUiState.observe(viewLifecycleOwner) { state -> +// when (state) { +// is FestivalUiState.Error -> { +// showErrorSnackBar(state.throwable) +// Timber.w( +// state.throwable, +// "${this::class.simpleName}: ${state.throwable.message}", +// ) +// } +// +// FestivalUiState.Loading -> { +// binding.tvSettingCurrentUniversityNotice.text = "" +// } +// +// is FestivalUiState.Success -> { +// binding.tvSettingCurrentUniversity.text = state.organization.universityName +// } +// } +// } } private fun setupServicePolicyClickListener() { 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 cca637b..5c45fca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,11 @@ poster_image 오늘 + 일정 화면으로 이동하는 버튼 + 일정 확인하기 + 축제 라인업 + 탐색 화면으로 이동하는 버튼 + 한 눈에 보기 @@ -123,8 +128,7 @@ 알림 받기 다음에 item_lineup_image - 일정 확인하기 - 축제 라인업 + 뒤로가기를 한 번 더 누르면 종료됩니다. @@ -135,7 +139,6 @@ 알림 새로운 소식이 있습니다. - 탐색 화면으로 이동하는 버튼 탐색 화면 닫기 버튼