From 72f97c525ab39ed19caeb3b68ac6e5209e7da590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A3=BC=EC=9E=AC=EC=B0=AC?= Date: Mon, 28 Sep 2026 23:37:18 +0900 Subject: [PATCH 1/3] Add menu favorite search flow --- .../dto/response/MenuFavoriteResponse.kt | 45 ++ .../repository/MenuFavoriteRepositoryImpl.kt | 26 ++ .../remote/service/MenuFavoriteService.kt | 30 ++ .../java/com/eatssu/android/di/DataModule.kt | 7 + .../com/eatssu/android/di/ServiceModule.kt | 7 + .../android/domain/model/MenuFavorite.kt | 17 + .../repository/MenuFavoriteRepository.kt | 12 + .../android/presentation/MainActivity.kt | 12 +- .../favorite/FavoriteMenuContent.kt | 395 ++++++++++++++++++ .../presentation/favorite/FavoriteScreen.kt | 87 +++- .../favorite/FavoriteViewModel.kt | 280 +++++++++++-- app/src/main/res/drawable/ic_search.xml | 13 + .../main/res/menu/menu_bottom_navigation.xml | 11 +- app/src/main/res/values-en/strings.xml | 13 + app/src/main/res/values-ja/strings.xml | 13 + app/src/main/res/values-vi/strings.xml | 13 + app/src/main/res/values/strings.xml | 13 + .../favorite/FavoriteViewModelBehaviorSpec.kt | 79 +++- 18 files changed, 1010 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/com/eatssu/android/data/remote/dto/response/MenuFavoriteResponse.kt create mode 100644 app/src/main/java/com/eatssu/android/data/remote/repository/MenuFavoriteRepositoryImpl.kt create mode 100644 app/src/main/java/com/eatssu/android/data/remote/service/MenuFavoriteService.kt create mode 100644 app/src/main/java/com/eatssu/android/domain/model/MenuFavorite.kt create mode 100644 app/src/main/java/com/eatssu/android/domain/repository/MenuFavoriteRepository.kt create mode 100644 app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteMenuContent.kt create mode 100644 app/src/main/res/drawable/ic_search.xml diff --git a/app/src/main/java/com/eatssu/android/data/remote/dto/response/MenuFavoriteResponse.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/response/MenuFavoriteResponse.kt new file mode 100644 index 000000000..1a7b0c8cb --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/remote/dto/response/MenuFavoriteResponse.kt @@ -0,0 +1,45 @@ +package com.eatssu.android.data.remote.dto.response + +import com.eatssu.android.domain.model.FavoriteMenu +import com.eatssu.android.domain.model.MenuFavoriteSearchResult +import com.eatssu.common.enums.Restaurant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FavoriteMenuResponse( + @SerialName("menuId") + val menuId: Long, + @SerialName("menuName") + val menuName: String, + @SerialName("restaurant") + val restaurant: Restaurant, + @SerialName("isDiscontinued") + val isDiscontinued: Boolean, +) + +@Serializable +data class MenuFavoriteSearchResponse( + @SerialName("menuId") + val menuId: Long, + @SerialName("menuName") + val menuName: String, + @SerialName("restaurant") + val restaurant: Restaurant, + @SerialName("isFavorite") + val isFavorite: Boolean, +) + +fun FavoriteMenuResponse.toDomain(): FavoriteMenu = FavoriteMenu( + menuId = menuId, + menuName = menuName, + restaurant = restaurant, + isDiscontinued = isDiscontinued, +) + +fun MenuFavoriteSearchResponse.toDomain(): MenuFavoriteSearchResult = MenuFavoriteSearchResult( + menuId = menuId, + menuName = menuName, + restaurant = restaurant, + isFavorite = isFavorite, +) diff --git a/app/src/main/java/com/eatssu/android/data/remote/repository/MenuFavoriteRepositoryImpl.kt b/app/src/main/java/com/eatssu/android/data/remote/repository/MenuFavoriteRepositoryImpl.kt new file mode 100644 index 000000000..22c14f158 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/remote/repository/MenuFavoriteRepositoryImpl.kt @@ -0,0 +1,26 @@ +package com.eatssu.android.data.remote.repository + +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.data.model.map +import com.eatssu.android.data.remote.dto.response.toDomain +import com.eatssu.android.data.remote.service.MenuFavoriteService +import com.eatssu.android.domain.model.FavoriteMenu +import com.eatssu.android.domain.model.MenuFavoriteSearchResult +import com.eatssu.android.domain.repository.MenuFavoriteRepository +import javax.inject.Inject + +class MenuFavoriteRepositoryImpl @Inject constructor( + private val menuFavoriteService: MenuFavoriteService, +) : MenuFavoriteRepository { + override suspend fun searchMenus(keyword: String): ApiResult> = + menuFavoriteService.searchMenus(keyword).map { menus -> menus.map { it.toDomain() } } + + override suspend fun getFavoriteMenus(): ApiResult> = + menuFavoriteService.getFavoriteMenus().map { menus -> menus.map { it.toDomain() } } + + override suspend fun addFavoriteMenu(menuId: Long): ApiResult = + menuFavoriteService.addFavoriteMenu(menuId) + + override suspend fun removeFavoriteMenu(menuId: Long): ApiResult = + menuFavoriteService.removeFavoriteMenu(menuId) +} diff --git a/app/src/main/java/com/eatssu/android/data/remote/service/MenuFavoriteService.kt b/app/src/main/java/com/eatssu/android/data/remote/service/MenuFavoriteService.kt new file mode 100644 index 000000000..21a3ead40 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/remote/service/MenuFavoriteService.kt @@ -0,0 +1,30 @@ +package com.eatssu.android.data.remote.service + +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.data.remote.dto.response.FavoriteMenuResponse +import com.eatssu.android.data.remote.dto.response.MenuFavoriteSearchResponse +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface MenuFavoriteService { + @GET("menu-favorites/search") + suspend fun searchMenus( + @Query("keyword") keyword: String, + ): ApiResult> + + @GET("menu-favorites") + suspend fun getFavoriteMenus(): ApiResult> + + @POST("menu-favorites/{menuId}") + suspend fun addFavoriteMenu( + @Path("menuId") menuId: Long, + ): ApiResult + + @DELETE("menu-favorites/{menuId}") + suspend fun removeFavoriteMenu( + @Path("menuId") menuId: Long, + ): ApiResult +} diff --git a/app/src/main/java/com/eatssu/android/di/DataModule.kt b/app/src/main/java/com/eatssu/android/di/DataModule.kt index c70584133..81664dbd9 100644 --- a/app/src/main/java/com/eatssu/android/di/DataModule.kt +++ b/app/src/main/java/com/eatssu/android/di/DataModule.kt @@ -5,6 +5,7 @@ import com.eatssu.android.data.remote.repository.FirebaseRemoteConfigRepositoryI import com.eatssu.android.data.remote.repository.HealthCheckRepositoryImpl import com.eatssu.android.data.remote.repository.MealRepositoryImpl import com.eatssu.android.data.remote.repository.MenuRepositoryImpl +import com.eatssu.android.data.remote.repository.MenuFavoriteRepositoryImpl import com.eatssu.android.data.remote.repository.OauthRepositoryImpl import com.eatssu.android.data.remote.repository.PartnershipRepositoryImpl import com.eatssu.android.data.remote.repository.PublicHolidayRepositoryImpl @@ -15,6 +16,7 @@ import com.eatssu.android.domain.repository.FirebaseRemoteConfigRepository import com.eatssu.android.domain.repository.HealthCheckRepository import com.eatssu.android.domain.repository.MealRepository import com.eatssu.android.domain.repository.MenuRepository +import com.eatssu.android.domain.repository.MenuFavoriteRepository import com.eatssu.android.domain.repository.OauthRepository import com.eatssu.android.domain.repository.PartnershipRepository import com.eatssu.android.domain.repository.PublicHolidayRepository @@ -70,6 +72,11 @@ abstract class DataModule { menuRepositoryImpl: MenuRepositoryImpl, ): MenuRepository + @Binds + internal abstract fun bindsMenuFavoriteRepository( + menuFavoriteRepositoryImpl: MenuFavoriteRepositoryImpl, + ): MenuFavoriteRepository + @Binds internal abstract fun bindsFirebaseRemoteConfigRepository( firebaseRemoteConfigRepositoryImpl: FirebaseRemoteConfigRepositoryImpl, diff --git a/app/src/main/java/com/eatssu/android/di/ServiceModule.kt b/app/src/main/java/com/eatssu/android/di/ServiceModule.kt index 93105ee13..726a0ffeb 100644 --- a/app/src/main/java/com/eatssu/android/di/ServiceModule.kt +++ b/app/src/main/java/com/eatssu/android/di/ServiceModule.kt @@ -3,6 +3,7 @@ package com.eatssu.android.di import com.eatssu.android.data.remote.service.HealthCheckService import com.eatssu.android.data.remote.service.MealService import com.eatssu.android.data.remote.service.MenuService +import com.eatssu.android.data.remote.service.MenuFavoriteService import com.eatssu.android.data.remote.service.OauthService import com.eatssu.android.data.remote.service.PartnershipService import com.eatssu.android.data.remote.service.ReportService @@ -55,6 +56,12 @@ object ServiceModule { return retrofit.create(MenuService::class.java) } + @Provides + @Singleton + fun provideMenuFavoriteService(retrofit: Retrofit): MenuFavoriteService { + return retrofit.create(MenuFavoriteService::class.java) + } + @Provides @Singleton fun providePartnershipService(retrofit: Retrofit): PartnershipService { diff --git a/app/src/main/java/com/eatssu/android/domain/model/MenuFavorite.kt b/app/src/main/java/com/eatssu/android/domain/model/MenuFavorite.kt new file mode 100644 index 000000000..ce84a9ffe --- /dev/null +++ b/app/src/main/java/com/eatssu/android/domain/model/MenuFavorite.kt @@ -0,0 +1,17 @@ +package com.eatssu.android.domain.model + +import com.eatssu.common.enums.Restaurant + +data class FavoriteMenu( + val menuId: Long, + val menuName: String, + val restaurant: Restaurant, + val isDiscontinued: Boolean, +) + +data class MenuFavoriteSearchResult( + val menuId: Long, + val menuName: String, + val restaurant: Restaurant, + val isFavorite: Boolean, +) diff --git a/app/src/main/java/com/eatssu/android/domain/repository/MenuFavoriteRepository.kt b/app/src/main/java/com/eatssu/android/domain/repository/MenuFavoriteRepository.kt new file mode 100644 index 000000000..c1a9a310b --- /dev/null +++ b/app/src/main/java/com/eatssu/android/domain/repository/MenuFavoriteRepository.kt @@ -0,0 +1,12 @@ +package com.eatssu.android.domain.repository + +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.domain.model.FavoriteMenu +import com.eatssu.android.domain.model.MenuFavoriteSearchResult + +interface MenuFavoriteRepository { + suspend fun searchMenus(keyword: String): ApiResult> + suspend fun getFavoriteMenus(): ApiResult> + suspend fun addFavoriteMenu(menuId: Long): ApiResult + suspend fun removeFavoriteMenu(menuId: Long): ApiResult +} 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 8ac118af5..83cc91135 100644 --- a/app/src/main/java/com/eatssu/android/presentation/MainActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/MainActivity.kt @@ -103,11 +103,10 @@ class MainActivity : BaseActivity( // false // } - //TODO 찜 기능 완성 시 -// R.id.favorite_menu -> { -// navController.navigate(R.id.favoriteFragment) -// true -// } + R.id.favorite_menu -> { + navController.navigate(R.id.favoriteFragment) + true + } R.id.mypage_menu -> { navController.navigate(R.id.myPageFragment) @@ -123,7 +122,8 @@ class MainActivity : BaseActivity( navController.addOnDestinationChangedListener { _, destination, _ -> val menuItemId = when (destination.id) { R.id.cafeteria_menu -> R.id.cafeteria_menu - R.id.mapFragment, R.id.favoriteDetailFragment, R.id.favoriteFragment -> R.id.map_menu + R.id.mapFragment -> R.id.map_menu + R.id.favoriteDetailFragment, R.id.favoriteFragment -> R.id.favorite_menu R.id.myPageFragment -> R.id.mypage_menu else -> null } diff --git a/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteMenuContent.kt b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteMenuContent.kt new file mode 100644 index 000000000..4b91a5730 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteMenuContent.kt @@ -0,0 +1,395 @@ +package com.eatssu.android.presentation.favorite + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.eatssu.android.R +import com.eatssu.android.domain.model.FavoriteMenu +import com.eatssu.android.domain.model.MenuFavoriteSearchResult +import com.eatssu.common.UiState +import com.eatssu.design_system.component.DelayedLoadingIndicator +import com.eatssu.design_system.theme.EatssuTheme +import com.eatssu.design_system.theme.Error +import com.eatssu.design_system.theme.Gray100 +import com.eatssu.design_system.theme.Gray200 +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.pretendardBold +import java.util.Locale + +@Composable +internal fun FavoriteMenuContent( + uiState: UiState, + onSearchQueryChanged: (String) -> Unit, + onSearchFavoriteClick: (MenuFavoriteSearchResult) -> Unit, + onFavoriteMenuClick: (FavoriteMenu) -> Unit, +) { + when (uiState) { + UiState.Init, UiState.Loading -> DelayedLoadingIndicator(modifier = Modifier.fillMaxSize()) + UiState.Error -> Unit + is UiState.Success -> { + val state = uiState.data + Column(modifier = Modifier.fillMaxSize()) { + MenuFavoriteSearchField( + query = state.menuSearchQuery, + onQueryChanged = onSearchQueryChanged, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 24.dp), + ) + + if (state.isMenuSearchActive) { + MenuSearchContent( + state = state, + onFavoriteClick = onSearchFavoriteClick, + ) + } else { + FavoriteMenuListContent( + state = state, + onFavoriteClick = onFavoriteMenuClick, + ) + } + } + } + } +} + +@Composable +private fun MenuFavoriteSearchField( + query: String, + onQueryChanged: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val shape = RoundedCornerShape(10.dp) + + BasicTextField( + value = query, + onValueChange = onQueryChanged, + modifier = modifier + .height(50.dp) + .clip(shape) + .background(Gray100) + .border( + width = 1.dp, + color = Gray200, + shape = shape, + ), + textStyle = EatssuTheme.typography.body2.copy(color = Gray700), + singleLine = true, + cursorBrush = SolidColor(Gray700), + interactionSource = interactionSource, + decorationBox = { innerTextField -> + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.ic_search), + contentDescription = null, + modifier = Modifier.size(width = 15.28.dp, height = 14.dp), + tint = Gray400, + ) + Spacer(modifier = Modifier.width(10.dp)) + Box(modifier = Modifier.weight(1f)) { + if (query.isEmpty()) { + Text( + text = stringResource(R.string.favorite_menu_search_hint), + style = EatssuTheme.typography.body2, + color = Gray400, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + innerTextField() + } + } + }, + ) +} + +@Composable +private fun FavoriteMenuListContent( + state: FavoriteState, + onFavoriteClick: (FavoriteMenu) -> Unit, +) { + when (state.favoriteMenuLoadState) { + MenuFavoriteLoadState.Loading -> DelayedLoadingIndicator(modifier = Modifier.fillMaxSize()) + MenuFavoriteLoadState.Error, + MenuFavoriteLoadState.Idle, + MenuFavoriteLoadState.Success, + -> if (state.favoriteMenus.isEmpty()) { + MenuFavoriteEmptyState( + title = stringResource(R.string.favorite_menu_empty_title), + description = stringResource(R.string.favorite_menu_empty_description), + ) + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + .padding(horizontal = 2.dp) + .padding(top = 22.dp), + ) { + Text( + text = stringResource(R.string.favorite_menu_section_title), + style = EatssuTheme.typography.subtitle1, + color = Gray700, + ) + Spacer(modifier = Modifier.height(12.dp)) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(Gray200), + ) + Spacer(modifier = Modifier.height(12.dp)) + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + items = state.favoriteMenus, + key = FavoriteMenu::menuId, + ) { menu -> + FavoriteMenuRow( + menu = menu, + onFavoriteClick = { onFavoriteClick(menu) }, + ) + } + } + } + } + } +} + +@Composable +private fun MenuSearchContent( + state: FavoriteState, + onFavoriteClick: (MenuFavoriteSearchResult) -> Unit, +) { + when (state.menuSearchLoadState) { + MenuFavoriteLoadState.Loading -> DelayedLoadingIndicator(modifier = Modifier.fillMaxSize()) + MenuFavoriteLoadState.Success -> if (state.menuSearchResults.isEmpty()) { + MenuFavoriteEmptyState( + title = stringResource(R.string.favorite_menu_search_empty_title), + description = stringResource(R.string.favorite_menu_search_empty_description), + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + .padding(top = 20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items( + items = state.menuSearchResults, + key = MenuFavoriteSearchResult::menuId, + ) { menu -> + MenuSearchResultRow( + menu = menu, + query = state.normalizedMenuSearchQuery, + onFavoriteClick = { onFavoriteClick(menu) }, + ) + } + } + } + + MenuFavoriteLoadState.Error, + MenuFavoriteLoadState.Idle, + -> Unit + } +} + +@Composable +private fun FavoriteMenuRow( + menu: FavoriteMenu, + onFavoriteClick: () -> Unit, +) { + MenuRow( + text = menuLabel( + menuName = menu.menuName, + restaurant = stringResource(menu.restaurant.displayNameResId), + isDiscontinued = menu.isDiscontinued, + ), + isFavorite = true, + onFavoriteClick = onFavoriteClick, + ) +} + +@Composable +private fun MenuSearchResultRow( + menu: MenuFavoriteSearchResult, + query: String, + onFavoriteClick: () -> Unit, +) { + MenuRow( + text = menuLabel( + menuName = menu.menuName, + restaurant = stringResource(menu.restaurant.displayNameResId), + query = query, + ), + isFavorite = menu.isFavorite, + onFavoriteClick = onFavoriteClick, + ) +} + +@Composable +private fun MenuRow( + text: AnnotatedString, + isFavorite: Boolean, + onFavoriteClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + modifier = Modifier.weight(1f), + style = EatssuTheme.typography.body3, + color = Gray700, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + painter = painterResource( + if (isFavorite) R.drawable.ic_like_filled else R.drawable.ic_like_line, + ), + contentDescription = stringResource( + if (isFavorite) { + R.string.favorite_menu_remove_content_description + } else { + R.string.favorite_menu_add_content_description + }, + ), + modifier = Modifier + .size(24.dp) + .clickable(onClick = onFavoriteClick), + tint = Gray700, + ) + } +} + +@Composable +private fun MenuFavoriteEmptyState( + title: String, + description: String, +) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.offset(y = (-65).dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = title, + style = EatssuTheme.typography.subtitle1, + color = Gray700, + ) + Text( + text = description, + style = EatssuTheme.typography.caption2, + color = Gray600, + ) + } + } +} + +@Composable +private fun menuLabel( + menuName: String, + restaurant: String, + query: String? = null, + isDiscontinued: Boolean = false, +): AnnotatedString = buildAnnotatedString { + append(menuName) + + if (!query.isNullOrBlank()) { + val start = menuName.lowercase(Locale.getDefault()) + .indexOf(query.lowercase(Locale.getDefault())) + if (start >= 0) { + addStyle( + style = SpanStyle( + fontFamily = pretendardBold, + fontWeight = FontWeight.Bold, + ), + start = start, + end = start + query.length, + ) + } + } + + append(" · ") + val restaurantStart = length + append(restaurant) + addStyle( + style = SpanStyle( + color = Gray500, + fontSize = 12.sp, + ), + start = restaurantStart - 5, + end = length, + ) + + if (isDiscontinued) { + append(" ") + val discontinuedStart = length + append(stringResource(R.string.favorite_menu_discontinued)) + addStyle( + style = SpanStyle( + color = Error, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ), + start = discontinuedStart, + end = length, + ) + } +} 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 472d7b77a..cce051780 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 @@ -41,6 +41,7 @@ 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 @@ -66,8 +67,11 @@ 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.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.iconRes import com.eatssu.android.presentation.util.TrackScreenViewEvent @@ -90,6 +94,7 @@ import com.eatssu.design_system.theme.Secondary import com.eatssu.design_system.theme.White import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlin.math.roundToInt @@ -103,6 +108,15 @@ fun FavoriteRoute( 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, @@ -112,6 +126,10 @@ fun FavoriteRoute( onRemoveFavorite = viewModel::removeFavorite, onRemoveFavorites = viewModel::removeFavorites, onRestoreFavorites = viewModel::restoreFavorites, + onMenuSearchQueryChanged = viewModel::onMenuSearchQueryChanged, + onSearchFavoriteClick = viewModel::toggleMenuFavorite, + onFavoriteMenuClick = viewModel::removeFavoriteMenu, + menuSnackbarEvent = menuSnackbarEvent, ) } @@ -125,9 +143,13 @@ internal fun FavoriteScreen( 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 = PARTNERSHIP_PAGE, + initialPage = MENU_PAGE, pageCount = { 2 }, ) val scope = rememberCoroutineScope() @@ -165,22 +187,24 @@ internal fun FavoriteScreen( textAlign = TextAlign.Center, ) - if (isEditMode) { - IconButton( - onClick = { + IconButton( + onClick = { + if (isEditMode) { isEditMode = false selectedPartnershipIds = emptySet() - }, - modifier = Modifier - .align(Alignment.CenterStart) - .padding(start = 12.dp), - ) { - Icon( - painter = painterResource(id = R.drawable.ic_arrow_left), - contentDescription = stringResource(R.string.nav_back), - tint = Gray500, - ) - } + } else { + onBackToMap() + } + }, + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 12.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_left), + contentDescription = stringResource(R.string.nav_back), + tint = Gray500, + ) } } } @@ -266,7 +290,12 @@ internal fun FavoriteScreen( }, ) } else { - Spacer(modifier = Modifier.fillMaxSize()) + FavoriteMenuContent( + uiState = uiState, + onSearchQueryChanged = onMenuSearchQueryChanged, + onSearchFavoriteClick = onSearchFavoriteClick, + onFavoriteMenuClick = onFavoriteMenuClick, + ) } } } @@ -292,6 +321,28 @@ 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 + }, + ) + } + } } } } @@ -533,9 +584,9 @@ private fun FavoriteTabs( ) { Text( text = label, - style = EatssuTheme.typography.h2, + style = EatssuTheme.typography.subtitle2.copy(lineHeight = 24.sp), color = if (selectedPage == page) Primary else Gray400, - modifier = Modifier.padding(vertical = 18.dp), + modifier = Modifier.padding(vertical = 8.5.dp), ) Box( modifier = Modifier 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 f3d05728d..cc6e8dffe 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 @@ -1,23 +1,35 @@ package com.eatssu.android.presentation.favorite +import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.eatssu.android.R import com.eatssu.android.data.local.FavoritePartnershipDataStore import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.domain.model.FavoriteMenu +import com.eatssu.android.domain.model.MenuFavoriteSearchResult import com.eatssu.android.domain.model.Partnership import com.eatssu.android.domain.model.PartnershipRestaurant +import com.eatssu.android.domain.repository.MenuFavoriteRepository import com.eatssu.android.domain.repository.PartnershipRepository import com.eatssu.common.UiState import com.eatssu.common.enums.StoreType import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +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.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +private const val MENU_SEARCH_MIN_LENGTH = 2 + data class FavoritePartnershipItem( val partnershipId: Int, val storeName: String, @@ -26,67 +38,237 @@ data class FavoritePartnershipItem( val detail: PartnershipRestaurant? = null, ) +enum class MenuFavoriteLoadState { + Idle, + Loading, + Success, + Error, +} + data class FavoriteState( val partnerships: List = emptyList(), val selectedStoreType: StoreType? = null, + val favoriteMenus: List = emptyList(), + val menuSearchResults: List = emptyList(), + val menuSearchQuery: String = "", + val favoriteMenuLoadState: MenuFavoriteLoadState = MenuFavoriteLoadState.Idle, + val menuSearchLoadState: MenuFavoriteLoadState = MenuFavoriteLoadState.Idle, ) { val filteredPartnerships: List get() = selectedStoreType?.let { type -> partnerships.filter { it.storeType == type } } ?: partnerships + + val normalizedMenuSearchQuery: String + get() = menuSearchQuery.trim() + + val isMenuSearchActive: Boolean + get() = normalizedMenuSearchQuery.length >= MENU_SEARCH_MIN_LENGTH } +data class FavoriteMenuSnackbarEvent( + @StringRes val messageRes: Int, + val isError: Boolean, +) + @HiltViewModel class FavoriteViewModel @Inject constructor( private val partnershipRepository: PartnershipRepository, private val favoritePartnershipDataStore: FavoritePartnershipDataStore, + private val menuFavoriteRepository: MenuFavoriteRepository, ) : ViewModel() { private val _uiState = MutableStateFlow>(UiState.Init) val uiState: StateFlow> = _uiState.asStateFlow() + private val _menuSnackbarEvents = MutableSharedFlow( + extraBufferCapacity = 1, + ) + val menuSnackbarEvents: SharedFlow = + _menuSnackbarEvents.asSharedFlow() + + private var menuSearchJob: Job? = null + fun loadFavorites() { viewModelScope.launch { - val selectedStoreType = (_uiState.value as? UiState.Success)?.data?.selectedStoreType + val previous = (_uiState.value as? UiState.Success)?.data ?: FavoriteState() _uiState.value = UiState.Loading - val items = partnershipRepository.getUserFavoritePartnerships() - .mapNotNull(Partnership::toFavoriteItemOrNull) - .distinctBy { it.partnershipId } - val order = - favoritePartnershipDataStore.reconcile(items.map { it.partnershipId }).distinct() - val itemById = items.associateBy { it.partnershipId } - - // 서버 목록을 기기에 기록한 최근 찜 순서에 맞춰 정렬한다. - _uiState.value = UiState.Success( - FavoriteState( - partnerships = order.mapNotNull(itemById::get), - selectedStoreType = selectedStoreType, - ), + val partnershipJob = async { + val items = partnershipRepository.getUserFavoritePartnerships() + .mapNotNull(Partnership::toFavoriteItemOrNull) + .distinctBy { it.partnershipId } + val order = favoritePartnershipDataStore + .reconcile(items.map { it.partnershipId }) + .distinct() + val itemById = items.associateBy { it.partnershipId } + order.mapNotNull(itemById::get) + } + val menuJob = async { menuFavoriteRepository.getFavoriteMenus() } + + val partnerships = partnershipJob.await() + when (val menuResult = menuJob.await()) { + is ApiResult.Success -> { + _uiState.value = UiState.Success( + previous.copy( + partnerships = partnerships, + favoriteMenus = menuResult.data, + favoriteMenuLoadState = MenuFavoriteLoadState.Success, + ), + ) + } + + else -> { + _uiState.value = UiState.Success( + previous.copy( + partnerships = partnerships, + favoriteMenus = emptyList(), + favoriteMenuLoadState = MenuFavoriteLoadState.Error, + ), + ) + showMenuSnackbar(R.string.favorite_menu_load_error, isError = true) + } + } + } + } + + fun onMenuSearchQueryChanged(query: String) { + menuSearchJob?.cancel() + val keyword = query.trim() + updateState { + it.copy( + menuSearchQuery = query, + menuSearchResults = if (keyword.length < MENU_SEARCH_MIN_LENGTH) { + emptyList() + } else { + it.menuSearchResults + }, + menuSearchLoadState = if (keyword.length < MENU_SEARCH_MIN_LENGTH) { + MenuFavoriteLoadState.Idle + } else { + MenuFavoriteLoadState.Loading + }, ) } + + if (keyword.length < MENU_SEARCH_MIN_LENGTH) return + + menuSearchJob = viewModelScope.launch { + when (val result = menuFavoriteRepository.searchMenus(keyword)) { + is ApiResult.Success -> updateSearchResultIfCurrent(keyword) { + it.copy( + menuSearchResults = result.data, + menuSearchLoadState = MenuFavoriteLoadState.Success, + ) + } + + else -> { + updateSearchResultIfCurrent(keyword) { + it.copy( + menuSearchResults = emptyList(), + menuSearchLoadState = MenuFavoriteLoadState.Error, + ) + } + showMenuSnackbar(R.string.favorite_menu_load_error, isError = true) + } + } + } + } + + fun toggleMenuFavorite(item: MenuFavoriteSearchResult) { + viewModelScope.launch { + val result = if (item.isFavorite) { + menuFavoriteRepository.removeFavoriteMenu(item.menuId) + } else { + menuFavoriteRepository.addFavoriteMenu(item.menuId) + } + + if (result is ApiResult.Success) { + updateState { state -> + val updatedResults = state.menuSearchResults.map { resultItem -> + if (resultItem.menuId == item.menuId) { + resultItem.copy(isFavorite = !item.isFavorite) + } else { + resultItem + } + } + val updatedFavorites = if (item.isFavorite) { + state.favoriteMenus.filterNot { it.menuId == item.menuId } + } else { + listOf( + FavoriteMenu( + menuId = item.menuId, + menuName = item.menuName, + restaurant = item.restaurant, + isDiscontinued = false, + ), + ) + state.favoriteMenus.filterNot { it.menuId == item.menuId } + } + state.copy( + menuSearchResults = updatedResults, + favoriteMenus = updatedFavorites, + ) + } + showMenuSnackbar( + messageRes = if (item.isFavorite) { + R.string.favorite_menu_deleted_snackbar + } else { + R.string.favorite_menu_added_snackbar + }, + isError = false, + ) + } else { + showMenuSnackbar(R.string.favorite_menu_action_error, isError = true) + } + } + } + + fun removeFavoriteMenu(item: FavoriteMenu) { + viewModelScope.launch { + when (menuFavoriteRepository.removeFavoriteMenu(item.menuId)) { + is ApiResult.Success -> { + updateState { state -> + state.copy( + favoriteMenus = state.favoriteMenus.filterNot { + it.menuId == item.menuId + }, + menuSearchResults = state.menuSearchResults.map { result -> + if (result.menuId == item.menuId) { + result.copy(isFavorite = false) + } else { + result + } + }, + ) + } + showMenuSnackbar(R.string.favorite_menu_deleted_snackbar, isError = false) + } + + else -> showMenuSnackbar(R.string.favorite_menu_action_error, isError = true) + } + } } fun selectStoreType(storeType: StoreType?) { - val current = _uiState.value as? UiState.Success ?: return - _uiState.value = UiState.Success(current.data.copy(selectedStoreType = storeType)) + updateState { it.copy(selectedStoreType = storeType) } } fun removeFavorite(partnershipId: Int) { - val current = (_uiState.value as? UiState.Success)?.data ?: return - viewModelScope.launch { val result = partnershipRepository.likePartnership(partnershipId, wasLiked = true) if (result is ApiResult.Success) { - val updatedPartnerships = - current.partnerships.filterNot { it.partnershipId == partnershipId } - _uiState.value = UiState.Success(current.copy(partnerships = updatedPartnerships)) + updateState { state -> + state.copy( + partnerships = state.partnerships.filterNot { + it.partnershipId == partnershipId + }, + ) + } } } } fun removeFavorites(partnershipIds: Set) { - val current = (_uiState.value as? UiState.Success)?.data ?: return if (partnershipIds.isEmpty()) return viewModelScope.launch { @@ -98,34 +280,68 @@ class FavoriteViewModel @Inject constructor( } val successfulIds = jobs.awaitAll().filterNotNull().toSet() if (successfulIds.isNotEmpty()) { - val updatedPartnerships = - current.partnerships.filterNot { it.partnershipId in successfulIds } - _uiState.value = UiState.Success(current.copy(partnerships = updatedPartnerships)) + updateState { state -> + state.copy( + partnerships = state.partnerships.filterNot { + it.partnershipId in successfulIds + }, + ) + } } } } fun restoreFavorites(items: List) { - val current = (_uiState.value as? UiState.Success)?.data ?: return if (items.isEmpty()) return viewModelScope.launch { val jobs = items.map { item -> async { - val result = - partnershipRepository.likePartnership(item.partnershipId, wasLiked = false) + val result = partnershipRepository.likePartnership( + item.partnershipId, + wasLiked = false, + ) if (result is ApiResult.Success) item else null } } val restoredItems = jobs.awaitAll().filterNotNull() if (restoredItems.isNotEmpty()) { - val existingIds = current.partnerships.map { it.partnershipId }.toSet() - val newlyAdded = restoredItems.filterNot { it.partnershipId in existingIds } - val updated = newlyAdded + current.partnerships - _uiState.value = UiState.Success(current.copy(partnerships = updated)) + updateState { state -> + val existingIds = state.partnerships.map { it.partnershipId }.toSet() + val newlyAdded = restoredItems.filterNot { it.partnershipId in existingIds } + state.copy(partnerships = newlyAdded + state.partnerships) + } } } } + + private fun updateSearchResultIfCurrent( + keyword: String, + transform: (FavoriteState) -> FavoriteState, + ) { + updateState { state -> + if (state.normalizedMenuSearchQuery == keyword) transform(state) else state + } + } + + private fun updateState(transform: (FavoriteState) -> FavoriteState) { + _uiState.update { current -> + if (current is UiState.Success) { + UiState.Success(transform(current.data)) + } else { + current + } + } + } + + private fun showMenuSnackbar(@StringRes messageRes: Int, isError: Boolean) { + _menuSnackbarEvents.tryEmit( + FavoriteMenuSnackbarEvent( + messageRes = messageRes, + isError = isError, + ), + ) + } } private fun Partnership.toFavoriteItemOrNull(): FavoritePartnershipItem? { diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 000000000..f73358afe --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/menu/menu_bottom_navigation.xml b/app/src/main/res/menu/menu_bottom_navigation.xml index 5fc67b5a7..32d965ee5 100644 --- a/app/src/main/res/menu/menu_bottom_navigation.xml +++ b/app/src/main/res/menu/menu_bottom_navigation.xml @@ -19,12 +19,11 @@ - - - - - - + Added to favorite partners. Removed from favorite partners. Cancel + Search for a menu to favorite. + No favorite menus yet. + Search for a menu you like and add it. + My favorite menus + No matching menus. + Try another menu name. + Added to favorite menus. + Removed from favorite menus. + Couldn\'t complete the action. Please try again. + Couldn\'t load menus. Please try again. + Add menu to favorites + Remove menu from favorites + Discontinued My Partners Festival All diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 5a3e74016..e153f794f 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -342,6 +342,19 @@ お気に入り提携に追加しました。 お気に入り提携から削除しました。 キャンセル + お気に入りにしたいメニューを検索してください。 + お気に入りのメニューはまだありません。 + 好きなメニューを検索して登録してみましょう! + お気に入りメニュー + 一致するメニューがありません。 + 別のメニュー名で検索してください。 + お気に入りメニューに追加しました。 + お気に入りメニューから削除しました。 + 処理できませんでした。しばらくしてからもう一度お試しください。 + メニューを読み込めませんでした。しばらくしてからもう一度お試しください。 + メニューをお気に入りに追加 + メニューをお気に入りから削除 + 販売終了 自分の提携 祭り すべて diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 13838cd74..3a1d61b72 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -342,6 +342,19 @@ Đã thêm vào đối tác yêu thích. Đã xóa khỏi đối tác yêu thích. Hủy + Tìm món ăn bạn muốn yêu thích. + Chưa có món ăn yêu thích. + Tìm và thêm món ăn bạn yêu thích. + Món ăn yêu thích của tôi + Không có món ăn phù hợp. + Hãy thử tên món ăn khác. + Đã thêm vào món ăn yêu thích. + Đã xóa khỏi món ăn yêu thích. + Không thể xử lý. Vui lòng thử lại sau. + Không thể tải món ăn. Vui lòng thử lại sau. + Thêm món ăn vào yêu thích + Xóa món ăn khỏi yêu thích + Ngừng bán Ưu đãi của tôi Lễ hội Tất cả diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9f3632c7d..b67fe5f1e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -342,6 +342,19 @@ 찜한 제휴에 추가했어요. 찜한 제휴에서 삭제했어요. 취소하기 + 찜하고 싶은 메뉴를 검색하세요. + 아직 찜한 메뉴가 없어요. + 좋아하는 메뉴를 검색해서 등록해 보세요! + 내가 찜한 메뉴 + 일치하는 메뉴가 없어요. + 다른 메뉴명으로 검색해주세요! + 찜한 메뉴에 추가했어요. + 찜한 메뉴에서 삭제했어요. + 처리하지 못했어요. 잠시 후 다시 시도해주세요. + 메뉴를 불러오지 못했어요. 잠시 후 다시 시도해주세요. + 메뉴 찜하기 + 메뉴 찜 취소하기 + 단종 내 제휴 축제 전체 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 3b4bcb615..7dd51423d 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 @@ -2,11 +2,18 @@ package com.eatssu.android.presentation.favorite import com.eatssu.android.data.local.FavoritePartnershipDataStore import com.eatssu.android.domain.repository.PartnershipRepository +import com.eatssu.android.domain.repository.MenuFavoriteRepository +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.domain.model.FavoriteMenu +import com.eatssu.android.domain.model.MenuFavoriteSearchResult import com.eatssu.android.test.AppBehaviorSpec import com.eatssu.android.test.samplePartnership import com.eatssu.common.UiState +import com.eatssu.common.enums.Restaurant import com.eatssu.common.enums.StoreType +import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe +import io.mockk.coVerify import io.mockk.coEvery import io.mockk.mockk @@ -15,6 +22,7 @@ class FavoriteViewModelBehaviorSpec : AppBehaviorSpec({ given("제휴 찜 화면") { val repository = mockk() val dataStore = mockk() + val menuRepository = mockk() val restaurant = samplePartnership(storeName = "식당", type = StoreType.RESTAURANT) val cafe = samplePartnership( storeName = "카페", @@ -24,8 +32,9 @@ class FavoriteViewModelBehaviorSpec : AppBehaviorSpec({ coEvery { repository.getUserFavoritePartnerships() } returns listOf(restaurant, cafe) coEvery { dataStore.reconcile(listOf(1, 2)) } returns listOf(2, 1) + coEvery { menuRepository.getFavoriteMenus() } returns ApiResult.Success(emptyList()) - val viewModel = FavoriteViewModel(repository, dataStore) + val viewModel = FavoriteViewModel(repository, dataStore, menuRepository) `when`("찜 목록을 불러오면") { viewModel.loadFavorites() @@ -63,4 +72,72 @@ class FavoriteViewModelBehaviorSpec : AppBehaviorSpec({ } } } + + given("메뉴 찜 화면") { + val partnershipRepository = mockk() + val dataStore = mockk() + val menuRepository = mockk() + val favoriteMenu = FavoriteMenu( + menuId = 10L, + menuName = "김치찌개", + restaurant = Restaurant.HAKSIK, + isDiscontinued = false, + ) + val searchResult = MenuFavoriteSearchResult( + menuId = 20L, + menuName = "김치닭볶음탕", + restaurant = Restaurant.DODAM, + isFavorite = false, + ) + + coEvery { partnershipRepository.getUserFavoritePartnerships() } returns emptyList() + coEvery { dataStore.reconcile(emptyList()) } returns emptyList() + coEvery { menuRepository.getFavoriteMenus() } returns ApiResult.Success(listOf(favoriteMenu)) + coEvery { menuRepository.searchMenus("김치") } returns ApiResult.Success(listOf(searchResult)) + coEvery { menuRepository.addFavoriteMenu(20L) } returns ApiResult.Success(Unit) + + val viewModel = FavoriteViewModel(partnershipRepository, dataStore, menuRepository) + + `when`("찜 목록을 불러오면") { + viewModel.loadFavorites() + + then("서버가 내려준 최신순 메뉴를 표시한다") { + val state = (viewModel.uiState.value as UiState.Success).data + state.favoriteMenus shouldBe listOf(favoriteMenu) + } + } + + `when`("공백을 제외한 검색어가 두 글자 미만이면") { + viewModel.loadFavorites() + viewModel.onMenuSearchQueryChanged(" 김 ") + + then("서버 검색을 호출하지 않는다") { + coVerify(exactly = 0) { menuRepository.searchMenus(any()) } + } + } + + `when`("두 글자 이상 메뉴를 검색하면") { + viewModel.loadFavorites() + viewModel.onMenuSearchQueryChanged(" 김치 ") + + then("앞뒤 공백을 제거한 결과를 식당 정보와 함께 유지한다") { + val state = (viewModel.uiState.value as UiState.Success).data + state.menuSearchResults shouldBe listOf(searchResult) + state.menuSearchResults.first().restaurant shouldBe Restaurant.DODAM + } + } + + `when`("검색 결과를 찜하면") { + viewModel.loadFavorites() + viewModel.onMenuSearchQueryChanged("김치") + viewModel.toggleMenuFavorite(searchResult) + + then("찜 목록의 맨 앞에 추가하고 검색 결과도 갱신한다") { + val state = (viewModel.uiState.value as UiState.Success).data + state.favoriteMenus shouldHaveSize 2 + state.favoriteMenus.first().menuId shouldBe 20L + state.menuSearchResults.first().isFavorite shouldBe true + } + } + } }) From f5d87cba068703dca87c9ef6a7a9531f03c89ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A3=BC=EC=9E=AC=EC=B0=AC?= Date: Tue, 29 Sep 2026 14:24:51 +0900 Subject: [PATCH 2/3] Align menu favorites with product spec --- .../favorite/FavoriteMenuContent.kt | 17 +++++++++++++++++ app/src/main/res/drawable/ic_close.xml | 13 +++++++++++++ app/src/main/res/values-en/strings.xml | 5 +++-- app/src/main/res/values-ja/strings.xml | 5 +++-- app/src/main/res/values-vi/strings.xml | 5 +++-- app/src/main/res/values/strings.xml | 9 +++++---- 6 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 app/src/main/res/drawable/ic_close.xml diff --git a/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteMenuContent.kt b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteMenuContent.kt index 4b91a5730..6d4bd0d4f 100644 --- a/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteMenuContent.kt +++ b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteMenuContent.kt @@ -142,6 +142,23 @@ private fun MenuFavoriteSearchField( } innerTextField() } + if (query.isNotEmpty()) { + Box( + modifier = Modifier + .size(32.dp) + .clickable { onQueryChanged("") }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_close), + contentDescription = stringResource( + R.string.favorite_menu_clear_search_content_description, + ), + modifier = Modifier.size(18.dp), + tint = Gray400, + ) + } + } } }, ) diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 000000000..d96422dc5 --- /dev/null +++ b/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 0848b4114..d0ee74c6d 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -344,14 +344,15 @@ Cancel Search for a menu to favorite. No favorite menus yet. - Search for a menu you like and add it. + Find a menu in the search field and add it. My favorite menus No matching menus. Try another menu name. Added to favorite menus. Removed from favorite menus. Couldn\'t complete the action. Please try again. - Couldn\'t load menus. Please try again. + Couldn\'t load menus.\nPlease try again. + Clear search text Add menu to favorites Remove menu from favorites Discontinued diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index e153f794f..886c70d20 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -344,14 +344,15 @@ キャンセル お気に入りにしたいメニューを検索してください。 お気に入りのメニューはまだありません。 - 好きなメニューを検索して登録してみましょう! + 検索欄からメニューを探して追加してみましょう! お気に入りメニュー 一致するメニューがありません。 別のメニュー名で検索してください。 お気に入りメニューに追加しました。 お気に入りメニューから削除しました。 処理できませんでした。しばらくしてからもう一度お試しください。 - メニューを読み込めませんでした。しばらくしてからもう一度お試しください。 + メニューを読み込めませんでした。\nしばらくしてからもう一度お試しください。 + 検索語をすべて削除 メニューをお気に入りに追加 メニューをお気に入りから削除 販売終了 diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 3a1d61b72..ec4b0d890 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -344,14 +344,15 @@ Hủy Tìm món ăn bạn muốn yêu thích. Chưa có món ăn yêu thích. - Tìm và thêm món ăn bạn yêu thích. + Tìm món ăn trong ô tìm kiếm và thêm vào. Món ăn yêu thích của tôi Không có món ăn phù hợp. Hãy thử tên món ăn khác. Đã thêm vào món ăn yêu thích. Đã xóa khỏi món ăn yêu thích. Không thể xử lý. Vui lòng thử lại sau. - Không thể tải món ăn. Vui lòng thử lại sau. + Không thể tải món ăn.\nVui lòng thử lại sau. + Xóa toàn bộ từ khóa tìm kiếm Thêm món ăn vào yêu thích Xóa món ăn khỏi yêu thích Ngừng bán diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b67fe5f1e..7e862be08 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -342,16 +342,17 @@ 찜한 제휴에 추가했어요. 찜한 제휴에서 삭제했어요. 취소하기 - 찜하고 싶은 메뉴를 검색하세요. + 찜하고 싶은 메뉴를 검색해 보세요. 아직 찜한 메뉴가 없어요. - 좋아하는 메뉴를 검색해서 등록해 보세요! + 검색창에서 메뉴를 찾아 추가해보세요! 내가 찜한 메뉴 - 일치하는 메뉴가 없어요. + 일치하는 메뉴가 없어요 다른 메뉴명으로 검색해주세요! 찜한 메뉴에 추가했어요. 찜한 메뉴에서 삭제했어요. 처리하지 못했어요. 잠시 후 다시 시도해주세요. - 메뉴를 불러오지 못했어요. 잠시 후 다시 시도해주세요. + 메뉴를 불러오지 못했어요.\n잠시 후 다시 시도해주세요. + 검색어 전체 삭제 메뉴 찜하기 메뉴 찜 취소하기 단종 From d1e93af22a104a0ed9193c5b39b2e9d4e198c81a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A3=BC=EC=9E=AC=EC=B0=AC?= Date: Sat, 3 Oct 2026 23:58:15 +0900 Subject: [PATCH 3/3] Keep unfavorited menus visible until leaving favorites --- .../favorite/FavoriteMenuContent.kt | 4 +- .../presentation/favorite/FavoriteScreen.kt | 2 +- .../favorite/FavoriteViewModel.kt | 141 +++++++++++------- .../favorite/FavoriteViewModelBehaviorSpec.kt | 46 ++++++ 4 files changed, 138 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteMenuContent.kt b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteMenuContent.kt index 6d4bd0d4f..c17102ffa 100644 --- a/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteMenuContent.kt +++ b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteMenuContent.kt @@ -210,6 +210,7 @@ private fun FavoriteMenuListContent( ) { menu -> FavoriteMenuRow( menu = menu, + isFavorite = menu.menuId !in state.unfavoritedMenuIds, onFavoriteClick = { onFavoriteClick(menu) }, ) } @@ -261,6 +262,7 @@ private fun MenuSearchContent( @Composable private fun FavoriteMenuRow( menu: FavoriteMenu, + isFavorite: Boolean, onFavoriteClick: () -> Unit, ) { MenuRow( @@ -269,7 +271,7 @@ private fun FavoriteMenuRow( restaurant = stringResource(menu.restaurant.displayNameResId), isDiscontinued = menu.isDiscontinued, ), - isFavorite = true, + isFavorite = isFavorite, onFavoriteClick = onFavoriteClick, ) } 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 cce051780..34ed16b17 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 @@ -128,7 +128,7 @@ fun FavoriteRoute( onRestoreFavorites = viewModel::restoreFavorites, onMenuSearchQueryChanged = viewModel::onMenuSearchQueryChanged, onSearchFavoriteClick = viewModel::toggleMenuFavorite, - onFavoriteMenuClick = viewModel::removeFavoriteMenu, + onFavoriteMenuClick = viewModel::toggleFavoriteMenu, menuSnackbarEvent = menuSnackbarEvent, ) } 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 cc6e8dffe..8809e86d2 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 @@ -49,6 +49,7 @@ data class FavoriteState( val partnerships: List = emptyList(), val selectedStoreType: StoreType? = null, val favoriteMenus: List = emptyList(), + val unfavoritedMenuIds: Set = emptySet(), val menuSearchResults: List = emptyList(), val menuSearchQuery: String = "", val favoriteMenuLoadState: MenuFavoriteLoadState = MenuFavoriteLoadState.Idle, @@ -88,6 +89,7 @@ class FavoriteViewModel @Inject constructor( _menuSnackbarEvents.asSharedFlow() private var menuSearchJob: Job? = null + private val pendingMenuActions = mutableSetOf() fun loadFavorites() { viewModelScope.launch { @@ -113,6 +115,7 @@ class FavoriteViewModel @Inject constructor( previous.copy( partnerships = partnerships, favoriteMenus = menuResult.data, + unfavoritedMenuIds = emptySet(), favoriteMenuLoadState = MenuFavoriteLoadState.Success, ), ) @@ -123,6 +126,7 @@ class FavoriteViewModel @Inject constructor( previous.copy( partnerships = partnerships, favoriteMenus = emptyList(), + unfavoritedMenuIds = emptySet(), favoriteMenuLoadState = MenuFavoriteLoadState.Error, ), ) @@ -176,75 +180,106 @@ class FavoriteViewModel @Inject constructor( } fun toggleMenuFavorite(item: MenuFavoriteSearchResult) { + if (!pendingMenuActions.add(item.menuId)) return viewModelScope.launch { - val result = if (item.isFavorite) { - menuFavoriteRepository.removeFavoriteMenu(item.menuId) - } else { - menuFavoriteRepository.addFavoriteMenu(item.menuId) - } + try { + val result = if (item.isFavorite) { + menuFavoriteRepository.removeFavoriteMenu(item.menuId) + } else { + menuFavoriteRepository.addFavoriteMenu(item.menuId) + } - if (result is ApiResult.Success) { - updateState { state -> - val updatedResults = state.menuSearchResults.map { resultItem -> - if (resultItem.menuId == item.menuId) { - resultItem.copy(isFavorite = !item.isFavorite) + if (result is ApiResult.Success) { + updateState { state -> + val updatedResults = state.menuSearchResults.map { resultItem -> + if (resultItem.menuId == item.menuId) { + resultItem.copy(isFavorite = !item.isFavorite) + } else { + resultItem + } + } + val updatedFavorites = if ( + item.isFavorite || state.favoriteMenus.any { it.menuId == item.menuId } + ) { + state.favoriteMenus } else { - resultItem + listOf( + FavoriteMenu( + menuId = item.menuId, + menuName = item.menuName, + restaurant = item.restaurant, + isDiscontinued = false, + ), + ) + state.favoriteMenus } + state.copy( + menuSearchResults = updatedResults, + favoriteMenus = updatedFavorites, + unfavoritedMenuIds = if (item.isFavorite) { + state.unfavoritedMenuIds + item.menuId + } else { + state.unfavoritedMenuIds - item.menuId + }, + ) } - val updatedFavorites = if (item.isFavorite) { - state.favoriteMenus.filterNot { it.menuId == item.menuId } - } else { - listOf( - FavoriteMenu( - menuId = item.menuId, - menuName = item.menuName, - restaurant = item.restaurant, - isDiscontinued = false, - ), - ) + state.favoriteMenus.filterNot { it.menuId == item.menuId } - } - state.copy( - menuSearchResults = updatedResults, - favoriteMenus = updatedFavorites, + showMenuSnackbar( + messageRes = if (item.isFavorite) { + R.string.favorite_menu_deleted_snackbar + } else { + R.string.favorite_menu_added_snackbar + }, + isError = false, ) + } else { + showMenuSnackbar(R.string.favorite_menu_action_error, isError = true) } - showMenuSnackbar( - messageRes = if (item.isFavorite) { - R.string.favorite_menu_deleted_snackbar - } else { - R.string.favorite_menu_added_snackbar - }, - isError = false, - ) - } else { - showMenuSnackbar(R.string.favorite_menu_action_error, isError = true) + } finally { + pendingMenuActions.remove(item.menuId) } } } - fun removeFavoriteMenu(item: FavoriteMenu) { + fun toggleFavoriteMenu(item: FavoriteMenu) { + if (!pendingMenuActions.add(item.menuId)) return viewModelScope.launch { - when (menuFavoriteRepository.removeFavoriteMenu(item.menuId)) { - is ApiResult.Success -> { - updateState { state -> - state.copy( - favoriteMenus = state.favoriteMenus.filterNot { - it.menuId == item.menuId - }, - menuSearchResults = state.menuSearchResults.map { result -> - if (result.menuId == item.menuId) { - result.copy(isFavorite = false) + try { + val state = (_uiState.value as? UiState.Success)?.data ?: return@launch + val isFavorite = item.menuId !in state.unfavoritedMenuIds + val result = if (isFavorite) { + menuFavoriteRepository.removeFavoriteMenu(item.menuId) + } else { + menuFavoriteRepository.addFavoriteMenu(item.menuId) + } + + when (result) { + is ApiResult.Success -> { + updateState { current -> + current.copy( + unfavoritedMenuIds = if (isFavorite) { + current.unfavoritedMenuIds + item.menuId } else { - result - } - }, + current.unfavoritedMenuIds - item.menuId + }, + menuSearchResults = current.menuSearchResults.map { searchResult -> + if (searchResult.menuId == item.menuId) { + searchResult.copy(isFavorite = !isFavorite) + } else { + searchResult + } + }, + ) + } + showMenuSnackbar( + if (isFavorite) R.string.favorite_menu_deleted_snackbar + else R.string.favorite_menu_added_snackbar, + isError = false, ) } - showMenuSnackbar(R.string.favorite_menu_deleted_snackbar, isError = false) - } - else -> showMenuSnackbar(R.string.favorite_menu_action_error, isError = true) + else -> showMenuSnackbar(R.string.favorite_menu_action_error, isError = true) + } + } finally { + pendingMenuActions.remove(item.menuId) } } } 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 7dd51423d..c81b48741 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 @@ -140,4 +140,50 @@ class FavoriteViewModelBehaviorSpec : AppBehaviorSpec({ } } } + + given("찜 목록에서 메뉴 하트를 잘못 눌렀을 때") { + val partnershipRepository = mockk() + val dataStore = mockk() + val menuRepository = mockk() + val favoriteMenu = FavoriteMenu( + menuId = 10L, + menuName = "김치찌개", + restaurant = Restaurant.HAKSIK, + isDiscontinued = false, + ) + + coEvery { partnershipRepository.getUserFavoritePartnerships() } returns emptyList() + coEvery { dataStore.reconcile(emptyList()) } returns emptyList() + coEvery { menuRepository.getFavoriteMenus() } returnsMany listOf( + ApiResult.Success(listOf(favoriteMenu)), + ApiResult.Success(emptyList()), + ) + coEvery { menuRepository.removeFavoriteMenu(10L) } returns ApiResult.Success(Unit) + coEvery { menuRepository.addFavoriteMenu(10L) } returns ApiResult.Success(Unit) + + val viewModel = FavoriteViewModel(partnershipRepository, dataStore, menuRepository) + + `when`("하트를 끄고 같은 화면에서 다시 켠 뒤, 다시 끄고 재진입하면") { + viewModel.loadFavorites() + viewModel.toggleFavoriteMenu(favoriteMenu) + + then("화면을 떠나기 전에는 메뉴가 남아 다시 찜할 수 있고, 재진입 후에는 사라진다") { + var state = (viewModel.uiState.value as UiState.Success).data + state.favoriteMenus shouldBe listOf(favoriteMenu) + state.unfavoritedMenuIds shouldBe setOf(10L) + + viewModel.toggleFavoriteMenu(favoriteMenu) + state = (viewModel.uiState.value as UiState.Success).data + state.favoriteMenus shouldBe listOf(favoriteMenu) + state.unfavoritedMenuIds shouldBe emptySet() + coVerify(exactly = 1) { menuRepository.addFavoriteMenu(10L) } + + viewModel.toggleFavoriteMenu(favoriteMenu) + viewModel.loadFavorites() + state = (viewModel.uiState.value as UiState.Success).data + state.favoriteMenus shouldBe emptyList() + state.unfavoritedMenuIds shouldBe emptySet() + } + } + } })