diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/LiveRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/LiveRepository.kt index 84685aba..d6a61386 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/LiveRepository.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/LiveRepository.kt @@ -245,10 +245,11 @@ class LiveRepository( authHeader = token ) - fun likeHeart(roomId: Long, token: String) = api.likeHeart( + fun likeHeart(roomId: Long, heartCount: Int = 1, token: String) = api.likeHeart( request = LiveRoomLikeHeartRequest( roomId = roomId, - container = "aos" + container = "aos", + heartCount = heartCount ), authHeader = token ) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt index a6a6b73f..1e1164c9 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt @@ -23,7 +23,9 @@ import android.text.method.LinkMovementMethod import android.text.style.ClickableSpan import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan +import android.view.Gravity import android.view.LayoutInflater +import android.view.MotionEvent import android.view.View import android.view.animation.AccelerateDecelerateInterpolator import android.view.inputmethod.EditorInfo @@ -1687,6 +1689,16 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB } } + LiveRoomChatRawMessageType.BIG_HEART_DONATION -> { + handler.post { + addHeartMessage(nickname) + addHeartAnimation() + lifecycleScope.launch { + viewModel.addHeartDonation(heartCount = message.can) + } + } + } + LiveRoomChatRawMessageType.SECRET_DONATION -> { val nickname = viewModel.getUserNickname(memberId.toInt()) val profileUrl = viewModel.getUserProfileUrl(memberId.toInt()) @@ -1839,63 +1851,248 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB } } + @SuppressLint("ClickableViewAccessibility") private fun initLikeHeartButton() { if (!isHost) { binding.flLikeHeart.visibility = View.VISIBLE - binding.flLikeHeart.setOnClickListener { - if (isAvailableLikeHeart) { - binding.flLikeHeart.isEnabled = false - viewModel.likeHeart( - roomId = roomId, - onSuccess = { - val donationRawMessage = Gson().toJson( - LiveRoomChatRawMessage( - type = LiveRoomChatRawMessageType.HEART_DONATION, - message = "", - can = 1, - signature = null, - signatureImageUrl = null, - donationMessage = null - ) - ) + binding.flLikeHeart.isClickable = true - agora.sendRawMessageToGroup( - rawMessage = donationRawMessage.toByteArray(), - onSuccess = { - val nickname = viewModel.getUserNickname( - SharedPreferenceManager.userId.toInt() - ) - handler.post { - addHeartMessage(nickname) - addHeartAnimation() - lifecycleScope.launch { viewModel.addHeartDonation() } - } - }, - onFailure = { - viewModel.refundDonation(roomId) - } - ) + // 클릭: 기존 동작 유지 (1캔 소모) + binding.flLikeHeart.setOnClickListener { handleHeartClick() } + + // 롱클릭 터치 핸들러로 위임 + binding.flLikeHeart.setOnTouchListener { _, event -> + handleHeartLongPressTouch(event) + } + } else { + binding.flLikeHeart.visibility = View.GONE + } + } + + private var longPressCenterHeart: ImageView? = null + + // 롱프레스 상태/파라미터 + private var isLongPressTriggered = false + private var isTrackingLongPress = false + private var pressStartTime = 0L + private var isLongPressBlockedByAvailability = false + + // 롱프레스 트리거까지의 총 시간(2초 유지 시 BIG HEART) + private val longPressDurationMs = 2000L + + private val longPressScaleDurationMs get() = longPressDurationMs + private val longPressScaleStart = 1.0f + private val longPressScaleMax = 4.0f + + // 롱프레스 진행 중 스케일 업데이트 러너블 + private val longPressScaleUpdateRunnable = object : Runnable { + override fun run() { + if (!isTrackingLongPress) return + if (isLongPressBlockedByAvailability) return + val elapsed = System.currentTimeMillis() - pressStartTime + + // 스케일은 더 빠르게 커지도록 별도의 duration 기준으로 계산(최대치 도달 후 유지) + val scaleFraction = (elapsed.coerceAtMost(longPressScaleDurationMs) + .toFloat() / longPressScaleDurationMs.toFloat()) + val scale = + longPressScaleStart + (longPressScaleMax - longPressScaleStart) * scaleFraction + updateCenterHeartScale(scale) + + // 2초 유지 시 트리거 실행 + if (elapsed >= longPressDurationMs && !isLongPressTriggered) { + isLongPressTriggered = true + isTrackingLongPress = false + removeCenterHeartForLongPress(withFade = true) + triggerBigHeartDonation() + return + } + + if (isTrackingLongPress && !isLongPressTriggered) { + // 다음 프레임 업데이트(약 60fps) + handler.postDelayed(this, 16L) + } + } + } + + // 롱프레스 터치 처리 로직 (initLikeHeartButton에서 위임) + private fun handleHeartLongPressTouch(event: MotionEvent): Boolean { + return when (event.action) { + MotionEvent.ACTION_DOWN -> { + // 이용 가능하지 않으면 롱프레스를 즉시 취소하고 안내 팝업 노출 + if (!isAvailableLikeHeart) { + isLongPressTriggered = false + isTrackingLongPress = false + isLongPressBlockedByAvailability = true + removeCenterHeartForLongPress(withFade = false) - binding.flLikeHeart.isEnabled = true - }, - onFailure = { - binding.flLikeHeart.isEnabled = true - } - ) - } else { SodaDialog( activity = this@LiveRoomActivity, layoutInflater = layoutInflater, title = "안내", - desc = "'좋아해요'는 유료 후원입니다.\n" + - "클릭시 1캔이 소진됩니다.", + desc = "'좋아해요'는 유료 후원입니다.\n클릭시 1캔이 소진됩니다.", confirmButtonTitle = "확인", confirmButtonClick = { isAvailableLikeHeart = true } ).show(screenWidth) + return true } + + isLongPressTriggered = false + isTrackingLongPress = true + pressStartTime = System.currentTimeMillis() + // 중앙 하트 생성 및 초기 스케일 설정 + showCenterHeartForLongPress() + updateCenterHeartScale(longPressScaleStart) + handler.post(longPressScaleUpdateRunnable) + true } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + // 이용 불가로 인해 즉시 안내를 띄운 케이스: 클릭 위임 없이 종료 + if (isLongPressBlockedByAvailability) { + isLongPressBlockedByAvailability = false + return true + } + + val wasTriggered = isLongPressTriggered + isTrackingLongPress = false + // 중앙 하트 제거 + removeCenterHeartForLongPress(withFade = !wasTriggered) + true + } + + else -> false + } + } + + private fun showCenterHeartForLongPress() { + if (longPressCenterHeart != null) return + val size = 33.3f.dpToPx().toInt() + val lp = FrameLayout.LayoutParams(size, size).apply { gravity = Gravity.CENTER } + val heart = ImageView(this).apply { + setImageResource(R.drawable.ic_heart_pink) + layoutParams = lp + scaleX = 1f + scaleY = 1f + alpha = 1f + } + binding.flRoot.addView(heart) + longPressCenterHeart = heart + } + + private fun updateCenterHeartScale(scale: Float) { + longPressCenterHeart?.let { + it.scaleX = scale + it.scaleY = scale + } + } + + private fun removeCenterHeartForLongPress(withFade: Boolean = false) { + val heart = longPressCenterHeart ?: return + longPressCenterHeart = null + if (withFade) { + heart.animate() + .alpha(0f) + .setDuration(150L) + .withEndAction { binding.flRoot.removeView(heart) } + .start() } else { - binding.flLikeHeart.visibility = View.GONE + binding.flRoot.removeView(heart) + } + } + + private fun handleHeartClick() { + if (isAvailableLikeHeart) { + binding.flLikeHeart.isEnabled = false + viewModel.likeHeart( + roomId = roomId, + onSuccess = { + val donationRawMessage = Gson().toJson( + LiveRoomChatRawMessage( + type = LiveRoomChatRawMessageType.HEART_DONATION, + message = "", + can = 1, + signature = null, + signatureImageUrl = null, + donationMessage = null + ) + ) + + agora.sendRawMessageToGroup( + rawMessage = donationRawMessage.toByteArray(), + onSuccess = { + val nickname = viewModel.getUserNickname( + SharedPreferenceManager.userId.toInt() + ) + handler.post { + addHeartMessage(nickname) + addHeartAnimation() + lifecycleScope.launch { viewModel.addHeartDonation() } + } + }, + onFailure = { + viewModel.refundDonation(roomId) + } + ) + + binding.flLikeHeart.isEnabled = true + }, + onFailure = { + binding.flLikeHeart.isEnabled = true + } + ) + } else { + SodaDialog( + activity = this@LiveRoomActivity, + layoutInflater = layoutInflater, + title = "안내", + desc = "'좋아해요'는 유료 후원입니다.\n클릭시 1캔이 소진됩니다.", + confirmButtonTitle = "확인", + confirmButtonClick = { isAvailableLikeHeart = true } + ).show(screenWidth) + } + } + + private fun triggerBigHeartDonation() { + if (isAvailableLikeHeart) { + binding.flLikeHeart.isEnabled = false + viewModel.likeHeart( + roomId = roomId, + heartCount = 100, + onSuccess = { + val donationRawMessage = Gson().toJson( + LiveRoomChatRawMessage( + type = LiveRoomChatRawMessageType.BIG_HEART_DONATION, + message = "", + can = 100, + signature = null, + signatureImageUrl = null, + donationMessage = null + ) + ) + + agora.sendRawMessageToGroup( + rawMessage = donationRawMessage.toByteArray(), + onSuccess = { + val nickname = viewModel.getUserNickname( + SharedPreferenceManager.userId.toInt() + ) + handler.post { + addHeartMessage(nickname) + addHeartAnimation() + lifecycleScope.launch { viewModel.addHeartDonation(100) } + } + }, + onFailure = { + viewModel.refundDonation(roomId) + } + ) + + binding.flLikeHeart.isEnabled = true + }, + onFailure = { + binding.flLikeHeart.isEnabled = true + } + ) } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomViewModel.kt index a8a8cdfe..607c1011 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomViewModel.kt @@ -567,15 +567,15 @@ class LiveRoomViewModel( ) } - fun likeHeart(roomId: Long, onSuccess: () -> Unit, onFailure: () -> Unit) { + fun likeHeart(roomId: Long, heartCount: Int = 1, onSuccess: () -> Unit, onFailure: () -> Unit) { compositeDisposable.add( - repository.likeHeart(roomId, token = "Bearer ${SharedPreferenceManager.token}") + repository.likeHeart(roomId, heartCount = heartCount, token = "Bearer ${SharedPreferenceManager.token}") .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( { if (it.success) { - SharedPreferenceManager.can -= 1 + SharedPreferenceManager.can -= heartCount onSuccess() } else { if (it.message != null) { @@ -682,9 +682,9 @@ class LiveRoomViewModel( } } - suspend fun addHeartDonation() { + suspend fun addHeartDonation(heartCount: Int = 1) { mutex.withLock { - _totalHeartCount.postValue(totalHeartCount.value!! + 1) + _totalHeartCount.postValue(totalHeartCount.value!! + heartCount) } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChatRawMessage.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChatRawMessage.kt index ab3ffcf0..3e192d12 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChatRawMessage.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChatRawMessage.kt @@ -35,5 +35,8 @@ enum class LiveRoomChatRawMessageType { ROULETTE_DONATION, @SerializedName("HEART_DONATION") - HEART_DONATION + HEART_DONATION, + + @SerializedName("BIG_HEART_DONATION") + BIG_HEART_DONATION } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/like/LiveRoomLikeHeartRequest.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/like/LiveRoomLikeHeartRequest.kt index fa535ed4..d8e189d0 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/like/LiveRoomLikeHeartRequest.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/like/LiveRoomLikeHeartRequest.kt @@ -6,5 +6,6 @@ import com.google.gson.annotations.SerializedName @Keep data class LiveRoomLikeHeartRequest( @SerializedName("roomId") val roomId: Long, - @SerializedName("container") val container: String + @SerializedName("container") val container: String, + @SerializedName("heartCount") val heartCount: Int = 1, )