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..c17102ffa --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteMenuContent.kt @@ -0,0 +1,414 @@ +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() + } + 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, + ) + } + } + } + }, + ) +} + +@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, + isFavorite = menu.menuId !in state.unfavoritedMenuIds, + 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, + isFavorite: Boolean, + onFavoriteClick: () -> Unit, +) { + MenuRow( + text = menuLabel( + menuName = menu.menuName, + restaurant = stringResource(menu.restaurant.displayNameResId), + isDiscontinued = menu.isDiscontinued, + ), + isFavorite = isFavorite, + 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 beaa02a81..ef1e0919a 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::toggleFavoriteMenu, + 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.subtitle2, + 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..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 @@ -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,272 @@ 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 unfavoritedMenuIds: Set = emptySet(), + 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 + private val pendingMenuActions = mutableSetOf() + 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, + unfavoritedMenuIds = emptySet(), + favoriteMenuLoadState = MenuFavoriteLoadState.Success, + ), + ) + } + + else -> { + _uiState.value = UiState.Success( + previous.copy( + partnerships = partnerships, + favoriteMenus = emptyList(), + unfavoritedMenuIds = emptySet(), + 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) { + if (!pendingMenuActions.add(item.menuId)) return + viewModelScope.launch { + 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) + } else { + resultItem + } + } + val updatedFavorites = if ( + item.isFavorite || state.favoriteMenus.any { it.menuId == item.menuId } + ) { + state.favoriteMenus + } else { + 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 + }, + ) + } + 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 toggleFavoriteMenu(item: FavoriteMenu) { + if (!pendingMenuActions.add(item.menuId)) return + viewModelScope.launch { + 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 { + 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, + ) + } + + else -> showMenuSnackbar(R.string.favorite_menu_action_error, isError = true) + } + } finally { + pendingMenuActions.remove(item.menuId) + } + } } 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 +315,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_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/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. + 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.\nPlease try again. + Clear search text + 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..886c70d20 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -342,6 +342,20 @@ お気に入り提携に追加しました。 お気に入り提携から削除しました。 キャンセル + お気に入りにしたいメニューを検索してください。 + お気に入りのメニューはまだありません。 + 検索欄からメニューを探して追加してみましょう! + お気に入りメニュー + 一致するメニューがありません。 + 別のメニュー名で検索してください。 + お気に入りメニューに追加しました。 + お気に入りメニューから削除しました。 + 処理できませんでした。しばらくしてからもう一度お試しください。 + メニューを読み込めませんでした。\nしばらくしてからもう一度お試しください。 + 検索語をすべて削除 + メニューをお気に入りに追加 + メニューをお気に入りから削除 + 販売終了 自分の提携 祭り すべて diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 13838cd74..ec4b0d890 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -342,6 +342,20 @@ Đã 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 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.\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 Ư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..7e862be08 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -342,6 +342,20 @@ 찜한 제휴에 추가했어요. 찜한 제휴에서 삭제했어요. 취소하기 + 찜하고 싶은 메뉴를 검색해 보세요. + 아직 찜한 메뉴가 없어요. + 검색창에서 메뉴를 찾아 추가해보세요! + 내가 찜한 메뉴 + 일치하는 메뉴가 없어요 + 다른 메뉴명으로 검색해주세요! + 찜한 메뉴에 추가했어요. + 찜한 메뉴에서 삭제했어요. + 처리하지 못했어요. 잠시 후 다시 시도해주세요. + 메뉴를 불러오지 못했어요.\n잠시 후 다시 시도해주세요. + 검색어 전체 삭제 + 메뉴 찜하기 + 메뉴 찜 취소하기 + 단종 내 제휴 축제 전체 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..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 @@ -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,118 @@ 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 + } + } + } + + 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() + } + } + } })