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 80fc673..cd93ed6 100644 --- a/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt +++ b/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt @@ -38,8 +38,7 @@ interface FestaBookAppGraph { // splashActivity @Provides - fun provideAppUpdateManager(application: Application): AppUpdateManager = - AppUpdateManagerFactory.create(application) + fun provideAppUpdateManager(application: Application): AppUpdateManager = AppUpdateManagerFactory.create(application) // logger val defaultFirebaseLogger: DefaultFirebaseLogger diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/CardBackground.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/CardBackground.kt index e126200..cacbeba 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/component/CardBackground.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/CardBackground.kt @@ -4,38 +4,42 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookShapes @Composable fun Modifier.cardBackground( backgroundColor: Color = FestabookColor.gray100, borderStroke: Dp = 1.dp, borderColor: Color = FestabookColor.gray200, - roundedCornerShape: Dp = 16.dp, + shape: Shape = festabookShapes.radius3, ): Modifier = background( color = backgroundColor, - shape = RoundedCornerShape(roundedCornerShape), + shape = shape, ).border( width = borderStroke, color = borderColor, - shape = RoundedCornerShape(roundedCornerShape), + shape = shape, ) @Composable @Preview(showBackground = true) private fun CardBackgroundPreview() { - Box( - modifier = - Modifier - .cardBackground() - .size(120.dp), - ) + FestabookTheme { + Box( + modifier = + Modifier + .cardBackground() + .size(120.dp), + ) + } } 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/Header.kt new file mode 100644 index 0000000..1efcadd --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/Header.kt @@ -0,0 +1,37 @@ +package com.daedan.festabook.presentation.common.component + +import androidx.compose.foundation.layout.fillMaxWidth +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.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun Header( + title: String, + modifier: Modifier = Modifier, + style: TextStyle = FestabookTypography.displayLarge, +) { + Text( + text = title, + style = style, + modifier = + modifier + .padding( + top = festabookSpacing.paddingTitleHorizontal, + bottom = festabookSpacing.paddingBody4, + start = festabookSpacing.paddingScreenGutter, + end = festabookSpacing.paddingScreenGutter, + ).fillMaxWidth(), + ) +} + +@Composable +@Preview(showBackground = true) +private fun HeaderPreview() { + Header(title = "FestaBook") +} 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..4a01b03 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 @@ -33,6 +33,7 @@ import com.daedan.festabook.presentation.common.showToast import com.daedan.festabook.presentation.home.HomeFragment import com.daedan.festabook.presentation.home.HomeViewModel import com.daedan.festabook.presentation.news.NewsFragment +import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.placeMap.PlaceMapFragment import com.daedan.festabook.presentation.schedule.ScheduleFragment import com.daedan.festabook.presentation.setting.SettingFragment @@ -44,7 +45,6 @@ import timber.log.Timber class MainActivity : AppCompatActivity(), NotificationPermissionRequester { - @Inject override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory @@ -59,6 +59,7 @@ class MainActivity : private val mainViewModel: MainViewModel by viewModels() private val homeViewModel: HomeViewModel by viewModels() + private val newsViewModel: NewsViewModel by viewModels() private val settingViewModel: SettingViewModel by viewModels() private val notificationPermissionManager by lazy { @@ -116,25 +117,24 @@ class MainActivity : ) { grantResults.forEachIndexed { index, result -> val text = permissions[index] - when(text) { + when (text) { Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION -> { + Manifest.permission.ACCESS_COARSE_LOCATION, + -> { if (!result.isGranted()) { showNotificationDeniedSnackbar( binding.root, this, - getString(R.string.map_request_location_permission_message) + getString(R.string.map_request_location_permission_message), ) } } } - } super.onRequestPermissionsResult(requestCode, permissions, grantResults) } - override fun shouldShowPermissionRationale(permission: String): Boolean = - shouldShowRequestPermissionRationale(permission) + override fun shouldShowPermissionRationale(permission: String): Boolean = shouldShowRequestPermissionRationale(permission) override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) @@ -145,7 +145,7 @@ class MainActivity : val canNavigateToNewsScreen = intent.getBooleanExtra(KEY_CAN_NAVIGATE_TO_NEWS, false) val noticeIdToExpand = intent.getLongExtra(KEY_NOTICE_ID_TO_EXPAND, INITIALIZED_ID) - if (noticeIdToExpand != INITIALIZED_ID) mainViewModel.expandNoticeItem(noticeIdToExpand) + if (noticeIdToExpand != INITIALIZED_ID) newsViewModel.expandNotice(noticeIdToExpand) if (canNavigateToNewsScreen) { binding.bnvMenu.selectedItemId = R.id.item_menu_news diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/MainViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/main/MainViewModel.kt index 4f453bb..a97d8f0 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/main/MainViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/main/MainViewModel.kt @@ -5,7 +5,6 @@ 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.DeviceRepository import com.daedan.festabook.domain.repository.FestivalRepository import com.daedan.festabook.presentation.common.Event @@ -25,13 +24,8 @@ class MainViewModel @Inject constructor( private val _backPressEvent: MutableLiveData> = MutableLiveData() val backPressEvent: LiveData> get() = _backPressEvent - private val _noticeIdToExpand: MutableLiveData = MutableLiveData() - val noticeIdToExpand: LiveData = _noticeIdToExpand - private val _isFirstVisit = - MutableLiveData( - festivalRepository.getIsFirstVisit().getOrDefault(true), - ) + MutableLiveData(festivalRepository.getIsFirstVisit().getOrDefault(true)) val isFirstVisit: LiveData get() = _isFirstVisit private var lastBackPressedTime: Long = 0 @@ -90,10 +84,6 @@ class MainViewModel @Inject constructor( } } - fun expandNoticeItem(announcementId: Long) { - _noticeIdToExpand.value = announcementId - } - companion object { private const val BACK_PRESS_INTERVAL: Long = 2000L } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt index 5286660..3299f19 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt @@ -1,7 +1,11 @@ package com.daedan.festabook.presentation.news 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 @@ -9,13 +13,8 @@ import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentNewsBinding import com.daedan.festabook.di.fragment.FragmentKey import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.main.MainViewModel -import com.daedan.festabook.presentation.news.adapter.NewsPagerAdapter -import com.daedan.festabook.presentation.news.faq.model.FAQItemUiModel -import com.daedan.festabook.presentation.news.lost.model.LostUiModel -import com.daedan.festabook.presentation.news.notice.adapter.NewsClickListener -import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel -import com.google.android.material.tabs.TabLayoutMediator +import com.daedan.festabook.presentation.news.component.NewsScreen +import com.daedan.festabook.presentation.theme.FestabookTheme import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject @@ -27,53 +26,25 @@ import dev.zacsweers.metro.binding ) @FragmentKey(NewsFragment::class) @Inject -class NewsFragment : - BaseFragment(), - NewsClickListener { +class NewsFragment : BaseFragment() { + override val layoutId: Int = R.layout.fragment_news + @Inject override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory - override val layoutId: Int = R.layout.fragment_news + private val newsViewModel: NewsViewModel by viewModels({ requireActivity() }) - private val newsPagerAdapter by lazy { - NewsPagerAdapter(this) - } - private val newsViewModel: NewsViewModel by viewModels() - private val mainViewModel: MainViewModel by viewModels({ requireActivity() }) - - override fun onViewCreated( - view: View, + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - binding.lifecycleOwner = viewLifecycleOwner - setupNewsTabLayout() - mainViewModel.noticeIdToExpand.observe(viewLifecycleOwner) { - binding.vpNews.currentItem = NOTICE_TAB_INDEX + ): View = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + FestabookTheme { + NewsScreen(newsViewModel = newsViewModel) + } + } } - } - - override fun onNoticeClick(notice: NoticeUiModel) { - newsViewModel.toggleNoticeExpanded(notice) - } - - override fun onFAQClick(faqItem: FAQItemUiModel) { - newsViewModel.toggleFAQExpanded(faqItem) - } - - override fun onLostGuideItemClick() { - newsViewModel.toggleLostGuideExpanded() - } - - private fun setupNewsTabLayout() { - binding.vpNews.adapter = newsPagerAdapter - TabLayoutMediator(binding.tlNews, binding.vpNews) { tab, position -> - val tabNameRes = NewsTab.entries[position].tabNameRes - tab.text = getString(tabNameRes) - }.attach() - } - - companion object { - private const val NOTICE_TAB_INDEX: Int = 0 - } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt index 539da37..dc47a55 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt @@ -1,9 +1,5 @@ package com.daedan.festabook.presentation.news -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey @@ -25,6 +21,9 @@ import com.daedan.festabook.presentation.news.notice.model.toUiModel 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 @ContributesIntoMap(AppScope::class) @@ -35,34 +34,29 @@ class NewsViewModel( private val faqRepository: FAQRepository, private val lostItemRepository: LostItemRepository, ) : ViewModel() { - var noticeUiState by mutableStateOf(NoticeUiState.InitialLoading) - private set + private val _noticeUiState: MutableStateFlow = + MutableStateFlow(NoticeUiState.InitialLoading) + val noticeUiState: StateFlow = _noticeUiState.asStateFlow() - val isNoticeScreenRefreshing by derivedStateOf { - noticeUiState is NoticeUiState.Refreshing - } - - var faqUiState by mutableStateOf(FAQUiState.InitialLoading) - private set + private val _faqUiState: MutableStateFlow = + MutableStateFlow(FAQUiState.InitialLoading) + val faqUiState: StateFlow = _faqUiState.asStateFlow() - var lostUiState by mutableStateOf(LostUiState.InitialLoading) - private set - - val isLostItemScreenRefreshing by derivedStateOf { - lostUiState is LostUiState.Refreshing - } + private val _lostUiState: MutableStateFlow = + MutableStateFlow(LostUiState.InitialLoading) + val lostUiState: StateFlow = _lostUiState.asStateFlow() private var noticeIdToExpand: Long? = null init { loadAllNotices(NoticeUiState.InitialLoading) - loadAllFAQs() + loadAllFAQs(FAQUiState.InitialLoading) loadAllLostItems(LostUiState.InitialLoading) } fun loadAllNotices(state: NoticeUiState) { viewModelScope.launch { - noticeUiState = state + _noticeUiState.value = state val result = noticeRepository.fetchNotices() result .onSuccess { notices -> @@ -76,16 +70,16 @@ class NewsViewModel( notices.indexOfFirst { it.id == noticeIdToExpand }.let { if (it == -1) DEFAULT_POSITION else it } - noticeUiState = + _noticeUiState.value = NoticeUiState.Success(updatedNotices, expandPosition) noticeIdToExpand = null }.onFailure { - noticeUiState = NoticeUiState.Error(it) + _noticeUiState.value = NoticeUiState.Error(it) } } } - fun toggleNoticeExpanded(notice: NoticeUiModel) { + fun toggleNotice(notice: NoticeUiModel) { updateNoticeUiState { notices -> notices.map { updatedNotice -> if (notice.id == updatedNotice.id) { @@ -97,10 +91,10 @@ class NewsViewModel( } } - fun expandNotice(noticeId: Long) { - this.noticeIdToExpand = noticeId + fun expandNotice(noticeIdToExpand: Long) { + this.noticeIdToExpand = noticeIdToExpand val notices = - when (val currentState = noticeUiState) { + when (val currentState = _noticeUiState.value) { is NoticeUiState.Refreshing -> currentState.oldNotices is NoticeUiState.Success -> currentState.notices else -> return @@ -109,7 +103,7 @@ class NewsViewModel( loadAllNotices(NoticeUiState.Refreshing(notices)) } - fun toggleFAQExpanded(faqItem: FAQItemUiModel) { + fun toggleFAQ(faqItem: FAQItemUiModel) { updateFAQUiState { faqItems -> faqItems.map { updatedFAQItem -> if (faqItem.questionId == updatedFAQItem.questionId) { @@ -121,7 +115,7 @@ class NewsViewModel( } } - fun toggleLostGuideExpanded() { + fun toggleLostGuide() { updateLostUiState { lostUiModels -> lostUiModels.map { lostUiModel -> if (lostUiModel is LostUiModel.Guide) { @@ -135,7 +129,7 @@ class NewsViewModel( fun loadAllLostItems(state: LostUiState) { viewModelScope.launch { - lostUiState = state + _lostUiState.value = state val result = lostItemRepository.getLost() val lostUiModels = @@ -146,28 +140,28 @@ class NewsViewModel( null -> LostUiModel.Guide() } } - lostUiState = LostUiState.Success(lostUiModels) + _lostUiState.value = LostUiState.Success(lostUiModels) } } - private fun loadAllFAQs(state: FAQUiState = FAQUiState.InitialLoading) { + private fun loadAllFAQs(state: FAQUiState) { viewModelScope.launch { - faqUiState = state + _faqUiState.value = state val result = faqRepository.getAllFAQ() result .onSuccess { faqItems -> - faqUiState = FAQUiState.Success(faqItems.map { it.toUiModel() }) + _faqUiState.value = FAQUiState.Success(faqItems.map { it.toUiModel() }) }.onFailure { - faqUiState = FAQUiState.Error(it) + _faqUiState.value = FAQUiState.Error(it) } } } private fun updateNoticeUiState(onUpdate: (List) -> List) { - noticeUiState = - when (val currentState = noticeUiState) { + _noticeUiState.value = + when (val currentState = _noticeUiState.value) { is NoticeUiState.Success -> currentState.copy( notices = onUpdate(currentState.notices), @@ -178,8 +172,8 @@ class NewsViewModel( } private fun updateFAQUiState(onUpdate: (List) -> List) { - val currentState = faqUiState - faqUiState = + val currentState = _faqUiState.value + _faqUiState.value = when (currentState) { is FAQUiState.Success -> currentState.copy(faqs = onUpdate(currentState.faqs)) else -> currentState @@ -187,8 +181,8 @@ class NewsViewModel( } private fun updateLostUiState(onUpdate: (List) -> List) { - val currentState = lostUiState - lostUiState = + val currentState = _lostUiState.value + _lostUiState.value = when (currentState) { is LostUiState.Success -> currentState.copy(lostItems = onUpdate(currentState.lostItems)) else -> currentState diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/adapter/NewsPagerAdapter.kt b/app/src/main/java/com/daedan/festabook/presentation/news/adapter/NewsPagerAdapter.kt deleted file mode 100644 index 9373d9f..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/news/adapter/NewsPagerAdapter.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.daedan.festabook.presentation.news.adapter - -import androidx.fragment.app.Fragment -import androidx.viewpager2.adapter.FragmentStateAdapter -import com.daedan.festabook.presentation.news.NewsTab -import com.daedan.festabook.presentation.news.faq.FAQFragment -import com.daedan.festabook.presentation.news.lost.LostItemFragment -import com.daedan.festabook.presentation.news.notice.NoticeFragment - -class NewsPagerAdapter( - fragment: Fragment, -) : FragmentStateAdapter(fragment) { - override fun getItemCount(): Int = NewsTab.entries.size - - override fun createFragment(position: Int): Fragment = - when (NewsTab.entries[position]) { - NewsTab.NOTICE -> NoticeFragment.newInstance() - NewsTab.FAQ -> FAQFragment.newInstance() - NewsTab.LOST_ITEM -> LostItemFragment.newInstance() - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsItem.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsItem.kt index c3f9876..d002aeb 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsItem.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsItem.kt @@ -21,11 +21,11 @@ import androidx.compose.ui.draw.rotate 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.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookSpacing private const val ICON_ROTATION_EXPANDED: Float = 180F private const val ICON_ROTATION_COLLAPSED: Float = 0F @@ -54,7 +54,7 @@ fun NewsItem( indication = null, interactionSource = null, ) { onclick() } - .padding(16.dp), + .padding(festabookSpacing.paddingBody4), ) { Row( modifier = Modifier.fillMaxWidth(), @@ -62,14 +62,14 @@ fun NewsItem( ) { if (icon != null) { icon() - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(festabookSpacing.paddingBody2)) } Text( text = title, style = FestabookTypography.titleSmall, modifier = Modifier.weight(1f), ) - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(festabookSpacing.paddingBody2)) if (createdAt != null) { Text( text = createdAt, @@ -86,7 +86,7 @@ fun NewsItem( } if (isExpanded) { - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody2)) Text(text = description) } } 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 new file mode 100644 index 0000000..728f9ee --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt @@ -0,0 +1,66 @@ +package com.daedan.festabook.presentation.news.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.MaterialTheme +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.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.component.Header +import com.daedan.festabook.presentation.news.NewsTab +import com.daedan.festabook.presentation.news.NewsViewModel +import com.daedan.festabook.presentation.news.lost.LostUiState +import com.daedan.festabook.presentation.news.notice.NoticeUiState + +@Composable +fun NewsScreen( + newsViewModel: NewsViewModel, + modifier: Modifier = Modifier, +) { + val pageState = rememberPagerState { NewsTab.entries.size } + val scope = rememberCoroutineScope() + + val noticeUiState by newsViewModel.noticeUiState.collectAsStateWithLifecycle() + val lostUiState by newsViewModel.lostUiState.collectAsStateWithLifecycle() + val faqUiState by newsViewModel.faqUiState.collectAsStateWithLifecycle() + + val isNoticeRefreshing = noticeUiState is NoticeUiState.Refreshing + val isLostItemRefreshing = lostUiState is LostUiState.Refreshing + + LaunchedEffect(noticeUiState) { + if (noticeUiState is NoticeUiState.Success) { + 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() }, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt new file mode 100644 index 0000000..1fbf10a --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt @@ -0,0 +1,88 @@ +package com.daedan.festabook.presentation.news.component + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.daedan.festabook.presentation.news.NewsTab +import com.daedan.festabook.presentation.news.faq.FAQUiState +import com.daedan.festabook.presentation.news.faq.component.FAQScreen +import com.daedan.festabook.presentation.news.faq.model.FAQItemUiModel +import com.daedan.festabook.presentation.news.lost.LostUiState +import com.daedan.festabook.presentation.news.lost.component.LostItemScreen +import com.daedan.festabook.presentation.news.notice.NoticeUiState +import com.daedan.festabook.presentation.news.notice.component.NoticeScreen +import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun NewsTabPage( + pageState: PagerState, + noticeUiState: NoticeUiState, + faqUiState: FAQUiState, + lostUiState: LostUiState, + onNoticeRefresh: () -> Unit, + onLostItemRefresh: () -> Unit, + isNoticeRefreshing: Boolean, + isLostItemRefreshing: Boolean, + onNoticeClick: (NoticeUiModel) -> Unit, + onFaqClick: (FAQItemUiModel) -> Unit, + onLostGuideClick: () -> Unit, + modifier: Modifier = Modifier, +) { + HorizontalPager( + state = pageState, + verticalAlignment = Alignment.Top, + modifier = modifier, + ) { index -> + val tab = NewsTab.entries[index] + when (tab) { + NewsTab.NOTICE -> + NoticeScreen( + uiState = noticeUiState, + onNoticeClick = onNoticeClick, + isRefreshing = isNoticeRefreshing, + onRefresh = onNoticeRefresh, + modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), + ) + + NewsTab.FAQ -> + FAQScreen( + uiState = faqUiState, + onFaqClick = onFaqClick, + modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), + ) + + NewsTab.LOST_ITEM -> + LostItemScreen( + lostUiState = lostUiState, + onLostGuideClick = onLostGuideClick, + isRefreshing = isLostItemRefreshing, + onRefresh = onLostItemRefresh, + modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), + ) + } + } +} + +@Composable +@Preview +private fun NewsTabPagePreview() { + NewsTabPage( + pageState = rememberPagerState { 3 }, + noticeUiState = NoticeUiState.Success(emptyList(), 0), + faqUiState = FAQUiState.Success(emptyList()), + lostUiState = LostUiState.Success(emptyList()), + onNoticeRefresh = {}, + onLostItemRefresh = {}, + isNoticeRefreshing = false, + isLostItemRefreshing = false, + onNoticeClick = {}, + onFaqClick = {}, + onLostGuideClick = {}, + ) +} 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 new file mode 100644 index 0000000..a8bc11b --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt @@ -0,0 +1,58 @@ +package com.daedan.festabook.presentation.news.component + +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.TabRowDefaults +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.daedan.festabook.presentation.news.NewsTab +import com.daedan.festabook.presentation.theme.FestabookColor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun NewsTabRow( + pageState: PagerState, + scope: CoroutineScope, + modifier: Modifier = Modifier, +) { + TabRow( + selectedTabIndex = pageState.currentPage, + containerColor = MaterialTheme.colorScheme.background, + contentColor = FestabookColor.black, + indicator = { tabPositions -> + TabRowDefaults.PrimaryIndicator( + color = FestabookColor.black, + width = tabPositions[pageState.currentPage].width, + modifier = Modifier.tabIndicatorOffset(currentTabPosition = tabPositions[pageState.currentPage]), + ) + }, + modifier = modifier, + ) { + NewsTab.entries.forEachIndexed { index, title -> + Tab( + selected = pageState.currentPage == index, + unselectedContentColor = FestabookColor.gray500, + onClick = { scope.launch { pageState.animateScrollToPage(index) } }, + text = { Text(text = stringResource(title.tabNameRes)) }, + ) + } + } +} + +@Composable +@Preview +private fun NewsTabRowPreview() { + NewsTabRow( + pageState = rememberPagerState { 3 }, + scope = rememberCoroutineScope(), + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/faq/FAQFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/news/faq/FAQFragment.kt deleted file mode 100644 index 353e48f..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/news/faq/FAQFragment.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.daedan.festabook.presentation.news.faq - -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.viewModels -import androidx.lifecycle.ViewModelProvider -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentFaqBinding -import com.daedan.festabook.di.appGraph -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.news.NewsViewModel -import com.daedan.festabook.presentation.news.faq.component.FAQScreen -import com.daedan.festabook.presentation.news.notice.adapter.NewsClickListener - -class FAQFragment : BaseFragment() { - override val layoutId: Int = R.layout.fragment_faq - - override val defaultViewModelProviderFactory: ViewModelProvider.Factory - get() = appGraph.metroViewModelFactory - private val viewModel: NewsViewModel by viewModels({ requireParentFragment() }) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = - ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - FAQScreen(uiState = viewModel.faqUiState, onFaqClick = { faqItemUiModel -> - (requireParentFragment() as NewsClickListener).onFAQClick(faqItemUiModel) - }) - } - } - - companion object { - fun newInstance() = FAQFragment() - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/faq/adapter/FAQViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/news/faq/adapter/FAQViewHolder.kt index 2114bad..7bdefd5 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/faq/adapter/FAQViewHolder.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/faq/adapter/FAQViewHolder.kt @@ -21,7 +21,6 @@ class FAQViewHolder( init { binding.root.setOnClickListener { faqItem?.let { - newsClickListener.onFAQClick(it) } ?: run { Timber.w("${this::class.java.simpleName} : FAQ 아이템이 null입니다.") } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt index 89482d2..5738544 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt @@ -6,19 +6,22 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.news.NewsViewModel import com.daedan.festabook.presentation.news.component.NewsItem import com.daedan.festabook.presentation.news.faq.FAQUiState import com.daedan.festabook.presentation.news.faq.model.FAQItemUiModel +import com.daedan.festabook.presentation.theme.festabookSpacing import timber.log.Timber -private const val PADDING: Int = 8 - @Composable fun FAQScreen( uiState: FAQUiState, @@ -32,7 +35,7 @@ fun FAQScreen( } } - is FAQUiState.InitialLoading -> Unit + is FAQUiState.InitialLoading -> LoadingStateScreen() is FAQUiState.Success -> { if (uiState.faqs.isEmpty()) { @@ -40,8 +43,12 @@ fun FAQScreen( } else { LazyColumn( modifier = modifier, - contentPadding = PaddingValues(top = PADDING.dp, bottom = PADDING.dp), - verticalArrangement = Arrangement.spacedBy(PADDING.dp), + contentPadding = + PaddingValues( + top = festabookSpacing.paddingBody2, + bottom = festabookSpacing.paddingBody2, + ), + verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody2), ) { items( items = uiState.faqs, diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/LostItemFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/LostItemFragment.kt deleted file mode 100644 index 305c10d..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/LostItemFragment.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.daedan.festabook.presentation.news.lost - -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.viewModels -import androidx.lifecycle.ViewModelProvider -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentLostItemBinding -import com.daedan.festabook.di.appGraph -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.news.NewsViewModel -import com.daedan.festabook.presentation.news.lost.component.LostItemScreen -import com.daedan.festabook.presentation.news.notice.adapter.NewsClickListener - -class LostItemFragment : BaseFragment() { - override val layoutId: Int = R.layout.fragment_lost_item - - override val defaultViewModelProviderFactory: ViewModelProvider.Factory - get() = appGraph.metroViewModelFactory - - private val viewModel: NewsViewModel by viewModels({ requireParentFragment() }) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = - ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val newsClickListener = requireParentFragment() as NewsClickListener - LostItemScreen( - lostUiState = viewModel.lostUiState, - onLostGuideClick = { newsClickListener.onLostGuideItemClick() }, - isRefreshing = viewModel.isLostItemScreenRefreshing, - onRefresh = { - val currentUiState = viewModel.lostUiState - val oldLostItems = - if (currentUiState is LostUiState.Success) currentUiState.lostItems else emptyList() - viewModel.loadAllLostItems(LostUiState.Refreshing(oldLostItems)) - }, - ) - } - } - - companion object { - fun newInstance() = LostItemFragment() - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/adapter/LostGuideItemViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/adapter/LostGuideItemViewHolder.kt index 741eeaa..d96e756 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/adapter/LostGuideItemViewHolder.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/lost/adapter/LostGuideItemViewHolder.kt @@ -19,7 +19,6 @@ class LostGuideItemViewHolder private constructor( init { binding.root.setOnClickListener { lostGuideItem?.let { - newsClickListener.onLostGuideItemClick() } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItem.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItem.kt index 50e2635..bf3567e 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItem.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItem.kt @@ -3,18 +3,16 @@ package com.daedan.festabook.presentation.news.lost.component import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.CoilImage import com.daedan.festabook.presentation.common.component.cardBackground - -private const val ROUNDED_CORNER_SHAPE = 16 +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookShapes @Composable fun LostItem( @@ -23,10 +21,10 @@ fun LostItem( onLostItemClick: () -> Unit = {}, ) { Card( - shape = RoundedCornerShape(ROUNDED_CORNER_SHAPE.dp), + shape = festabookShapes.radius3, modifier = modifier - .cardBackground(roundedCornerShape = ROUNDED_CORNER_SHAPE.dp) + .cardBackground() .aspectRatio(1f) .clickable(indication = null, interactionSource = null) { onLostItemClick() }, ) { @@ -41,8 +39,10 @@ fun LostItem( @Composable @Preview private fun LostItemPreview() { - LostItem( - url = "https://i.imgur.com/Zblctu7.png", - onLostItemClick = { }, - ) + FestabookTheme { + LostItem( + url = "https://i.imgur.com/Zblctu7.png", + onLostItemClick = { }, + ) + } } 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 f140d80..6272bd0 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 @@ -21,19 +21,21 @@ 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 androidx.lifecycle.compose.collectAsStateWithLifecycle 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.NewsViewModel import com.daedan.festabook.presentation.news.component.NewsItem import com.daedan.festabook.presentation.news.lost.LostUiState import com.daedan.festabook.presentation.news.lost.model.LostItemUiStatus import com.daedan.festabook.presentation.news.lost.model.LostUiModel +import com.daedan.festabook.presentation.theme.festabookSpacing import timber.log.Timber private const val SPAN_COUNT: Int = 2 -private const val PADDING: Int = 8 @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -56,7 +58,6 @@ fun LostItemScreen( PullToRefreshContainer( isRefreshing = isRefreshing, onRefresh = onRefresh, - modifier = modifier, ) { pullToRefreshState -> when (lostUiState) { LostUiState.InitialLoading -> LoadingStateScreen() @@ -115,9 +116,13 @@ private fun LostItemContent( LazyVerticalGrid( modifier = modifier, columns = GridCells.Fixed(SPAN_COUNT), - contentPadding = PaddingValues(top = PADDING.dp, bottom = PADDING.dp), - verticalArrangement = Arrangement.spacedBy(PADDING.dp), - horizontalArrangement = Arrangement.spacedBy(PADDING.dp), + contentPadding = + PaddingValues( + top = festabookSpacing.paddingBody2, + bottom = festabookSpacing.paddingBody2, + ), + verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody2), + horizontalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody2), ) { item(span = { GridItemSpan(SPAN_COUNT) }) { val guide = lostItems.firstOrNull() as? LostUiModel.Guide diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/NoticeFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/NoticeFragment.kt deleted file mode 100644 index ba6e5c4..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/NoticeFragment.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.daedan.festabook.presentation.news.notice - -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.viewModels -import androidx.lifecycle.ViewModelProvider -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentNoticeBinding -import com.daedan.festabook.di.appGraph -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.main.MainViewModel -import com.daedan.festabook.presentation.news.NewsViewModel -import com.daedan.festabook.presentation.news.notice.adapter.NewsClickListener -import com.daedan.festabook.presentation.news.notice.component.NoticeScreen - -class NoticeFragment : BaseFragment() { - override val layoutId: Int = R.layout.fragment_notice - - override val defaultViewModelProviderFactory: ViewModelProvider.Factory - get() = appGraph.metroViewModelFactory - private val newsViewModel: NewsViewModel by viewModels({ requireParentFragment() }) - private val mainViewModel: MainViewModel by viewModels({ requireActivity() }) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = - ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - NoticeScreen( - uiState = newsViewModel.noticeUiState, - onNoticeClick = { notice -> - (requireParentFragment() as NewsClickListener) - .onNoticeClick(notice) - }, - isRefreshing = newsViewModel.isNoticeScreenRefreshing, - onRefresh = { - val currentUiState = newsViewModel.noticeUiState - val oldNotices = - if (currentUiState is NoticeUiState.Success) currentUiState.notices else emptyList() - newsViewModel.loadAllNotices(NoticeUiState.Refreshing(oldNotices)) - }, - ) - } - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - setupObserver() - } - - private fun setupObserver() { - mainViewModel.noticeIdToExpand.observe(viewLifecycleOwner) { noticeId -> - newsViewModel.expandNotice(noticeId) - } - } - - companion object { - fun newInstance() = NoticeFragment() - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/adapter/NoticeViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/adapter/NoticeViewHolder.kt index 472fa80..cb31dd2 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/adapter/NoticeViewHolder.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/notice/adapter/NoticeViewHolder.kt @@ -21,7 +21,6 @@ class NoticeViewHolder( init { binding.layoutNoticeItem.setOnClickListener { noticeItem?.let { - newsClickListener.onNoticeClick(it) } ?: run { Timber.w("${this::class.java.simpleName} 공지 아이템이 null입니다.") } 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 4edb88b..fbc2815 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,25 +9,27 @@ 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 androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.NewsViewModel import com.daedan.festabook.presentation.news.component.NewsItem import com.daedan.festabook.presentation.news.notice.NoticeUiState import com.daedan.festabook.presentation.news.notice.NoticeUiState.Companion.DEFAULT_POSITION import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel +import com.daedan.festabook.presentation.theme.festabookSpacing import timber.log.Timber -private const val PADDING: Int = 8 - @OptIn(ExperimentalMaterial3Api::class) @Composable fun NoticeScreen( @@ -94,8 +96,12 @@ private fun NoticeContent( LazyColumn( modifier = modifier, state = listState, - contentPadding = PaddingValues(top = PADDING.dp, bottom = PADDING.dp), - verticalArrangement = Arrangement.spacedBy(PADDING.dp), + contentPadding = + PaddingValues( + top = festabookSpacing.paddingBody2, + bottom = festabookSpacing.paddingBody2, + ), + verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody2), ) { items( items = notices, diff --git a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookShapes.kt b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookShapes.kt new file mode 100644 index 0000000..8f08a18 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookShapes.kt @@ -0,0 +1,21 @@ +package com.daedan.festabook.presentation.theme + +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.dp + +data class FestabookShapes( + val radius1: CornerBasedShape = RoundedCornerShape(6.dp), + val radius2: CornerBasedShape = RoundedCornerShape(10.dp), + val radius3: CornerBasedShape = RoundedCornerShape(16.dp), + val radius4: CornerBasedShape = RoundedCornerShape(20.dp), + val radius5: CornerBasedShape = RoundedCornerShape(24.dp), + val radiusFull: CornerBasedShape = RoundedCornerShape(999.dp), +) + +val LocalShapes = staticCompositionLocalOf { FestabookShapes() } + +val festabookShapes: FestabookShapes + @Composable get() = LocalShapes.current 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 new file mode 100644 index 0000000..3d12413 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookSpacing.kt @@ -0,0 +1,20 @@ +package com.daedan.festabook.presentation.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +data class FestabookSpacing( + val paddingScreenGutter: Dp = 16.dp, + val paddingTitleHorizontal: Dp = 40.dp, + val paddingBody1: Dp = 4.dp, + val paddingBody2: Dp = 8.dp, + val paddingBody3: Dp = 12.dp, + val paddingBody4: Dp = 16.dp, +) + +val LocalSpacing = staticCompositionLocalOf { FestabookSpacing() } + +val festabookSpacing + @Composable get() = LocalSpacing.current diff --git a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt new file mode 100644 index 0000000..5a7b46b --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt @@ -0,0 +1,27 @@ +package com.daedan.festabook.presentation.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider + +private val LightColorScheme = + lightColorScheme( + background = FestabookColor.white, + ) + +@Composable +fun FestabookTheme(content: @Composable () -> Unit) { + val spacing = FestabookSpacing() + val shapes = FestabookShapes() + CompositionLocalProvider( + LocalSpacing provides spacing, + LocalShapes provides shapes, + ) { + MaterialTheme( + colorScheme = LightColorScheme, + typography = FestabookTypography, + content = content, + ) + } +} diff --git a/app/src/test/java/com/daedan/festabook/news/NewsViewModelTest.kt b/app/src/test/java/com/daedan/festabook/news/NewsViewModelTest.kt index af35b53..e5e1518 100644 --- a/app/src/test/java/com/daedan/festabook/news/NewsViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/news/NewsViewModelTest.kt @@ -5,7 +5,6 @@ import com.daedan.festabook.domain.model.Lost import com.daedan.festabook.domain.repository.FAQRepository import com.daedan.festabook.domain.repository.LostItemRepository import com.daedan.festabook.domain.repository.NoticeRepository -import com.daedan.festabook.getOrAwaitValue import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.faq.FAQUiState import com.daedan.festabook.presentation.news.faq.model.toUiModel @@ -79,7 +78,7 @@ class NewsViewModelTest { // then val expected = FAKE_NOTICES.map { it.toUiModel() } - val actual = newsViewModel.noticeUiState + val actual = newsViewModel.noticeUiState.value coVerify { noticeRepository.fetchNotices() } assertThat(actual).isEqualTo( NoticeUiState.Success(expected, DEFAULT_POSITION), @@ -99,11 +98,11 @@ class NewsViewModelTest { ) // when - newsViewModel.loadAllLostItems() + newsViewModel.loadAllLostItems(LostUiState.InitialLoading) advanceUntilIdle() // then - val actual = newsViewModel.lostUiState.getOrAwaitValue() + val actual = newsViewModel.lostUiState.value coVerify { lostItemRepository.getLost() } assertThat(actual).isEqualTo(expected) } @@ -121,7 +120,7 @@ class NewsViewModelTest { // then val expected = NoticeUiState.Error(exception) - val actual = newsViewModel.noticeUiState + val actual = newsViewModel.noticeUiState.value coVerify { noticeRepository.fetchNotices() } assertThat(actual).isEqualTo(expected) } @@ -138,7 +137,7 @@ class NewsViewModelTest { // then val expected = FAKE_FAQS.map { it.toUiModel() } - val actual = newsViewModel.faqUiState + val actual = newsViewModel.faqUiState.value coVerify { faqRepository.getAllFAQ() } assertThat(actual).isEqualTo(FAQUiState.Success(expected)) } @@ -156,7 +155,7 @@ class NewsViewModelTest { // then val expected = FAQUiState.Error(exception) - val actual = newsViewModel.faqUiState + val actual = newsViewModel.faqUiState.value coVerify { faqRepository.getAllFAQ() } assertThat(actual).isEqualTo(expected) } @@ -168,7 +167,7 @@ class NewsViewModelTest { val notice = FAKE_NOTICES.first().toUiModel() // when - newsViewModel.toggleNoticeExpanded(notice) + newsViewModel.toggleNotice(notice) advanceUntilIdle() // then @@ -183,7 +182,7 @@ class NewsViewModelTest { createdAt = LocalDateTime.of(2025, 1, 1, 0, 0, 0), ), ) - val actual = newsViewModel.noticeUiState + val actual = newsViewModel.noticeUiState.value assertThat(actual).isEqualTo(NoticeUiState.Success(expected, DEFAULT_POSITION)) } @@ -194,29 +193,15 @@ class NewsViewModelTest { val faq = FAKE_FAQS.first().toUiModel() // when - newsViewModel.toggleFAQExpanded(faq) + newsViewModel.toggleFAQ(faq) advanceUntilIdle() // then val expected = listOf(faq.copy(isExpanded = true)) - val actual = newsViewModel.faqUiState + val actual = newsViewModel.faqUiState.value assertThat(actual).isEqualTo(FAQUiState.Success(expected)) } - @Test - fun `분실물 아이템의 클릭 이벤트를 발생시킬 수 있다`() = - runTest { - // given - val lostItem: LostUiModel.Item = mockk() - - // when - newsViewModel.lostItemClick(lostItem) - - // then - val actual = newsViewModel.lostItemClickEvent.getOrAwaitValue() - assertThat(actual.peekContent()).isEqualTo(lostItem) - } - @Test fun `처음 로드했을 때 펼처질 공지사항을 지정할 수 있다`() = runTest { @@ -233,7 +218,7 @@ class NewsViewModelTest { advanceUntilIdle() // then - val actual = newsViewModel.noticeUiState + val actual = newsViewModel.noticeUiState.value coVerify { noticeRepository.fetchNotices() } assertThat(actual).isEqualTo(NoticeUiState.Success(expected, 1)) } @@ -253,10 +238,10 @@ class NewsViewModelTest { ) // when - newsViewModel.toggleLostGuideExpanded() + newsViewModel.toggleLostGuide() // then - val actual = newsViewModel.lostUiState.getOrAwaitValue() + val actual = newsViewModel.lostUiState.value assertThat(actual).isEqualTo(expected) } }