diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 589e1d9..82965f2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -191,4 +191,13 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) debugImplementation(libs.logging.interceptor) + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testImplementation(libs.junit.jupiter.params) + testImplementation(libs.junit.platform.launcher) + testRuntimeOnly(libs.junit.vintage.engine) +} + +tasks.withType().configureEach { + useJUnitPlatform() } diff --git a/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt b/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt index f55f15f..d75cf9d 100644 --- a/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt +++ b/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt @@ -8,7 +8,6 @@ import com.daedan.festabook.di.viewmodel.MetroViewModelFactory import com.daedan.festabook.logging.DefaultFirebaseLogger import com.daedan.festabook.presentation.main.MainActivity import com.daedan.festabook.presentation.placeDetail.PlaceDetailActivity -import com.daedan.festabook.presentation.schedule.ScheduleViewModel import com.daedan.festabook.presentation.splash.SplashActivity import com.google.android.play.core.appupdate.AppUpdateManager import com.google.android.play.core.appupdate.AppUpdateManagerFactory @@ -41,8 +40,6 @@ interface FestaBookAppGraph { val defaultFirebaseLogger: DefaultFirebaseLogger val metroViewModelFactory: MetroViewModelFactory - - val scheduleViewModelFactory: ScheduleViewModel.Factory } val Context.appGraph get() = (applicationContext as FestaBookApp).festaBookGraph diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/Header.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt similarity index 90% rename from app/src/main/java/com/daedan/festabook/presentation/common/component/Header.kt rename to app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt index 1efcadd..cd09357 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/component/Header.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt @@ -11,7 +11,7 @@ import com.daedan.festabook.presentation.theme.FestabookTypography import com.daedan.festabook.presentation.theme.festabookSpacing @Composable -fun Header( +fun FestabookTopAppBar( title: String, modifier: Modifier = Modifier, style: TextStyle = FestabookTypography.displayLarge, @@ -32,6 +32,6 @@ fun Header( @Composable @Preview(showBackground = true) -private fun HeaderPreview() { - Header(title = "FestaBook") +private fun FestabookTopAppBarPreview() { + FestabookTopAppBar(title = "FestaBook") } diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/PullToRefreshContainer.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/PullToRefreshContainer.kt index 754b825..71a743c 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/component/PullToRefreshContainer.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/PullToRefreshContainer.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp @@ -22,7 +23,7 @@ import androidx.compose.ui.unit.dp import com.daedan.festabook.R import com.daedan.festabook.presentation.theme.FestabookColor -const val PULL_OFFSET_LIMIT = 180F +private const val PULL_OFFSET_LIMIT = 180F @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -31,7 +32,7 @@ fun PullToRefreshContainer( onRefresh: () -> Unit, modifier: Modifier = Modifier, pullOffsetLimit: Float = PULL_OFFSET_LIMIT, - content: @Composable (PullToRefreshState) -> Unit, + content: @Composable (Modifier) -> Unit, ) { val pullToRefreshState = rememberPullToRefreshState() val threshold = (pullOffsetLimit / 2).dp @@ -52,7 +53,11 @@ fun PullToRefreshContainer( }, modifier = modifier.fillMaxSize(), ) { - content(pullToRefreshState) + content( + Modifier.graphicsLayer { + translationY = pullToRefreshState.distanceFraction * pullOffsetLimit + }, + ) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt index 728f9ee..e420ba7 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt @@ -1,9 +1,10 @@ package com.daedan.festabook.presentation.news.component -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -12,7 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.R -import com.daedan.festabook.presentation.common.component.Header +import com.daedan.festabook.presentation.common.component.FestabookTopAppBar import com.daedan.festabook.presentation.news.NewsTab import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.lost.LostUiState @@ -38,29 +39,36 @@ fun NewsScreen( pageState.animateScrollToPage(NewsTab.NOTICE.ordinal) } } - - Column(modifier = modifier.background(color = MaterialTheme.colorScheme.background)) { - Header(title = stringResource(R.string.news_title)) - NewsTabRow(pageState, scope) - NewsTabPage( - pageState = pageState, - noticeUiState = noticeUiState, - faqUiState = faqUiState, - lostUiState = lostUiState, - isNoticeRefreshing = isNoticeRefreshing, - isLostItemRefreshing = isLostItemRefreshing, - onNoticeRefresh = { - val oldNotices = - (noticeUiState as? NoticeUiState.Success)?.notices ?: emptyList() - newsViewModel.loadAllNotices(NoticeUiState.Refreshing(oldNotices)) - }, - onLostItemRefresh = { - val oldLostItems = (lostUiState as? LostUiState.Success)?.lostItems ?: emptyList() - newsViewModel.loadAllLostItems(LostUiState.Refreshing(oldLostItems)) - }, - onNoticeClick = { newsViewModel.toggleNotice(it) }, - onFaqClick = { newsViewModel.toggleFAQ(it) }, - onLostGuideClick = { newsViewModel.toggleLostGuide() }, - ) + Scaffold( + topBar = { FestabookTopAppBar(title = stringResource(R.string.news_title)) }, + modifier = modifier, + ) { innerPadding -> + Column( + modifier = Modifier.padding(top = innerPadding.calculateTopPadding()), + ) { + NewsTabRow(pageState, scope) + NewsTabPage( + pageState = pageState, + noticeUiState = noticeUiState, + faqUiState = faqUiState, + lostUiState = lostUiState, + isNoticeRefreshing = isNoticeRefreshing, + isLostItemRefreshing = isLostItemRefreshing, + onNoticeRefresh = { + val oldNotices = + (noticeUiState as? NoticeUiState.Success)?.notices ?: emptyList() + newsViewModel.loadAllNotices(NoticeUiState.Refreshing(oldNotices)) + }, + onLostItemRefresh = { + val oldLostItems = + (lostUiState as? LostUiState.Success)?.lostItems ?: emptyList() + newsViewModel.loadAllLostItems(LostUiState.Refreshing(oldLostItems)) + }, + onNoticeClick = { newsViewModel.toggleNotice(it) }, + onFaqClick = { newsViewModel.toggleFAQ(it) }, + onLostGuideClick = { newsViewModel.toggleLostGuide() }, + modifier = Modifier.fillMaxSize(), + ) + } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt index a8bc11b..574d144 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.daedan.festabook.presentation.news.NewsTab import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -27,7 +28,6 @@ fun NewsTabRow( TabRow( selectedTabIndex = pageState.currentPage, containerColor = MaterialTheme.colorScheme.background, - contentColor = FestabookColor.black, indicator = { tabPositions -> TabRowDefaults.PrimaryIndicator( color = FestabookColor.black, @@ -41,6 +41,7 @@ fun NewsTabRow( Tab( selected = pageState.currentPage == index, unselectedContentColor = FestabookColor.gray500, + selectedContentColor = FestabookColor.black, onClick = { scope.launch { pageState.animateScrollToPage(index) } }, text = { Text(text = stringResource(title.tabNameRes)) }, ) @@ -51,8 +52,10 @@ fun NewsTabRow( @Composable @Preview private fun NewsTabRowPreview() { - NewsTabRow( - pageState = rememberPagerState { 3 }, - scope = rememberCoroutineScope(), - ) + FestabookTheme { + NewsTabRow( + pageState = rememberPagerState { 3 }, + scope = rememberCoroutineScope(), + ) + } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt index 6ab0e0b..cb21469 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt @@ -16,14 +16,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.EmptyStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen -import com.daedan.festabook.presentation.common.component.PULL_OFFSET_LIMIT import com.daedan.festabook.presentation.common.component.PullToRefreshContainer import com.daedan.festabook.presentation.news.component.NewsItem import com.daedan.festabook.presentation.news.lost.LostUiState @@ -55,9 +53,11 @@ fun LostItemScreen( PullToRefreshContainer( isRefreshing = isRefreshing, onRefresh = onRefresh, - ) { pullToRefreshState -> + ) { graphicsLayer -> when (lostUiState) { - LostUiState.InitialLoading -> LoadingStateScreen() + LostUiState.InitialLoading -> { + LoadingStateScreen() + } is LostUiState.Error -> { LaunchedEffect(lostUiState) { @@ -73,10 +73,7 @@ fun LostItemScreen( modifier = modifier .fillMaxSize() - .graphicsLayer { - translationY = - pullToRefreshState.distanceFraction * PULL_OFFSET_LIMIT - }, + .then(graphicsLayer), ) } @@ -88,10 +85,7 @@ fun LostItemScreen( modifier = modifier .fillMaxSize() - .graphicsLayer { - translationY = - pullToRefreshState.distanceFraction * PULL_OFFSET_LIMIT - }, + .then(graphicsLayer), ) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt index 7de3160..fc18391 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt @@ -9,16 +9,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.EmptyStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen -import com.daedan.festabook.presentation.common.component.PULL_OFFSET_LIMIT import com.daedan.festabook.presentation.common.component.PullToRefreshContainer import com.daedan.festabook.presentation.news.component.NewsItem import com.daedan.festabook.presentation.news.notice.NoticeUiState @@ -39,9 +36,11 @@ fun NoticeScreen( PullToRefreshContainer( isRefreshing = isRefreshing, onRefresh = onRefresh, - ) { pullToRefreshState -> + ) { graphicsLayer -> when (uiState) { - NoticeUiState.InitialLoading -> LoadingStateScreen() + NoticeUiState.InitialLoading -> { + LoadingStateScreen() + } is NoticeUiState.Error -> { LaunchedEffect(uiState) { @@ -53,10 +52,7 @@ fun NoticeScreen( NoticeContent( notices = uiState.oldNotices, onNoticeClick = onNoticeClick, - modifier = - modifier.graphicsLayer { - translationY = pullToRefreshState.distanceFraction * PULL_OFFSET_LIMIT - }, + modifier = modifier.then(graphicsLayer), ) } @@ -65,10 +61,7 @@ fun NoticeScreen( notices = uiState.notices, expandPosition = uiState.expandPosition, onNoticeClick = onNoticeClick, - modifier = - modifier.graphicsLayer { - translationY = pullToRefreshState.distanceFraction * PULL_OFFSET_LIMIT - }, + modifier = modifier.then(graphicsLayer), ) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/OnBookmarkCheckedListener.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/OnBookmarkCheckedListener.kt deleted file mode 100644 index a6e1e0e..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/OnBookmarkCheckedListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.daedan.festabook.presentation.schedule - -fun interface OnBookmarkCheckedListener { - fun onBookmarkChecked(scheduleEventId: Long) -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleDatesUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleDatesUiState.kt deleted file mode 100644 index 679e7e8..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleDatesUiState.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.daedan.festabook.presentation.schedule - -import com.daedan.festabook.presentation.schedule.model.ScheduleDateUiModel - -sealed interface ScheduleDatesUiState { - data object Loading : ScheduleDatesUiState - - data class Success( - val dates: List, - val initialDatePosition: Int, - ) : ScheduleDatesUiState - - data class Error( - val throwable: Throwable, - ) : ScheduleDatesUiState -} 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 f576e20..df46b54 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleEventsUiState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleEventsUiState.kt @@ -3,7 +3,11 @@ package com.daedan.festabook.presentation.schedule import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel sealed interface ScheduleEventsUiState { - data object Loading : ScheduleEventsUiState + data object InitialLoading : ScheduleEventsUiState + + data class Refreshing( + val oldEvents: List, + ) : ScheduleEventsUiState data class Success( val events: List, diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt index f8d256b..d2e7ecd 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt @@ -1,28 +1,25 @@ package com.daedan.festabook.presentation.schedule -import android.annotation.SuppressLint import android.os.Bundle import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModelProvider import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentScheduleBinding -import com.daedan.festabook.databinding.ItemScheduleTabBinding import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.logging.logger -import com.daedan.festabook.logging.model.schedule.ScheduleMenuItemReClickLogData import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.common.OnMenuItemReClickListener -import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.schedule.adapter.SchedulePagerAdapter -import com.google.android.material.tabs.TabLayout -import com.google.android.material.tabs.TabLayoutMediator +import com.daedan.festabook.presentation.schedule.component.ScheduleScreen +import com.daedan.festabook.presentation.theme.FestabookTheme import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import dev.zacsweers.metro.binding -import timber.log.Timber @ContributesIntoMap( scope = AppScope::class, @@ -30,99 +27,31 @@ import timber.log.Timber ) @FragmentKey(ScheduleFragment::class) @Inject -class ScheduleFragment( - private val viewModelFactory: ScheduleViewModel.Factory, -) : BaseFragment(), +class ScheduleFragment : + BaseFragment(), OnMenuItemReClickListener { override val layoutId: Int = R.layout.fragment_schedule - private val adapter: SchedulePagerAdapter by lazy { - SchedulePagerAdapter(this) - } + @Inject + override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory - private val viewModel: ScheduleViewModel by viewModels { - ScheduleViewModel.factory( - viewModelFactory, - ) - } + private val viewModel: ScheduleViewModel by viewModels() - override fun onViewCreated( - view: View, + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, - ) { - binding.vpSchedule.adapter = adapter - setupObservers() - } - - override fun onMenuItemReClick() { - viewModel.loadAllDates() - viewModel.loadScheduleByDate() - binding.logger.log(ScheduleMenuItemReClickLogData(binding.logger.getBaseLogData())) - } - - @SuppressLint("WrongConstant") - private fun setupScheduleTabLayout(initialCurrentDateIndex: Int) { - binding.vpSchedule.offscreenPageLimit = PRELOAD_PAGE_COUNT - - TabLayoutMediator(binding.tlSchedule, binding.vpSchedule) { tab, position -> - setupScheduleTabView(tab, position) - binding.vpSchedule.setCurrentItem(initialCurrentDateIndex, false) - }.attach() - } - - private fun setupScheduleTabView( - tab: TabLayout.Tab, - position: Int, - ) { - val itemScheduleTabBinding = - ItemScheduleTabBinding.inflate( - LayoutInflater.from(requireContext()), - binding.tlSchedule, - false, - ) - tab.customView = itemScheduleTabBinding.root - - itemScheduleTabBinding.tvScheduleTabItem.text = - viewModel.scheduleDatesUiState.value - .let { - (it as? ScheduleDatesUiState.Success)?.dates?.get(position)?.date - ?: EMPTY_DATE_TEXT - } - } - - private fun setupObservers() { - viewModel.scheduleDatesUiState.observe(viewLifecycleOwner) { scheduleDatesUiState -> - - when (scheduleDatesUiState) { - is ScheduleDatesUiState.Loading -> { - showLoadingView(isLoading = true) - } - - is ScheduleDatesUiState.Success -> { - showLoadingView(isLoading = false) - setupScheduleTabLayout(scheduleDatesUiState.initialDatePosition) - adapter.submitList(scheduleDatesUiState.dates) - } - - is ScheduleDatesUiState.Error -> { - showLoadingView(isLoading = false) - Timber.w( - scheduleDatesUiState.throwable, - "${this::class.simpleName}: ${scheduleDatesUiState.throwable.message}", - ) - showErrorSnackBar(scheduleDatesUiState.throwable) + ): View = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + FestabookTheme { + ScheduleScreen(scheduleViewModel = viewModel) } } } - } - private fun showLoadingView(isLoading: Boolean) { - binding.lavScheduleLoading.visibility = if (isLoading) View.VISIBLE else View.GONE - binding.vpSchedule.visibility = if (isLoading) View.INVISIBLE else View.VISIBLE - } - - companion object { - private const val PRELOAD_PAGE_COUNT: Int = 2 - private const val EMPTY_DATE_TEXT: String = "" + override fun onMenuItemReClick() { + viewModel.loadSchedules() } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleTabPageFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleTabPageFragment.kt deleted file mode 100644 index 3bd30ac..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleTabPageFragment.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.daedan.festabook.presentation.schedule - -import android.os.Bundle -import android.view.View -import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.DefaultItemAnimator -import androidx.recyclerview.widget.LinearLayoutManager -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentScheduleTabPageBinding -import com.daedan.festabook.di.appGraph -import com.daedan.festabook.logging.logger -import com.daedan.festabook.logging.model.schedule.ScheduleSwipeRefreshLogData -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.schedule.ScheduleViewModel.Companion.INVALID_ID -import com.daedan.festabook.presentation.schedule.adapter.ScheduleAdapter -import timber.log.Timber - -class ScheduleTabPageFragment : BaseFragment() { - override val layoutId: Int = R.layout.fragment_schedule_tab_page - private val viewModel: ScheduleViewModel by viewModels { - val dateId: Long = arguments?.getLong(KEY_DATE_ID, INVALID_ID) ?: INVALID_ID - ScheduleViewModel.factory(appGraph.scheduleViewModelFactory, dateId) - } - private val adapter: ScheduleAdapter by lazy { - ScheduleAdapter() - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - setupObservers() - setupScheduleEventRecyclerView() - - binding.lifecycleOwner = viewLifecycleOwner - onSwipeRefreshScheduleByDateListener() - } - - private fun setupScheduleEventRecyclerView() { - binding.rvScheduleEvent.adapter = adapter - (binding.rvScheduleEvent.itemAnimator as DefaultItemAnimator).supportsChangeAnimations = - false - viewModel.loadScheduleByDate() - } - - private fun onSwipeRefreshScheduleByDateListener() { - binding.srlScheduleEvent.setOnRefreshListener { - binding.logger.log(ScheduleSwipeRefreshLogData(binding.logger.getBaseLogData())) - viewModel.loadScheduleByDate() - } - } - - private fun setupObservers() { - viewModel.scheduleEventsUiState.observe(viewLifecycleOwner) { schedule -> - when (schedule) { - is ScheduleEventsUiState.Loading, - -> { - showLoadingView(isLoading = true) - showEmptyStateMessage() - } - - is ScheduleEventsUiState.Success -> { - showLoadingView(isLoading = false) - adapter.submitList(schedule.events) { - showEmptyStateMessage() - scrollToCenterOfCurrentEvent(schedule.currentEventPosition) - } - } - - is ScheduleEventsUiState.Error -> { - Timber.w( - schedule.throwable, - "ScheduleTabPageFragment: ${schedule.throwable.message}", - ) - showErrorSnackBar(schedule.throwable) - showLoadingView(isLoading = false) - showEmptyStateMessage() - } - } - } - } - - private fun showLoadingView(isLoading: Boolean) { - if (isLoading) { - binding.rvScheduleEvent.visibility = View.INVISIBLE - binding.viewScheduleEventTimeLine.visibility = View.INVISIBLE - binding.lavScheduleLoading.visibility = View.VISIBLE - } else { - binding.lavScheduleLoading.visibility = View.GONE - binding.viewScheduleEventTimeLine.visibility = View.VISIBLE - binding.rvScheduleEvent.visibility = View.VISIBLE - } - binding.srlScheduleEvent.isRefreshing = false - } - - private fun scrollToCenterOfCurrentEvent(position: Int) { - val recyclerView = binding.rvScheduleEvent - val layoutManager = recyclerView.layoutManager as LinearLayoutManager - layoutManager.scrollToPositionWithOffset(position, NO_OFFSET) - - recyclerView.post { - val view = layoutManager.findViewByPosition(position) - if (view != null) { - val viewTop = layoutManager.getDecoratedTop(view) - val viewHeight = view.height - val parentHeight = recyclerView.height - val dy = viewTop - ((parentHeight - viewHeight) / HALF) - - recyclerView.smoothScrollBy(NO_OFFSET, dy) - } - } - } - - private fun showEmptyStateMessage() { - val itemCount = binding.rvScheduleEvent.adapter?.itemCount ?: 0 - - if (itemCount == 0) { - binding.rvScheduleEvent.visibility = View.GONE - binding.viewScheduleEventTimeLine.visibility = View.GONE - binding.tvEmptyState.root.visibility = View.VISIBLE - } else { - binding.rvScheduleEvent.visibility = View.VISIBLE - binding.viewScheduleEventTimeLine.visibility = View.VISIBLE - binding.tvEmptyState.root.visibility = View.GONE - } - } - - companion object { - const val KEY_DATE_ID = "dateId" - private const val NO_OFFSET: Int = 0 - private const val HALF: Int = 2 - - fun newInstance(dateId: Long): ScheduleTabPageFragment { - val fragment = ScheduleTabPageFragment() - val args = - Bundle().apply { - putLong(KEY_DATE_ID, dateId) - } - fragment.arguments = args - return fragment - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleUiState.kt new file mode 100644 index 0000000..3d629c3 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleUiState.kt @@ -0,0 +1,25 @@ +package com.daedan.festabook.presentation.schedule + +import com.daedan.festabook.presentation.schedule.model.ScheduleDateUiModel + +sealed interface ScheduleUiState { + data object InitialLoading : ScheduleUiState + + data class Refreshing( + val lastSuccessState: Success, + ) : ScheduleUiState + + data class Success( + val dates: List, + val currentDatePosition: Int, + val eventsUiStateByPosition: Map = emptyMap(), + ) : ScheduleUiState + + data class Error( + val throwable: Throwable, + ) : ScheduleUiState + + companion object { + const val DEFAULT_POSITION: Int = 0 + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt index 3f969af..01c808b 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt @@ -1,104 +1,192 @@ package com.daedan.festabook.presentation.schedule -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory +import com.daedan.festabook.di.viewmodel.ViewModelKey +import com.daedan.festabook.domain.model.ScheduleDate import com.daedan.festabook.domain.repository.ScheduleRepository +import com.daedan.festabook.presentation.schedule.model.ScheduleDateUiModel +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus import com.daedan.festabook.presentation.schedule.model.toUiModel -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedFactory -import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope import java.time.LocalDate -class ScheduleViewModel @AssistedInject constructor( +@ContributesIntoMap(AppScope::class) +@ViewModelKey(ScheduleViewModel::class) +@Inject +class ScheduleViewModel( private val scheduleRepository: ScheduleRepository, - @Assisted private val dateId: Long, ) : ViewModel() { - @AssistedFactory - interface Factory { - fun create(dateId: Long): ScheduleViewModel - } - - private val _scheduleEventsUiState: MutableLiveData = - MutableLiveData() - val scheduleEventsUiState: LiveData get() = _scheduleEventsUiState - - private val _scheduleDatesUiState: MutableLiveData = - MutableLiveData() - val scheduleDatesUiState: LiveData get() = _scheduleDatesUiState + private val _scheduleUiState: MutableStateFlow = + MutableStateFlow(ScheduleUiState.InitialLoading) + val scheduleUiState: StateFlow = _scheduleUiState.asStateFlow() init { - loadAllDates() - if (dateId != INVALID_ID) loadScheduleByDate() + loadSchedules() } - fun loadScheduleByDate() { - if (dateId == INVALID_ID) return - if (_scheduleEventsUiState.value == ScheduleEventsUiState.Loading) return + fun loadSchedules( + scheduleUiState: ScheduleUiState = ScheduleUiState.InitialLoading, + scheduleEventUiState: ScheduleEventsUiState = ScheduleEventsUiState.InitialLoading, + selectedDatePosition: Int? = null, + preloadCount: Int = PRELOAD_PAGE_COUNT, + ) { viewModelScope.launch { - _scheduleEventsUiState.value = ScheduleEventsUiState.Loading - - val result = scheduleRepository.fetchScheduleEventsById(dateId) - result - .onSuccess { scheduleEvents -> - val scheduleEventUiModels = scheduleEvents.map { it.toUiModel() } - val currentEventPosition = - scheduleEventUiModels - .indexOfFirst { scheduleEvent -> scheduleEvent.status == ScheduleEventUiStatus.ONGOING } - .coerceAtLeast(FIRST_INDEX) - - _scheduleEventsUiState.value = - ScheduleEventsUiState.Success(scheduleEventUiModels, currentEventPosition) - }.onFailure { - _scheduleEventsUiState.value = - ScheduleEventsUiState.Error(it) - } + val datesResult = loadAllDates(scheduleUiState, selectedDatePosition) + + if (datesResult.isSuccess) { + val currentPosition = + (_scheduleUiState.value as ScheduleUiState.Success).currentDatePosition + loadEventsInRange(currentPosition, scheduleEventUiState, preloadCount) + } } } - fun loadAllDates() { - if (_scheduleDatesUiState.value == ScheduleDatesUiState.Loading) return - viewModelScope.launch { - _scheduleDatesUiState.value = ScheduleDatesUiState.Loading - - val result = scheduleRepository.fetchAllScheduleDates() - result - .onSuccess { scheduleDates -> - val scheduleDateUiModels = scheduleDates.map { it.toUiModel() } - val today = LocalDate.now() - - val currentDatePosition = - scheduleDates - .indexOfFirst { !it.date.isBefore(today) } - .let { currentIndex -> if (currentIndex == INVALID_INDEX) FIRST_INDEX else currentIndex } - - _scheduleDatesUiState.value = - ScheduleDatesUiState.Success(scheduleDateUiModels, currentDatePosition) - }.onFailure { - _scheduleDatesUiState.value = ScheduleDatesUiState.Error(it) + private suspend fun loadAllDates( + scheduleUiState: ScheduleUiState, + selectedDatePosition: Int?, + ): Result> { + _scheduleUiState.value = scheduleUiState + val result = scheduleRepository.fetchAllScheduleDates() + + return result.fold( + onSuccess = { scheduleDates -> + val scheduleDateUiModels = scheduleDates.map { it.toUiModel() } + val currentDatePosition = + selectedDatePosition ?: getCurrentDatePosition(scheduleDates) + + _scheduleUiState.value = + ScheduleUiState.Success( + dates = scheduleDateUiModels, + currentDatePosition = currentDatePosition, + ) + + Result.success(scheduleDateUiModels) + }, + onFailure = { throwable -> + _scheduleUiState.value = ScheduleUiState.Error(throwable) + Result.failure(throwable) + }, + ) + } + + fun loadEventsInRange( + currentPosition: Int, + scheduleEventUiState: ScheduleEventsUiState = ScheduleEventsUiState.InitialLoading, + preloadCount: Int = PRELOAD_PAGE_COUNT, + ) { + (_scheduleUiState.value as? ScheduleUiState.Success)?.dates?.let { scheduleDates -> + val range = + getPreloadRange( + totalPageSize = scheduleDates.size, + currentPosition = currentPosition, + preloadCount = preloadCount, + ) + viewModelScope.launch { + supervisorScope { + range.forEach { position -> + if (isEventLoaded(position)) return@forEach + + val scheduleDateUiModel = scheduleDates[position] + launch { + loadEventsByPosition( + position = position, + scheduleDateUiModel = scheduleDateUiModel, + scheduleEventsUiState = scheduleEventUiState, + ) + } + } } + } } } + private suspend fun loadEventsByPosition( + position: Int, + scheduleDateUiModel: ScheduleDateUiModel, + scheduleEventsUiState: ScheduleEventsUiState, + ) { + updateEventUiState(position, scheduleEventsUiState) + + val result = + scheduleRepository.fetchScheduleEventsById(scheduleDateUiModel.id) + + result + .onSuccess { scheduleEvents -> + val uiModels = scheduleEvents.map { it.toUiModel() } + updateEventUiState( + position = position, + scheduleEventsUiState = + ScheduleEventsUiState.Success( + events = uiModels, + currentEventPosition = getCurrentEventPosition(uiModels), + ), + ) + }.onFailure { + updateEventUiState(position, ScheduleEventsUiState.Error(it)) + } + } + + private fun updateEventUiState( + position: Int, + scheduleEventsUiState: ScheduleEventsUiState, + ) { + val currentUiState = _scheduleUiState.value + if (currentUiState !is ScheduleUiState.Success) return + + _scheduleUiState.value = + currentUiState.copy( + eventsUiStateByPosition = + currentUiState.eventsUiStateByPosition + (position to scheduleEventsUiState), + ) + } + + private fun getCurrentEventPosition(scheduleEventUiModels: List): Int { + val currentEventPosition = + scheduleEventUiModels + .indexOfFirst { + it.status != ScheduleEventUiStatus.COMPLETED + }.coerceAtLeast(FIRST_INDEX) + return currentEventPosition + } + + private fun getCurrentDatePosition( + scheduleDates: List, + today: LocalDate = LocalDate.now(), + ): Int { + val currentDatePosition = + scheduleDates + .indexOfFirst { !it.date.isBefore(today) } + .coerceAtLeast(FIRST_INDEX) + return currentDatePosition + } + + private fun getPreloadRange( + totalPageSize: Int, + preloadCount: Int, + currentPosition: Int, + ): IntRange { + val start = (currentPosition - preloadCount).coerceAtLeast(FIRST_INDEX) + val end = (currentPosition + preloadCount).coerceAtMost(totalPageSize - 1) + return start..end + } + + private fun isEventLoaded(position: Int): Boolean { + val currentScheduleUiState = _scheduleUiState.value + if (currentScheduleUiState !is ScheduleUiState.Success) return false + return currentScheduleUiState.eventsUiStateByPosition[position] is ScheduleEventsUiState.Success + } + companion object { - const val INVALID_ID: Long = -1L private const val FIRST_INDEX: Int = 0 - private const val INVALID_INDEX: Int = -1 - - fun factory( - factory: Factory, - dateId: Long = INVALID_ID, - ): ViewModelProvider.Factory = - viewModelFactory { - initializer { - factory.create(dateId) - } - } + const val PRELOAD_PAGE_COUNT: Int = 2 } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleItemViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleItemViewHolder.kt index be6953e..b64caf0 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleItemViewHolder.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleItemViewHolder.kt @@ -13,7 +13,6 @@ import com.daedan.festabook.logging.model.schedule.ScheduleEventClickLogData import com.daedan.festabook.presentation.common.toPx import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus -import com.daedan.festabook.presentation.schedule.model.toKoreanString class ScheduleItemViewHolder( private val binding: ItemScheduleTabPageBinding, @@ -135,7 +134,6 @@ class ScheduleItemViewHolder( backgroundResId: Int?, ) = with(binding.tvScheduleEventStatus) { val gray050 = ContextCompat.getColor(context, R.color.gray050) - text = status.toKoreanString(context) setTextColor(textColor) gravity = if (status == ScheduleEventUiStatus.COMPLETED) Gravity.END else Gravity.CENTER backgroundResId?.let { setBackgroundResource(it) } ?: setBackgroundColor(gray050) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/SchedulePagerAdapter.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/SchedulePagerAdapter.kt deleted file mode 100644 index ebc1d3e..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/SchedulePagerAdapter.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.daedan.festabook.presentation.schedule.adapter - -import androidx.fragment.app.Fragment -import androidx.viewpager2.adapter.FragmentStateAdapter -import com.daedan.festabook.presentation.schedule.ScheduleTabPageFragment -import com.daedan.festabook.presentation.schedule.model.ScheduleDateUiModel - -class SchedulePagerAdapter( - fragment: Fragment, - private val items: MutableList = mutableListOf(), -) : FragmentStateAdapter(fragment) { - override fun getItemCount(): Int = items.size - - override fun createFragment(position: Int): Fragment { - val dateId: Long = items[position].id - return ScheduleTabPageFragment.newInstance(dateId) - } - - fun submitList(newItems: List) { - items.clear() - items.addAll(newItems) - notifyItemRangeChanged(FIRST_INDEX, itemCount) - } - - companion object { - private const val FIRST_INDEX: Int = 0 - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt new file mode 100644 index 0000000..b3a7a2a --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt @@ -0,0 +1,224 @@ +package com.daedan.festabook.presentation.schedule.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.component.cardBackground +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun ScheduleEventCard( + scheduleEvent: ScheduleEventUiModel, + modifier: Modifier = Modifier, +) { + val scheduleEventCardColors = scheduleEventCardColors(scheduleEvent.status) + + Column( + modifier = + modifier + .cardBackground( + backgroundColor = MaterialTheme.colorScheme.background, + borderColor = scheduleEventCardColors.cardBorderColor, + shape = festabookShapes.radius2, + ).padding(festabookSpacing.paddingBody4), + verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody1), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody1), + ) { + Text( + text = scheduleEvent.title, + style = FestabookTypography.titleLarge, + color = scheduleEventCardColors.titleColor, + modifier = Modifier.weight(1f), + ) + ScheduleEventLabel(scheduleEvent.status) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody1), + ) { + Icon( + painter = painterResource(R.drawable.ic_clock), + contentDescription = stringResource(R.string.content_description_iv_location), + tint = scheduleEventCardColors.contentColor, + ) + Text( + text = + stringResource( + R.string.format_date, + scheduleEvent.startTime, + scheduleEvent.endTime, + ), + style = FestabookTypography.bodySmall, + color = scheduleEventCardColors.contentColor, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody1), + ) { + Icon( + painter = painterResource(R.drawable.ic_location), + contentDescription = stringResource(R.string.content_description_iv_location), + tint = scheduleEventCardColors.contentColor, + ) + Text( + text = scheduleEvent.location, + style = FestabookTypography.bodySmall, + color = scheduleEventCardColors.contentColor, + ) + } + } +} + +@Composable +private fun ScheduleEventLabel(scheduleEventUiStatus: ScheduleEventUiStatus) { + val scheduleEventCardProps = scheduleEventCardColors(scheduleEventUiStatus) + Box( + modifier = + Modifier + .size(48.dp, 24.dp) + .cardBackground( + backgroundColor = scheduleEventCardProps.labelBackgroundColor, + borderColor = scheduleEventCardProps.labelBorderColor, + shape = festabookShapes.radius1, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = scheduleLabelText(scheduleEventUiStatus), + style = FestabookTypography.bodySmall, + color = scheduleEventCardProps.labelTextColor, + ) + } +} + +@Composable +private fun scheduleLabelText(status: ScheduleEventUiStatus): String = + when (status) { + ScheduleEventUiStatus.UPCOMING -> stringResource(R.string.schedule_status_upcoming) + ScheduleEventUiStatus.ONGOING -> stringResource(R.string.schedule_status_ongoing) + ScheduleEventUiStatus.COMPLETED -> stringResource(R.string.schedule_status_completed) + } + +@Composable +private fun scheduleEventCardColors(status: ScheduleEventUiStatus): ScheduleEventCardProps = + when (status) { + ScheduleEventUiStatus.UPCOMING -> { + ScheduleEventCardProps( + cardBorderColor = FestabookColor.accentGreen, + titleColor = FestabookColor.black, + contentColor = FestabookColor.gray500, + labelTextColor = FestabookColor.black, + labelBackgroundColor = FestabookColor.white, + labelBorderColor = FestabookColor.black, + ) + } + + ScheduleEventUiStatus.ONGOING -> { + ScheduleEventCardProps( + cardBorderColor = FestabookColor.accentBlue, + titleColor = FestabookColor.black, + contentColor = FestabookColor.gray500, + labelTextColor = FestabookColor.white, + labelBackgroundColor = FestabookColor.black, + labelBorderColor = FestabookColor.black, + ) + } + + ScheduleEventUiStatus.COMPLETED -> { + ScheduleEventCardProps( + cardBorderColor = FestabookColor.gray400, + titleColor = FestabookColor.gray400, + contentColor = FestabookColor.gray400, + labelTextColor = FestabookColor.gray400, + labelBackgroundColor = FestabookColor.white, + labelBorderColor = FestabookColor.white, + ) + } + } + +@Composable +@Preview(showBackground = true) +private fun OnGoingScheduleEventCardPreview() { + FestabookTheme { + ScheduleEventCard( + scheduleEvent = + ScheduleEventUiModel( + id = 1, + status = ScheduleEventUiStatus.ONGOING, + startTime = "09:00", + endTime = "18:00", + title = "동아리 버스킹 공연", + location = "운동장", + ), + ) + } +} + +@Composable +@Preview(showBackground = true) +private fun UpComingScheduleEventCardPreview() { + FestabookTheme { + ScheduleEventCard( + scheduleEvent = + ScheduleEventUiModel( + id = 1, + status = ScheduleEventUiStatus.UPCOMING, + startTime = "09:00", + endTime = "18:00", + title = "동아리 버스킹 공연", + location = "운동장", + ), + ) + } +} + +@Composable +@Preview(showBackground = true) +private fun CompleteScheduleEventCardONGOINGPreview() { + FestabookTheme { + ScheduleEventCard( + scheduleEvent = + ScheduleEventUiModel( + id = 1, + status = ScheduleEventUiStatus.COMPLETED, + startTime = "09:00", + endTime = "18:00", + title = "동아리 버스킹 공연", + location = "운동장", + ), + ) + } +} + +data class ScheduleEventCardProps( + val cardBorderColor: Color, + val titleColor: Color, + val contentColor: Color, + val labelTextColor: Color, + val labelBackgroundColor: Color, + val labelBorderColor: Color, +) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt new file mode 100644 index 0000000..afed2f0 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt @@ -0,0 +1,140 @@ +package com.daedan.festabook.presentation.schedule.component + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.tooling.preview.Preview +import com.airbnb.lottie.LottieComposition +import com.airbnb.lottie.LottieProperty +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieDynamicProperties +import com.airbnb.lottie.compose.rememberLottieDynamicProperty +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun ScheduleEventItem( + composition: LottieComposition?, + scheduleEvent: ScheduleEventUiModel, + modifier: Modifier = Modifier, +) { + val props = lottieTimeLineCircleProps(scheduleEvent.status) + val dynamicProperties = rememberScheduleEventDynamicProperties(props) + val progress by animateLottieCompositionAsState( + composition = composition, + iterations = LottieConstants.IterateForever, + isPlaying = scheduleEvent.status != ScheduleEventUiStatus.COMPLETED, + ) + + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + LottieAnimation( + composition = composition, + progress = { progress }, + modifier = Modifier.size(festabookSpacing.paddingBody4 * 4), + dynamicProperties = dynamicProperties, + ) + + ScheduleEventCard(scheduleEvent = scheduleEvent) + } +} + +@Composable +@Preview +private fun ScheduleEventItemPreview() { + ScheduleEventItem( + composition = null, + scheduleEvent = + ScheduleEventUiModel( + id = 1, + status = ScheduleEventUiStatus.ONGOING, + startTime = "9:00", + endTime = "18:00", + title = "동아리 버스킹 공연", + location = "운동장", + ), + ) +} + +@Composable +private fun rememberScheduleEventDynamicProperties(props: LottieTimeLineCircleProps) = + rememberLottieDynamicProperties( + rememberLottieDynamicProperty( + property = LottieProperty.COLOR, + value = props.centerColor.toArgb(), + *props.centerKeyPath.toTypedArray(), + ), + rememberLottieDynamicProperty( + property = LottieProperty.OPACITY, + value = (props.outerOpacity * 100).toInt(), + *props.outerKeyPath.toTypedArray(), + ), + rememberLottieDynamicProperty( + property = LottieProperty.OPACITY, + value = (props.innerOpacity * 100).toInt(), + *props.innerKeyPath.toTypedArray(), + ), + rememberLottieDynamicProperty( + property = LottieProperty.COLOR, + value = props.outerColor.toArgb(), + *props.outerKeyPath.toTypedArray(), + ), + rememberLottieDynamicProperty( + property = LottieProperty.COLOR, + value = props.innerColor.toArgb(), + *props.innerKeyPath.toTypedArray(), + ), + ) + +@Composable +private fun lottieTimeLineCircleProps(status: ScheduleEventUiStatus): LottieTimeLineCircleProps = + when (status) { + ScheduleEventUiStatus.UPCOMING -> { + LottieTimeLineCircleProps( + centerColor = FestabookColor.accentGreen, + outerOpacity = 0f, + innerOpacity = 1f, + outerColor = FestabookColor.accentGreen, + innerColor = FestabookColor.accentGreen, + ) + } + + ScheduleEventUiStatus.ONGOING -> { + LottieTimeLineCircleProps( + centerColor = FestabookColor.accentBlue, + outerOpacity = 1f, + innerOpacity = 1f, + outerColor = FestabookColor.accentBlue, + innerColor = FestabookColor.accentBlue, + ) + } + + ScheduleEventUiStatus.COMPLETED -> { + LottieTimeLineCircleProps( + centerColor = FestabookColor.gray300, + outerOpacity = 0f, + innerOpacity = 0f, + outerColor = FestabookColor.gray300, + innerColor = FestabookColor.gray300, + ) + } + } + +data class LottieTimeLineCircleProps( + val centerColor: Color, + val outerOpacity: Float, + val innerOpacity: Float, + val outerColor: Color, + val innerColor: Color, + val centerKeyPath: List = listOf("centerCircle", "**", "Fill 1"), + val outerKeyPath: List = listOf("outerWave", "**", "Fill 1"), + val innerKeyPath: List = listOf("innerWave", "**", "Fill 1"), +) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt new file mode 100644 index 0000000..adeae6f --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt @@ -0,0 +1,91 @@ +package com.daedan.festabook.presentation.schedule.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.component.FestabookTopAppBar +import com.daedan.festabook.presentation.common.component.LoadingStateScreen +import com.daedan.festabook.presentation.schedule.ScheduleEventsUiState +import com.daedan.festabook.presentation.schedule.ScheduleUiState +import com.daedan.festabook.presentation.schedule.ScheduleViewModel +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.festabookSpacing +import timber.log.Timber + +@Composable +fun ScheduleScreen( + scheduleViewModel: ScheduleViewModel, + modifier: Modifier = Modifier, +) { + val scheduleUiState by scheduleViewModel.scheduleUiState.collectAsStateWithLifecycle() + val currentState = + when (scheduleUiState) { + is ScheduleUiState.Refreshing -> (scheduleUiState as ScheduleUiState.Refreshing).lastSuccessState + is ScheduleUiState.Success -> scheduleUiState + else -> scheduleUiState + } + + Scaffold( + topBar = { FestabookTopAppBar(title = stringResource(R.string.schedule_title)) }, + modifier = modifier, + ) { innerPadding -> + when (currentState) { + ScheduleUiState.InitialLoading -> { + LoadingStateScreen() + } + + is ScheduleUiState.Error -> { + Timber.w(currentState.throwable.stackTraceToString()) + } + + else -> { + val currentStateSuccess = currentState as ScheduleUiState.Success + val pageState = + rememberPagerState(initialPage = currentStateSuccess.currentDatePosition) { currentStateSuccess.dates.size } + val scope = rememberCoroutineScope() + LaunchedEffect(pageState.currentPage) { + scheduleViewModel.loadEventsInRange(currentPosition = pageState.currentPage) + } + + Column(modifier = Modifier.padding(top = innerPadding.calculateTopPadding())) { + ScheduleTabRow( + pageState = pageState, + scope = scope, + dates = currentStateSuccess.dates, + ) + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody4)) + HorizontalDivider( + thickness = 1.dp, + color = FestabookColor.gray300, + modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), + ) + ScheduleTabPage( + pagerState = pageState, + scheduleUiState = currentStateSuccess, + onRefresh = { oldEvents -> + scheduleViewModel.loadSchedules( + scheduleUiState = ScheduleUiState.Refreshing(currentStateSuccess), + scheduleEventUiState = ScheduleEventsUiState.Refreshing(oldEvents), + selectedDatePosition = pageState.currentPage, + preloadCount = 0, + ) + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt new file mode 100644 index 0000000..8734816 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt @@ -0,0 +1,182 @@ +package com.daedan.festabook.presentation.schedule.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.airbnb.lottie.LottieComposition +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.rememberLottieComposition +import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.component.EmptyStateScreen +import com.daedan.festabook.presentation.common.component.LoadingStateScreen +import com.daedan.festabook.presentation.common.component.PullToRefreshContainer +import com.daedan.festabook.presentation.schedule.ScheduleEventsUiState +import com.daedan.festabook.presentation.schedule.ScheduleUiState +import com.daedan.festabook.presentation.schedule.ScheduleUiState.Companion.DEFAULT_POSITION +import com.daedan.festabook.presentation.schedule.ScheduleViewModel.Companion.PRELOAD_PAGE_COUNT +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookSpacing +import timber.log.Timber + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScheduleTabPage( + pagerState: PagerState, + scheduleUiState: ScheduleUiState.Success, + onRefresh: (List) -> Unit, + modifier: Modifier = Modifier, +) { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.pulse_circle)) + HorizontalPager( + state = pagerState, + modifier = modifier, + beyondViewportPageCount = PRELOAD_PAGE_COUNT, + ) { index -> + val scheduleEventsUiState = scheduleUiState.eventsUiStateByPosition[index] + val isRefreshing = scheduleEventsUiState is ScheduleEventsUiState.Refreshing + val oldEvents = + (scheduleEventsUiState as? ScheduleEventsUiState.Success)?.events ?: emptyList() + + PullToRefreshContainer( + isRefreshing = isRefreshing, + onRefresh = { onRefresh(oldEvents) }, + ) { graphicsLayer -> + when (scheduleEventsUiState) { + is ScheduleEventsUiState.Error -> { + Timber.w(scheduleEventsUiState.throwable.stackTraceToString()) + } + + ScheduleEventsUiState.InitialLoading -> { + LoadingStateScreen() + } + + is ScheduleEventsUiState.Refreshing -> { + ScheduleTabContent( + composition = composition, + scheduleEvents = scheduleEventsUiState.oldEvents, + modifier = + Modifier + .padding(end = festabookSpacing.paddingScreenGutter) + .then(graphicsLayer), + ) + } + + is ScheduleEventsUiState.Success -> { + ScheduleTabContent( + composition = composition, + scheduleEvents = scheduleEventsUiState.events, + currentEventPosition = scheduleEventsUiState.currentEventPosition, + modifier = + Modifier + .padding(end = festabookSpacing.paddingScreenGutter) + .then(graphicsLayer), + ) + } + + null -> {} + } + } + } +} + +@Composable +private fun ScheduleTabContent( + composition: LottieComposition?, + scheduleEvents: List, + modifier: Modifier = Modifier, + currentEventPosition: Int = DEFAULT_POSITION, +) { + val listState = rememberLazyListState() + val scrollState = rememberScrollState() + + LaunchedEffect(Unit) { + listState.animateScrollToItem(currentEventPosition) + } + if (scheduleEvents.isEmpty()) { + EmptyStateScreen( + modifier = + modifier + .fillMaxSize() + .verticalScroll(scrollState), + ) + } else { + Box(modifier = modifier) { + VerticalDivider( + thickness = 1.dp, + color = FestabookColor.gray300, + modifier = + Modifier + .padding(start = festabookSpacing.paddingScreenGutter + festabookSpacing.paddingBody4), + ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody5), + contentPadding = PaddingValues(vertical = festabookSpacing.paddingBody5), + state = listState, + ) { + items(items = scheduleEvents, key = { scheduleEvent -> scheduleEvent.id }) { + ScheduleEventItem( + composition = composition, + scheduleEvent = it, + ) + } + } + } + } +} + +@Composable +@Preview(showBackground = true) +private fun ScheduleTabContentPreview() { + FestabookTheme { + ScheduleTabContent( + 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 = "운동장", + ), + ), + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt new file mode 100644 index 0000000..80f6397 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt @@ -0,0 +1,87 @@ +package com.daedan.festabook.presentation.schedule.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.daedan.festabook.presentation.common.component.cardBackground +import com.daedan.festabook.presentation.schedule.model.ScheduleDateUiModel +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun ScheduleTabRow( + pageState: PagerState, + scope: CoroutineScope, + dates: List, + modifier: Modifier = Modifier, +) { + ScrollableTabRow( + edgePadding = festabookSpacing.paddingScreenGutter, + selectedTabIndex = pageState.currentPage, + containerColor = MaterialTheme.colorScheme.background, + indicator = { tabPositions -> + ScheduleTabIndicator(modifier = Modifier.tabIndicatorOffset(currentTabPosition = tabPositions[pageState.currentPage])) + }, + divider = {}, + modifier = modifier, + ) { + dates.forEachIndexed { index, scheduleDate -> + Tab( + selected = pageState.currentPage == index, + unselectedContentColor = FestabookColor.gray500, + selectedContentColor = MaterialTheme.colorScheme.background, + onClick = { scope.launch { pageState.animateScrollToPage(index) } }, + text = { Text(text = scheduleDate.date) }, + ) + } + } +} + +@Composable +private fun ScheduleTabIndicator(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .padding(festabookSpacing.paddingBody1) + .fillMaxSize() + .cardBackground( + backgroundColor = FestabookColor.black, + borderStroke = 0.dp, + borderColor = FestabookColor.black, + shape = festabookShapes.radius4, + ).zIndex(-1f), + ) +} + +@Preview +@Composable +private fun ScheduleTabRowPreview() { + ScheduleTabRow( + pageState = rememberPagerState { 5 }, + scope = rememberCoroutineScope(), + dates = + listOf( + ScheduleDateUiModel(1, "11/12"), + ScheduleDateUiModel(2, "11/13"), + ScheduleDateUiModel(3, "11/13"), + ScheduleDateUiModel(3, "11/13"), + ScheduleDateUiModel(3, "11/13"), + ), + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiModel.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiModel.kt index f22b60c..482e5ff 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiModel.kt @@ -10,7 +10,6 @@ data class ScheduleEventUiModel( val endTime: String, val title: String, val location: String, - val isBookmarked: Boolean = false, ) fun ScheduleEvent.toUiModel(): ScheduleEventUiModel = diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiStatus.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiStatus.kt index 86d9eca..9f0892d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiStatus.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiStatus.kt @@ -1,17 +1,14 @@ package com.daedan.festabook.presentation.schedule.model -import android.content.Context -import com.daedan.festabook.R - enum class ScheduleEventUiStatus { UPCOMING, ONGOING, COMPLETED, } -fun ScheduleEventUiStatus.toKoreanString(context: Context): String = - when (this) { - ScheduleEventUiStatus.UPCOMING -> context.getString(R.string.schedule_status_upcoming) - ScheduleEventUiStatus.ONGOING -> context.getString(R.string.schedule_status_ongoing) - ScheduleEventUiStatus.COMPLETED -> context.getString(R.string.schedule_status_completed) - } +// fun ScheduleEventUiStatus.toKoreanString(context: Context): String = +// when (this) { +// ScheduleEventUiStatus.UPCOMING -> context.getString(R.string.schedule_status_upcoming) +// ScheduleEventUiStatus.ONGOING -> context.getString(R.string.schedule_status_ongoing) +// ScheduleEventUiStatus.COMPLETED -> context.getString(R.string.schedule_status_completed) +// } diff --git a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookSpacing.kt b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookSpacing.kt index c4f00cd..79e3ea4 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookSpacing.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookSpacing.kt @@ -13,6 +13,7 @@ data class FestabookSpacing( val paddingBody2: Dp = 8.dp, val paddingBody3: Dp = 12.dp, val paddingBody4: Dp = 16.dp, + val paddingBody5: Dp = 20.dp, ) val LocalSpacing = staticCompositionLocalOf { FestabookSpacing() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cca637b..9dd1e25 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ 현재 표시할 항목이 없습니다. + %1$s ~ %2$s diff --git a/app/src/test/java/com/daedan/festabook/schedule/ScheduleTestFixtures.kt b/app/src/test/java/com/daedan/festabook/schedule/ScheduleTestFixtures.kt index ff92c55..2c27e4b 100644 --- a/app/src/test/java/com/daedan/festabook/schedule/ScheduleTestFixtures.kt +++ b/app/src/test/java/com/daedan/festabook/schedule/ScheduleTestFixtures.kt @@ -3,7 +3,6 @@ package com.daedan.festabook.schedule import com.daedan.festabook.domain.model.ScheduleDate import com.daedan.festabook.domain.model.ScheduleEvent import com.daedan.festabook.domain.model.ScheduleEventStatus -import com.daedan.festabook.presentation.schedule.ScheduleEventsUiState import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus import java.time.LocalDate @@ -12,23 +11,31 @@ val FAKE_SCHEDULE_EVENTS = listOf( ScheduleEvent( id = 1L, - status = ScheduleEventStatus.UPCOMING, // 필요 시 enum 정의에 맞게 변경 - startTime = "2025-07-26T10:00:00", - endTime = "2025-07-26T11:00:00", + status = ScheduleEventStatus.ONGOING, + startTime = "10:00", + endTime = "11:00", title = "안드로이드 스터디", location = "서울 강남구 어딘가", ), ScheduleEvent( id = 1L, - status = ScheduleEventStatus.ONGOING, - startTime = "2025-07-26T10:00:00", - endTime = "2025-07-26T11:00:00", + status = ScheduleEventStatus.UPCOMING, // 필요 시 enum 정의에 맞게 변경 + startTime = "10:00", + endTime = "11:00", title = "안드로이드 스터디", location = "서울 강남구 어딘가", ), ) val FAKE_SCHEDULE_EVENTS_UI_MODELS = listOf( + ScheduleEventUiModel( + id = 1L, + status = ScheduleEventUiStatus.ONGOING, // enum이나 클래스에 맞게 수정 + startTime = "10:00", + endTime = "11:00", + title = "안드로이드 스터디", + location = "서울 강남구 어딘가", + ), ScheduleEventUiModel( id = 1L, status = ScheduleEventUiStatus.UPCOMING, // enum이나 클래스에 맞게 수정 @@ -36,12 +43,9 @@ val FAKE_SCHEDULE_EVENTS_UI_MODELS = endTime = "11:00", title = "안드로이드 스터디", location = "서울 강남구 어딘가", - isBookmarked = false, ), ) -val FAKE_SCHEDULE_EVENTS_UI_STATE = ScheduleEventsUiState.Success(FAKE_SCHEDULE_EVENTS_UI_MODELS, 1) - val FAKE_SCHEDULE_DATES = listOf( ScheduleDate(id = 1L, date = LocalDate.of(2025, 7, 26)), diff --git a/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt b/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt index f663132..598919c 100644 --- a/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/schedule/ScheduleViewModelTest.kt @@ -1,12 +1,9 @@ package com.daedan.festabook.schedule import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.daedan.festabook.domain.model.ScheduleEvent -import com.daedan.festabook.domain.model.ScheduleEventStatus import com.daedan.festabook.domain.repository.ScheduleRepository -import com.daedan.festabook.getOrAwaitValue -import com.daedan.festabook.presentation.schedule.ScheduleDatesUiState import com.daedan.festabook.presentation.schedule.ScheduleEventsUiState +import com.daedan.festabook.presentation.schedule.ScheduleUiState import com.daedan.festabook.presentation.schedule.ScheduleViewModel import com.daedan.festabook.presentation.schedule.model.toUiModel import io.mockk.coEvery @@ -19,13 +16,14 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain -import org.assertj.core.api.Assertions.assertThat -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Rule -import org.junit.Test +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.fail +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll @OptIn(ExperimentalCoroutinesApi::class) class ScheduleViewModelTest { @@ -38,139 +36,109 @@ class ScheduleViewModelTest { private lateinit var scheduleRepository: ScheduleRepository private lateinit var scheduleViewModel: ScheduleViewModel - @Before + @BeforeEach fun setUp() { Dispatchers.setMain(testDispatcher) scheduleRepository = mockk() coEvery { scheduleRepository.fetchAllScheduleDates() } returns - Result.success( - FAKE_SCHEDULE_DATES, - ) + Result.success(FAKE_SCHEDULE_DATES) coEvery { scheduleRepository.fetchScheduleEventsById(dateId) } returns - Result.success( - FAKE_SCHEDULE_EVENTS, - ) + Result.success(FAKE_SCHEDULE_EVENTS) - scheduleViewModel = ScheduleViewModel(scheduleRepository, dateId) + scheduleViewModel = ScheduleViewModel(scheduleRepository) } - @After + @AfterEach fun tearDown() { Dispatchers.resetMain() } @Test - fun `해당 날짜에 맞는 일정을 불러온다`() = + fun `ViewModel이 생성되면 해당 날짜를 불러온다`() = runTest { // given + advanceUntilIdle() // when - scheduleViewModel.loadScheduleByDate() - advanceUntilIdle() // then - coVerify { scheduleRepository.fetchAllScheduleDates() } - coVerify { scheduleRepository.fetchScheduleEventsById(dateId) } - - val state = scheduleViewModel.scheduleEventsUiState.value - assertTrue(state is ScheduleEventsUiState.Success) - - val expected = FAKE_SCHEDULE_EVENTS.map { it.toUiModel() } - val result = (state as ScheduleEventsUiState.Success).events - assertEquals(expected, result) + val stateResult = scheduleViewModel.scheduleUiState.value + val expectedDate = FAKE_SCHEDULE_DATES.map { it.toUiModel() } + + assertAll( + { coVerify { scheduleRepository.fetchAllScheduleDates() } }, + { coVerify { scheduleRepository.fetchScheduleEventsById(dateId) } }, + { assertTrue(stateResult is ScheduleUiState.Success) }, + { assertEquals(expectedDate, (stateResult as ScheduleUiState.Success).dates) }, + ) } @Test - fun `현재 진행중인 일정의 인덱스를 불러올 수 있다`() = + fun `ViewModel이 생성되면 날짜에 해당하는 일정들을 불러온다`() = runTest { // given - coEvery { scheduleRepository.fetchScheduleEventsById(dateId) } returns - Result.success( - FAKE_SCHEDULE_EVENTS, - ) + advanceUntilIdle() // when - scheduleViewModel.loadScheduleByDate() - advanceUntilIdle() + val state = scheduleViewModel.scheduleUiState.value // then - val state = scheduleViewModel.scheduleEventsUiState.getOrAwaitValue() - assertTrue(state is ScheduleEventsUiState.Success) + val successState = + state as? ScheduleUiState.Success ?: fail("ScheduleUiState.Success 가 아님: $state") + + val eventsState = + successState.eventsUiStateByPosition[0] as? ScheduleEventsUiState.Success + ?: fail("ScheduleEventsUiState.Success 가 아님") - val expected = 1 - val actual = (state as ScheduleEventsUiState.Success).currentEventPosition - assertThat(actual).isEqualTo(expected) + assertAll( + { coVerify { scheduleRepository.fetchAllScheduleDates() } }, + { coVerify { scheduleRepository.fetchScheduleEventsById(dateId) } }, + { assertEquals(FAKE_SCHEDULE_EVENTS_UI_MODELS, eventsState.events) }, + ) } @Test - fun `현재 진행중인 행사가 없다면 가장 첫 번쨰 일정의 인덱스를 불러온다`() = + fun `현재 진행중인 날짜의 인덱스를 불러올 수 있다`() = runTest { // given - coEvery { scheduleRepository.fetchScheduleEventsById(dateId) } returns - Result.success( - listOf( - ScheduleEvent( - id = 1L, - status = ScheduleEventStatus.UPCOMING, - startTime = "2025-07-26T10:00:00", - endTime = "2025-07-26T11:00:00", - title = "안드로이드 스터디", - location = "서울 강남구 어딘가", - ), - ), - ) + advanceUntilIdle() // when - scheduleViewModel.loadScheduleByDate() - advanceUntilIdle() + val state = scheduleViewModel.scheduleUiState.value // then - val state = scheduleViewModel.scheduleEventsUiState.getOrAwaitValue() - assertTrue(state is ScheduleEventsUiState.Success) + val successState = + state as? ScheduleUiState.Success ?: fail("ScheduleUiState.Success 가 아님: $state") - val expected = 0 - val actual = (state as ScheduleEventsUiState.Success).currentEventPosition - assertThat(actual).isEqualTo(expected) + assertAll( + { coVerify { scheduleRepository.fetchAllScheduleDates() } }, + { coVerify { scheduleRepository.fetchScheduleEventsById(dateId) } }, + { assertEquals(0, successState.currentDatePosition) }, + ) } @Test - fun `dateId에 유효하지 않은 값을 넣고 뷰모델을 생성하면 일정을 불러오지 않는다`() = + fun `현재 진행중인 일정의 인덱스를 불러올 수 있다`() = runTest { // given - val dateId = ScheduleViewModel.INVALID_ID - - // when - scheduleViewModel = ScheduleViewModel(scheduleRepository, dateId) advanceUntilIdle() - // then - coVerify(exactly = 0) { scheduleRepository.fetchScheduleEventsById(dateId) } - } - - @Test - fun `모든 날짜의 축제 정보를 불러올 수 있다`() = - runTest { - // given - coEvery { scheduleRepository.fetchAllScheduleDates() } returns - Result.success( - FAKE_SCHEDULE_DATES, - ) - - val expected = - ScheduleDatesUiState.Success( - FAKE_SCHEDULE_DATES.map { it.toUiModel() }, - 0, - ) - // when - scheduleViewModel.loadAllDates() - advanceUntilIdle() + val state = scheduleViewModel.scheduleUiState.value // then - coVerify { scheduleRepository.fetchAllScheduleDates() } - val actual = scheduleViewModel.scheduleDatesUiState.getOrAwaitValue() - assertThat(actual).isEqualTo(expected) + val successState = + state as? ScheduleUiState.Success ?: fail("ScheduleUiState.Success 가 아님: $state") + val eventsState = + successState.eventsUiStateByPosition[0] as? ScheduleEventsUiState.Success + ?: fail("ScheduleEventsUiState.Success 가 아님") + + assertAll( + { coVerify { scheduleRepository.fetchAllScheduleDates() } }, + { coVerify { scheduleRepository.fetchScheduleEventsById(dateId) } }, + { assertEquals(0, eventsState.currentEventPosition) }, + ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d8bb01b..178b55b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,9 @@ firebaseBom = "34.0.0" fragmentKtx = "1.8.8" kotlin = "2.2.20" coreKtx = "1.16.0" -junit = "4.13.2" +junit4 = "4.13.2" +junit5 = "5.14.1" +junitPlatformLauncher = "6.0.1" junitVersion = "1.2.1" espressoCore = "3.6.1" appcompat = "1.7.0" @@ -55,10 +57,11 @@ androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = firebase-analytics = { module = "com.google.firebase:firebase-analytics" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } firebase-crashlytics-ndk = { module = "com.google.firebase:firebase-crashlytics-ndk" } -junit = { group = "junit", name = "junit", version.ref = "junit" } +junit = { group = "junit", name = "junit", version.ref = "junit4" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit5" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } ktlint = { module = "io.nlopez.compose.rules:ktlint", version.ref = "ktlintVersion" } logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" } @@ -83,6 +86,10 @@ androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "uiTooling" } +junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit5" } +junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit5" } +junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit5" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatformLauncher" } [plugins]