diff --git a/app/src/main/java/com/eatssu/android/data/local/FavoritePartnershipDataStore.kt b/app/src/main/java/com/eatssu/android/data/local/FavoritePartnershipDataStore.kt new file mode 100644 index 000000000..0af3e5ba8 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/local/FavoritePartnershipDataStore.kt @@ -0,0 +1,76 @@ +package com.eatssu.android.data.local + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import javax.inject.Inject + +private val Context.favoritePartnershipDataStore by preferencesDataStore( + name = "favorite_partnership", +) + +class FavoritePartnershipDataStore @Inject constructor( + @ApplicationContext private val context: Context, +) { + private companion object { + val FAVORITE_ORDER = stringPreferencesKey("favorite_order") + } + + val favoriteOrder: Flow> = context.favoritePartnershipDataStore.data + .map { preferences -> + preferences[FAVORITE_ORDER].decodeFavoriteOrder() + } + + suspend fun markLiked(partnershipId: Int) { + updateOrder { current -> listOf(partnershipId) + current.filterNot { it == partnershipId } } + } + + suspend fun markUnliked(partnershipId: Int) { + updateOrder { current -> current.filterNot { it == partnershipId } } + } + + suspend fun reconcile(serverIds: List): List { + var reconciled = emptyList() + context.favoritePartnershipDataStore.edit { preferences -> + reconciled = reconcileFavoriteOrder( + current = preferences[FAVORITE_ORDER].decodeFavoriteOrder(), + serverIds = serverIds, + ) + preferences[FAVORITE_ORDER] = Json.encodeToString(reconciled) + } + return reconciled + } + + suspend fun clear() { + context.favoritePartnershipDataStore.edit { it.clear() } + } + + private suspend fun updateOrder(transform: (List) -> List) { + context.favoritePartnershipDataStore.edit { preferences -> + val current = preferences[FAVORITE_ORDER].decodeFavoriteOrder() + preferences[FAVORITE_ORDER] = Json.encodeToString(transform(current)) + } + } +} + +internal fun reconcileFavoriteOrder( + current: List, + serverIds: List, +): List { + val serverIdSet = serverIds.toSet() + return buildList { + // 저장된 정렬 정보에 중복 ID가 있어도 화면 목록에는 한 번만 노출한다. + addAll(current.filter { it in serverIdSet }.distinct()) + addAll(serverIds.filterNot { it in this }) + } +} + +private fun String?.decodeFavoriteOrder(): List = + this?.let { value -> runCatching { Json.decodeFromString>(value) }.getOrNull() } + .orEmpty() diff --git a/app/src/main/java/com/eatssu/android/data/remote/repository/PartnershipRepositoryImpl.kt b/app/src/main/java/com/eatssu/android/data/remote/repository/PartnershipRepositoryImpl.kt index 994db912a..408f73f44 100644 --- a/app/src/main/java/com/eatssu/android/data/remote/repository/PartnershipRepositoryImpl.kt +++ b/app/src/main/java/com/eatssu/android/data/remote/repository/PartnershipRepositoryImpl.kt @@ -1,5 +1,8 @@ package com.eatssu.android.data.remote.repository +import com.eatssu.android.data.local.FavoritePartnershipDataStore +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.data.model.isSuccess import com.eatssu.android.data.model.map import com.eatssu.android.data.model.orEmptyList import com.eatssu.android.data.model.orNull @@ -14,6 +17,7 @@ import javax.inject.Inject class PartnershipRepositoryImpl @Inject constructor( private val partnershipService: PartnershipService, private val userService: UserService, + private val favoritePartnershipDataStore: FavoritePartnershipDataStore, ) : PartnershipRepository { // 유저의 학과 상관없이 모든 제휴 정보 조회 @@ -26,8 +30,30 @@ class PartnershipRepositoryImpl @Inject constructor( override suspend fun getPartnershipById(partnershipId: Int): PartnershipRestaurant? = partnershipService.getPartnershipById(partnershipId).map { it.toDomain() }.orNull() + // 특정 식당 클릭 시 제휴 정보 조회 + override suspend fun likePartnership( + partnershipId: Int, + wasLiked: Boolean?, + ): ApiResult { + val result = partnershipService.likePartnership(partnershipId) + if (result.isSuccess() && wasLiked != null) { + if (wasLiked) { + favoritePartnershipDataStore.markUnliked(partnershipId) + } else { + favoritePartnershipDataStore.markLiked(partnershipId) + } + } + return result + } + + // 유저의 학과에 해당하는 제휴 정보 조회 override suspend fun getUserCollegePartnerships(): List = userService.getUserDepartmentPartnerships().map { list -> list.map { it.toDomain() } } .orEmptyList() + + // 서버에서 사용자가 찜한 제휴만 조회한다. + override suspend fun getUserFavoritePartnerships(): List = + userService.getUserFavoritePartnerships().map { list -> list.map { it.toDomain() } } + .orEmptyList() } diff --git a/app/src/main/java/com/eatssu/android/data/remote/service/PartnershipService.kt b/app/src/main/java/com/eatssu/android/data/remote/service/PartnershipService.kt index 80c650fc0..afb4d8013 100644 --- a/app/src/main/java/com/eatssu/android/data/remote/service/PartnershipService.kt +++ b/app/src/main/java/com/eatssu/android/data/remote/service/PartnershipService.kt @@ -4,6 +4,8 @@ import com.eatssu.android.data.model.ApiResult import com.eatssu.android.data.remote.dto.response.PartnershipResponse import com.eatssu.android.data.remote.dto.response.PartnershipRestaurantResponse import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path interface PartnershipService { @@ -13,10 +15,14 @@ interface PartnershipService { // 개별 제휴 조회 @GET("partnerships/{partnershipId}") - suspend fun getPartnershipById(partnershipId: Int): ApiResult + suspend fun getPartnershipById( + @Path("partnershipId") partnershipId: Int + ): ApiResult // TODO 제휴 찜/등록하기/ 취소하기 - @GET("partnerships/{partnershipId}/like") - suspend fun likePartnership(partnershipId: Int): ApiResult + @POST("partnerships/{partnershipId}/like") + suspend fun likePartnership( + @Path("partnershipId") partnershipId: Int + ): ApiResult } \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/remote/service/UserService.kt b/app/src/main/java/com/eatssu/android/data/remote/service/UserService.kt index 509ccc069..9a8eb4a32 100644 --- a/app/src/main/java/com/eatssu/android/data/remote/service/UserService.kt +++ b/app/src/main/java/com/eatssu/android/data/remote/service/UserService.kt @@ -54,6 +54,9 @@ interface UserService { @GET("users/department/partnerships") // 유저 학과의 제휴 조회 suspend fun getUserDepartmentPartnerships(): ApiResult> + @GET("users/partnerships") // 유저가 찜한 제휴 조회 + suspend fun getUserFavoritePartnerships(): ApiResult> + @GET("users/language") // 언어 설정 조회 suspend fun getUserLanguage(): ApiResult diff --git a/app/src/main/java/com/eatssu/android/domain/model/PartnershipRestaurant.kt b/app/src/main/java/com/eatssu/android/domain/model/PartnershipRestaurant.kt index 78036bc57..38fea9746 100644 --- a/app/src/main/java/com/eatssu/android/domain/model/PartnershipRestaurant.kt +++ b/app/src/main/java/com/eatssu/android/domain/model/PartnershipRestaurant.kt @@ -1,7 +1,10 @@ package com.eatssu.android.domain.model +import android.os.Parcelable import com.eatssu.common.enums.StoreType +import kotlinx.parcelize.Parcelize +@Parcelize data class PartnershipRestaurant( val id: Int, val partnershipType: String, @@ -18,4 +21,4 @@ data class PartnershipRestaurant( val likedByUser: Boolean, val naverMapUrl: String? = null, val kakaoMapUrl: String? = null, -) +) : Parcelable diff --git a/app/src/main/java/com/eatssu/android/domain/repository/PartnershipRepository.kt b/app/src/main/java/com/eatssu/android/domain/repository/PartnershipRepository.kt index 1b5d70c10..f8c27508c 100644 --- a/app/src/main/java/com/eatssu/android/domain/repository/PartnershipRepository.kt +++ b/app/src/main/java/com/eatssu/android/domain/repository/PartnershipRepository.kt @@ -1,10 +1,16 @@ package com.eatssu.android.domain.repository +import com.eatssu.android.data.model.ApiResult import com.eatssu.android.domain.model.Partnership import com.eatssu.android.domain.model.PartnershipRestaurant interface PartnershipRepository { suspend fun getAllPartnerships(): List suspend fun getPartnershipById(partnershipId: Int): PartnershipRestaurant? + suspend fun likePartnership( + partnershipId: Int, + wasLiked: Boolean? = null, + ): ApiResult suspend fun getUserCollegePartnerships(): List -} \ No newline at end of file + suspend fun getUserFavoritePartnerships(): List +} diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/auth/LogoutUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/auth/LogoutUseCase.kt index a49369b9b..1a79e1e6d 100644 --- a/app/src/main/java/com/eatssu/android/domain/usecase/auth/LogoutUseCase.kt +++ b/app/src/main/java/com/eatssu/android/domain/usecase/auth/LogoutUseCase.kt @@ -1,6 +1,7 @@ package com.eatssu.android.domain.usecase.auth import com.eatssu.android.data.local.AccountDataStore +import com.eatssu.android.data.local.FavoritePartnershipDataStore import com.eatssu.android.data.local.SettingDataStore import com.eatssu.android.data.local.TokenStore import com.eatssu.common.analytics.AnalyticsTracker @@ -10,12 +11,14 @@ class LogoutUseCase @Inject constructor( private val accountDataStore: AccountDataStore, private val tokenStore: TokenStore, private val settingDataStore: SettingDataStore, + private val favoritePartnershipDataStore: FavoritePartnershipDataStore, private val analyticsTracker: AnalyticsTracker, ) { suspend operator fun invoke() { accountDataStore.clear() tokenStore.clear() settingDataStore.clear() + favoritePartnershipDataStore.clear() analyticsTracker.resetIdentity() } } 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 06a0f47c6..2e7239f24 100644 --- a/app/src/main/java/com/eatssu/android/presentation/MainActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/MainActivity.kt @@ -11,14 +11,18 @@ import android.view.View.GONE import androidx.activity.viewModels import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.core.os.bundleOf import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment import androidx.work.WorkManager import com.eatssu.android.R import com.eatssu.android.databinding.ActivityMainBinding +import com.eatssu.android.domain.model.PartnershipRestaurant import com.eatssu.android.presentation.base.BaseActivity import com.eatssu.android.presentation.event.AnyoneButMeEventPopupController import com.eatssu.android.presentation.event.AnyoneButMeEventTooltipController +import com.eatssu.android.presentation.favorite.FavoriteDetailFragment import com.eatssu.android.presentation.login.LoginActivity import com.eatssu.android.presentation.mypage.MyPageViewModel import com.eatssu.android.presentation.mypage.userinfo.UserInfoActivity @@ -57,6 +61,7 @@ class MainActivity : BaseActivity( private val mainViewModel: MainViewModel by viewModels() private val myPageViewModel: MyPageViewModel by viewModels() + private lateinit var navController: NavController @SuppressLint("SuspiciousIndentation") override fun onCreate(savedInstanceState: Bundle?) { @@ -77,7 +82,7 @@ class MainActivity : BaseActivity( private fun setNavigation() { val navHostFragment = supportFragmentManager .findFragmentById(R.id.nav_host_fragment) as NavHostFragment - val navController = navHostFragment.navController + navController = navHostFragment.navController binding.bottomNaviBar.itemIconTintList = null binding.bottomNaviBar.setOnSingleItemSelectedListener { item -> @@ -92,10 +97,15 @@ class MainActivity : BaseActivity( true } - R.id.anyone_but_me_menu -> { - mainViewModel.analyticsPlzNotMe() - anyoneButMeEventPopupController.openAnyoneButMePage() - false +// R.id.anyone_but_me_menu -> { +// mainViewModel.analyticsPlzNotMe() +// anyoneButMeEventPopupController.openAnyoneButMePage() +// false +// } + + R.id.favorite_menu -> { + navController.navigate(R.id.favoriteFragment) + true } R.id.mypage_menu -> { @@ -108,6 +118,39 @@ 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.map_menu + R.id.favoriteFragment -> R.id.favorite_menu + R.id.myPageFragment -> R.id.mypage_menu + else -> null + } + menuItemId?.let { binding.bottomNaviBar.menu.findItem(it).isChecked = true } + } + } + + fun openFavoriteTab() { + if (navController.currentDestination?.id != R.id.favoriteFragment) { + navController.navigate(R.id.favoriteFragment) + } + } + + fun openMapTab() { + if (navController.currentDestination?.id != R.id.mapFragment) { + navController.navigate(R.id.mapFragment) + } + } + + fun openFavoriteDetail(partnership: PartnershipRestaurant) { + navController.navigate( + R.id.favoriteDetailFragment, + bundleOf( + FavoriteDetailFragment.ARG_PARTNERSHIP_ID to partnership.id, + FavoriteDetailFragment.ARG_PARTNERSHIP to partnership, + ), + ) } private fun bindEventPopup(showOnLaunch: Boolean) { diff --git a/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteDetailFragment.kt b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteDetailFragment.kt new file mode 100644 index 000000000..c4bb8cbe2 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteDetailFragment.kt @@ -0,0 +1,64 @@ +package com.eatssu.android.presentation.favorite + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.core.os.BundleCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.eatssu.android.analytics.ProvideAnalyticsTracker +import com.eatssu.android.domain.model.PartnershipRestaurant +import com.eatssu.android.presentation.map.MapExternalNavigator +import com.eatssu.common.analytics.AnalyticsTracker +import com.eatssu.design_system.theme.EatssuTheme +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class FavoriteDetailFragment : Fragment() { + + @Inject + lateinit var analyticsTracker: AnalyticsTracker + + @Inject + lateinit var mapExternalNavigator: MapExternalNavigator + + private val viewModel: FavoriteDetailViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val partnership = requireNotNull( + BundleCompat.getParcelable( + requireArguments(), + ARG_PARTNERSHIP, + PartnershipRestaurant::class.java, + ), + ) + + return ComposeView(requireContext()).apply { + setContent { + ProvideAnalyticsTracker(analyticsTracker) { + EatssuTheme { + FavoriteDetailRoute( + partnership = partnership, + viewModel = viewModel, + mapExternalNavigator = mapExternalNavigator, + onBack = { findNavController().popBackStack() }, + ) + } + } + } + } + } + + companion object { + const val ARG_PARTNERSHIP_ID = "partnershipId" + const val ARG_PARTNERSHIP = "partnership" + } +} diff --git a/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteDetailScreen.kt b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteDetailScreen.kt new file mode 100644 index 000000000..052417ce4 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteDetailScreen.kt @@ -0,0 +1,167 @@ +package com.eatssu.android.presentation.favorite + +import android.content.Context +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.eatssu.android.R +import com.eatssu.android.domain.model.PartnershipRestaurant +import com.eatssu.android.presentation.map.MapExternalNavigator +import com.eatssu.android.presentation.map.MapProvider +import com.eatssu.android.presentation.map.component.MapRestaurantBottomSheet +import com.eatssu.android.presentation.map.model.RestaurantInfo +import com.eatssu.android.presentation.map.openMapWithoutResolution +import com.eatssu.common.UiState +import com.eatssu.design_system.component.DelayedLoadingIndicator +import com.eatssu.design_system.theme.White +import com.naver.maps.geometry.LatLng +import com.naver.maps.map.CameraPosition +import com.naver.maps.map.compose.ExperimentalNaverMapApi +import com.naver.maps.map.compose.MapProperties +import com.naver.maps.map.compose.MapUiSettings +import com.naver.maps.map.compose.NaverMap +import com.naver.maps.map.compose.rememberCameraPositionState +import kotlinx.coroutines.launch + +@Composable +fun FavoriteDetailRoute( + partnership: PartnershipRestaurant, + viewModel: FavoriteDetailViewModel, + mapExternalNavigator: MapExternalNavigator, + onBack: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val scope = rememberCoroutineScope() + + LaunchedEffect(partnership.id) { + viewModel.showPartnership(partnership) + } + + when (val state = uiState) { + UiState.Init, UiState.Loading -> Box(Modifier.fillMaxSize()) { + DelayedLoadingIndicator(modifier = Modifier.align(Alignment.Center)) + } + + UiState.Error -> LaunchedEffect(Unit) { onBack() } + is UiState.Success -> FavoriteDetailScreen( + restaurant = state.data, + onBack = onBack, + onLikeClick = viewModel::toggleLike, + onNaverMapClick = { + scope.launch { + openMap(context, mapExternalNavigator, MapProvider.NAVER, state.data) + } + }, + onKakaoMapClick = { + scope.launch { + openMap(context, mapExternalNavigator, MapProvider.KAKAO, state.data) + } + }, + ) + } +} + +@OptIn(ExperimentalNaverMapApi::class, ExperimentalMaterial3Api::class) +@Composable +private fun FavoriteDetailScreen( + restaurant: PartnershipRestaurant, + onBack: () -> Unit, + onLikeClick: () -> Unit, + onNaverMapClick: () -> Unit, + onKakaoMapClick: () -> Unit, +) { + val cameraPositionState = rememberCameraPositionState { + position = CameraPosition( + LatLng(restaurant.latitude, restaurant.longitude), + 16.5, + ) + } + + Box(modifier = Modifier.fillMaxSize()) { + NaverMap( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + uiSettings = MapUiSettings( + isZoomControlEnabled = false, + isLocationButtonEnabled = false, + ), + properties = MapProperties(), + contentPadding = PaddingValues( + bottom = dimensionResource(R.dimen.bottom_nav_height), + ), + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background(BottomSheetDefaults.ScrimColor), + ) + + Box( + modifier = Modifier + .padding(start = 18.dp, top = 30.dp) + .size(44.dp) + .clip(CircleShape) + .background(White) + .clickable(onClick = onBack), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(R.drawable.ic_arrow_left), + contentDescription = "뒤로가기", + modifier = Modifier.size(24.dp), + ) + } + + MapRestaurantBottomSheet( + storeName = restaurant.storeName, + storeType = restaurant.storeType, + isLike = restaurant.likedByUser, + mapRestaurantList = listOf( + RestaurantInfo( + collegeName = restaurant.collegeName, + departmentName = restaurant.departmentName, + period = "${restaurant.startDate} ~ ${restaurant.endDate}", + benefit = restaurant.description, + ), + ), + onNaverMapClick = onNaverMapClick, + onKakaoMapClick = onKakaoMapClick, + onDismiss = onBack, + onLikeClick = onLikeClick, + scrimColor = Color.Transparent, + ) + } +} + +private suspend fun openMap( + context: Context, + navigator: MapExternalNavigator, + provider: MapProvider, + restaurant: PartnershipRestaurant, +) { + navigator.open(context, provider, restaurant) || + context.openMapWithoutResolution(provider, restaurant) +} diff --git a/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteDetailViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteDetailViewModel.kt new file mode 100644 index 000000000..e8b16d684 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteDetailViewModel.kt @@ -0,0 +1,57 @@ +package com.eatssu.android.presentation.favorite + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.domain.model.PartnershipRestaurant +import com.eatssu.android.domain.repository.PartnershipRepository +import com.eatssu.common.UiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class FavoriteDetailViewModel @Inject constructor( + private val partnershipRepository: PartnershipRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow>(UiState.Init) + val uiState: StateFlow> = _uiState.asStateFlow() + + fun showPartnership(partnership: PartnershipRestaurant) { + // 찜 목록에서 받은 데이터를 재사용해 상세 진입 시 서버를 다시 조회하지 않는다. + _uiState.value = UiState.Success(partnership) + } + + fun loadPartnership(partnershipId: Int) { + if ((_uiState.value as? UiState.Success)?.data?.id == partnershipId) return + + viewModelScope.launch { + _uiState.value = UiState.Loading + _uiState.value = partnershipRepository.getPartnershipById(partnershipId) + ?.let { UiState.Success(it) } + ?: UiState.Error + } + } + + fun toggleLike() { + val current = (_uiState.value as? UiState.Success)?.data ?: return + + viewModelScope.launch { + val result = partnershipRepository.likePartnership(current.id, current.likedByUser) + if (result is ApiResult.Success) { + _uiState.value = UiState.Success( + current.copy( + likedByUser = !current.likedByUser, + partnershipLikeCount = ( + current.partnershipLikeCount + if (current.likedByUser) -1 else 1 + ).coerceAtLeast(0), + ), + ) + } + } + } +} diff --git a/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteFragment.kt b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteFragment.kt new file mode 100644 index 000000000..9785c16b0 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteFragment.kt @@ -0,0 +1,49 @@ +package com.eatssu.android.presentation.favorite + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import com.eatssu.android.analytics.ProvideAnalyticsTracker +import com.eatssu.android.presentation.MainActivity +import com.eatssu.common.analytics.AnalyticsTracker +import com.eatssu.design_system.theme.EatssuTheme +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class FavoriteFragment : Fragment() { + + @Inject + lateinit var analyticsTracker: AnalyticsTracker + + private val viewModel: FavoriteViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = ComposeView(requireContext()).apply { + setContent { + ProvideAnalyticsTracker(analyticsTracker) { + EatssuTheme { + FavoriteRoute( + viewModel = viewModel, + onBackToMap = { mainActivity().openMapTab() }, + onPartnershipClick = mainActivity()::openFavoriteDetail, + ) + } + } + } + } + + override fun onResume() { + super.onResume() + viewModel.loadFavorites() + } + + private fun mainActivity(): MainActivity = requireActivity() as MainActivity +} 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 new file mode 100644 index 000000000..9b9958b3c --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteScreen.kt @@ -0,0 +1,799 @@ +package com.eatssu.android.presentation.favorite + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.itemsIndexed +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.eatssu.android.R +import com.eatssu.android.domain.model.PartnershipRestaurant +import com.eatssu.android.presentation.map.iconRes +import com.eatssu.android.presentation.util.TrackScreenViewEvent +import com.eatssu.common.UiState +import com.eatssu.common.enums.ScreenId +import com.eatssu.common.enums.StoreType +import com.eatssu.design_system.component.DelayedLoadingIndicator +import com.eatssu.design_system.component.EatSsuSnackbar +import com.eatssu.design_system.component.EatSsuSnackbarType +import com.eatssu.design_system.component.EatSsuTopBar +import com.eatssu.design_system.theme.EatssuTheme +import com.eatssu.design_system.theme.Error +import com.eatssu.design_system.theme.Gray200 +import com.eatssu.design_system.theme.Gray300 +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.Primary +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.launch +import kotlin.math.roundToInt + +private const val MENU_PAGE = 0 +private const val PARTNERSHIP_PAGE = 1 + +@Composable +fun FavoriteRoute( + viewModel: FavoriteViewModel, + onBackToMap: () -> Unit, + onPartnershipClick: (PartnershipRestaurant) -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + FavoriteScreen( + uiState = uiState, + onBackToMap = onBackToMap, + onStoreTypeSelected = viewModel::selectStoreType, + onPartnershipClick = onPartnershipClick, + onRemoveFavorite = viewModel::removeFavorite, + onRemoveFavorites = viewModel::removeFavorites, + onRestoreFavorites = viewModel::restoreFavorites, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun FavoriteScreen( + uiState: UiState, + onBackToMap: () -> Unit, + onStoreTypeSelected: (StoreType?) -> Unit, + onPartnershipClick: (PartnershipRestaurant) -> Unit, + onRemoveFavorite: (Int) -> Unit = {}, + onRemoveFavorites: (Set) -> Unit = {}, + onRestoreFavorites: (List) -> Unit = {}, +) { + val pagerState = rememberPagerState( + initialPage = PARTNERSHIP_PAGE, + pageCount = { 2 }, + ) + val scope = rememberCoroutineScope() + var isEditMode by remember { mutableStateOf(false) } + var selectedPartnershipIds by remember { mutableStateOf(setOf()) } + var lastDeletedPartnerships by remember { + mutableStateOf>( + emptyList() + ) + } + var showDeleteSnackbar by remember { mutableStateOf(false) } + var snackbarJob by remember { mutableStateOf(null) } + + TrackScreenViewEvent(ScreenId.MAP_FAVORITE) + + Scaffold( + topBar = { + EatSsuTopBar( + title = stringResource(R.string.favorite_title), + onBack = { + if (isEditMode) { + isEditMode = false + selectedPartnershipIds = emptySet() + } else { + onBackToMap() + } + }, + ) + }, + modifier = Modifier.padding(top = 17.dp) + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(bottom = dimensionResource(R.dimen.bottom_nav_height)), + ) { + Column(modifier = Modifier.fillMaxSize()) { + if (isEditMode) { + FavoriteEditContent( + uiState = uiState, + selectedPartnershipIds = selectedPartnershipIds, + onToggleSelect = { id -> + selectedPartnershipIds = if (id in selectedPartnershipIds) { + selectedPartnershipIds - id + } else { + selectedPartnershipIds + id + } + }, + onToggleSelectAll = { allIds, isAllSelected -> + selectedPartnershipIds = if (isAllSelected) emptySet() else allIds + }, + onDelete = { + val deletedItems = + (uiState as? UiState.Success)?.data?.partnerships?.filter { it.partnershipId in selectedPartnershipIds } + .orEmpty() + onRemoveFavorites(selectedPartnershipIds) + if (deletedItems.isNotEmpty()) { + lastDeletedPartnerships = deletedItems + showDeleteSnackbar = true + snackbarJob?.cancel() + snackbarJob = scope.launch { + delay(3500) + showDeleteSnackbar = false + } + } + selectedPartnershipIds = emptySet() + isEditMode = false + }, + ) + } else { + FavoriteTabs( + selectedPage = pagerState.currentPage, + onPageSelected = { page -> + scope.launch { + pagerState.animateScrollToPage( + page + ) + } + }, + ) + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + if (page == PARTNERSHIP_PAGE) { + FavoritePartnershipContent( + uiState = uiState, + onStoreTypeSelected = onStoreTypeSelected, + onPartnershipClick = onPartnershipClick, + onRemoveFavorite = { id -> + val deletedItem = + (uiState as? UiState.Success)?.data?.partnerships?.find { it.partnershipId == id } + onRemoveFavorite(id) + if (deletedItem != null) { + lastDeletedPartnerships = listOf(deletedItem) + showDeleteSnackbar = true + snackbarJob?.cancel() + snackbarJob = scope.launch { + delay(3500) + showDeleteSnackbar = false + } + } + }, + onEditClick = { + isEditMode = true + selectedPartnershipIds = emptySet() + }, + ) + } else { + Spacer(modifier = Modifier.fillMaxSize()) + } + } + } + } + + AnimatedVisibility( + visible = showDeleteSnackbar, + enter = fadeIn() + slideInVertically { it / 2 }, + exit = fadeOut() + slideOutVertically { it / 2 }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 8.dp), + ) { + EatSsuSnackbar( + message = stringResource(R.string.favorite_deleted_snackbar), + actionLabel = stringResource(R.string.favorite_undo), + onActionClick = { + snackbarJob?.cancel() + showDeleteSnackbar = false + onRestoreFavorites(lastDeletedPartnerships) + lastDeletedPartnerships = emptyList() + }, + type = EatSsuSnackbarType.Success, + ) + } + } + } +} + +@Composable +private fun FavoriteEditContent( + uiState: UiState, + selectedPartnershipIds: Set, + onToggleSelect: (Int) -> Unit, + onToggleSelectAll: (Set, Boolean) -> Unit, + onDelete: () -> Unit, +) { + when (uiState) { + UiState.Init, UiState.Loading -> DelayedLoadingIndicator(modifier = Modifier.fillMaxSize()) + UiState.Error -> FavoriteEmptyContent() + is UiState.Success -> { + val partnerships = uiState.data.filteredPartnerships + if (partnerships.isEmpty()) { + FavoriteEmptyContent() + } else { + val allIds = partnerships.map { it.partnershipId }.toSet() + val isAllSelected = + allIds.isNotEmpty() && selectedPartnershipIds.size == allIds.size + + Column(modifier = Modifier.fillMaxSize()) { + FavoriteSelectAllRow( + isAllSelected = isAllSelected, + onToggleSelectAll = { onToggleSelectAll(allIds, isAllSelected) }, + ) + + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + itemsIndexed( + items = partnerships, + key = { index, item -> "${item.partnershipId}_$index" }, + ) { _, item -> + val isSelected = item.partnershipId in selectedPartnershipIds + FavoriteEditRow( + item = item, + isSelected = isSelected, + onToggle = { onToggleSelect(item.partnershipId) }, + ) + } + } + + val count = selectedPartnershipIds.size + val isEnabled = count > 0 + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp) + .height(56.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (isEnabled) Error else Gray200) + .clickable(enabled = isEnabled, onClick = onDelete), + contentAlignment = Alignment.Center, + ) { + Text( + text = if (count > 0) { + stringResource(R.string.favorite_delete_count, count) + } else { + stringResource(R.string.favorite_delete) + }, + style = EatssuTheme.typography.button1, + color = if (isEnabled) White else Gray400, + ) + } + } + } + } + } +} + +@Composable +private fun FavoriteSelectAllRow( + isAllSelected: Boolean, + onToggleSelectAll: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggleSelectAll) + .padding(horizontal = 24.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularCheckbox( + checked = isAllSelected, + onCheckedChange = onToggleSelectAll, + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.favorite_select_all), + style = EatssuTheme.typography.h2, + color = Color.Black, + ) + } +} + +@Composable +private fun FavoriteEditRow( + item: FavoritePartnershipItem, + isSelected: Boolean, + onToggle: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(horizontal = 24.dp, vertical = 18.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularCheckbox( + checked = isSelected, + onCheckedChange = onToggle, + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = item.storeName, + style = EatssuTheme.typography.h2, + color = Color.Black, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = item.storeType.value, + style = EatssuTheme.typography.body3, + color = Gray600, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = item.description, + style = EatssuTheme.typography.body3, + color = Gray600, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Image( + painter = painterResource(R.drawable.ic_arrow_right), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } +} + +@Composable +private fun CircularCheckbox( + checked: Boolean, + modifier: Modifier = Modifier, + onCheckedChange: (() -> Unit)? = null, +) { + val clickableModifier = if (onCheckedChange != null) { + modifier.clickable(onClick = onCheckedChange) + } else { + modifier + } + + Canvas( + modifier = clickableModifier.size(24.dp), + ) { + val center = Offset(size.width / 2f, size.height / 2f) + val radius = size.minDimension / 2f - 1.dp.toPx() + + if (checked) { + drawCircle( + color = Primary, + radius = radius, + center = center, + ) + val path = Path().apply { + moveTo(size.width * 0.28f, size.height * 0.50f) + lineTo(size.width * 0.44f, size.height * 0.66f) + lineTo(size.width * 0.72f, size.height * 0.36f) + } + drawPath( + path = path, + color = White, + style = Stroke( + width = 2.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round, + ), + ) + } else { + drawCircle( + color = White, + radius = radius, + center = center, + ) + drawCircle( + color = Gray300, + radius = radius, + center = center, + style = Stroke(width = 1.5.dp.toPx()), + ) + val path = Path().apply { + moveTo(size.width * 0.28f, size.height * 0.50f) + lineTo(size.width * 0.44f, size.height * 0.66f) + lineTo(size.width * 0.72f, size.height * 0.36f) + } + drawPath( + path = path, + color = Gray300, + style = Stroke( + width = 1.5.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round, + ), + ) + } + } +} + +@Composable +private fun FavoriteTabs( + selectedPage: Int, + onPageSelected: (Int) -> Unit, +) { + Row(modifier = Modifier.fillMaxWidth()) { + listOf( + MENU_PAGE to stringResource(R.string.favorite_menu_tab), + PARTNERSHIP_PAGE to stringResource(R.string.favorite_partnership_tab), + ).forEach { (page, label) -> + Column( + modifier = Modifier + .weight(1f) + .clickable { onPageSelected(page) }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = label, + style = EatssuTheme.typography.h2, + color = if (selectedPage == page) Primary else Gray400, + modifier = Modifier.padding(vertical = 18.dp), + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(3.dp) + .background(if (selectedPage == page) Primary else Gray400), + ) + } + } + } +} + +@Composable +private fun FavoritePartnershipContent( + uiState: UiState, + onStoreTypeSelected: (StoreType?) -> Unit, + onPartnershipClick: (PartnershipRestaurant) -> Unit, + onRemoveFavorite: (Int) -> Unit, + onEditClick: () -> Unit, +) { + when (uiState) { + UiState.Init, UiState.Loading -> DelayedLoadingIndicator(modifier = Modifier.fillMaxSize()) + UiState.Error -> FavoriteEmptyContent() + is UiState.Success -> Column(modifier = Modifier.fillMaxSize()) { + FavoriteFilters( + selectedStoreType = uiState.data.selectedStoreType, + onStoreTypeSelected = onStoreTypeSelected, + onEditClick = onEditClick, + ) + + if (uiState.data.filteredPartnerships.isEmpty()) { + FavoriteEmptyContent() + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + itemsIndexed( + items = uiState.data.filteredPartnerships, + key = { index, item -> "${item.partnershipId}_$index" }, + ) { _, item -> + FavoriteSwipeToDismissItem( + item = item, + onClick = { item.detail?.let(onPartnershipClick) }, + onRemoveFavorite = onRemoveFavorite, + ) + } + } + } + } + } +} + +@Composable +private fun FavoriteFilters( + selectedStoreType: StoreType?, + onStoreTypeSelected: (StoreType?) -> Unit, + onEditClick: () -> Unit, +) { + val filters = listOf( + null to stringResource(R.string.favorite_filter_all), + StoreType.RESTAURANT to StoreType.RESTAURANT.value, + StoreType.CAFE to StoreType.CAFE.value, + StoreType.PUB to StoreType.PUB.value, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + filters.forEach { (type, label) -> + val selected = selectedStoreType == type + Text( + text = label, + style = EatssuTheme.typography.body3, + fontWeight = if (selected) FontWeight.W500 else FontWeight.W400, + color = if (selected) Primary else Gray400, + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .then( + if (selected) Modifier.background(Secondary) + else Modifier.border(1.dp, Gray300, RoundedCornerShape(24.dp)), + ) + .clickable { onStoreTypeSelected(type) } + .padding(horizontal = 18.dp, vertical = 10.dp), + ) + } + Spacer(Modifier.weight(1f)) + Text( + text = stringResource(R.string.favorite_edit), + style = EatssuTheme.typography.button2.copy(color = Gray500), + modifier = Modifier.clickable(onClick = onEditClick), + ) + } +} + +@Composable +private fun FavoriteSwipeToDismissItem( + item: FavoritePartnershipItem, + onClick: () -> Unit, + onRemoveFavorite: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + val offsetX = remember { Animatable(0f) } + val maxOffsetPx = with(LocalDensity.current) { -72.dp.toPx() } + + Box( + modifier = modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .background(Color.White), + contentAlignment = Alignment.CenterEnd, + ) { + Box( + modifier = Modifier + .width(72.dp) + .fillMaxHeight(), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(Color(0xFFE53935)) + .clickable { + scope.launch { offsetX.animateTo(0f) } + onRemoveFavorite(item.partnershipId) + }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_remove), + contentDescription = stringResource(R.string.button_delete), + tint = Color.White, + modifier = Modifier.size(22.dp), + ) + } + } + + Box( + modifier = Modifier + .offset { IntOffset(offsetX.value.roundToInt(), 0) } + .fillMaxWidth() + .background(Color.White) + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragEnd = { + scope.launch { + if (offsetX.value < maxOffsetPx / 2) { + offsetX.animateTo(maxOffsetPx) + } else { + offsetX.animateTo(0f) + } + } + }, + onHorizontalDrag = { change, dragAmount -> + change.consume() + scope.launch { + val newOffset = + (offsetX.value + dragAmount).coerceIn(maxOffsetPx, 0f) + offsetX.snapTo(newOffset) + } + }, + ) + }, + ) { + FavoritePartnershipRow( + item = item, + onClick = { + if (offsetX.value < 0f) { + scope.launch { offsetX.animateTo(0f) } + } else { + onClick() + } + }, + ) + } + } +} + +@Composable +private fun FavoritePartnershipRow( + item: FavoritePartnershipItem, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color.White) + .clickable(onClick = onClick) + .padding(horizontal = 24.dp, vertical = 18.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(46.dp) + .background(Primary, CircleShape), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(item.storeType.iconRes), + contentDescription = null, + modifier = Modifier.size(28.dp), + ) + } + + Spacer(modifier = Modifier.width(14.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = item.storeName, + style = EatssuTheme.typography.h2, + color = Color.Black, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = item.storeType.value, + style = EatssuTheme.typography.body3, + color = Gray600, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = item.description, + style = EatssuTheme.typography.body3, + color = Gray600, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Image( + painter = painterResource(R.drawable.ic_arrow_right), + contentDescription = stringResource(R.string.favorite_open_detail), + modifier = Modifier.size(24.dp), + ) + } +} + +@Composable +private fun FavoriteEmptyContent() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.favorite_empty_title), + style = EatssuTheme.typography.h1, + color = Gray600, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.favorite_empty_description), + style = EatssuTheme.typography.body2, + color = Gray600, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun FavoriteScreenPreview() { + EatssuTheme { + FavoriteScreen( + uiState = UiState.Success( + FavoriteState( + partnerships = listOf( + FavoritePartnershipItem( + 1, + "식당 이름", + StoreType.RESTAURANT, + "학생증 인증하면 음료수 1개 증정" + ), + FavoritePartnershipItem(2, "카페 이름", StoreType.CAFE, "학생증 인증하면 음료수 1개 증정"), + ), + ), + ), + onBackToMap = {}, + onStoreTypeSelected = {}, + onPartnershipClick = {}, + ) + } +} diff --git a/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteViewModel.kt new file mode 100644 index 000000000..f3d05728d --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/favorite/FavoriteViewModel.kt @@ -0,0 +1,159 @@ +package com.eatssu.android.presentation.favorite + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.eatssu.android.data.local.FavoritePartnershipDataStore +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.domain.model.Partnership +import com.eatssu.android.domain.model.PartnershipRestaurant +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.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class FavoritePartnershipItem( + val partnershipId: Int, + val storeName: String, + val storeType: StoreType, + val description: String, + val detail: PartnershipRestaurant? = null, +) + +data class FavoriteState( + val partnerships: List = emptyList(), + val selectedStoreType: StoreType? = null, +) { + val filteredPartnerships: List + get() = selectedStoreType?.let { type -> + partnerships.filter { it.storeType == type } + } ?: partnerships +} + +@HiltViewModel +class FavoriteViewModel @Inject constructor( + private val partnershipRepository: PartnershipRepository, + private val favoritePartnershipDataStore: FavoritePartnershipDataStore, +) : ViewModel() { + + private val _uiState = MutableStateFlow>(UiState.Init) + val uiState: StateFlow> = _uiState.asStateFlow() + + fun loadFavorites() { + viewModelScope.launch { + val selectedStoreType = (_uiState.value as? UiState.Success)?.data?.selectedStoreType + _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, + ), + ) + } + } + + fun selectStoreType(storeType: StoreType?) { + val current = _uiState.value as? UiState.Success ?: return + _uiState.value = UiState.Success(current.data.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)) + } + } + } + + fun removeFavorites(partnershipIds: Set) { + val current = (_uiState.value as? UiState.Success)?.data ?: return + if (partnershipIds.isEmpty()) return + + viewModelScope.launch { + val jobs = partnershipIds.map { id -> + async { + val result = partnershipRepository.likePartnership(id, wasLiked = true) + if (result is ApiResult.Success) id else null + } + } + 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)) + } + } + } + + 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) + 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)) + } + } + } +} + +private fun Partnership.toFavoriteItemOrNull(): FavoritePartnershipItem? { + val representative = partnershipInfos.firstOrNull { it.isLiked } + ?: partnershipInfos.firstOrNull() + ?: return null + + return FavoritePartnershipItem( + partnershipId = representative.id, + storeName = storeName, + storeType = restaurantType, + description = representative.description, + detail = PartnershipRestaurant( + id = representative.id, + partnershipType = representative.partnershipType, + storeName = storeName, + description = representative.description, + startDate = representative.startDate, + endDate = representative.endDate, + storeType = restaurantType, + longitude = longitude, + latitude = latitude, + collegeName = representative.collegeName, + departmentName = representative.departmentName, + partnershipLikeCount = representative.likeCount, + likedByUser = representative.isLiked, + naverMapUrl = naverMapUrl, + kakaoMapUrl = kakaoMapUrl, + ), + ) +} diff --git a/app/src/main/java/com/eatssu/android/presentation/map/MapFragment.kt b/app/src/main/java/com/eatssu/android/presentation/map/MapFragment.kt index e76e68dd4..598c97fa4 100644 --- a/app/src/main/java/com/eatssu/android/presentation/map/MapFragment.kt +++ b/app/src/main/java/com/eatssu/android/presentation/map/MapFragment.kt @@ -7,6 +7,7 @@ import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment import com.eatssu.android.analytics.ProvideAnalyticsTracker +import com.eatssu.android.presentation.MainActivity import com.eatssu.common.analytics.AnalyticsTracker import com.eatssu.design_system.theme.EatssuTheme import dagger.hilt.android.AndroidEntryPoint @@ -35,7 +36,12 @@ class MapFragment : Fragment() { setContent { ProvideAnalyticsTracker(analyticsTracker) { EatssuTheme { - MapRoute(mapExternalNavigator = mapExternalNavigator) + MapRoute( + mapExternalNavigator = mapExternalNavigator, + onFavoriteClick = { + (requireActivity() as MainActivity).openFavoriteTab() + }, + ) } } } diff --git a/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt b/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt index b44376a1a..0994d5b08 100644 --- a/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt +++ b/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt @@ -27,6 +27,7 @@ import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.SheetState +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberModalBottomSheetState @@ -101,6 +102,7 @@ fun MapRoute( viewModel: MapViewModel = viewModel(), mainViewModel: MainViewModel = viewModel(), mapExternalNavigator: MapExternalNavigator? = null, + onFavoriteClick: () -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -287,6 +289,7 @@ fun MapRoute( collegeId = collegeId, departmentName = departmentName, selectedFilter = mapState.selectedFilter, + onFavoriteClick = onFavoriteClick, ) } @@ -309,6 +312,7 @@ internal fun MapScreen( collegeId: Long, departmentName: String?, selectedFilter: FilterType, + onFavoriteClick: () -> Unit, ) { val analyticsTracker = LocalAnalyticsTracker.current @@ -362,6 +366,7 @@ internal fun MapScreen( MapRestaurantBottomSheet( storeName = info.storeName, storeType = storeType, + isLike = info.likedByUser, mapRestaurantList = mapState.restaurantInfoList, onNaverMapClick = { openMap(MapProvider.NAVER, info) @@ -371,6 +376,9 @@ internal fun MapScreen( }, onDismiss = { onHidePartnershipSheet() + }, + onLikeClick = { + viewModel.likePartnership(info.id) } ) } @@ -497,6 +505,28 @@ internal fun MapScreen( departmentName = departmentName.toString(), filters = mapState.availableFilters, ) + + Surface( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding( + end = 18.dp, + bottom = dimensionResource(R.dimen.bottom_nav_height) + 18.dp, + ) + .size(48.dp), + shape = CircleShape, + color = Color.White, + shadowElevation = 4.dp, + onClick = onFavoriteClick, + ) { + Box(contentAlignment = Alignment.Center) { + Image( + painter = painterResource(R.drawable.ic_like_selected), + contentDescription = stringResource(R.string.favorite_open), + modifier = Modifier.size(24.dp), + ) + } + } } } } diff --git a/app/src/main/java/com/eatssu/android/presentation/map/MapViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/map/MapViewModel.kt index be68e25d1..fdde27143 100644 --- a/app/src/main/java/com/eatssu/android/presentation/map/MapViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/map/MapViewModel.kt @@ -3,6 +3,7 @@ package com.eatssu.android.presentation.map import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.eatssu.android.R +import com.eatssu.android.data.model.ApiResult import com.eatssu.android.domain.model.Partnership import com.eatssu.android.domain.model.PartnershipRestaurant import com.eatssu.android.domain.repository.PartnershipRepository @@ -286,8 +287,62 @@ class MapViewModel @Inject constructor( ), ) } + + fun likePartnership(id: Int) { + viewModelScope.launch { + val current = _uiState.value as? UiState.Success ?: return@launch + val wasLiked = current.data.partnershipLikeStatus(id) ?: return@launch + val result = partnershipRepository.likePartnership(id, wasLiked) + if (result !is ApiResult.Success) return@launch + + val latest = _uiState.value as? UiState.Success ?: return@launch + + // 서버 반영에 성공하면 기존 목록과 현재 열린 상세의 좋아요 상태를 함께 갱신한다. + _uiState.value = UiState.Success(latest.data.togglePartnershipLike(id)) + } + } } +private fun MapState.partnershipLikeStatus(id: Int): Boolean? = + restaurantPartnershipInfo + ?.takeIf { it.id == id } + ?.likedByUser + ?: partnerships.asSequence() + .flatMap { it.partnershipInfos.asSequence() } + .firstOrNull { it.id == id } + ?.isLiked + +private fun MapState.togglePartnershipLike(id: Int): MapState = + copy( + partnerships = partnerships.map { partnership -> + partnership.copy( + partnershipInfos = partnership.partnershipInfos.map { info -> + if (info.id == id) { + info.copy( + isLiked = !info.isLiked, + likeCount = info.likeCount.updatedLikeCount(info.isLiked), + ) + } else { + info + } + }, + ) + }, + restaurantPartnershipInfo = restaurantPartnershipInfo?.let { info -> + if (info.id == id) { + info.copy( + likedByUser = !info.likedByUser, + partnershipLikeCount = info.partnershipLikeCount.updatedLikeCount(info.likedByUser), + ) + } else { + info + } + }, + ) + +private fun Int.updatedLikeCount(wasLiked: Boolean): Int = + (this + if (wasLiked) -1 else 1).coerceAtLeast(0) + private fun List.hasFestivalPartnership(): Boolean = any { partnership -> partnership.partnershipInfos.any { info -> info.periodType == PeriodType.FESTIVAL } diff --git a/app/src/main/java/com/eatssu/android/presentation/map/component/MapRestaurantBottomSheet.kt b/app/src/main/java/com/eatssu/android/presentation/map/component/MapRestaurantBottomSheet.kt index 1db64fc86..37b66854e 100644 --- a/app/src/main/java/com/eatssu/android/presentation/map/component/MapRestaurantBottomSheet.kt +++ b/app/src/main/java/com/eatssu/android/presentation/map/component/MapRestaurantBottomSheet.kt @@ -1,14 +1,18 @@ package com.eatssu.android.presentation.map.component +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -18,6 +22,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface @@ -25,10 +30,17 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color.Companion.White +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle @@ -42,24 +54,36 @@ import com.eatssu.android.presentation.map.model.RestaurantInfo import com.eatssu.android.presentation.util.TrackScreenViewEvent import com.eatssu.common.enums.ScreenId import com.eatssu.common.enums.StoreType +import com.eatssu.design_system.component.EatSsuSnackbar +import com.eatssu.design_system.component.EatSsuSnackbarType import com.eatssu.design_system.theme.EatssuTheme 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.Primary +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun MapRestaurantBottomSheet( storeName: String, storeType: StoreType, + isLike: Boolean, mapRestaurantList: List, onNaverMapClick: () -> Unit = {}, onKakaoMapClick: () -> Unit = {}, onDismiss: () -> Unit = {}, + onLikeClick: () -> Unit = {}, + scrimColor: Color = BottomSheetDefaults.ScrimColor, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val context = LocalContext.current + val scope = rememberCoroutineScope() + var snackbarState by remember { mutableStateOf?>(null) } + var snackbarJob by remember { mutableStateOf(null) } LaunchedEffect(Unit) { sheetState.show() @@ -71,14 +95,16 @@ fun MapRestaurantBottomSheet( onDismissRequest = onDismiss, containerColor = White, sheetState = sheetState, - dragHandle = null + dragHandle = null, + scrimColor = scrimColor, ) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(White) - .padding(bottom = 40.dp) - ) { + Box(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(White) + .padding(bottom = 40.dp) + ) { // 상단 회색 바 Spacer(modifier = Modifier.height(14.dp)) @@ -137,22 +163,33 @@ fun MapRestaurantBottomSheet( } // 찜 기능 -// Row( -// verticalAlignment = Alignment.CenterVertically, -// modifier = Modifier.padding(bottom = 24.dp) -// ) { -// Image( -// painter = painterResource(id = R.drawable.ic_like), -// contentDescription = "좋아요", -// modifier = Modifier.size(18.dp) -// ) -// Spacer(modifier = Modifier.width(4.dp)) -// Text( -// text = "0", -// style = EatssuTheme.typography.caption2, -// color = Gray600 -// ) -// } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 24.dp) + ) { + Image( + painter = painterResource(id = if (isLike) R.drawable.ic_like_filled else R.drawable.ic_like_line), + contentDescription = "좋아요", + modifier = Modifier + .size(24.dp) + .clickable { + val wasLiked = isLike + onLikeClick() + snackbarJob?.cancel() + snackbarJob = scope.launch { + snackbarState = if (!wasLiked) { + context.getString(R.string.favorite_added_snackbar) to null + } else { + context.getString(R.string.favorite_deleted_snackbar) to context.getString( + R.string.favorite_undo + ) + } + delay(3000) + snackbarState = null + } + } + ) + } } Spacer(modifier = Modifier.height(20.dp)) @@ -251,9 +288,35 @@ fun MapRestaurantBottomSheet( modifier = Modifier.weight(1f), ) } + } + + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 104.dp), + ) { + androidx.compose.animation.AnimatedVisibility( + visible = snackbarState != null, + enter = fadeIn() + slideInVertically { it / 2 }, + exit = fadeOut() + slideOutVertically { it / 2 }, + ) { + snackbarState?.let { (message, actionLabel) -> + EatSsuSnackbar( + message = message, + actionLabel = actionLabel, + onActionClick = { + snackbarJob?.cancel() + snackbarState = null + onLikeClick() + }, + type = EatSsuSnackbarType.Success, + ) + } + } } } } +} @Composable private fun MapLinkButton( @@ -301,6 +364,7 @@ fun MapRestaurantBottomSheetPreview() { storeName = "현선이네", mapRestaurantList = dummyList, storeType = StoreType.RESTAURANT, + isLike = false ) } } diff --git a/app/src/main/res/drawable/ic_like_default.xml b/app/src/main/res/drawable/ic_like_default.xml new file mode 100644 index 000000000..16bcff9b6 --- /dev/null +++ b/app/src/main/res/drawable/ic_like_default.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_like_filled.xml b/app/src/main/res/drawable/ic_like_filled.xml new file mode 100644 index 000000000..38fe44c82 --- /dev/null +++ b/app/src/main/res/drawable/ic_like_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_like_line.xml b/app/src/main/res/drawable/ic_like_line.xml new file mode 100644 index 000000000..8666f10de --- /dev/null +++ b/app/src/main/res/drawable/ic_like_line.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_like_selected.xml b/app/src/main/res/drawable/ic_like_selected.xml new file mode 100644 index 000000000..9ac10a580 --- /dev/null +++ b/app/src/main/res/drawable/ic_like_selected.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/selector_ic_like.xml b/app/src/main/res/drawable/selector_ic_like.xml new file mode 100644 index 000000000..2071cd57f --- /dev/null +++ b/app/src/main/res/drawable/selector_ic_like.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/menu/menu_bottom_navigation.xml b/app/src/main/res/menu/menu_bottom_navigation.xml index db27a0a65..6000e8239 100644 --- a/app/src/main/res/menu/menu_bottom_navigation.xml +++ b/app/src/main/res/menu/menu_bottom_navigation.xml @@ -12,11 +12,17 @@ android:icon="@drawable/selector_ic_map" android:title="@string/nav_map"/> + + + + + + + android:icon="@drawable/selector_ic_like" + android:title="@string/favorite" /> + + + + + + - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cb2769533..61ee940ec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -311,7 +311,23 @@ 식당 위치 비고 영업 시간 + 찜한 제휴 + + 메뉴 찜 + 제휴 찜 + 전체 + 아직 찜한 제휴가 없어요. + 관심 있는 제휴를 찜하고 빠르게 확인해 보세요! + 제휴 찜 열기 + 제휴 상세보기 + 편집 + 전체 선택 + 삭제 + 삭제 %d + 찜한 제휴에 추가했어요. + 찜한 제휴에서 삭제했어요. + 취소하기 내 제휴 축제 전체 diff --git a/app/src/test/java/com/eatssu/android/data/local/FavoritePartnershipOrderBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/local/FavoritePartnershipOrderBehaviorSpec.kt new file mode 100644 index 000000000..be1461940 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/data/local/FavoritePartnershipOrderBehaviorSpec.kt @@ -0,0 +1,36 @@ +package com.eatssu.android.data.local + +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.shouldBe + +class FavoritePartnershipOrderBehaviorSpec : AppBehaviorSpec({ + + given("기기에 기록한 제휴 찜 순서") { + `when`("서버 목록과 조정하면") { + then("서버에 남은 로컬 순서를 우선하고 새 서버 항목을 뒤에 붙인다") { + reconcileFavoriteOrder( + current = listOf(3, 1, 9), + serverIds = listOf(1, 2, 3), + ) shouldBe listOf(3, 1, 2) + } + } + + `when`("로컬 기록이 없으면") { + then("서버 응답 순서를 그대로 사용한다") { + reconcileFavoriteOrder( + current = emptyList(), + serverIds = listOf(4, 2, 1), + ) shouldBe listOf(4, 2, 1) + } + } + + `when`("기기에 같은 제휴 ID가 중복 저장되어 있으면") { + then("중복을 제거해 화면에는 한 번만 노출한다") { + reconcileFavoriteOrder( + current = listOf(255, 255, 1), + serverIds = listOf(255, 1), + ) shouldBe listOf(255, 1) + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/PartnershipRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/PartnershipRepositoryImplBehaviorSpec.kt index c72a0bc44..4f645e82a 100644 --- a/app/src/test/java/com/eatssu/android/data/remote/repository/PartnershipRepositoryImplBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/data/remote/repository/PartnershipRepositoryImplBehaviorSpec.kt @@ -1,5 +1,6 @@ package com.eatssu.android.data.remote.repository +import com.eatssu.android.data.local.FavoritePartnershipDataStore import com.eatssu.android.data.model.ApiResult import com.eatssu.android.data.remote.dto.response.PartnershipResponse import com.eatssu.android.data.remote.dto.response.PartnershipRestaurantResponse @@ -9,6 +10,7 @@ import com.eatssu.android.test.AppBehaviorSpec import com.eatssu.common.enums.StoreType import io.kotest.matchers.shouldBe import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -19,7 +21,12 @@ class PartnershipRepositoryImplBehaviorSpec : AppBehaviorSpec({ given("PartnershipRepositoryImpl") { val partnershipService = mockk() val userService = mockk() - val repository = PartnershipRepositoryImpl(partnershipService, userService) + val favoritePartnershipDataStore = mockk(relaxed = true) + val repository = PartnershipRepositoryImpl( + partnershipService, + userService, + favoritePartnershipDataStore, + ) `when`("전체 제휴 조회 API가 성공하면") { val response = listOf( @@ -133,5 +140,46 @@ class PartnershipRepositoryImplBehaviorSpec : AppBehaviorSpec({ } } } + + `when`("유저 찜 제휴 조회 API가 성공하면") { + val response = listOf( + PartnershipResponse( + storeName = "Favorite Cafe", + restaurantType = StoreType.CAFE, + partnershipInfos = emptyList(), + ) + ) + coEvery { userService.getUserFavoritePartnerships() } returns ApiResult.Success(response) + + then("도메인 제휴 목록으로 변환한다") { + runTest { + repository.getUserFavoritePartnerships() + .first().storeName shouldBe "Favorite Cafe" + } + } + } + + `when`("새 제휴 찜 요청이 성공하면") { + coEvery { partnershipService.likePartnership(7) } returns ApiResult.Success(Unit) + + then("최근 찜 순서의 맨 앞으로 기록한다") { + runTest { + repository.likePartnership(7, wasLiked = false) + coVerify(exactly = 1) { favoritePartnershipDataStore.markLiked(7) } + coVerify(exactly = 0) { favoritePartnershipDataStore.markUnliked(any()) } + } + } + } + + `when`("제휴 찜 취소 요청이 성공하면") { + coEvery { partnershipService.likePartnership(7) } returns ApiResult.Success(Unit) + + then("최근 찜 순서에서 제거한다") { + runTest { + repository.likePartnership(7, wasLiked = true) + coVerify(exactly = 1) { favoritePartnershipDataStore.markUnliked(7) } + } + } + } } }) diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/auth/AuthDelegatingUseCasesBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/auth/AuthDelegatingUseCasesBehaviorSpec.kt index db0dd2209..36965a02a 100644 --- a/app/src/test/java/com/eatssu/android/domain/usecase/auth/AuthDelegatingUseCasesBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/domain/usecase/auth/AuthDelegatingUseCasesBehaviorSpec.kt @@ -1,6 +1,7 @@ package com.eatssu.android.domain.usecase.auth import com.eatssu.android.data.local.AccountDataStore +import com.eatssu.android.data.local.FavoritePartnershipDataStore import com.eatssu.android.data.local.SettingDataStore import com.eatssu.android.data.local.TokenStore import com.eatssu.android.domain.model.ReissueTokenResult @@ -107,12 +108,20 @@ class AuthDelegatingUseCasesBehaviorSpec : AppBehaviorSpec({ val accountDataStore = mockk() val tokenStore = mockk() val settingDataStore = mockk() + val favoritePartnershipDataStore = mockk() val analyticsTracker = mockk(relaxed = true) - val useCase = LogoutUseCase(accountDataStore, tokenStore, settingDataStore, analyticsTracker) + val useCase = LogoutUseCase( + accountDataStore, + tokenStore, + settingDataStore, + favoritePartnershipDataStore, + analyticsTracker, + ) coJustRun { accountDataStore.clear() } every { tokenStore.clear() } just Runs coJustRun { settingDataStore.clear() } + coJustRun { favoritePartnershipDataStore.clear() } `when`("invoke를 호출하면") { then("로컬 저장소를 순서대로 clear한다") { @@ -122,6 +131,7 @@ class AuthDelegatingUseCasesBehaviorSpec : AppBehaviorSpec({ accountDataStore.clear() tokenStore.clear() settingDataStore.clear() + favoritePartnershipDataStore.clear() } io.mockk.verify(exactly = 1) { analyticsTracker.resetIdentity() } } diff --git a/app/src/test/java/com/eatssu/android/presentation/favorite/FavoriteDetailViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/favorite/FavoriteDetailViewModelBehaviorSpec.kt new file mode 100644 index 000000000..6d07e7f3b --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/favorite/FavoriteDetailViewModelBehaviorSpec.kt @@ -0,0 +1,34 @@ +package com.eatssu.android.presentation.favorite + +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.domain.repository.PartnershipRepository +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.samplePartnershipRestaurant +import com.eatssu.common.UiState +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk + +class FavoriteDetailViewModelBehaviorSpec : AppBehaviorSpec({ + + given("제휴 찜 상세 화면") { + val repository = mockk() + val restaurant = samplePartnershipRestaurant(id = 7) + coEvery { repository.getPartnershipById(7) } returns restaurant + coEvery { repository.likePartnership(7, true) } returns ApiResult.Success(Unit) + val viewModel = FavoriteDetailViewModel(repository) + + `when`("상세에서 찜을 취소하면") { + viewModel.loadPartnership(7) + viewModel.toggleLike() + + then("서버 토글 후 상세 하트를 비운다") { + val state = (viewModel.uiState.value as UiState.Success).data + state.likedByUser shouldBe false + state.partnershipLikeCount shouldBe 2 + coVerify(exactly = 1) { repository.likePartnership(7, true) } + } + } + } +}) 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 new file mode 100644 index 000000000..3b4bcb615 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/favorite/FavoriteViewModelBehaviorSpec.kt @@ -0,0 +1,66 @@ +package com.eatssu.android.presentation.favorite + +import com.eatssu.android.data.local.FavoritePartnershipDataStore +import com.eatssu.android.domain.repository.PartnershipRepository +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.samplePartnership +import com.eatssu.common.UiState +import com.eatssu.common.enums.StoreType +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.mockk + +class FavoriteViewModelBehaviorSpec : AppBehaviorSpec({ + + given("제휴 찜 화면") { + val repository = mockk() + val dataStore = mockk() + val restaurant = samplePartnership(storeName = "식당", type = StoreType.RESTAURANT) + val cafe = samplePartnership( + storeName = "카페", + infos = restaurant.partnershipInfos.map { it.copy(id = 2) }, + type = StoreType.CAFE, + ) + + coEvery { repository.getUserFavoritePartnerships() } returns listOf(restaurant, cafe) + coEvery { dataStore.reconcile(listOf(1, 2)) } returns listOf(2, 1) + + val viewModel = FavoriteViewModel(repository, dataStore) + + `when`("찜 목록을 불러오면") { + viewModel.loadFavorites() + + then("기기에 기록한 최근순으로 화면 상태를 만든다") { + val state = (viewModel.uiState.value as UiState.Success).data + state.partnerships.map { it.partnershipId } shouldBe listOf(2, 1) + state.partnerships.map { it.storeName } shouldBe listOf("카페", "식당") + } + } + + `when`("음식점 필터를 선택하면") { + viewModel.loadFavorites() + viewModel.selectStoreType(StoreType.RESTAURANT) + + then("음식점만 노출한다") { + val state = (viewModel.uiState.value as UiState.Success).data + state.filteredPartnerships.map { it.storeName } shouldBe listOf("식당") + } + } + + `when`("찜 해제를 요청하면") { + coEvery { + repository.likePartnership( + 1, + wasLiked = true + ) + } returns com.eatssu.android.data.model.ApiResult.Success(Unit) + viewModel.loadFavorites() + viewModel.removeFavorite(1) + + then("해당 항목을 목록에서 제거한다") { + val state = (viewModel.uiState.value as UiState.Success).data + state.partnerships.map { it.partnershipId } shouldBe listOf(2) + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt index 7110b153d..6ba0d4075 100644 --- a/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt @@ -1,5 +1,6 @@ package com.eatssu.android.presentation.map +import com.eatssu.android.data.model.ApiResult import com.eatssu.android.domain.model.College import com.eatssu.android.domain.model.Department import com.eatssu.android.domain.model.Partnership @@ -536,5 +537,114 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({ } } } + + `when`("제휴 좋아요 요청이 성공하면") { + val partnership = samplePartnership( + storeName = "Cafe Like", + infos = listOf( + Partnership.PartnershipInfo( + id = 7, + partnershipType = "DISCOUNT", + collegeName = "IT", + departmentName = "CS", + likeCount = 3, + isLiked = false, + description = "할인", + startDate = "2025-01-01", + endDate = "2025-12-31", + periodType = PeriodType.NORMAL, + ) + ), + ) + val representative = samplePartnershipRestaurant(id = 7).copy( + likedByUser = false, + partnershipLikeCount = 3, + ) + + coEvery { getUserCollegeDepartmentUseCase() } returns sampleUserInfo( + nickname = "eatssu", + college = College(collegeId = 1, collegeName = "IT"), + department = Department(departmentId = 11, departmentName = "CS"), + ) + coEvery { partnershipRepository.getAllPartnerships() } returns emptyList() + coEvery { partnershipRepository.getUserCollegePartnerships() } returns listOf( + partnership + ) + coEvery { partnershipRepository.likePartnership(7, false) } returns ApiResult.Success( + Unit + ) + every { + getPartnershipDetailUseCase(listOf(partnership), "Cafe Like", 7) + } returns representative + + val viewModel = MapViewModel( + partnershipRepository = partnershipRepository, + getPartnershipDetailUseCase = getPartnershipDetailUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + analyticsTracker = analyticsTracker, + ) + + then("목록과 현재 열린 상세의 좋아요 상태를 함께 갱신한다") { + runTest { + eventually(2.seconds) { + (viewModel.uiState.value as UiState.Success).data.partnerships shouldBe listOf( + partnership + ) + } + viewModel.selectPartnershipByStoreName("Cafe Like", 7) + viewModel.likePartnership(7) + + eventually(2.seconds) { + val state = (viewModel.uiState.value as UiState.Success).data + state.partnerships.first().partnershipInfos.first().isLiked shouldBe true + state.partnerships.first().partnershipInfos.first().likeCount shouldBe 4 + state.restaurantPartnershipInfo?.likedByUser shouldBe true + state.restaurantPartnershipInfo?.partnershipLikeCount shouldBe 4 + } + coVerify(exactly = 1) { partnershipRepository.likePartnership(7, false) } + } + } + } + + `when`("제휴 좋아요 요청이 실패하면") { + val partnership = samplePartnership(storeName = "Cafe Failed") + + coEvery { getUserCollegeDepartmentUseCase() } returns sampleUserInfo( + nickname = "eatssu", + college = College(collegeId = 1, collegeName = "IT"), + department = Department(departmentId = 11, departmentName = "CS"), + ) + coEvery { partnershipRepository.getAllPartnerships() } returns emptyList() + coEvery { partnershipRepository.getUserCollegePartnerships() } returns listOf( + partnership + ) + coEvery { + partnershipRepository.likePartnership(1, true) + } returns ApiResult.Failure(500, "error") + + val viewModel = MapViewModel( + partnershipRepository = partnershipRepository, + getPartnershipDetailUseCase = getPartnershipDetailUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + analyticsTracker = analyticsTracker, + ) + + then("기존 로컬 상태를 유지한다") { + runTest { + eventually(2.seconds) { + (viewModel.uiState.value as UiState.Success).data.partnerships shouldBe listOf( + partnership + ) + } + viewModel.likePartnership(1) + + eventually(2.seconds) { + (viewModel.uiState.value as UiState.Success).data.partnerships shouldBe listOf( + partnership + ) + } + } + } + } } })