diff --git a/core/data/src/main/java/com/teamwable/data/mapper/toData/PostCommentModelMapper.kt b/core/data/src/main/java/com/teamwable/data/mapper/toData/PostCommentModelMapper.kt index d22039eb..9d8af670 100644 --- a/core/data/src/main/java/com/teamwable/data/mapper/toData/PostCommentModelMapper.kt +++ b/core/data/src/main/java/com/teamwable/data/mapper/toData/PostCommentModelMapper.kt @@ -2,5 +2,5 @@ package com.teamwable.data.mapper.toData import com.teamwable.network.dto.request.RequestPostCommentDto -internal fun Pair.toPostCommentDto(): RequestPostCommentDto = - RequestPostCommentDto(first, second) +internal fun Triple.toPostCommentDto(): RequestPostCommentDto = + RequestPostCommentDto(commentText = first, parentCommentId = second, parentCommentWriterId = third) diff --git a/core/data/src/main/java/com/teamwable/data/mapper/toModel/ResponseCommentDtoMapper.kt b/core/data/src/main/java/com/teamwable/data/mapper/toModel/ResponseCommentDtoMapper.kt index d80b5317..83a6a2ae 100644 --- a/core/data/src/main/java/com/teamwable/data/mapper/toModel/ResponseCommentDtoMapper.kt +++ b/core/data/src/main/java/com/teamwable/data/mapper/toModel/ResponseCommentDtoMapper.kt @@ -17,5 +17,18 @@ internal fun ResponseCommentDto.toComment(): Comment = this.time, this.memberFanTeam, this.contentId, - this.isBlind ?: false, + this.isBlind, + this.parentCommentId, ) + +internal fun ResponseCommentDto.toComments(): List { + val comments = mutableListOf() + + comments.add(this.toComment()) + + this.childComments?.forEach { child -> + comments.addAll(child.toComments()) + } + + return comments +} diff --git a/core/data/src/main/java/com/teamwable/data/repository/CommentRepository.kt b/core/data/src/main/java/com/teamwable/data/repository/CommentRepository.kt index 20e78dee..da3dc056 100644 --- a/core/data/src/main/java/com/teamwable/data/repository/CommentRepository.kt +++ b/core/data/src/main/java/com/teamwable/data/repository/CommentRepository.kt @@ -12,7 +12,7 @@ interface CommentRepository { suspend fun deleteComment(commentId: Long): Result - suspend fun postComment(contentId: Long, commentText: String): Result + suspend fun postComment(contentId: Long, commentInfo: Triple): Result suspend fun postGhost(request: Ghost): Result diff --git a/core/data/src/main/java/com/teamwable/data/repository/NewsRepository.kt b/core/data/src/main/java/com/teamwable/data/repository/NewsRepository.kt index 2b37457d..1b6eec5f 100644 --- a/core/data/src/main/java/com/teamwable/data/repository/NewsRepository.kt +++ b/core/data/src/main/java/com/teamwable/data/repository/NewsRepository.kt @@ -16,4 +16,6 @@ interface NewsRepository { fun getNewsInfo(): Flow> fun getNoticeInfo(): Flow> + + suspend fun getNumber(): Result> } diff --git a/core/data/src/main/java/com/teamwable/data/repository/UserInfoRepository.kt b/core/data/src/main/java/com/teamwable/data/repository/UserInfoRepository.kt index f1df55bd..da1a67ee 100644 --- a/core/data/src/main/java/com/teamwable/data/repository/UserInfoRepository.kt +++ b/core/data/src/main/java/com/teamwable/data/repository/UserInfoRepository.kt @@ -19,6 +19,10 @@ interface UserInfoRepository { fun getIsAdmin(): Flow + fun getNewsNumber(): Flow + + fun getNoticeNumber(): Flow + suspend fun saveAccessToken(accessToken: String) suspend fun saveRefreshToken(refreshToken: String) @@ -35,6 +39,10 @@ interface UserInfoRepository { suspend fun saveIsAdmin(isAdmin: Boolean) + suspend fun saveNewsNumber(newsNumber: Int) + + suspend fun saveNoticeNumber(noticeNumber: Int) + suspend fun clearAll() suspend fun clearForRefreshToken() diff --git a/core/data/src/main/java/com/teamwable/data/repositoryimpl/DefaultCommentRepository.kt b/core/data/src/main/java/com/teamwable/data/repositoryimpl/DefaultCommentRepository.kt index 84078f8d..f7a8fee2 100644 --- a/core/data/src/main/java/com/teamwable/data/repositoryimpl/DefaultCommentRepository.kt +++ b/core/data/src/main/java/com/teamwable/data/repositoryimpl/DefaultCommentRepository.kt @@ -3,11 +3,13 @@ package com.teamwable.data.repositoryimpl import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData +import androidx.paging.flatMap import androidx.paging.map import com.teamwable.data.mapper.toData.toPostCommentDto import com.teamwable.data.mapper.toData.toPostCommentLikeDto import com.teamwable.data.mapper.toData.toPostGhostDto import com.teamwable.data.mapper.toModel.toComment +import com.teamwable.data.mapper.toModel.toComments import com.teamwable.data.repository.CommentRepository import com.teamwable.model.Comment import com.teamwable.model.Ghost @@ -28,7 +30,7 @@ internal class DefaultCommentRepository @Inject constructor( getNextCursor = { comments -> comments.lastOrNull()?.commentId }, ) }.flow.map { pagingData -> - pagingData.map { it.toComment() } + pagingData.flatMap { it.toComments() } } } @@ -50,9 +52,8 @@ internal class DefaultCommentRepository @Inject constructor( return it.handleThrowable() } - override suspend fun postComment(contentId: Long, commentText: String): Result = runCatching { - val request = Pair(commentText, "comment").toPostCommentDto() - apiService.postComment(contentId, request) + override suspend fun postComment(contentId: Long, commentInfo: Triple): Result = runCatching { + apiService.postComment(contentId, commentInfo.toPostCommentDto()) Unit }.onFailure { return it.handleThrowable() diff --git a/core/data/src/main/java/com/teamwable/data/repositoryimpl/DefaultNewsRepository.kt b/core/data/src/main/java/com/teamwable/data/repositoryimpl/DefaultNewsRepository.kt index f1fb5ace..fddfb4d6 100644 --- a/core/data/src/main/java/com/teamwable/data/repositoryimpl/DefaultNewsRepository.kt +++ b/core/data/src/main/java/com/teamwable/data/repositoryimpl/DefaultNewsRepository.kt @@ -61,4 +61,13 @@ internal class DefaultNewsRepository @Inject constructor( pagingData.map { it.toNoticeInfoModel() } } } + + override suspend fun getNumber(): Result> { + return runCatching { + mapOf( + "news" to newsService.getNumber().data.newsNumber, + "notice" to newsService.getNumber().data.noticeNumber + ) + }.onFailure { return it.handleThrowable() } + } } diff --git a/core/data/src/main/java/com/teamwable/data/repositoryimpl/DefaultUserInfoRepository.kt b/core/data/src/main/java/com/teamwable/data/repositoryimpl/DefaultUserInfoRepository.kt index 9b38325d..bc4a1c57 100644 --- a/core/data/src/main/java/com/teamwable/data/repositoryimpl/DefaultUserInfoRepository.kt +++ b/core/data/src/main/java/com/teamwable/data/repositoryimpl/DefaultUserInfoRepository.kt @@ -32,6 +32,12 @@ internal class DefaultUserInfoRepository @Inject constructor( override fun getIsAdmin(): Flow = wablePreferencesDataSource.isAdmin + override fun getNewsNumber(): Flow = + wablePreferencesDataSource.newsNumber + + override fun getNoticeNumber(): Flow = + wablePreferencesDataSource.noticeNumber + override suspend fun saveAccessToken(accessToken: String) { wablePreferencesDataSource.updateAccessToken(accessToken) } @@ -64,6 +70,14 @@ internal class DefaultUserInfoRepository @Inject constructor( wablePreferencesDataSource.updateIsAdmin(isAdmin) } + override suspend fun saveNewsNumber(newsNumber: Int) { + wablePreferencesDataSource.updateNewsNumber(newsNumber) + } + + override suspend fun saveNoticeNumber(noticeNumber: Int) { + wablePreferencesDataSource.updateNoticeNumber(noticeNumber) + } + override suspend fun clearAll() { wablePreferencesDataSource.clear() } diff --git a/core/datastore/src/main/java/com/teamwable/datastore/datasource/DefaultWablePreferenceDatasource.kt b/core/datastore/src/main/java/com/teamwable/datastore/datasource/DefaultWablePreferenceDatasource.kt index a09a956f..29e37c70 100644 --- a/core/datastore/src/main/java/com/teamwable/datastore/datasource/DefaultWablePreferenceDatasource.kt +++ b/core/datastore/src/main/java/com/teamwable/datastore/datasource/DefaultWablePreferenceDatasource.kt @@ -26,6 +26,8 @@ class DefaultWablePreferenceDatasource @Inject constructor( val MemberProfileUrl = stringPreferencesKey("memberProfileUrl") val IsPushAlarmAllowed = booleanPreferencesKey("isPushAlarmAllowed") val IsAdmin = booleanPreferencesKey("isAdmin") + val NewsNumber = intPreferencesKey("newsNumber") + val NoticeNumber = intPreferencesKey("noticeNumber") } override val accessToken: Flow = dataStore.data @@ -76,6 +78,18 @@ class DefaultWablePreferenceDatasource @Inject constructor( preferences[PreferencesKeys.IsAdmin] ?: false } + override val newsNumber: Flow = dataStore.data + .catch { handleError(it) } + .map { preferences -> + preferences[PreferencesKeys.NewsNumber] ?: -1 + } + + override val noticeNumber: Flow = dataStore.data + .catch { handleError(it) } + .map { preferences -> + preferences[PreferencesKeys.NoticeNumber] ?: -1 + } + override suspend fun updateAccessToken(accessToken: String) { dataStore.edit { preferences -> preferences[PreferencesKeys.AccessToken] = accessToken @@ -124,6 +138,18 @@ class DefaultWablePreferenceDatasource @Inject constructor( } } + override suspend fun updateNewsNumber(newsNumber: Int) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.NewsNumber] = newsNumber + } + } + + override suspend fun updateNoticeNumber(noticeNumber: Int) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.NoticeNumber] = noticeNumber + } + } + override suspend fun clear() { dataStore.edit { preferences -> preferences.clear() diff --git a/core/datastore/src/main/java/com/teamwable/datastore/datasource/WablePreferencesDataSource.kt b/core/datastore/src/main/java/com/teamwable/datastore/datasource/WablePreferencesDataSource.kt index 18801467..e03591e2 100644 --- a/core/datastore/src/main/java/com/teamwable/datastore/datasource/WablePreferencesDataSource.kt +++ b/core/datastore/src/main/java/com/teamwable/datastore/datasource/WablePreferencesDataSource.kt @@ -11,6 +11,8 @@ interface WablePreferencesDataSource { val memberProfileUrl: Flow val isPushAlarmAllowed: Flow val isAdmin: Flow + val newsNumber: Flow + val noticeNumber: Flow suspend fun updateAccessToken(accessToken: String) @@ -28,6 +30,10 @@ interface WablePreferencesDataSource { suspend fun updateIsAdmin(isAdmin: Boolean) + suspend fun updateNewsNumber(newsNumber: Int) + + suspend fun updateNoticeNumber(noticeNumber: Int) + suspend fun clear() suspend fun clearForRefreshToken() diff --git a/core/model/src/main/java/com/teamwable/model/Comment.kt b/core/model/src/main/java/com/teamwable/model/Comment.kt index 65339e78..6c354a65 100644 --- a/core/model/src/main/java/com/teamwable/model/Comment.kt +++ b/core/model/src/main/java/com/teamwable/model/Comment.kt @@ -14,6 +14,7 @@ data class Comment( val postAuthorTeamTag: String, val feedId: Long?, val isBlind: Boolean, + val parentCommentId: Long?, val ghostColor: String = GhostColor.DEFAULT_0, val isAuth: Boolean = false, ) diff --git a/core/network/src/main/java/com/teamwable/network/datasource/CommentService.kt b/core/network/src/main/java/com/teamwable/network/datasource/CommentService.kt index 33f544a1..38a58872 100644 --- a/core/network/src/main/java/com/teamwable/network/datasource/CommentService.kt +++ b/core/network/src/main/java/com/teamwable/network/datasource/CommentService.kt @@ -14,7 +14,7 @@ import retrofit2.http.Path import retrofit2.http.Query interface CommentService { - @GET("api/v2/content/{contentId}/comments") + @GET("api/v3/content/{contentId}/comments") suspend fun getHomeDetailComments( @Path(value = "contentId") contentId: Long, @Query(value = "cursor") cursor: Long = -1, @@ -31,7 +31,7 @@ interface CommentService { @Path(value = "commentId") commentId: Long, ): BaseUnitResponse - @POST("api/v1/content/{contentId}/comment") + @POST("api/v3/content/{contentId}/comment") suspend fun postComment( @Path(value = "contentId") contentId: Long, @Body request: RequestPostCommentDto, diff --git a/core/network/src/main/java/com/teamwable/network/datasource/NewsService.kt b/core/network/src/main/java/com/teamwable/network/datasource/NewsService.kt index 23f0d3d3..f931a7b8 100644 --- a/core/network/src/main/java/com/teamwable/network/datasource/NewsService.kt +++ b/core/network/src/main/java/com/teamwable/network/datasource/NewsService.kt @@ -1,5 +1,6 @@ package com.teamwable.network.datasource +import com.teamwable.network.dto.response.main.ResponseNewsNumberDto import com.teamwable.network.dto.response.news.ResponseGameTypeDto import com.teamwable.network.dto.response.news.ResponseNewsInfoDto import com.teamwable.network.dto.response.news.ResponseNoticeInfoDto @@ -28,4 +29,7 @@ interface NewsService { suspend fun getNoticeInfo( @Query(value = "cursor") contentId: Long = -1, ): BaseResponse> + + @GET("api/v1/information/number") + suspend fun getNumber(): BaseResponse } diff --git a/core/network/src/main/java/com/teamwable/network/datasource/NotificationService.kt b/core/network/src/main/java/com/teamwable/network/datasource/NotificationService.kt index 4fabc048..5ae09a0a 100644 --- a/core/network/src/main/java/com/teamwable/network/datasource/NotificationService.kt +++ b/core/network/src/main/java/com/teamwable/network/datasource/NotificationService.kt @@ -2,7 +2,7 @@ package com.teamwable.network.datasource import com.teamwable.network.dto.response.notification.ResponseInformationDto import com.teamwable.network.dto.response.notification.ResponseNotificationsDto -import com.teamwable.network.dto.response.notification.ResponseNumberDto +import com.teamwable.network.dto.response.notification.ResponseNotificationNumberDto import com.teamwable.network.util.BaseResponse import retrofit2.http.GET import retrofit2.http.PATCH @@ -10,7 +10,7 @@ import retrofit2.http.Query interface NotificationService { @GET("api/v1/notification/number") - suspend fun getNumber(): BaseResponse + suspend fun getNumber(): BaseResponse @PATCH("api/v1/notification-check") suspend fun patchCheck(): BaseResponse diff --git a/core/network/src/main/java/com/teamwable/network/dto/request/RequestPostCommentDto.kt b/core/network/src/main/java/com/teamwable/network/dto/request/RequestPostCommentDto.kt index d5ed8234..212164ca 100644 --- a/core/network/src/main/java/com/teamwable/network/dto/request/RequestPostCommentDto.kt +++ b/core/network/src/main/java/com/teamwable/network/dto/request/RequestPostCommentDto.kt @@ -6,5 +6,6 @@ import kotlinx.serialization.Serializable @Serializable data class RequestPostCommentDto( @SerialName("commentText") val commentText: String, - @SerialName("notificationTriggerType") val notificationTriggerType: String, + @SerialName("parentCommentId") val parentCommentId: Long, + @SerialName("parentCommentWriterId") val parentCommentWriterId: Long, ) diff --git a/core/network/src/main/java/com/teamwable/network/dto/response/ResponseCommentDto.kt b/core/network/src/main/java/com/teamwable/network/dto/response/ResponseCommentDto.kt index 0af3a719..7b02b94f 100644 --- a/core/network/src/main/java/com/teamwable/network/dto/response/ResponseCommentDto.kt +++ b/core/network/src/main/java/com/teamwable/network/dto/response/ResponseCommentDto.kt @@ -19,5 +19,7 @@ data class ResponseCommentDto( @SerialName("commentImageUrl") val commentImageUrl: String? = null, @SerialName("memberFanTeam") val memberFanTeam: String, @SerialName("contentId") val contentId: Long? = null, - @SerialName("isBlind") val isBlind: Boolean? = null, // TODO::임시로 nullable 받음 대댓글 구현후 non-null 변경 예정 + @SerialName("isBlind") val isBlind: Boolean, + @SerialName("parentCommentId") val parentCommentId: Long? = null, // -1 : 부모 댓글, null : profile의 comment, 이외 : 대댓글 + @SerialName("childComments") val childComments: List? = null, // null : 대댓글, [] : 부모 댓글의 대댓글 없음 ) diff --git a/core/network/src/main/java/com/teamwable/network/dto/response/main/ResponseNewsNumberDto.kt b/core/network/src/main/java/com/teamwable/network/dto/response/main/ResponseNewsNumberDto.kt new file mode 100644 index 00000000..7638113c --- /dev/null +++ b/core/network/src/main/java/com/teamwable/network/dto/response/main/ResponseNewsNumberDto.kt @@ -0,0 +1,12 @@ +package com.teamwable.network.dto.response.main + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ResponseNewsNumberDto( + @SerialName("newsNumber") + val newsNumber: Int = 0, + @SerialName("noticeNumber") + val noticeNumber: Int = 0 +) diff --git a/core/network/src/main/java/com/teamwable/network/dto/response/notification/ResponseNumberDto.kt b/core/network/src/main/java/com/teamwable/network/dto/response/notification/ResponseNotificationNumberDto.kt similarity index 84% rename from core/network/src/main/java/com/teamwable/network/dto/response/notification/ResponseNumberDto.kt rename to core/network/src/main/java/com/teamwable/network/dto/response/notification/ResponseNotificationNumberDto.kt index 2c5ebd2b..1e32a4b0 100644 --- a/core/network/src/main/java/com/teamwable/network/dto/response/notification/ResponseNumberDto.kt +++ b/core/network/src/main/java/com/teamwable/network/dto/response/notification/ResponseNotificationNumberDto.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class ResponseNumberDto( +data class ResponseNotificationNumberDto( @SerialName("notificationNumber") val notificationNumber: Int = 0 ) diff --git a/core/ui/src/main/java/com/teamwable/ui/component/Snackbar.kt b/core/ui/src/main/java/com/teamwable/ui/component/Snackbar.kt index 96352dd2..33990766 100644 --- a/core/ui/src/main/java/com/teamwable/ui/component/Snackbar.kt +++ b/core/ui/src/main/java/com/teamwable/ui/component/Snackbar.kt @@ -63,8 +63,8 @@ class Snackbar(private val view: View, private val type: SnackbarType) { }, duration) } - fun updateToCommentComplete() { - initLayout(SnackbarType.COMMENT_COMPLETE) + fun updateToCommentComplete(type: SnackbarType) { + initLayout(type) dismissSnackbar(1000) } diff --git a/core/ui/src/main/java/com/teamwable/ui/extensions/ContextExt.kt b/core/ui/src/main/java/com/teamwable/ui/extensions/ContextExt.kt index 00d6466f..29c10f66 100644 --- a/core/ui/src/main/java/com/teamwable/ui/extensions/ContextExt.kt +++ b/core/ui/src/main/java/com/teamwable/ui/extensions/ContextExt.kt @@ -13,6 +13,7 @@ import android.util.TypedValue import android.view.View import android.view.WindowManager import android.view.inputmethod.InputMethodManager +import android.widget.EditText import android.widget.Toast import androidx.annotation.ColorRes import androidx.annotation.DrawableRes @@ -54,6 +55,12 @@ fun Context.hideKeyboard(view: View) { inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0) } +fun Context.showKeyboard(view: EditText) { + view.requestFocus() + val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) +} + fun Context.openKeyboard(view: View) { val inputMethodManager = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager inputMethodManager.showSoftInput(view, 0) diff --git a/core/ui/src/main/java/com/teamwable/ui/shareAdapter/ChildCommentViewHolder.kt b/core/ui/src/main/java/com/teamwable/ui/shareAdapter/ChildCommentViewHolder.kt new file mode 100644 index 00000000..5edd4512 --- /dev/null +++ b/core/ui/src/main/java/com/teamwable/ui/shareAdapter/ChildCommentViewHolder.kt @@ -0,0 +1,72 @@ +package com.teamwable.ui.shareAdapter + +import android.graphics.Color +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.teamwable.model.Comment +import com.teamwable.ui.databinding.ItemChildCommentBinding +import com.teamwable.ui.extensions.load +import com.teamwable.ui.extensions.visible + +class ChildCommentViewHolder private constructor( + private val binding: ItemChildCommentBinding, + commentClickListener: CommentClickListener, +) : RecyclerView.ViewHolder(binding.root), LikeableViewHolder { + private lateinit var item: Comment + override val likeBtn = binding.btnChildCommentLike + override val likeCountTv = binding.tvChildCommentLikeCount + + init { + setupClickListener(itemView, binding.tvChildCommentContent) { commentClickListener.onItemClick(item.feedId ?: return@setupClickListener) } + setupClickListener(binding.btnChildCommentGhost) { commentClickListener.onGhostBtnClick(item.postAuthorId, item.commentId) } + setupClickListener(binding.btnChildCommentLike) { commentClickListener.onLikeBtnClick(this, item) } + setupClickListener(binding.ivChildCommentProfileImg, binding.tvChildCommentNickname) { commentClickListener.onPostAuthorProfileClick(item.postAuthorId) } + setupClickListener(binding.btnChildCommentMore) { commentClickListener.onKebabBtnClick(item) } + } + + private fun setupClickListener(vararg views: View, action: () -> Unit) { + views.forEach { view -> + view.setOnClickListener { + if (this::item.isInitialized) action() + } + } + } + + fun bind(comment: Comment?) { + item = comment ?: return + with(binding) { + ivChildCommentProfileImg.load(comment.postAuthorProfile) + tvChildCommentNickname.text = comment.postAuthorNickname + tvChildCommentGhostLevel.text = comment.postAuthorGhost + tvChildCommentUploadTime.text = comment.uploadTime + tvChildCommentContent.text = comment.content + btnChildCommentLike.isChecked = comment.isLiked + tvChildCommentLikeCount.text = comment.likedNumber + tvTeamTag.teamName = comment.postAuthorTeamTag + btnChildCommentGhost.isEnabled = !comment.isPostAuthorGhost + viewChildCommentTransparentBg.setBackgroundColor(Color.parseColor(comment.ghostColor)) + btnChildCommentGhost.visible(!comment.isAuth) + spacerChildComment.visible(!comment.isAuth) + setBlindVisible(comment.isBlind) + } + } + + private fun setBlindVisible(isBlind: Boolean) = with(binding) { + tvChildCommentContent.visible(!isBlind) + tvChildCommentBlind.visible(isBlind) + } + + companion object { + fun from(parent: ViewGroup, commentClickListener: CommentClickListener): ChildCommentViewHolder = + ChildCommentViewHolder( + ItemChildCommentBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ), + commentClickListener, + ) + } +} diff --git a/core/ui/src/main/java/com/teamwable/ui/shareAdapter/CommentAdapter.kt b/core/ui/src/main/java/com/teamwable/ui/shareAdapter/CommentAdapter.kt index 75d6696a..06d7b16f 100644 --- a/core/ui/src/main/java/com/teamwable/ui/shareAdapter/CommentAdapter.kt +++ b/core/ui/src/main/java/com/teamwable/ui/shareAdapter/CommentAdapter.kt @@ -1,26 +1,37 @@ package com.teamwable.ui.shareAdapter import android.view.ViewGroup -import androidx.paging.PagingData import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.RecyclerView.ViewHolder import com.teamwable.model.Comment import com.teamwable.ui.extensions.ItemDiffCallback +import com.teamwable.ui.type.CommentType -class CommentAdapter(private val commentClickListener: CommentClickListener) : PagingDataAdapter(commentDiffCallback) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CommentViewHolder = CommentViewHolder.from(parent, commentClickListener) +class CommentAdapter(private val commentClickListener: CommentClickListener) : PagingDataAdapter(commentDiffCallback) { + override fun getItemViewType(position: Int): Int { + return when (getItem(position)?.parentCommentId ?: PARENT_COMMENT_DEFAULT) { + PARENT_COMMENT_DEFAULT -> CommentType.PARENT.id + else -> CommentType.CHILD.id + } + } - override fun onBindViewHolder(holder: CommentViewHolder, position: Int) = holder.bind(getItem(position)) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return when (viewType) { + CommentType.PARENT.id -> CommentViewHolder.from(parent, commentClickListener) + else -> ChildCommentViewHolder.from(parent, commentClickListener) + } + } - suspend fun removeComment(commentId: Long) { - val currentList = snapshot().items.toMutableList() - val indexToRemove = currentList.indexOfFirst { it.commentId == commentId } - if (indexToRemove != -1) { - currentList.removeAt(indexToRemove) - submitData(PagingData.from(currentList)) + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val data = getItem(position) + when (data?.parentCommentId ?: PARENT_COMMENT_DEFAULT) { + PARENT_COMMENT_DEFAULT -> (holder as? CommentViewHolder)?.run { bind(data) } + else -> (holder as? ChildCommentViewHolder)?.run { bind(data) } } } companion object { + const val PARENT_COMMENT_DEFAULT = -1L private val commentDiffCallback = ItemDiffCallback( onItemsTheSame = { old, new -> old.commentId == new.commentId }, onContentsTheSame = { old, new -> old == new }, diff --git a/core/ui/src/main/java/com/teamwable/ui/shareAdapter/CommentClickListener.kt b/core/ui/src/main/java/com/teamwable/ui/shareAdapter/CommentClickListener.kt index e9237e4a..a74e5724 100644 --- a/core/ui/src/main/java/com/teamwable/ui/shareAdapter/CommentClickListener.kt +++ b/core/ui/src/main/java/com/teamwable/ui/shareAdapter/CommentClickListener.kt @@ -1,15 +1,24 @@ package com.teamwable.ui.shareAdapter +import android.widget.CheckBox +import android.widget.TextView import com.teamwable.model.Comment interface CommentClickListener { fun onGhostBtnClick(postAuthorId: Long, commentId: Long) - fun onLikeBtnClick(viewHolder: CommentViewHolder, comment: Comment) + fun onLikeBtnClick(viewHolder: LikeableViewHolder, comment: Comment) fun onPostAuthorProfileClick(id: Long) fun onKebabBtnClick(comment: Comment) fun onItemClick(feedId: Long) + + fun onChildCommentClick(comment: Comment) +} + +interface LikeableViewHolder { + val likeBtn: CheckBox + val likeCountTv: TextView } diff --git a/core/ui/src/main/java/com/teamwable/ui/shareAdapter/CommentViewHolder.kt b/core/ui/src/main/java/com/teamwable/ui/shareAdapter/CommentViewHolder.kt index d8a65209..39106408 100644 --- a/core/ui/src/main/java/com/teamwable/ui/shareAdapter/CommentViewHolder.kt +++ b/core/ui/src/main/java/com/teamwable/ui/shareAdapter/CommentViewHolder.kt @@ -13,10 +13,10 @@ import com.teamwable.ui.extensions.visible class CommentViewHolder private constructor( private val binding: ItemCommentBinding, commentClickListener: CommentClickListener, -) : RecyclerView.ViewHolder(binding.root) { +) : RecyclerView.ViewHolder(binding.root), LikeableViewHolder { private lateinit var item: Comment - val likeBtn = binding.btnCommentLike - val likeCountTv = binding.tvCommentLikeCount + override val likeBtn = binding.btnCommentLike + override val likeCountTv = binding.tvCommentLikeCount init { setupClickListener(itemView, binding.tvCommentContent) { commentClickListener.onItemClick(item.feedId ?: return@setupClickListener) } @@ -24,6 +24,7 @@ class CommentViewHolder private constructor( setupClickListener(binding.btnCommentLike) { commentClickListener.onLikeBtnClick(this, item) } setupClickListener(binding.ivCommentProfileImg, binding.tvCommentNickname) { commentClickListener.onPostAuthorProfileClick(item.postAuthorId) } setupClickListener(binding.btnCommentMore) { commentClickListener.onKebabBtnClick(item) } + setupClickListener(binding.btnCommentWriteChildComment, binding.tvCommentWriteChildCommentLabel) { commentClickListener.onChildCommentClick(item) } } private fun setupClickListener(vararg views: View, action: () -> Unit) { @@ -50,6 +51,7 @@ class CommentViewHolder private constructor( btnCommentGhost.visible(!comment.isAuth) spacerComment.visible(!comment.isAuth) setBlindVisible(comment.isBlind) + groupCommentWriteChildComment.visible(comment.parentCommentId != null) } } diff --git a/core/ui/src/main/java/com/teamwable/ui/shareAdapter/FeedClickListener.kt b/core/ui/src/main/java/com/teamwable/ui/shareAdapter/FeedClickListener.kt index 4203c4c7..c914355f 100644 --- a/core/ui/src/main/java/com/teamwable/ui/shareAdapter/FeedClickListener.kt +++ b/core/ui/src/main/java/com/teamwable/ui/shareAdapter/FeedClickListener.kt @@ -15,5 +15,5 @@ interface FeedClickListener { fun onKebabBtnClick(feed: Feed) - fun onCommentBtnClick(feedId: Long) + fun onCommentBtnClick(postAuthorNickname: String) } diff --git a/core/ui/src/main/java/com/teamwable/ui/shareAdapter/FeedViewHolder.kt b/core/ui/src/main/java/com/teamwable/ui/shareAdapter/FeedViewHolder.kt index 9a3b97ec..21e27aca 100644 --- a/core/ui/src/main/java/com/teamwable/ui/shareAdapter/FeedViewHolder.kt +++ b/core/ui/src/main/java/com/teamwable/ui/shareAdapter/FeedViewHolder.kt @@ -25,6 +25,7 @@ class FeedViewHolder private constructor( setupClickListener(binding.ivFeedProfileImg, binding.tvFeedNickname) { feedClickListener.onPostAuthorProfileClick(item.postAuthorId) } setupClickListener(binding.ivFeedImg) { feedClickListener.onFeedImageClick(item.image) } setupClickListener(binding.btnFeedMore) { feedClickListener.onKebabBtnClick(item) } + setupClickListener(binding.btnFeedComment) { feedClickListener.onCommentBtnClick(item.postAuthorNickname) } } private fun setupClickListener(vararg views: View, action: () -> Unit) { diff --git a/core/ui/src/main/java/com/teamwable/ui/type/CommentType.kt b/core/ui/src/main/java/com/teamwable/ui/type/CommentType.kt new file mode 100644 index 00000000..fba8bfce --- /dev/null +++ b/core/ui/src/main/java/com/teamwable/ui/type/CommentType.kt @@ -0,0 +1,6 @@ +package com.teamwable.ui.type + +enum class CommentType(val id: Int) { + PARENT(0), + CHILD(1), +} diff --git a/core/ui/src/main/java/com/teamwable/ui/type/SnackbarType.kt b/core/ui/src/main/java/com/teamwable/ui/type/SnackbarType.kt index 00c8fcd4..3d589d8a 100644 --- a/core/ui/src/main/java/com/teamwable/ui/type/SnackbarType.kt +++ b/core/ui/src/main/java/com/teamwable/ui/type/SnackbarType.kt @@ -12,6 +12,8 @@ enum class SnackbarType( GHOST(R.string.label_snack_bar_ghost), COMMENT_ING(R.string.label_snack_bar_comment_ing), COMMENT_COMPLETE(R.string.label_snack_bar_comment_complete), + CHILD_COMMENT_ING(R.string.label_snack_bar_child_comment_ing), + CHILD_COMMENT_COMPLETE(R.string.label_snack_bar_child_comment_complete), REPORT(R.string.label_snack_bar_report), BAN(R.string.label_snack_bar_ban), } diff --git a/core/ui/src/main/java/com/teamwable/ui/util/CommentActionHandler.kt b/core/ui/src/main/java/com/teamwable/ui/util/CommentActionHandler.kt index 5287693e..8563674b 100644 --- a/core/ui/src/main/java/com/teamwable/ui/util/CommentActionHandler.kt +++ b/core/ui/src/main/java/com/teamwable/ui/util/CommentActionHandler.kt @@ -12,7 +12,7 @@ import com.teamwable.model.LikeState import com.teamwable.ui.component.BottomSheet import com.teamwable.ui.component.TwoButtonDialog import com.teamwable.ui.component.TwoLabelBottomSheet -import com.teamwable.ui.shareAdapter.CommentViewHolder +import com.teamwable.ui.shareAdapter.LikeableViewHolder import com.teamwable.ui.type.BanTriggerType import com.teamwable.ui.type.BottomSheetType import com.teamwable.ui.type.DialogType @@ -70,7 +70,7 @@ class CommentActionHandler( } } - fun onLikeBtnClick(viewHolder: CommentViewHolder, id: Long, saveLike: (Long, LikeState) -> Unit) { + fun onLikeBtnClick(viewHolder: LikeableViewHolder, id: Long, saveLike: (Long, LikeState) -> Unit) { val likeCount = viewHolder.likeCountTv.text.toString().toInt() val updatedLikeCount = if (viewHolder.likeBtn.isChecked) { trackEvent(CLICK_LIKE_COMMENT) diff --git a/core/ui/src/main/res/anim/spinner_rotate.xml b/core/ui/src/main/res/anim/spinner_rotate.xml index 4de97b34..19058b49 100644 --- a/core/ui/src/main/res/anim/spinner_rotate.xml +++ b/core/ui/src/main/res/anim/spinner_rotate.xml @@ -1,5 +1,6 @@ + + + + + diff --git a/core/ui/src/main/res/layout/item_child_comment.xml b/core/ui/src/main/res/layout/item_child_comment.xml new file mode 100644 index 00000000..9bdaf7a4 --- /dev/null +++ b/core/ui/src/main/res/layout/item_child_comment.xml @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/ui/src/main/res/layout/item_comment.xml b/core/ui/src/main/res/layout/item_comment.xml index 3c8c373c..00c5f94c 100644 --- a/core/ui/src/main/res/layout/item_comment.xml +++ b/core/ui/src/main/res/layout/item_comment.xml @@ -156,6 +156,12 @@ app:layout_constraintStart_toStartOf="@id/tv_comment_ghost_level" app:layout_constraintTop_toBottomOf="@id/tv_comment_content" /> + + (FragmentHomeBinding::i ) } - override fun onCommentBtnClick(feedId: Long) {} + override fun onCommentBtnClick(postAuthorNickname: String) {} } private fun handleProfileNavigation(id: Long) { diff --git a/feature/home/src/main/java/com/teamwable/homedetail/HomeDetailFragment.kt b/feature/home/src/main/java/com/teamwable/homedetail/HomeDetailFragment.kt index c023c210..7004c747 100644 --- a/feature/home/src/main/java/com/teamwable/homedetail/HomeDetailFragment.kt +++ b/feature/home/src/main/java/com/teamwable/homedetail/HomeDetailFragment.kt @@ -13,6 +13,7 @@ import androidx.paging.LoadState import androidx.paging.map import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver import com.teamwable.common.util.AmplitudeHomeTag.CLICK_WRITE_COMMENT import com.teamwable.common.util.AmplitudeUtil.trackEvent import com.teamwable.home.R @@ -27,17 +28,21 @@ import com.teamwable.ui.extensions.colorOf import com.teamwable.ui.extensions.deepLinkNavigateTo import com.teamwable.ui.extensions.hideKeyboard import com.teamwable.ui.extensions.setOnDuplicateBlockClick +import com.teamwable.ui.extensions.showKeyboard import com.teamwable.ui.extensions.stringOf import com.teamwable.ui.extensions.viewLifeCycle import com.teamwable.ui.extensions.viewLifeCycleScope +import com.teamwable.ui.extensions.visible import com.teamwable.ui.shareAdapter.CommentAdapter +import com.teamwable.ui.shareAdapter.CommentAdapter.Companion.PARENT_COMMENT_DEFAULT import com.teamwable.ui.shareAdapter.CommentClickListener -import com.teamwable.ui.shareAdapter.CommentViewHolder import com.teamwable.ui.shareAdapter.FeedAdapter import com.teamwable.ui.shareAdapter.FeedClickListener import com.teamwable.ui.shareAdapter.FeedViewHolder +import com.teamwable.ui.shareAdapter.LikeableViewHolder import com.teamwable.ui.shareAdapter.PagingLoadingAdapter import com.teamwable.ui.type.AlarmTriggerType +import com.teamwable.ui.type.CommentType import com.teamwable.ui.type.DialogType import com.teamwable.ui.type.ProfileUserType import com.teamwable.ui.type.SnackbarType @@ -61,28 +66,36 @@ class HomeDetailFragment : BindingFragment(FragmentHo private val commentAdapter: CommentAdapter by lazy { CommentAdapter(onClickCommentItem()) } private val args: HomeDetailFragmentArgs by navArgs() private val viewModel: HomeDetailViewModel by viewModels() + private lateinit var commentActionHandler: CommentActionHandler private lateinit var feedActionHandler: FeedActionHandler private val singleEventHandler: SingleEventHandler by lazy { SingleEventHandler.from() } + private lateinit var commentAdapterObserver: AdapterDataObserver + private var isCommentNull = true private var totalCommentLength = 0 - private var isCommentAdded = false override fun initView() { commentActionHandler = CommentActionHandler(requireContext(), findNavController(), parentFragmentManager, viewLifecycleOwner) feedActionHandler = FeedActionHandler(requireContext(), findNavController(), parentFragmentManager, viewLifecycleOwner) val commentSnackbar = Snackbar.make(binding.root, SnackbarType.COMMENT_ING) + val childCommentSnackbar = Snackbar.make(binding.root, SnackbarType.CHILD_COMMENT_ING) viewModel.updateHomeDetailToNetwork(args.feedId) - collect(commentSnackbar) + collect(commentSnackbar, childCommentSnackbar) initBackBtnClickListener() } - private fun collect(commentSnackbar: Snackbar) { + override fun onDestroyView() { + super.onDestroyView() + if (::commentAdapterObserver.isInitialized) commentAdapter.unregisterAdapterDataObserver(commentAdapterObserver) + } + + private fun collect(commentSnackbar: Snackbar, childCommentSnackbar: Snackbar) { viewLifeCycleScope.launch { viewModel.uiState.flowWithLifecycle(viewLifeCycle).collect { uiState -> when (uiState) { - is HomeDetailUiState.Success -> setLayout(uiState.feed, commentSnackbar) + is HomeDetailUiState.Success -> setLayout(uiState.feed, commentSnackbar, childCommentSnackbar) is HomeDetailUiState.RemoveFeed -> { saveFeedStateResult(Feed(feedId = uiState.feedId), isRemoved = true) @@ -91,7 +104,7 @@ class HomeDetailFragment : BindingFragment(FragmentHo } is HomeDetailUiState.Error -> (activity as Navigation).navigateToErrorFragment() - is HomeDetailUiState.Loading -> Unit + is HomeDetailUiState.Loading -> binding.pbHomeDetailLoading.visible(true) } } } @@ -100,43 +113,49 @@ class HomeDetailFragment : BindingFragment(FragmentHo viewModel.event.flowWithLifecycle(viewLifeCycle).collect { sideEffect -> when (sideEffect) { is HomeDetailSideEffect.ShowCommentSnackBar -> { - commentSnackbar.updateToCommentComplete() - isCommentAdded = true + commentSnackbar.updateToCommentComplete(SnackbarType.COMMENT_COMPLETE) commentAdapter.refresh() } is HomeDetailSideEffect.ShowSnackBar -> Snackbar.make(binding.root, sideEffect.type).show() + is HomeDetailSideEffect.ShowChildCommentSnackBar -> { + childCommentSnackbar.updateToCommentComplete(SnackbarType.CHILD_COMMENT_COMPLETE) + commentAdapter.refresh() + } + is HomeDetailSideEffect.DismissBottomSheet -> findNavController().popBackStack() } } } } - private fun setLayout(feed: Feed, commentSnackbar: Snackbar) { + private fun setLayout(feed: Feed, commentSnackbar: Snackbar, childCommentSnackbar: Snackbar) { submitFeedList(feed) submitCommentList(feed) + handleCommentLoadingState() concatAdapter() scrollToBottomOnCommentAdded() - initEditTextHint(feed.postAuthorNickname) - initEditTextBtn(feed.feedId, commentSnackbar) + initEditTextHint(feed.postAuthorNickname, CommentType.PARENT) + initEditTextBtn(feed, commentSnackbar, childCommentSnackbar) initRvClickListenerToHideKeyboard() } - private fun initEditTextHint(nickname: String) { - binding.etHomeDetailCommentInput.hint = getString(R.string.hint_home_detail_comment_input, nickname) + private fun initEditTextHint(nickname: String, type: CommentType) = when (type) { + CommentType.PARENT -> binding.etHomeDetailCommentInput.hint = getString(R.string.hint_home_detail_comment_input, nickname) + CommentType.CHILD -> binding.etHomeDetailCommentInput.hint = getString(R.string.hint_home_detail_child_comment_input, nickname) } - private fun initEditTextBtn(contentId: Long, commentSnackbar: Snackbar) { + private fun initEditTextBtn(feed: Feed, commentSnackbar: Snackbar, childCommentSnackbar: Snackbar) { binding.run { etHomeDetailCommentInput.doAfterTextChanged { isCommentNull = etHomeDetailCommentInput.text.isNullOrBlank() totalCommentLength = etHomeDetailCommentInput.text.length - handleUploadBtn(isCommentNull, totalCommentLength, contentId, commentSnackbar) + handleUploadBtn(isCommentNull, totalCommentLength, feed, commentSnackbar, childCommentSnackbar) } } } - private fun handleUploadBtn(isCommentNull: Boolean, totalCommentLength: Int, contentId: Long, commentSnackbar: Snackbar) { + private fun handleUploadBtn(isCommentNull: Boolean, totalCommentLength: Int, feed: Feed, commentSnackbar: Snackbar, childCommentSnackbar: Snackbar) { when { (!isCommentNull && totalCommentLength <= POSTING_MAX) -> { setUploadingBtnSrc( @@ -144,7 +163,7 @@ class HomeDetailFragment : BindingFragment(FragmentHo com.teamwable.common.R.drawable.ic_home_comment_upload_btn_active, ) { binding.ibHomeDetailCommentInputUpload.isEnabled = true - initUploadingActivateBtnClickListener(contentId, commentSnackbar) + initUploadingActivateBtnClickListener(feed, commentSnackbar, childCommentSnackbar) } } @@ -169,13 +188,12 @@ class HomeDetailFragment : BindingFragment(FragmentHo clickListener.invoke() } - private fun initUploadingActivateBtnClickListener(contentId: Long, commentSnackbar: Snackbar) { + private fun initUploadingActivateBtnClickListener(feed: Feed, commentSnackbar: Snackbar, childCommentSnackbar: Snackbar) { binding.ibHomeDetailCommentInputUpload.setOnDuplicateBlockClick { trackEvent(CLICK_WRITE_COMMENT) - viewModel.addComment(contentId, binding.etHomeDetailCommentInput.text.toString()) - commentSnackbar.show() - binding.etHomeDetailCommentInput.text.clear() - requireActivity().hideKeyboard(binding.root) + viewModel.addComment(feed.feedId, binding.etHomeDetailCommentInput.text.toString()) + if (viewModel.parentCommentIds.first == PARENT_COMMENT_DEFAULT) commentSnackbar.show() else childCommentSnackbar.show() + handleCommentBtnClick(feed.postAuthorNickname, CommentType.PARENT) } } @@ -216,11 +234,18 @@ class HomeDetailFragment : BindingFragment(FragmentHo ) } - override fun onCommentBtnClick(feedId: Long) { - binding.etHomeDetailCommentInput.requestFocus() + override fun onCommentBtnClick(postAuthorNickname: String) { + handleCommentBtnClick(postAuthorNickname, CommentType.PARENT) + viewModel.setParentCommentIds(PARENT_COMMENT_DEFAULT, PARENT_COMMENT_DEFAULT) } } + private fun handleCommentBtnClick(nickname: String, commentType: CommentType) { + initEditTextHint(nickname, commentType) + binding.etHomeDetailCommentInput.text.clear() + binding.root.context.showKeyboard(binding.etHomeDetailCommentInput) + } + private fun onClickCommentItem() = object : CommentClickListener { override fun onGhostBtnClick(postAuthorId: Long, commentId: Long) { commentActionHandler.onGhostBtnClick(DialogType.TRANSPARENCY) { @@ -228,7 +253,7 @@ class HomeDetailFragment : BindingFragment(FragmentHo } } - override fun onLikeBtnClick(viewHolder: CommentViewHolder, comment: Comment) { + override fun onLikeBtnClick(viewHolder: LikeableViewHolder, comment: Comment) { commentActionHandler.onLikeBtnClick(viewHolder, comment.commentId) { commentId, likeState -> singleEventHandler.debounce(coroutineScope = lifecycleScope) { if (comment.isLiked != viewHolder.likeBtn.isChecked) viewModel.updateCommentLike(commentId, comment.content, likeState) @@ -246,13 +271,18 @@ class HomeDetailFragment : BindingFragment(FragmentHo fetchUserType = { viewModel.fetchUserType(it) }, removeComment = { viewModel.removeComment(it) }, reportUser = { nickname, content -> viewModel.reportUser(nickname, content) }, - banUser = { trigger, banType -> }, // TODO::대댓글 구현 후 구현 + banUser = { trigger, banType -> viewModel.banUser(Triple(trigger.postAuthorId, banType, trigger.commentId)) }, ) } override fun onItemClick(feedId: Long) { requireActivity().hideKeyboard(binding.root) } + + override fun onChildCommentClick(comment: Comment) { + handleCommentBtnClick(comment.postAuthorNickname, CommentType.CHILD) + viewModel.setParentCommentIds(comment.commentId, comment.postAuthorId) + } } private fun handleProfileNavigation(id: Long) { @@ -291,18 +321,16 @@ class HomeDetailFragment : BindingFragment(FragmentHo } private fun scrollToBottomOnCommentAdded() { - var isFirstLoad = true - - viewLifeCycleScope.launch { - // 답글 아래로 스크롤 - commentAdapter.loadStateFlow.collectLatest { loadStates -> - if (loadStates.source.append is LoadState.NotLoading && isCommentAdded) { - binding.rvHomeDetail.smoothScrollToPosition(commentAdapter.itemCount) - if (loadStates.append.endOfPaginationReached) isCommentAdded = false - } - - if (loadStates.source.refresh is LoadState.NotLoading && !isFirstLoad) isFirstLoad = false + commentAdapterObserver = object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + binding.rvHomeDetail.smoothScrollToPosition(positionStart + 1) } + }.apply { commentAdapter.registerAdapterDataObserver(this) } + } + + private fun handleCommentLoadingState() = viewLifeCycleScope.launch { + commentAdapter.loadStateFlow.collectLatest { loadStates -> + binding.pbHomeDetailLoading.visible(loadStates.refresh is LoadState.Loading) } } diff --git a/feature/home/src/main/java/com/teamwable/homedetail/HomeDetailViewModel.kt b/feature/home/src/main/java/com/teamwable/homedetail/HomeDetailViewModel.kt index ae4f90f5..fdae2dc3 100644 --- a/feature/home/src/main/java/com/teamwable/homedetail/HomeDetailViewModel.kt +++ b/feature/home/src/main/java/com/teamwable/homedetail/HomeDetailViewModel.kt @@ -14,6 +14,7 @@ import com.teamwable.model.Comment import com.teamwable.model.Feed import com.teamwable.model.Ghost import com.teamwable.model.LikeState +import com.teamwable.ui.shareAdapter.CommentAdapter.Companion.PARENT_COMMENT_DEFAULT import com.teamwable.ui.type.ProfileUserType import com.teamwable.ui.type.SnackbarType import dagger.hilt.android.lifecycle.HiltViewModel @@ -47,11 +48,14 @@ class HomeDetailViewModel @Inject constructor( private val removedCommentsFlow = MutableStateFlow(setOf()) private val ghostedFlow = MutableStateFlow(setOf()) private val likeCommentsFlow = MutableStateFlow(mapOf()) - private val banFeedsFlow = MutableStateFlow(setOf()) + private val bannedFlow = MutableStateFlow(setOf()) private var authId: Long = -1 private var isAdmin = false + private var _parentCommentIds = Pair(PARENT_COMMENT_DEFAULT, PARENT_COMMENT_DEFAULT) + val parentCommentIds get() = _parentCommentIds + init { fetchAuthId() fetchIsAdmin() @@ -72,7 +76,7 @@ class HomeDetailViewModel @Inject constructor( fun updateHomeDetailToFlow(feed: Feed): Flow> { val feedFlow = flowOf(PagingData.from(listOf(feed))).cachedIn(viewModelScope) - return combine(feedFlow, ghostedFlow, likeFeedsFlow, banFeedsFlow) { feedsFlow, ghostedUserIds, likeStates, banState -> + return combine(feedFlow, ghostedFlow, likeFeedsFlow, bannedFlow) { feedsFlow, ghostedUserIds, likeStates, banState -> feedsFlow .map { data -> val likeState = likeStates[data.feedId] ?: LikeState(data.isLiked, data.likedNumber) @@ -85,13 +89,14 @@ class HomeDetailViewModel @Inject constructor( fun updateComments(feedId: Long): Flow> { val commentsFlow = commentRepository.getHomeDetailComments(feedId).cachedIn(viewModelScope) - return combine(commentsFlow, removedCommentsFlow, ghostedFlow, likeCommentsFlow) { commentsFlow, removedCommentIds, ghostedUserIds, likeStates -> + return combine(commentsFlow, removedCommentsFlow, ghostedFlow, likeCommentsFlow, bannedFlow) { commentsFlow, removedCommentIds, ghostedUserIds, likeStates, banState -> commentsFlow .filter { removedCommentIds.contains(it.commentId).not() } .map { data -> val likeState = likeStates[data.commentId] ?: LikeState(data.isLiked, data.likedNumber) val transformedGhost = if (ghostedUserIds.contains(data.postAuthorId)) data.copy(isPostAuthorGhost = true) else data - transformedGhost.copy(likedNumber = likeState.count, isLiked = likeState.isLiked) + val transformedBan = if (banState.contains(data.commentId)) transformedGhost.copy(isBlind = true) else transformedGhost + transformedBan.copy(likedNumber = likeState.count, isLiked = likeState.isLiked) } } } @@ -121,12 +126,17 @@ class HomeDetailViewModel @Inject constructor( } } - fun addComment(contentId: Long, commentText: String) { - viewModelScope.launch { - commentRepository.postComment(contentId, commentText) - .onSuccess { _event.emit(HomeDetailSideEffect.ShowCommentSnackBar) } - .onFailure { _uiState.value = HomeDetailUiState.Error(it.message.toString()) } - } + fun addComment(contentId: Long, commentText: String) = viewModelScope.launch { + commentRepository.postComment(contentId, Triple(commentText, _parentCommentIds.first, _parentCommentIds.second)) + .onSuccess { + if (_parentCommentIds.first == PARENT_COMMENT_DEFAULT) { + _event.emit(HomeDetailSideEffect.ShowCommentSnackBar) + } else { + _event.emit(HomeDetailSideEffect.ShowChildCommentSnackBar) + setParentCommentIds(PARENT_COMMENT_DEFAULT, PARENT_COMMENT_DEFAULT) + } + } + .onFailure { _uiState.value = HomeDetailUiState.Error(it.message.toString()) } } fun updateHomeDetailToNetwork(feedId: Long) { @@ -193,11 +203,15 @@ class HomeDetailViewModel @Inject constructor( fun banUser(banInfo: Triple) = viewModelScope.launch { profileRepository.postBan(banInfo) .onSuccess { - banFeedsFlow.value = banFeedsFlow.value.toMutableSet().apply { add(banInfo.third) } + bannedFlow.value = bannedFlow.value.toMutableSet().apply { add(banInfo.third) } _event.emit(HomeDetailSideEffect.ShowSnackBar(SnackbarType.BAN)) } .onFailure { _uiState.value = HomeDetailUiState.Error(it.message.toString()) } } + + fun setParentCommentIds(parentCommentId: Long, parentCommentAuthorId: Long) { + _parentCommentIds = Pair(parentCommentId, parentCommentAuthorId) + } } sealed interface HomeDetailUiState { @@ -215,5 +229,7 @@ sealed interface HomeDetailSideEffect { data object ShowCommentSnackBar : HomeDetailSideEffect + data object ShowChildCommentSnackBar : HomeDetailSideEffect + data object DismissBottomSheet : HomeDetailSideEffect } diff --git a/feature/home/src/main/res/layout/fragment_home_detail.xml b/feature/home/src/main/res/layout/fragment_home_detail.xml index de53fe1c..059265f7 100644 --- a/feature/home/src/main/res/layout/fragment_home_detail.xml +++ b/feature/home/src/main/res/layout/fragment_home_detail.xml @@ -46,6 +46,19 @@ tools:listitem="@layout/item_comment" /> + + diff --git a/feature/home/src/main/res/values/strings.xml b/feature/home/src/main/res/values/strings.xml index 4fc8c4f3..4165f6c1 100644 --- a/feature/home/src/main/res/values/strings.xml +++ b/feature/home/src/main/res/values/strings.xml @@ -8,6 +8,7 @@ 게시글 %s에게 댓글 남기기... + %s에게 답글 남기기... diff --git a/feature/news/src/main/java/com/teamwable/news/NewsFragment.kt b/feature/news/src/main/java/com/teamwable/news/NewsFragment.kt index 7507039e..cdcd7ee5 100644 --- a/feature/news/src/main/java/com/teamwable/news/NewsFragment.kt +++ b/feature/news/src/main/java/com/teamwable/news/NewsFragment.kt @@ -1,7 +1,10 @@ package com.teamwable.news +import androidx.fragment.app.viewModels +import androidx.lifecycle.flowWithLifecycle import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator +import com.teamwable.common.uistate.UiState import com.teamwable.common.util.AmplitudeNewsTag.CLICK_GAMESCHEDULE import com.teamwable.common.util.AmplitudeNewsTag.CLICK_NEWS import com.teamwable.common.util.AmplitudeNewsTag.CLICK_NOTICE @@ -11,16 +14,66 @@ import com.teamwable.news.databinding.FragmentNewsBinding import com.teamwable.ui.base.BindingFragment import com.teamwable.ui.extensions.colorOf import com.teamwable.ui.extensions.stringOf +import com.teamwable.ui.extensions.viewLifeCycle +import com.teamwable.ui.extensions.viewLifeCycleScope import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import timber.log.Timber @AndroidEntryPoint class NewsFragment : BindingFragment(FragmentNewsBinding::inflate) { + private val viewModel: NewsViewModel by viewModels() + + private var serverNewsNumber = -1 + private var serverNoticeNumber = -1 + override fun initView() { initNewsViewPagerAdapter() initTabClickListener() - setBadgeOnNews(NewsTabType.NEWS.idx, true) - setBadgeOnNews(NewsTabType.NOTICE.idx, true) + setupNumberObserve() + } + + private fun setupNumberObserve() { + viewModel.newsNumberUiState.flowWithLifecycle(viewLifeCycle).onEach { state -> + when (state) { + is UiState.Success -> { + serverNewsNumber = getServerNumber(state, "news") + serverNoticeNumber = getServerNumber(state, "notice") + + saveNumberFromServerToLocal() + } + + else -> Unit + } + }.launchIn(viewLifeCycleScope) + } + + private fun getServerNumber(state: UiState.Success>, idx: String) = + state.data[idx]?.takeIf { it >= 0 } ?: 0 + + + private suspend fun saveNumberFromServerToLocal() { +// viewModel.saveNewsNumber(1) +// viewModel.saveNoticeNumber(2) + + val localNewsNumber = viewModel.getNewsNumberFromLocal() + val localNoticeNumber = viewModel.getNoticeNumberFromLocal() + + if (serverNewsNumber > localNewsNumber) { + Timber.tag("here").d("news server: $serverNewsNumber, local: $localNewsNumber") + setBadgeOnNews(NewsTabType.NEWS.ordinal, true) + } else { + Timber.tag("here").d("equal news server: $serverNewsNumber, local: $localNewsNumber") + } + + if (serverNoticeNumber > localNoticeNumber) { + Timber.tag("here").d("notice server: $serverNoticeNumber, local: $localNoticeNumber") + setBadgeOnNews(NewsTabType.NOTICE.ordinal, true) + } else { + Timber.tag("here").d("equal notice server: $serverNoticeNumber, local: $localNoticeNumber") + } } private fun setBadgeOnNews(idx: Int, isVisible: Boolean) { @@ -36,10 +89,10 @@ class NewsFragment : BindingFragment(FragmentNewsBinding::i vpNews.adapter = NewsViewPagerAdapter(this@NewsFragment) TabLayoutMediator(tlNews, vpNews) { tab, position -> when (position) { - NewsTabType.MATCH.idx -> tab.text = stringOf(R.string.tv_news_tab_match) - NewsTabType.RANK.idx -> tab.text = stringOf(R.string.tv_news_tab_rank) - NewsTabType.NEWS.idx -> tab.text = stringOf(R.string.tv_news_tab_news) - NewsTabType.NOTICE.idx -> tab.text = stringOf(R.string.tv_news_tab_notice) + NewsTabType.MATCH.ordinal -> tab.text = stringOf(R.string.tv_news_tab_match) + NewsTabType.RANK.ordinal -> tab.text = stringOf(R.string.tv_news_tab_rank) + NewsTabType.NEWS.ordinal -> tab.text = stringOf(R.string.tv_news_tab_news) + NewsTabType.NOTICE.ordinal -> tab.text = stringOf(R.string.tv_news_tab_notice) } }.attach() } @@ -49,16 +102,18 @@ class NewsFragment : BindingFragment(FragmentNewsBinding::i binding.tlNews.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab?) { when (tab?.position) { - NewsTabType.MATCH.idx -> trackEvent(CLICK_GAMESCHEDULE) - NewsTabType.RANK.idx -> trackEvent(CLICK_RANKING) - NewsTabType.NEWS.idx -> { + NewsTabType.MATCH.ordinal -> trackEvent(CLICK_GAMESCHEDULE) + NewsTabType.RANK.ordinal -> trackEvent(CLICK_RANKING) + NewsTabType.NEWS.ordinal -> { trackEvent(CLICK_NEWS) - setBadgeOnNews(NewsTabType.NEWS.idx, false) + setBadgeOnNews(NewsTabType.NEWS.ordinal, false) + viewModel.saveNewsNumber(serverNewsNumber) } - NewsTabType.NOTICE.idx -> { + NewsTabType.NOTICE.ordinal -> { trackEvent(CLICK_NOTICE) - setBadgeOnNews(NewsTabType.NOTICE.idx, false) + setBadgeOnNews(NewsTabType.NOTICE.ordinal, false) + viewModel.saveNoticeNumber(serverNoticeNumber) } } } diff --git a/feature/news/src/main/java/com/teamwable/news/NewsTabType.kt b/feature/news/src/main/java/com/teamwable/news/NewsTabType.kt index 6677f8e6..88e91cdf 100644 --- a/feature/news/src/main/java/com/teamwable/news/NewsTabType.kt +++ b/feature/news/src/main/java/com/teamwable/news/NewsTabType.kt @@ -2,12 +2,9 @@ package com.teamwable.news import androidx.annotation.StringRes -enum class NewsTabType( - val idx: Int, - @StringRes val title: Int = R.string.tv_news_tab_news, -) { - MATCH(0), - RANK(1), - NEWS(2, title = R.string.tv_news_tab_news), - NOTICE(3, title = R.string.tv_news_tab_notice), +enum class NewsTabType(@StringRes val title: Int = R.string.tv_news_tab_news) { + MATCH, + RANK, + NEWS(title = R.string.tv_news_tab_news), + NOTICE(title = R.string.tv_news_tab_notice), } diff --git a/feature/news/src/main/java/com/teamwable/news/NewsViewModel.kt b/feature/news/src/main/java/com/teamwable/news/NewsViewModel.kt index f408e9a2..1f434005 100644 --- a/feature/news/src/main/java/com/teamwable/news/NewsViewModel.kt +++ b/feature/news/src/main/java/com/teamwable/news/NewsViewModel.kt @@ -4,17 +4,22 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.teamwable.common.uistate.UiState import com.teamwable.data.repository.NewsRepository +import com.teamwable.data.repository.UserInfoRepository import com.teamwable.model.news.NewsMatchModel import com.teamwable.model.news.NewsRankModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class NewsViewModel -@Inject constructor(private val newsRepository: NewsRepository) : ViewModel() { +@Inject constructor( + private val newsRepository: NewsRepository, + private val userInfoRepository: UserInfoRepository +) : ViewModel() { private val _gameTypeUiState = MutableStateFlow>(UiState.Loading) val gameTypeUiState = _gameTypeUiState.asStateFlow() @@ -24,8 +29,12 @@ class NewsViewModel private val _rankUiState = MutableStateFlow>>(UiState.Loading) val rankUiState = _rankUiState.asStateFlow() + private val _newsNumberUiState = MutableStateFlow>>(UiState.Loading) + val newsNumberUiState = _newsNumberUiState.asStateFlow() + init { getGameType() + getNewsNumber() } private fun getGameType() { @@ -51,4 +60,28 @@ class NewsViewModel .onFailure { _rankUiState.value = UiState.Failure(it.message.toString()) } } } + + private fun getNewsNumber() { + viewModelScope.launch { + newsRepository.getNumber() + .onSuccess { _newsNumberUiState.value = UiState.Success(it) } + .onFailure { _newsNumberUiState.value = UiState.Failure(it.message.toString()) } + } + } + + suspend fun getNewsNumberFromLocal() = userInfoRepository.getNewsNumber().first() + + fun saveNewsNumber(newsNumber: Int) { + viewModelScope.launch { + userInfoRepository.saveNewsNumber(newsNumber) + } + } + + suspend fun getNoticeNumberFromLocal() = userInfoRepository.getNoticeNumber().first() + + fun saveNoticeNumber(noticeNumber: Int) { + viewModelScope.launch { + userInfoRepository.saveNoticeNumber(noticeNumber) + } + } } diff --git a/feature/news/src/main/java/com/teamwable/news/NewsViewPagerAdapter.kt b/feature/news/src/main/java/com/teamwable/news/NewsViewPagerAdapter.kt index a13cbdf7..fd6c1b84 100644 --- a/feature/news/src/main/java/com/teamwable/news/NewsViewPagerAdapter.kt +++ b/feature/news/src/main/java/com/teamwable/news/NewsViewPagerAdapter.kt @@ -13,10 +13,10 @@ class NewsViewPagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) override fun createFragment(position: Int): Fragment { return when (position) { - NewsTabType.MATCH.idx -> NewsMatchFragment() - NewsTabType.RANK.idx -> NewsRankFragment() - NewsTabType.NEWS.idx -> NewsNewsFragment() - NewsTabType.NOTICE.idx -> NewsNoticeFragment() + NewsTabType.MATCH.ordinal -> NewsMatchFragment() + NewsTabType.RANK.ordinal -> NewsRankFragment() + NewsTabType.NEWS.ordinal -> NewsNewsFragment() + NewsTabType.NOTICE.ordinal -> NewsNoticeFragment() else -> NewsMatchFragment() } } diff --git a/feature/news/src/main/java/com/teamwable/news/notice/NewsNoticeItem.kt b/feature/news/src/main/java/com/teamwable/news/notice/NewsNoticeItem.kt index b895e199..2a1c0549 100644 --- a/feature/news/src/main/java/com/teamwable/news/notice/NewsNoticeItem.kt +++ b/feature/news/src/main/java/com/teamwable/news/notice/NewsNoticeItem.kt @@ -2,15 +2,19 @@ package com.teamwable.news.notice import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.teamwable.designsystem.theme.WableTheme @@ -29,13 +33,30 @@ fun NewsNoticeItem( .clickable { onItemClick(data) } .padding(vertical = 12.dp, horizontal = 20.dp) ) { - Row { - Text(text = data.newsTitle, style = WableTheme.typography.body01) - Spacer(modifier = Modifier.weight(1f)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = data.newsTitle, + style = WableTheme.typography.body01, + modifier = Modifier + .align(Alignment.CenterVertically) + .weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.width(10.dp)) WableNewsTimeText(data.time) } Spacer(modifier = Modifier.height(2.dp)) - Text(text = data.newsText, color = WableTheme.colors.gray600, maxLines = 2, style = WableTheme.typography.body04) + Text( + text = data.newsText, + color = WableTheme.colors.gray600, + style = WableTheme.typography.body04, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) } } diff --git a/feature/posting/src/main/res/layout/fragment_posting.xml b/feature/posting/src/main/res/layout/fragment_posting.xml index 0fd57df6..0243d364 100644 --- a/feature/posting/src/main/res/layout/fragment_posting.xml +++ b/feature/posting/src/main/res/layout/fragment_posting.xml @@ -78,6 +78,7 @@ android:textAppearance="@style/TextAppearance.Wable.Head1" android:textColor="@color/black" android:textColorHint="@color/gray_700" + android:textCursorDrawable="@drawable/shape_purple_50_cursor" app:layout_constraintTop_toTopOf="parent" tools:text="@string/dummy" /> @@ -125,6 +126,7 @@ android:textColor="@color/gray_800" android:textColorHint="@color/gray_500" android:textColorLink="@color/gray_800" + android:textCursorDrawable="@drawable/shape_purple_50_cursor" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintTop_toBottomOf="@id/iv_posting_photo" tools:text="@string/dummy" /> diff --git a/feature/profile/src/main/java/com/teamwable/profile/profiletabs/ProfileCommentListFragment.kt b/feature/profile/src/main/java/com/teamwable/profile/profiletabs/ProfileCommentListFragment.kt index 8fd34089..b90d4c77 100644 --- a/feature/profile/src/main/java/com/teamwable/profile/profiletabs/ProfileCommentListFragment.kt +++ b/feature/profile/src/main/java/com/teamwable/profile/profiletabs/ProfileCommentListFragment.kt @@ -23,7 +23,7 @@ import com.teamwable.ui.extensions.viewLifeCycleScope import com.teamwable.ui.extensions.visible import com.teamwable.ui.shareAdapter.CommentAdapter import com.teamwable.ui.shareAdapter.CommentClickListener -import com.teamwable.ui.shareAdapter.CommentViewHolder +import com.teamwable.ui.shareAdapter.LikeableViewHolder import com.teamwable.ui.shareAdapter.PagingLoadingAdapter import com.teamwable.ui.type.AlarmTriggerType import com.teamwable.ui.type.DialogType @@ -90,7 +90,7 @@ class ProfileCommentListFragment : BindingFragment singleEventHandler.debounce(coroutineScope = lifecycleScope) { if (comment.isLiked != viewHolder.likeBtn.isChecked) viewModel.updateLike(commentId, comment.content, likeState) @@ -113,6 +113,8 @@ class ProfileCommentListFragment : BindingFragment(Frag ) } - override fun onCommentBtnClick(feedId: Long) {} + override fun onCommentBtnClick(postAuthorNickname: String) {} } private fun setAdapter() {