diff --git a/core/database/src/main/java/com/chan/database/datastore/LikeDataStoreManager.kt b/core/database/src/main/java/com/chan/database/datastore/LikeDataStoreManager.kt new file mode 100644 index 00000000..f2856a1c --- /dev/null +++ b/core/database/src/main/java/com/chan/database/datastore/LikeDataStoreManager.kt @@ -0,0 +1,38 @@ +package com.chan.database.datastore + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import com.chan.like.proto.Like +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import java.util.concurrent.ConcurrentHashMap + +object LikeDataStoreManager { + + private val storeCache = ConcurrentHashMap>() + private val scopeCache = ConcurrentHashMap() + + fun getDataStore(context: Context, userId: String): DataStore { + + return storeCache.getOrPut(userId) { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + scopeCache[userId] = scope + + DataStoreFactory.create( + serializer = LikeSerializer, + produceFile = { context.dataStoreFile("like_$userId.pb") }, + scope = scope + ) + } + } + + fun clearAll() { + scopeCache.values.forEach { it.cancel() } + scopeCache.clear() + storeCache.clear() + } +} \ No newline at end of file diff --git a/core/database/src/main/java/com/chan/database/datastore/LikeSerializer.kt b/core/database/src/main/java/com/chan/database/datastore/LikeSerializer.kt new file mode 100644 index 00000000..3492a632 --- /dev/null +++ b/core/database/src/main/java/com/chan/database/datastore/LikeSerializer.kt @@ -0,0 +1,21 @@ +package com.chan.database.datastore + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.chan.like.proto.Like +import java.io.InputStream +import java.io.OutputStream + +object LikeSerializer : Serializer { + override val defaultValue: Like = Like.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): Like { + try { + return Like.parseFrom(input) + } catch (exception: com.google.protobuf.InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto.", exception) + } + } + + override suspend fun writeTo(t: Like, output: OutputStream) = t.writeTo(output) +} \ No newline at end of file diff --git a/core/database/src/main/java/com/chan/database/di/RepositoryModule.kt b/core/database/src/main/java/com/chan/database/di/RepositoryModule.kt new file mode 100644 index 00000000..f3ff58c8 --- /dev/null +++ b/core/database/src/main/java/com/chan/database/di/RepositoryModule.kt @@ -0,0 +1,18 @@ +package com.chan.database.di + +import com.chan.database.repository.LikeRepositoryImpl +import com.chan.domain.repository.LikeRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + + @Binds + abstract fun bindLikeRepository( + likeRepositoryImpl: LikeRepositoryImpl + ) : LikeRepository +} \ No newline at end of file diff --git a/core/database/src/main/java/com/chan/database/mappers/EntityToDomainMappers.kt b/core/database/src/main/java/com/chan/database/mappers/EntityToDomainMappers.kt new file mode 100644 index 00000000..db7c69bc --- /dev/null +++ b/core/database/src/main/java/com/chan/database/mappers/EntityToDomainMappers.kt @@ -0,0 +1,20 @@ +package com.chan.database.mappers + +import com.chan.database.entity.CommonProductEntity +import com.chan.domain.ProductsVO + +fun CommonProductEntity.toProductsVO(): ProductsVO { + return ProductsVO( + productId = productId, + productName = productName, + brandName = brandName, + imageUrl = imageUrl, + originalPrice = originalPrice, + discountPercent = discountPercent, + discountPrice = discountPrice, + tags = tags, + reviewRating = reviewRating, + reviewCount = reviewCount, + categoryIds = categoryIds, + ) +} \ No newline at end of file diff --git a/core/database/src/main/java/com/chan/database/repository/LikeRepositoryImpl.kt b/core/database/src/main/java/com/chan/database/repository/LikeRepositoryImpl.kt new file mode 100644 index 00000000..bf2fa050 --- /dev/null +++ b/core/database/src/main/java/com/chan/database/repository/LikeRepositoryImpl.kt @@ -0,0 +1,67 @@ +package com.chan.database.repository + +import android.content.Context +import androidx.datastore.core.DataStore +import com.chan.auth.domain.usecase.GetCurrentUserIdUseCase +import com.chan.database.dao.ProductsDao +import com.chan.database.datastore.LikeDataStoreManager +import com.chan.database.mappers.toProductsVO +import com.chan.domain.ProductsVO +import com.chan.domain.repository.LikeRepository +import com.chan.like.proto.Like +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import javax.inject.Inject +import kotlin.collections.toMutableSet +import kotlin.collections.toSet + +class LikeRepositoryImpl @Inject constructor( + @ApplicationContext private val context: Context, + private val getCurrentUserIdUseCase: GetCurrentUserIdUseCase, + private val productsDao: ProductsDao +) : LikeRepository { + + private fun getLikeStore(): DataStore { + val userId = getCurrentUserIdUseCase() ?: "guest" + return LikeDataStoreManager.getDataStore(context, userId) + } + + + override fun getLikedProductIds(): Flow> { + return getLikeStore().data + .map { it.productIdsList.toSet() } + .distinctUntilChanged() + } + + override suspend fun toggleLike(productId: String) { + getLikeStore().updateData { current -> + val currentLikedSet = current.productIdsList.toMutableSet() + + if (currentLikedSet.contains(productId)) + currentLikedSet.remove(productId) + else + currentLikedSet.add(productId) + + current.toBuilder().clearProductIds().addAllProductIds(currentLikedSet).build() + } + } + + override fun getLikedProducts(): Flow> { + return getLikeStore().data.mapLatest { likeProto -> + val likedIds = likeProto.productIdsList.toSet() + if (likedIds.isEmpty()) + return@mapLatest emptyList() + + val allProducts = productsDao.getAllProducts() + + allProducts + .filter { it.productId in likedIds } + .map { it.toProductsVO() } + + } + } + +} \ No newline at end of file diff --git a/core/database/src/main/proto/like.proto b/core/database/src/main/proto/like.proto new file mode 100644 index 00000000..dcb24ac6 --- /dev/null +++ b/core/database/src/main/proto/like.proto @@ -0,0 +1,8 @@ +syntax = "proto3"; + +option java_package = "com.chan.like.proto"; +option java_multiple_files = true; + +message Like { + repeated string product_ids = 1; +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/chan/domain/repository/LikeRepository.kt b/core/domain/src/main/java/com/chan/domain/repository/LikeRepository.kt new file mode 100644 index 00000000..11c0c9a4 --- /dev/null +++ b/core/domain/src/main/java/com/chan/domain/repository/LikeRepository.kt @@ -0,0 +1,11 @@ +package com.chan.domain.repository + +import com.chan.domain.ProductsVO +import kotlinx.coroutines.flow.Flow + + +interface LikeRepository { + fun getLikedProductIds() : Flow> + suspend fun toggleLike(productId: String) + fun getLikedProducts() : Flow> +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/chan/domain/usecase/GetLikedProductIdsUseCase.kt b/core/domain/src/main/java/com/chan/domain/usecase/GetLikedProductIdsUseCase.kt new file mode 100644 index 00000000..a7aa5cd5 --- /dev/null +++ b/core/domain/src/main/java/com/chan/domain/usecase/GetLikedProductIdsUseCase.kt @@ -0,0 +1,13 @@ +package com.chan.domain.usecase + +import com.chan.domain.repository.LikeRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetLikedProductIdsUseCase @Inject constructor( + private val likeRepository: LikeRepository, +) { + operator fun invoke() : Flow> { + return likeRepository.getLikedProductIds() + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/chan/domain/usecase/GetLikedProductsUseCase.kt b/core/domain/src/main/java/com/chan/domain/usecase/GetLikedProductsUseCase.kt new file mode 100644 index 00000000..400a8e5b --- /dev/null +++ b/core/domain/src/main/java/com/chan/domain/usecase/GetLikedProductsUseCase.kt @@ -0,0 +1,14 @@ +package com.chan.domain.usecase + +import com.chan.domain.ProductsVO +import com.chan.domain.repository.LikeRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetLikedProductsUseCase @Inject constructor( + private val likeRepository: LikeRepository +) { + operator fun invoke() : Flow> { + return likeRepository.getLikedProducts() + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/chan/domain/usecase/ToggleLikeUseCase.kt b/core/domain/src/main/java/com/chan/domain/usecase/ToggleLikeUseCase.kt new file mode 100644 index 00000000..4974e1f9 --- /dev/null +++ b/core/domain/src/main/java/com/chan/domain/usecase/ToggleLikeUseCase.kt @@ -0,0 +1,12 @@ +package com.chan.domain.usecase + +import com.chan.domain.repository.LikeRepository +import javax.inject.Inject + +class ToggleLikeUseCase @Inject constructor( + private val likeRepository: LikeRepository +) { + suspend operator fun invoke(productId: String) { + likeRepository.toggleLike(productId) + } +} \ No newline at end of file diff --git a/feature/like/src/main/java/com/chan/like/LikeContract.kt b/feature/like/src/main/java/com/chan/like/LikeContract.kt new file mode 100644 index 00000000..ee38842c --- /dev/null +++ b/feature/like/src/main/java/com/chan/like/LikeContract.kt @@ -0,0 +1,42 @@ +package com.chan.like + +import com.chan.android.LoadingState +import com.chan.android.ViewEffect +import com.chan.android.ViewEvent +import com.chan.android.ViewState +import com.chan.android.model.ProductsModel + +class LikeContract { + + sealed class Event : ViewEvent { + object LoadFavorites: Event() + + sealed class TopBar : Event() { + object NavigateToSearch : TopBar() + object NavigateToCart : TopBar() + } + + sealed class LikeAction : Event() { + data class ToggleFavorite(val productId: String) : LikeAction() + data class NavigateToProductDetail(val productId: String) : LikeAction() + data class ShowCartPopup(val productId: String) : LikeAction() + } + } + + data class State( + //좋아요 상품 목록 + val likeProducts: List = emptyList(), + //좋아요 상품 Id + val likedProductIds: Set = emptySet(), + val loadingState: LoadingState = LoadingState.Idle + ) : ViewState + + sealed class Effect : ViewEffect { + sealed class Navigation : Effect() { + object ToCartRoute : Navigation() + object ToSearchRoute : Navigation() + data class ToCartPopupRoute(val productId: String) : Navigation() + data class ToProductDetail(val productId: String) : Navigation() + } + } +} \ No newline at end of file diff --git a/feature/like/src/main/java/com/chan/like/LikeScreen.kt b/feature/like/src/main/java/com/chan/like/LikeScreen.kt new file mode 100644 index 00000000..8fb343de --- /dev/null +++ b/feature/like/src/main/java/com/chan/like/LikeScreen.kt @@ -0,0 +1,73 @@ +package com.chan.like + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.ShoppingCart +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.chan.android.model.ProductCardOrientation +import com.chan.android.ui.CommonProductsCard +import com.chan.android.ui.composable.MainTopBar +import com.chan.android.ui.theme.White + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LikeScreen( + state: LikeContract.State, + onEvent: (LikeContract.Event) -> Unit, +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Scaffold( + topBar = { + Column { + MainTopBar( + navigationIcon = null, + titleContent = { + Text(text = stringResource(R.string.history)) + }, + actions = { + IconButton(onClick = { onEvent(LikeContract.Event.TopBar.NavigateToSearch) }) { + Icon(Icons.Default.Search, contentDescription = "검색") + } + IconButton(onClick = { onEvent(LikeContract.Event.TopBar.NavigateToCart) }) { + Icon(Icons.Default.ShoppingCart, contentDescription = "장바구니") + } + }, + scrollBehavior = scrollBehavior + ) + } + }, + modifier = Modifier.fillMaxSize() + ) { innerPadding -> + Column(modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .background(White)) { + state.likeProducts.forEach { product -> + CommonProductsCard( + product = product, + isLiked = product.productId in state.likedProductIds, + modifier = Modifier.fillMaxWidth(), + orientation = ProductCardOrientation.HORIZONTAL, + onClick = { onEvent(LikeContract.Event.LikeAction.NavigateToProductDetail(it)) }, + onLikeClick = { onEvent(LikeContract.Event.LikeAction.ToggleFavorite(it)) }, + onCartClick = { onEvent(LikeContract.Event.LikeAction.ShowCartPopup(it)) } + ) + } + + } + } +} \ No newline at end of file diff --git a/feature/like/src/main/java/com/chan/like/LikeViewModel.kt b/feature/like/src/main/java/com/chan/like/LikeViewModel.kt new file mode 100644 index 00000000..3665811e --- /dev/null +++ b/feature/like/src/main/java/com/chan/like/LikeViewModel.kt @@ -0,0 +1,68 @@ +package com.chan.like + +import androidx.lifecycle.viewModelScope +import com.chan.android.BaseViewModel +import com.chan.domain.usecase.GetLikedProductIdsUseCase +import com.chan.domain.usecase.GetLikedProductsUseCase +import com.chan.domain.usecase.ToggleLikeUseCase +import com.chan.like.LikeContract.Effect.Navigation.ToCartPopupRoute +import com.chan.like.LikeContract.Effect.Navigation.ToCartRoute +import com.chan.like.LikeContract.Effect.Navigation.ToProductDetail +import com.chan.like.LikeContract.Effect.Navigation.ToSearchRoute +import com.chan.like.mapper.toProductsModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class LikeViewModel @Inject constructor( + private val toggleLikeUseCase: ToggleLikeUseCase, + private val getLikedProductIdsUseCase: GetLikedProductIdsUseCase, + private val getLikedProductsUseCase: GetLikedProductsUseCase, +) : BaseViewModel() { + + init { + observeLikes() + } + + override fun setInitialState() = LikeContract.State() + + override fun handleEvent(event: LikeContract.Event) { + when (event) { + LikeContract.Event.TopBar.NavigateToCart -> setEffect { ToCartRoute } + LikeContract.Event.TopBar.NavigateToSearch -> setEffect { ToSearchRoute } + is LikeContract.Event.LikeAction.NavigateToProductDetail -> setEffect { + ToProductDetail( + event.productId + ) + } + + is LikeContract.Event.LikeAction.ShowCartPopup -> setEffect { ToCartPopupRoute(event.productId) } + is LikeContract.Event.LikeAction.ToggleFavorite -> toggleFavorite(event.productId) + LikeContract.Event.LoadFavorites -> loadFavorites() + } + } + + private fun observeLikes() { + viewModelScope.launch { + getLikedProductIdsUseCase.invoke() + .collect { ids -> + setState { copy(likedProductIds = ids) } + } + } + } + + private fun loadFavorites() { + viewModelScope.launch { + val products = getLikedProductsUseCase.invoke().first().map { it.toProductsModel() } + setState { copy(likeProducts = products) } + } + } + + private fun toggleFavorite(productId: String) { + viewModelScope.launch { + toggleLikeUseCase.invoke(productId) + } + } +} \ No newline at end of file diff --git a/feature/like/src/main/java/com/chan/like/mapper/LikeMappers.kt b/feature/like/src/main/java/com/chan/like/mapper/LikeMappers.kt new file mode 100644 index 00000000..cb0244c9 --- /dev/null +++ b/feature/like/src/main/java/com/chan/like/mapper/LikeMappers.kt @@ -0,0 +1,22 @@ +package com.chan.like.mapper + +import com.chan.android.model.ProductsModel +import com.chan.domain.ProductsVO +import java.text.NumberFormat +import java.util.Locale + +fun ProductsVO.toProductsModel(): ProductsModel { + return ProductsModel( + productId = productId, + productName = productName, + brandName = brandName, + imageUrl = "https://image.oliveyoung.co.kr/cfimages/cf-goods/uploads/images/thumbnails/550/10/0000/0016/A00000016559829ko.jpg?l=ko", + originalPrice = NumberFormat.getNumberInstance(Locale.KOREA).format(originalPrice) + "원", + discountPrice = NumberFormat.getNumberInstance(Locale.KOREA).format(discountPrice) + "원", + discountPercent = "${discountPercent}%", + tags = tags, + reviewRating = reviewRating, + reviewCount = "(${NumberFormat.getNumberInstance(Locale.KOREA).format(reviewCount)})", + categoryIds = categoryIds, + ) +} \ No newline at end of file diff --git a/feature/mypage/src/main/java/com/chan/mypage/domain/usecase/LogoutUseCase.kt b/feature/mypage/src/main/java/com/chan/mypage/domain/usecase/LogoutUseCase.kt index 6ea3d714..39cd6e81 100644 --- a/feature/mypage/src/main/java/com/chan/mypage/domain/usecase/LogoutUseCase.kt +++ b/feature/mypage/src/main/java/com/chan/mypage/domain/usecase/LogoutUseCase.kt @@ -1,16 +1,16 @@ package com.chan.mypage.domain.usecase -import android.content.Context import com.chan.auth.domain.AuthRepository import com.chan.database.datastore.CartDataStoreManager -import dagger.hilt.android.qualifiers.ApplicationContext +import com.chan.database.datastore.LikeDataStoreManager import javax.inject.Inject class LogoutUseCase @Inject constructor( - private val authRepository: AuthRepository + private val authRepository: AuthRepository, ) { suspend operator fun invoke() { authRepository.logout() CartDataStoreManager.clearAll() + LikeDataStoreManager.clearAll() } } \ No newline at end of file