diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e543b0fa..fabcbf1b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -86,6 +86,7 @@ dependencies { implementation(projects.feature.exercise) implementation(projects.feature.timer) implementation(projects.feature.matching) + implementation(projects.feature.feed) implementation(projects.feature.chatting) implementation(libs.androidx.appcompat) diff --git a/app/src/main/java/com/project200/undabang/main/MainActivity.kt b/app/src/main/java/com/project200/undabang/main/MainActivity.kt index 26307f6b..392c858b 100644 --- a/app/src/main/java/com/project200/undabang/main/MainActivity.kt +++ b/app/src/main/java/com/project200/undabang/main/MainActivity.kt @@ -146,6 +146,8 @@ class MainActivity : AppCompatActivity(), BottomNavigationController { com.project200.undabang.feature.exercise.R.id.exerciseShareEditFragment, com.project200.undabang.feature.matching.R.id.matchingGuideFragment, com.project200.undabang.feature.chatting.R.id.chattingRoomFragment, + com.project200.undabang.feature.feed.R.id.feedFormFragment, + com.project200.undabang.feature.feed.R.id.feedDetailFragment, // ... 필요한 다른 프래그먼트 ID들 추가 ... // ) diff --git a/app/src/main/res/drawable/ic_nav_feed.xml b/app/src/main/res/drawable/ic_nav_feed.xml new file mode 100644 index 00000000..caef363e --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_feed.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/menu/bottom_nav_menu.xml b/app/src/main/res/menu/bottom_nav_menu.xml index 0dd8bf96..56eba78e 100644 --- a/app/src/main/res/menu/bottom_nav_menu.xml +++ b/app/src/main/res/menu/bottom_nav_menu.xml @@ -8,14 +8,18 @@ android:id="@id/matching_nav_graph" android:icon="@drawable/ic_nav_matching" android:title="@string/matching" /> + - + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b8452b34..781d437a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,6 +10,7 @@ 기록 매칭 + 피드 채팅 타이머 MY diff --git a/data/src/main/java/com/project200/data/api/ApiService.kt b/data/src/main/java/com/project200/data/api/ApiService.kt index 2188a5c2..77294b29 100644 --- a/data/src/main/java/com/project200/data/api/ApiService.kt +++ b/data/src/main/java/com/project200/data/api/ApiService.kt @@ -1,11 +1,18 @@ package com.project200.data.api import com.project200.data.dto.BaseResponse +import com.project200.data.dto.CommentDTO +import com.project200.data.dto.CreateCommentRequestDTO +import com.project200.data.dto.CreateCommentResponseDTO +import com.project200.data.dto.CreateFeedRequestDTO import com.project200.data.dto.CustomTimerIdDTO import com.project200.data.dto.DeletePreferredExerciseDTO import com.project200.data.dto.EditExercisePlaceDTO import com.project200.data.dto.ExerciseIdDto import com.project200.data.dto.ExpectedScoreInfoDTO +import com.project200.data.dto.FeedCreateResultDTO +import com.project200.data.dto.FeedDTO +import com.project200.data.dto.FeedPictureUploadDTO import com.project200.data.dto.GetBlockedMemberDTO import com.project200.data.dto.GetChattingMessagesDTO import com.project200.data.dto.GetChattingRoomsDTO @@ -15,6 +22,7 @@ import com.project200.data.dto.GetExerciseCountByRangeDTO import com.project200.data.dto.GetExercisePlaceDTO import com.project200.data.dto.GetExerciseRecordData import com.project200.data.dto.GetExerciseRecordListDto +import com.project200.data.dto.GetFeedsDTO import com.project200.data.dto.GetIsNicknameDuplicated import com.project200.data.dto.GetIsRegisteredData import com.project200.data.dto.GetMatchingMembersDto @@ -27,6 +35,7 @@ import com.project200.data.dto.GetProfileDTO import com.project200.data.dto.GetProfileImageResponseDto import com.project200.data.dto.GetScoreDTO import com.project200.data.dto.GetSimpleTimersDTO +import com.project200.data.dto.LikeRequestDTO import com.project200.data.dto.NotificationStateDTO import com.project200.data.dto.PatchCustomTimerTitleRequest import com.project200.data.dto.PatchExerciseRequestDto @@ -46,6 +55,7 @@ import com.project200.data.dto.PostSignUpRequest import com.project200.data.dto.PutProfileRequest import com.project200.data.dto.SimpleTimerIdDTO import com.project200.data.dto.SimpleTimerRequest +import com.project200.data.dto.UpdateFeedRequestDTO import com.project200.data.utils.AccessTokenApi import com.project200.data.utils.AccessTokenWithFcmApi import com.project200.data.utils.IdTokenApi @@ -474,4 +484,94 @@ interface ApiService { @Header("X-Fcm-Token") fcmToken: String, @Body notiRequest: List, ): BaseResponse + + /** 피드 */ + @GET("api/v1/feeds") + @AccessTokenApi + suspend fun getFeeds( + @Query("prevFeedId") prevFeedId: Long?, + @Query("size") size: Int?, + ): BaseResponse + + @GET("api/v1/feeds/{feedId}") + @AccessTokenApi + suspend fun getFeedDetail( + @Path("feedId") feedId: Long, + ): BaseResponse + + @POST("api/v1/feeds") + @AccessTokenApi + suspend fun postFeed( + @Body createFeedRequest: CreateFeedRequestDTO, + ): BaseResponse + + @DELETE("api/v1/feeds/{feedId}") + @AccessTokenApi + suspend fun deleteFeed( + @Path("feedId") feedId: Long, + ): BaseResponse + + @PATCH("api/v1/feeds/{feedId}") + @AccessTokenApi + suspend fun updateFeed( + @Path("feedId") feedId: Long, + @Body updateFeedRequest: UpdateFeedRequestDTO, + ): BaseResponse + + // 피드 좋아요 + @POST("api/v1/feeds/{feedId}/like") + @AccessTokenApi + suspend fun likeFeed( + @Path("feedId") feedId: Long, + @Body request: LikeRequestDTO, + ): BaseResponse + + /** 댓글 */ + // 댓글 목록 조회 + @GET("api/v1/feeds/{feedId}/comments") + @AccessTokenApi + suspend fun getComments( + @Path("feedId") feedId: Long, + ): BaseResponse> + + // 댓글 작성 + @POST("api/v1/feeds/{feedId}/comments") + @AccessTokenApi + suspend fun createComment( + @Path("feedId") feedId: Long, + @Body request: CreateCommentRequestDTO, + ): BaseResponse + + // 댓글 좋아요 + @POST("api/v1/comments/{commentId}/like") + @AccessTokenApi + suspend fun likeComment( + @Path("commentId") commentId: Long, + @Body request: LikeRequestDTO, + ): BaseResponse + + // 댓글 삭제 + @DELETE("api/v1/comments/{commentId}") + @AccessTokenApi + suspend fun deleteComment( + @Path("commentId") commentId: Long, + ): BaseResponse + + /** 피드 이미지 */ + // 피드 이미지 업로드 + @Multipart + @POST("api/v1/feeds/{feedId}/pictures") + @AccessTokenApi + suspend fun postFeedImages( + @Path("feedId") feedId: Long, + @Part pictures: List, + ): BaseResponse> + + // 피드 이미지 삭제 + @DELETE("api/v1/feeds/{feedId}/pictures/{pictureId}") + @AccessTokenApi + suspend fun deleteFeedImage( + @Path("feedId") feedId: Long, + @Path("pictureId") pictureId: Long, + ): BaseResponse } diff --git a/data/src/main/java/com/project200/data/di/RepositoryModule.kt b/data/src/main/java/com/project200/data/di/RepositoryModule.kt index e5d411a8..09365e31 100644 --- a/data/src/main/java/com/project200/data/di/RepositoryModule.kt +++ b/data/src/main/java/com/project200/data/di/RepositoryModule.kt @@ -7,6 +7,7 @@ import com.project200.data.impl.ChatSocketRepositoryImpl import com.project200.data.impl.ChattingRepositoryImpl import com.project200.data.impl.ExerciseRecordRepositoryImpl import com.project200.data.impl.FcmRepositoryImpl +import com.project200.data.impl.FeedRepositoryImpl import com.project200.data.impl.MatchingRepositoryImpl import com.project200.data.impl.MemberRepositoryImpl import com.project200.data.impl.NotificationRepositoryImpl @@ -20,6 +21,7 @@ import com.project200.domain.repository.ChatSocketRepository import com.project200.domain.repository.ChattingRepository import com.project200.domain.repository.ExerciseRecordRepository import com.project200.domain.repository.FcmRepository +import com.project200.domain.repository.FeedRepository import com.project200.domain.repository.MatchingRepository import com.project200.domain.repository.MemberRepository import com.project200.domain.repository.NotificationRepository @@ -86,4 +88,8 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindNotificationRepository(notificationRepositoryImpl: NotificationRepositoryImpl): NotificationRepository + + @Binds + @Singleton + abstract fun bindFeedRepository(feedRepositoryImpl: FeedRepositoryImpl): FeedRepository } diff --git a/data/src/main/java/com/project200/data/dto/CommentDTO.kt b/data/src/main/java/com/project200/data/dto/CommentDTO.kt new file mode 100644 index 00000000..363e5b50 --- /dev/null +++ b/data/src/main/java/com/project200/data/dto/CommentDTO.kt @@ -0,0 +1,56 @@ +package com.project200.data.dto + +import com.squareup.moshi.JsonClass +import java.time.LocalDateTime + +@JsonClass(generateAdapter = true) +data class GetCommentsDTO( + val comments: List, +) + +@JsonClass(generateAdapter = true) +data class CommentDTO( + val commentId: Long, + val memberId: String, + val memberNickname: String, + val memberProfileImageUrl: String?, + val memberThumbnailUrl: String?, + val content: String, + val likesCount: Int, + val taggedMember: TaggedMemberDTO, + val commentIsLiked: Boolean = false, + val createdAt: LocalDateTime, + val children: List, +) + +@JsonClass(generateAdapter = true) +data class TaggedMemberDTO( + val memberId: String?, + val memberNickname: String?, +) + +@JsonClass(generateAdapter = true) +data class ReplyDTO( + val commentId: Long, + val memberId: String, + val memberNickname: String, + val memberProfileImageUrl: String?, + val memberThumbnailUrl: String?, + val content: String, + val likesCount: Int, + val commentIsLiked: Boolean = false, + val createdAt: LocalDateTime, + val taggedMember: TaggedMemberDTO? = null, +) + +@JsonClass(generateAdapter = true) +data class CreateCommentRequestDTO( + val content: String, + val parentCommentId: Long?, + val taggedMemberId: String? = null, +) + +@JsonClass(generateAdapter = true) +data class CreateCommentResponseDTO( + val commentId: Long, +) diff --git a/data/src/main/java/com/project200/data/dto/FeedDTO.kt b/data/src/main/java/com/project200/data/dto/FeedDTO.kt new file mode 100644 index 00000000..38d7645b --- /dev/null +++ b/data/src/main/java/com/project200/data/dto/FeedDTO.kt @@ -0,0 +1,63 @@ +package com.project200.data.dto + +import com.squareup.moshi.JsonClass +import java.time.LocalDateTime + +@JsonClass(generateAdapter = true) +data class GetFeedsDTO( + val hasNext: Boolean, + val feeds: List, +) + +@JsonClass(generateAdapter = true) +data class FeedDTO( + val feedId: Long, + val feedContent: String, + val feedLikesCount: Int, + val feedCommentsCount: Int, + val feedTypeId: Long?, + val feedTypeName: String?, + val feedTypeDesc: String?, + val feedCreatedAt: LocalDateTime, + val feedIsLiked: Boolean, + val feedHasCommented: Boolean, + val memberId: String, + val nickname: String, + val profileUrl: String?, + val thumbnailUrl: String?, + val feedPictures: List, +) + +@JsonClass(generateAdapter = true) +data class FeedPictureDTO( + val feedPictureId: Long, + val feedPictureUrl: String, +) + +@JsonClass(generateAdapter = true) +data class CreateFeedRequestDTO( + val feedContent: String, + val feedTypeId: Long?, +) + +@JsonClass(generateAdapter = true) +data class FeedCreateResultDTO( + val feedId: Long, +) + +@JsonClass(generateAdapter = true) +data class UpdateFeedRequestDTO( + val feedContent: String, + val feedTypeId: Long?, +) + +@JsonClass(generateAdapter = true) +data class FeedPictureUploadDTO( + val pictureId: Long, + val pictureUrl: String, +) + +@JsonClass(generateAdapter = true) +data class LikeRequestDTO( + val liked: Boolean, +) diff --git a/data/src/main/java/com/project200/data/impl/ChatSocketRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/ChatSocketRepositoryImpl.kt index c485bebd..a0469356 100644 --- a/data/src/main/java/com/project200/data/impl/ChatSocketRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/ChatSocketRepositoryImpl.kt @@ -69,6 +69,15 @@ class ChatSocketRepositoryImpl private var heartbeatJob: Job? = null private var retryJob: Job? = null + // [MEASURE] 측정용 변수 + private val connectionAttemptCount = AtomicInteger(0) + private val connectionSuccessCount = AtomicInteger(0) + private val connectionFailCount = AtomicInteger(0) + private val reconnectCount = AtomicInteger(0) + private val reconnectSuccessCount = AtomicInteger(0) + private var lastDisconnectTime: Long = 0L + private var totalDowntimeMs: Long = 0L + // 네트워크 복구 감지 init { CoroutineScope(Dispatchers.IO).launch { @@ -94,6 +103,25 @@ class ChatSocketRepositoryImpl * 채팅방 소켓 연결 해제 */ override fun disconnect() { + // [MEASURE] 세션 통계 출력 + Timber.w("[MEASURE] === 세션 통계 ===") + Timber.w("[MEASURE] 연결 시도: ${connectionAttemptCount.get()}") + Timber.w("[MEASURE] 연결 성공: ${connectionSuccessCount.get()}") + Timber.w("[MEASURE] 연결 실패: ${connectionFailCount.get()}") + Timber.w("[MEASURE] 연결 끊김(재연결 시도): ${reconnectCount.get()}") + Timber.w("[MEASURE] 재연결 성공: ${reconnectSuccessCount.get()}") + Timber.w("[MEASURE] 총 다운타임: ${totalDowntimeMs}ms") + if (connectionAttemptCount.get() > 0) { + Timber.w("[MEASURE] 연결 성공률: ${connectionSuccessCount.get() * 100 / connectionAttemptCount.get()}%") + } + if (reconnectCount.get() > 0) { + Timber.w("[MEASURE] 재연결 성공률: ${reconnectSuccessCount.get() * 100 / reconnectCount.get()}%") + Timber.w( + "[MEASURE] 평균 복구 시간: ${if (reconnectSuccessCount.get() > 0) totalDowntimeMs / reconnectSuccessCount.get() else 0}ms", + ) + } + Timber.w("[MEASURE] ================") + isUserInChatRoom = false stopHeartbeat() retryJob?.cancel() @@ -122,6 +150,8 @@ class ChatSocketRepositoryImpl Timber.d("WebSocket already connected.") return@withLock } + connectionAttemptCount.incrementAndGet() + Timber.w("[MEASURE] 연결 시도 #${connectionAttemptCount.get()}") try { // 티켓 발급 val response = chatApi.getChatTicket(chatRoomId) @@ -134,11 +164,14 @@ class ChatSocketRepositoryImpl if (BuildConfig.DEBUG) { "$BASE_URL_DEBUG$ticket" } else { - "$BASE_URL_RELEASE$ticket" + // "$BASE_URL_RELEASE$ticket" + "$BASE_URL_DEBUG$ticket" } val request = Request.Builder().url(wsUrl).build() webSocket = okHttpClient.newWebSocket(request, socketListener) } catch (e: Exception) { + connectionFailCount.incrementAndGet() + Timber.w("[MEASURE] 연결 실패 #${connectionFailCount.get()}: ${e.message}") handleConnectionFailure(e) } } @@ -154,6 +187,18 @@ class ChatSocketRepositoryImpl webSocket: WebSocket, response: Response, ) { + connectionSuccessCount.incrementAndGet() + if (lastDisconnectTime > 0L) { + val downtimeMs = System.currentTimeMillis() - lastDisconnectTime + totalDowntimeMs += downtimeMs + reconnectSuccessCount.incrementAndGet() + Timber.w( + "[MEASURE] 재연결 성공! 복구 소요시간: ${downtimeMs}ms, " + + "재연결 성공: ${reconnectSuccessCount.get()}/${reconnectCount.get()}", + ) + lastDisconnectTime = 0L + } + Timber.w("[MEASURE] 연결 성공 (${connectionSuccessCount.get()}/${connectionAttemptCount.get()})") retryCount.set(0) startApplicationHeartbeat() } @@ -233,6 +278,9 @@ class ChatSocketRepositoryImpl } private fun cleanupAndRetry(t: Throwable) { + reconnectCount.incrementAndGet() + lastDisconnectTime = System.currentTimeMillis() + Timber.w("[MEASURE] 연결 끊김 #${reconnectCount.get()}: ${t.message}") stopHeartbeat() webSocket = null handleConnectionFailure(t) diff --git a/data/src/main/java/com/project200/data/impl/FeedRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/FeedRepositoryImpl.kt new file mode 100644 index 00000000..69ff49ab --- /dev/null +++ b/data/src/main/java/com/project200/data/impl/FeedRepositoryImpl.kt @@ -0,0 +1,201 @@ +package com.project200.data.impl + +import android.content.Context +import androidx.core.net.toUri +import com.project200.common.di.IoDispatcher +import com.project200.data.api.ApiService +import com.project200.data.dto.CommentDTO +import com.project200.data.dto.CreateCommentRequestDTO +import com.project200.data.dto.CreateCommentResponseDTO +import com.project200.data.dto.FeedCreateResultDTO +import com.project200.data.dto.FeedDTO +import com.project200.data.dto.FeedPictureUploadDTO +import com.project200.data.dto.GetFeedsDTO +import com.project200.data.dto.LikeRequestDTO +import com.project200.data.mapper.toDTO +import com.project200.data.mapper.toModel +import com.project200.data.mapper.toMultipartBodyPart +import com.project200.data.utils.apiCallBuilder +import com.project200.domain.model.BaseResult +import com.project200.domain.model.Comment +import com.project200.domain.model.CreateCommentResult +import com.project200.domain.model.CreateFeedModel +import com.project200.domain.model.Feed +import com.project200.domain.model.FeedCreateResult +import com.project200.domain.model.FeedListResult +import com.project200.domain.model.FeedPicture +import com.project200.domain.model.UpdateFeedModel +import com.project200.domain.repository.FeedRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import timber.log.Timber +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +class FeedRepositoryImpl + @Inject + constructor( + private val apiService: ApiService, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + @ApplicationContext private val context: Context, + ) : FeedRepository { + override suspend fun getFeeds( + prevFeedId: Long?, + size: Int?, + ): BaseResult { + return apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { apiService.getFeeds(prevFeedId, size) }, + mapper = { dto: GetFeedsDTO? -> + dto?.toModel() ?: FeedListResult(hasNext = false, feeds = emptyList()) + }, + ) + } + + override suspend fun getFeedDetail(feedId: Long): BaseResult { + return apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { apiService.getFeedDetail(feedId) }, + mapper = { dto: FeedDTO? -> + dto?.toModel() ?: throw NoSuchElementException("피드 상세 데이터가 없습니다.") + }, + ) + } + + override suspend fun createFeed(createFeedModel: CreateFeedModel): BaseResult { + return apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { apiService.postFeed(createFeedModel.toDTO()) }, + mapper = { dto: FeedCreateResultDTO? -> + dto?.toModel() ?: throw NoSuchElementException("피드 생성 결과 데이터가 없습니다.") + }, + ) + } + + override suspend fun deleteFeed(feedId: Long): BaseResult { + return apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { apiService.deleteFeed(feedId) }, + mapper = { Unit }, + ) + } + + override suspend fun getComments(feedId: Long): BaseResult> { + return apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { apiService.getComments(feedId) }, + mapper = { dtoList: List? -> + dtoList?.map { it.toModel() } ?: emptyList() + }, + ) + } + + override suspend fun createComment( + feedId: Long, + content: String, + parentCommentId: Long?, + taggedMemberId: String?, + ): BaseResult { + return apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { + apiService.createComment( + feedId = feedId, + request = + CreateCommentRequestDTO( + content = content, + parentCommentId = parentCommentId, + taggedMemberId = taggedMemberId, + ), + ) + }, + mapper = { dto: CreateCommentResponseDTO? -> + dto?.toModel() ?: throw NoSuchElementException("댓글 생성 결과 데이터가 없습니다.") + }, + ) + } + + override suspend fun likeComment( + commentId: Long, + liked: Boolean, + ): BaseResult { + return apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { apiService.likeComment(commentId, LikeRequestDTO(liked)) }, + mapper = { Unit }, + ) + } + + override suspend fun deleteComment(commentId: Long): BaseResult { + return apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { apiService.deleteComment(commentId) }, + mapper = { Unit }, + ) + } + + override suspend fun likeFeed( + feedId: Long, + liked: Boolean, + ): BaseResult { + return apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { apiService.likeFeed(feedId, LikeRequestDTO(liked)) }, + mapper = { Unit }, + ) + } + + override suspend fun updateFeed(updateFeedModel: UpdateFeedModel): BaseResult { + return apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { apiService.updateFeed(updateFeedModel.feedId, updateFeedModel.toDTO()) }, + mapper = { Unit }, + ) + } + + override suspend fun uploadFeedImages( + feedId: Long, + imageUris: List, + ): BaseResult> { + val uris = + imageUris.mapNotNull { + try { + it.toUri() + } catch (e: CancellationException) { + Timber.w(e, "CancellationException: $it") + throw e + } catch (e: Exception) { + Timber.w(e, "Invalid URI string: $it") + null + } + } + + val imageParts = + uris.mapNotNull { uri -> + uri.toMultipartBodyPart(context, "pictures") + } + + if (imageParts.isEmpty() && uris.isNotEmpty()) { + return BaseResult.Error("CONVERSION_FAILED", "이미지 파일 변환에 실패했습니다.") + } + + return apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { apiService.postFeedImages(feedId, imageParts) }, + mapper = { dtoList: List? -> + dtoList?.map { it.toModel() } ?: emptyList() + }, + ) + } + + override suspend fun deleteFeedImage( + feedId: Long, + imageId: Long, + ): BaseResult { + return apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { apiService.deleteFeedImage(feedId, imageId) }, + mapper = { Unit }, + ) + } + } diff --git a/data/src/main/java/com/project200/data/mapper/CommentMapper.kt b/data/src/main/java/com/project200/data/mapper/CommentMapper.kt new file mode 100644 index 00000000..48641fa9 --- /dev/null +++ b/data/src/main/java/com/project200/data/mapper/CommentMapper.kt @@ -0,0 +1,54 @@ +package com.project200.data.mapper + +import com.project200.data.dto.CommentDTO +import com.project200.data.dto.CreateCommentResponseDTO +import com.project200.data.dto.ReplyDTO +import com.project200.data.dto.TaggedMemberDTO +import com.project200.domain.model.Comment +import com.project200.domain.model.CreateCommentResult +import com.project200.domain.model.Reply +import com.project200.domain.model.TaggedMember + +fun CommentDTO.toModel(): Comment { + return Comment( + commentId = commentId, + memberId = memberId, + memberNickname = memberNickname, + memberProfileImageUrl = memberProfileImageUrl, + memberThumbnailUrl = memberThumbnailUrl, + content = content, + likesCount = likesCount, + isLiked = commentIsLiked, + createdAt = createdAt, + children = children.map { it.toModel() }, + ) +} + +fun ReplyDTO.toModel(): Reply { + return Reply( + commentId = commentId, + memberId = memberId, + memberNickname = memberNickname, + memberProfileImageUrl = memberProfileImageUrl, + memberThumbnailUrl = memberThumbnailUrl, + content = content, + likesCount = likesCount, + isLiked = commentIsLiked, + createdAt = createdAt, + taggedMember = taggedMember?.toModel(), + ) +} + +fun TaggedMemberDTO.toModel(): TaggedMember? { + if (memberId == null || memberNickname == null) return null + return TaggedMember( + memberId = memberId, + memberNickname = memberNickname, + ) +} + +fun CreateCommentResponseDTO.toModel(): CreateCommentResult { + return CreateCommentResult( + commentId = commentId, + ) +} diff --git a/data/src/main/java/com/project200/data/mapper/FeedMapper.kt b/data/src/main/java/com/project200/data/mapper/FeedMapper.kt new file mode 100644 index 00000000..fd00d51d --- /dev/null +++ b/data/src/main/java/com/project200/data/mapper/FeedMapper.kt @@ -0,0 +1,76 @@ +package com.project200.data.mapper + +import com.project200.data.dto.CreateFeedRequestDTO +import com.project200.data.dto.FeedCreateResultDTO +import com.project200.data.dto.FeedDTO +import com.project200.data.dto.FeedPictureDTO +import com.project200.data.dto.FeedPictureUploadDTO +import com.project200.data.dto.GetFeedsDTO +import com.project200.data.dto.UpdateFeedRequestDTO +import com.project200.domain.model.CreateFeedModel +import com.project200.domain.model.Feed +import com.project200.domain.model.FeedCreateResult +import com.project200.domain.model.FeedListResult +import com.project200.domain.model.FeedPicture +import com.project200.domain.model.UpdateFeedModel + +fun GetFeedsDTO.toModel(): FeedListResult { + return FeedListResult( + hasNext = hasNext, + feeds = feeds.map { it.toModel() }, + ) +} + +fun FeedDTO.toModel(): Feed { + return Feed( + feedId = feedId, + feedContent = feedContent, + feedLikesCount = feedLikesCount, + feedCommentsCount = feedCommentsCount, + feedTypeId = feedTypeId, + feedTypeName = feedTypeName, + feedTypeDesc = feedTypeDesc, + feedCreatedAt = feedCreatedAt, + feedIsLiked = feedIsLiked, + feedHasCommented = feedHasCommented, + memberId = memberId, + nickname = nickname, + profileUrl = profileUrl, + thumbnailUrl = thumbnailUrl, + feedPictures = feedPictures.map { it.toModel() }, + ) +} + +fun FeedPictureDTO.toModel(): FeedPicture { + return FeedPicture( + feedPictureId = feedPictureId, + feedPictureUrl = feedPictureUrl, + ) +} + +fun CreateFeedModel.toDTO(): CreateFeedRequestDTO { + return CreateFeedRequestDTO( + feedContent = feedContent, + feedTypeId = feedTypeId, + ) +} + +fun FeedCreateResultDTO.toModel(): FeedCreateResult { + return FeedCreateResult( + feedId = feedId, + ) +} + +fun UpdateFeedModel.toDTO(): UpdateFeedRequestDTO { + return UpdateFeedRequestDTO( + feedContent = feedContent, + feedTypeId = feedTypeId, + ) +} + +fun FeedPictureUploadDTO.toModel(): FeedPicture { + return FeedPicture( + feedPictureId = pictureId, + feedPictureUrl = pictureUrl, + ) +} diff --git a/domain/src/main/java/com/project200/domain/model/FeedModel.kt b/domain/src/main/java/com/project200/domain/model/FeedModel.kt new file mode 100644 index 00000000..204f766f --- /dev/null +++ b/domain/src/main/java/com/project200/domain/model/FeedModel.kt @@ -0,0 +1,81 @@ +package com.project200.domain.model + +import java.time.LocalDateTime + +data class FeedListResult( + val hasNext: Boolean, + val feeds: List, +) + +data class Feed( + val feedId: Long, + val feedContent: String, + val feedLikesCount: Int, + val feedCommentsCount: Int, + val feedTypeId: Long?, + val feedTypeName: String?, + val feedTypeDesc: String?, + val feedCreatedAt: LocalDateTime, + val feedIsLiked: Boolean, + val feedHasCommented: Boolean, + val memberId: String, + val nickname: String, + val profileUrl: String?, + val thumbnailUrl: String?, + val feedPictures: List, +) + +data class FeedPicture( + val feedPictureId: Long, + val feedPictureUrl: String, +) + +data class CreateFeedModel( + val feedContent: String, + val feedTypeId: Long?, +) + +data class UpdateFeedModel( + val feedId: Long, + val feedContent: String, + val feedTypeId: Long?, +) + +data class FeedCreateResult( + val feedId: Long, +) + +data class Comment( + val commentId: Long, + val memberId: String, + val memberNickname: String, + val memberProfileImageUrl: String?, + val memberThumbnailUrl: String?, + val content: String, + val likesCount: Int, + val isLiked: Boolean, + val createdAt: LocalDateTime, + val children: List, +) + +data class TaggedMember( + val memberId: String?, + val memberNickname: String?, +) + +data class Reply( + val commentId: Long, + val memberId: String, + val memberNickname: String, + val memberProfileImageUrl: String?, + val memberThumbnailUrl: String?, + val content: String, + val likesCount: Int, + val isLiked: Boolean, + val createdAt: LocalDateTime, + val taggedMember: TaggedMember? = null, +) + +data class CreateCommentResult( + val commentId: Long, +) diff --git a/domain/src/main/java/com/project200/domain/repository/FeedRepository.kt b/domain/src/main/java/com/project200/domain/repository/FeedRepository.kt new file mode 100644 index 00000000..6442b551 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/repository/FeedRepository.kt @@ -0,0 +1,37 @@ +package com.project200.domain.repository + +import com.project200.domain.model.BaseResult +import com.project200.domain.model.Comment +import com.project200.domain.model.CreateCommentResult +import com.project200.domain.model.CreateFeedModel +import com.project200.domain.model.Feed +import com.project200.domain.model.FeedCreateResult +import com.project200.domain.model.FeedPicture +import com.project200.domain.model.FeedListResult +import com.project200.domain.model.UpdateFeedModel + +interface FeedRepository { + suspend fun getFeeds(prevFeedId: Long?, size: Int? = null): BaseResult + + suspend fun getFeedDetail(feedId: Long): BaseResult + + suspend fun createFeed(createFeedModel: CreateFeedModel): BaseResult + + suspend fun deleteFeed(feedId: Long): BaseResult + + suspend fun updateFeed(updateFeedModel: UpdateFeedModel): BaseResult + + suspend fun uploadFeedImages(feedId: Long, imageUris: List): BaseResult> + + suspend fun deleteFeedImage(feedId: Long, imageId: Long): BaseResult + + suspend fun getComments(feedId: Long): BaseResult> + + suspend fun createComment(feedId: Long, content: String, parentCommentId: Long?, taggedMemberId: String? = null): BaseResult + + suspend fun likeComment(commentId: Long, liked: Boolean): BaseResult + + suspend fun deleteComment(commentId: Long): BaseResult + + suspend fun likeFeed(feedId: Long, liked: Boolean): BaseResult +} diff --git a/domain/src/main/java/com/project200/domain/usecase/CreateCommentUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/CreateCommentUseCase.kt new file mode 100644 index 00000000..2d8e8ca0 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/CreateCommentUseCase.kt @@ -0,0 +1,19 @@ +package com.project200.domain.usecase + +import com.project200.domain.model.BaseResult +import com.project200.domain.model.CreateCommentResult +import com.project200.domain.repository.FeedRepository +import javax.inject.Inject + +class CreateCommentUseCase @Inject constructor( + private val feedRepository: FeedRepository, +) { + suspend operator fun invoke( + feedId: Long, + content: String, + parentCommentId: Long? = null, + taggedMemberId: String? = null, + ): BaseResult { + return feedRepository.createComment(feedId, content, parentCommentId, taggedMemberId) + } +} diff --git a/domain/src/main/java/com/project200/domain/usecase/CreateFeedUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/CreateFeedUseCase.kt new file mode 100644 index 00000000..8c83af85 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/CreateFeedUseCase.kt @@ -0,0 +1,15 @@ +package com.project200.domain.usecase + +import com.project200.domain.model.BaseResult +import com.project200.domain.model.CreateFeedModel +import com.project200.domain.model.FeedCreateResult +import com.project200.domain.repository.FeedRepository +import javax.inject.Inject + +class CreateFeedUseCase @Inject constructor( + private val feedRepository: FeedRepository +) { + suspend operator fun invoke(createFeedModel: CreateFeedModel): BaseResult { + return feedRepository.createFeed(createFeedModel) + } +} diff --git a/domain/src/main/java/com/project200/domain/usecase/DeleteCommentUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/DeleteCommentUseCase.kt new file mode 100644 index 00000000..282fbb0f --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/DeleteCommentUseCase.kt @@ -0,0 +1,13 @@ +package com.project200.domain.usecase + +import com.project200.domain.model.BaseResult +import com.project200.domain.repository.FeedRepository +import javax.inject.Inject + +class DeleteCommentUseCase @Inject constructor( + private val feedRepository: FeedRepository, +) { + suspend operator fun invoke(commentId: Long): BaseResult { + return feedRepository.deleteComment(commentId) + } +} diff --git a/domain/src/main/java/com/project200/domain/usecase/DeleteFeedImageUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/DeleteFeedImageUseCase.kt new file mode 100644 index 00000000..a3d93389 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/DeleteFeedImageUseCase.kt @@ -0,0 +1,13 @@ +package com.project200.domain.usecase + +import com.project200.domain.model.BaseResult +import com.project200.domain.repository.FeedRepository +import javax.inject.Inject + +class DeleteFeedImageUseCase @Inject constructor( + private val feedRepository: FeedRepository +) { + suspend operator fun invoke(feedId: Long, imageId: Long): BaseResult { + return feedRepository.deleteFeedImage(feedId, imageId) + } +} diff --git a/domain/src/main/java/com/project200/domain/usecase/DeleteFeedUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/DeleteFeedUseCase.kt new file mode 100644 index 00000000..4f693a0f --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/DeleteFeedUseCase.kt @@ -0,0 +1,13 @@ +package com.project200.domain.usecase + +import com.project200.domain.model.BaseResult +import com.project200.domain.repository.FeedRepository +import javax.inject.Inject + +class DeleteFeedUseCase @Inject constructor( + private val feedRepository: FeedRepository +) { + suspend operator fun invoke(feedId: Long): BaseResult { + return feedRepository.deleteFeed(feedId) + } +} diff --git a/domain/src/main/java/com/project200/domain/usecase/GetCommentsUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/GetCommentsUseCase.kt new file mode 100644 index 00000000..abc132d3 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/GetCommentsUseCase.kt @@ -0,0 +1,14 @@ +package com.project200.domain.usecase + +import com.project200.domain.model.BaseResult +import com.project200.domain.model.Comment +import com.project200.domain.repository.FeedRepository +import javax.inject.Inject + +class GetCommentsUseCase @Inject constructor( + private val feedRepository: FeedRepository, +) { + suspend operator fun invoke(feedId: Long): BaseResult> { + return feedRepository.getComments(feedId) + } +} diff --git a/domain/src/main/java/com/project200/domain/usecase/GetFeedDetailUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/GetFeedDetailUseCase.kt new file mode 100644 index 00000000..27715359 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/GetFeedDetailUseCase.kt @@ -0,0 +1,14 @@ +package com.project200.domain.usecase + +import com.project200.domain.model.BaseResult +import com.project200.domain.model.Feed +import com.project200.domain.repository.FeedRepository +import javax.inject.Inject + +class GetFeedDetailUseCase @Inject constructor( + private val feedRepository: FeedRepository, +) { + suspend operator fun invoke(feedId: Long): BaseResult { + return feedRepository.getFeedDetail(feedId) + } +} diff --git a/domain/src/main/java/com/project200/domain/usecase/GetFeedsUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/GetFeedsUseCase.kt new file mode 100644 index 00000000..1a12e65f --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/GetFeedsUseCase.kt @@ -0,0 +1,14 @@ +package com.project200.domain.usecase + +import com.project200.domain.model.BaseResult +import com.project200.domain.model.FeedListResult +import com.project200.domain.repository.FeedRepository +import javax.inject.Inject + +class GetFeedsUseCase @Inject constructor( + private val feedRepository: FeedRepository, +) { + suspend operator fun invoke(prevFeedId: Long? = null, size: Int? = null): BaseResult { + return feedRepository.getFeeds(prevFeedId, size) + } +} diff --git a/domain/src/main/java/com/project200/domain/usecase/LikeCommentUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/LikeCommentUseCase.kt new file mode 100644 index 00000000..30b54ba2 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/LikeCommentUseCase.kt @@ -0,0 +1,13 @@ +package com.project200.domain.usecase + +import com.project200.domain.model.BaseResult +import com.project200.domain.repository.FeedRepository +import javax.inject.Inject + +class LikeCommentUseCase @Inject constructor( + private val feedRepository: FeedRepository, +) { + suspend operator fun invoke(commentId: Long, liked: Boolean): BaseResult { + return feedRepository.likeComment(commentId, liked) + } +} diff --git a/domain/src/main/java/com/project200/domain/usecase/LikeFeedUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/LikeFeedUseCase.kt new file mode 100644 index 00000000..aef6e25b --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/LikeFeedUseCase.kt @@ -0,0 +1,13 @@ +package com.project200.domain.usecase + +import com.project200.domain.model.BaseResult +import com.project200.domain.repository.FeedRepository +import javax.inject.Inject + +class LikeFeedUseCase @Inject constructor( + private val feedRepository: FeedRepository, +) { + suspend operator fun invoke(feedId: Long, liked: Boolean): BaseResult { + return feedRepository.likeFeed(feedId, liked) + } +} diff --git a/domain/src/main/java/com/project200/domain/usecase/UpdateFeedUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/UpdateFeedUseCase.kt new file mode 100644 index 00000000..1981095d --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/UpdateFeedUseCase.kt @@ -0,0 +1,14 @@ +package com.project200.domain.usecase + +import com.project200.domain.model.BaseResult +import com.project200.domain.model.UpdateFeedModel +import com.project200.domain.repository.FeedRepository +import javax.inject.Inject + +class UpdateFeedUseCase @Inject constructor( + private val feedRepository: FeedRepository +) { + suspend operator fun invoke(updateFeedModel: UpdateFeedModel): BaseResult { + return feedRepository.updateFeed(updateFeedModel) + } +} diff --git a/domain/src/main/java/com/project200/domain/usecase/UploadFeedImagesUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/UploadFeedImagesUseCase.kt new file mode 100644 index 00000000..8ba6857a --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/UploadFeedImagesUseCase.kt @@ -0,0 +1,14 @@ +package com.project200.domain.usecase + +import com.project200.domain.model.BaseResult +import com.project200.domain.model.FeedPicture +import com.project200.domain.repository.FeedRepository +import javax.inject.Inject + +class UploadFeedImagesUseCase @Inject constructor( + private val feedRepository: FeedRepository +) { + suspend operator fun invoke(feedId: Long, imageUris: List): BaseResult> { + return feedRepository.uploadFeedImages(feedId, imageUris) + } +} diff --git a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt index e69dc43f..0772ab01 100644 --- a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt +++ b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt @@ -391,7 +391,7 @@ class ChattingRoomFragment : BindingFragment(R.layo if (chatRoomStateRepository.activeChatRoomId.value == args.roomId) { chatRoomStateRepository.setActiveChatRoomId(null) } - viewModel.disconnect() + viewModel.scheduleDisconnect() } companion object { diff --git a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomViewModel.kt b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomViewModel.kt index d76628c5..d619dafa 100644 --- a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomViewModel.kt +++ b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomViewModel.kt @@ -14,12 +14,15 @@ import com.project200.domain.usecase.ObserveOpponentStatusUseCase import com.project200.domain.usecase.ObserveSocketMessagesUseCase import com.project200.domain.usecase.SendSocketMessageUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -53,6 +56,8 @@ class ChattingRoomViewModel private var lastChatId: Long? = null // 새 메시지 조회를 위한 마지막 메시지 ID var hasNextMessages: Boolean = true // 더 로드할 메시지가 있는지 여부 + private var disconnectJob: Job? = null + init { viewModelScope.launch { observeSocketMessagesUseCase().collect { chat -> @@ -80,16 +85,35 @@ class ChattingRoomViewModel loadInitialMessages(chatRoomId) } - // 소켓 연결 및 공백 채우기 + // 소켓 연결 및 공백 채우기 (유예 시간 내 복귀 시 재연결 생략) fun connectAndSync() { if (chatRoomId == DEFAULT_ID) return - // 소켓 연결 시도 - connectChatRoomUseCase(chatRoomId) - // 소켓이 끊겨있던 동안 온 메시지 가져오기 - syncMissedMessages() + + val wasInGracePeriod = disconnectJob?.isActive == true + disconnectJob?.cancel() + disconnectJob = null + + if (!wasInGracePeriod) { + // 유예 시간 만료 또는 최초 연결 → 전체 재연결 + connectChatRoomUseCase(chatRoomId) + syncMissedMessages() + } + } + + fun scheduleDisconnect() { + disconnectJob?.cancel() + disconnectJob = + viewModelScope.launch { + Timber.w("[MEASURE] 연결 해제 유예 시작 (${DISCONNECT_GRACE_PERIOD_MS}ms)") + delay(DISCONNECT_GRACE_PERIOD_MS) + Timber.w("[MEASURE] 유예 시간 만료, 연결 해제") + disconnectChatRoomUseCase() + } } fun disconnect() { + disconnectJob?.cancel() + disconnectJob = null disconnectChatRoomUseCase() } @@ -162,6 +186,7 @@ class ChattingRoomViewModel fun syncMissedMessages() { if (chatRoomId == DEFAULT_ID) return viewModelScope.launch { + val startTime = System.currentTimeMillis() when (val result = getNewChatMessagesUseCase(chatRoomId, lastChatId)) { is BaseResult.Success -> { if (result.data.messages.isNotEmpty()) { @@ -177,6 +202,8 @@ class ChattingRoomViewModel lastChatId = uniqueNewMessages.lastOrNull()?.chatId updateAndEmitMessages(currentMessages + uniqueNewMessages) } + } else { + Timber.w("[MEASURE] 누락 메시지 없음 (0건), 소요시간: ${System.currentTimeMillis() - startTime}ms") } } @@ -237,5 +264,6 @@ class ChattingRoomViewModel companion object { const val DEFAULT_ID = -1L const val LOAD_SIZE = 30 // 초기 로드 메시지 개수 + private const val DISCONNECT_GRACE_PERIOD_MS = 5000L } } diff --git a/feature/feed/build.gradle.kts b/feature/feed/build.gradle.kts new file mode 100644 index 00000000..37571886 --- /dev/null +++ b/feature/feed/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + id("convention.android.library") + id("convention.android.hilt") + alias(libs.plugins.navigation.safeargs) +} + +android { + namespace = "com.project200.undabang.feature.feed" + + buildFeatures { + viewBinding = true + } +} + +dependencies { + implementation(projects.domain) + implementation(projects.common) + implementation(projects.presentation) + + implementation(libs.androidx.appcompat) + implementation(libs.androidx.constraintlayout) + implementation(libs.google.android.material) + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.activity) + + // Lifecycle + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.livedata.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + + // Navigation + implementation(libs.androidx.navigation.fragment.ktx) + implementation(libs.androidx.navigation.ui.ktx) + + testImplementation(libs.androidx.arch.core.testing) + testImplementation(libs.turbine) + androidTestImplementation(libs.androidx.navigation.testing) + + // Glide + implementation(libs.glide) + ksp(libs.glide.compiler.ksp) + + // CircleImageView + implementation(libs.circleimageview) + + // ViewPager2 Indicator + implementation(libs.circleindicator) + + // Shimmer + implementation(libs.shimmer) + + // SwipeRefreshLayout + implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") +} diff --git a/feature/feed/src/androidTest/java/com/project200/feature/feed/ExampleInstrumentedTest.kt b/feature/feed/src/androidTest/java/com/project200/feature/feed/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..b355346a --- /dev/null +++ b/feature/feed/src/androidTest/java/com/project200/feature/feed/ExampleInstrumentedTest.kt @@ -0,0 +1,16 @@ +package com.project200.feature.feed + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.project200.undabang.feature.feed.test", appContext.packageName) + } +} diff --git a/feature/feed/src/main/AndroidManifest.xml b/feature/feed/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8bdb7e14 --- /dev/null +++ b/feature/feed/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/CommentItem.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/CommentItem.kt new file mode 100644 index 00000000..6a780f3b --- /dev/null +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/CommentItem.kt @@ -0,0 +1,59 @@ +package com.project200.undabang.feature.feed.detail + +import com.project200.domain.model.Comment +import com.project200.domain.model.Reply +import com.project200.domain.model.TaggedMember +import java.time.LocalDateTime + +sealed class CommentItem { + abstract val commentId: Long + abstract val memberId: String + abstract val memberNickname: String + abstract val memberProfileImageUrl: String? + abstract val memberThumbnailUrl: String? + abstract val content: String + abstract val likesCount: Int + abstract val isLiked: Boolean + abstract val createdAt: LocalDateTime + + data class CommentData( + val comment: Comment, + ) : CommentItem() { + override val commentId: Long = comment.commentId + override val memberId: String = comment.memberId + override val memberNickname: String = comment.memberNickname + override val memberProfileImageUrl: String? = comment.memberProfileImageUrl + override val memberThumbnailUrl: String? = comment.memberThumbnailUrl + override val content: String = comment.content + override val likesCount: Int = comment.likesCount + override val isLiked: Boolean = comment.isLiked + override val createdAt: LocalDateTime = comment.createdAt + } + + data class ReplyData( + val reply: Reply, + val parentCommentId: Long, + ) : CommentItem() { + override val commentId: Long = reply.commentId + override val memberId: String = reply.memberId + override val memberNickname: String = reply.memberNickname + override val memberProfileImageUrl: String? = reply.memberProfileImageUrl + override val memberThumbnailUrl: String? = reply.memberThumbnailUrl + override val content: String = reply.content + override val likesCount: Int = reply.likesCount + override val isLiked: Boolean = reply.isLiked + override val createdAt: LocalDateTime = reply.createdAt + val taggedMember: TaggedMember? = reply.taggedMember + } +} + +fun List.toCommentItems(): List { + val result = mutableListOf() + for (comment in this) { + result.add(CommentItem.CommentData(comment)) + for (reply in comment.children) { + result.add(CommentItem.ReplyData(reply, comment.commentId)) + } + } + return result +} diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/CommentRVAdapter.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/CommentRVAdapter.kt new file mode 100644 index 00000000..7e98c742 --- /dev/null +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/CommentRVAdapter.kt @@ -0,0 +1,241 @@ +package com.project200.undabang.feature.feed.detail + +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.project200.presentation.utils.RelativeTimeUtil +import com.project200.undabang.feature.feed.R +import com.project200.undabang.feature.feed.databinding.ItemCommentBinding +import com.project200.undabang.feature.feed.databinding.ItemReplyBinding + +class CommentRVAdapter( + private val currentMemberId: String?, + private val onLikeClick: (CommentItem) -> Unit, + private val onReplyClick: (CommentItem) -> Unit, + private val onMoreClick: (CommentItem) -> Unit, +) : ListAdapter(CommentDiffCallback()) { + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is CommentItem.CommentData -> VIEW_TYPE_COMMENT + is CommentItem.ReplyData -> VIEW_TYPE_REPLY + } + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): RecyclerView.ViewHolder { + return when (viewType) { + VIEW_TYPE_COMMENT -> { + val binding = + ItemCommentBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + CommentViewHolder(binding) + } + VIEW_TYPE_REPLY -> { + val binding = + ItemReplyBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + ReplyViewHolder(binding) + } + else -> throw IllegalArgumentException("Unknown view type: $viewType") + } + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + ) { + when (val item = getItem(position)) { + is CommentItem.CommentData -> (holder as CommentViewHolder).bind(item) + is CommentItem.ReplyData -> (holder as ReplyViewHolder).bind(item) + } + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: MutableList, + ) { + if (payloads.isEmpty()) { + super.onBindViewHolder(holder, position, payloads) + return + } + + val payload = + payloads.firstOrNull() as? Set<*> ?: run { + super.onBindViewHolder(holder, position, payloads) + return + } + + if (PAYLOAD_LIKE in payload) { + val item = getItem(position) + when (holder) { + is CommentViewHolder -> holder.bindLike(item) + is ReplyViewHolder -> holder.bindLike(item) + } + } + } + + inner class CommentViewHolder( + private val binding: ItemCommentBinding, + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: CommentItem.CommentData) { + with(binding) { + nicknameTv.text = item.memberNickname + timeTv.text = RelativeTimeUtil.getRelativeTime(item.createdAt) + timeTv.visibility = View.VISIBLE + contentTv.text = item.content + likeCountTv.text = item.likesCount.toString() + + val profileUrl = item.memberThumbnailUrl ?: item.memberProfileImageUrl + Glide.with(root.context) + .load(profileUrl) + .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default) + .error(com.project200.undabang.presentation.R.drawable.ic_profile_default) + .circleCrop() + .into(profileIv) + + val likeIcon = + if (item.isLiked) { + R.drawable.ic_like_fill + } else { + R.drawable.ic_like + } + likeIv.setImageResource(likeIcon) + + val isMyComment = currentMemberId != null && item.memberId == currentMemberId + moreIv.visibility = if (isMyComment) View.VISIBLE else View.GONE + + likeIv.setOnClickListener { onLikeClick(item) } + replyBtn.setOnClickListener { onReplyClick(item) } + moreIv.setOnClickListener { onMoreClick(item) } + } + } + + fun bindLike(item: CommentItem) { + with(binding) { + likeCountTv.text = item.likesCount.toString() + val likeIcon = + if (item.isLiked) { + R.drawable.ic_like_fill + } else { + R.drawable.ic_like + } + likeIv.setImageResource(likeIcon) + } + } + } + + inner class ReplyViewHolder( + private val binding: ItemReplyBinding, + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: CommentItem.ReplyData) { + with(binding) { + nicknameTv.text = item.memberNickname + timeTv.text = RelativeTimeUtil.getRelativeTime(item.createdAt) + timeTv.visibility = View.VISIBLE + likeCountTv.text = item.likesCount.toString() + + if (item.taggedMember != null) { + val tag = "@${item.taggedMember.memberNickname} " + val spannable = SpannableString("$tag${item.content}") + val tagColor = ContextCompat.getColor(root.context, R.color.tag) + spannable.setSpan( + ForegroundColorSpan(tagColor), + 0, + tag.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + contentTv.text = spannable + } else { + contentTv.text = item.content + } + + val profileUrl = item.memberThumbnailUrl ?: item.memberProfileImageUrl + Glide.with(root.context) + .load(profileUrl) + .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default) + .error(com.project200.undabang.presentation.R.drawable.ic_profile_default) + .circleCrop() + .into(profileIv) + + likeIv.setImageResource( + if (item.isLiked) { + R.drawable.ic_like_fill + } else { + R.drawable.ic_like + }, + ) + + val isMyComment = currentMemberId != null && item.memberId == currentMemberId + moreIv.visibility = if (isMyComment) View.VISIBLE else View.GONE + + likeIv.setOnClickListener { onLikeClick(item) } + replyBtn.setOnClickListener { onReplyClick(item) } + moreIv.setOnClickListener { onMoreClick(item) } + } + } + + fun bindLike(item: CommentItem) { + with(binding) { + likeCountTv.text = item.likesCount.toString() + val likeIcon = + if (item.isLiked) { + R.drawable.ic_like_fill + } else { + R.drawable.ic_like + } + likeIv.setImageResource(likeIcon) + } + } + } + + class CommentDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: CommentItem, + newItem: CommentItem, + ): Boolean { + return oldItem.commentId == newItem.commentId && + oldItem::class == newItem::class + } + + override fun areContentsTheSame( + oldItem: CommentItem, + newItem: CommentItem, + ): Boolean { + return oldItem == newItem + } + + override fun getChangePayload( + oldItem: CommentItem, + newItem: CommentItem, + ): Any? { + val payloads = mutableSetOf() + if (oldItem.isLiked != newItem.isLiked || oldItem.likesCount != newItem.likesCount) { + payloads.add(PAYLOAD_LIKE) + } + return payloads.ifEmpty { null } + } + } + + companion object { + private const val VIEW_TYPE_COMMENT = 0 + private const val VIEW_TYPE_REPLY = 1 + const val PAYLOAD_LIKE = "payload_like" + } +} diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailFragment.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailFragment.kt new file mode 100644 index 00000000..16d50b9a --- /dev/null +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailFragment.kt @@ -0,0 +1,261 @@ +package com.project200.undabang.feature.feed.detail + +import android.text.Editable +import android.text.TextWatcher +import android.view.View +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.LinearLayoutManager +import com.bumptech.glide.Glide +import com.project200.domain.model.Feed +import com.project200.presentation.base.BindingFragment +import com.project200.presentation.utils.RelativeTimeUtil +import com.project200.presentation.utils.collectFlow +import com.project200.presentation.utils.collectToast +import com.project200.presentation.view.MenuBottomSheetDialog +import com.project200.undabang.feature.feed.R +import com.project200.undabang.feature.feed.databinding.FragmentFeedDetailBinding +import com.project200.undabang.feature.feed.form.FeedFormFragment +import com.project200.undabang.feature.feed.list.FeedListFragment +import com.project200.undabang.feature.feed.list.ImageRVAdapter +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class FeedDetailFragment : BindingFragment(R.layout.fragment_feed_detail) { + private val viewModel: FeedDetailViewModel by viewModels() + private val args: FeedDetailFragmentArgs by navArgs() + private var commentRVAdapter: CommentRVAdapter? = null + + override fun getViewBinding(view: View): FragmentFeedDetailBinding { + return FragmentFeedDetailBinding.bind(view) + } + + override fun setupViews() { + viewModel.setFeedId(args.feedId) + initToolbar() + initCommentInput() + initObserver() + observeFeedUpdated() + } + + private fun observeFeedUpdated() { + findNavController().currentBackStackEntry?.savedStateHandle?.getLiveData(FeedFormFragment.FEED_UPDATED_KEY) + ?.observe(viewLifecycleOwner) { updated -> + if (updated) { + viewModel.refreshFeed() + findNavController().previousBackStackEntry?.savedStateHandle?.set(FeedListFragment.REFRESH_KEY, true) + findNavController().currentBackStackEntry?.savedStateHandle?.remove(FeedFormFragment.FEED_UPDATED_KEY) + } + } + } + + private fun initToolbar() { + binding.baseToolbar.apply { + setTitle("") + showBackButton(true) { findNavController().navigateUp() } + } + } + + private fun initCommentInput() { + with(binding.commentInputLayout) { + commentInputEt.addTextChangedListener( + object : TextWatcher { + override fun beforeTextChanged( + s: CharSequence?, + start: Int, + count: Int, + after: Int, + ) {} + + override fun onTextChanged( + s: CharSequence?, + start: Int, + before: Int, + count: Int, + ) {} + + override fun afterTextChanged(s: Editable?) { + val hasText = !s.isNullOrBlank() + val sendIcon = + if (hasText) { + R.drawable.ic_send + } else { + R.drawable.ic_send_unable + } + sendBtn.setImageResource(sendIcon) + } + }, + ) + + sendBtn.setOnClickListener { + val content = commentInputEt.text.toString() + if (content.isNotBlank()) { + viewModel.createComment(content) + commentInputEt.text.clear() + } + } + + cancelReplyIv.setOnClickListener { + viewModel.setReplyTarget(null) + } + } + } + + private fun initObserver() { + viewModel.feed.observe(viewLifecycleOwner) { feed -> + bindFeedData(feed) + } + + viewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> + if (isLoading) { + binding.shimmerLayout.visibility = View.VISIBLE + binding.shimmerLayout.startShimmer() + binding.scrollView.visibility = View.GONE + } else { + binding.shimmerLayout.stopShimmer() + binding.shimmerLayout.visibility = View.GONE + binding.scrollView.visibility = View.VISIBLE + } + } + + collectToast(viewModel.toastEvent) + + collectFlow(viewModel.feedDeleted) { + findNavController().previousBackStackEntry?.savedStateHandle?.set(FeedListFragment.REFRESH_KEY, true) + findNavController().navigateUp() + } + + collectFlow(viewModel.feedLoadError) { + binding.errorTv.visibility = View.VISIBLE + binding.scrollView.visibility = View.GONE + } + + viewModel.isMyFeed.observe(viewLifecycleOwner) { isMyFeed -> + binding.moreIv.visibility = if (isMyFeed) View.VISIBLE else View.GONE + } + + viewModel.comments.observe(viewLifecycleOwner) { comments -> + setupCommentAdapter() + val items = comments.toCommentItems() + commentRVAdapter?.submitList(items) + + binding.noCommentsTv.visibility = if (comments.isEmpty()) View.VISIBLE else View.GONE + binding.commentsRv.visibility = if (comments.isEmpty()) View.GONE else View.VISIBLE + } + + viewModel.replyTarget.observe(viewLifecycleOwner) { target -> + with(binding.commentInputLayout) { + if (target != null) { + replyTargetTv.text = getString(R.string.feed_reply_writing, target.memberNickname) + replyTargetTv.visibility = View.VISIBLE + cancelReplyIv.visibility = View.VISIBLE + } else { + replyTargetTv.visibility = View.GONE + cancelReplyIv.visibility = View.GONE + } + } + } + } + + private fun setupCommentAdapter() { + if (commentRVAdapter != null) return + + val currentMemberId = viewModel.currentMemberId.value + commentRVAdapter = + CommentRVAdapter( + currentMemberId = currentMemberId, + onLikeClick = { item -> viewModel.toggleCommentLike(item) }, + onReplyClick = { item -> viewModel.setReplyTarget(item) }, + onMoreClick = { item -> showCommentMenuBottomSheet(item) }, + ) + binding.commentsRv.adapter = commentRVAdapter + } + + private fun showCommentMenuBottomSheet(item: CommentItem) { + MenuBottomSheetDialog( + onDeleteClicked = { + viewModel.deleteComment(item.commentId) + }, + showEditButton = false, + ).show(parentFragmentManager, "CommentMenu") + } + + private fun bindFeedData(feed: Feed) { + with(binding) { + nicknameTv.text = feed.nickname + timeTv.text = RelativeTimeUtil.getRelativeTime(feed.feedCreatedAt) + contentTv.text = feed.feedContent + likeCountTv.text = feed.feedLikesCount.toString() + commentCountTv.text = feed.feedCommentsCount.toString() + + val likeIcon = + if (feed.feedIsLiked) { + R.drawable.ic_like_fill + } else { + R.drawable.ic_like + } + likeIv.setImageResource(likeIcon) + + likeIv.setOnClickListener { + viewModel.toggleFeedLike() + } + + val hasType = !feed.feedTypeName.isNullOrBlank() + arrowIv.visibility = if (hasType) View.VISIBLE else View.GONE + feedTypeTv.visibility = if (hasType) View.VISIBLE else View.GONE + if (hasType) { + feedTypeTv.text = feed.feedTypeName + } + + Glide.with(profileIv.context) + .load(feed.profileUrl) + .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default) + .error(com.project200.undabang.presentation.R.drawable.ic_profile_default) + .circleCrop() + .into(profileIv) + + if (feed.feedPictures.isNotEmpty()) { + imagesRv.visibility = View.VISIBLE + imagesRv.layoutManager = + LinearLayoutManager( + context, + LinearLayoutManager.HORIZONTAL, + false, + ) + imagesRv.adapter = ImageRVAdapter(feed.feedPictures) + } else { + imagesRv.visibility = View.GONE + } + + moreIv.setOnClickListener { + showMenuBottomSheet() + } + } + } + + private fun showMenuBottomSheet() { + MenuBottomSheetDialog( + onEditClicked = { + navigateToEditFeed() + }, + onDeleteClicked = { + viewModel.deleteFeed() + }, + ).show(parentFragmentManager, MenuBottomSheetDialog::class.java.simpleName) + } + + private fun navigateToEditFeed() { + val feed = viewModel.feed.value ?: return + val action = + FeedDetailFragmentDirections.actionFeedDetailFragmentToFeedFormFragment( + feedId = feed.feedId, + ) + findNavController().navigate(action) + } + + override fun onDestroyView() { + commentRVAdapter = null + super.onDestroyView() + } +} diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailViewModel.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailViewModel.kt new file mode 100644 index 00000000..f0fedb3a --- /dev/null +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailViewModel.kt @@ -0,0 +1,350 @@ +package com.project200.undabang.feature.feed.detail + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.project200.domain.model.BaseResult +import com.project200.domain.model.Comment +import com.project200.domain.model.Feed +import com.project200.domain.usecase.CreateCommentUseCase +import com.project200.domain.usecase.DeleteCommentUseCase +import com.project200.domain.usecase.DeleteFeedUseCase +import com.project200.domain.usecase.GetCommentsUseCase +import com.project200.domain.usecase.GetFeedDetailUseCase +import com.project200.domain.usecase.GetMemberIdUseCase +import com.project200.domain.usecase.LikeCommentUseCase +import com.project200.domain.usecase.LikeFeedUseCase +import com.project200.presentation.utils.UiText +import com.project200.undabang.feature.feed.R +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class FeedDetailViewModel + @Inject + constructor( + private val getFeedDetailUseCase: GetFeedDetailUseCase, + private val getMemberIdUseCase: GetMemberIdUseCase, + private val deleteFeedUseCase: DeleteFeedUseCase, + private val getCommentsUseCase: GetCommentsUseCase, + private val createCommentUseCase: CreateCommentUseCase, + private val likeCommentUseCase: LikeCommentUseCase, + private val deleteCommentUseCase: DeleteCommentUseCase, + private val likeFeedUseCase: LikeFeedUseCase, + ) : ViewModel() { + private var feedId: Long = -1L + + private val _feed = MutableLiveData() + val feed: LiveData get() = _feed + + private val _isLoading = MutableLiveData(false) + val isLoading: LiveData get() = _isLoading + + private val _toastEvent = MutableSharedFlow() + val toastEvent: SharedFlow = _toastEvent.asSharedFlow() + + private val _feedDeleted = MutableSharedFlow() + val feedDeleted: SharedFlow = _feedDeleted.asSharedFlow() + + private val _feedLoadError = MutableSharedFlow() + val feedLoadError: SharedFlow = _feedLoadError.asSharedFlow() + + private val _currentMemberId = MutableLiveData() + val currentMemberId: LiveData get() = _currentMemberId + + private val _isMyFeed = MutableLiveData(false) + val isMyFeed: LiveData get() = _isMyFeed + + private val _comments = MutableLiveData>() + val comments: LiveData> get() = _comments + + private val _commentsLoading = MutableLiveData(false) + val commentsLoading: LiveData get() = _commentsLoading + + private val _replyTarget = MutableLiveData() + val replyTarget: LiveData get() = _replyTarget + + private val commentLikeJobs = mutableMapOf() + private val pendingCommentLikes = mutableMapOf() + + private var feedLikeJob: Job? = null + private var pendingFeedLike: Boolean? = null + private var originalFeedLikeState: Boolean? = null + + fun setFeedId(feedId: Long) { + this.feedId = feedId + loadCurrentMemberId() + loadFeedDetail() + loadComments() + } + + private fun loadCurrentMemberId() { + viewModelScope.launch { + _currentMemberId.value = getMemberIdUseCase() + } + } + + private fun loadFeedDetail() { + if (feedId == -1L) { + viewModelScope.launch { + _toastEvent.emit(UiText.StringResource(R.string.feed_load_error)) + _feedLoadError.emit(Unit) + } + return + } + + _isLoading.value = true + viewModelScope.launch { + when (val result = getFeedDetailUseCase(feedId)) { + is BaseResult.Success -> { + _feed.value = result.data + checkIsMyFeed(result.data.memberId) + } + is BaseResult.Error -> { + _toastEvent.emit(UiText.StringResource(R.string.feed_load_error)) + _feedLoadError.emit(Unit) + } + } + _isLoading.value = false + } + } + + private fun checkIsMyFeed(feedMemberId: String) { + val currentId = _currentMemberId.value + _isMyFeed.value = currentId != null && currentId == feedMemberId + } + + fun refreshFeed() { + loadFeedDetail() + loadComments() + } + + fun deleteFeed() { + viewModelScope.launch { + when (deleteFeedUseCase(feedId)) { + is BaseResult.Success -> { + _toastEvent.emit(UiText.StringResource(R.string.feed_deleted)) + _feedDeleted.emit(Unit) + } + is BaseResult.Error -> { + _toastEvent.emit(UiText.StringResource(R.string.feed_delete_error)) + } + } + } + } + + fun loadComments() { + if (feedId == -1L) return + + _commentsLoading.value = true + viewModelScope.launch { + when (val result = getCommentsUseCase(feedId)) { + is BaseResult.Success -> { + _comments.value = result.data + } + is BaseResult.Error -> { + _toastEvent.emit(UiText.StringResource(R.string.comment_load_error)) + } + } + _commentsLoading.value = false + } + } + + fun createComment(content: String) { + if (content.isBlank() || feedId == -1L) return + + viewModelScope.launch { + val target = _replyTarget.value + val parentCommentId = + when (target) { + is CommentItem.CommentData -> target.commentId + is CommentItem.ReplyData -> target.parentCommentId + null -> null + } + // 대댓글에 답글 달 때만 태그 추가 (댓글에 답글 달 때는 태그 X) + val taggedMemberId = + when (target) { + is CommentItem.ReplyData -> target.memberId + else -> null + } + when (createCommentUseCase(feedId, content, parentCommentId, taggedMemberId)) { + is BaseResult.Success -> { + _toastEvent.emit(UiText.StringResource(R.string.comment_created)) + _replyTarget.value = null + loadComments() + refreshFeedCommentsCount() + } + is BaseResult.Error -> { + _toastEvent.emit(UiText.StringResource(R.string.comment_create_error)) + } + } + } + } + + fun setReplyTarget(target: CommentItem?) { + _replyTarget.value = target + } + + // 좋아요 토글 시 로컬 상태 즉시 업데이트 + 1초 딜레이 후 서버 반영 (중복 요청 방지) + fun toggleCommentLike(item: CommentItem) { + val commentId = item.commentId + val currentLikedState = pendingCommentLikes[commentId] ?: item.isLiked + val newLikedState = !currentLikedState + + pendingCommentLikes[commentId] = newLikedState + updateCommentLikeLocally(commentId, newLikedState) + + commentLikeJobs[commentId]?.cancel() + commentLikeJobs[commentId] = + viewModelScope.launch { + delay(LIKE_DEBOUNCE_MS) + + val finalLikedState = pendingCommentLikes[commentId] ?: return@launch + if (finalLikedState == item.isLiked) { + pendingCommentLikes.remove(commentId) + return@launch + } + + when (likeCommentUseCase(commentId, finalLikedState)) { + is BaseResult.Success -> { + pendingCommentLikes.remove(commentId) + } + is BaseResult.Error -> { + pendingCommentLikes.remove(commentId) + updateCommentLikeLocally(commentId, item.isLiked) + _toastEvent.emit(UiText.StringResource(R.string.like_error)) + } + } + } + } + + // 좋아요 상태를 업데이트할 수 있도록 수정 + private fun updateCommentLikeLocally( + commentId: Long, + isLiked: Boolean, + ) { + val currentComments = _comments.value ?: return + val updatedComments = + currentComments.map { comment -> + if (comment.commentId == commentId) { + comment.copy( + isLiked = isLiked, + likesCount = if (isLiked) comment.likesCount + 1 else comment.likesCount - 1, + ) + } else { + val updatedChildren = + comment.children.map { reply -> + if (reply.commentId == commentId) { + reply.copy( + isLiked = isLiked, + likesCount = if (isLiked) reply.likesCount + 1 else reply.likesCount - 1, + ) + } else { + reply + } + } + comment.copy(children = updatedChildren) + } + } + _comments.value = updatedComments + } + + fun deleteComment(commentId: Long) { + viewModelScope.launch { + when (deleteCommentUseCase(commentId)) { + is BaseResult.Success -> { + _toastEvent.emit(UiText.StringResource(R.string.comment_deleted)) + loadComments() + refreshFeedCommentsCount() + } + is BaseResult.Error -> { + _toastEvent.emit(UiText.StringResource(R.string.comment_delete_error)) + } + } + } + } + + private fun refreshFeedCommentsCount() { + viewModelScope.launch { + when (val result = getFeedDetailUseCase(feedId)) { + is BaseResult.Success -> { + _feed.value = result.data + } + is BaseResult.Error -> { } + } + } + } + + // 좋아요 토글 시 로컬 상태 즉시 업데이트 + 1초 딜레이 후 서버 반영 (중복 요청 방지) + fun toggleFeedLike() { + val currentFeed = _feed.value ?: return + + if (originalFeedLikeState == null) { + originalFeedLikeState = currentFeed.feedIsLiked + } + + val currentLikedState = pendingFeedLike ?: currentFeed.feedIsLiked + val newLikedState = !currentLikedState + + pendingFeedLike = newLikedState + updateFeedLikeLocally(newLikedState) + + feedLikeJob?.cancel() + feedLikeJob = + viewModelScope.launch { + delay(LIKE_DEBOUNCE_MS) + + val finalLikedState = pendingFeedLike ?: return@launch + val originalState = originalFeedLikeState ?: return@launch + + if (finalLikedState == originalState) { + resetFeedLikePendingState() + return@launch + } + + when (likeFeedUseCase(feedId, finalLikedState)) { + is BaseResult.Success -> { + resetFeedLikePendingState() + } + is BaseResult.Error -> { + resetFeedLikePendingState() + updateFeedLikeLocally(originalState) + _toastEvent.emit(UiText.StringResource(R.string.like_error)) + } + } + } + } + + // 좋아요 상태 변경이 완료되거나 취소된 후에 pending 상태 초기화 + private fun resetFeedLikePendingState() { + pendingFeedLike = null + originalFeedLikeState = null + } + + private fun updateFeedLikeLocally(isLiked: Boolean) { + val currentFeed = _feed.value ?: return + if (currentFeed.feedIsLiked == isLiked) return + + _feed.value = + currentFeed.copy( + feedIsLiked = isLiked, + feedLikesCount = + if (isLiked) { + currentFeed.feedLikesCount + 1 + } else { + currentFeed.feedLikesCount - 1 + }, + ) + } + + companion object { + private const val LIKE_DEBOUNCE_MS = 1000L + } + } diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormFragment.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormFragment.kt new file mode 100644 index 00000000..833ec17f --- /dev/null +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormFragment.kt @@ -0,0 +1,163 @@ +package com.project200.undabang.feature.feed.form + +import android.view.View +import android.widget.Toast +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import com.bumptech.glide.Glide +import com.project200.domain.model.PreferredExercise +import com.project200.presentation.base.BindingFragment +import com.project200.presentation.utils.collectToast +import com.project200.presentation.view.SelectionBottomSheetDialog +import com.project200.undabang.feature.feed.R +import com.project200.undabang.feature.feed.databinding.FragmentFeedFormBinding +import com.project200.undabang.feature.feed.list.FeedListFragment +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class FeedFormFragment : BindingFragment(R.layout.fragment_feed_form) { + private val viewModel: FeedFormViewModel by viewModels() + private val args: FeedFormFragmentArgs by navArgs() + private val imageAdapter = + FeedFormImageAdapter( + onDeleteExistingClick = { imageId -> viewModel.removeExistingImage(imageId) }, + onDeleteNewClick = { uri -> viewModel.removeImage(uri) }, + ) + + private val pickImagesLauncher = + registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris -> + if (uris.isNotEmpty()) { + viewModel.addImages(uris) + } + } + + override fun getViewBinding(view: View): FragmentFeedFormBinding { + return FragmentFeedFormBinding.bind(view) + } + + override fun setupViews() { + viewModel.initData(feedId = args.feedId) + initToolbar() + initView() + initObserver() + } + + private fun initToolbar() { + viewModel.isEditMode.observe(viewLifecycleOwner) { isEditMode -> + val title = + if (isEditMode) { + getString(R.string.feed_form_edit_title) + } else { + getString(R.string.feed_form_title) + } + binding.baseToolbar.setTitle(title) + } + binding.baseToolbar.showBackButton(true) { findNavController().navigateUp() } + binding.completeBtn.setOnClickListener { + viewModel.submitFeed(binding.contentEt.text.toString()) + } + } + + private fun initView() { + binding.imageListRv.adapter = imageAdapter + + binding.addImageBtn.setOnClickListener { + pickImagesLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } + + binding.dabangSelectionTv.setOnClickListener { + showDabangSelection() + } + + binding.arrowIv.setOnClickListener { + showDabangSelection() + } + } + + private fun showDabangSelection() { + viewModel.requestShowDabangSelection() + } + + private fun updateImageList() { + val existingImages = viewModel.registeredImages.value ?: emptyList() + val newImages = viewModel.selectedImages.value ?: emptyList() + imageAdapter.submitList(existingImages, newImages) + } + + private fun displayDabangSelection(types: List) { + val names = types.map { it.name } + + SelectionBottomSheetDialog(names) { selectedName -> + val selected = types.find { it.name == selectedName } + viewModel.selectType(selected) + binding.dabangSelectionTv.text = selectedName + binding.dabangSelectionTv.setTextColor(resources.getColor(com.project200.undabang.presentation.R.color.black, null)) + }.show(parentFragmentManager, SelectionBottomSheetDialog::class.java.name) + viewModel.onDabangSelectionShown() + } + + private fun initObserver() { + viewModel.userProfile.observe(viewLifecycleOwner) { profile -> + binding.nicknameTv.text = profile.nickname + Glide.with(this) + .load(profile.profileImageUrl) + .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default) + .circleCrop() + .into(binding.profileIv) + } + + viewModel.selectedImages.observe(viewLifecycleOwner) { _ -> + updateImageList() + } + + viewModel.registeredImages.observe(viewLifecycleOwner) { _ -> + updateImageList() + } + + viewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> + binding.loadingPb.visibility = if (isLoading) View.VISIBLE else View.GONE + } + + viewModel.createSuccess.observe(viewLifecycleOwner) { + Toast.makeText(context, R.string.feed_form_create_success, Toast.LENGTH_SHORT).show() + findNavController().previousBackStackEntry?.savedStateHandle?.set(FeedListFragment.REFRESH_KEY, true) + findNavController().popBackStack() + } + + viewModel.updateSuccess.observe(viewLifecycleOwner) { success -> + if (success) { + Toast.makeText(context, R.string.feed_form_update_success, Toast.LENGTH_SHORT).show() + findNavController().previousBackStackEntry?.savedStateHandle?.set(FEED_UPDATED_KEY, true) + findNavController().popBackStack() + } + } + + collectToast(viewModel.toastEvent) + + viewModel.showDabangSelection.observe(viewLifecycleOwner) { types -> + if (!types.isNullOrEmpty()) { + displayDabangSelection(types) + } + } + + viewModel.initialContentForEdit.observe(viewLifecycleOwner) { content -> + if (!content.isNullOrEmpty()) { + binding.contentEt.setText(content) + } + } + + viewModel.selectedType.observe(viewLifecycleOwner) { type -> + if (type != null) { + binding.dabangSelectionTv.text = type.name + binding.dabangSelectionTv.setTextColor(resources.getColor(com.project200.undabang.presentation.R.color.black, null)) + } + } + } + + companion object { + const val FEED_UPDATED_KEY = "feed_updated" + } +} diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormImageAdapter.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormImageAdapter.kt new file mode 100644 index 00000000..3a23efff --- /dev/null +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormImageAdapter.kt @@ -0,0 +1,72 @@ +package com.project200.undabang.feature.feed.form + +import android.net.Uri +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.project200.undabang.feature.feed.R +import com.project200.undabang.feature.feed.databinding.ItemFeedFormImageBinding + +class FeedFormImageAdapter( + private val onDeleteExistingClick: (Long) -> Unit, + private val onDeleteNewClick: (Uri) -> Unit, +) : RecyclerView.Adapter() { + private val items = mutableListOf() + + fun submitList( + registeredImages: List, + newImages: List, + ) { + items.clear() + items.addAll(registeredImages.map { FeedFormImageItem.Existing(it.imageId, it.imageUrl) }) + items.addAll(newImages.map { FeedFormImageItem.New(it) }) + notifyDataSetChanged() + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): ViewHolder { + val binding = + ItemFeedFormImageBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ViewHolder(binding) + } + + override fun onBindViewHolder( + holder: ViewHolder, + position: Int, + ) { + holder.bind(items[position]) + } + + override fun getItemCount(): Int = items.size + + inner class ViewHolder(private val binding: ItemFeedFormImageBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(item: FeedFormImageItem) { + val imageSource: Any = + when (item) { + is FeedFormImageItem.Existing -> item.imageUrl + is FeedFormImageItem.New -> item.uri + } + + Glide.with(binding.formImageIv.context) + .load(imageSource) + .placeholder(R.drawable.ic_feed_image_placeholder) + .fitCenter() + .into(binding.formImageIv) + + binding.deleteIv.setOnClickListener { + when (item) { + is FeedFormImageItem.Existing -> onDeleteExistingClick(item.imageId) + is FeedFormImageItem.New -> onDeleteNewClick(item.uri) + } + } + } + } +} diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormViewModel.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormViewModel.kt new file mode 100644 index 00000000..e45adb94 --- /dev/null +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormViewModel.kt @@ -0,0 +1,255 @@ +package com.project200.undabang.feature.feed.form + +import android.net.Uri +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.project200.domain.model.BaseResult +import com.project200.domain.model.CreateFeedModel +import com.project200.domain.model.PreferredExercise +import com.project200.domain.model.UpdateFeedModel +import com.project200.domain.model.UserProfile +import com.project200.domain.usecase.CreateFeedUseCase +import com.project200.domain.usecase.DeleteFeedImageUseCase +import com.project200.domain.usecase.GetFeedDetailUseCase +import com.project200.domain.usecase.GetPreferredExerciseTypesUseCase +import com.project200.domain.usecase.GetPreferredExerciseUseCase +import com.project200.domain.usecase.GetUserProfileUseCase +import com.project200.domain.usecase.UpdateFeedUseCase +import com.project200.domain.usecase.UploadFeedImagesUseCase +import com.project200.presentation.utils.UiText +import com.project200.undabang.feature.feed.R +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class FeedFormViewModel + @Inject + constructor( + private val getUserProfileUseCase: GetUserProfileUseCase, + private val getPreferredExerciseUseCase: GetPreferredExerciseUseCase, + private val getPreferredExerciseTypesUseCase: GetPreferredExerciseTypesUseCase, + private val getFeedDetailUseCase: GetFeedDetailUseCase, + private val createFeedUseCase: CreateFeedUseCase, + private val updateFeedUseCase: UpdateFeedUseCase, + private val uploadFeedImagesUseCase: UploadFeedImagesUseCase, + private val deleteFeedImageUseCase: DeleteFeedImageUseCase, + ) : ViewModel() { + private var feedId: Long = -1L + + private val _isEditMode = MutableLiveData(false) + val isEditMode: LiveData get() = _isEditMode + + private val _userProfile = MutableLiveData() + val userProfile: LiveData get() = _userProfile + + private val _exerciseTypes = MutableLiveData>() + val exerciseTypes: LiveData> get() = _exerciseTypes + + private val _selectedType = MutableLiveData() + val selectedType: LiveData get() = _selectedType + + private val _selectedImages = MutableLiveData>(emptyList()) + val selectedImages: LiveData> get() = _selectedImages + + private val _registeredImages = MutableLiveData>(emptyList()) + val registeredImages: LiveData> get() = _registeredImages + + private val deletedImageIds = mutableListOf() + + private val _isLoading = MutableLiveData() + val isLoading: LiveData get() = _isLoading + + private val _createSuccess = MutableLiveData() + val createSuccess: LiveData get() = _createSuccess + + private val _updateSuccess = MutableLiveData() + val updateSuccess: LiveData get() = _updateSuccess + + private val _toastEvent = MutableSharedFlow() + val toastEvent: SharedFlow = _toastEvent.asSharedFlow() + + private val _showDabangSelection = MutableLiveData?>() + val showDabangSelection: LiveData?> get() = _showDabangSelection + + private val _initialContentForEdit = MutableLiveData() + val initialContentForEdit: LiveData get() = _initialContentForEdit + + fun initData(feedId: Long = -1L) { + this.feedId = feedId + _isEditMode.value = feedId != -1L + loadData() + } + + private fun loadData() { + viewModelScope.launch { + when (val result = getUserProfileUseCase()) { + is BaseResult.Success -> _userProfile.value = result.data + is BaseResult.Error -> _toastEvent.emit(UiText.StringResource(R.string.profile_load_error)) + } + + val preferredResult = getPreferredExerciseUseCase() + val allTypesResult = getPreferredExerciseTypesUseCase() + + val preferredList = if (preferredResult is BaseResult.Success) preferredResult.data else emptyList() + val allList = if (allTypesResult is BaseResult.Success) allTypesResult.data else emptyList() + + val combined = (preferredList + allList).distinctBy { it.exerciseTypeId } + _exerciseTypes.value = combined + + if (_isEditMode.value == true) { + loadFeedForEdit(combined) + } + } + } + + private suspend fun loadFeedForEdit(exerciseTypes: List) { + when (val result = getFeedDetailUseCase(feedId)) { + is BaseResult.Success -> { + val feed = result.data + _initialContentForEdit.value = feed.feedContent + + val existingList = + feed.feedPictures.map { picture -> + RegisteredImage(picture.feedPictureId, picture.feedPictureUrl) + } + _registeredImages.value = existingList + + val typeId = feed.feedTypeId + val typeName = feed.feedTypeName + if (typeId != null && typeName != null) { + val matchedType = + exerciseTypes.find { it.exerciseTypeId == typeId } + ?: PreferredExercise( + preferredExerciseId = -1L, + exerciseTypeId = typeId, + name = typeName, + skillLevel = "", + daysOfWeek = List(7) { false }, + imageUrl = null, + ) + _selectedType.value = matchedType + } + } + is BaseResult.Error -> { + _toastEvent.emit(UiText.StringResource(R.string.feed_load_error)) + } + } + } + + fun selectType(exercise: PreferredExercise?) { + _selectedType.value = exercise + } + + fun requestShowDabangSelection() { + val types = _exerciseTypes.value + if (!types.isNullOrEmpty()) { + _showDabangSelection.value = types + } + } + + fun onDabangSelectionShown() { + _showDabangSelection.value = null + } + + fun addImages(uris: List) { + val current = _selectedImages.value ?: emptyList() + _selectedImages.value = current + uris + } + + fun removeImage(uri: Uri) { + val current = _selectedImages.value ?: emptyList() + _selectedImages.value = current.filter { it != uri } + } + + fun removeExistingImage(imageId: Long) { + deletedImageIds.add(imageId) + val current = _registeredImages.value ?: emptyList() + _registeredImages.value = current.filter { it.imageId != imageId } + } + + fun submitFeed(content: String) { + if (content.isBlank()) { + viewModelScope.launch { + _toastEvent.emit(UiText.StringResource(R.string.feed_form_empty_content_warning)) + } + return + } + + _isLoading.value = true + viewModelScope.launch { + if (_isEditMode.value == true) { + val model = + UpdateFeedModel( + feedId = feedId, + feedContent = content, + feedTypeId = _selectedType.value?.exerciseTypeId, + ) + when (updateFeedUseCase(model)) { + is BaseResult.Success -> { + var hasImageError = false + + deletedImageIds.forEach { imageId -> + when (deleteFeedImageUseCase(feedId, imageId)) { + is BaseResult.Error -> hasImageError = true + is BaseResult.Success -> {} + } + } + + val newImages = _selectedImages.value ?: emptyList() + if (newImages.isNotEmpty()) { + val imageUriStrings = newImages.map { it.toString() } + when (uploadFeedImagesUseCase(feedId, imageUriStrings)) { + is BaseResult.Error -> hasImageError = true + is BaseResult.Success -> {} + } + } + + if (hasImageError) { + _toastEvent.emit(UiText.StringResource(R.string.feed_form_image_upload_error)) + } + _updateSuccess.value = true + } + is BaseResult.Error -> { + _toastEvent.emit(UiText.StringResource(R.string.feed_form_update_error)) + } + } + } else { + val model = + CreateFeedModel( + feedContent = content, + feedTypeId = _selectedType.value?.exerciseTypeId, + ) + when (val result = createFeedUseCase(model)) { + is BaseResult.Success -> { + val createdFeedId = result.data.feedId + val images = _selectedImages.value ?: emptyList() + if (images.isNotEmpty()) { + val imageUriStrings = images.map { it.toString() } + when (uploadFeedImagesUseCase(createdFeedId, imageUriStrings)) { + is BaseResult.Success -> { + _createSuccess.value = createdFeedId + } + is BaseResult.Error -> { + _createSuccess.value = createdFeedId + _toastEvent.emit(UiText.StringResource(R.string.feed_form_image_upload_error)) + } + } + } else { + _createSuccess.value = createdFeedId + } + } + is BaseResult.Error -> { + _toastEvent.emit(UiText.StringResource(R.string.feed_form_create_error)) + } + } + } + _isLoading.value = false + } + } + } diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedImage.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedImage.kt new file mode 100644 index 00000000..59159d71 --- /dev/null +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedImage.kt @@ -0,0 +1,14 @@ +package com.project200.undabang.feature.feed.form + +import android.net.Uri + +data class RegisteredImage( + val imageId: Long, + val imageUrl: String, +) + +sealed class FeedFormImageItem { + data class Existing(val imageId: Long, val imageUrl: String) : FeedFormImageItem() + + data class New(val uri: Uri) : FeedFormImageItem() +} diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListAdapter.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListAdapter.kt new file mode 100644 index 00000000..07d6c347 --- /dev/null +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListAdapter.kt @@ -0,0 +1,99 @@ +package com.project200.undabang.feature.feed.list + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.project200.domain.model.Feed +import com.project200.presentation.utils.RelativeTimeUtil +import com.project200.undabang.feature.feed.R +import com.project200.undabang.feature.feed.databinding.ItemFeedBinding + +class FeedListAdapter( + private val onItemClick: (Feed) -> Unit, +) : RecyclerView.Adapter() { + private val items = mutableListOf() + private var currentMemberId: String? = null + + fun submitList(newItems: List) { + items.clear() + items.addAll(newItems) + notifyDataSetChanged() + } + + fun setCurrentMemberId(memberId: String) { + currentMemberId = memberId + notifyDataSetChanged() + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): FeedViewHolder { + val binding = + ItemFeedBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return FeedViewHolder(binding) + } + + override fun onBindViewHolder( + holder: FeedViewHolder, + position: Int, + ) { + holder.bind(items[position]) + } + + override fun getItemCount(): Int = items.size + + inner class FeedViewHolder(private val binding: ItemFeedBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(feed: Feed) { + with(binding) { + nicknameTv.text = feed.nickname + timeTv.text = RelativeTimeUtil.getRelativeTime(feed.feedCreatedAt) + + val hasType = !feed.feedTypeName.isNullOrBlank() + arrowIv.visibility = if (hasType) View.VISIBLE else View.GONE + feedTypeTv.visibility = if (hasType) View.VISIBLE else View.GONE + if (hasType) { + feedTypeTv.text = feed.feedTypeName + } + + Glide.with(root.context) + .load(feed.profileUrl) + .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default) + .error(com.project200.undabang.presentation.R.drawable.ic_profile_default) + .circleCrop() + .into(profileIv) + + contentTv.text = feed.feedContent + + if (feed.feedPictures.isNotEmpty()) { + imagesRv.visibility = View.VISIBLE + + val imageAdapter = ImageRVAdapter(feed.feedPictures) + imagesRv.adapter = imageAdapter + } else { + imagesRv.visibility = View.GONE + } + + likeIv.setImageResource( + if (feed.feedIsLiked) { + R.drawable.ic_like_fill + } else { + R.drawable.ic_like + }, + ) + + likeCountTv.text = feed.feedLikesCount.toString() + commentCountTv.text = feed.feedCommentsCount.toString() + + root.setOnClickListener { onItemClick(feed) } + } + } + } +} diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListFragment.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListFragment.kt new file mode 100644 index 00000000..4b350730 --- /dev/null +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListFragment.kt @@ -0,0 +1,170 @@ +package com.project200.undabang.feature.feed.list + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.project200.presentation.base.BindingFragment +import com.project200.presentation.utils.collectToast +import com.project200.presentation.view.SelectionBottomSheetDialog +import com.project200.undabang.feature.feed.R +import com.project200.undabang.feature.feed.databinding.FragmentFeedListBinding +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class FeedListFragment : BindingFragment(R.layout.fragment_feed_list) { + private val viewModel: FeedListViewModel by viewModels() + private lateinit var feedAdapter: FeedListAdapter + + override fun getViewBinding(view: View): FragmentFeedListBinding { + return FragmentFeedListBinding.bind(view) + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + initAdapter() + initToolbar() + initView() + initObserver() + observeRefreshSignal() + } + + private fun observeRefreshSignal() { + val savedStateHandle = findNavController().currentBackStackEntry?.savedStateHandle + savedStateHandle?.getLiveData(REFRESH_KEY)?.observe(viewLifecycleOwner) { shouldRefresh -> + if (shouldRefresh) { + viewModel.loadFeeds(isRefresh = true) + savedStateHandle.remove(REFRESH_KEY) + } + } + } + + private fun initAdapter() { + feedAdapter = + FeedListAdapter( + onItemClick = { feed -> + findNavController().navigate( + FeedListFragmentDirections.actionFeedListFragmentToFeedDetailFragment(feed.feedId), + ) + }, + ) + } + + private fun initToolbar() { + binding.baseToolbar.apply { + setTitle(getString(R.string.feed_title)) + showBackButton(false) + setSubButton(R.drawable.ic_feed_add) { + findNavController().navigate( + FeedListFragmentDirections.actionFeedListFragmentToFeedFormFragment(), + ) + } + setSecondarySubButton(R.drawable.ic_category) { + showCategoryBottomSheet() + } + } + } + + private fun showCategoryBottomSheet() { + viewModel.requestShowCategoryBottomSheet() + } + + private fun displayCategoryBottomSheet(items: List) { + SelectionBottomSheetDialog(items) { selectedType -> + viewModel.selectType(selectedType) + }.show(parentFragmentManager, SelectionBottomSheetDialog::class.java.name) + viewModel.onCategoryBottomSheetShown() + } + + private fun initView() { + binding.swipeRefreshLayout.setOnRefreshListener { + viewModel.loadFeeds(isRefresh = true) + } + + binding.feedListRv.apply { + adapter = feedAdapter + layoutManager = LinearLayoutManager(context) + addOnScrollListener( + object : RecyclerView.OnScrollListener() { + override fun onScrolled( + recyclerView: RecyclerView, + dx: Int, + dy: Int, + ) { + super.onScrolled(recyclerView, dx, dy) + + val layoutManager = recyclerView.layoutManager as LinearLayoutManager + val totalItemCount = layoutManager.itemCount + val lastVisibleItem = layoutManager.findLastVisibleItemPosition() + + if (viewModel.canLoadMore() && totalItemCount <= (lastVisibleItem + 5)) { + viewModel.loadFeeds() + } + } + }, + ) + } + } + + private fun initObserver() { + viewModel.feedList.observe(viewLifecycleOwner) { feeds -> + feedAdapter.submitList(feeds) + } + + viewModel.showEmptyView.observe(viewLifecycleOwner) { showEmpty -> + binding.emptyTv.visibility = if (showEmpty) View.VISIBLE else View.GONE + } + + viewModel.showCategoryBottomSheet.observe(viewLifecycleOwner) { items -> + if (!items.isNullOrEmpty()) { + displayCategoryBottomSheet(items) + } + } + + viewModel.selectedType.observe(viewLifecycleOwner) { type -> + binding.baseToolbar.apply { + if (type != null) { + setTitle(type) + setSecondarySubButton(null) + showBackButton(true) { viewModel.clearType() } + } else { + setTitle(getString(R.string.feed_title)) + setSecondarySubButton(R.drawable.ic_category) { + showCategoryBottomSheet() + } + showBackButton(false) + } + } + } + + viewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> + val isInitialLoading = isLoading && feedAdapter.itemCount == 0 + if (isInitialLoading) { + binding.shimmerLayout.visibility = View.VISIBLE + binding.shimmerLayout.startShimmer() + binding.swipeRefreshLayout.visibility = View.GONE + binding.emptyTv.visibility = View.GONE + } else { + binding.shimmerLayout.stopShimmer() + binding.shimmerLayout.visibility = View.GONE + binding.swipeRefreshLayout.visibility = View.VISIBLE + binding.swipeRefreshLayout.isRefreshing = false + } + } + + collectToast(viewModel.toastEvent) + + viewModel.currentMemberId.observe(viewLifecycleOwner) { memberId -> + feedAdapter.setCurrentMemberId(memberId) + } + } + + companion object { + const val REFRESH_KEY = "feed_list_refresh" + } +} diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListViewModel.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListViewModel.kt new file mode 100644 index 00000000..62d63260 --- /dev/null +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListViewModel.kt @@ -0,0 +1,204 @@ +package com.project200.undabang.feature.feed.list + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.project200.domain.model.BaseResult +import com.project200.domain.model.Feed +import com.project200.domain.usecase.DeleteFeedUseCase +import com.project200.domain.usecase.GetFeedsUseCase +import com.project200.domain.usecase.GetMemberIdUseCase +import com.project200.domain.usecase.GetPreferredExerciseTypesUseCase +import com.project200.domain.usecase.GetPreferredExerciseUseCase +import com.project200.presentation.utils.UiText +import com.project200.undabang.feature.feed.R +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class FeedListViewModel + @Inject + constructor( + private val getFeedsUseCase: GetFeedsUseCase, + private val getPreferredExerciseUseCase: GetPreferredExerciseUseCase, + private val getPreferredExerciseTypesUseCase: GetPreferredExerciseTypesUseCase, + private val getMemberIdUseCase: GetMemberIdUseCase, + private val deleteFeedUseCase: DeleteFeedUseCase, + ) : ViewModel() { + private val _feedList = MutableLiveData>() + val feedList: LiveData> get() = _feedList + + private val _selectedType = MutableLiveData(null) + val selectedType: LiveData = _selectedType + + private val _isLoading = MutableLiveData(false) + val isLoading: LiveData get() = _isLoading + + private val _toastEvent = MutableSharedFlow() + val toastEvent: SharedFlow = _toastEvent.asSharedFlow() + + private val _isEmpty = MutableLiveData(false) + val isEmpty: LiveData get() = _isEmpty + + private val _exerciseTypeList = MutableLiveData>() + val exerciseTypeList: LiveData> = _exerciseTypeList + + private val _currentMemberId = MutableLiveData() + val currentMemberId: LiveData get() = _currentMemberId + + private val _showEmptyView = MutableLiveData(false) + val showEmptyView: LiveData get() = _showEmptyView + + private val _showCategoryBottomSheet = MutableLiveData?>() + val showCategoryBottomSheet: LiveData?> get() = _showCategoryBottomSheet + + private var hasNext: Boolean = true + private var lastFeedId: Long? = null + private val allFeeds = mutableListOf() + + companion object { + private const val DEFAULT_PAGE_SIZE = 10 + } + + init { + loadFeeds() + loadExerciseTypes() + loadCurrentMemberId() + } + + private fun loadCurrentMemberId() { + viewModelScope.launch { + _currentMemberId.value = getMemberIdUseCase() + } + } + + fun selectType(type: String?) { + _selectedType.value = type + updateFilteredList() + } + + fun clearType() { + _selectedType.value = null + updateFilteredList() + } + + fun canLoadMore(): Boolean { + return _selectedType.value == null && _isLoading.value != true && hasNext + } + + fun requestShowCategoryBottomSheet() { + val items = _exerciseTypeList.value + if (items.isNullOrEmpty()) { + loadExerciseTypes() + } else { + _showCategoryBottomSheet.value = items + } + } + + fun onCategoryBottomSheetShown() { + _showCategoryBottomSheet.value = null + } + + private fun updateShowEmptyView() { + _showEmptyView.value = _isEmpty.value == true && _isLoading.value != true + } + + private fun updateFilteredList() { + val type = _selectedType.value + if (type == null) { + _feedList.value = allFeeds.toList() + } else { + _feedList.value = allFeeds.filter { it.feedTypeName == type } + } + } + + fun loadExerciseTypes() { + if (!_exerciseTypeList.value.isNullOrEmpty()) return + viewModelScope.launch { + val preferredResult = getPreferredExerciseUseCase() + val preferredNames = + if (preferredResult is BaseResult.Success) { + preferredResult.data.map { it.name } + } else { + emptyList() + } + + val allTypesResult = getPreferredExerciseTypesUseCase() + val allNames = + if (allTypesResult is BaseResult.Success) { + allTypesResult.data.map { it.name } + } else { + emptyList() + } + + val combinedList = + mutableListOf().apply { + addAll(preferredNames) + addAll(allNames.filterNot { preferredNames.contains(it) }) + } + + _exerciseTypeList.value = combinedList + } + } + + fun loadFeeds(isRefresh: Boolean = false) { + if (isRefresh) { + hasNext = true + lastFeedId = null + allFeeds.clear() + } + + if (!hasNext || (_isLoading.value == true && !isRefresh)) return + + _isLoading.value = true + + viewModelScope.launch { + when (val result = getFeedsUseCase(lastFeedId, DEFAULT_PAGE_SIZE)) { + is BaseResult.Success -> { + val newFeeds = result.data.feeds + hasNext = result.data.hasNext + + if (newFeeds.isNotEmpty()) { + lastFeedId = newFeeds.last().feedId + allFeeds.addAll(newFeeds) + } + updateFilteredList() + _isEmpty.value = allFeeds.isEmpty() + updateShowEmptyView() + } + is BaseResult.Error -> { + _toastEvent.emit(UiText.StringResource(R.string.unknown_error)) + if (allFeeds.isEmpty()) { + _isEmpty.value = true + _feedList.value = emptyList() + updateShowEmptyView() + } + } + } + _isLoading.value = false + updateShowEmptyView() + } + } + + fun deleteFeed(feedId: Long) { + viewModelScope.launch { + when (deleteFeedUseCase(feedId)) { + is BaseResult.Success -> { + allFeeds.removeAll { it.feedId == feedId } + updateFilteredList() + _isEmpty.value = allFeeds.isEmpty() + updateShowEmptyView() + _toastEvent.emit(UiText.StringResource(R.string.feed_deleted)) + } + is BaseResult.Error -> { + _toastEvent.emit(UiText.StringResource(R.string.feed_delete_error)) + } + } + } + } + } diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/ImageRVAdapter.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/ImageRVAdapter.kt new file mode 100644 index 00000000..99507e97 --- /dev/null +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/ImageRVAdapter.kt @@ -0,0 +1,48 @@ +package com.project200.undabang.feature.feed.list + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.project200.domain.model.FeedPicture +import com.project200.presentation.utils.UiUtils.dpToPx +import com.project200.undabang.feature.feed.R +import com.project200.undabang.feature.feed.databinding.ItemFeedImageBinding + +class ImageRVAdapter(private val pictures: List) : + RecyclerView.Adapter() { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): ImageViewHolder { + val binding = + ItemFeedImageBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ImageViewHolder(binding) + } + + override fun onBindViewHolder( + holder: ImageViewHolder, + position: Int, + ) { + holder.bind(pictures[position]) + } + + override fun getItemCount(): Int = pictures.size + + inner class ImageViewHolder(private val binding: ItemFeedImageBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(picture: FeedPicture) { + Glide.with(binding.feedImageIv.context) + .load(picture.feedPictureUrl) + .placeholder(R.drawable.ic_feed_image_placeholder) + .transform(RoundedCorners(dpToPx(binding.root.context, 12f))) + .error(R.drawable.ic_feed_image_placeholder) + .into(binding.feedImageIv) + } + } +} diff --git a/feature/feed/src/main/res/drawable/bg_edit_text_rounded.xml b/feature/feed/src/main/res/drawable/bg_edit_text_rounded.xml new file mode 100644 index 00000000..1bff3a52 --- /dev/null +++ b/feature/feed/src/main/res/drawable/bg_edit_text_rounded.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/feature/feed/src/main/res/drawable/bg_image_corner.xml b/feature/feed/src/main/res/drawable/bg_image_corner.xml new file mode 100644 index 00000000..98eb7c37 --- /dev/null +++ b/feature/feed/src/main/res/drawable/bg_image_corner.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/feature/feed/src/main/res/drawable/ic_add_image.xml b/feature/feed/src/main/res/drawable/ic_add_image.xml new file mode 100644 index 00000000..e5f850fb --- /dev/null +++ b/feature/feed/src/main/res/drawable/ic_add_image.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/feed/src/main/res/drawable/ic_category.xml b/feature/feed/src/main/res/drawable/ic_category.xml new file mode 100644 index 00000000..88233247 --- /dev/null +++ b/feature/feed/src/main/res/drawable/ic_category.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/feed/src/main/res/drawable/ic_comment.xml b/feature/feed/src/main/res/drawable/ic_comment.xml new file mode 100644 index 00000000..de2ef37d --- /dev/null +++ b/feature/feed/src/main/res/drawable/ic_comment.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/feed/src/main/res/drawable/ic_delete_circle.xml b/feature/feed/src/main/res/drawable/ic_delete_circle.xml new file mode 100644 index 00000000..07f8b290 --- /dev/null +++ b/feature/feed/src/main/res/drawable/ic_delete_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/feed/src/main/res/drawable/ic_feed_add.xml b/feature/feed/src/main/res/drawable/ic_feed_add.xml new file mode 100644 index 00000000..87ec4633 --- /dev/null +++ b/feature/feed/src/main/res/drawable/ic_feed_add.xml @@ -0,0 +1,13 @@ + + + + diff --git a/feature/feed/src/main/res/drawable/ic_feed_image_placeholder.xml b/feature/feed/src/main/res/drawable/ic_feed_image_placeholder.xml new file mode 100644 index 00000000..34162a1c --- /dev/null +++ b/feature/feed/src/main/res/drawable/ic_feed_image_placeholder.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/feed/src/main/res/drawable/ic_like.xml b/feature/feed/src/main/res/drawable/ic_like.xml new file mode 100644 index 00000000..ca4ce4d0 --- /dev/null +++ b/feature/feed/src/main/res/drawable/ic_like.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/feed/src/main/res/drawable/ic_like_fill.xml b/feature/feed/src/main/res/drawable/ic_like_fill.xml new file mode 100644 index 00000000..edb72766 --- /dev/null +++ b/feature/feed/src/main/res/drawable/ic_like_fill.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/feed/src/main/res/drawable/ic_more.xml b/feature/feed/src/main/res/drawable/ic_more.xml new file mode 100644 index 00000000..79c293ce --- /dev/null +++ b/feature/feed/src/main/res/drawable/ic_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/feed/src/main/res/drawable/ic_send.xml b/feature/feed/src/main/res/drawable/ic_send.xml new file mode 100644 index 00000000..c7efa9f4 --- /dev/null +++ b/feature/feed/src/main/res/drawable/ic_send.xml @@ -0,0 +1,12 @@ + + + + diff --git a/feature/feed/src/main/res/drawable/ic_send_unable.xml b/feature/feed/src/main/res/drawable/ic_send_unable.xml new file mode 100644 index 00000000..1b68b2ff --- /dev/null +++ b/feature/feed/src/main/res/drawable/ic_send_unable.xml @@ -0,0 +1,12 @@ + + + + diff --git a/feature/feed/src/main/res/layout/fragment_feed_detail.xml b/feature/feed/src/main/res/layout/fragment_feed_detail.xml new file mode 100644 index 00000000..700ad015 --- /dev/null +++ b/feature/feed/src/main/res/layout/fragment_feed_detail.xml @@ -0,0 +1,255 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/feed/src/main/res/layout/fragment_feed_form.xml b/feature/feed/src/main/res/layout/fragment_feed_form.xml new file mode 100644 index 00000000..4fd4e8ef --- /dev/null +++ b/feature/feed/src/main/res/layout/fragment_feed_form.xml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/feed/src/main/res/layout/fragment_feed_list.xml b/feature/feed/src/main/res/layout/fragment_feed_list.xml new file mode 100644 index 00000000..1d9652c1 --- /dev/null +++ b/feature/feed/src/main/res/layout/fragment_feed_list.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/feed/src/main/res/layout/item_comment.xml b/feature/feed/src/main/res/layout/item_comment.xml new file mode 100644 index 00000000..7ec01c7a --- /dev/null +++ b/feature/feed/src/main/res/layout/item_comment.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/feature/feed/src/main/res/layout/item_feed.xml b/feature/feed/src/main/res/layout/item_feed.xml new file mode 100644 index 00000000..d9c08e24 --- /dev/null +++ b/feature/feed/src/main/res/layout/item_feed.xml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/feed/src/main/res/layout/item_feed_form_image.xml b/feature/feed/src/main/res/layout/item_feed_form_image.xml new file mode 100644 index 00000000..045c7b53 --- /dev/null +++ b/feature/feed/src/main/res/layout/item_feed_form_image.xml @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/feature/feed/src/main/res/layout/item_feed_image.xml b/feature/feed/src/main/res/layout/item_feed_image.xml new file mode 100644 index 00000000..42f74c73 --- /dev/null +++ b/feature/feed/src/main/res/layout/item_feed_image.xml @@ -0,0 +1,9 @@ + + diff --git a/feature/feed/src/main/res/layout/item_feed_skeleton.xml b/feature/feed/src/main/res/layout/item_feed_skeleton.xml new file mode 100644 index 00000000..f2099edf --- /dev/null +++ b/feature/feed/src/main/res/layout/item_feed_skeleton.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/feed/src/main/res/layout/item_reply.xml b/feature/feed/src/main/res/layout/item_reply.xml new file mode 100644 index 00000000..89a31aca --- /dev/null +++ b/feature/feed/src/main/res/layout/item_reply.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + diff --git a/feature/feed/src/main/res/layout/layout_comment_input.xml b/feature/feed/src/main/res/layout/layout_comment_input.xml new file mode 100644 index 00000000..bfd15a29 --- /dev/null +++ b/feature/feed/src/main/res/layout/layout_comment_input.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + diff --git a/feature/feed/src/main/res/layout/layout_feed_list_skeleton.xml b/feature/feed/src/main/res/layout/layout_feed_list_skeleton.xml new file mode 100644 index 00000000..faf41bc4 --- /dev/null +++ b/feature/feed/src/main/res/layout/layout_feed_list_skeleton.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/feature/feed/src/main/res/navigation/feed_nav_graph.xml b/feature/feed/src/main/res/navigation/feed_nav_graph.xml new file mode 100644 index 00000000..921464ee --- /dev/null +++ b/feature/feed/src/main/res/navigation/feed_nav_graph.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + diff --git a/feature/feed/src/main/res/values/colors.xml b/feature/feed/src/main/res/values/colors.xml new file mode 100644 index 00000000..2ccc0866 --- /dev/null +++ b/feature/feed/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #446DF6 + \ No newline at end of file diff --git a/feature/feed/src/main/res/values/strings.xml b/feature/feed/src/main/res/values/strings.xml new file mode 100644 index 00000000..8954c5bf --- /dev/null +++ b/feature/feed/src/main/res/values/strings.xml @@ -0,0 +1,40 @@ + + + + 피드 + 아직 작성된 피드가 없어요\n첫 번째 피드를 작성해보세요! + 피드를 불러오는 데 실패했습니다. + 좋아요 %d + 댓글 %d + 다방 선택 + 알 수 없는 오류가 발생했습니다. + + + 새로운 피드 + 피드 수정 + 완료 + 피드 내용을 입력해주세요. + 이미지 추가 + 내용을 입력해주세요. + 피드가 등록되었습니다. + 피드 등록에 실패했습니다. + 피드는 등록되었으나 이미지 업로드에 실패했습니다. + 피드가 수정되었습니다. + 피드 수정에 실패했습니다. + 프로필을 불러오는데 실패했습니다. + @%s 님에게 답글 작성 중 + + + 답글 달기 + 댓글 추가… + 아직 댓글이 없습니다. + 데이터를 불러오는데 실패했습니다. + 피드가 삭제되었습니다. + 피드 삭제에 실패했습니다. + 댓글이 작성되었습니다. + 댓글이 삭제되었습니다. + 댓글을 불러오는데 실패했습니다. + 댓글 작성에 실패했습니다. + 댓글 삭제에 실패했습니다. + 좋아요 처리에 실패했습니다. + diff --git a/feature/feed/src/test/java/com/project200/feature/feed/ExampleUnitTest.kt b/feature/feed/src/test/java/com/project200/feature/feed/ExampleUnitTest.kt new file mode 100644 index 00000000..6c7e5b50 --- /dev/null +++ b/feature/feed/src/test/java/com/project200/feature/feed/ExampleUnitTest.kt @@ -0,0 +1,10 @@ +package com.project200.feature.feed + +import org.junit.Test + +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assert(2 + 2 == 4) + } +} diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/MypageFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/MypageFragment.kt index 994fca75..9c9cb8cb 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/MypageFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/MypageFragment.kt @@ -242,8 +242,8 @@ class MypageFragment : BindingFragment(R.layout.fragment_ Glide.with(binding.mypageProfileIv) .load(imageRes) - .placeholder(R.drawable.ic_profile_default) - .error(R.drawable.ic_profile_default) + .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default) + .error(com.project200.undabang.presentation.R.drawable.ic_profile_default) .into(binding.mypageProfileIv) } diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt index d72d645b..ea28826c 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt @@ -215,8 +215,8 @@ class ProfileEditFragment : private fun setupProfileImage(imageUrl: String?) { Glide.with(binding.profileImgIv) .load(imageUrl) - .placeholder(R.drawable.ic_profile_default) - .error(R.drawable.ic_profile_default) + .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default) + .error(com.project200.undabang.presentation.R.drawable.ic_profile_default) .into(binding.profileImgIv) } diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageAdapter.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageAdapter.kt index 68f5aef3..906000b9 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageAdapter.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageAdapter.kt @@ -30,7 +30,7 @@ class ProfileImageAdapter : ListAdapter Unit)? = null, ) { binding.backBtn.apply { + setImageResource(com.project200.undabang.presentation.R.drawable.ic_arrow_back) visibility = if (show) View.VISIBLE else View.INVISIBLE setOnClickListener { onClick?.invoke() } } diff --git a/presentation/src/main/java/com/project200/presentation/utils/FlowExtensions.kt b/presentation/src/main/java/com/project200/presentation/utils/FlowExtensions.kt new file mode 100644 index 00000000..f251c3cf --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/utils/FlowExtensions.kt @@ -0,0 +1,19 @@ +package com.project200.presentation.utils + +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +inline fun Fragment.collectFlow( + flow: Flow, + crossinline action: suspend (T) -> Unit, +) { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + flow.collect { action(it) } + } + } +} diff --git a/presentation/src/main/java/com/project200/presentation/utils/RelativeTimeUtil.kt b/presentation/src/main/java/com/project200/presentation/utils/RelativeTimeUtil.kt new file mode 100644 index 00000000..c6ef67ec --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/utils/RelativeTimeUtil.kt @@ -0,0 +1,37 @@ +package com.project200.presentation.utils + +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit + +object RelativeTimeUtil { + fun getRelativeTime(localDateTime: LocalDateTime?): String { + if (localDateTime == null) return "" + + val now = LocalDateTime.now() + val seconds = ChronoUnit.SECONDS.between(localDateTime, now) + val minutes = ChronoUnit.MINUTES.between(localDateTime, now) + val hours = ChronoUnit.HOURS.between(localDateTime, now) + val days = ChronoUnit.DAYS.between(localDateTime, now) + + return when { + seconds < 60 -> "방금 전" + minutes < 60 -> "${minutes}분 전" + hours < 24 -> "${hours}시간 전" + days < 7 -> "${days}일 전" + days < 30 -> "${days / 7}주 전" + days < 365 -> "${days / 30}개월 전" + else -> "${days / 365}년 전" + } + } + + fun getRelativeTime(isoString: String?): String { + if (isoString.isNullOrEmpty()) return "" + return try { + val parsed = LocalDateTime.parse(isoString, DateTimeFormatter.ISO_DATE_TIME) + getRelativeTime(parsed) + } catch (e: Exception) { + isoString + } + } +} diff --git a/presentation/src/main/java/com/project200/presentation/utils/ToastUtils.kt b/presentation/src/main/java/com/project200/presentation/utils/ToastUtils.kt new file mode 100644 index 00000000..1e148cca --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/utils/ToastUtils.kt @@ -0,0 +1,22 @@ +package com.project200.presentation.utils + +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +fun Fragment.collectToast( + toastFlow: Flow, + duration: Int = Toast.LENGTH_SHORT, +) { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + toastFlow.collect { uiText -> + Toast.makeText(requireContext(), uiText.asString(requireContext()), duration).show() + } + } + } +} diff --git a/presentation/src/main/java/com/project200/presentation/utils/UiText.kt b/presentation/src/main/java/com/project200/presentation/utils/UiText.kt new file mode 100644 index 00000000..591cdec0 --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/utils/UiText.kt @@ -0,0 +1,38 @@ +package com.project200.presentation.utils + +import android.content.Context +import androidx.annotation.StringRes + +sealed class UiText { + data class DynamicString(val value: String) : UiText() + + class StringResource( + @StringRes val resId: Int, + vararg val args: Any, + ) : UiText() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is StringResource) return false + return resId == other.resId && args.contentEquals(other.args) + } + + override fun hashCode(): Int { + var result = resId + result = 31 * result + args.contentHashCode() + return result + } + } + + fun asString(context: Context): String { + return when (this) { + is DynamicString -> value + is StringResource -> { + if (args.isEmpty()) { + context.getString(resId) + } else { + context.getString(resId, *args) + } + } + } + } +} diff --git a/presentation/src/main/java/com/project200/presentation/view/MenuBottomSheetDialog.kt b/presentation/src/main/java/com/project200/presentation/view/MenuBottomSheetDialog.kt index 0d8d6b95..ab76df99 100644 --- a/presentation/src/main/java/com/project200/presentation/view/MenuBottomSheetDialog.kt +++ b/presentation/src/main/java/com/project200/presentation/view/MenuBottomSheetDialog.kt @@ -9,12 +9,14 @@ import com.project200.undabang.presentation.R import com.project200.undabang.presentation.databinding.BottomSheetDialogMenuBinding /** 메뉴 바텀 시트 다이얼로그 - * @param onEditClicked 수정 버튼 클릭 시 호출되는 콜백 + * @param onEditClicked 수정 버튼 클릭 시 호출되는 콜백 (showEditButton이 true일 때만 사용) * @param onDeleteClicked 삭제 버튼 클릭 시 호출되는 콜백 + * @param showEditButton 수정 버튼 표시 여부 (기본값: true) */ class MenuBottomSheetDialog( - val onEditClicked: () -> Unit, + val onEditClicked: () -> Unit = {}, val onDeleteClicked: () -> Unit, + val showEditButton: Boolean = true, ) : BottomSheetDialogFragment() { private var _binding: BottomSheetDialogMenuBinding? = null val binding get() = _binding!! @@ -39,9 +41,14 @@ class MenuBottomSheetDialog( ) { super.onViewCreated(view, savedInstanceState) - binding.editBtn.setOnClickListener { - onEditClicked() - dismiss() + if (showEditButton) { + binding.editBtn.visibility = View.VISIBLE + binding.editBtn.setOnClickListener { + onEditClicked() + dismiss() + } + } else { + binding.editBtn.visibility = View.GONE } binding.deleteBtn.setOnClickListener { diff --git a/presentation/src/main/res/drawable/bg_circle_gray.xml b/presentation/src/main/res/drawable/bg_circle_gray.xml new file mode 100644 index 00000000..600cffb8 --- /dev/null +++ b/presentation/src/main/res/drawable/bg_circle_gray.xml @@ -0,0 +1,5 @@ + + + + diff --git a/feature/profile/src/main/res/drawable/ic_profile_default.xml b/presentation/src/main/res/drawable/ic_profile_default.xml similarity index 100% rename from feature/profile/src/main/res/drawable/ic_profile_default.xml rename to presentation/src/main/res/drawable/ic_profile_default.xml diff --git a/presentation/src/main/res/layout/view_base_toolbar.xml b/presentation/src/main/res/layout/view_base_toolbar.xml index 883afd44..11c16acb 100644 --- a/presentation/src/main/res/layout/view_base_toolbar.xml +++ b/presentation/src/main/res/layout/view_base_toolbar.xml @@ -1,44 +1,56 @@ - + android:src="@drawable/ic_arrow_back" + android:visibility="visible" + android:layout_marginStart="4dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + android:visibility="invisible" + android:layout_marginEnd="4dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" /> - + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@+id/sub_btn" + app:layout_constraintTop_toTopOf="parent" /> + + diff --git a/presentation/src/main/res/values/styles.xml b/presentation/src/main/res/values/styles.xml index 429d3ec3..2e89dc0c 100644 --- a/presentation/src/main/res/values/styles.xml +++ b/presentation/src/main/res/values/styles.xml @@ -71,4 +71,10 @@ @style/MenuTextAppearance + + + \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 9fb5ef6d..91b4bc7d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -41,3 +41,4 @@ include(":feature:exercise") include(":feature:timer") include(":feature:matching") include(":feature:chatting") +include(":feature:feed")