Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/#882 댓글 하이라이팅 #890

Merged
merged 11 commits into from
Dec 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions android/2023-emmsale/app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
# is used.
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation

# ApiResponse 클래스 축소 및 난독화 해제하여 CallAdapter에서 retrofit2.Call<ApiResponse>를 반환하는 CallAdapter 만들 수 있도록 변경
-keepnames class com.emmsale.data.common.retrofit.callAdapter.ApiResponse
# ApiResponse 클래스의 타입 매개변수를 유지하기 위해 추가. 안하면 CallAdapter에서 retrofit2.Call<ApiResponse>를 반환하는 CallAdapter 만들 수 없음.
-keepnames, allowobfuscation class com.emmsale.data.common.retrofit.callAdapter.ApiResponse
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ abstract class NetworkFragment<V : ViewDataBinding>(

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
observeCommentUiEvent()
observeCommonUiEvent()
}

private fun observeCommentUiEvent() {
viewModel.networkUiEvent.observe(this) { handleCommentUiEvent(it) }
private fun observeCommonUiEvent() {
viewModel.networkUiEvent.observe(this) { handleCommonUiEvent(it) }
}

private fun handleCommentUiEvent(event: NetworkUiEvent) {
private fun handleCommonUiEvent(event: NetworkUiEvent) {
when (event) {
NetworkUiEvent.RequestFailByNetworkError -> binding.root.showSnackBar(getString(R.string.all_network_check_message))
is NetworkUiEvent.Unexpected -> showUnexpectedErrorOccurredDialog()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.emmsale.presentation.common.layoutManager

import android.content.Context
import androidx.recyclerview.widget.LinearSmoothScroller
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject

class CenterSmoothScroller @Inject constructor(
@ApplicationContext context: Context,
) : LinearSmoothScroller(context) {

override fun calculateDtToFit(
viewStart: Int,
viewEnd: Int,
boxStart: Int,
boxEnd: Int,
snapPreference: Int,
): Int = (boxStart + (boxEnd - boxStart) / 2) - (viewStart + (viewEnd - viewStart) / 2)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.emmsale.presentation.common.layoutManager

import android.content.Context
import androidx.recyclerview.widget.LinearSmoothScroller
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject

class EndSmoothScroller @Inject constructor(
@ApplicationContext context: Context,
) : LinearSmoothScroller(context) {

override fun getVerticalSnapPreference(): Int = SNAP_TO_END
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class SubTextInputWindow @JvmOverloads constructor(
init {
applyStyledAttributes(attrs)
addView(binding.root)
isClickable = true
background = context.getColor(R.color.white).toDrawable()
elevation = 5f.dp
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.emmsale.R
import com.emmsale.data.model.Comment
import com.emmsale.databinding.ActivityChildCommentsBinding
import com.emmsale.presentation.base.NetworkActivity
import com.emmsale.presentation.common.extension.hideKeyboard
import com.emmsale.presentation.common.extension.showKeyboard
import com.emmsale.presentation.common.extension.showSnackBar
import com.emmsale.presentation.common.layoutManager.CenterSmoothScroller
import com.emmsale.presentation.common.layoutManager.EndSmoothScroller
import com.emmsale.presentation.common.recyclerView.DividerItemDecoration
import com.emmsale.presentation.common.views.InfoDialog
import com.emmsale.presentation.common.views.WarningDialog
Expand All @@ -24,18 +27,26 @@ import com.emmsale.presentation.ui.childCommentList.ChildCommentsViewModel.Compa
import com.emmsale.presentation.ui.childCommentList.recyclerView.CommentsAdapter
import com.emmsale.presentation.ui.childCommentList.uiState.ChildCommentsUiEvent
import com.emmsale.presentation.ui.feedDetail.FeedDetailActivity
import com.emmsale.presentation.ui.feedDetail.uiState.CommentsUiState
import com.emmsale.presentation.ui.profile.ProfileActivity
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject

@AndroidEntryPoint
class ChildCommentsActivity :
NetworkActivity<ActivityChildCommentsBinding>(R.layout.activity_child_comments) {

override val viewModel: ChildCommentsViewModel by viewModels()

@Inject
lateinit var centerSmoothScroller: CenterSmoothScroller

@Inject
lateinit var endSmoothScroller: EndSmoothScroller

private val commentsAdapter: CommentsAdapter = CommentsAdapter(
onCommentClick = { comment -> viewModel.unhighlight(comment.id) },
onCommentClick = { },
onAuthorImageClick = { authorId -> ProfileActivity.startActivity(this, authorId) },
onCommentMenuClick = ::showCommentMenuDialog,
)
Expand Down Expand Up @@ -63,9 +74,20 @@ class ChildCommentsActivity :

private fun BottomMenuDialog.addCommentUpdateButton(commentId: Long) {
addMenuItemBelow(context.getString(R.string.all_update_button_label)) {
viewModel.setEditMode(true, commentId)
binding.stiwCommentUpdate.requestFocusOnEditText()
showKeyboard()
startToEditComment(commentId)
}
}

private fun startToEditComment(commentId: Long) {
val position = viewModel.startEditComment(commentId) ?: return

lifecycleScope.launch {
delay(KEYBOARD_SHOW_WAITING_TIME)
binding.rvChildcommentsChildcomments
.layoutManager
?.startSmoothScroll(endSmoothScroller.apply { targetPosition = position })
}
}

Expand Down Expand Up @@ -130,12 +152,12 @@ class ChildCommentsActivity :
hideKeyboard()
}
binding.onCommentUpdateCancelButtonClick = {
viewModel.setEditMode(false)
viewModel.cancelEditComment()
hideKeyboard()
}
binding.onUpdatedCommentSubmitButtonClick = {
val commentId = viewModel.editingCommentId.value
if (commentId != null) viewModel.updateComment(commentId, it)
val comment = viewModel.editingComment.value
if (comment != null) viewModel.updateComment(comment.id, it)
hideKeyboard()
}
}
Expand Down Expand Up @@ -172,23 +194,30 @@ class ChildCommentsActivity :

private fun observeComments() {
viewModel.comments.observe(this) {
commentsAdapter.submitList(it.commentUiStates) { scrollToIfFirstFetch(it) }
commentsAdapter.submitList(it.commentUiStates) { handleHighlightComment() }
}
}

private fun scrollToIfFirstFetch(commentUiState: CommentsUiState) {
fun cantScroll(): Boolean =
viewModel.isAlreadyFirstFetched || commentUiState.commentUiStates.isEmpty()
private fun handleHighlightComment() {
if (highlightCommentId == INVALID_COMMENT_ID || isNotRealFirstEnter()) return

if (highlightCommentId == INVALID_COMMENT_ID || cantScroll()) return
val position = viewModel.comments.value.commentUiStates
.indexOfFirst {
it.comment.id == highlightCommentId
}
binding.rvChildcommentsChildcomments.scrollToPosition(position)

viewModel.highlight(highlightCommentId)
viewModel.isAlreadyFirstFetched = true
highlightCommentOnFirstEnter()
}

private fun isNotRealFirstEnter(): Boolean =
viewModel.isAlreadyFirstFetched || viewModel.comments.value.commentUiStates.isEmpty()

private fun highlightCommentOnFirstEnter() {
val position = viewModel.highlightCommentOnFirstEnter(highlightCommentId) ?: return

binding.rvChildcommentsChildcomments.scrollToPosition(position)
lifecycleScope.launch {
delay(100L) // 버그 때문에
binding.rvChildcommentsChildcomments
.layoutManager
?.startSmoothScroll(centerSmoothScroller.apply { targetPosition = position })
}
}

private fun observeUiEvent() {
Expand Down Expand Up @@ -242,6 +271,7 @@ class ChildCommentsActivity :
private const val KEY_HIGHLIGHT_COMMENT_ID = "KEY_HIGHLIGHT_COMMENT_ID"
private const val KEY_FROM_POST_DETAIL = "KEY_FROM_POST_DETAIL"
private const val INVALID_COMMENT_ID: Long = -1
private const val KEYBOARD_SHOW_WAITING_TIME = 300L

fun startActivity(
context: Context,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import com.emmsale.data.model.Comment
import com.emmsale.data.repository.interfaces.CommentRepository
import com.emmsale.data.repository.interfaces.TokenRepository
import com.emmsale.presentation.base.RefreshableViewModel
Expand All @@ -14,6 +16,8 @@ import com.emmsale.presentation.ui.childCommentList.uiState.ChildCommentsUiEvent
import com.emmsale.presentation.ui.feedDetail.uiState.CommentsUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.properties.Delegates.vetoable

Expand All @@ -36,16 +40,16 @@ class ChildCommentsViewModel @Inject constructor(
private val _comments = NotNullMutableLiveData(CommentsUiState())
val comments: NotNullLiveData<CommentsUiState> = _comments

private val _editingCommentId = MutableLiveData<Long?>()
val editingCommentId: LiveData<Long?> = _editingCommentId
private val _editingComment = MutableLiveData<Comment?>()
val editingComment: LiveData<Comment?> = _editingComment

val editingCommentContent: LiveData<String?> = _editingCommentId.map { commentId ->
if (commentId == null) null else _comments.value[commentId]?.comment?.content
}
val isEditingComment: LiveData<Boolean> = _editingComment.map { it != null }

private val _canSubmitComment = NotNullMutableLiveData(true)
val canSubmitComment: NotNullLiveData<Boolean> = _canSubmitComment

private var unhighlightJob: Job? = null

private val _uiEvent = SingleLiveEvent<ChildCommentsUiEvent>()
val uiEvent: LiveData<ChildCommentsUiEvent> = _uiEvent

Expand All @@ -69,7 +73,7 @@ class ChildCommentsViewModel @Inject constructor(

fun updateComment(commentId: Long, content: String): Job = commandAndRefresh(
command = { commentRepository.updateComment(commentId, content) },
onSuccess = { _editingCommentId.value = null },
onSuccess = { _editingComment.value = null },
onFailure = { _, _ -> _uiEvent.value = ChildCommentsUiEvent.CommentUpdateFail },
onStart = { _canSubmitComment.value = false },
onFinish = { _canSubmitComment.value = true },
Expand All @@ -80,8 +84,34 @@ class ChildCommentsViewModel @Inject constructor(
onFailure = { _, _ -> _uiEvent.value = ChildCommentsUiEvent.CommentDeleteFail },
)

fun setEditMode(isEditMode: Boolean, commentId: Long = INVALID_COMMENT_ID) {
_editingCommentId.value = if (isEditMode) commentId else null
/**
* @return 수정할 댓글 위치
*/
fun startEditComment(commentId: Long): Int? {
_editingComment.value = comments.value.commentUiStates
.find { it.comment.id == commentId }
?.comment
?: return null
unhighlightJob?.cancel()
_comments.value = _comments.value.highlight(commentId)
return _comments.value.getPosition(commentId)
}

fun cancelEditComment() {
_editingComment.value = null
_comments.value = _comments.value.unhighlight()
}

/**
* @return 하이라이팅할 댓글 위치
*/
fun highlightCommentOnFirstEnter(commentId: Long): Int? {
_comments.value = _comments.value.highlight(commentId)
unhighlightJob = viewModelScope.launch {
delay(COMMENT_HIGHLIGHTING_DURATION_ON_FIRST_ENTER)
_comments.value = _comments.value.unhighlight()
}
return _comments.value.getPosition(commentId)
}

fun reportComment(commentId: Long): Job = command(
Expand All @@ -106,24 +136,12 @@ class ChildCommentsViewModel @Inject constructor(
onSuccess = { _comments.value = CommentsUiState(uid, it) },
)

fun highlight(commentId: Long) {
val comment = _comments.value.commentUiStates.find { it.comment.id == commentId } ?: return
if (comment.isHighlight) return
_comments.value = _comments.value.highlight(commentId)
}

fun unhighlight(commentId: Long) {
val comment = _comments.value.commentUiStates.find { it.comment.id == commentId } ?: return
if (!comment.isHighlight) return
_comments.value = _comments.value.unhighlight()
}

companion object {
const val KEY_FEED_ID = "KEY_FEED_ID"
const val KEY_PARENT_COMMENT_ID = "KEY_PARENT_COMMENT_ID"

private const val INVALID_COMMENT_ID: Long = -1

private const val REPORT_DUPLICATE_ERROR_CODE = 400

private const val COMMENT_HIGHLIGHTING_DURATION_ON_FIRST_ENTER = 2000L
}
}
Loading
Loading