diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/SnackBar.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/SnackBar.kt new file mode 100644 index 00000000..59caf1b3 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/SnackBar.kt @@ -0,0 +1,79 @@ +package com.daedan.festabook.presentation.common.component + +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarData +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.daedan.festabook.R +import com.daedan.festabook.data.util.ApiResultException +import com.daedan.festabook.presentation.theme.FestabookColor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.reflect.KClass + +@Composable +fun FestabookSnackbar( + data: SnackbarData, + modifier: Modifier = Modifier, +) { + Snackbar( + modifier = modifier, + snackbarData = data, + actionColor = FestabookColor.accentBlue, + ) +} + +class SnackbarManager( + val hostState: SnackbarHostState, + val scope: CoroutineScope, + private val actionLabel: String, + private val errorMessages: Map, String>, + private val defaultErrorMessage: String, +) { + fun show(message: String) { + hostState.currentSnackbarData?.dismiss() + scope.launch { + hostState.showSnackbar( + message = message, + duration = SnackbarDuration.Short, + actionLabel = actionLabel, + ) + } + } + + fun showError(throwable: Throwable) { + val message = errorMessages[throwable::class] ?: defaultErrorMessage + show(message) + } +} + +@Composable +fun rememberAppSnackbarManager( + snackbarHostState: SnackbarHostState, + scope: CoroutineScope = rememberCoroutineScope(), +): SnackbarManager { + val clientErrorMessage = stringResource(R.string.error_client_exception) + val serverErrorMessage = stringResource(R.string.error_server_exception) + val networkErrorMessage = stringResource(R.string.error_network_exception) + val unknownErrorMessage = stringResource(R.string.error_unknown_exception) + val actionLabel = stringResource(R.string.fail_snackbar_confirm) + + val errorMessages = + remember { + mapOf( + ApiResultException.ClientException::class to clientErrorMessage, + ApiResultException.ServerException::class to serverErrorMessage, + ApiResultException.NetworkException::class to networkErrorMessage, + ApiResultException.UnknownException::class to unknownErrorMessage, + ) + } + + return remember(snackbarHostState, scope) { + SnackbarManager(snackbarHostState, scope, actionLabel, errorMessages, unknownErrorMessage) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt index 0214a9c3..73d6a093 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt @@ -11,7 +11,9 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -37,9 +39,22 @@ fun HomeScreen( viewModel: HomeViewModel, onNavigateToExplore: () -> Unit, modifier: Modifier = Modifier, + onShowErrorSnackbar: (Throwable) -> Unit = {}, // TODO Fragment 제거 시 필수 파라미터로 변경 ) { val festivalUiState by viewModel.festivalUiState.collectAsStateWithLifecycle() val lineupUiState by viewModel.lineupUiState.collectAsStateWithLifecycle() + val currentOnShowErrorSnackbar by rememberUpdatedState(onShowErrorSnackbar) + LaunchedEffect(festivalUiState) { + when (val state = festivalUiState) { + is FestivalUiState.Error -> { + currentOnShowErrorSnackbar(state.throwable) + } + + else -> { + Unit + } + } + } when (val state = festivalUiState) { is FestivalUiState.Loading -> { diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt b/app/src/main/java/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt index 588682c5..5d97d185 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt @@ -13,6 +13,7 @@ import com.daedan.festabook.presentation.main.component.FirstVisitDialog fun NavGraphBuilder.homeNavGraph( viewModel: HomeViewModel, mainViewModel: MainViewModel, + onShowErrorSnackbar: (Throwable) -> Unit, onSubscriptionConfirm: () -> Unit, onNavigateToExplore: () -> Unit, ) { @@ -26,6 +27,7 @@ fun NavGraphBuilder.homeNavGraph( } HomeScreen( viewModel = viewModel, + onShowErrorSnackbar = onShowErrorSnackbar, onNavigateToExplore = onNavigateToExplore, ) } diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/component/MainScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/main/component/MainScreen.kt index 952c828f..005c4c3e 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/main/component/MainScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/main/component/MainScreen.kt @@ -3,16 +3,24 @@ package com.daedan.festabook.presentation.main.component import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost +import com.daedan.festabook.R import com.daedan.festabook.logging.DefaultFirebaseLogger import com.daedan.festabook.presentation.NotificationPermissionManager import com.daedan.festabook.presentation.common.ObserveAsEvents +import com.daedan.festabook.presentation.common.component.FestabookSnackbar +import com.daedan.festabook.presentation.common.component.SnackbarManager +import com.daedan.festabook.presentation.common.component.rememberAppSnackbarManager import com.daedan.festabook.presentation.home.HomeViewModel import com.daedan.festabook.presentation.home.navigation.homeNavGraph import com.daedan.festabook.presentation.main.FestabookMainTab @@ -52,6 +60,10 @@ fun MainScreen( settingViewModel: SettingViewModel = viewModel(), ) { val navigator = rememberFestabookNavigator() + val snackbarHostState = remember { SnackbarHostState() } + val snackbarManager = rememberAppSnackbarManager(snackbarHostState) + val backPressExitMessage = stringResource(R.string.back_press_exit_message) + val noticeEnabledMessage = stringResource(R.string.setting_notice_enabled) ObserveAsEvents(flow = mainViewModel.navigateNewsEvent) { navigator.navigateToMainTab(FestabookMainTab.NEWS) @@ -60,23 +72,22 @@ fun MainScreen( if (isDoublePress) { onAppFinish() } else { - // TODO: SnackBarHost로 변경 -// showToast(getString(R.string.back_press_exit_message)) + snackbarManager.show(backPressExitMessage) } } ObserveAsEvents(flow = homeViewModel.navigateToScheduleEvent) { navigator.navigateToMainTab(FestabookMainTab.SCHEDULE) } - ObserveAsEvents(flow = settingViewModel.success) { - // TODO: SnackBarHost로 변경 -// showSnackBar(getString(R.string.setting_notice_enabled)) - } BackHandler { mainViewModel.onBackPressed() } Scaffold( - // TODO: 스낵바 구현 및 하위 프래그먼트에 해당 SnackBar 적용 + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) { data -> + FestabookSnackbar(data) + } + }, bottomBar = { if (navigator.shouldShowBottomBar) { FestabookBottomNavigationBar( @@ -124,6 +135,7 @@ fun MainScreen( placeMapViewModel = placeMapViewModel, locationSource = locationSource, logger = logger, + onShowErrorSnackBar = snackbarManager::showError, onStartPlaceDetail = { navigator.navigate( FestabookRoute.PlaceDetail( @@ -144,6 +156,7 @@ fun MainScreen( notificationPermissionManager = notificationPermissionManager, onNavigateToExplore = onNavigateToExplore, onSubscriptionConfirm = onSubscriptionConfirm, + snackbarManager = snackbarManager, ) } } @@ -160,6 +173,7 @@ private fun FestabookNavHost( notificationPermissionManager: NotificationPermissionManager, onNavigateToExplore: () -> Unit, onSubscriptionConfirm: () -> Unit, + snackbarManager: SnackbarManager, modifier: Modifier = Modifier, ) { NavHost( @@ -172,23 +186,27 @@ private fun FestabookNavHost( mainViewModel = mainViewModel, onNavigateToExplore = onNavigateToExplore, onSubscriptionConfirm = onSubscriptionConfirm, + onShowErrorSnackbar = snackbarManager::showError, ) scheduleNavGraph( viewModel = scheduleViewModel, + onShowErrorSnackbar = snackbarManager::showError, ) placeMapNavGraph( placeDetailViewModelFactory = placeDetailViewModelFactory, onBackToPreviousClick = { navigator.popBackStack() }, + onShowErrorSnackbar = snackbarManager::showError, ) newsNavGraph( viewModel = newsViewModel, + onShowErrorSnackbar = snackbarManager::showError, ) settingNavGraph( homeViewModel = homeViewModel, settingViewModel = settingViewModel, notificationPermissionManager = notificationPermissionManager, - onShowSnackBar = { }, - onShowErrorSnackBar = { }, + onShowSnackBar = snackbarManager::show, + onShowErrorSnackBar = snackbarManager::showError, ) } } 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 e420ba76..fe382d64 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 @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -16,6 +17,7 @@ import com.daedan.festabook.R 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.faq.FAQUiState import com.daedan.festabook.presentation.news.lost.LostUiState import com.daedan.festabook.presentation.news.notice.NoticeUiState @@ -23,6 +25,7 @@ import com.daedan.festabook.presentation.news.notice.NoticeUiState fun NewsScreen( newsViewModel: NewsViewModel, modifier: Modifier = Modifier, + onShowErrorSnackbar: (Throwable) -> Unit = {}, // TODO Fragment 제거 시 필수 파라미터로 변경 ) { val pageState = rememberPagerState { NewsTab.entries.size } val scope = rememberCoroutineScope() @@ -30,15 +33,50 @@ fun NewsScreen( val noticeUiState by newsViewModel.noticeUiState.collectAsStateWithLifecycle() val lostUiState by newsViewModel.lostUiState.collectAsStateWithLifecycle() val faqUiState by newsViewModel.faqUiState.collectAsStateWithLifecycle() + val currentOnShowErrorSnackbar by rememberUpdatedState(onShowErrorSnackbar) val isNoticeRefreshing = noticeUiState is NoticeUiState.Refreshing val isLostItemRefreshing = lostUiState is LostUiState.Refreshing LaunchedEffect(noticeUiState) { - if (noticeUiState is NoticeUiState.Success) { - pageState.animateScrollToPage(NewsTab.NOTICE.ordinal) + when (val uiState = noticeUiState) { + is NoticeUiState.Success -> { + pageState.animateScrollToPage(NewsTab.NOTICE.ordinal) + } + + is NoticeUiState.Error -> { + currentOnShowErrorSnackbar(uiState.throwable) + } + + else -> { + Unit + } + } + } + LaunchedEffect(lostUiState) { + when (val uiState = lostUiState) { + is LostUiState.Error -> { + currentOnShowErrorSnackbar(uiState.throwable) + } + + else -> { + Unit + } } } + + LaunchedEffect(faqUiState) { + when (val uiState = faqUiState) { + is FAQUiState.Error -> { + currentOnShowErrorSnackbar(uiState.throwable) + } + + else -> { + Unit + } + } + } + Scaffold( topBar = { FestabookTopAppBar(title = stringResource(R.string.news_title)) }, modifier = modifier, diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/navigation/NewsNavigation.kt b/app/src/main/java/com/daedan/festabook/presentation/news/navigation/NewsNavigation.kt index 7e286e41..ee34b28f 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/navigation/NewsNavigation.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/navigation/NewsNavigation.kt @@ -6,10 +6,14 @@ import com.daedan.festabook.presentation.main.MainTabRoute import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.component.NewsScreen -fun NavGraphBuilder.newsNavGraph(viewModel: NewsViewModel) { +fun NavGraphBuilder.newsNavGraph( + viewModel: NewsViewModel, + onShowErrorSnackbar: (Throwable) -> Unit, +) { composable { NewsScreen( newsViewModel = viewModel, + onShowErrorSnackbar = onShowErrorSnackbar, ) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/component/PlaceDetailScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/component/PlaceDetailScreen.kt index 9ec75b47..e4250823 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/component/PlaceDetailScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/component/PlaceDetailScreen.kt @@ -70,6 +70,7 @@ import com.daedan.festabook.presentation.theme.festabookSpacing fun PlaceDetailRoute( viewModel: PlaceDetailViewModel, onBackToPreviousClick: () -> Unit, + onShowErrorSnackbar: (Throwable) -> Unit, modifier: Modifier = Modifier, ) { val placeDetailUiState by viewModel.placeDetail.collectAsStateWithLifecycle() @@ -77,6 +78,7 @@ fun PlaceDetailRoute( modifier = modifier, uiState = placeDetailUiState, onBackToPreviousClick = onBackToPreviousClick, + onShowErrorSnackbar = onShowErrorSnackbar, ) } @@ -85,13 +87,28 @@ fun PlaceDetailScreen( uiState: PlaceDetailUiState, onBackToPreviousClick: () -> Unit, modifier: Modifier = Modifier, + onShowErrorSnackbar: (Throwable) -> Unit = {}, // TODO Fragment 제거 시 필수 파라미터로 변경 ) { val scrollState = rememberScrollState() + val currentOnShowErrorSnackbar by rememberUpdatedState(onShowErrorSnackbar) var isDialogOpen by remember { mutableStateOf(false) } + BackHandler(enabled = !isDialogOpen) { onBackToPreviousClick() } + LaunchedEffect(uiState) { + when (uiState) { + is PlaceDetailUiState.Error -> { + currentOnShowErrorSnackbar(uiState.throwable) + } + + else -> { + Unit + } + } + } + when (uiState) { is PlaceDetailUiState.Success -> { val pagerState = @@ -410,6 +427,7 @@ private fun PlaceDetailScreenPreview() { FestabookTheme { PlaceDetailScreen( onBackToPreviousClick = {}, + onShowErrorSnackbar = {}, uiState = PlaceDetailUiState.Success( placeDetail = diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt index 0608f076..c2b70acc 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt @@ -54,6 +54,7 @@ import timber.log.Timber fun PlaceMapRoute( placeMapViewModel: PlaceMapViewModel, onStartPlaceDetail: (PlaceMapSideEffect.StartPlaceDetail) -> Unit, + onShowErrorSnackBar: (Throwable) -> Unit, locationSource: FusedLocationSource, logger: DefaultFirebaseLogger, modifier: Modifier = Modifier, @@ -92,7 +93,7 @@ fun PlaceMapRoute( places = it.places, ) }, - onShowErrorSnackBar = { }, + onShowErrorSnackBar = { onShowErrorSnackBar(it.error.throwable) }, ) } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt index ac9b9a7b..fac91a7b 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt @@ -26,6 +26,7 @@ import kotlin.reflect.typeOf fun NavGraphBuilder.placeMapNavGraph( onBackToPreviousClick: () -> Unit, placeDetailViewModelFactory: PlaceDetailViewModel.Factory, + onShowErrorSnackbar: (Throwable) -> Unit, ) { composable { } @@ -56,13 +57,12 @@ fun NavGraphBuilder.placeMapNavGraph( PlaceDetailRoute( modifier = Modifier.graphicsLayer( - // compositingStrategy를 사용해 자식들을 하나의 레이어로 강제 통합 compositingStrategy = CompositingStrategy.Offscreen, - // 하드웨어 가속을 통해 애니메이션 도중 레이어가 쪼개지는 것을 방지 clip = true, ), viewModel = viewModel, onBackToPreviousClick = onBackToPreviousClick, + onShowErrorSnackbar = onShowErrorSnackbar, ) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt index adeae6fa..588a2322 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -29,8 +30,10 @@ import timber.log.Timber fun ScheduleScreen( scheduleViewModel: ScheduleViewModel, modifier: Modifier = Modifier, + onShowErrorSnackbar: (Throwable) -> Unit = {}, // TODO Fragment 제거 시 필수 파라미터로 변경 ) { val scheduleUiState by scheduleViewModel.scheduleUiState.collectAsStateWithLifecycle() + val currentOnShowErrorSnackbar by rememberUpdatedState(onShowErrorSnackbar) val currentState = when (scheduleUiState) { is ScheduleUiState.Refreshing -> (scheduleUiState as ScheduleUiState.Refreshing).lastSuccessState @@ -38,6 +41,18 @@ fun ScheduleScreen( else -> scheduleUiState } + LaunchedEffect(currentState) { + when (currentState) { + is ScheduleUiState.Error -> { + currentOnShowErrorSnackbar(currentState.throwable) + } + + else -> { + Unit + } + } + } + Scaffold( topBar = { FestabookTopAppBar(title = stringResource(R.string.schedule_title)) }, modifier = modifier, diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/navigation/ScheduleNavigation.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/navigation/ScheduleNavigation.kt index 1d011fdb..6caad10f 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/navigation/ScheduleNavigation.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/navigation/ScheduleNavigation.kt @@ -6,10 +6,14 @@ import com.daedan.festabook.presentation.main.MainTabRoute import com.daedan.festabook.presentation.schedule.ScheduleViewModel import com.daedan.festabook.presentation.schedule.component.ScheduleScreen -fun NavGraphBuilder.scheduleNavGraph(viewModel: ScheduleViewModel) { +fun NavGraphBuilder.scheduleNavGraph( + viewModel: ScheduleViewModel, + onShowErrorSnackbar: (Throwable) -> Unit, +) { composable { ScheduleScreen( scheduleViewModel = viewModel, + onShowErrorSnackbar = onShowErrorSnackbar, ) } }