diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9416bb39..954d3e29 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -150,6 +150,7 @@ android { dependencies { ktlintRuleset(libs.ktlint) implementation(libs.map.sdk) + implementation(libs.androidx.navigation.compose) implementation(libs.play.services.location) implementation(libs.androidx.core.ktx) implementation(libs.androidx.fragment.ktx) 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 new file mode 100644 index 00000000..13bdf5fb --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt @@ -0,0 +1,24 @@ +package com.daedan.festabook.presentation.home.navigation + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.home.component.HomeScreen +import com.daedan.festabook.presentation.main.MainTabRoute + +fun NavGraphBuilder.homeNavGraph( + padding: PaddingValues, + viewModel: HomeViewModel, + onNavigateToExplore: () -> Unit, +) { + composable { + HomeScreen( + modifier = Modifier.padding(padding), + viewModel = viewModel, + onNavigateToExplore = onNavigateToExplore, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/FestabookMainTab.kt b/app/src/main/java/com/daedan/festabook/presentation/main/FestabookMainTab.kt new file mode 100644 index 00000000..77ab8b65 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/main/FestabookMainTab.kt @@ -0,0 +1,59 @@ +package com.daedan.festabook.presentation.main + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import com.daedan.festabook.R +import com.daedan.festabook.presentation.theme.FestabookColor + +enum class FestabookMainTab( + @param:DrawableRes val iconResId: Int, + @param:StringRes val labelResId: Int, + val route: MainTabRoute, +) { + HOME( + iconResId = R.drawable.ic_home, + labelResId = R.string.menu_home_title, + route = MainTabRoute.Home, + ), + SCHEDULE( + iconResId = R.drawable.ic_schedule, + labelResId = R.string.menu_schedule_title, + route = MainTabRoute.Schedule, + ), + PLACE_MAP( + iconResId = R.drawable.ic_map, + labelResId = R.string.menu_map_title, + route = MainTabRoute.PlaceMap, + ), + NEWS( + iconResId = R.drawable.ic_news, + labelResId = R.string.menu_news_title, + route = MainTabRoute.News, + ), + SETTING( + iconResId = R.drawable.ic_setting, + labelResId = R.string.menu_setting_title, + route = MainTabRoute.Setting, + ), + ; + + companion object Defaults { + @Composable + fun find(predicate: @Composable (FestabookRoute) -> Boolean) = + entries.find { + predicate(it.route) + } + + val selectedColor + @Composable + @ReadOnlyComposable + get() = FestabookColor.black + + val unselectedColor + @Composable + @ReadOnlyComposable + get() = FestabookColor.gray400 + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/FestabookNavigator.kt b/app/src/main/java/com/daedan/festabook/presentation/main/FestabookNavigator.kt new file mode 100644 index 00000000..7e5101a7 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/main/FestabookNavigator.kt @@ -0,0 +1,84 @@ +package com.daedan.festabook.presentation.main + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navOptions + +class FestabookNavigator( + val navController: NavHostController, +) { + private val currentDestination + @Composable + get() = + navController + .currentBackStackEntryAsState() + .value + ?.destination + + private val defaultNavOptions = + navOptions { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + + val currentTab + @Composable + get() = + FestabookMainTab.find { + currentDestination?.hasRoute(it::class) ?: false + } + + val shouldShowBottomBar + @Composable + get() = + FestabookMainTab.find { + currentDestination?.hasRoute(it::class) ?: false + } != null + + val startRoute = MainTabRoute.Home // TODO: Splash와 Explore 연동 시 변경 + + fun navigateToMainTab(route: FestabookRoute) { + navController.navigate( + route, + defaultNavOptions, + ) + } + + fun navigate( + route: FestabookRoute, + navOptions: NavOptions? = null, + ) { + navController.navigate( + route, + navOptions, + ) + } + + fun popBackStack() { + navController.popBackStack() + } + + fun popBackStack( + route: FestabookRoute, + inclusive: Boolean = false, + ) { + navController.popBackStack(route, inclusive) + } +} + +@Composable +fun rememberFestabookNavigator(): FestabookNavigator { + val navController = rememberNavController() + return remember { + FestabookNavigator(navController = navController) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/FestabookRoute.kt b/app/src/main/java/com/daedan/festabook/presentation/main/FestabookRoute.kt new file mode 100644 index 00000000..e9eac87f --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/main/FestabookRoute.kt @@ -0,0 +1,34 @@ +package com.daedan.festabook.presentation.main + +import kotlinx.serialization.Serializable + +@Serializable +sealed interface FestabookRoute { + @Serializable + data object Splash : FestabookRoute + + // TODO: PlaceUiModel, PlaceDetailUiModel 생성자에 추가 후 UiModel에 @Serializable 어노테이션 필요 + @Serializable + data object PlaceDetail : FestabookRoute + + @Serializable + data object Explore : FestabookRoute +} + +@Serializable +sealed interface MainTabRoute : FestabookRoute { + @Serializable + data object Home : MainTabRoute + + @Serializable + data object Schedule : MainTabRoute + + @Serializable + data object PlaceMap : MainTabRoute + + @Serializable + data object News : MainTabRoute + + @Serializable + data object Setting : MainTabRoute +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/component/FestabookBottomNavigationBar.kt b/app/src/main/java/com/daedan/festabook/presentation/main/component/FestabookBottomNavigationBar.kt new file mode 100644 index 00000000..740e5aa0 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/main/component/FestabookBottomNavigationBar.kt @@ -0,0 +1,150 @@ +package com.daedan.festabook.presentation.main.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.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.main.FestabookMainTab +import com.daedan.festabook.presentation.main.FestabookMainTab.Defaults.selectedColor +import com.daedan.festabook.presentation.main.FestabookMainTab.Defaults.unselectedColor +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.festabookSpacing + +@Composable +fun FestabookBottomNavigationBar( + currentTab: FestabookMainTab?, + onTabSelect: (FestabookMainTab) -> Unit, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.BottomCenter, + modifier = modifier, + ) { + Row( + modifier = + Modifier + .background(color = FestabookColor.white) + .fillMaxWidth() + .height(70.dp), + ) { + FestabookMainTab.entries.forEach { item -> + when (item) { + FestabookMainTab.PLACE_MAP -> Spacer(modifier = Modifier.weight(1f)) + else -> { + FestabookNavigationItem( + tab = item, + selected = item == currentTab, + onClick = onTabSelect, + ) + } + } + } + } + PlaceMapNavigationItem(onClick = onTabSelect) + } +} + +@Composable +private fun RowScope.FestabookNavigationItem( + onClick: (FestabookMainTab) -> Unit, + tab: FestabookMainTab, + selected: Boolean, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .weight(1f) + .fillMaxHeight() + .background(color = FestabookColor.white) + .selectable( + selected = selected, + onClick = { onClick(tab) }, + interactionSource = remember { MutableInteractionSource() }, + indication = null, // 리플(Ripple) 완벽 제거 + ), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + painter = painterResource(id = tab.iconResId), + contentDescription = stringResource(tab.labelResId), + tint = if (selected) selectedColor else unselectedColor, + ) + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody1)) + Text( + text = stringResource(tab.labelResId), + style = FestabookTypography.labelMedium, + color = if (selected) selectedColor else unselectedColor, + ) + } + } +} + +@Composable +private fun PlaceMapNavigationItem( + onClick: (FestabookMainTab) -> Unit, + modifier: Modifier = Modifier, +) { + Image( + modifier = + modifier + .offset(y = -festabookSpacing.paddingBody4) + .clickable( + onClick = { + onClick(FestabookMainTab.PLACE_MAP) + }, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ), + painter = painterResource(id = R.drawable.btn_fab_manu), + contentDescription = stringResource(FestabookMainTab.PLACE_MAP.labelResId), + ) +} + +@Preview(showBackground = true) +@Composable +private fun FestabookBottomNavigationBarPreview() { + FestabookTheme { + var currentTabState by remember { mutableStateOf(FestabookMainTab.HOME) } + Scaffold( + bottomBar = { + FestabookBottomNavigationBar( + currentTab = currentTabState, + onTabSelect = { currentTabState = it }, + ) + }, + ) { it } + } +} 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 new file mode 100644 index 00000000..8c3c7869 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/main/component/MainScreen.kt @@ -0,0 +1,48 @@ +package com.daedan.festabook.presentation.main.component + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.compose.NavHost +import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.home.navigation.homeNavGraph +import com.daedan.festabook.presentation.main.FestabookRoute +import com.daedan.festabook.presentation.main.rememberFestabookNavigator + +@Composable +fun MainScreen( + homeViewModel: HomeViewModel, + modifier: Modifier = Modifier, +) { + val navigator = rememberFestabookNavigator() + + Scaffold( + // TODO: 스낵바 구현 및 하위 프래그먼트에 해당 SnackBar 적용 + bottomBar = { + if (navigator.shouldShowBottomBar) { + FestabookBottomNavigationBar( + currentTab = navigator.currentTab, + onTabSelect = { navigator.navigateToMainTab(it.route) }, + ) + } + }, + modifier = modifier, + ) { innerPadding -> + NavHost( + modifier = Modifier.fillMaxSize(), + startDestination = navigator.startRoute, + navController = navigator.navController, + ) { + homeNavGraph( + padding = innerPadding, + viewModel = homeViewModel, + onNavigateToExplore = { navigator.navigate(FestabookRoute.Explore) }, + ) + + // TODO: 각 화면에서 나머지 graph들 정의 + // 만약 Fragment에서 Screen에 넣어줄 부가적인 작업 시 각 화면에서 Route 컴포저블로 감싸서 전달 요망 + // 참고:https://github.com/Project-Unifest/unifest-android/blob/develop/feature/home/src/main/kotlin/com/unifest/android/feature/home/HomeScreen.kt + } + } +} diff --git a/app/src/test/java/com/daedan/festabook/FlowExtension.kt b/app/src/test/java/com/daedan/festabook/FlowExtension.kt deleted file mode 100644 index 16f9d7bf..00000000 --- a/app/src/test/java/com/daedan/festabook/FlowExtension.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.daedan.festabook - -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.timeout -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.withTimeout -import kotlin.time.Duration.Companion.seconds - -@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) -fun TestScope.observeEvent(flow: Flow): Deferred { - val event = - backgroundScope.async { - withTimeout(3000) { - flow.first() - } - } - advanceUntilIdle() - return event -} - -@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) -fun TestScope.observeMultipleEvent( - flow: Flow, - result: MutableList, -) { - backgroundScope.launch(UnconfinedTestDispatcher()) { - flow - .timeout(3.seconds) - .collect { - result.add(it) - } - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2f9f357f..f5aebc83 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ material = "1.12.0" activity = "1.10.1" constraintlayout = "2.2.1" mockk = "1.14.5" +navigationCompose = "2.9.6" photoviewDialog = "1.0.3" playServicesLocation = "21.3.0" retrofit = "3.0.0" @@ -48,6 +49,7 @@ activityCompose = "1.11.0" androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swiperefreshlayout" } app-update-ktx = { module = "com.google.android.play:app-update-ktx", version.ref = "appUpdateKtx" } assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertjCore" }