From 8c715fdead3d8eddca09c38b910bce36682a3dc1 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Wed, 21 Jan 2026 15:19:46 +0900 Subject: [PATCH 01/20] =?UTF-8?q?refactor(main):=20=ED=95=98=EB=8B=A8=20?= =?UTF-8?q?=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=EC=9D=84=20Compos?= =?UTF-8?q?e=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 XML 기반의 `BottomNavigationView`와 `FloatingActionButton`으로 구현되었던 메인 화면의 하단 네비게이션 UI를 Jetpack Compose 기반으로 전면 교체했습니다. 이로 인해 XML 레이아웃과 관련된 코드들이 제거되고, Compose 기반의 상태 관리 로직으로 변경되었습니다. - **`MainActivity.kt` 수정:** - `BottomNavigationView`와 관련된 기존 리스너(`setOnItemSelectedListener`, `setOnItemReselectedListener`) 및 클릭 핸들러 로직을 제거했습니다. - `ComposeView`를 추가하고 `FestabookBottomNavigationBar` 컴포저블을 설정했습니다. - 탭 선택 상태를 관리하기 위해 `MutableState`(`currentTabState`)를 도입하고, 탭 선택 시 `Fragment`를 전환하는 로직을 Compose 콜백 내에서 처리하도록 변경했습니다. - `handleNavigation`, `navigateToScheduleEvent` 등 네비게이션 관련 로직을 `currentTabState`를 변경하도록 수정했습니다. - **`activity_main.xml` 수정:** - 기존의 `CoordinatorLayout`, `BottomAppBar`, `BottomNavigationView`, `FloatingActionButton` 뷰들을 주석 처리하고, `FragmentContainerView`와 하단 네비게이션을 위한 `ComposeView`(`cv_main`)만 남겨두었습니다. - **`PermissionUtil.kt` & `FragmentUtil.kt` 수정:** - `Snackbar`의 `anchorView`를 기존 `bab_menu` ID에서 새로운 ComposeView의 ID인 `cv_main`으로 변경하여, 스낵바가 새로운 하단 네비게이션 바 위에 올바르게 표시되도록 수정했습니다. --- .../presentation/common/FragmentUtil.kt | 6 +- .../presentation/common/PermissionUtil.kt | 2 +- .../presentation/main/MainActivity.kt | 160 ++++++++---------- app/src/main/res/layout/activity_main.xml | 91 +++++----- 4 files changed, 126 insertions(+), 133 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/FragmentUtil.kt b/app/src/main/java/com/daedan/festabook/presentation/common/FragmentUtil.kt index b487a170..38fd1c1d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/FragmentUtil.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/FragmentUtil.kt @@ -8,11 +8,11 @@ import android.os.Parcelable import android.util.TypedValue import android.view.View import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.Fragment import com.daedan.festabook.R import com.daedan.festabook.data.util.ApiResultException -import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.snackbar.Snackbar import java.io.Serializable @@ -63,7 +63,7 @@ fun Activity.showErrorSnackBar(msg: String) { Snackbar.LENGTH_SHORT, ) snackBar.setAnchorView( - findViewById(R.id.bab_menu), + findViewById(R.id.cv_main), ) snackBar .setAction( @@ -116,7 +116,7 @@ fun Activity.showSnackBar(msg: String) { Snackbar.LENGTH_SHORT, ) snackBar.setAnchorView( - findViewById(R.id.bab_menu), + findViewById(R.id.cv_main), ) snackBar .setAction( diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/PermissionUtil.kt b/app/src/main/java/com/daedan/festabook/presentation/common/PermissionUtil.kt index 7403fff8..4972b425 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/PermissionUtil.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/PermissionUtil.kt @@ -20,7 +20,7 @@ fun showNotificationDeniedSnackbar( view, text, Snackbar.LENGTH_LONG, - ).setAnchorView(view.rootView.findViewById(R.id.bab_menu)) + ).setAnchorView(view.rootView.findViewById(R.id.cv_main)) .setAction(context.getString(R.string.move_to_setting_text)) { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { 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 528af417..7638273d 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 @@ -10,10 +10,12 @@ import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat -import androidx.core.view.marginBottom -import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentFactory import androidx.fragment.app.add @@ -35,12 +37,14 @@ import com.daedan.festabook.presentation.common.showSnackBar 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.main.component.FestabookBottomNavigationBar 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 import com.daedan.festabook.presentation.setting.SettingViewModel +import com.daedan.festabook.presentation.theme.FestabookTheme import com.google.android.material.dialog.MaterialAlertDialogBuilder import dev.zacsweers.metro.Inject import kotlinx.coroutines.launch @@ -61,6 +65,8 @@ class MainActivity : ActivityMainBinding.inflate(layoutInflater) } + private lateinit var currentTabState: MutableState + private val mainViewModel: MainViewModel by viewModels() private val homeViewModel: HomeViewModel by viewModels() private val newsViewModel: NewsViewModel by viewModels() @@ -100,14 +106,71 @@ class MainActivity : super.onCreate(savedInstanceState) enableEdgeToEdge() setupBinding() + + binding.cvMain.setContent { + currentTabState = remember { mutableStateOf(FestabookMainTab.HOME) } + LaunchedEffect(Unit) { + handleNavigation(intent) + } + FestabookTheme { + FestabookBottomNavigationBar( + currentTab = currentTabState.value, + onTabSelect = { tab -> + when (tab) { + FestabookMainTab.HOME -> { + currentTabState.value = FestabookMainTab.HOME + switchFragment(HomeFragment::class.java, TAG_HOME_FRAGMENT) + } + + FestabookMainTab.SCHEDULE -> { + currentTabState.value = FestabookMainTab.SCHEDULE + val fragment = + supportFragmentManager.findFragmentByTag(TAG_SCHEDULE_FRAGMENT) + if (fragment is OnMenuItemReClickListener && !fragment.isHidden) fragment.onMenuItemReClick() + switchFragment( + ScheduleFragment::class.java, + TAG_SCHEDULE_FRAGMENT, + ) + } + + FestabookMainTab.PLACE_MAP -> { + currentTabState.value = FestabookMainTab.PLACE_MAP + val fragment = + supportFragmentManager.findFragmentByTag(TAG_PLACE_MAP_FRAGMENT) + if (fragment is OnMenuItemReClickListener && !fragment.isHidden) fragment.onMenuItemReClick() + switchFragment(PlaceMapFragment::class.java, TAG_PLACE_MAP_FRAGMENT) + } + + FestabookMainTab.NEWS -> { + currentTabState.value = FestabookMainTab.NEWS + switchFragment(NewsFragment::class.java, TAG_NEWS_FRAGMENT) + } + + FestabookMainTab.SETTING -> { + currentTabState.value = FestabookMainTab.SETTING + switchFragment( + SettingFragment::class.java, + TAG_SETTING_FRAGMENT, + ) + } + } + }, + ) + } + } mainViewModel.registerDeviceAndFcmToken() setupHomeFragment(savedInstanceState) - setUpBottomNavigation() setupObservers() - onMenuItemClick() - onMenuItemReClick() onBackPress() - handleNavigation(intent) + } + + private fun setupBinding() { + setContentView(binding.root) + ViewCompat.setOnApplyWindowInsetsListener(binding.main) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } } private fun setupFragmentFactory() { @@ -152,7 +215,7 @@ class MainActivity : if (noticeIdToExpand != INITIALIZED_ID) newsViewModel.expandNotice(noticeIdToExpand) if (canNavigateToNewsScreen) { - binding.bnvMenu.selectedItemId = R.id.item_menu_news + currentTabState.value = FestabookMainTab.NEWS } } @@ -166,9 +229,7 @@ class MainActivity : lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { homeViewModel.navigateToScheduleEvent.collect { - if (binding.bnvMenu.selectedItemId != R.id.item_menu_schedule) { - binding.bnvMenu.selectedItemId = R.id.item_menu_schedule - } + currentTabState.value = FestabookMainTab.SCHEDULE } } } @@ -183,27 +244,6 @@ class MainActivity : } } - private fun setupBinding() { - setContentView(binding.root) - ViewCompat.setOnApplyWindowInsetsListener(binding.main) { v, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - insets - } - } - - private fun setUpBottomNavigation() { - binding.fabMap.post { - binding.fcvFragmentContainer.updatePadding( - bottom = binding.babMenu.height + binding.babMenu.marginBottom, - ) - } - binding.babMenu.setOnApplyWindowInsetsListener(null) - binding.babMenu.setPadding(0, 0, 0, 0) - binding.bnvMenu.setOnApplyWindowInsetsListener(null) - binding.bnvMenu.setPadding(0, 0, 0, 0) - } - private fun setupHomeFragment(savedInstanceState: Bundle?) { if (savedInstanceState == null) { supportFragmentManager.commitNow { @@ -223,64 +263,6 @@ class MainActivity : ) } - private fun onMenuItemClick() { - binding.bnvMenu.setOnItemSelectedListener { icon -> - when (icon.itemId) { - R.id.item_menu_home -> { - switchFragment(HomeFragment::class.java, TAG_HOME_FRAGMENT) - } - - R.id.item_menu_schedule -> { - switchFragment( - ScheduleFragment::class.java, - TAG_SCHEDULE_FRAGMENT, - ) - } - - R.id.item_menu_news -> { - switchFragment(NewsFragment::class.java, TAG_NEWS_FRAGMENT) - } - - R.id.item_menu_setting -> { - switchFragment( - SettingFragment::class.java, - TAG_SETTING_FRAGMENT, - ) - } - } - true - } - binding.fabMap.setOnClickListener { - binding.bnvMenu.selectedItemId = R.id.item_menu_map - val fragment = supportFragmentManager.findFragmentByTag(TAG_PLACE_MAP_FRAGMENT) - if (fragment is OnMenuItemReClickListener && !fragment.isHidden) fragment.onMenuItemReClick() - switchFragment(PlaceMapFragment::class.java, TAG_PLACE_MAP_FRAGMENT) - } - } - - private fun onMenuItemReClick() { - binding.bnvMenu.setOnItemReselectedListener { icon -> - when (icon.itemId) { - R.id.item_menu_home -> { - Unit - } - - R.id.item_menu_schedule -> { - val fragment = supportFragmentManager.findFragmentByTag(TAG_SCHEDULE_FRAGMENT) - if (fragment is OnMenuItemReClickListener) fragment.onMenuItemReClick() - } - - R.id.item_menu_news -> { - Unit - } - - R.id.item_menu_setting -> { - Unit - } - } - } - } - private fun switchFragment( fragment: Class, tag: String, diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index bb9dc516..f34945fb 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,56 +1,67 @@ + xmlns:tools="http://schemas.android.com/tools" + xmlns:app="http://schemas.android.com/apk/res-auto"> - + android:layout_height="0dp" /> - + - - + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + - + From 220a5873db6b50a151e02a7f003522b0de219044 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Wed, 21 Jan 2026 21:11:18 +0900 Subject: [PATCH 02/20] =?UTF-8?q?refactor(Navigation):=20=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=ED=83=AD=20=ED=99=94=EB=A9=B4=EB=B3=84=20Navigatio?= =?UTF-8?q?n=20Graph=20=EB=AA=A8=EB=93=88=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메인 화면의 각 탭(소식, 장소, 일정, 설정)에 해당하는 `NavGraph`를 별도의 파일로 분리하여 모듈화했습니다. 이 리팩토링을 통해 `NavHost`의 구조를 더 깔끔하게 관리하고, 각 화면의 네비게이션 관련 로직을 독립적으로 구성할 수 있도록 개선했습니다. - **`newsNavGraph` 추가:** `NewsScreen`을 `MainTabRoute.News`와 연결하는 네비게이션 그래프를 정의했습니다. - **`placeMapNavGraph` 추가:** `PlaceMapRoute`를 `MainTabRoute.PlaceMap`과 연결하는 네비게이션 그래프를 정의했습니다. - **`scheduleNavGraph` 추가:** `ScheduleScreen`을 `MainTabRoute.Schedule`과 연결하는 네비게이션 그래프를 정의했습니다. - **`settingNavGraph` 추가:** `SettingRoute`를 `MainTabRoute.Setting`과 연결하는 네비게이션 그래프를 정의했습니다. --- .../news/navigation/NewsNavigation.kt | 22 ++++++ .../placeMap/component/PlaceMapScreen.kt | 34 +++++++++ .../placeMap/navigation/PlaceMapNavigation.kt | 69 +++++++++++++++++++ .../schedule/navigation/ScheduleNavigation.kt | 22 ++++++ .../setting/component/SettingScreen.kt | 64 ++++++++++++++++- .../setting/navigation/SettingNavigation.kt | 32 +++++++++ 6 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/news/navigation/NewsNavigation.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/schedule/navigation/ScheduleNavigation.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt 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 new file mode 100644 index 00000000..b8f8b05a --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/news/navigation/NewsNavigation.kt @@ -0,0 +1,22 @@ +package com.daedan.festabook.presentation.news.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.main.MainTabRoute +import com.daedan.festabook.presentation.news.NewsViewModel +import com.daedan.festabook.presentation.news.component.NewsScreen + +fun NavGraphBuilder.newsNavGraph( + padding: PaddingValues, + viewModel: NewsViewModel, +) { + composable { + NewsScreen( + modifier = Modifier.padding(padding), + newsViewModel = viewModel, + ) + } +} 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 cc93e1ae..e0112738 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 @@ -7,19 +7,53 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.presentation.common.ObserveAsEvents +import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel import com.daedan.festabook.presentation.placeMap.intent.event.FilterEvent import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent import com.daedan.festabook.presentation.placeMap.intent.event.PlaceMapEvent import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent +import com.daedan.festabook.presentation.placeMap.intent.handler.MapControlSideEffectHandler +import com.daedan.festabook.presentation.placeMap.intent.handler.PlaceMapSideEffectHandler import com.daedan.festabook.presentation.placeMap.intent.state.LoadState import com.daedan.festabook.presentation.placeMap.intent.state.MapDelegate import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.festabookSpacing +@Composable +fun PlaceMapRoute( + placeMapViewModel: PlaceMapViewModel, + mapDelegate: MapDelegate, + mapControlSideEffectHandler: MapControlSideEffectHandler, + placeMapSideEffectHandler: PlaceMapSideEffectHandler, + modifier: Modifier = Modifier, +) { + val uiState by placeMapViewModel.uiState.collectAsStateWithLifecycle() + val bottomSheetState = rememberPlaceListBottomSheetState() + + ObserveAsEvents(flow = placeMapViewModel.mapControlSideEffect) { event -> + mapControlSideEffectHandler(event) + } + + ObserveAsEvents(flow = placeMapViewModel.placeMapSideEffect) { event -> + placeMapSideEffectHandler(event) + } + + PlaceMapScreen( + uiState = uiState, + modifier = modifier, + onEvent = { placeMapViewModel.onPlaceMapEvent(it) }, + bottomSheetState = bottomSheetState, + mapDelegate = mapDelegate, + ) +} + @Composable fun PlaceMapScreen( uiState: PlaceMapUiState, 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 new file mode 100644 index 00000000..90b682d5 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt @@ -0,0 +1,69 @@ +package com.daedan.festabook.presentation.placeMap.navigation + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.daedan.festabook.logging.DefaultFirebaseLogger +import com.daedan.festabook.presentation.main.MainTabRoute +import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.component.PlaceMapRoute +import com.daedan.festabook.presentation.placeMap.component.rememberPlaceListBottomSheetState +import com.daedan.festabook.presentation.placeMap.intent.handler.MapControlSideEffectHandler +import com.daedan.festabook.presentation.placeMap.intent.handler.PlaceMapSideEffectHandler +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.MapDelegate +import com.daedan.festabook.presentation.placeMap.intent.state.MapManagerDelegate +import com.naver.maps.map.util.FusedLocationSource + +fun NavGraphBuilder.placeMapNavGraph( + padding: PaddingValues, + placeMapViewModel: PlaceMapViewModel, + logger: DefaultFirebaseLogger, + locationSource: FusedLocationSource, + onStartPlaceDetail: (PlaceMapSideEffect.StartPlaceDetail) -> Unit, + onPreloadImages: (PlaceMapSideEffect.PreloadImages) -> Unit, + onShowErrorSnackBar: (PlaceMapSideEffect.ShowErrorSnackBar) -> Unit, +) { + composable { + val density = LocalDensity.current + val bottomSheetState = rememberPlaceListBottomSheetState() + val mapDelegate = remember { MapDelegate() } + val mapManagerDelegate = remember { MapManagerDelegate() } + val mapControlSideEffectHandler = + remember { + MapControlSideEffectHandler( + initialPadding = with(density) { 254.dp.toPx() }.toInt(), + logger = logger, + locationSource = locationSource, + viewModel = placeMapViewModel, + mapDelegate = mapDelegate, + mapManagerDelegate = mapManagerDelegate, + ) + } + val placeMapSideEffectHandler = + remember { + PlaceMapSideEffectHandler( + mapManagerDelegate = mapManagerDelegate, + bottomSheetState = bottomSheetState, + viewModel = placeMapViewModel, + logger = logger, + onStartPlaceDetail = onStartPlaceDetail, + onPreloadImages = onPreloadImages, + onShowErrorSnackBar = onShowErrorSnackBar, + ) + } + + PlaceMapRoute( + modifier = Modifier.padding(padding), + placeMapViewModel = placeMapViewModel, + mapControlSideEffectHandler = mapControlSideEffectHandler, + placeMapSideEffectHandler = placeMapSideEffectHandler, + mapDelegate = mapDelegate, + ) + } +} 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 new file mode 100644 index 00000000..d1a3ef92 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/navigation/ScheduleNavigation.kt @@ -0,0 +1,22 @@ +package com.daedan.festabook.presentation.schedule.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.main.MainTabRoute +import com.daedan.festabook.presentation.schedule.ScheduleViewModel +import com.daedan.festabook.presentation.schedule.component.ScheduleScreen + +fun NavGraphBuilder.scheduleNavGraph( + padding: PaddingValues, + viewModel: ScheduleViewModel, +) { + composable { + ScheduleScreen( + modifier = Modifier.padding(padding), + scheduleViewModel = viewModel, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/component/SettingScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/component/SettingScreen.kt index 202f3120..ca077bdf 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/setting/component/SettingScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/component/SettingScreen.kt @@ -24,25 +24,85 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.BuildConfig import com.daedan.festabook.R import com.daedan.festabook.domain.model.Festival import com.daedan.festabook.domain.model.Organization +import com.daedan.festabook.presentation.NotificationPermissionManager +import com.daedan.festabook.presentation.common.ObserveAsEvents import com.daedan.festabook.presentation.common.component.FestabookSwitch import com.daedan.festabook.presentation.common.component.FestabookTopAppBar +import com.daedan.festabook.presentation.home.HomeViewModel import com.daedan.festabook.presentation.home.adapter.FestivalUiState +import com.daedan.festabook.presentation.setting.SettingViewModel 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 +import timber.log.Timber import java.time.LocalDate +private const val POLICY_URL: String = + "https://www.notion.so/244a540dc0b780638e56e31c4bdb3c9f" + +private const val CONTACT_US_URL = + "https://forms.gle/XjqJFfQrTPgkZzGZ9" + +@Composable +fun SettingRoute( + homeViewModel: HomeViewModel, + settingViewModel: SettingViewModel, + notificationPermissionManager: NotificationPermissionManager, + onShowSnackBar: (String) -> Unit, + onShowErrorSnackBar: (Throwable) -> Unit, + modifier: Modifier = Modifier, +) { + val festival by homeViewModel.festivalUiState.collectAsStateWithLifecycle() + val isUniversitySubscribed by settingViewModel.isAllowed.collectAsStateWithLifecycle() + val isSubscribedLoading by settingViewModel.isLoading.collectAsStateWithLifecycle() + val context = LocalContext.current + val uriHandler = LocalUriHandler.current + + ObserveAsEvents(flow = settingViewModel.permissionCheckEvent) { + notificationPermissionManager.requestNotificationPermission(context) + } + + ObserveAsEvents(flow = settingViewModel.successFlow) { + onShowSnackBar(context.getString(R.string.setting_notice_enabled)) + } + + ObserveAsEvents(flow = settingViewModel.error) { + onShowErrorSnackBar(it) + } + SettingScreen( + modifier = modifier, + festivalUiState = festival, + isUniversitySubscribed = isUniversitySubscribed, + appVersion = BuildConfig.VERSION_NAME, + isSubscribeEnabled = !isSubscribedLoading, + onSubscribeClick = { settingViewModel.notificationAllowClick() }, + onPolicyClick = { uriHandler.openUri(POLICY_URL) }, + onContactUsClick = { uriHandler.openUri(CONTACT_US_URL) }, + onError = { + onShowErrorSnackBar(it.throwable) + Timber.w( + it.throwable, + "${"SettingRoute"}: ${it.throwable.message}", + ) + }, + ) +} + @Composable fun SettingScreen( festivalUiState: FestivalUiState, @@ -99,7 +159,9 @@ fun SettingScreen( ) } - else -> Unit + else -> { + Unit + } } HorizontalDivider( diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt new file mode 100644 index 00000000..67de108a --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt @@ -0,0 +1,32 @@ +package com.daedan.festabook.presentation.setting.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.NotificationPermissionManager +import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.main.MainTabRoute +import com.daedan.festabook.presentation.setting.SettingViewModel +import com.daedan.festabook.presentation.setting.component.SettingRoute + +fun NavGraphBuilder.settingNavGraph( + padding: PaddingValues, + homeViewModel: HomeViewModel, + settingViewModel: SettingViewModel, + notificationPermissionManager: NotificationPermissionManager, + onShowSnackBar: (String) -> Unit, + onShowErrorSnackBar: (Throwable) -> Unit, +) { + composable { + SettingRoute( + modifier = Modifier.padding(padding), + homeViewModel = homeViewModel, + settingViewModel = settingViewModel, + notificationPermissionManager = notificationPermissionManager, + onShowSnackBar = onShowSnackBar, + onShowErrorSnackBar = onShowErrorSnackBar, + ) + } +} From 06ccf08d3f5b80d9baa3f467a9e64e99f3026ce0 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Wed, 21 Jan 2026 21:11:49 +0900 Subject: [PATCH 03/20] =?UTF-8?q?refactor(Main):=20`currentDestination`=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=95=88=EC=A0=95=EC=84=B1=20=EA=B0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FestabookNavigator`에서 현재 목적지(`currentDestination`)를 찾는 로직을 수정하여 안정성을 높였습니다. - **`FestabookNavigator.kt` 수정:** - 기존 `currentBackStackEntryAsState` 대신 `visibleEntries.collectAsState()`를 사용하여 현재 화면에 보이는 `NavEntry` 목록을 수집하도록 변경했습니다. - `lastOrNull`을 이용해 이 목록에서 유효한 `route`를 가진 마지막 `destination`을 찾도록 하여, 화면 전환 중 발생할 수 있는 잠재적 문제를 방지했습니다. - `navigateToMainTab` 함수 내에서 사용되던 `defaultNavOptions`를 인라인 `navOptions` 블록으로 대체했습니다. --- .../presentation/main/FestabookNavigator.kt | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) 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 index 7e5101a7..18e45ba7 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/main/FestabookNavigator.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/main/FestabookNavigator.kt @@ -1,12 +1,12 @@ package com.daedan.festabook.presentation.main import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState 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 @@ -17,19 +17,11 @@ class FestabookNavigator( @Composable get() = navController - .currentBackStackEntryAsState() + .visibleEntries + .collectAsState() .value + .lastOrNull { it.destination.route != null } ?.destination - - private val defaultNavOptions = - navOptions { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - val currentTab @Composable get() = @@ -49,7 +41,13 @@ class FestabookNavigator( fun navigateToMainTab(route: FestabookRoute) { navController.navigate( route, - defaultNavOptions, + navOptions { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + }, ) } From 1faefccba8ec487fbb9505282235da80c293e247 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Wed, 21 Jan 2026 21:12:16 +0900 Subject: [PATCH 04/20] =?UTF-8?q?refactor(main):=20MainActivity=EB=A5=BC?= =?UTF-8?q?=20Compose=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 `MainActivity`에서 Fragment와 Compose를 혼용하던 구조를 완전히 Compose 기반으로 전환했습니다. 이로 인해 Fragment 관련 코드가 대부분 제거되고, 모든 화면이 `NavHost`를 통해 관리됩니다. - **`MainActivity.kt` 수정:** - `setContent`를 `Activity`의 최상위 컨텐츠로 설정하고, 모든 UI를 `MainScreen` 컴포저블로 대체했습니다. - Fragment를 전환하던 로직(`switchFragment`)과 관련 태그(`TAG_HOME_FRAGMENT` 등)를 모두 삭제했습니다. - `setupBinding`, `setupHomeFragment` 등 기존 View 시스템과 Fragment 초기화 관련 코드를 제거했습니다. - 지도 화면에 필요한 `FusedLocationSource`를 초기화하는 로직을 추가했습니다. - 장소 목록 이미지 사전 로딩을 위한 `preloadImages` 함수를 추가했습니다. - **`main/component/MainScreen.kt` 수정:** - `NavHost`에 모든 탭(`Home`, `Schedule`, `PlaceMap`, `News`, `Setting`)에 대한 `NavGraph`를 등록했습니다. - 각 화면에 필요한 ViewModel과 의존성(`logger`, `locationSource`, `notificationPermissionManager` 등)을 주입하도록 구조를 변경했습니다. - 화면 간 이동(예: `onNavigateToExplore`)과 이미지 프리로딩(`onPreloadImages`) 콜백을 정의하여 `MainActivity`에서 처리할 수 있도록 했습니다. --- .../presentation/main/MainActivity.kt | 176 ++++++++++++------ .../presentation/main/component/MainScreen.kt | 57 +++++- 2 files changed, 163 insertions(+), 70 deletions(-) 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 7638273d..928e3f44 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 @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts @@ -14,8 +15,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentFactory import androidx.fragment.app.add @@ -25,29 +24,36 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.request.ImageResult import com.daedan.festabook.R import com.daedan.festabook.databinding.ActivityMainBinding import com.daedan.festabook.di.appGraph import com.daedan.festabook.presentation.NotificationPermissionManager import com.daedan.festabook.presentation.NotificationPermissionRequester -import com.daedan.festabook.presentation.common.OnMenuItemReClickListener +import com.daedan.festabook.presentation.common.convertImageUrl import com.daedan.festabook.presentation.common.isGranted import com.daedan.festabook.presentation.common.showNotificationDeniedSnackbar import com.daedan.festabook.presentation.common.showSnackBar import com.daedan.festabook.presentation.common.showToast +import com.daedan.festabook.presentation.explore.ExploreActivity import com.daedan.festabook.presentation.home.HomeFragment import com.daedan.festabook.presentation.home.HomeViewModel -import com.daedan.festabook.presentation.main.component.FestabookBottomNavigationBar -import com.daedan.festabook.presentation.news.NewsFragment +import com.daedan.festabook.presentation.main.component.MainScreen 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 +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.setting.SettingViewModel import com.daedan.festabook.presentation.theme.FestabookTheme import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.naver.maps.map.util.FusedLocationSource import dev.zacsweers.metro.Inject +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import timber.log.Timber class MainActivity : @@ -80,6 +86,10 @@ class MainActivity : ) } + private val locationSource by lazy { + FusedLocationSource(this, LOCATION_PERMISSION_REQUEST_CODE) + } + override val permissionLauncher: ActivityResultLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission(), @@ -105,72 +115,81 @@ class MainActivity : setupFragmentFactory() super.onCreate(savedInstanceState) enableEdgeToEdge() - setupBinding() +// setupBinding() - binding.cvMain.setContent { + setContent { currentTabState = remember { mutableStateOf(FestabookMainTab.HOME) } LaunchedEffect(Unit) { handleNavigation(intent) } FestabookTheme { - FestabookBottomNavigationBar( - currentTab = currentTabState.value, - onTabSelect = { tab -> - when (tab) { - FestabookMainTab.HOME -> { - currentTabState.value = FestabookMainTab.HOME - switchFragment(HomeFragment::class.java, TAG_HOME_FRAGMENT) - } - - FestabookMainTab.SCHEDULE -> { - currentTabState.value = FestabookMainTab.SCHEDULE - val fragment = - supportFragmentManager.findFragmentByTag(TAG_SCHEDULE_FRAGMENT) - if (fragment is OnMenuItemReClickListener && !fragment.isHidden) fragment.onMenuItemReClick() - switchFragment( - ScheduleFragment::class.java, - TAG_SCHEDULE_FRAGMENT, - ) - } - - FestabookMainTab.PLACE_MAP -> { - currentTabState.value = FestabookMainTab.PLACE_MAP - val fragment = - supportFragmentManager.findFragmentByTag(TAG_PLACE_MAP_FRAGMENT) - if (fragment is OnMenuItemReClickListener && !fragment.isHidden) fragment.onMenuItemReClick() - switchFragment(PlaceMapFragment::class.java, TAG_PLACE_MAP_FRAGMENT) - } - - FestabookMainTab.NEWS -> { - currentTabState.value = FestabookMainTab.NEWS - switchFragment(NewsFragment::class.java, TAG_NEWS_FRAGMENT) - } - - FestabookMainTab.SETTING -> { - currentTabState.value = FestabookMainTab.SETTING - switchFragment( - SettingFragment::class.java, - TAG_SETTING_FRAGMENT, - ) - } - } + MainScreen( + notificationPermissionManager = notificationPermissionManager, + logger = appGraph.defaultFirebaseLogger, + locationSource = locationSource, + onNavigateToExplore = { + startActivity(ExploreActivity.newIntent(this)) }, + onPreloadImages = { preloadImages(this, it.places) }, ) +// FestabookBottomNavigationBar( +// currentTab = currentTabState.value, +// onTabSelect = { tab -> +// when (tab) { +// FestabookMainTab.HOME -> { +// currentTabState.value = FestabookMainTab.HOME +// switchFragment(HomeFragment::class.java, TAG_HOME_FRAGMENT) +// } +// +// FestabookMainTab.SCHEDULE -> { +// currentTabState.value = FestabookMainTab.SCHEDULE +// val fragment = +// supportFragmentManager.findFragmentByTag(TAG_SCHEDULE_FRAGMENT) +// if (fragment is OnMenuItemReClickListener && !fragment.isHidden) fragment.onMenuItemReClick() +// switchFragment( +// ScheduleFragment::class.java, +// TAG_SCHEDULE_FRAGMENT, +// ) +// } +// +// FestabookMainTab.PLACE_MAP -> { +// currentTabState.value = FestabookMainTab.PLACE_MAP +// val fragment = +// supportFragmentManager.findFragmentByTag(TAG_PLACE_MAP_FRAGMENT) +// if (fragment is OnMenuItemReClickListener && !fragment.isHidden) fragment.onMenuItemReClick() +// switchFragment(PlaceMapFragment::class.java, TAG_PLACE_MAP_FRAGMENT) +// } +// +// FestabookMainTab.NEWS -> { +// currentTabState.value = FestabookMainTab.NEWS +// switchFragment(NewsFragment::class.java, TAG_NEWS_FRAGMENT) +// } +// +// FestabookMainTab.SETTING -> { +// currentTabState.value = FestabookMainTab.SETTING +// switchFragment( +// SettingFragment::class.java, +// TAG_SETTING_FRAGMENT, +// ) +// } +// } +// }, +// ) } } mainViewModel.registerDeviceAndFcmToken() - setupHomeFragment(savedInstanceState) +// setupHomeFragment(savedInstanceState) setupObservers() onBackPress() } private fun setupBinding() { setContentView(binding.root) - ViewCompat.setOnApplyWindowInsetsListener(binding.main) { v, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - insets - } +// ViewCompat.setOnApplyWindowInsetsListener(binding.main) { v, insets -> +// val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) +// v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) +// insets +// } } private fun setupFragmentFactory() { @@ -280,6 +299,42 @@ class MainActivity : } } + // OOM 주의 !! 추후 페이징 처리 및 chunk 단위로 나눠서 로드합니다 + private fun preloadImages( + context: Context, + places: List, + maxSize: Int = 20, + ) { + val imageLoader = context.imageLoader + val deferredList = mutableListOf>() + + lifecycleScope.launch(Dispatchers.IO) { + places + .take(maxSize) + .filterNotNull() + .forEach { place -> + val deferred = + async { + val request = + ImageRequest + .Builder(context) + .data(place.imageUrl.convertImageUrl()) + .build() + + runCatching { + withTimeout(2000) { + imageLoader.execute(request) + } + }.onFailure { + Timber.d("preload 실패") + }.getOrNull() + } + deferredList.add(deferred) + } + deferredList.awaitAll() + } + } + private fun showAlarmDialog() { val dialog = MaterialAlertDialogBuilder(this, R.style.MainAlarmDialogTheme) @@ -295,11 +350,8 @@ class MainActivity : companion object { const val KEY_NOTICE_ID_TO_EXPAND = "noticeIdToExpand" const val KEY_CAN_NAVIGATE_TO_NEWS = "canNavigateToNews" - private const val TAG_HOME_FRAGMENT = "homeFragment" - private const val TAG_SCHEDULE_FRAGMENT = "scheduleFragment" - private const val TAG_PLACE_MAP_FRAGMENT = "placeMapFragment" - private const val TAG_NEWS_FRAGMENT = "newsFragment" - private const val TAG_SETTING_FRAGMENT = "settingFragment" + const val LOCATION_PERMISSION_REQUEST_CODE = 1234 + private const val INITIALIZED_ID = -1L fun newIntent(context: Context) = 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 8c3c7869..d4cac959 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 @@ -1,19 +1,40 @@ 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.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost +import com.daedan.festabook.logging.DefaultFirebaseLogger +import com.daedan.festabook.presentation.NotificationPermissionManager 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 +import com.daedan.festabook.presentation.news.NewsViewModel +import com.daedan.festabook.presentation.news.navigation.newsNavGraph +import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect +import com.daedan.festabook.presentation.placeMap.navigation.placeMapNavGraph +import com.daedan.festabook.presentation.schedule.ScheduleViewModel +import com.daedan.festabook.presentation.schedule.navigation.scheduleNavGraph +import com.daedan.festabook.presentation.setting.SettingViewModel +import com.daedan.festabook.presentation.setting.navigation.settingNavGraph +import com.naver.maps.map.util.FusedLocationSource @Composable fun MainScreen( - homeViewModel: HomeViewModel, + notificationPermissionManager: NotificationPermissionManager, + logger: DefaultFirebaseLogger, + locationSource: FusedLocationSource, + onPreloadImages: (PlaceMapSideEffect.PreloadImages) -> Unit, // TODO: 추후 Context에 의존적이지 않게 변경 + onNavigateToExplore: () -> Unit, // TODO 검색화면 마이그레이션 시 제거 modifier: Modifier = Modifier, + homeViewModel: HomeViewModel = viewModel(), + scheduleViewModel: ScheduleViewModel = viewModel(), + placeMapViewModel: PlaceMapViewModel = viewModel(), + newsViewModel: NewsViewModel = viewModel(), + settingViewModel: SettingViewModel = viewModel(), ) { val navigator = rememberFestabookNavigator() @@ -30,19 +51,39 @@ fun MainScreen( modifier = modifier, ) { innerPadding -> NavHost( - modifier = Modifier.fillMaxSize(), startDestination = navigator.startRoute, navController = navigator.navController, ) { homeNavGraph( padding = innerPadding, viewModel = homeViewModel, - onNavigateToExplore = { navigator.navigate(FestabookRoute.Explore) }, + onNavigateToExplore = onNavigateToExplore, + ) + scheduleNavGraph( + padding = innerPadding, + viewModel = scheduleViewModel, + ) + placeMapNavGraph( + padding = innerPadding, + placeMapViewModel = placeMapViewModel, + logger = logger, + locationSource = locationSource, + onStartPlaceDetail = { navigator.navigate(FestabookRoute.PlaceDetail) }, + onPreloadImages = onPreloadImages, + onShowErrorSnackBar = { }, + ) + newsNavGraph( + padding = innerPadding, + viewModel = newsViewModel, + ) + settingNavGraph( + padding = innerPadding, + homeViewModel = homeViewModel, + settingViewModel = settingViewModel, + notificationPermissionManager = notificationPermissionManager, + onShowSnackBar = { }, + onShowErrorSnackBar = { }, ) - - // 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 } } } From f66eebc90c03713a49946ec883c741116426c1e4 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Wed, 21 Jan 2026 21:13:01 +0900 Subject: [PATCH 05/20] =?UTF-8?q?fix(PlaceMap):=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=EB=A6=AC=20=EB=88=84=EC=88=98=20=EB=A1=9C=EC=A7=81=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20Stop=20=EC=83=81=ED=83=9C=EC=9D=BC=20=EB=95=8C?= =?UTF-8?q?=EC=97=90=EB=8F=84=20Destroy=EA=B0=80=20=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `NaverMapContent` 컴포저블에서 `DisposableEffect`가 해제될 때 `MapView`의 생명주기 메서드(`onPause`, `onStop`, `onDestroy`)를 수동으로 호출하여 메모리 누수를 해결하려던 코드를 제거했습니다. 이 로직은 `rememberMapViewWithLifecycle`에서 이미 처리되고 있어 중복되었고, 불필요한 복잡성을 야기했습니다. - **`NaverMapContent.kt` 수정:** - `DisposableEffect`의 `onDispose` 블록 내에서 `MapView`의 생명주기 메서드를 직접 호출하는 로직을 삭제했습니다. - `AndroidView`와 오버레이되는 `content`를 `Box` 레이아웃으로 감싸 구조를 명확히 했습니다. --- .../placeMap/component/NaverMapContent.kt | 35 +++++-------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt index ed43be57..8c8201a0 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt @@ -3,6 +3,7 @@ package com.daedan.festabook.presentation.placeMap.component import android.content.ComponentCallbacks2 import android.content.res.Configuration import android.os.Bundle +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -37,12 +38,15 @@ fun NaverMapContent( val naverMap = mapView.getMapAndRunCallback(onMapReady) mapDelegate.initMap(naverMap) } - AndroidView( - factory = { mapView }, - modifier = modifier.dragInterceptor(onMapDrag), - ) + Box(modifier = modifier) { + // TODO AndroidView와 CMP 뷰의 혼용으로 컴파일러 경고 발생중 -> 추후 해결하겠습니다 + AndroidView( + factory = { mapView }, + modifier = Modifier.dragInterceptor(onMapDrag), + ) + content(mapDelegate.value) + } RegisterMapLifeCycle(mapView) - content(mapDelegate.value) } private fun Modifier.dragInterceptor(onMapDrag: () -> Unit): Modifier = @@ -115,27 +119,6 @@ private fun RegisterMapLifeCycle(mapView: MapView) { mapView.onSaveInstanceState(savedInstanceState) lifecycle.removeObserver(mapLifecycleObserver) context.unregisterComponentCallbacks(callbacks) - - // dispose 시점에 Lifecycle.Event가 끝까지 진행되지 않아 발생되는 - // MapView Memory Leak 수정합니다. - when (previousState.value) { - Lifecycle.Event.ON_CREATE, Lifecycle.Event.ON_STOP -> { - mapView.onDestroy() - } - - Lifecycle.Event.ON_START, Lifecycle.Event.ON_PAUSE -> { - mapView.onStop() - mapView.onDestroy() - } - - Lifecycle.Event.ON_RESUME -> { - mapView.onPause() - mapView.onStop() - mapView.onDestroy() - } - - else -> Unit - } } } } From 824fbe93e8e6908e55a5eb43e26ed160a3413f6c Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Thu, 22 Jan 2026 14:46:10 +0900 Subject: [PATCH 06/20] =?UTF-8?q?refactor(PlaceMap):=20NaverMap=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=ED=94=84=EC=82=AC=EC=9D=B4=ED=81=B4=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `NaverMapContent` 컴포저블에서 `AndroidView`가 해제될 때 `MapView`의 생명주기 메서드(`onPause`, `onStop`, `onDestroy`)가 호출되도록 `onRelease` 콜백을 추가했습니다. 이를 통해 지도 리소스가 올바르게 해제되도록 하여 메모리 누수를 방지합니다. - **`NaverMapContent.kt` 수정:** - `AndroidView`에 `onRelease` 블록을 추가하여 컴포지션에서 제거될 때 `mapView.onPause()`, `mapView.onStop()`, `mapView.onDestroy()`를 순차적으로 호출하도록 구현했습니다. --- .../presentation/placeMap/component/NaverMapContent.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt index 8c8201a0..c1931e25 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt @@ -43,6 +43,9 @@ fun NaverMapContent( AndroidView( factory = { mapView }, modifier = Modifier.dragInterceptor(onMapDrag), + onRelease = { + mapView.onDestroy() + }, ) content(mapDelegate.value) } From 72ccac5138f54251b60234cf9bde149fed5d7b44 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Thu, 22 Jan 2026 15:14:50 +0900 Subject: [PATCH 07/20] =?UTF-8?q?refactor(main):=20=ED=95=98=EB=8B=A8=20?= =?UTF-8?q?=ED=83=AD=20=EC=9E=AC=EC=84=A0=ED=83=9D=20=EC=8B=9C=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EA=B0=B1=EC=8B=A0=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 하단 네비게이션 바의 동일한 탭을 다시 선택했을 때, 각 화면의 데이터를 새로고침하거나 초기 상태로 되돌리는 기능을 추가했습니다. - **`FestabookBottomNavigationBar.kt` 수정:** - `FestabookBottomNavigationBar` 컴포저블에 `onTabReSelect` 콜백 파라미터를 추가했습니다. - 각 네비게이션 아이템(`FestabookNavigationItem`, `PlaceMapNavigationItem`) 클릭 시, 현재 선택된 탭과 클릭된 탭이 동일할 경우 `onTabReSelect` 콜백이 호출되도록 로직을 수정했습니다. - **`MainScreen.kt` 수정:** - `FestabookBottomNavigationBar`의 `onTabReSelect` 콜백을 구현하여 각 탭에 맞는 동작을 정의했습니다. - **SCHEDULE 탭:** 재선택 시 `scheduleViewModel.loadSchedules()`를 호출하여 스케줄 목록을 새로고침합니다. - **PLACE_MAP 탭:** 재선택 시 `placeMapViewModel`의 관련 함수를 호출하여 선택된 장소를 해제하고 초기 상태로 되돌립니다. --- .../component/FestabookBottomNavigationBar.kt | 16 +++++++++++++--- .../presentation/main/component/MainScreen.kt | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) 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 index 740e5aa0..9b81045a 100644 --- 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 @@ -43,6 +43,7 @@ fun FestabookBottomNavigationBar( currentTab: FestabookMainTab?, onTabSelect: (FestabookMainTab) -> Unit, modifier: Modifier = Modifier, + onTabReSelect: (FestabookMainTab) -> Unit = {}, ) { Box( contentAlignment = Alignment.BottomCenter, @@ -57,18 +58,27 @@ fun FestabookBottomNavigationBar( ) { FestabookMainTab.entries.forEach { item -> when (item) { - FestabookMainTab.PLACE_MAP -> Spacer(modifier = Modifier.weight(1f)) + FestabookMainTab.PLACE_MAP -> { + Spacer(modifier = Modifier.weight(1f)) + } + else -> { FestabookNavigationItem( tab = item, selected = item == currentTab, - onClick = onTabSelect, + onClick = { + if (it == currentTab) onTabReSelect(it) + onTabSelect(it) + }, ) } } } } - PlaceMapNavigationItem(onClick = onTabSelect) + PlaceMapNavigationItem(onClick = { + if (it == currentTab) onTabReSelect(it) + onTabSelect(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 index d4cac959..af43ee5f 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 @@ -9,11 +9,13 @@ import com.daedan.festabook.logging.DefaultFirebaseLogger import com.daedan.festabook.presentation.NotificationPermissionManager import com.daedan.festabook.presentation.home.HomeViewModel import com.daedan.festabook.presentation.home.navigation.homeNavGraph +import com.daedan.festabook.presentation.main.FestabookMainTab import com.daedan.festabook.presentation.main.FestabookRoute import com.daedan.festabook.presentation.main.rememberFestabookNavigator import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.navigation.newsNavGraph import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect import com.daedan.festabook.presentation.placeMap.navigation.placeMapNavGraph import com.daedan.festabook.presentation.schedule.ScheduleViewModel @@ -45,6 +47,22 @@ fun MainScreen( FestabookBottomNavigationBar( currentTab = navigator.currentTab, onTabSelect = { navigator.navigateToMainTab(it.route) }, + onTabReSelect = { tab -> + when (tab) { + FestabookMainTab.SCHEDULE -> { + scheduleViewModel.loadSchedules() + } + + FestabookMainTab.PLACE_MAP -> { + placeMapViewModel.onPlaceMapEvent(SelectEvent.UnSelectPlace) + placeMapViewModel.onMenuItemReClicked() + } + + else -> { + Unit + } + } + }, ) } }, From 6cfd1b75fbbe6bb4106317efe3d3c36b65b4147f Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Thu, 22 Jan 2026 17:46:33 +0900 Subject: [PATCH 08/20] =?UTF-8?q?refactor(PlaceMap):=20=EC=A7=80=EB=8F=84?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=EC=9D=84=20NavHost=20=EC=99=B8=EB=B6=80?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=98=EC=97=AC=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존에 `NavHost` 내에서 컴포저블로 관리되던 지도 화면(`PlaceMapRoute`)을 `NavHost` 외부로 분리하는 리팩토링을 진행했습니다. 이를 통해 다른 탭으로 이동했다가 다시 지도 탭으로 돌아왔을 때, 지도의 상태(위치, 줌 레벨 등)와 관련 데이터가 초기화되지 않고 그대로 유지되도록 개선했습니다. - **`MainScreen.kt` 수정:** - `PlaceMapRoute`를 `NavHost`의 바깥으로 이동시켜 `NavHost`와 동일한 레벨에 배치했습니다. - `mutableStateOf`를 사용하여 지도 화면의 가시성(`shouldShowPlaceMap`)을 관리하고, 현재 탭이 지도일 때만 화면이 보이도록 `alpha` 값을 조절했습니다. - `PlaceMapRoute`에 필요한 의존성(ViewModel, locationSource 등)을 직접 전달하도록 구조를 변경했습니다. - **`placeMap/navigation/PlaceMapNavigation.kt` 수정:** - `placeMapNavGraph`의 역할을 축소하여, 더 이상 지도 UI를 직접 생성하지 않고 `PlaceMap` 탭이 선택되었을 때 `MainScreen`에 있는 가시성 상태(`mapScreenVisibilityState`)를 `true`로 변경하는 역할만 담당하도록 수정했습니다. - **`placeMap/component/PlaceMapScreen.kt` 수정:** - `PlaceMapRoute` 내부에서 `remember`를 사용하여 `MapDelegate`, `MapManagerDelegate`, `MapControlSideEffectHandler`, `PlaceMapSideEffectHandler` 등 지도 관련 객체들을 생성하고 상태를 유지하도록 변경했습니다. - 이로 인해 `placeMapNavGraph`에서 전달받던 여러 파라미터가 `PlaceMapRoute` 내에서 자체적으로 관리되도록 구조가 단순화되었습니다. --- .../presentation/main/component/MainScreen.kt | 30 ++++++--- .../placeMap/component/PlaceMapScreen.kt | 42 ++++++++++++- .../placeMap/navigation/PlaceMapNavigation.kt | 63 +------------------ 3 files changed, 65 insertions(+), 70 deletions(-) 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 af43ee5f..cabdd11d 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 @@ -1,8 +1,12 @@ package com.daedan.festabook.presentation.main.component +import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import com.daedan.festabook.logging.DefaultFirebaseLogger @@ -15,6 +19,7 @@ import com.daedan.festabook.presentation.main.rememberFestabookNavigator import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.navigation.newsNavGraph import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.component.PlaceMapRoute import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect import com.daedan.festabook.presentation.placeMap.navigation.placeMapNavGraph @@ -25,6 +30,7 @@ import com.daedan.festabook.presentation.setting.navigation.settingNavGraph import com.naver.maps.map.util.FusedLocationSource @Composable +@Suppress("ktlint:compose:vm-forwarding-check") fun MainScreen( notificationPermissionManager: NotificationPermissionManager, logger: DefaultFirebaseLogger, @@ -68,6 +74,22 @@ fun MainScreen( }, modifier = modifier, ) { innerPadding -> + val shouldShowPlaceMap = remember { mutableStateOf(false) } + PlaceMapRoute( + modifier = + Modifier + .alpha( + if (shouldShowPlaceMap.value) 1f else 0f, + ).padding(innerPadding), + placeMapViewModel = placeMapViewModel, + locationSource = locationSource, + logger = logger, + onStartPlaceDetail = { + navigator.navigateToMainTab(FestabookRoute.PlaceDetail) + }, + onPreloadImages = onPreloadImages, + ) + NavHost( startDestination = navigator.startRoute, navController = navigator.navController, @@ -82,13 +104,7 @@ fun MainScreen( viewModel = scheduleViewModel, ) placeMapNavGraph( - padding = innerPadding, - placeMapViewModel = placeMapViewModel, - logger = logger, - locationSource = locationSource, - onStartPlaceDetail = { navigator.navigate(FestabookRoute.PlaceDetail) }, - onPreloadImages = onPreloadImages, - onShowErrorSnackBar = { }, + mapScreenVisibilityState = shouldShowPlaceMap, ) newsNavGraph( padding = innerPadding, 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 e0112738..307d08cc 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 @@ -8,10 +8,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.logging.DefaultFirebaseLogger import com.daedan.festabook.presentation.common.ObserveAsEvents import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel import com.daedan.festabook.presentation.placeMap.intent.event.FilterEvent @@ -20,22 +24,54 @@ import com.daedan.festabook.presentation.placeMap.intent.event.PlaceMapEvent import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent import com.daedan.festabook.presentation.placeMap.intent.handler.MapControlSideEffectHandler import com.daedan.festabook.presentation.placeMap.intent.handler.PlaceMapSideEffectHandler +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect import com.daedan.festabook.presentation.placeMap.intent.state.LoadState import com.daedan.festabook.presentation.placeMap.intent.state.MapDelegate +import com.daedan.festabook.presentation.placeMap.intent.state.MapManagerDelegate import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.festabookSpacing +import com.naver.maps.map.util.FusedLocationSource @Composable +@Suppress("ktlint:compose:vm-forwarding-check") fun PlaceMapRoute( placeMapViewModel: PlaceMapViewModel, - mapDelegate: MapDelegate, - mapControlSideEffectHandler: MapControlSideEffectHandler, - placeMapSideEffectHandler: PlaceMapSideEffectHandler, + onStartPlaceDetail: (PlaceMapSideEffect.StartPlaceDetail) -> Unit, + onPreloadImages: (PlaceMapSideEffect.PreloadImages) -> Unit, + locationSource: FusedLocationSource, + logger: DefaultFirebaseLogger, modifier: Modifier = Modifier, ) { val uiState by placeMapViewModel.uiState.collectAsStateWithLifecycle() + val density = LocalDensity.current val bottomSheetState = rememberPlaceListBottomSheetState() + val mapDelegate = remember { MapDelegate() } + val mapManagerDelegate = remember { MapManagerDelegate() } + + val mapControlSideEffectHandler = + remember { + MapControlSideEffectHandler( + initialPadding = with(density) { 254.dp.toPx() }.toInt(), + logger = logger, + locationSource = locationSource, + viewModel = placeMapViewModel, + mapDelegate = mapDelegate, + mapManagerDelegate = mapManagerDelegate, + ) + } + val placeMapSideEffectHandler = + remember { + PlaceMapSideEffectHandler( + mapManagerDelegate = mapManagerDelegate, + bottomSheetState = bottomSheetState, + viewModel = placeMapViewModel, + logger = logger, + onStartPlaceDetail = onStartPlaceDetail, + onPreloadImages = onPreloadImages, + onShowErrorSnackBar = { }, + ) + } ObserveAsEvents(flow = placeMapViewModel.mapControlSideEffect) { event -> mapControlSideEffectHandler(event) 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 90b682d5..025d207c 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 @@ -1,69 +1,12 @@ package com.daedan.festabook.presentation.placeMap.navigation -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp +import androidx.compose.runtime.MutableState import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable -import com.daedan.festabook.logging.DefaultFirebaseLogger import com.daedan.festabook.presentation.main.MainTabRoute -import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel -import com.daedan.festabook.presentation.placeMap.component.PlaceMapRoute -import com.daedan.festabook.presentation.placeMap.component.rememberPlaceListBottomSheetState -import com.daedan.festabook.presentation.placeMap.intent.handler.MapControlSideEffectHandler -import com.daedan.festabook.presentation.placeMap.intent.handler.PlaceMapSideEffectHandler -import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect -import com.daedan.festabook.presentation.placeMap.intent.state.MapDelegate -import com.daedan.festabook.presentation.placeMap.intent.state.MapManagerDelegate -import com.naver.maps.map.util.FusedLocationSource -fun NavGraphBuilder.placeMapNavGraph( - padding: PaddingValues, - placeMapViewModel: PlaceMapViewModel, - logger: DefaultFirebaseLogger, - locationSource: FusedLocationSource, - onStartPlaceDetail: (PlaceMapSideEffect.StartPlaceDetail) -> Unit, - onPreloadImages: (PlaceMapSideEffect.PreloadImages) -> Unit, - onShowErrorSnackBar: (PlaceMapSideEffect.ShowErrorSnackBar) -> Unit, -) { +fun NavGraphBuilder.placeMapNavGraph(mapScreenVisibilityState: MutableState) { composable { - val density = LocalDensity.current - val bottomSheetState = rememberPlaceListBottomSheetState() - val mapDelegate = remember { MapDelegate() } - val mapManagerDelegate = remember { MapManagerDelegate() } - val mapControlSideEffectHandler = - remember { - MapControlSideEffectHandler( - initialPadding = with(density) { 254.dp.toPx() }.toInt(), - logger = logger, - locationSource = locationSource, - viewModel = placeMapViewModel, - mapDelegate = mapDelegate, - mapManagerDelegate = mapManagerDelegate, - ) - } - val placeMapSideEffectHandler = - remember { - PlaceMapSideEffectHandler( - mapManagerDelegate = mapManagerDelegate, - bottomSheetState = bottomSheetState, - viewModel = placeMapViewModel, - logger = logger, - onStartPlaceDetail = onStartPlaceDetail, - onPreloadImages = onPreloadImages, - onShowErrorSnackBar = onShowErrorSnackBar, - ) - } - - PlaceMapRoute( - modifier = Modifier.padding(padding), - placeMapViewModel = placeMapViewModel, - mapControlSideEffectHandler = mapControlSideEffectHandler, - placeMapSideEffectHandler = placeMapSideEffectHandler, - mapDelegate = mapDelegate, - ) + mapScreenVisibilityState.value = true } } From d0648dd618e8c5ddebc182c1397ce661ef2f85c1 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Fri, 23 Jan 2026 00:54:34 +0900 Subject: [PATCH 09/20] =?UTF-8?q?refactor:kotlinx.LocalDateTime=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=EB=B0=8F,=20@Serializable=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 1 + .../festabook/data/model/response/notice/NoticeResponse.kt | 2 +- .../data/model/response/place/PlaceDetailResponse.kt | 2 +- .../main/java/com/daedan/festabook/domain/model/Notice.kt | 2 +- .../presentation/news/notice/model/NoticeUiModel.kt | 7 +++++-- .../presentation/placeDetail/model/ImageUiModel.kt | 2 ++ .../presentation/placeDetail/model/PlaceDetailUiModel.kt | 2 ++ .../festabook/presentation/placeMap/model/PlaceUiModel.kt | 2 ++ gradle/libs.versions.toml | 2 ++ 9 files changed, 17 insertions(+), 5 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 954d3e29..c62deb16 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -149,6 +149,7 @@ android { dependencies { ktlintRuleset(libs.ktlint) + implementation(libs.kotlinx.datetime) implementation(libs.map.sdk) implementation(libs.androidx.navigation.compose) implementation(libs.play.services.location) diff --git a/app/src/main/java/com/daedan/festabook/data/model/response/notice/NoticeResponse.kt b/app/src/main/java/com/daedan/festabook/data/model/response/notice/NoticeResponse.kt index a91f293d..3b655f96 100644 --- a/app/src/main/java/com/daedan/festabook/data/model/response/notice/NoticeResponse.kt +++ b/app/src/main/java/com/daedan/festabook/data/model/response/notice/NoticeResponse.kt @@ -1,9 +1,9 @@ package com.daedan.festabook.data.model.response.notice import com.daedan.festabook.domain.model.Notice +import kotlinx.datetime.LocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import java.time.LocalDateTime @Serializable data class NoticeResponse( diff --git a/app/src/main/java/com/daedan/festabook/data/model/response/place/PlaceDetailResponse.kt b/app/src/main/java/com/daedan/festabook/data/model/response/place/PlaceDetailResponse.kt index 55142c0f..9747a311 100644 --- a/app/src/main/java/com/daedan/festabook/data/model/response/place/PlaceDetailResponse.kt +++ b/app/src/main/java/com/daedan/festabook/data/model/response/place/PlaceDetailResponse.kt @@ -5,9 +5,9 @@ import com.daedan.festabook.domain.model.Notice import com.daedan.festabook.domain.model.Place import com.daedan.festabook.domain.model.PlaceDetail import com.daedan.festabook.domain.model.PlaceDetailImage +import kotlinx.datetime.LocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import java.time.LocalDateTime import java.time.LocalTime import java.time.format.DateTimeFormatter diff --git a/app/src/main/java/com/daedan/festabook/domain/model/Notice.kt b/app/src/main/java/com/daedan/festabook/domain/model/Notice.kt index 0f091167..b9b60668 100644 --- a/app/src/main/java/com/daedan/festabook/domain/model/Notice.kt +++ b/app/src/main/java/com/daedan/festabook/domain/model/Notice.kt @@ -1,6 +1,6 @@ package com.daedan.festabook.domain.model -import java.time.LocalDateTime +import kotlinx.datetime.LocalDateTime data class Notice( val id: Long, diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/model/NoticeUiModel.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/model/NoticeUiModel.kt index a9e48b54..a839749c 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/model/NoticeUiModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/notice/model/NoticeUiModel.kt @@ -2,11 +2,14 @@ package com.daedan.festabook.presentation.news.notice.model import android.os.Parcelable import com.daedan.festabook.domain.model.Notice +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.toJavaLocalDateTime import kotlinx.parcelize.Parcelize -import java.time.LocalDateTime +import kotlinx.serialization.Serializable import java.time.format.DateTimeFormatter @Parcelize +@Serializable data class NoticeUiModel( val id: Long, val title: String, @@ -18,7 +21,7 @@ data class NoticeUiModel( val formattedCreatedAt: String get() = runCatching { - formatter.format(createdAt) + formatter.format(createdAt.toJavaLocalDateTime()) }.getOrDefault("") companion object { diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/model/ImageUiModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/model/ImageUiModel.kt index ba3a666f..667956fc 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/model/ImageUiModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/model/ImageUiModel.kt @@ -3,8 +3,10 @@ package com.daedan.festabook.presentation.placeDetail.model import android.os.Parcelable import com.daedan.festabook.domain.model.PlaceDetailImage import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable @Parcelize +@Serializable data class ImageUiModel( val url: String? = null, val id: Long = -1, diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/model/PlaceDetailUiModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/model/PlaceDetailUiModel.kt index 02504d96..d2b2f2a7 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/model/PlaceDetailUiModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/model/PlaceDetailUiModel.kt @@ -7,10 +7,12 @@ import com.daedan.festabook.presentation.news.notice.model.toUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.placeMap.model.toUiModel import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable import java.time.LocalTime import java.time.format.DateTimeFormatter @Parcelize +@Serializable data class PlaceDetailUiModel( val place: PlaceUiModel, val notices: List, diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceUiModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceUiModel.kt index deff0d7f..608d67a8 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceUiModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceUiModel.kt @@ -3,8 +3,10 @@ package com.daedan.festabook.presentation.placeMap.model import android.os.Parcelable import com.daedan.festabook.domain.model.Place import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable @Parcelize +@Serializable data class PlaceUiModel( val id: Long, val imageUrl: String?, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f5aebc83..c400497d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ junitVersion = "1.2.1" espressoCore = "3.6.1" appcompat = "1.7.0" kotlinxCoroutinesTest = "1.10.2" +kotlinxDatetime = "0.7.1" landscapistCoil3 = "2.8.2" landscapistPlaceholder = "2.8.2" landscapistZoomable = "2.8.2" @@ -68,6 +69,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co 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" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } landscapist-coil3 = { module = "com.github.skydoves:landscapist-coil3", version.ref = "landscapistCoil3" } landscapist-placeholder = { module = "com.github.skydoves:landscapist-placeholder", version.ref = "landscapistPlaceholder" } landscapist-zoomable = { module = "com.github.skydoves:landscapist-zoomable", version.ref = "landscapistZoomable" } From 4aa5aacc70fcc9200bc02f26e4d59124b82a2821 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Fri, 23 Jan 2026 00:55:31 +0900 Subject: [PATCH 10/20] =?UTF-8?q?refactor(navigation):=20NavGraph=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20`padding`=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Scaffold`가 `MainScreen`으로 이동하면서 각 `NavGraph` 함수(`homeNavGraph`, `newsNavGraph`, `scheduleNavGraph`, `settingNavGraph`)에서 더 이상 사용되지 않는 `padding` 파라미터와 관련 `Modifier.padding()` 코드를 제거하여 코드를 정리했습니다. - **각 `NavGraph` 파일 수정:** - `homeNavGraph`, `newsNavGraph`, `scheduleNavGraph`, `settingNavGraph` 함수에서 `padding: PaddingValues` 파라미터를 삭제했습니다. - 각 함수 내부의 컴포저블에서 `Modifier.padding(padding)`을 적용하는 로직을 제거했습니다. - **`FestabookNavigator.kt` 수정:** - `navigateToMainTab` 함수의 파라미터 타입을 `FestabookRoute`에서 더 명확한 `FestabookMainTab`으로 변경했습니다. --- .../presentation/home/navigation/HomeNavigation.kt | 5 ----- .../festabook/presentation/main/FestabookNavigator.kt | 4 ++-- .../presentation/news/navigation/NewsNavigation.kt | 9 +-------- .../schedule/navigation/ScheduleNavigation.kt | 9 +-------- .../presentation/setting/navigation/SettingNavigation.kt | 5 ----- 5 files changed, 4 insertions(+), 28 deletions(-) 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 13bdf5fb..707a95b8 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 @@ -1,8 +1,5 @@ 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 @@ -10,13 +7,11 @@ 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/FestabookNavigator.kt b/app/src/main/java/com/daedan/festabook/presentation/main/FestabookNavigator.kt index 18e45ba7..a9f6f09b 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/main/FestabookNavigator.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/main/FestabookNavigator.kt @@ -38,9 +38,9 @@ class FestabookNavigator( val startRoute = MainTabRoute.Home // TODO: Splash와 Explore 연동 시 변경 - fun navigateToMainTab(route: FestabookRoute) { + fun navigateToMainTab(tab: FestabookMainTab) { navController.navigate( - route, + tab.route, navOptions { popUpTo(navController.graph.findStartDestination().id) { saveState = true 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 b8f8b05a..7e286e41 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 @@ -1,21 +1,14 @@ package com.daedan.festabook.presentation.news.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.main.MainTabRoute import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.component.NewsScreen -fun NavGraphBuilder.newsNavGraph( - padding: PaddingValues, - viewModel: NewsViewModel, -) { +fun NavGraphBuilder.newsNavGraph(viewModel: NewsViewModel) { composable { NewsScreen( - modifier = Modifier.padding(padding), newsViewModel = viewModel, ) } 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 d1a3ef92..1d011fdb 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 @@ -1,21 +1,14 @@ package com.daedan.festabook.presentation.schedule.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.main.MainTabRoute import com.daedan.festabook.presentation.schedule.ScheduleViewModel import com.daedan.festabook.presentation.schedule.component.ScheduleScreen -fun NavGraphBuilder.scheduleNavGraph( - padding: PaddingValues, - viewModel: ScheduleViewModel, -) { +fun NavGraphBuilder.scheduleNavGraph(viewModel: ScheduleViewModel) { composable { ScheduleScreen( - modifier = Modifier.padding(padding), scheduleViewModel = viewModel, ) } diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt index 67de108a..a7ab51fc 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt @@ -1,8 +1,5 @@ package com.daedan.festabook.presentation.setting.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.NotificationPermissionManager @@ -12,7 +9,6 @@ import com.daedan.festabook.presentation.setting.SettingViewModel import com.daedan.festabook.presentation.setting.component.SettingRoute fun NavGraphBuilder.settingNavGraph( - padding: PaddingValues, homeViewModel: HomeViewModel, settingViewModel: SettingViewModel, notificationPermissionManager: NotificationPermissionManager, @@ -21,7 +17,6 @@ fun NavGraphBuilder.settingNavGraph( ) { composable { SettingRoute( - modifier = Modifier.padding(padding), homeViewModel = homeViewModel, settingViewModel = settingViewModel, notificationPermissionManager = notificationPermissionManager, From 80b20efff9b260cc4d42f6b081fed4dbbb59120e Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Fri, 23 Jan 2026 00:56:14 +0900 Subject: [PATCH 11/20] =?UTF-8?q?feat(PlaceDetail):=20=EC=9E=A5=EC=86=8C?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4=EC=9D=84=20NavHost?= =?UTF-8?q?=20=EB=82=B4=20=EC=BB=B4=ED=8F=AC=EC=A0=80=EB=B8=94=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존에 `PlaceMap` 화면 위에서 별도의 레이어로 표시되던 장소 상세 화면을 `NavHost` 내의 독립된 컴포저블(`PlaceDetailRoute`)로 전환했습니다. 이를 통해 화면 전환 로직을 표준화하고, 다른 화면으로의 이동 및 상태 관리를 개선했습니다. - **`placeMap/navigation/PlaceMapNavigation.kt` 수정:** - `placeMapNavGraph`에 `FestabookRoute.PlaceDetail` 라우트를 추가하여 장소 상세 화면을 `NavHost` 내에서 관리하도록 변경했습니다. - 화면 전환 시 애니메이션(`slide`, `fade`, `scale`)을 추가하여 사용자 경험을 개선했습니다. - 복잡한 객체(UI Model)를 네비게이션 인자로 전달하기 위해 `kotlinx.serialization`을 사용한 커스텀 `NavType`을 구현했습니다. - **`main/component/MainScreen.kt` 수정:** - `PlaceMapRoute`에서 장소 상세 화면을 시작하는 로직을 `navigator.navigate` 호출로 변경했습니다. - `NavHost`를 `FestabookNavHost` 컴포저블로 분리하고, 장소 상세 화면에 필요한 `PlaceDetailViewModel.Factory`를 주입하도록 수정했습니다. - 지도 화면의 가시성(`alpha`)을 `navigator.currentTab` 상태와 연동하여 관리하도록 변경했습니다. - **`placeDetail/component/PlaceDetailScreen.kt` 추가:** - `PlaceDetailRoute` 컴포저블을 새로 정의하여 `ViewModel`로부터 UI 상태를 수집하고 `PlaceDetailScreen`에 전달하도록 구현했습니다. - **`main/FestabookRoute.kt` 수정:** - `PlaceDetail` 라우트를 `data class`로 변경하여 `placeUiModel`과 `placeDetailUiModel`을 네비게이션 인자로 받을 수 있도록 수정했습니다. - **`main/MainActivity.kt` 수정:** - `MainScreen`에 `PlaceDetailViewModel.Factory`를 주입하는 코드를 추가했습니다. --- .../presentation/main/FestabookRoute.kt | 8 +- .../presentation/main/MainActivity.kt | 8 +- .../presentation/main/component/MainScreen.kt | 96 ++++++++++++------- .../component/PlaceDetailScreen.kt | 25 ++++- .../placeMap/navigation/PlaceMapNavigation.kt | 85 +++++++++++++++- 5 files changed, 174 insertions(+), 48 deletions(-) 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 index e9eac87f..13a9611d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/main/FestabookRoute.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/main/FestabookRoute.kt @@ -1,5 +1,7 @@ package com.daedan.festabook.presentation.main +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import kotlinx.serialization.Serializable @Serializable @@ -7,9 +9,11 @@ sealed interface FestabookRoute { @Serializable data object Splash : FestabookRoute - // TODO: PlaceUiModel, PlaceDetailUiModel 생성자에 추가 후 UiModel에 @Serializable 어노테이션 필요 @Serializable - data object PlaceDetail : FestabookRoute + data class PlaceDetail( + val placeUiModel: PlaceUiModel? = null, + val placeDetailUiModel: PlaceDetailUiModel? = null, + ) : FestabookRoute @Serializable data object Explore : FestabookRoute 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 928e3f44..15fc7827 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 @@ -13,8 +13,6 @@ import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentFactory import androidx.fragment.app.add @@ -42,6 +40,7 @@ import com.daedan.festabook.presentation.home.HomeFragment import com.daedan.festabook.presentation.home.HomeViewModel import com.daedan.festabook.presentation.main.component.MainScreen import com.daedan.festabook.presentation.news.NewsViewModel +import com.daedan.festabook.presentation.placeDetail.PlaceDetailViewModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.setting.SettingViewModel import com.daedan.festabook.presentation.theme.FestabookTheme @@ -62,6 +61,9 @@ class MainActivity : @Inject override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory + @Inject + private lateinit var viewModelFactory: PlaceDetailViewModel.Factory + @Inject private lateinit var fragmentFactory: FragmentFactory @@ -118,7 +120,6 @@ class MainActivity : // setupBinding() setContent { - currentTabState = remember { mutableStateOf(FestabookMainTab.HOME) } LaunchedEffect(Unit) { handleNavigation(intent) } @@ -127,6 +128,7 @@ class MainActivity : notificationPermissionManager = notificationPermissionManager, logger = appGraph.defaultFirebaseLogger, locationSource = locationSource, + placeDetailViewModelFactory = viewModelFactory, onNavigateToExplore = { startActivity(ExploreActivity.newIntent(this)) }, 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 cabdd11d..5adf296d 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,8 +3,6 @@ package com.daedan.festabook.presentation.main.component import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.lifecycle.viewmodel.compose.viewModel @@ -14,10 +12,12 @@ import com.daedan.festabook.presentation.NotificationPermissionManager import com.daedan.festabook.presentation.home.HomeViewModel import com.daedan.festabook.presentation.home.navigation.homeNavGraph import com.daedan.festabook.presentation.main.FestabookMainTab +import com.daedan.festabook.presentation.main.FestabookNavigator import com.daedan.festabook.presentation.main.FestabookRoute import com.daedan.festabook.presentation.main.rememberFestabookNavigator import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.navigation.newsNavGraph +import com.daedan.festabook.presentation.placeDetail.PlaceDetailViewModel import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel import com.daedan.festabook.presentation.placeMap.component.PlaceMapRoute import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent @@ -35,6 +35,7 @@ fun MainScreen( notificationPermissionManager: NotificationPermissionManager, logger: DefaultFirebaseLogger, locationSource: FusedLocationSource, + placeDetailViewModelFactory: PlaceDetailViewModel.Factory, onPreloadImages: (PlaceMapSideEffect.PreloadImages) -> Unit, // TODO: 추후 Context에 의존적이지 않게 변경 onNavigateToExplore: () -> Unit, // TODO 검색화면 마이그레이션 시 제거 modifier: Modifier = Modifier, @@ -52,7 +53,7 @@ fun MainScreen( if (navigator.shouldShowBottomBar) { FestabookBottomNavigationBar( currentTab = navigator.currentTab, - onTabSelect = { navigator.navigateToMainTab(it.route) }, + onTabSelect = { navigator.navigateToMainTab(it) }, onTabReSelect = { tab -> when (tab) { FestabookMainTab.SCHEDULE -> { @@ -74,50 +75,75 @@ fun MainScreen( }, modifier = modifier, ) { innerPadding -> - val shouldShowPlaceMap = remember { mutableStateOf(false) } PlaceMapRoute( modifier = Modifier .alpha( - if (shouldShowPlaceMap.value) 1f else 0f, + if (navigator.currentTab == FestabookMainTab.PLACE_MAP) 1f else 0f, ).padding(innerPadding), placeMapViewModel = placeMapViewModel, locationSource = locationSource, logger = logger, onStartPlaceDetail = { - navigator.navigateToMainTab(FestabookRoute.PlaceDetail) + navigator.navigate( + FestabookRoute.PlaceDetail( + placeDetailUiModel = it.placeDetail.value, + ), + ) }, onPreloadImages = onPreloadImages, ) + FestabookNavHost( + modifier = Modifier.padding(innerPadding), + navigator = navigator, + homeViewModel = homeViewModel, + scheduleViewModel = scheduleViewModel, + placeDetailViewModelFactory = placeDetailViewModelFactory, + newsViewModel = newsViewModel, + settingViewModel = settingViewModel, + notificationPermissionManager = notificationPermissionManager, + onNavigateToExplore = onNavigateToExplore, + ) + } +} - NavHost( - startDestination = navigator.startRoute, - navController = navigator.navController, - ) { - homeNavGraph( - padding = innerPadding, - viewModel = homeViewModel, - onNavigateToExplore = onNavigateToExplore, - ) - scheduleNavGraph( - padding = innerPadding, - viewModel = scheduleViewModel, - ) - placeMapNavGraph( - mapScreenVisibilityState = shouldShowPlaceMap, - ) - newsNavGraph( - padding = innerPadding, - viewModel = newsViewModel, - ) - settingNavGraph( - padding = innerPadding, - homeViewModel = homeViewModel, - settingViewModel = settingViewModel, - notificationPermissionManager = notificationPermissionManager, - onShowSnackBar = { }, - onShowErrorSnackBar = { }, - ) - } +@Composable +private fun FestabookNavHost( + navigator: FestabookNavigator, + homeViewModel: HomeViewModel, + scheduleViewModel: ScheduleViewModel, + placeDetailViewModelFactory: PlaceDetailViewModel.Factory, + newsViewModel: NewsViewModel, + settingViewModel: SettingViewModel, + notificationPermissionManager: NotificationPermissionManager, + onNavigateToExplore: () -> Unit, + modifier: Modifier = Modifier, +) { + NavHost( + modifier = modifier, + startDestination = navigator.startRoute, + navController = navigator.navController, + ) { + homeNavGraph( + viewModel = homeViewModel, + onNavigateToExplore = onNavigateToExplore, + ) + scheduleNavGraph( + viewModel = scheduleViewModel, + ) + placeMapNavGraph( + placeDetailViewModelFactory = placeDetailViewModelFactory, + onBackToPreviousClick = { navigator.popBackStack() }, + ) + newsNavGraph( + viewModel = newsViewModel, + ) + settingNavGraph( + homeViewModel = homeViewModel, + settingViewModel = settingViewModel, + notificationPermissionManager = notificationPermissionManager, + onShowSnackBar = { }, + 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 0efd70f7..037dca92 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 @@ -11,14 +11,11 @@ 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.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState @@ -50,11 +47,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.zIndex +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.FestabookImage import com.daedan.festabook.presentation.common.component.LoadingStateScreen import com.daedan.festabook.presentation.common.component.URLText +import com.daedan.festabook.presentation.placeDetail.PlaceDetailViewModel import com.daedan.festabook.presentation.placeDetail.model.ImageUiModel import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiState @@ -66,6 +65,20 @@ import com.daedan.festabook.presentation.theme.FestabookTheme import com.daedan.festabook.presentation.theme.FestabookTypography import com.daedan.festabook.presentation.theme.festabookSpacing +@Composable +fun PlaceDetailRoute( + viewModel: PlaceDetailViewModel, + onBackToPreviousClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val placeDetailUiState by viewModel.placeDetail.collectAsStateWithLifecycle() + PlaceDetailScreen( + modifier = modifier, + uiState = placeDetailUiState, + onBackToPreviousClick = onBackToPreviousClick, + ) +} + @Composable fun PlaceDetailScreen( uiState: PlaceDetailUiState, @@ -91,7 +104,10 @@ fun PlaceDetailScreen( Column( modifier = - modifier.verticalScroll(scrollState), + modifier + .fillMaxSize() + .background(color = FestabookColor.white) + .verticalScroll(scrollState), ) { PlaceDetailImageContent( images = uiState.placeDetail.images, @@ -189,7 +205,6 @@ private fun PlaceDetailImageContent( BackToPreviousButton( modifier = Modifier - .windowInsetsPadding(WindowInsets.statusBars) .padding( top = festabookSpacing.paddingBody4, start = festabookSpacing.paddingScreenGutter, 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 025d207c..ac9b9a7b 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 @@ -1,12 +1,91 @@ package com.daedan.festabook.presentation.placeMap.navigation -import androidx.compose.runtime.MutableState +import android.net.Uri +import android.os.Bundle +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType import androidx.navigation.compose.composable +import androidx.navigation.toRoute +import com.daedan.festabook.presentation.main.FestabookRoute import com.daedan.festabook.presentation.main.MainTabRoute +import com.daedan.festabook.presentation.placeDetail.PlaceDetailViewModel +import com.daedan.festabook.presentation.placeDetail.component.PlaceDetailRoute +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import kotlinx.serialization.json.Json +import kotlin.reflect.typeOf -fun NavGraphBuilder.placeMapNavGraph(mapScreenVisibilityState: MutableState) { +fun NavGraphBuilder.placeMapNavGraph( + onBackToPreviousClick: () -> Unit, + placeDetailViewModelFactory: PlaceDetailViewModel.Factory, +) { composable { - mapScreenVisibilityState.value = true + } + + composable( + typeMap = + mapOf( + typeOf() to defaultNavType(), + typeOf() to defaultNavType(), + ), + enterTransition = { + slideInVertically(initialOffsetY = { it / 10 }) + fadeIn() + }, + exitTransition = { + slideOutVertically(targetOffsetY = { it / 10 }) + fadeOut() + }, + ) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel = + viewModel( + factory = + PlaceDetailViewModel.factory( + placeDetailViewModelFactory, + route.placeUiModel, + route.placeDetailUiModel, + ), + ) + PlaceDetailRoute( + modifier = + Modifier.graphicsLayer( + // compositingStrategy를 사용해 자식들을 하나의 레이어로 강제 통합 + compositingStrategy = CompositingStrategy.Offscreen, + // 하드웨어 가속을 통해 애니메이션 도중 레이어가 쪼개지는 것을 방지 + clip = true, + ), + viewModel = viewModel, + onBackToPreviousClick = onBackToPreviousClick, + ) } } + +// TODO UIModel에서 Parcelable 제거 및 CMP에 맞게 안드로이드 의존성 제거 + +private inline fun defaultNavType() = + object : NavType(isNullableAllowed = true) { + override fun get( + bundle: Bundle, + key: String, + ): T? = bundle.getString(key)?.let { Json.decodeFromString(it) } + + override fun parseValue(value: String): T = + Json.decodeFromString( + Uri.decode(value), + ) + + override fun put( + bundle: Bundle, + key: String, + value: T, + ) = bundle.putString(key, Json.encodeToString(value)) + + override fun serializeAsValue(value: T): String = Uri.encode(Json.encodeToString(value)) + } From 28507ee5394cde7e8d998322927daa2a880fe9e7 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Fri, 23 Jan 2026 17:34:53 +0900 Subject: [PATCH 12/20] =?UTF-8?q?refactor(main):=20=EA=B8=B0=EC=A1=B4=20An?= =?UTF-8?q?droid=20=EA=B8=B0=EB=B0=98=20MainActivity=EB=A5=BC=20=EC=99=84?= =?UTF-8?q?=EC=A0=84=ED=95=9C=20Compose=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98=ED=95=98=EA=B3=A0=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존에 Fragment와 Compose를 혼용하던 `MainActivity`의 구조를 완전한 Compose 기반으로 리팩토링했습니다. 이 과정에서 Fragment 관련 코드를 모두 제거하고, 첫 방문 시 알림 설정 다이얼로그를 Compose 컴포넌트로 재구현했습니다. 또한, 이미지 프리로딩 로직을 `MainActivity`에서 `PlaceMap` 화면 내부로 이동시켜 책임을 명확히 분리했습니다. - **`MainActivity.kt` 수정:** - Fragment를 관리하던 코드(`switchFragment`, `setupFragmentFactory` 등)를 전부 삭제했습니다. - `OnBackPressedCallback`을 Compose의 `BackHandler`로 대체하여 뒤로가기 로직을 Compose 내에서 처리하도록 변경했습니다. - 첫 방문 사용자에게 보여주던 `MaterialAlertDialog`를 `FirstVisitDialog` 컴포저블로 대체했습니다. - `LiveData`와 `Event` 래퍼를 `StateFlow`, `SharedFlow`로 마이그레이션하여 상태 관리를 개선했습니다. - `onPreloadImages` 콜백을 제거하고, 이미지 프리로딩 책임을 `PlaceMap` 컴포저블로 이전했습니다. - **`FirstVisitDialog.kt` 추가:** - 첫 방문 시 알림 권한을 요청하는 다이얼로그를 Compose 기반의 `FirstVisitDialog` 컴포저블로 새로 구현했습니다. - **`PlaceMapScreen.kt` 수정:** - `MainActivity`에서 담당하던 이미지 프리로딩(`preloadImages`) 로직을 `PlaceMapRoute` 컴포저블 내부로 이동시켰습니다. - **기타 수정:** - **`PlaceDetailScreen.kt`:** `BackHandler`를 추가하여 뒤로가기 버튼 클릭 시 이전 화면으로 이동하도록 수정했습니다. - **`MainViewModel.kt`, `SettingViewModel.kt`:** `LiveData`를 `StateFlow` 및 `SharedFlow`로 전환하여 Compose 환경에 더 적합한 방식으로 상태를 관리하도록 수정했습니다. --- .../home/navigation/HomeNavigation.kt | 13 ++ .../presentation/main/MainActivity.kt | 221 +----------------- .../presentation/main/MainViewModel.kt | 44 +++- .../main/component/FirstVisitDialog.kt | 165 +++++++++++++ .../presentation/main/component/MainScreen.kt | 36 ++- .../component/PlaceDetailScreen.kt | 4 + .../placeMap/component/PlaceMapScreen.kt | 62 ++++- .../presentation/setting/SettingViewModel.kt | 5 +- 8 files changed, 318 insertions(+), 232 deletions(-) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/main/component/FirstVisitDialog.kt 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 707a95b8..588682c5 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 @@ -1,16 +1,29 @@ package com.daedan.festabook.presentation.home.navigation +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 +import com.daedan.festabook.presentation.main.MainViewModel +import com.daedan.festabook.presentation.main.component.FirstVisitDialog fun NavGraphBuilder.homeNavGraph( viewModel: HomeViewModel, + mainViewModel: MainViewModel, + onSubscriptionConfirm: () -> Unit, onNavigateToExplore: () -> Unit, ) { composable { + val isFirstVisit by mainViewModel.isFirstVisit.collectAsStateWithLifecycle() + if (isFirstVisit) { + FirstVisitDialog( + onConfirm = { onSubscriptionConfirm() }, + onDecline = { mainViewModel.declineAlert() }, + ) + } HomeScreen( viewModel = viewModel, onNavigateToExplore = onNavigateToExplore, 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 15fc7827..cb4490f8 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 @@ -4,7 +4,6 @@ import android.Manifest import android.content.Context import android.content.Intent import android.os.Bundle -import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher @@ -12,47 +11,21 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentFactory -import androidx.fragment.app.add -import androidx.fragment.app.commit -import androidx.fragment.app.commitNow -import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import coil3.imageLoader -import coil3.request.ImageRequest -import coil3.request.ImageResult import com.daedan.festabook.R -import com.daedan.festabook.databinding.ActivityMainBinding import com.daedan.festabook.di.appGraph import com.daedan.festabook.presentation.NotificationPermissionManager import com.daedan.festabook.presentation.NotificationPermissionRequester -import com.daedan.festabook.presentation.common.convertImageUrl import com.daedan.festabook.presentation.common.isGranted import com.daedan.festabook.presentation.common.showNotificationDeniedSnackbar -import com.daedan.festabook.presentation.common.showSnackBar -import com.daedan.festabook.presentation.common.showToast import com.daedan.festabook.presentation.explore.ExploreActivity -import com.daedan.festabook.presentation.home.HomeFragment -import com.daedan.festabook.presentation.home.HomeViewModel import com.daedan.festabook.presentation.main.component.MainScreen import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.placeDetail.PlaceDetailViewModel -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.setting.SettingViewModel import com.daedan.festabook.presentation.theme.FestabookTheme -import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.naver.maps.map.util.FusedLocationSource import dev.zacsweers.metro.Inject -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout import timber.log.Timber class MainActivity : @@ -64,19 +37,10 @@ class MainActivity : @Inject private lateinit var viewModelFactory: PlaceDetailViewModel.Factory - @Inject - private lateinit var fragmentFactory: FragmentFactory - @Inject private lateinit var notificationPermissionManagerFactory: NotificationPermissionManager.Factory - private val binding: ActivityMainBinding by lazy { - ActivityMainBinding.inflate(layoutInflater) - } - - private lateinit var currentTabState: MutableState private val mainViewModel: MainViewModel by viewModels() - private val homeViewModel: HomeViewModel by viewModels() private val newsViewModel: NewsViewModel by viewModels() private val settingViewModel: SettingViewModel by viewModels() @@ -114,90 +78,30 @@ class MainActivity : override fun onCreate(savedInstanceState: Bundle?) { appGraph.inject(this) - setupFragmentFactory() super.onCreate(savedInstanceState) enableEdgeToEdge() -// setupBinding() - setContent { LaunchedEffect(Unit) { handleNavigation(intent) } + FestabookTheme { MainScreen( notificationPermissionManager = notificationPermissionManager, logger = appGraph.defaultFirebaseLogger, locationSource = locationSource, placeDetailViewModelFactory = viewModelFactory, + onAppFinish = { finish() }, onNavigateToExplore = { startActivity(ExploreActivity.newIntent(this)) }, - onPreloadImages = { preloadImages(this, it.places) }, ) -// FestabookBottomNavigationBar( -// currentTab = currentTabState.value, -// onTabSelect = { tab -> -// when (tab) { -// FestabookMainTab.HOME -> { -// currentTabState.value = FestabookMainTab.HOME -// switchFragment(HomeFragment::class.java, TAG_HOME_FRAGMENT) -// } -// -// FestabookMainTab.SCHEDULE -> { -// currentTabState.value = FestabookMainTab.SCHEDULE -// val fragment = -// supportFragmentManager.findFragmentByTag(TAG_SCHEDULE_FRAGMENT) -// if (fragment is OnMenuItemReClickListener && !fragment.isHidden) fragment.onMenuItemReClick() -// switchFragment( -// ScheduleFragment::class.java, -// TAG_SCHEDULE_FRAGMENT, -// ) -// } -// -// FestabookMainTab.PLACE_MAP -> { -// currentTabState.value = FestabookMainTab.PLACE_MAP -// val fragment = -// supportFragmentManager.findFragmentByTag(TAG_PLACE_MAP_FRAGMENT) -// if (fragment is OnMenuItemReClickListener && !fragment.isHidden) fragment.onMenuItemReClick() -// switchFragment(PlaceMapFragment::class.java, TAG_PLACE_MAP_FRAGMENT) -// } -// -// FestabookMainTab.NEWS -> { -// currentTabState.value = FestabookMainTab.NEWS -// switchFragment(NewsFragment::class.java, TAG_NEWS_FRAGMENT) -// } -// -// FestabookMainTab.SETTING -> { -// currentTabState.value = FestabookMainTab.SETTING -// switchFragment( -// SettingFragment::class.java, -// TAG_SETTING_FRAGMENT, -// ) -// } -// } -// }, -// ) } } mainViewModel.registerDeviceAndFcmToken() -// setupHomeFragment(savedInstanceState) - setupObservers() - onBackPress() - } - - private fun setupBinding() { - setContentView(binding.root) -// ViewCompat.setOnApplyWindowInsetsListener(binding.main) { v, insets -> -// val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) -// v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) -// insets -// } - } - - private fun setupFragmentFactory() { - supportFragmentManager.fragmentFactory = fragmentFactory } + // TODO SnackBarHost로 변경 override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, @@ -211,7 +115,7 @@ class MainActivity : -> { if (!result.isGranted()) { showNotificationDeniedSnackbar( - binding.root, + window.decorView.rootView, this, getString(R.string.map_request_location_permission_message), ) @@ -230,125 +134,14 @@ class MainActivity : } private fun handleNavigation(intent: Intent) { - 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) newsViewModel.expandNotice(noticeIdToExpand) - - if (canNavigateToNewsScreen) { - currentTabState.value = FestabookMainTab.NEWS - } - } - - private fun setupObservers() { - mainViewModel.backPressEvent.observe(this) { event -> - event.getContentIfNotHandled()?.let { isDoublePress -> - if (isDoublePress) finish() else showToast(getString(R.string.back_press_exit_message)) - } - } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - homeViewModel.navigateToScheduleEvent.collect { - currentTabState.value = FestabookMainTab.SCHEDULE - } - } - } - - mainViewModel.isFirstVisit.observe(this) { isFirstVisit -> - if (isFirstVisit) { - showAlarmDialog() - } - } - settingViewModel.success.observe(this) { - showSnackBar(getString(R.string.setting_notice_enabled)) + val canNavigateToNews = intent.getBooleanExtra(KEY_CAN_NAVIGATE_TO_NEWS, false) + if (canNavigateToNews) { + mainViewModel.navigateToNews() } } - private fun setupHomeFragment(savedInstanceState: Bundle?) { - if (savedInstanceState == null) { - supportFragmentManager.commitNow { - add(R.id.fcv_fragment_container) - } - } - } - - private fun onBackPress() { - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - mainViewModel.onBackPressed() - } - }, - ) - } - - private fun switchFragment( - fragment: Class, - tag: String, - ) { - supportFragmentManager.commit { - supportFragmentManager.fragments.forEach { fragment -> hide(fragment) } - - val existing = supportFragmentManager.findFragmentByTag(tag) - if (existing != null) { - show(existing) - } else { - add(R.id.fcv_fragment_container, fragment, null, tag) - } - setReorderingAllowed(true) - } - } - - // OOM 주의 !! 추후 페이징 처리 및 chunk 단위로 나눠서 로드합니다 - private fun preloadImages( - context: Context, - places: List, - maxSize: Int = 20, - ) { - val imageLoader = context.imageLoader - val deferredList = mutableListOf>() - - lifecycleScope.launch(Dispatchers.IO) { - places - .take(maxSize) - .filterNotNull() - .forEach { place -> - val deferred = - async { - val request = - ImageRequest - .Builder(context) - .data(place.imageUrl.convertImageUrl()) - .build() - - runCatching { - withTimeout(2000) { - imageLoader.execute(request) - } - }.onFailure { - Timber.d("preload 실패") - }.getOrNull() - } - deferredList.add(deferred) - } - deferredList.awaitAll() - } - } - - private fun showAlarmDialog() { - val dialog = - MaterialAlertDialogBuilder(this, R.style.MainAlarmDialogTheme) - .setView(R.layout.view_main_alert_dialog) - .setPositiveButton(R.string.main_alarm_dialog_confirm_button) { _, _ -> - notificationPermissionManager.requestNotificationPermission(this) - }.setNegativeButton(R.string.main_alarm_dialog_cancel_button) { dialog, _ -> - dialog.dismiss() - }.create() - dialog.show() - } - companion object { const val KEY_NOTICE_ID_TO_EXPAND = "noticeIdToExpand" const val KEY_CAN_NAVIGATE_TO_NEWS = "canNavigateToNews" 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 32c0fb4b..1311a8d8 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 @@ -1,17 +1,20 @@ package com.daedan.festabook.presentation.main -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey import com.daedan.festabook.domain.repository.DeviceRepository import com.daedan.festabook.domain.repository.FestivalRepository -import com.daedan.festabook.presentation.common.Event import com.google.firebase.messaging.FirebaseMessaging import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import timber.log.Timber @@ -22,12 +25,22 @@ class MainViewModel( private val deviceRepository: DeviceRepository, festivalRepository: FestivalRepository, ) : ViewModel() { - private val _backPressEvent: MutableLiveData> = MutableLiveData() - val backPressEvent: LiveData> get() = _backPressEvent + private val _backPressEvent: MutableSharedFlow = + MutableSharedFlow( + extraBufferCapacity = 1, + ) + val backPressEvent: SharedFlow = _backPressEvent.asSharedFlow() + + private val _navigateNewsEvent: MutableSharedFlow = + MutableSharedFlow( + replay = 1, + extraBufferCapacity = 1, + ) + val navigateNewsEvent = _navigateNewsEvent.asSharedFlow() private val _isFirstVisit = - MutableLiveData(festivalRepository.getIsFirstVisit().getOrDefault(true)) - val isFirstVisit: LiveData get() = _isFirstVisit + MutableStateFlow(festivalRepository.getIsFirstVisit().getOrDefault(true)) + val isFirstVisit: StateFlow = _isFirstVisit.asStateFlow() private var lastBackPressedTime: Long = 0 @@ -37,7 +50,10 @@ class MainViewModel( Timber.d("registerDeviceAndFcmToken() UUID: $uuid, FCM: $fcmToken") when { - uuid.isBlank() -> Timber.w("❌ UUID 생성 전") + uuid.isBlank() -> { + Timber.w("❌ UUID 생성 전") + } + !fcmToken.isNullOrBlank() -> { Timber.d("✅ 기존 값으로 디바이스 등록 실행") registerDevice(uuid, fcmToken) @@ -58,13 +74,21 @@ class MainViewModel( } } + fun navigateToNews() { + _navigateNewsEvent.tryEmit(Unit) + } + + fun declineAlert() { + _isFirstVisit.value = false + } + fun onBackPressed() { val currentTime = System.currentTimeMillis() if (currentTime - lastBackPressedTime < BACK_PRESS_INTERVAL) { - _backPressEvent.value = Event(true) + _backPressEvent.tryEmit(true) } else { lastBackPressedTime = currentTime - _backPressEvent.value = Event(false) + _backPressEvent.tryEmit(false) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/component/FirstVisitDialog.kt b/app/src/main/java/com/daedan/festabook/presentation/main/component/FirstVisitDialog.kt new file mode 100644 index 00000000..5c483091 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/main/component/FirstVisitDialog.kt @@ -0,0 +1,165 @@ +package com.daedan.festabook.presentation.main.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +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.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.daedan.festabook.R +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 FirstVisitDialog( + onConfirm: () -> Unit, + onDecline: () -> Unit = {}, +) { + FirstVisitInfoDialog( + title = stringResource(id = R.string.main_alarm_dialog_title), + message = stringResource(id = R.string.main_alarm_dialog_message), + confirmButtonText = stringResource(id = R.string.main_alarm_dialog_confirm_button), + declineButtonText = stringResource(id = R.string.main_alarm_dialog_cancel_button), + iconResId = R.drawable.ic_alarm, + confirmButtonColor = FestabookColor.accentBlue, + declineButtonColor = FestabookColor.gray400, + onConfirm = onConfirm, + onDecline = onDecline, + ) +} + +@Composable +private fun FirstVisitInfoDialog( + title: String, + message: String, + confirmButtonText: String, + declineButtonText: String, + confirmButtonColor: Color, + declineButtonColor: Color, + onConfirm: () -> Unit, + onDecline: () -> Unit, + @DrawableRes iconResId: Int? = null, +) { + Dialog( + onDismissRequest = { onDecline() }, + properties = + DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + ) { + Column( + modifier = + Modifier + .background( + color = FestabookColor.white, + shape = festabookShapes.radius4, + ).padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + iconResId?.let { + Image( + painter = painterResource(id = it), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + Text( + text = title, + style = FestabookTypography.displaySmall, + textAlign = TextAlign.Center, + color = FestabookColor.gray800, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = message, + style = FestabookTypography.bodyMedium, + textAlign = TextAlign.Center, + color = FestabookColor.gray800, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Button( + onClick = onDecline, + modifier = + Modifier + .wrapContentWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = FestabookColor.white, + contentColor = FestabookColor.white, + ), + shape = festabookShapes.radiusFull, + border = BorderStroke(width = 1.dp, declineButtonColor), + contentPadding = PaddingValues(festabookSpacing.paddingBody1), + ) { + Text( + color = declineButtonColor, + text = declineButtonText, + ) + } + Spacer(Modifier.padding(festabookSpacing.paddingBody1)) + Button( + onClick = onConfirm, + modifier = + Modifier + .wrapContentWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = confirmButtonColor, + contentColor = FestabookColor.white, + ), + shape = festabookShapes.radiusFull, + contentPadding = PaddingValues(festabookSpacing.paddingBody3), + ) { + Text(text = confirmButtonText) + } + } + } + } +} + +@Preview +@Composable +private fun UpdateDialogPreview() { + FestabookTheme { + FirstVisitDialog( + onConfirm = {}, + onDecline = {}, + ) + } +} 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 5adf296d..4531dc06 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 @@ -1,5 +1,6 @@ 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.runtime.Composable @@ -9,11 +10,13 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import com.daedan.festabook.logging.DefaultFirebaseLogger import com.daedan.festabook.presentation.NotificationPermissionManager +import com.daedan.festabook.presentation.common.ObserveAsEvents import com.daedan.festabook.presentation.home.HomeViewModel import com.daedan.festabook.presentation.home.navigation.homeNavGraph import com.daedan.festabook.presentation.main.FestabookMainTab import com.daedan.festabook.presentation.main.FestabookNavigator import com.daedan.festabook.presentation.main.FestabookRoute +import com.daedan.festabook.presentation.main.MainViewModel import com.daedan.festabook.presentation.main.rememberFestabookNavigator import com.daedan.festabook.presentation.news.NewsViewModel import com.daedan.festabook.presentation.news.navigation.newsNavGraph @@ -21,7 +24,6 @@ import com.daedan.festabook.presentation.placeDetail.PlaceDetailViewModel import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel import com.daedan.festabook.presentation.placeMap.component.PlaceMapRoute import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent -import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect import com.daedan.festabook.presentation.placeMap.navigation.placeMapNavGraph import com.daedan.festabook.presentation.schedule.ScheduleViewModel import com.daedan.festabook.presentation.schedule.navigation.scheduleNavGraph @@ -36,9 +38,10 @@ fun MainScreen( logger: DefaultFirebaseLogger, locationSource: FusedLocationSource, placeDetailViewModelFactory: PlaceDetailViewModel.Factory, - onPreloadImages: (PlaceMapSideEffect.PreloadImages) -> Unit, // TODO: 추후 Context에 의존적이지 않게 변경 + onAppFinish: () -> Unit, onNavigateToExplore: () -> Unit, // TODO 검색화면 마이그레이션 시 제거 modifier: Modifier = Modifier, + mainViewModel: MainViewModel = viewModel(), homeViewModel: HomeViewModel = viewModel(), scheduleViewModel: ScheduleViewModel = viewModel(), placeMapViewModel: PlaceMapViewModel = viewModel(), @@ -47,6 +50,28 @@ fun MainScreen( ) { val navigator = rememberFestabookNavigator() + ObserveAsEvents(flow = mainViewModel.navigateNewsEvent) { + navigator.navigateToMainTab(FestabookMainTab.NEWS) + } + ObserveAsEvents(flow = mainViewModel.backPressEvent) { isDoublePress -> + if (isDoublePress) { + onAppFinish() + } else { + // TODO: SnackBarHost로 변경 +// showToast(getString(R.string.back_press_exit_message)) + } + } + 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 적용 bottomBar = { @@ -91,11 +116,11 @@ fun MainScreen( ), ) }, - onPreloadImages = onPreloadImages, ) FestabookNavHost( modifier = Modifier.padding(innerPadding), navigator = navigator, + mainViewModel = mainViewModel, homeViewModel = homeViewModel, scheduleViewModel = scheduleViewModel, placeDetailViewModelFactory = placeDetailViewModelFactory, @@ -110,6 +135,7 @@ fun MainScreen( @Composable private fun FestabookNavHost( navigator: FestabookNavigator, + mainViewModel: MainViewModel, homeViewModel: HomeViewModel, scheduleViewModel: ScheduleViewModel, placeDetailViewModelFactory: PlaceDetailViewModel.Factory, @@ -126,7 +152,11 @@ private fun FestabookNavHost( ) { homeNavGraph( viewModel = homeViewModel, + mainViewModel = mainViewModel, onNavigateToExplore = onNavigateToExplore, + onSubscriptionConfirm = { + notificationPermissionManager.requestNotificationPermission(navigator.navController.context) + }, ) scheduleNavGraph( viewModel = scheduleViewModel, 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 037dca92..0d5b73bc 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 @@ -1,5 +1,6 @@ package com.daedan.festabook.presentation.placeDetail.component +import androidx.activity.compose.BackHandler import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState @@ -72,6 +73,9 @@ fun PlaceDetailRoute( modifier: Modifier = Modifier, ) { val placeDetailUiState by viewModel.placeDetail.collectAsStateWithLifecycle() + BackHandler { + onBackToPreviousClick() + } PlaceDetailScreen( modifier = modifier, uiState = placeDetailUiState, 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 307d08cc..1e8d077e 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 @@ -1,5 +1,6 @@ package com.daedan.festabook.presentation.placeMap.component +import android.content.Context import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -9,14 +10,20 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.request.ImageResult import com.daedan.festabook.logging.DefaultFirebaseLogger import com.daedan.festabook.presentation.common.ObserveAsEvents +import com.daedan.festabook.presentation.common.convertImageUrl import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel import com.daedan.festabook.presentation.placeMap.intent.event.FilterEvent import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent @@ -29,20 +36,30 @@ import com.daedan.festabook.presentation.placeMap.intent.state.LoadState import com.daedan.festabook.presentation.placeMap.intent.state.MapDelegate import com.daedan.festabook.presentation.placeMap.intent.state.MapManagerDelegate import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.festabookSpacing import com.naver.maps.map.util.FusedLocationSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import timber.log.Timber @Composable @Suppress("ktlint:compose:vm-forwarding-check") fun PlaceMapRoute( placeMapViewModel: PlaceMapViewModel, onStartPlaceDetail: (PlaceMapSideEffect.StartPlaceDetail) -> Unit, - onPreloadImages: (PlaceMapSideEffect.PreloadImages) -> Unit, locationSource: FusedLocationSource, logger: DefaultFirebaseLogger, modifier: Modifier = Modifier, ) { + val context = LocalContext.current + val scope = rememberCoroutineScope() val uiState by placeMapViewModel.uiState.collectAsStateWithLifecycle() val density = LocalDensity.current val bottomSheetState = rememberPlaceListBottomSheetState() @@ -68,7 +85,13 @@ fun PlaceMapRoute( viewModel = placeMapViewModel, logger = logger, onStartPlaceDetail = onStartPlaceDetail, - onPreloadImages = onPreloadImages, + onPreloadImages = { + preloadImages( + context = context, + scope = scope, + places = it.places, + ) + }, onShowErrorSnackBar = { }, ) } @@ -189,3 +212,38 @@ fun PlaceMapScreen( } } } + +private fun preloadImages( + context: Context, + scope: CoroutineScope, + places: List, + maxSize: Int = 20, +) { + val imageLoader = context.imageLoader + val deferredList = mutableListOf>() + scope.launch(Dispatchers.IO) { + places + .take(maxSize) + .filterNotNull() + .forEach { place -> + val deferred = + async { + val request = + ImageRequest + .Builder(context) + .data(place.imageUrl.convertImageUrl()) + .build() + + runCatching { + withTimeout(2000) { + imageLoader.execute(request) + } + }.onFailure { + Timber.d("preload 실패") + }.getOrNull() + } + deferredList.add(deferred) + } + deferredList.awaitAll() + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt index 3bf2061f..360a01dc 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt @@ -1,8 +1,6 @@ package com.daedan.festabook.presentation.setting -import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey import com.daedan.festabook.domain.repository.FestivalNotificationRepository @@ -43,7 +41,8 @@ class SettingViewModel( private val _success: MutableSharedFlow = MutableSharedFlow() - val success: LiveData = _success.asLiveData() + + val success = _success.asSharedFlow() val successFlow = _success.asSharedFlow() fun notificationAllowClick() { From b9251e5982a5872720542b2d954624c71aef2960 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Fri, 23 Jan 2026 17:50:19 +0900 Subject: [PATCH 13/20] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EC=97=90=EC=84=9C=20`LiveData`=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B0=A9=EC=8B=9D=EC=9D=84=20`observeEven?= =?UTF-8?q?t`=20=ED=97=AC=ED=8D=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 `getOrAwaitValue`를 사용하던 `MainViewModelTest`의 `LiveData` 이벤트 테스트 방식을 새로운 `observeEvent` 유틸리티 함수를 사용하도록 수정했습니다. 이 변경으로 비동기 이벤트 처리를 더 명확하고 안정적으로 테스트할 수 있게 되었습니다. - **`main/MainViewModelTest.kt` 수정:** - `getOrAwaitValue` 대신 `observeEvent`를 사용하여 `backPressEvent` `LiveData`를 관찰하도록 변경했습니다. - `runCurrent()`와 `await()`를 사용하여 코루틴의 실행 시점을 제어하고 이벤트 발생을 기다리는 로직으로 수정했습니다. - **`news/NewsTestFixture.kt`, `news/NewsViewModelTest.kt` 수정:** - 테스트 데이터 생성 시 사용되던 `java.time.LocalDateTime`을 `kotlinx.datetime.LocalDateTime`으로 통일하여 코드 일관성을 개선했습니다. --- .../daedan/festabook/main/MainViewModelTest.kt | 17 ++++++++++++----- .../daedan/festabook/news/NewsTestFixture.kt | 8 ++++---- .../daedan/festabook/news/NewsViewModelTest.kt | 3 +-- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/app/src/test/java/com/daedan/festabook/main/MainViewModelTest.kt b/app/src/test/java/com/daedan/festabook/main/MainViewModelTest.kt index 6411dfc7..ce6bb81f 100644 --- a/app/src/test/java/com/daedan/festabook/main/MainViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/main/MainViewModelTest.kt @@ -4,7 +4,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.daedan.festabook.domain.repository.DeviceRepository import com.daedan.festabook.domain.repository.FestivalNotificationRepository import com.daedan.festabook.domain.repository.FestivalRepository -import com.daedan.festabook.getOrAwaitValue +import com.daedan.festabook.observeEvent import com.daedan.festabook.presentation.main.MainViewModel import com.google.firebase.messaging.FirebaseMessaging import io.mockk.coEvery @@ -18,6 +18,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.assertj.core.api.Assertions.assertThat @@ -97,23 +98,29 @@ class MainViewModelTest { fun `뒤로 가기를 두 번 빠르게 두 번 클릭했을 때, 종료 이벤트가 발생한다`() = runTest { // given - when + val event = observeEvent(mainViewModel.backPressEvent) mainViewModel.onBackPressed() + runCurrent() mainViewModel.onBackPressed() // then - val actual = mainViewModel.backPressEvent.getOrAwaitValue() - assertThat(actual.peekContent()).isTrue() + val actual = event.await() + advanceUntilIdle() + assertThat(actual).isTrue() } @Test fun `뒤로 가기를 한 번만 클릭했을 때 종료 이벤트가 발생하지 않는다`() = runTest { // given - when + val event = observeEvent(mainViewModel.backPressEvent) + runCurrent() mainViewModel.onBackPressed() // then - val actual = mainViewModel.backPressEvent.getOrAwaitValue() - assertThat(actual.peekContent()).isFalse() + val actual = event.await() + advanceUntilIdle() + assertThat(actual).isFalse() } @Test diff --git a/app/src/test/java/com/daedan/festabook/news/NewsTestFixture.kt b/app/src/test/java/com/daedan/festabook/news/NewsTestFixture.kt index a20631d4..e7dd384d 100644 --- a/app/src/test/java/com/daedan/festabook/news/NewsTestFixture.kt +++ b/app/src/test/java/com/daedan/festabook/news/NewsTestFixture.kt @@ -6,7 +6,7 @@ import com.daedan.festabook.domain.model.LostItemStatus import com.daedan.festabook.domain.model.Notice import com.daedan.festabook.presentation.news.lost.model.toLostGuideItemUiModel import com.daedan.festabook.presentation.news.lost.model.toLostItemUiModel -import java.time.LocalDateTime +import kotlinx.datetime.LocalDateTime val FAKE_NOTICES = listOf( @@ -15,14 +15,14 @@ val FAKE_NOTICES = title = "테스트 1", content = "테스트 1", isPinned = false, - createdAt = LocalDateTime.of(2025, 1, 1, 0, 0, 0), + createdAt = LocalDateTime(2025, 1, 1, 0, 0, 0), ), Notice( id = 2, title = "테스트 2", content = "테스트 2", isPinned = true, - createdAt = LocalDateTime.of(2025, 1, 1, 0, 0, 0), + createdAt = LocalDateTime(2025, 1, 1, 0, 0, 0), ), ) @@ -46,7 +46,7 @@ val FAKE_LOST_ITEM = imageUrl = "테스트 이미지 주소", storageLocation = "테스트 장소", status = LostItemStatus.PENDING, - createdAt = LocalDateTime.of(2025, 1, 1, 0, 0, 0), + createdAt = java.time.LocalDateTime.of(2025, 1, 1, 0, 0, 0), ), ) 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 e5e15183..70cc1faa 100644 --- a/app/src/test/java/com/daedan/festabook/news/NewsViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/news/NewsViewModelTest.kt @@ -31,7 +31,6 @@ import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test -import java.time.LocalDateTime @OptIn(ExperimentalCoroutinesApi::class) class NewsViewModelTest { @@ -179,7 +178,7 @@ class NewsViewModelTest { title = "테스트 2", content = "테스트 2", isPinned = true, - createdAt = LocalDateTime.of(2025, 1, 1, 0, 0, 0), + createdAt = kotlinx.datetime.LocalDateTime(2025, 1, 1, 0, 0, 0), ), ) val actual = newsViewModel.noticeUiState.value From be5f972e9a2e2570e9e73ef70c1213b7556b1995 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Tue, 27 Jan 2026 14:41:19 +0900 Subject: [PATCH 14/20] =?UTF-8?q?refactor(PlaceMap):=20=EC=A7=80=EB=8F=84?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EC=88=A8=EA=B9=80=20=EC=8B=9C=20?= =?UTF-8?q?=ED=84=B0=EC=B9=98=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B9=84?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지도 화면(`PlaceMapRoute`)이 보이지 않을 때 터치 이벤트를 소비(consume)하도록 `pointerInput`을 추가하여, 지도 화면이 다른 화면 뒤에 가려져 있을 때 발생하는 의도치 않은 상호작용을 차단했습니다. 기존에는 `alpha` 값을 0으로 설정하여 화면을 투명하게만 만들었기 때문에, 보이지 않는 상태에서도 지도와의 터치 상호작용이 가능했습니다. 이 문제를 해결하기 위해 `pointerInput`을 사용하여 `isVisible` 상태가 `false`일 때 모든 포인터 이벤트를 차단하도록 로직을 개선했습니다. - **`MainScreen.kt` 수정:** - `PlaceMapRoute`의 `Modifier`에 `pointerInput(isVisible)` 블록을 추가했습니다. - 지도 화면이 보이지 않을 때(`isVisible`이 `false`일 경우), `awaitPointerEventScope`를 통해 들어오는 모든 포인터 이벤트를 `consume()`하여 하위 컴포저블로 전달되지 않도록 처리했습니다. - 가시성 제어 로직을 `alpha` 속성에서 `graphicsLayer`의 `alpha`로 이동시켰습니다. --- .../presentation/main/component/MainScreen.kt | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) 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 4531dc06..4b5f674a 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 @@ -5,7 +5,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import com.daedan.festabook.logging.DefaultFirebaseLogger @@ -100,12 +102,24 @@ fun MainScreen( }, modifier = modifier, ) { innerPadding -> + val isVisible = navigator.currentTab == FestabookMainTab.PLACE_MAP PlaceMapRoute( modifier = Modifier - .alpha( - if (navigator.currentTab == FestabookMainTab.PLACE_MAP) 1f else 0f, - ).padding(innerPadding), + .graphicsLayer { + alpha = if (isVisible) 1f else 0f + }.padding(innerPadding) + .pointerInput(isVisible) { + if (!isVisible) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Initial) + .changes + .forEach { it.consume() } + } + } + } + }, placeMapViewModel = placeMapViewModel, locationSource = locationSource, logger = logger, From bf9df2507197b1aa21fd69cfa6f31337598a2e56 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Tue, 27 Jan 2026 15:13:37 +0900 Subject: [PATCH 15/20] =?UTF-8?q?refactor(setting):=20`successFlow`?= =?UTF-8?q?=EB=A5=BC=20`success`=EB=A1=9C=20=EC=9D=B4=EB=A6=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SettingViewModel`에서 사용되던 `successFlow`의 이름을 `success`로 변경하여, `_success`와 `success`가 쌍을 이루도록 하여 코드의 일관성과 가독성을 높였습니다. - **`SettingViewModel.kt` 수정:** - 불필요한 `successFlow` 프로퍼티를 제거하고 `success`를 사용하도록 통일했습니다. - **`SettingScreen.kt`, `SettingFragment.kt` 수정:** - `ViewModel`의 변경된 프로퍼티 이름(`success`)을 참조하도록 수정했습니다. --- .../daedan/festabook/presentation/setting/SettingFragment.kt | 2 +- .../daedan/festabook/presentation/setting/SettingViewModel.kt | 1 - .../festabook/presentation/setting/component/SettingScreen.kt | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt index 024d5c51..92d5d57b 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt @@ -96,7 +96,7 @@ class SettingFragment( notificationPermissionManager.requestNotificationPermission(context) } - ObserveAsEvents(flow = settingViewModel.successFlow) { + ObserveAsEvents(flow = settingViewModel.success) { requireActivity().showSnackBar(getString(R.string.setting_notice_enabled)) } diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt index 360a01dc..c383ed77 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt @@ -43,7 +43,6 @@ class SettingViewModel( MutableSharedFlow() val success = _success.asSharedFlow() - val successFlow = _success.asSharedFlow() fun notificationAllowClick() { if (!_isAllowed.value) { diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/component/SettingScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/component/SettingScreen.kt index ca077bdf..2daa6027 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/setting/component/SettingScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/component/SettingScreen.kt @@ -77,7 +77,7 @@ fun SettingRoute( notificationPermissionManager.requestNotificationPermission(context) } - ObserveAsEvents(flow = settingViewModel.successFlow) { + ObserveAsEvents(flow = settingViewModel.success) { onShowSnackBar(context.getString(R.string.setting_notice_enabled)) } From d352259aa486cd3576bd9625426c353537abeb20 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Tue, 27 Jan 2026 15:22:55 +0900 Subject: [PATCH 16/20] =?UTF-8?q?refactor(UI):=20=ED=95=98=EB=8B=A8=20?= =?UTF-8?q?=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EB=B0=94?= =?UTF-8?q?=EC=97=90=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=ED=8C=A8=EB=94=A9=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FestabookBottomNavigationBar`의 최상위 `Box` 컴포저블에 `.navigationBarsPadding()` 수정자를 추가했습니다. 이를 통해 제스처 네비게이션과 같은 시스템 UI와 겹치지 않도록 하단 네비게이션 바에 적절한 패딩을 적용하여 UI 일관성을 개선했습니다. --- .../main/component/FestabookBottomNavigationBar.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index 9b81045a..c49de79f 100644 --- 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 @@ -13,6 +13,7 @@ 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.navigationBarsPadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.selection.selectable import androidx.compose.material3.Icon @@ -47,7 +48,8 @@ fun FestabookBottomNavigationBar( ) { Box( contentAlignment = Alignment.BottomCenter, - modifier = modifier, + modifier = + modifier.navigationBarsPadding(), ) { Row( modifier = From 65c2fbe63e0ba34d7989eb5f93fb92b4fd956aa6 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Tue, 27 Jan 2026 15:26:46 +0900 Subject: [PATCH 17/20] =?UTF-8?q?refactor(main):=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=9A=94=EC=B2=AD=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9D=84=20Activity=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MainScreen` 컴포저블 내에서 직접 처리하던 알림 권한 요청 로직을 `MainActivity`로 상위로 이동시키는 리팩토링을 진행했습니다. 이를 통해 권한 요청의 책임을 컴포저블이 아닌 Activity가 갖도록 하여 관심사를 분리하고 코드 구조를 개선했습니다. - **`MainActivity.kt` 수정:** - `MainScreen` 컴포저블에 `onSubscriptionConfirm` 콜백을 추가하여, `notificationPermissionManager`를 통해 알림 권한을 요청하는 로직을 `MainActivity`에서 직접 처리하도록 변경했습니다. - **`main/component/MainScreen.kt` 수정:** - `MainScreen`과 내부 `NavHost`에 `onSubscriptionConfirm` 콜백 파라미터를 추가했습니다. - 기존에 `homeNavGraph`에서 `Context`를 이용해 직접 권한을 요청하던 구현을 제거하고, 상위로부터 전달받은 `onSubscriptionConfirm` 콜백을 호출하도록 수정했습니다. --- .../com/daedan/festabook/presentation/main/MainActivity.kt | 3 +++ .../festabook/presentation/main/component/MainScreen.kt | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) 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 cb4490f8..ed6726aa 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 @@ -92,6 +92,9 @@ class MainActivity : locationSource = locationSource, placeDetailViewModelFactory = viewModelFactory, onAppFinish = { finish() }, + onSubscriptionConfirm = { + notificationPermissionManager.requestNotificationPermission(this) + }, onNavigateToExplore = { startActivity(ExploreActivity.newIntent(this)) }, 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 4b5f674a..952c828f 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 @@ -41,6 +41,7 @@ fun MainScreen( locationSource: FusedLocationSource, placeDetailViewModelFactory: PlaceDetailViewModel.Factory, onAppFinish: () -> Unit, + onSubscriptionConfirm: () -> Unit, onNavigateToExplore: () -> Unit, // TODO 검색화면 마이그레이션 시 제거 modifier: Modifier = Modifier, mainViewModel: MainViewModel = viewModel(), @@ -142,6 +143,7 @@ fun MainScreen( settingViewModel = settingViewModel, notificationPermissionManager = notificationPermissionManager, onNavigateToExplore = onNavigateToExplore, + onSubscriptionConfirm = onSubscriptionConfirm, ) } } @@ -157,6 +159,7 @@ private fun FestabookNavHost( settingViewModel: SettingViewModel, notificationPermissionManager: NotificationPermissionManager, onNavigateToExplore: () -> Unit, + onSubscriptionConfirm: () -> Unit, modifier: Modifier = Modifier, ) { NavHost( @@ -168,9 +171,7 @@ private fun FestabookNavHost( viewModel = homeViewModel, mainViewModel = mainViewModel, onNavigateToExplore = onNavigateToExplore, - onSubscriptionConfirm = { - notificationPermissionManager.requestNotificationPermission(navigator.navController.context) - }, + onSubscriptionConfirm = onSubscriptionConfirm, ) scheduleNavGraph( viewModel = scheduleViewModel, From 7ef34f09f4a0b45f84b14b65fcb109e94fdfd781 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Tue, 27 Jan 2026 15:32:35 +0900 Subject: [PATCH 18/20] =?UTF-8?q?fix(PlaceDetail):=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EC=96=BC=EB=A1=9C=EA=B7=B8=20=ED=91=9C=EC=8B=9C=20=EC=A4=91=20?= =?UTF-8?q?=EB=92=A4=EB=A1=9C=EA=B0=80=EA=B8=B0=20=EB=8F=99=EC=9E=91=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 장소 상세 화면에서 다이얼로그가 열려 있을 때, 뒤로가기 버튼을 누르면 다이얼로그가 닫히지 않고 이전 화면으로 이동하는 버그를 해결했습니다. - **`PlaceDetailScreen.kt` 수정:** - 기존에 최상단에 있던 `BackHandler`를 `PlaceDetailScreen` 컴포저블 내부로 이동시켰습니다. - 다이얼로그의 열림 상태(`isDialogOpen`)에 따라 `BackHandler`의 활성화 여부를 제어하도록 수정했습니다. 다이얼로그가 열려있지 않을 때(`!isDialogOpen`)만 `onBackToPreviousClick` 콜백이 호출되어 이전 화면으로 이동합니다. --- .../presentation/placeDetail/component/PlaceDetailScreen.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 0d5b73bc..9ec75b47 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 @@ -73,9 +73,6 @@ fun PlaceDetailRoute( modifier: Modifier = Modifier, ) { val placeDetailUiState by viewModel.placeDetail.collectAsStateWithLifecycle() - BackHandler { - onBackToPreviousClick() - } PlaceDetailScreen( modifier = modifier, uiState = placeDetailUiState, @@ -91,6 +88,9 @@ fun PlaceDetailScreen( ) { val scrollState = rememberScrollState() var isDialogOpen by remember { mutableStateOf(false) } + BackHandler(enabled = !isDialogOpen) { + onBackToPreviousClick() + } when (uiState) { is PlaceDetailUiState.Success -> { From 91abcef530500077e16cd9bdb6bd159d766e9378 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Tue, 27 Jan 2026 15:39:31 +0900 Subject: [PATCH 19/20] =?UTF-8?q?refactor(PlaceMap):=20`remember`=20?= =?UTF-8?q?=ED=82=A4=20=EC=B6=94=EA=B0=80=EB=A1=9C=20SideEffect=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=9E=AC=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지도 화면(`PlaceMapScreen`)에서 `SideEffect` 핸들러 객체들을 생성하는 `remember` 블록에 키(key)를 명시적으로 추가했습니다. 이를 통해 의존성이 변경될 때마다 핸들러가 올바르게 재생성되도록 보장하여, 상태 불일치로 인해 발생할 수 있는 잠재적인 버그를 예방합니다. - **`PlaceMapScreen.kt` 수정:** - `MapControlSideEffectHandler`를 생성하는 `remember`에 `placeMapViewModel`, `logger`, `locationSource`, `mapDelegate`, `mapManagerDelegate`를 키로 추가했습니다. - `PlaceMapSideEffectHandler`를 생성하는 `remember`에 `placeMapViewModel`, `logger`, `mapManagerDelegate`를 키로 추가했습니다. - **`FestabookBottomNavigationBar.kt` 수정:** - 하단 네비게이션 바의 배경색이 누락되어 있던 문제를 해결하기 위해, `Box` 컴포저블에 흰색(`FestabookColor.white`) 배경을 추가했습니다. --- .../main/component/FestabookBottomNavigationBar.kt | 4 +++- .../presentation/placeMap/component/PlaceMapScreen.kt | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) 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 index c49de79f..0196e366 100644 --- 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 @@ -49,7 +49,9 @@ fun FestabookBottomNavigationBar( Box( contentAlignment = Alignment.BottomCenter, modifier = - modifier.navigationBarsPadding(), + modifier + .background(color = FestabookColor.white) + .navigationBarsPadding(), ) { Row( modifier = 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 1e8d077e..0608f076 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 @@ -67,7 +67,7 @@ fun PlaceMapRoute( val mapManagerDelegate = remember { MapManagerDelegate() } val mapControlSideEffectHandler = - remember { + remember(placeMapViewModel, logger, locationSource, mapDelegate, mapManagerDelegate) { MapControlSideEffectHandler( initialPadding = with(density) { 254.dp.toPx() }.toInt(), logger = logger, @@ -78,7 +78,7 @@ fun PlaceMapRoute( ) } val placeMapSideEffectHandler = - remember { + remember(placeMapViewModel, logger, mapManagerDelegate) { PlaceMapSideEffectHandler( mapManagerDelegate = mapManagerDelegate, bottomSheetState = bottomSheetState, From 4c5d37943e67578357d3f8858ede50b7795c8ab9 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Wed, 28 Jan 2026 10:58:09 +0900 Subject: [PATCH 20/20] =?UTF-8?q?feat(main):=20=ED=95=98=EB=8B=A8=20?= =?UTF-8?q?=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EB=B0=94?= =?UTF-8?q?=EC=97=90=20=EA=B7=B8=EB=A6=BC=EC=9E=90=20=ED=9A=A8=EA=B3=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 하단 네비게이션 바(`FestabookBottomNavigationBar`)에 `shadow` Modifier를 적용하여 입체감을 더하고 다른 UI 요소와의 구분을 명확하게 했습니다. - **`FestabookBottomNavigationBar.kt` 수정:** - `Box` 컴포저블에 `shadow(elevation = 10.dp)`를 추가하여 그림자 효과를 적용했습니다. --- .../main/component/FestabookBottomNavigationBar.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index 0196e366..657401fb 100644 --- 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 @@ -26,6 +26,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -50,7 +51,10 @@ fun FestabookBottomNavigationBar( contentAlignment = Alignment.BottomCenter, modifier = modifier - .background(color = FestabookColor.white) + .shadow( + elevation = 10.dp, + clip = false, + ).background(color = FestabookColor.white) .navigationBarsPadding(), ) { Row(