diff --git a/core/common/kotlin/src/main/java/team/ppac/common/kotlin/model/ReactionState.kt b/core/common/kotlin/src/main/java/team/ppac/common/kotlin/model/ReactionState.kt new file mode 100644 index 00000000..1842040d --- /dev/null +++ b/core/common/kotlin/src/main/java/team/ppac/common/kotlin/model/ReactionState.kt @@ -0,0 +1,55 @@ +package team.ppac.common.kotlin.model + +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +class ReactionState { + private val _isFirstClickEvent = AtomicBoolean(true) + private val _reactionCount = AtomicInteger(0) + private val _isUpdating = AtomicBoolean(false) + private var lastClickTime: Long = 0 + + val reactionCount: Int + get() = _reactionCount.get() + + val isUpdating: Boolean + get() = _isUpdating.get() + + val isFirstClickEvent: Boolean + get() = _isFirstClickEvent.get() + + fun setIsFirstClickEvent(value: Boolean) { + _isFirstClickEvent.set(value) + } + + fun addReactionCount(count: Int) { + _reactionCount.addAndGet(count) + } + + fun startUpdate() { + _isUpdating.compareAndSet(false, true) + } + + fun endUpdate() { + _isUpdating.set(false) + } + + fun releaseState() { + _reactionCount.set(0) + _isFirstClickEvent.set(true) + } + + fun isDoubleClickEvent(): Boolean { + val currentClickTime: Long = System.currentTimeMillis() + return if (currentClickTime - lastClickTime <= DOUBLE_CLICK_INTERVAL) { + true + } else { + lastClickTime = System.currentTimeMillis() + false + } + } + + companion object { + private const val DOUBLE_CLICK_INTERVAL = 400 + } +} \ No newline at end of file diff --git a/core/data/src/main/java/team/ppac/data/mapper/ReactionMemeMapper.kt b/core/data/src/main/java/team/ppac/data/mapper/ReactionMemeMapper.kt new file mode 100644 index 00000000..df175907 --- /dev/null +++ b/core/data/src/main/java/team/ppac/data/mapper/ReactionMemeMapper.kt @@ -0,0 +1,8 @@ +package team.ppac.data.mapper + +import team.ppac.domain.model.ReactionMeme +import team.ppac.remote.model.response.meme.ReactionMemeResponse + +internal fun ReactionMemeResponse.toReactionMeme(): ReactionMeme = ReactionMeme( + count = count, +) \ No newline at end of file diff --git a/core/data/src/main/java/team/ppac/data/repository/MemeRepositoryImpl.kt b/core/data/src/main/java/team/ppac/data/repository/MemeRepositoryImpl.kt index 41d7e552..b91c4388 100644 --- a/core/data/src/main/java/team/ppac/data/repository/MemeRepositoryImpl.kt +++ b/core/data/src/main/java/team/ppac/data/repository/MemeRepositoryImpl.kt @@ -3,11 +3,13 @@ package team.ppac.data.repository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import team.ppac.data.mapper.toMeme +import team.ppac.data.mapper.toReactionMeme import team.ppac.data.paging.ITEMS_PER_PAGE import team.ppac.data.paging.createPager import team.ppac.domain.model.Meme import team.ppac.domain.model.MemeWatchType import team.ppac.domain.model.MemeWithPagination +import team.ppac.domain.model.ReactionMeme import team.ppac.domain.repository.MemeRepository import team.ppac.domain.repository.SavedMemeEvent import team.ppac.remote.datasource.MemeDataSource @@ -58,8 +60,8 @@ internal class MemeRepositoryImpl @Inject constructor( ) } - override suspend fun reactMeme(memeId: String): Boolean { - return memeDataSource.reactMeme(memeId) + override suspend fun reactMeme(memeId: String, count: Int): ReactionMeme { + return memeDataSource.reactMeme(memeId, count).toReactionMeme() } override suspend fun watchMeme(memeId: String, watchType: MemeWatchType): Boolean { diff --git a/core/designsystem/src/main/kotlin/team/ppac/designsystem/component/button/Button.kt b/core/designsystem/src/main/kotlin/team/ppac/designsystem/component/button/Button.kt index 7ace1ee0..a43a0943 100644 --- a/core/designsystem/src/main/kotlin/team/ppac/designsystem/component/button/Button.kt +++ b/core/designsystem/src/main/kotlin/team/ppac/designsystem/component/button/Button.kt @@ -50,6 +50,7 @@ fun FarmemeWeakButton( text: String = "", textColor: Color = FarmemeTheme.textColor.primary, withStar: Boolean = false, + isDebounceClick: Boolean = true, onClick: () -> Unit = { }, icon: @Composable () -> Unit, ) { @@ -57,7 +58,7 @@ fun FarmemeWeakButton( modifier = modifier .clip(FarmemeRadius.Radius25.shape) .background(color = backgroundColor) - .noRippleClickable(onClick = onClick) + .noRippleClickable(onClick = onClick, isDebounceClick = isDebounceClick) .padding(15.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, diff --git a/core/designsystem/src/main/kotlin/team/ppac/designsystem/util/extension/NoRippleClickable.kt b/core/designsystem/src/main/kotlin/team/ppac/designsystem/util/extension/NoRippleClickable.kt index bcbf2549..426b86b6 100644 --- a/core/designsystem/src/main/kotlin/team/ppac/designsystem/util/extension/NoRippleClickable.kt +++ b/core/designsystem/src/main/kotlin/team/ppac/designsystem/util/extension/NoRippleClickable.kt @@ -15,13 +15,15 @@ import team.ppac.common.kotlin.model.MultipleEventsCutter @SuppressLint("UnnecessaryComposedModifier") fun Modifier.noRippleClickable( enabled: Boolean = true, + isDebounceClick: Boolean = true, onClickLabel: String? = null, role: Role? = null, - onClick: () -> Unit, + onClick: () -> Unit ): Modifier = composed { this then singleClickable( indication = null, enabled = enabled, + isDebounceClick = isDebounceClick, onClickLabel = onClickLabel, role = role, onClick = onClick @@ -31,17 +33,19 @@ fun Modifier.noRippleClickable( @SuppressLint("UnnecessaryComposedModifier") fun Modifier.rippleClickable( rippleColor: Color = Color.Unspecified, + isDebounceClick: Boolean = true, enabled: Boolean = true, onClickLabel: String? = null, role: Role? = null, - onClick: () -> Unit, + onClick: () -> Unit ): Modifier = composed { this then singleClickable( indication = rememberRipple(color = rippleColor), enabled = enabled, + isDebounceClick = isDebounceClick, onClickLabel = onClickLabel, role = role, - onClick = onClick + onClick = onClick, ) } @@ -49,19 +53,23 @@ fun Modifier.rippleClickable( private fun Modifier.singleClickable( indication: Indication?, enabled: Boolean = true, + debounceMillis: Long = 300L, + isDebounceClick: Boolean = true, onClickLabel: String? = null, role: Role? = null, - debounceMillis: Long = 300L, onClick: () -> Unit, ): Modifier = composed { val multipleEventsCutter = remember { MultipleEventsCutter(debounceMillis) } - clickable( interactionSource = remember { MutableInteractionSource() }, indication = indication, enabled = enabled, onClickLabel = onClickLabel, role = role, - onClick = { multipleEventsCutter.processEvent(onClick) }, + onClick = if (isDebounceClick) { + { multipleEventsCutter.processEvent(onClick) } + } else { + { onClick() } + }, ) } diff --git a/core/domain/src/main/java/team/ppac/domain/model/ReactionMeme.kt b/core/domain/src/main/java/team/ppac/domain/model/ReactionMeme.kt new file mode 100644 index 00000000..057eac21 --- /dev/null +++ b/core/domain/src/main/java/team/ppac/domain/model/ReactionMeme.kt @@ -0,0 +1,5 @@ +package team.ppac.domain.model + +data class ReactionMeme ( + val count: Int +) \ No newline at end of file diff --git a/core/domain/src/main/java/team/ppac/domain/repository/MemeRepository.kt b/core/domain/src/main/java/team/ppac/domain/repository/MemeRepository.kt index 9d062a2e..2fc0e700 100644 --- a/core/domain/src/main/java/team/ppac/domain/repository/MemeRepository.kt +++ b/core/domain/src/main/java/team/ppac/domain/repository/MemeRepository.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.Flow import team.ppac.domain.model.Meme import team.ppac.domain.model.MemeWatchType import team.ppac.domain.model.MemeWithPagination +import team.ppac.domain.model.ReactionMeme interface MemeRepository { suspend fun getMeme(memeId: String): Meme @@ -14,8 +15,7 @@ interface MemeRepository { keyword: String, getCurrentPage: (Int) -> Unit ): MemeWithPagination - - suspend fun reactMeme(memeId: String): Boolean + suspend fun reactMeme(memeId: String, count: Int): ReactionMeme suspend fun watchMeme( memeId: String, watchType: MemeWatchType, diff --git a/core/domain/src/main/java/team/ppac/domain/usecase/ReactMemeUseCase.kt b/core/domain/src/main/java/team/ppac/domain/usecase/ReactMemeUseCase.kt index 2a188e46..99a8d14f 100644 --- a/core/domain/src/main/java/team/ppac/domain/usecase/ReactMemeUseCase.kt +++ b/core/domain/src/main/java/team/ppac/domain/usecase/ReactMemeUseCase.kt @@ -1,16 +1,17 @@ package team.ppac.domain.usecase +import team.ppac.domain.model.ReactionMeme import team.ppac.domain.repository.MemeRepository import javax.inject.Inject interface ReactMemeUseCase { - suspend operator fun invoke(memeId: String): Boolean + suspend operator fun invoke(memeId: String, count: Int): ReactionMeme } internal class ReactMemeUseCaseImpl @Inject constructor( private val memeRepository: MemeRepository, ) : ReactMemeUseCase { - override suspend fun invoke(memeId: String): Boolean { - return memeRepository.reactMeme(memeId) + override suspend fun invoke(memeId: String, count: Int): ReactionMeme { + return memeRepository.reactMeme(memeId, count) } } \ No newline at end of file diff --git a/core/remote/src/main/kotlin/team/ppac/remote/api/MemeApi.kt b/core/remote/src/main/kotlin/team/ppac/remote/api/MemeApi.kt index 2ed1dc0e..b1ebdb7c 100644 --- a/core/remote/src/main/kotlin/team/ppac/remote/api/MemeApi.kt +++ b/core/remote/src/main/kotlin/team/ppac/remote/api/MemeApi.kt @@ -2,6 +2,7 @@ package team.ppac.remote.api import okhttp3.MultipartBody import okhttp3.RequestBody +import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.Multipart @@ -9,7 +10,9 @@ import retrofit2.http.POST import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query +import team.ppac.remote.model.request.meme.ReactMemeRequest import team.ppac.remote.model.response.meme.MemeResponse +import team.ppac.remote.model.response.meme.ReactionMemeResponse import team.ppac.remote.model.response.user.MemesResponse import team.ppac.remote.model.response.meme.UploadMemeResponse @@ -37,7 +40,10 @@ internal interface MemeApi { ): MemesResponse @POST("/api/meme/{memeId}/reaction") - suspend fun reactMeme(@Path("memeId") memeId: String): Boolean + suspend fun reactMeme( + @Path("memeId") memeId: String, + @Body reactMemeRequest: ReactMemeRequest + ): ReactionMemeResponse @POST("/api/meme/{memeId}/watch/{type}") suspend fun watchMeme( diff --git a/core/remote/src/main/kotlin/team/ppac/remote/datasource/MemeDataSource.kt b/core/remote/src/main/kotlin/team/ppac/remote/datasource/MemeDataSource.kt index eb81f321..6cdcfc69 100644 --- a/core/remote/src/main/kotlin/team/ppac/remote/datasource/MemeDataSource.kt +++ b/core/remote/src/main/kotlin/team/ppac/remote/datasource/MemeDataSource.kt @@ -1,6 +1,7 @@ package team.ppac.remote.datasource import team.ppac.remote.model.response.meme.MemeResponse +import team.ppac.remote.model.response.meme.ReactionMemeResponse import team.ppac.remote.model.response.user.MemesResponse interface MemeDataSource { @@ -13,7 +14,7 @@ interface MemeDataSource { page: Int, size: Int, ): MemesResponse - suspend fun reactMeme(memeId: String): Boolean + suspend fun reactMeme(memeId: String, count: Int): ReactionMemeResponse suspend fun watchMeme( memeId: String, type: String, diff --git a/core/remote/src/main/kotlin/team/ppac/remote/datasource/impl/MemeDataSourceImpl.kt b/core/remote/src/main/kotlin/team/ppac/remote/datasource/impl/MemeDataSourceImpl.kt index c4dce0b7..574a8e3f 100644 --- a/core/remote/src/main/kotlin/team/ppac/remote/datasource/impl/MemeDataSourceImpl.kt +++ b/core/remote/src/main/kotlin/team/ppac/remote/datasource/impl/MemeDataSourceImpl.kt @@ -11,7 +11,9 @@ import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody import team.ppac.remote.api.MemeApi import team.ppac.remote.datasource.MemeDataSource +import team.ppac.remote.model.request.meme.ReactMemeRequest import team.ppac.remote.model.response.meme.MemeResponse +import team.ppac.remote.model.response.meme.ReactionMemeResponse import team.ppac.remote.model.response.user.MemesResponse import java.io.File import java.io.FileOutputStream @@ -43,8 +45,8 @@ internal class MemeDataSourceImpl @Inject constructor( return memeApi.getSearchMemes(keyword, page, size) } - override suspend fun reactMeme(memeId: String): Boolean { - return memeApi.reactMeme(memeId) + override suspend fun reactMeme(memeId: String, count: Int): ReactionMemeResponse { + return memeApi.reactMeme(memeId, reactMemeRequest = ReactMemeRequest(count)) } override suspend fun watchMeme(memeId: String, type: String): Boolean { diff --git a/core/remote/src/main/kotlin/team/ppac/remote/model/request/meme/ReactMemeRequest.kt b/core/remote/src/main/kotlin/team/ppac/remote/model/request/meme/ReactMemeRequest.kt new file mode 100644 index 00000000..b152dc9c --- /dev/null +++ b/core/remote/src/main/kotlin/team/ppac/remote/model/request/meme/ReactMemeRequest.kt @@ -0,0 +1,10 @@ +package team.ppac.remote.model.request.meme + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ReactMemeRequest ( + @field:Json(name = "count") + val count: Int, +) \ No newline at end of file diff --git a/core/remote/src/main/kotlin/team/ppac/remote/model/response/meme/ReactionMemeResponse.kt b/core/remote/src/main/kotlin/team/ppac/remote/model/response/meme/ReactionMemeResponse.kt new file mode 100644 index 00000000..0839cb38 --- /dev/null +++ b/core/remote/src/main/kotlin/team/ppac/remote/model/response/meme/ReactionMemeResponse.kt @@ -0,0 +1,10 @@ +package team.ppac.remote.model.response.meme + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ReactionMemeResponse ( + @field:Json(name = "count") + val count: Int, +) \ No newline at end of file diff --git a/feature/detail/src/main/java/team/ppac/detail/DetailViewModel.kt b/feature/detail/src/main/java/team/ppac/detail/DetailViewModel.kt index 0b107a35..45585159 100644 --- a/feature/detail/src/main/java/team/ppac/detail/DetailViewModel.kt +++ b/feature/detail/src/main/java/team/ppac/detail/DetailViewModel.kt @@ -5,7 +5,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay import team.ppac.common.android.base.BaseViewModel +import team.ppac.common.kotlin.model.ReactionState import team.ppac.designsystem.foundation.FarmemeIcon import team.ppac.detail.mapper.toDetailMemeUiModel import team.ppac.detail.mvi.DetailIntent @@ -18,6 +20,7 @@ import team.ppac.domain.usecase.ReactMemeUseCase import team.ppac.domain.usecase.SaveMemeUseCase import team.ppac.domain.usecase.ShareMemeUseCase import team.ppac.errorhandling.FarmemeNetworkException +import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -31,6 +34,8 @@ class DetailViewModel @Inject constructor( private val shareMemeUseCase: ShareMemeUseCase, ) : BaseViewModel(savedStateHandle) { + private val reactionState = ReactionState() + init { launch { getMeme(currentState.memeId) @@ -50,11 +55,25 @@ class DetailViewModel @Inject constructor( } } + override suspend fun handleIntent(intent: DetailIntent) { when (intent) { is DetailIntent.ClickFunnyButton -> { - incrementReactionCount() - postSideEffect(DetailSideEffect.RunRisingEffect) + if (!reactionState.isUpdating && reactionState.isDoubleClickEvent()) { + incrementReactionCount() + postSideEffect(DetailSideEffect.RunRisingEffect) + reactionState.addReactionCount(1) + if (reactionState.isFirstClickEvent) { + reactionState.setIsFirstClickEvent(false) + launch { + updateReactionCountWithDelay() + } + } + } else { + incrementReactionCount() + postSideEffect(DetailSideEffect.RunRisingEffect) + updateReactionCount(1) + } } is DetailIntent.ClickBackButton -> { @@ -148,7 +167,7 @@ class DetailViewModel @Inject constructor( } } - private suspend fun incrementReactionCount() { + private fun incrementReactionCount() { reduce { copy( detailMemeUiModel = detailMemeUiModel.copy( @@ -157,13 +176,25 @@ class DetailViewModel @Inject constructor( ) ) } + } + + private suspend fun updateReactionCountWithDelay() { + delay(1000) + reactionState.startUpdate() + updateReactionCount(reactionState.reactionCount) + reactionState.releaseState() + reactionState.endUpdate() + } + + private suspend fun updateReactionCount(reactionCount: Int) { runCatching { - reactMemeUseCase(currentState.memeId) + reactMemeUseCase(currentState.memeId, reactionCount) }.onFailure { + Timber.tag(TAG).i("updateReactionCount failMessage= $it") reduce { copy( detailMemeUiModel = detailMemeUiModel.copy( - reactionCount = detailMemeUiModel.reactionCount - 1, + reactionCount = detailMemeUiModel.reactionCount - reactionCount, isReaction = false ) ) diff --git a/feature/detail/src/main/java/team/ppac/detail/component/DetailContent.kt b/feature/detail/src/main/java/team/ppac/detail/component/DetailContent.kt index 31138c70..b6bf71c1 100644 --- a/feature/detail/src/main/java/team/ppac/detail/component/DetailContent.kt +++ b/feature/detail/src/main/java/team/ppac/detail/component/DetailContent.kt @@ -271,6 +271,7 @@ private fun DetailFunnyButton( .background(color = if (currentDetailScreenSize == DetailScreenSize.SMALL) FarmemeTheme.backgroundColor.white else FarmemeTheme.skeletonColor.primary) .rippleClickable( rippleColor = FarmemeTheme.skeletonColor.secondary, + isDebounceClick = false, onClick = { if (!isLoading) { coroutineScope.launch { diff --git a/feature/recommendation/src/main/java/team/ppac/recommendation/RecommendationViewModel.kt b/feature/recommendation/src/main/java/team/ppac/recommendation/RecommendationViewModel.kt index 23b4b728..28a286a7 100644 --- a/feature/recommendation/src/main/java/team/ppac/recommendation/RecommendationViewModel.kt +++ b/feature/recommendation/src/main/java/team/ppac/recommendation/RecommendationViewModel.kt @@ -10,7 +10,9 @@ import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import team.ppac.common.android.base.BaseViewModel +import team.ppac.common.kotlin.model.ReactionState import team.ppac.designsystem.foundation.FarmemeIcon +import team.ppac.domain.model.Meme import team.ppac.domain.model.MemeWatchType import team.ppac.domain.usecase.DeleteSavedMemeUseCase import team.ppac.domain.usecase.GetThisWeekRecommendMemesUseCase @@ -38,6 +40,8 @@ class RecommendationViewModel @Inject constructor( ) : BaseViewModel( savedStateHandle ) { + private val reactionState = ReactionState() + init { launch { initialAction() @@ -59,26 +63,34 @@ class RecommendationViewModel @Inject constructor( override suspend fun handleIntent(intent: RecommendationIntent) { when (intent) { is RecommendationIntent.ClickButton.LoL -> { - postSideEffect(RecommendationSideEffect.RunRisingEffect(intent.meme)) - reduce { - updateReaction(intent.meme) { - it.copy( - reactionCount = it.reactionCount + 1, - isReaction = true, - ) + if (!reactionState.isUpdating && reactionState.isDoubleClickEvent()) { + postSideEffect(RecommendationSideEffect.RunRisingEffect(intent.meme)) + reduce { + updateReaction(intent.meme) { + it.copy( + reactionCount = it.reactionCount + 1, + isReaction = true, + ) + } } - } - runCatching { - reactMemeUseCase(intent.meme.id) - }.onFailure { + reactionState.addReactionCount(1) + if (reactionState.isFirstClickEvent) { + reactionState.setIsFirstClickEvent(false) + launch { + updateReactionCountWithDelay(intent.meme) + } + } + } else { + postSideEffect(RecommendationSideEffect.RunRisingEffect(intent.meme)) reduce { updateReaction(intent.meme) { it.copy( - reactionCount = it.reactionCount - 1, - isReaction = false + reactionCount = it.reactionCount + 1, + isReaction = true, ) } } + updateReactionCount(intent.meme, 1) } } @@ -166,4 +178,28 @@ class RecommendationViewModel @Inject constructor( } } } + + private suspend fun updateReactionCountWithDelay(meme: Meme) { + delay(1000) + reactionState.startUpdate() + updateReactionCount(meme, reactionState.reactionCount) + reactionState.releaseState() + reactionState.endUpdate() + } + + private suspend fun updateReactionCount(meme: Meme, reactionCount: Int) { + runCatching { + reactMemeUseCase(meme.id, reactionCount) + }.onFailure { + reduce { + updateReaction(meme) { + it.copy( + reactionCount = it.reactionCount - reactionCount, + isReaction = false + ) + } + } + } + } + } \ No newline at end of file diff --git a/feature/recommendation/src/main/java/team/ppac/recommendation/component/ActionButtons.kt b/feature/recommendation/src/main/java/team/ppac/recommendation/component/ActionButtons.kt index 2fe20594..ebc7ebe8 100644 --- a/feature/recommendation/src/main/java/team/ppac/recommendation/component/ActionButtons.kt +++ b/feature/recommendation/src/main/java/team/ppac/recommendation/component/ActionButtons.kt @@ -55,6 +55,8 @@ internal fun ActionButtons( val bound = it.boundsInWindow() onReactionButtonPositioned(bound.topLeft) }, + isDebounceClick = false, + backgroundColor = FarmemeTheme.backgroundColor.white, icon = { if (meme.reactionCount == 0) { Row(