diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0ce02e62..39970e46 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,8 +30,8 @@ android { applicationId = "com.eatssu.android" minSdk = 28 targetSdk = 37 - versionCode = 71 - versionName = "3.2.16" + versionCode = 72 + versionName = "3.2.17" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/eatssu/android/presentation/MainActivity.kt b/app/src/main/java/com/eatssu/android/presentation/MainActivity.kt index 83cc9113..ad494359 100644 --- a/app/src/main/java/com/eatssu/android/presentation/MainActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/MainActivity.kt @@ -1,8 +1,8 @@ package com.eatssu.android.presentation -import android.Manifest import android.annotation.SuppressLint import android.content.pm.PackageManager +import android.Manifest import android.os.Build import android.os.Bundle import android.os.SystemClock @@ -13,32 +13,33 @@ import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.lifecycle.lifecycleScope -import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.NavController import androidx.work.WorkManager -import com.eatssu.android.R import com.eatssu.android.databinding.ActivityMainBinding import com.eatssu.android.domain.model.PartnershipRestaurant import com.eatssu.android.presentation.base.BaseActivity import com.eatssu.android.presentation.event.AnyoneButMeEventPopupController import com.eatssu.android.presentation.event.AnyoneButMeEventTooltipController import com.eatssu.android.presentation.favorite.FavoriteDetailFragment +import com.eatssu.android.presentation.favorite.FavoriteFragment import com.eatssu.android.presentation.login.LoginActivity import com.eatssu.android.presentation.mypage.MyPageViewModel import com.eatssu.android.presentation.mypage.userinfo.UserInfoActivity import com.eatssu.android.presentation.util.showInfoToast import com.eatssu.android.presentation.util.showToast import com.eatssu.android.presentation.util.startActivity +import com.eatssu.android.R +import com.eatssu.common.enums.ScreenId import com.eatssu.common.UiEvent import com.eatssu.common.UiState -import com.eatssu.common.enums.ScreenId import com.google.android.material.bottomnavigation.BottomNavigationView import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import java.time.LocalDateTime import javax.inject.Inject +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch @AndroidEntryPoint @@ -131,9 +132,12 @@ class MainActivity : BaseActivity( } } - fun openFavoriteTab() { + fun openFavoriteTab(showPartnership: Boolean = false) { if (navController.currentDestination?.id != R.id.favoriteFragment) { - navController.navigate(R.id.favoriteFragment) + navController.navigate( + R.id.favoriteFragment, + bundleOf(FavoriteFragment.ARG_SHOW_PARTNERSHIP to showPartnership), + ) } } diff --git a/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteFragment.kt b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteFragment.kt index 9785c16b..4c3647aa 100644 --- a/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteFragment.kt +++ b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteFragment.kt @@ -17,6 +17,10 @@ import javax.inject.Inject @AndroidEntryPoint class FavoriteFragment : Fragment() { + companion object { + const val ARG_SHOW_PARTNERSHIP = "show_partnership" + } + @Inject lateinit var analyticsTracker: AnalyticsTracker @@ -32,7 +36,7 @@ class FavoriteFragment : Fragment() { EatssuTheme { FavoriteRoute( viewModel = viewModel, - onBackToMap = { mainActivity().openMapTab() }, + showPartnership = arguments?.getBoolean(ARG_SHOW_PARTNERSHIP) == true, onPartnershipClick = mainActivity()::openFavoriteDetail, ) } diff --git a/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteScreen.kt b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteScreen.kt index ef1e0919..9ac34ec4 100644 --- a/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteScreen.kt +++ b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteScreen.kt @@ -1,37 +1,35 @@ package com.eatssu.android.presentation.favorite +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border +import androidx.compose.foundation.Canvas import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -41,43 +39,43 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.eatssu.android.R -import com.eatssu.android.domain.model.FavoriteMenu -import com.eatssu.android.domain.model.MenuFavoriteSearchResult import com.eatssu.android.domain.model.PartnershipRestaurant +import com.eatssu.android.presentation.map.component.MapStyleTabs +import com.eatssu.android.presentation.map.component.PartnershipCategory +import com.eatssu.android.presentation.map.component.PartnershipCategoryChip import com.eatssu.android.presentation.map.iconRes import com.eatssu.android.presentation.util.TrackScreenViewEvent -import com.eatssu.common.UiState +import com.eatssu.android.R import com.eatssu.common.enums.ScreenId import com.eatssu.common.enums.StoreType +import com.eatssu.common.UiState import com.eatssu.design_system.component.DelayedLoadingIndicator import com.eatssu.design_system.component.EatSsuSnackbar import com.eatssu.design_system.component.EatSsuSnackbarType @@ -89,14 +87,14 @@ import com.eatssu.design_system.theme.Gray400 import com.eatssu.design_system.theme.Gray500 import com.eatssu.design_system.theme.Gray600 import com.eatssu.design_system.theme.Gray700 +import com.eatssu.design_system.theme.pretendardMedium +import com.eatssu.design_system.theme.pretendardSemiBold import com.eatssu.design_system.theme.Primary -import com.eatssu.design_system.theme.Secondary import com.eatssu.design_system.theme.White -import kotlinx.coroutines.Job +import kotlin.math.roundToInt import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import kotlin.math.roundToInt private const val MENU_PAGE = 0 private const val PARTNERSHIP_PAGE = 1 @@ -104,32 +102,18 @@ private const val PARTNERSHIP_PAGE = 1 @Composable fun FavoriteRoute( viewModel: FavoriteViewModel, - onBackToMap: () -> Unit, + showPartnership: Boolean = false, onPartnershipClick: (PartnershipRestaurant) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - var menuSnackbarEvent by remember { mutableStateOf(null) } - - LaunchedEffect(viewModel) { - viewModel.menuSnackbarEvents.collectLatest { event -> - menuSnackbarEvent = event - delay(3500) - menuSnackbarEvent = null - } - } - FavoriteScreen( uiState = uiState, - onBackToMap = onBackToMap, + showPartnership = showPartnership, onStoreTypeSelected = viewModel::selectStoreType, onPartnershipClick = onPartnershipClick, onRemoveFavorite = viewModel::removeFavorite, onRemoveFavorites = viewModel::removeFavorites, onRestoreFavorites = viewModel::restoreFavorites, - onMenuSearchQueryChanged = viewModel::onMenuSearchQueryChanged, - onSearchFavoriteClick = viewModel::toggleMenuFavorite, - onFavoriteMenuClick = viewModel::toggleFavoriteMenu, - menuSnackbarEvent = menuSnackbarEvent, ) } @@ -137,21 +121,14 @@ fun FavoriteRoute( @Composable internal fun FavoriteScreen( uiState: UiState, - onBackToMap: () -> Unit, + showPartnership: Boolean = false, onStoreTypeSelected: (StoreType?) -> Unit, onPartnershipClick: (PartnershipRestaurant) -> Unit, onRemoveFavorite: (Int) -> Unit = {}, onRemoveFavorites: (Set) -> Unit = {}, onRestoreFavorites: (List) -> Unit = {}, - onMenuSearchQueryChanged: (String) -> Unit = {}, - onSearchFavoriteClick: (MenuFavoriteSearchResult) -> Unit = {}, - onFavoriteMenuClick: (FavoriteMenu) -> Unit = {}, - menuSnackbarEvent: FavoriteMenuSnackbarEvent? = null, ) { - val pagerState = rememberPagerState( - initialPage = MENU_PAGE, - pageCount = { 2 }, - ) + var selectedPage by rememberSaveable { mutableStateOf(if (showPartnership) PARTNERSHIP_PAGE else MENU_PAGE) } val scope = rememberCoroutineScope() var isEditMode by remember { mutableStateOf(false) } var selectedPartnershipIds by remember { mutableStateOf(setOf()) } @@ -163,6 +140,11 @@ internal fun FavoriteScreen( var showDeleteSnackbar by remember { mutableStateOf(false) } var snackbarJob by remember { mutableStateOf(null) } + BackHandler(enabled = isEditMode) { + isEditMode = false + selectedPartnershipIds = emptySet() + } + TrackScreenViewEvent(ScreenId.MAP_FAVORITE) Scaffold( @@ -187,14 +169,10 @@ internal fun FavoriteScreen( textAlign = TextAlign.Center, ) - IconButton( + if (isEditMode) IconButton( onClick = { - if (isEditMode) { - isEditMode = false - selectedPartnershipIds = emptySet() - } else { - onBackToMap() - } + isEditMode = false + selectedPartnershipIds = emptySet() }, modifier = Modifier .align(Alignment.CenterStart) @@ -250,22 +228,17 @@ internal fun FavoriteScreen( }, ) } else { - FavoriteTabs( - selectedPage = pagerState.currentPage, - onPageSelected = { page -> - scope.launch { - pagerState.animateScrollToPage( - page - ) - } - }, + MapStyleTabs( + labels = listOf( + stringResource(R.string.favorite_menu_tab), + stringResource(R.string.favorite_partnership_tab), + ), + selectedPage = selectedPage, + onPageSelected = { selectedPage = it }, ) - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - ) { page -> - if (page == PARTNERSHIP_PAGE) { + Box(modifier = Modifier.fillMaxSize()) { + if (selectedPage == PARTNERSHIP_PAGE) { FavoritePartnershipContent( uiState = uiState, onStoreTypeSelected = onStoreTypeSelected, @@ -290,11 +263,9 @@ internal fun FavoriteScreen( }, ) } else { - FavoriteMenuContent( - uiState = uiState, - onSearchQueryChanged = onMenuSearchQueryChanged, - onSearchFavoriteClick = onSearchFavoriteClick, - onFavoriteMenuClick = onFavoriteMenuClick, + FavoriteEmptyContent( + title = stringResource(R.string.favorite_menu_preparing_title), + description = stringResource(R.string.favorite_menu_preparing_description), ) } } @@ -321,28 +292,6 @@ internal fun FavoriteScreen( type = EatSsuSnackbarType.Success, ) } - - AnimatedVisibility( - visible = menuSnackbarEvent != null, - enter = fadeIn() + slideInVertically { it / 2 }, - exit = fadeOut() + slideOutVertically { it / 2 }, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(horizontal = 8.dp) - .padding(bottom = 6.dp), - ) { - menuSnackbarEvent?.let { event -> - EatSsuSnackbar( - message = stringResource(event.messageRes), - modifier = Modifier.fillMaxWidth(), - type = if (event.isError) { - EatSsuSnackbarType.Danger - } else { - EatSsuSnackbarType.Success - }, - ) - } - } } } } @@ -490,10 +439,11 @@ private fun FavoriteEditRow( ) } - Image( + Icon( painter = painterResource(R.drawable.ic_arrow_right), + tint = Gray300, contentDescription = null, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(18.dp), ) } } @@ -566,39 +516,6 @@ private fun CircularCheckbox( } } -@Composable -private fun FavoriteTabs( - selectedPage: Int, - onPageSelected: (Int) -> Unit, -) { - Row(modifier = Modifier.fillMaxWidth()) { - listOf( - MENU_PAGE to stringResource(R.string.favorite_menu_tab), - PARTNERSHIP_PAGE to stringResource(R.string.favorite_partnership_tab), - ).forEach { (page, label) -> - Column( - modifier = Modifier - .weight(1f) - .clickable { onPageSelected(page) }, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = label, - style = EatssuTheme.typography.subtitle2.copy(lineHeight = 24.sp), - color = if (selectedPage == page) Primary else Gray400, - modifier = Modifier.padding(vertical = 8.5.dp), - ) - Box( - modifier = Modifier - .fillMaxWidth() - .height(3.dp) - .background(if (selectedPage == page) Primary else Gray400), - ) - } - } - } -} - @Composable private fun FavoritePartnershipContent( uiState: UiState, @@ -643,42 +560,29 @@ private fun FavoriteFilters( onStoreTypeSelected: (StoreType?) -> Unit, onEditClick: () -> Unit, ) { - val filters = listOf( - null to stringResource(R.string.favorite_filter_all), - StoreType.RESTAURANT to StoreType.RESTAURANT.value, - StoreType.CAFE to StoreType.CAFE.value, - StoreType.PUB to StoreType.PUB.value, - ) - Row( modifier = Modifier .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 24.dp, vertical = 14.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + .padding(horizontal = 24.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, ) { - filters.forEach { (type, label) -> - val selected = selectedStoreType == type - Text( - text = label, - style = EatssuTheme.typography.body2, - fontWeight = if (selected) FontWeight.W500 else FontWeight.W400, - color = if (selected) Primary else Gray400, - modifier = Modifier - .clip(RoundedCornerShape(24.dp)) - .then( - if (selected) Modifier.background(Secondary) - else Modifier.border(1.dp, Gray300, RoundedCornerShape(24.dp)), - ) - .clickable { onStoreTypeSelected(type) } - .padding(horizontal = 18.dp, vertical = 10.dp), - ) + Row( + modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + PartnershipCategory.entries.forEach { category -> + PartnershipCategoryChip( + category = category, + isSelected = selectedStoreType == category.storeType, + onClick = { onStoreTypeSelected(category.storeType) }, + ) + } } - Spacer(Modifier.weight(1f)) + Spacer(Modifier.width(12.dp)) Text( text = stringResource(R.string.favorite_edit), - style = EatssuTheme.typography.button2.copy(color = Gray500), + style = EatssuTheme.typography.button2.copy(fontFamily = pretendardSemiBold), + color = Gray500, modifier = Modifier.clickable(onClick = onEditClick), ) } @@ -823,16 +727,20 @@ private fun FavoritePartnershipRow( ) } - Image( + Icon( painter = painterResource(R.drawable.ic_arrow_right), + tint = Gray300, contentDescription = stringResource(R.string.favorite_open_detail), - modifier = Modifier.size(24.dp), + modifier = Modifier.size(18.dp), ) } } @Composable -private fun FavoriteEmptyContent() { +private fun FavoriteEmptyContent( + title: String = stringResource(R.string.favorite_empty_title), + description: String = stringResource(R.string.favorite_empty_description), +) { Column( modifier = Modifier .fillMaxSize() @@ -841,14 +749,16 @@ private fun FavoriteEmptyContent() { verticalArrangement = Arrangement.Center, ) { Text( - text = stringResource(R.string.favorite_empty_title), - style = EatssuTheme.typography.h1, + text = title, + style = EatssuTheme.typography.subtitle2.copy(lineHeight = 24.sp), + textAlign = TextAlign.Center, color = Gray600, ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(R.string.favorite_empty_description), - style = EatssuTheme.typography.body2, + text = description, + style = EatssuTheme.typography.caption2.copy(fontFamily = pretendardMedium), + textAlign = TextAlign.Center, color = Gray600, ) } @@ -872,7 +782,7 @@ private fun FavoriteScreenPreview() { ), ), ), - onBackToMap = {}, + showPartnership = true, onStoreTypeSelected = {}, onPartnershipClick = {}, ) diff --git a/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteViewModel.kt index 8809e86d..93c8398f 100644 --- a/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteViewModel.kt @@ -91,7 +91,7 @@ class FavoriteViewModel @Inject constructor( private var menuSearchJob: Job? = null private val pendingMenuActions = mutableSetOf() - fun loadFavorites() { + fun loadFavorites(includeMenuFavorites: Boolean = false) { viewModelScope.launch { val previous = (_uiState.value as? UiState.Success)?.data ?: FavoriteState() _uiState.value = UiState.Loading @@ -106,9 +106,25 @@ class FavoriteViewModel @Inject constructor( val itemById = items.associateBy { it.partnershipId } order.mapNotNull(itemById::get) } - val menuJob = async { menuFavoriteRepository.getFavoriteMenus() } + // Menu favorites remain unavailable in this release. + val menuJob = if (includeMenuFavorites) { + async { menuFavoriteRepository.getFavoriteMenus() } + } else { + null + } val partnerships = partnershipJob.await() + if (menuJob == null) { + _uiState.value = UiState.Success( + previous.copy( + partnerships = partnerships, + favoriteMenus = emptyList(), + unfavoritedMenuIds = emptySet(), + favoriteMenuLoadState = MenuFavoriteLoadState.Idle, + ), + ) + return@launch + } when (val menuResult = menuJob.await()) { is ApiResult.Success -> { _uiState.value = UiState.Success( diff --git a/app/src/main/java/com/eatssu/android/presentation/map/MapFragment.kt b/app/src/main/java/com/eatssu/android/presentation/map/MapFragment.kt index 2e9c1951..09676893 100644 --- a/app/src/main/java/com/eatssu/android/presentation/map/MapFragment.kt +++ b/app/src/main/java/com/eatssu/android/presentation/map/MapFragment.kt @@ -7,6 +7,7 @@ import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment import com.eatssu.android.analytics.ProvideAnalyticsTracker +import com.eatssu.android.presentation.MainActivity import com.eatssu.common.analytics.AnalyticsTracker import com.eatssu.design_system.theme.EatssuTheme import dagger.hilt.android.AndroidEntryPoint @@ -37,6 +38,9 @@ class MapFragment : Fragment() { EatssuTheme { MapRoute( mapExternalNavigator = mapExternalNavigator, + onFavoriteClick = { + (requireActivity() as MainActivity).openFavoriteTab(showPartnership = true) + }, ) } } diff --git a/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt b/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt index 763be6e3..1e3f73bf 100644 --- a/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt +++ b/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt @@ -2,12 +2,12 @@ package com.eatssu.android.presentation.map -import android.Manifest import android.app.Activity import android.content.Context import android.content.ContextWrapper import android.content.Intent import android.content.pm.PackageManager +import android.Manifest import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -15,30 +15,27 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -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.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.Scaffold import androidx.compose.material3.SheetState -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow -import androidx.compose.material3.TabRowDefaults -import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -46,43 +43,45 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable 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.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import com.eatssu.android.R import com.eatssu.android.analytics.LocalAnalyticsTracker import com.eatssu.android.domain.model.Partnership import com.eatssu.android.domain.model.PartnershipRestaurant +import com.eatssu.android.presentation.goodprice.GoodPriceMapRoute import com.eatssu.android.presentation.MainState import com.eatssu.android.presentation.MainViewModel -import com.eatssu.android.presentation.goodprice.GoodPriceMapRoute import com.eatssu.android.presentation.map.component.DepartmentBottomSheet import com.eatssu.android.presentation.map.component.FestivalPartnershipHelp import com.eatssu.android.presentation.map.component.MapRestaurantBottomSheet +import com.eatssu.android.presentation.map.component.MapStyleTabs import com.eatssu.android.presentation.map.component.PartnershipCategory import com.eatssu.android.presentation.map.component.PartnershipCategoryFilterRow import com.eatssu.android.presentation.map.component.PartnershipMarkerIcon import com.eatssu.android.presentation.mypage.userinfo.UserInfoActivity -import com.eatssu.android.presentation.util.TrackScreenViewEvent import com.eatssu.android.presentation.util.showToast -import com.eatssu.common.UiEvent -import com.eatssu.common.UiState -import com.eatssu.common.UiText +import com.eatssu.android.presentation.util.TrackScreenViewEvent +import com.eatssu.android.R import com.eatssu.common.analytics.MapAnalyticsEvent import com.eatssu.common.enums.ScreenId +import com.eatssu.common.enums.StoreType import com.eatssu.common.enums.ToastType +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState +import com.eatssu.common.UiText import com.eatssu.design_system.theme.EatssuTheme import com.eatssu.design_system.theme.Gray300 import com.eatssu.design_system.theme.Gray500 @@ -119,6 +118,7 @@ fun MapRoute( viewModel: MapViewModel = viewModel(), mainViewModel: MainViewModel = viewModel(), mapExternalNavigator: MapExternalNavigator? = null, + onFavoriteClick: () -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -271,50 +271,14 @@ fun MapRoute( ) } - // 상단 탭: 학교 제휴 | 착한 가격 - TabRow( - selectedTabIndex = selectedTabIndex, - containerColor = White, - contentColor = Primary, - indicator = { tabPositions -> - TabRowDefaults.SecondaryIndicator( - modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]), - color = Primary, - height = 2.dp, - ) - }, - divider = { - HorizontalDivider(color = Gray300, thickness = 0.5.dp) - }, - ) { - Tab( - selected = selectedTabIndex == 0, - onClick = { selectedTabIndex = 0 }, - text = { - Text( - text = stringResource(R.string.tab_school_partnership), - style = EatssuTheme.typography.subtitle2.copy( - fontWeight = if (selectedTabIndex == 0) FontWeight.Bold else FontWeight.Normal, - ), - color = if (selectedTabIndex == 0) Primary else Gray500, - ) - }, - ) - - Tab( - selected = selectedTabIndex == 1, - onClick = { selectedTabIndex = 1 }, - text = { - Text( - text = stringResource(R.string.tab_good_price), - style = EatssuTheme.typography.subtitle2.copy( - fontWeight = if (selectedTabIndex == 1) FontWeight.Bold else FontWeight.Normal, - ), - color = if (selectedTabIndex == 1) Primary else Gray500, - ) - }, - ) - } + MapStyleTabs( + labels = listOf( + stringResource(R.string.tab_school_partnership), + stringResource(R.string.tab_good_price), + ), + selectedPage = selectedTabIndex, + onPageSelected = { selectedTabIndex = it }, + ) } }, ) { innerPadding -> @@ -386,6 +350,7 @@ fun MapRoute( departmentId = departmentId, collegeId = collegeId, selectedCategory = mapState.selectedCategory, + onFavoriteClick = onFavoriteClick, ) } @@ -421,9 +386,19 @@ internal fun MapScreen( departmentId: Long, collegeId: Long, selectedCategory: PartnershipCategory, + onFavoriteClick: () -> Unit, ) { val analyticsTracker = LocalAnalyticsTracker.current var showFestivalHelp by rememberSaveable { mutableStateOf(false) } + val markerItems = mapState.visiblePartnerships.toMarkerItems() + val clusterItems = remember(markerItems) { + markerItems.associateBy { marker -> + ItemKey( + marker.storeName, + LatLng(marker.latitude, marker.longitude), + ) + } + } LaunchedEffect(mapState.hasFestivalPartnerships) { if (!mapState.hasFestivalPartnerships) showFestivalHelp = false @@ -461,6 +436,8 @@ internal fun MapScreen( MapRestaurantBottomSheet( storeName = info.storeName, + isLiked = info.likedByUser, + onFavoriteClick = { viewModel.likePartnership(info.id) }, storeType = storeType, mapRestaurantList = mapState.restaurantInfoList, onNaverMapClick = { @@ -495,13 +472,6 @@ internal fun MapScreen( locationTrackingMode = LocationTrackingMode.NoFollow, ), ) { - val clusterItems = mapState.visiblePartnerships.associateBy { - ItemKey( - it.storeName, - LatLng(it.latitude, it.longitude), - ) - } - Clustering( items = clusterItems, thresholdStrategy = { 25.0 }, @@ -520,8 +490,8 @@ internal fun MapScreen( } }, leafContent = { info -> - val partnership = info.tag as? Partnership - if (partnership != null) { + val marker = info.tag as? PartnershipMarkerItem + if (marker != null) { Row( modifier = Modifier .background(Color.White, RoundedCornerShape(13.dp)) @@ -535,10 +505,13 @@ internal fun MapScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = spacedBy(3.dp), ) { - PartnershipMarkerIcon(partnership = partnership) + PartnershipMarkerIcon( + storeType = marker.storeType, + isFestivalPartnership = marker.isFestivalPartnership, + ) Text( - text = partnership.storeName, + text = marker.storeName, style = EatssuTheme.typography.caption3, color = Color.Black, ) @@ -550,9 +523,9 @@ internal fun MapScreen( true }, onClickLeaf = { info, _ -> - val partnership = info.tag as? Partnership ?: return@Clustering true + val marker = info.tag as? PartnershipMarkerItem ?: return@Clustering true - if (partnership.partnershipInfos.isEmpty()) { + if (!marker.hasPartnershipInfo) { // 제휴 정보가 없을 때는 토스트만 띄우고 바텀시트는 안 띄움 showToast( UiText.StringResource(R.string.toast_partnership_info_not_found), @@ -560,7 +533,7 @@ internal fun MapScreen( ) } else { // 제휴 정보가 있을 때만 바텀시트 띄움 - viewModel.selectPartnershipByStoreName(partnership.storeName) + viewModel.selectPartnershipByStoreName(marker.storeName) } true }, @@ -576,6 +549,23 @@ internal fun MapScreen( modifier = Modifier.padding(top = 12.dp), ) + IconButton( + onClick = onFavoriteClick, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 12.dp, end = 20.dp) + .size(40.dp) + .background(White, CircleShape) + .border(1.dp, Gray300, CircleShape), + ) { + Icon( + painter = painterResource(R.drawable.ic_like_line), + contentDescription = stringResource(R.string.favorite_partnership_tab), + tint = Gray700, + modifier = Modifier.size(24.dp), + ) + } + if (mapState.hasFestivalPartnerships) { FestivalPartnershipHelp( isMessageVisible = showFestivalHelp, @@ -609,3 +599,24 @@ fun MapFragmentComposeViewPreview() { data class ItemKey(val id: String, private val latLng: LatLng) : ClusteringKey { override fun getPosition() = latLng } + +internal data class PartnershipMarkerItem( + val storeName: String, + val longitude: Double, + val latitude: Double, + val storeType: StoreType, + val hasPartnershipInfo: Boolean, + val isFestivalPartnership: Boolean, +) + +internal fun List.toMarkerItems(): List = + map { partnership -> + PartnershipMarkerItem( + storeName = partnership.storeName, + longitude = partnership.longitude, + latitude = partnership.latitude, + storeType = partnership.restaurantType, + hasPartnershipInfo = partnership.partnershipInfos.isNotEmpty(), + isFestivalPartnership = partnership.hasFestivalPartnership, + ) + } diff --git a/app/src/main/java/com/eatssu/android/presentation/map/component/MapRestaurantBottomSheet.kt b/app/src/main/java/com/eatssu/android/presentation/map/component/MapRestaurantBottomSheet.kt index 107152c7..0b8f6e32 100644 --- a/app/src/main/java/com/eatssu/android/presentation/map/component/MapRestaurantBottomSheet.kt +++ b/app/src/main/java/com/eatssu/android/presentation/map/component/MapRestaurantBottomSheet.kt @@ -1,44 +1,46 @@ package com.eatssu.android.presentation.map.component -import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -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.Row import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color.Companion.White +import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.eatssu.android.R import com.eatssu.android.presentation.map.iconRes import com.eatssu.android.presentation.map.model.RestaurantInfo import com.eatssu.android.presentation.util.TrackScreenViewEvent +import com.eatssu.android.R import com.eatssu.common.enums.ScreenId import com.eatssu.common.enums.StoreType import com.eatssu.design_system.theme.EatssuTheme @@ -54,6 +56,8 @@ fun MapRestaurantBottomSheet( storeName: String, storeType: StoreType, mapRestaurantList: List, + isLiked: Boolean = false, + onFavoriteClick: () -> Unit = {}, onNaverMapClick: () -> Unit = {}, onKakaoMapClick: () -> Unit = {}, onDismiss: () -> Unit = {}, @@ -104,7 +108,7 @@ fun MapRestaurantBottomSheet( .padding(horizontal = 24.dp), verticalAlignment = Alignment.CenterVertically, ) { - Column { + Column(modifier = Modifier.weight(1f)) { Text( text = storeName, style = EatssuTheme.typography.subtitle2 @@ -137,7 +141,21 @@ fun MapRestaurantBottomSheet( } } - // TODO 찜 탭 재도입 시 상세 찜 UI 복구 + IconButton( + onClick = onFavoriteClick, + modifier = Modifier.align(Alignment.Top).size(24.dp), + ) { + Icon( + painter = painterResource( + if (isLiked) R.drawable.ic_like_filled else R.drawable.ic_like_line, + ), + contentDescription = stringResource( + if (isLiked) R.string.favorite_remove_partnership else R.string.favorite_add_partnership, + ), + tint = Color.Unspecified, + modifier = Modifier.size(24.dp), + ) + } } Spacer(modifier = Modifier.height(20.dp)) diff --git a/app/src/main/java/com/eatssu/android/presentation/map/component/MapStyleTabs.kt b/app/src/main/java/com/eatssu/android/presentation/map/component/MapStyleTabs.kt new file mode 100644 index 00000000..011a9bd6 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/map/component/MapStyleTabs.kt @@ -0,0 +1,56 @@ +package com.eatssu.android.presentation.map.component + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.TabRowDefaults +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.eatssu.design_system.theme.EatssuTheme +import com.eatssu.design_system.theme.Gray300 +import com.eatssu.design_system.theme.Gray500 +import com.eatssu.design_system.theme.Primary +import com.eatssu.design_system.theme.White + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MapStyleTabs( + labels: List, + selectedPage: Int, + onPageSelected: (Int) -> Unit, +) { + TabRow( + selectedTabIndex = selectedPage, + containerColor = White, + contentColor = Primary, + indicator = { positions -> + TabRowDefaults.SecondaryIndicator( + modifier = Modifier.tabIndicatorOffset(positions[selectedPage]), + color = Primary, + height = 2.dp, + ) + }, + divider = { HorizontalDivider(color = Gray300, thickness = 0.5.dp) }, + ) { + labels.forEachIndexed { index, label -> + Tab( + selected = selectedPage == index, + onClick = { onPageSelected(index) }, + text = { + Text( + text = label, + style = EatssuTheme.typography.subtitle2.copy( + fontWeight = if (selectedPage == index) FontWeight.Bold else FontWeight.Normal, + ), + color = if (selectedPage == index) Primary else Gray500, + ) + }, + ) + } + } +} diff --git a/app/src/main/java/com/eatssu/android/presentation/map/component/PartnershipCategoryFilterRow.kt b/app/src/main/java/com/eatssu/android/presentation/map/component/PartnershipCategoryFilterRow.kt index a3a79caf..e1c6fae5 100644 --- a/app/src/main/java/com/eatssu/android/presentation/map/component/PartnershipCategoryFilterRow.kt +++ b/app/src/main/java/com/eatssu/android/presentation/map/component/PartnershipCategoryFilterRow.kt @@ -53,41 +53,52 @@ fun PartnershipCategoryFilterRow( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { items(PartnershipCategory.entries, key = { it.name }) { category -> - val isSelected = category == selectedCategory - - Box( - modifier = Modifier - .shadow( - elevation = 3.dp, - shape = CircleShape, - ambientColor = Color(0x1A949494), - spotColor = Color(0x1A949494), - ) - .clip(CircleShape) - .background(if (isSelected) Primary else White) - .border( - width = if (isSelected) 0.dp else 1.dp, - color = if (isSelected) Color.Transparent else Gray300, - shape = CircleShape, - ) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { onCategorySelected(category) }, - ) - .padding(horizontal = 14.dp, vertical = 8.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(category.labelResId), - style = if (isSelected) { - EatssuTheme.typography.body2 - } else { - EatssuTheme.typography.body3 - }, - color = if (isSelected) White else Gray400, - ) - } + PartnershipCategoryChip( + category = category, + isSelected = category == selectedCategory, + onClick = { onCategorySelected(category) }, + ) } } } + +@Composable +fun PartnershipCategoryChip( + category: PartnershipCategory, + isSelected: Boolean, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .shadow( + elevation = 3.dp, + shape = CircleShape, + ambientColor = Color(0x1A949494), + spotColor = Color(0x1A949494), + ) + .clip(CircleShape) + .background(if (isSelected) Primary else White) + .border( + width = if (isSelected) 0.dp else 1.dp, + color = if (isSelected) Color.Transparent else Gray300, + shape = CircleShape, + ) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .padding(horizontal = 14.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(category.labelResId), + style = if (isSelected) { + EatssuTheme.typography.body2 + } else { + EatssuTheme.typography.body3 + }, + color = if (isSelected) White else Gray400, + ) + } +} diff --git a/app/src/main/java/com/eatssu/android/presentation/map/component/PartnershipMarkerIcon.kt b/app/src/main/java/com/eatssu/android/presentation/map/component/PartnershipMarkerIcon.kt index 9ce3f2d3..c2845c98 100644 --- a/app/src/main/java/com/eatssu/android/presentation/map/component/PartnershipMarkerIcon.kt +++ b/app/src/main/java/com/eatssu/android/presentation/map/component/PartnershipMarkerIcon.kt @@ -12,8 +12,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.eatssu.android.R -import com.eatssu.android.domain.model.Partnership -import com.eatssu.android.presentation.map.hasFestivalPartnership import com.eatssu.android.presentation.map.iconRes import com.eatssu.common.enums.StoreType @@ -21,10 +19,11 @@ private val FestivalWine = Color(0xFF880A19) @Composable internal fun PartnershipMarkerIcon( - partnership: Partnership, + storeType: StoreType, + isFestivalPartnership: Boolean, modifier: Modifier = Modifier, ) { - if (partnership.hasFestivalPartnership) { + if (isFestivalPartnership) { Box( modifier = modifier .size(20.dp) @@ -32,13 +31,13 @@ internal fun PartnershipMarkerIcon( contentAlignment = Alignment.Center, ) { Image( - painter = painterResource(id = partnership.restaurantType.iconRes), + painter = painterResource(id = storeType.iconRes), contentDescription = null, modifier = Modifier.size(14.dp), ) } } else { - val iconRes = when (partnership.restaurantType) { + val iconRes = when (storeType) { StoreType.CAFE -> R.drawable.ic_map_marker_cafe StoreType.PUB -> R.drawable.ic_map_marker_pub else -> R.drawable.ic_map_marker_restaurant diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index d0ee74c6..20ce559a 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -391,4 +391,8 @@ Student Union Building, 2F + Menu favorites are coming soon. + Stay tuned! + Add partner to favorites + Remove partner from favorites diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 886c70d2..f0127ae3 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -395,4 +395,8 @@ 学生会館 2階 + メニューのお気に入りは準備中です。 + もうしばらくお待ちください! + 提携店をお気に入りに追加 + 提携店をお気に入りから削除 diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index ec4b0d89..11ecf38e 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -395,4 +395,8 @@ Tòa Student Union, tầng 2 + Tính năng yêu thích món ăn đang được chuẩn bị. + Sắp ra mắt! + Thêm đối tác vào yêu thích + Bỏ đối tác khỏi yêu thích diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7e862be0..1d2c4f67 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -405,4 +405,8 @@ 학생회관 2층 + 메뉴 찜은 준비 중이에요. + 곧 만나볼 수 있어요! + 제휴 찜하기 + 제휴 찜 취소하기 diff --git a/app/src/test/java/com/eatssu/android/presentation/favorite/FavoriteViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/favorite/FavoriteViewModelBehaviorSpec.kt index c81b4874..af9e8d31 100644 --- a/app/src/test/java/com/eatssu/android/presentation/favorite/FavoriteViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/favorite/FavoriteViewModelBehaviorSpec.kt @@ -43,6 +43,8 @@ class FavoriteViewModelBehaviorSpec : AppBehaviorSpec({ val state = (viewModel.uiState.value as UiState.Success).data state.partnerships.map { it.partnershipId } shouldBe listOf(2, 1) state.partnerships.map { it.storeName } shouldBe listOf("카페", "식당") + coVerify(exactly = 0) { menuRepository.getFavoriteMenus() } + state.favoriteMenuLoadState shouldBe MenuFavoriteLoadState.Idle } } @@ -99,7 +101,7 @@ class FavoriteViewModelBehaviorSpec : AppBehaviorSpec({ val viewModel = FavoriteViewModel(partnershipRepository, dataStore, menuRepository) `when`("찜 목록을 불러오면") { - viewModel.loadFavorites() + viewModel.loadFavorites(includeMenuFavorites = true) then("서버가 내려준 최신순 메뉴를 표시한다") { val state = (viewModel.uiState.value as UiState.Success).data @@ -108,7 +110,7 @@ class FavoriteViewModelBehaviorSpec : AppBehaviorSpec({ } `when`("공백을 제외한 검색어가 두 글자 미만이면") { - viewModel.loadFavorites() + viewModel.loadFavorites(includeMenuFavorites = true) viewModel.onMenuSearchQueryChanged(" 김 ") then("서버 검색을 호출하지 않는다") { @@ -117,7 +119,7 @@ class FavoriteViewModelBehaviorSpec : AppBehaviorSpec({ } `when`("두 글자 이상 메뉴를 검색하면") { - viewModel.loadFavorites() + viewModel.loadFavorites(includeMenuFavorites = true) viewModel.onMenuSearchQueryChanged(" 김치 ") then("앞뒤 공백을 제거한 결과를 식당 정보와 함께 유지한다") { @@ -128,7 +130,7 @@ class FavoriteViewModelBehaviorSpec : AppBehaviorSpec({ } `when`("검색 결과를 찜하면") { - viewModel.loadFavorites() + viewModel.loadFavorites(includeMenuFavorites = true) viewModel.onMenuSearchQueryChanged("김치") viewModel.toggleMenuFavorite(searchResult) @@ -164,7 +166,7 @@ class FavoriteViewModelBehaviorSpec : AppBehaviorSpec({ val viewModel = FavoriteViewModel(partnershipRepository, dataStore, menuRepository) `when`("하트를 끄고 같은 화면에서 다시 켠 뒤, 다시 끄고 재진입하면") { - viewModel.loadFavorites() + viewModel.loadFavorites(includeMenuFavorites = true) viewModel.toggleFavoriteMenu(favoriteMenu) then("화면을 떠나기 전에는 메뉴가 남아 다시 찜할 수 있고, 재진입 후에는 사라진다") { @@ -179,7 +181,7 @@ class FavoriteViewModelBehaviorSpec : AppBehaviorSpec({ coVerify(exactly = 1) { menuRepository.addFavoriteMenu(10L) } viewModel.toggleFavoriteMenu(favoriteMenu) - viewModel.loadFavorites() + viewModel.loadFavorites(includeMenuFavorites = true) state = (viewModel.uiState.value as UiState.Success).data state.favoriteMenus shouldBe emptyList() state.unfavoritedMenuIds shouldBe emptySet() diff --git a/app/src/test/java/com/eatssu/android/presentation/map/PartnershipMarkerItemBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/map/PartnershipMarkerItemBehaviorSpec.kt new file mode 100644 index 00000000..47511ab5 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/map/PartnershipMarkerItemBehaviorSpec.kt @@ -0,0 +1,37 @@ +package com.eatssu.android.presentation.map + +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.samplePartnership +import io.kotest.matchers.shouldBe + +class PartnershipMarkerItemBehaviorSpec : AppBehaviorSpec({ + + given("제휴 지도 마커 목록") { + val partnership = samplePartnership(storeName = "Cafe Like") + + `when`("찜 상태와 찜 개수만 변경되면") { + val likedPartnership = partnership.copy( + partnershipInfos = partnership.partnershipInfos.map { info -> + info.copy( + isLiked = !info.isLiked, + likeCount = info.likeCount + 1, + ) + }, + ) + + then("지도 마커 입력은 변경되지 않는다") { + listOf(partnership).toMarkerItems() shouldBe + listOf(likedPartnership).toMarkerItems() + } + } + + `when`("마커 표시 정보가 변경되면") { + val movedPartnership = partnership.copy(latitude = partnership.latitude + 0.001) + + then("지도 마커 입력도 변경된다") { + (listOf(partnership).toMarkerItems() == + listOf(movedPartnership).toMarkerItems()) shouldBe false + } + } + } +}) diff --git a/release-notes/v3.2.17.yml b/release-notes/v3.2.17.yml new file mode 100644 index 00000000..554852d8 --- /dev/null +++ b/release-notes/v3.2.17.yml @@ -0,0 +1,4 @@ +ko: | + - 메뉴를 찜하고 검색해 원하는 메뉴를 더 빠르게 확인할 수 있어요. +en: | + - Favorite and search menus to find what you want more quickly.