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/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/home/navigation/HomeNavigation.kt b/app/src/main/java/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt index 13bdf5fb..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,22 +1,30 @@ 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.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( - padding: PaddingValues, viewModel: HomeViewModel, + mainViewModel: MainViewModel, + onSubscriptionConfirm: () -> Unit, onNavigateToExplore: () -> Unit, ) { composable { + val isFirstVisit by mainViewModel.isFirstVisit.collectAsStateWithLifecycle() + if (isFirstVisit) { + FirstVisitDialog( + onConfirm = { onSubscriptionConfirm() }, + onDecline = { mainViewModel.declineAlert() }, + ) + } 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 7e5101a7..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 @@ -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() = @@ -46,10 +38,16 @@ class FestabookNavigator( val startRoute = MainTabRoute.Home // TODO: Splash와 Explore 연동 시 변경 - fun navigateToMainTab(route: FestabookRoute) { + fun navigateToMainTab(tab: FestabookMainTab) { navController.navigate( - route, - defaultNavOptions, + tab.route, + navOptions { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + }, ) } 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 528af417..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,46 +4,28 @@ 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 import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -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 -import androidx.fragment.app.commit -import androidx.fragment.app.commitNow -import androidx.lifecycle.Lifecycle +import androidx.compose.runtime.LaunchedEffect import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle 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.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.home.HomeFragment -import com.daedan.festabook.presentation.home.HomeViewModel -import com.daedan.festabook.presentation.news.NewsFragment +import com.daedan.festabook.presentation.explore.ExploreActivity +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.placeDetail.PlaceDetailViewModel import com.daedan.festabook.presentation.setting.SettingViewModel -import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.naver.maps.map.util.FusedLocationSource import dev.zacsweers.metro.Inject -import kotlinx.coroutines.launch import timber.log.Timber class MainActivity : @@ -53,16 +35,12 @@ class MainActivity : override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory @Inject - private lateinit var fragmentFactory: FragmentFactory + private lateinit var viewModelFactory: PlaceDetailViewModel.Factory @Inject private lateinit var notificationPermissionManagerFactory: NotificationPermissionManager.Factory - private val binding: ActivityMainBinding by lazy { - ActivityMainBinding.inflate(layoutInflater) - } private val mainViewModel: MainViewModel by viewModels() - private val homeViewModel: HomeViewModel by viewModels() private val newsViewModel: NewsViewModel by viewModels() private val settingViewModel: SettingViewModel by viewModels() @@ -74,6 +52,10 @@ class MainActivity : ) } + private val locationSource by lazy { + FusedLocationSource(this, LOCATION_PERMISSION_REQUEST_CODE) + } + override val permissionLauncher: ActivityResultLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission(), @@ -96,24 +78,30 @@ class MainActivity : override fun onCreate(savedInstanceState: Bundle?) { appGraph.inject(this) - setupFragmentFactory() super.onCreate(savedInstanceState) enableEdgeToEdge() - setupBinding() - mainViewModel.registerDeviceAndFcmToken() - setupHomeFragment(savedInstanceState) - setUpBottomNavigation() - setupObservers() - onMenuItemClick() - onMenuItemReClick() - onBackPress() - handleNavigation(intent) - } + setContent { + LaunchedEffect(Unit) { + handleNavigation(intent) + } - private fun setupFragmentFactory() { - supportFragmentManager.fragmentFactory = fragmentFactory + FestabookTheme { + MainScreen( + notificationPermissionManager = notificationPermissionManager, + logger = appGraph.defaultFirebaseLogger, + locationSource = locationSource, + placeDetailViewModelFactory = viewModelFactory, + onAppFinish = { finish() }, + onNavigateToExplore = { + startActivity(ExploreActivity.newIntent(this)) + }, + ) + } + } + mainViewModel.registerDeviceAndFcmToken() } + // TODO SnackBarHost로 변경 override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, @@ -127,7 +115,7 @@ class MainActivity : -> { if (!result.isGranted()) { showNotificationDeniedSnackbar( - binding.root, + window.decorView.rootView, this, getString(R.string.map_request_location_permission_message), ) @@ -146,178 +134,19 @@ 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) { - binding.bnvMenu.selectedItemId = R.id.item_menu_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 { - if (binding.bnvMenu.selectedItemId != R.id.item_menu_schedule) { - binding.bnvMenu.selectedItemId = R.id.item_menu_schedule - } - } - } - } - - mainViewModel.isFirstVisit.observe(this) { isFirstVisit -> - if (isFirstVisit) { - showAlarmDialog() - } - } - settingViewModel.success.observe(this) { - showSnackBar(getString(R.string.setting_notice_enabled)) - } - } - - 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 + val canNavigateToNews = intent.getBooleanExtra(KEY_CAN_NAVIGATE_TO_NEWS, false) + if (canNavigateToNews) { + mainViewModel.navigateToNews() } } - 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 { - add(R.id.fcv_fragment_container) - } - } - } - - private fun onBackPress() { - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - mainViewModel.onBackPressed() - } - }, - ) - } - - 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, - ) { - 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) - } - } - - 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" - 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/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/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/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 8c3c7869..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,48 +1,179 @@ package com.daedan.festabook.presentation.main.component -import androidx.compose.foundation.layout.fillMaxSize +import androidx.activity.compose.BackHandler +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.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 +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.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 +@Suppress("ktlint:compose:vm-forwarding-check") fun MainScreen( - homeViewModel: HomeViewModel, + notificationPermissionManager: NotificationPermissionManager, + logger: DefaultFirebaseLogger, + locationSource: FusedLocationSource, + placeDetailViewModelFactory: PlaceDetailViewModel.Factory, + onAppFinish: () -> Unit, + onNavigateToExplore: () -> Unit, // TODO 검색화면 마이그레이션 시 제거 modifier: Modifier = Modifier, + mainViewModel: MainViewModel = viewModel(), + homeViewModel: HomeViewModel = viewModel(), + scheduleViewModel: ScheduleViewModel = viewModel(), + placeMapViewModel: PlaceMapViewModel = viewModel(), + newsViewModel: NewsViewModel = viewModel(), + settingViewModel: SettingViewModel = viewModel(), ) { 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 = { if (navigator.shouldShowBottomBar) { FestabookBottomNavigationBar( currentTab = navigator.currentTab, - onTabSelect = { navigator.navigateToMainTab(it.route) }, + onTabSelect = { navigator.navigateToMainTab(it) }, + onTabReSelect = { tab -> + when (tab) { + FestabookMainTab.SCHEDULE -> { + scheduleViewModel.loadSchedules() + } + + FestabookMainTab.PLACE_MAP -> { + placeMapViewModel.onPlaceMapEvent(SelectEvent.UnSelectPlace) + placeMapViewModel.onMenuItemReClicked() + } + + else -> { + Unit + } + } + }, ) } }, modifier = modifier, ) { innerPadding -> - NavHost( - modifier = Modifier.fillMaxSize(), - startDestination = navigator.startRoute, - navController = navigator.navController, - ) { - homeNavGraph( - padding = innerPadding, - viewModel = homeViewModel, - onNavigateToExplore = { navigator.navigate(FestabookRoute.Explore) }, - ) + PlaceMapRoute( + modifier = + Modifier + .alpha( + if (navigator.currentTab == FestabookMainTab.PLACE_MAP) 1f else 0f, + ).padding(innerPadding), + placeMapViewModel = placeMapViewModel, + locationSource = locationSource, + logger = logger, + onStartPlaceDetail = { + navigator.navigate( + FestabookRoute.PlaceDetail( + placeDetailUiModel = it.placeDetail.value, + ), + ) + }, + ) + FestabookNavHost( + modifier = Modifier.padding(innerPadding), + navigator = navigator, + mainViewModel = mainViewModel, + homeViewModel = homeViewModel, + scheduleViewModel = scheduleViewModel, + placeDetailViewModelFactory = placeDetailViewModelFactory, + newsViewModel = newsViewModel, + settingViewModel = settingViewModel, + notificationPermissionManager = notificationPermissionManager, + onNavigateToExplore = onNavigateToExplore, + ) + } +} - // 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 - } +@Composable +private fun FestabookNavHost( + navigator: FestabookNavigator, + mainViewModel: MainViewModel, + 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, + mainViewModel = mainViewModel, + onNavigateToExplore = onNavigateToExplore, + onSubscriptionConfirm = { + notificationPermissionManager.requestNotificationPermission(navigator.navController.context) + }, + ) + 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/news/navigation/NewsNavigation.kt b/app/src/main/java/com/daedan/festabook/presentation/news/navigation/NewsNavigation.kt new file mode 100644 index 00000000..7e286e41 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/news/navigation/NewsNavigation.kt @@ -0,0 +1,15 @@ +package com.daedan.festabook.presentation.news.navigation + +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(viewModel: NewsViewModel) { + composable { + NewsScreen( + newsViewModel = viewModel, + ) + } +} 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/component/PlaceDetailScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/component/PlaceDetailScreen.kt index 0efd70f7..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 @@ -11,14 +12,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 +48,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 +66,23 @@ 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() + BackHandler { + onBackToPreviousClick() + } + PlaceDetailScreen( + modifier = modifier, + uiState = placeDetailUiState, + onBackToPreviousClick = onBackToPreviousClick, + ) +} + @Composable fun PlaceDetailScreen( uiState: PlaceDetailUiState, @@ -91,7 +108,10 @@ fun PlaceDetailScreen( Column( modifier = - modifier.verticalScroll(scrollState), + modifier + .fillMaxSize() + .background(color = FestabookColor.white) + .verticalScroll(scrollState), ) { PlaceDetailImageContent( images = uiState.placeDetail.images, @@ -189,7 +209,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/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/component/NaverMapContent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt index ed43be57..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 @@ -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,18 @@ 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), + onRelease = { + mapView.onDestroy() + }, + ) + content(mapDelegate.value) + } RegisterMapLifeCycle(mapView) - content(mapDelegate.value) } private fun Modifier.dragInterceptor(onMapDrag: () -> Unit): Modifier = @@ -115,27 +122,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 - } } } } 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..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 @@ -7,18 +8,110 @@ 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.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 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.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, + 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() + 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 = { + preloadImages( + context = context, + scope = scope, + places = it.places, + ) + }, + onShowErrorSnackBar = { }, + ) + } + + 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( @@ -119,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/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/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..ac9b9a7b --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt @@ -0,0 +1,91 @@ +package com.daedan.festabook.presentation.placeMap.navigation + +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( + onBackToPreviousClick: () -> Unit, + placeDetailViewModelFactory: PlaceDetailViewModel.Factory, +) { + composable { + } + + 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)) + } 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..1d011fdb --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/navigation/ScheduleNavigation.kt @@ -0,0 +1,15 @@ +package com.daedan.festabook.presentation.schedule.navigation + +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(viewModel: ScheduleViewModel) { + composable { + ScheduleScreen( + scheduleViewModel = viewModel, + ) + } +} 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() { 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..a7ab51fc --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt @@ -0,0 +1,27 @@ +package com.daedan.festabook.presentation.setting.navigation + +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( + homeViewModel: HomeViewModel, + settingViewModel: SettingViewModel, + notificationPermissionManager: NotificationPermissionManager, + onShowSnackBar: (String) -> Unit, + onShowErrorSnackBar: (Throwable) -> Unit, +) { + composable { + SettingRoute( + homeViewModel = homeViewModel, + settingViewModel = settingViewModel, + notificationPermissionManager = notificationPermissionManager, + onShowSnackBar = onShowSnackBar, + onShowErrorSnackBar = onShowErrorSnackBar, + ) + } +} 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" /> - + - - + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + - + 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 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" }