diff --git a/app/src/main/java/com/texthip/thip/data/model/comments/response/CommentsDeleteResponse.kt b/app/src/main/java/com/texthip/thip/data/model/comments/response/CommentsDeleteResponse.kt new file mode 100644 index 00000000..21d8cfc9 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/comments/response/CommentsDeleteResponse.kt @@ -0,0 +1,8 @@ +package com.texthip.thip.data.model.comments.response + +import kotlinx.serialization.Serializable + +@Serializable +data class CommentsDeleteResponse( + val postId: Long +) diff --git a/app/src/main/java/com/texthip/thip/data/model/comments/response/CommentsResponse.kt b/app/src/main/java/com/texthip/thip/data/model/comments/response/CommentsResponse.kt index 0db6c4d7..3e35e956 100644 --- a/app/src/main/java/com/texthip/thip/data/model/comments/response/CommentsResponse.kt +++ b/app/src/main/java/com/texthip/thip/data/model/comments/response/CommentsResponse.kt @@ -11,16 +11,17 @@ data class CommentsResponse( @Serializable data class CommentList( - val commentId: Int, - val creatorId: Int, - val creatorProfileImageUrl: String, - val creatorNickname: String, - val aliasName: String, - val aliasColor: String, - val postDate: String, - val content: String, + val commentId: Int?, + val creatorId: Int?, + val creatorProfileImageUrl: String?, + val creatorNickname: String?, + val aliasName: String?, + val aliasColor: String?, + val postDate: String?, + val content: String?, val likeCount: Int, val isDeleted: Boolean, + val isWriter: Boolean, val isLike: Boolean, val replyList: List, ) @@ -38,4 +39,5 @@ data class ReplyList( val content: String, val likeCount: Int, val isLike: Boolean, + val isWriter: Boolean, ) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/feed/response/FeedDetailResponse.kt b/app/src/main/java/com/texthip/thip/data/model/feed/response/FeedDetailResponse.kt new file mode 100644 index 00000000..9b03febd --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/feed/response/FeedDetailResponse.kt @@ -0,0 +1,26 @@ +package com.texthip.thip.data.model.feed.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FeedDetailResponse( + @SerialName("feedId") val feedId: Int, + @SerialName("creatorId") val creatorId: Int, + @SerialName("creatorNickname") val creatorNickname: String, + @SerialName("creatorProfileImageUrl") val creatorProfileImageUrl: String?, + @SerialName("aliasName") val aliasName: String, + @SerialName("aliasColor") val aliasColor: String, + @SerialName("postDate") val postDate: String, + @SerialName("bookTitle") val bookTitle: String, + @SerialName("isbn") val isbn: String, + @SerialName("bookAuthor") val bookAuthor: String, + @SerialName("contentBody") val contentBody: String, + @SerialName("contentUrls") val contentUrls: List, + @SerialName("likeCount") val likeCount: Int, + @SerialName("commentCount") val commentCount: Int, + @SerialName("isSaved") val isSaved: Boolean, + @SerialName("isLiked") val isLiked: Boolean, + @SerialName("isWriter") val isWriter: Boolean, + @SerialName("tagList") val tagList: List +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/feed/response/FeedUsersInfoResponse.kt b/app/src/main/java/com/texthip/thip/data/model/feed/response/FeedUsersInfoResponse.kt new file mode 100644 index 00000000..ac5c5b06 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/feed/response/FeedUsersInfoResponse.kt @@ -0,0 +1,16 @@ +package com.texthip.thip.data.model.feed.response + +import kotlinx.serialization.Serializable + +@Serializable +data class FeedUsersInfoResponse( + val creatorId: Int, + val profileImageUrl: String, + val nickname: String, + val aliasName: String, + val aliasColor: String, + val followerCount: Int, + val totalFeedCount: Int, + val isFollowing: Boolean, + val latestFollowerProfileImageUrls: List +) diff --git a/app/src/main/java/com/texthip/thip/data/model/feed/response/FeedUsersResponse.kt b/app/src/main/java/com/texthip/thip/data/model/feed/response/FeedUsersResponse.kt new file mode 100644 index 00000000..5ba8eb19 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/feed/response/FeedUsersResponse.kt @@ -0,0 +1,27 @@ +package com.texthip.thip.data.model.feed.response + +import kotlinx.serialization.Serializable + +@Serializable +data class FeedUsersResponse ( + val feedList: List, + val nextCursor: String? = null, + val isLast: Boolean = false, +) + +@Serializable +data class FeedList( + val feedId: Long, + val postDate: String, + val isbn: String, + val bookTitle: String, + val bookAuthor: String, + val contentBody: String, + val contentUrls: List, + val likeCount: Int, + val commentCount: Int, + val isPublic: Boolean, + val isSaved: Boolean, + val isLiked: Boolean, + val isWriter: Boolean, +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/feeds/response/AllFeedResponse.kt b/app/src/main/java/com/texthip/thip/data/model/feeds/response/AllFeedResponse.kt new file mode 100644 index 00000000..e9bda179 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/feeds/response/AllFeedResponse.kt @@ -0,0 +1,33 @@ +package com.texthip.thip.data.model.feeds.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class AllFeedResponse( + @SerialName("feedList") val feedList: List, + @SerialName("nextCursor") val nextCursor: String?, + @SerialName("isLast") val isLast: Boolean +) + +@Serializable +data class AllFeedItem( + @SerialName("feedId") val feedId: Int, + @SerialName("creatorId") val creatorId: Int, + @SerialName("creatorNickname") val creatorNickname: String, + @SerialName("creatorProfileImageUrl") val creatorProfileImageUrl: String?, + @SerialName("aliasName") val aliasName: String, + @SerialName("aliasColor") val aliasColor: String, + @SerialName("postDate") val postDate: String, + @SerialName("isbn") val isbn: String, + @SerialName("bookTitle") val bookTitle: String, + @SerialName("bookAuthor") val bookAuthor: String, + @SerialName("contentBody") val contentBody: String, + @SerialName("contentUrls") val contentUrls: List, + @SerialName("likeCount") val likeCount: Int, + @SerialName("commentCount") val commentCount: Int, + @SerialName("isSaved") val isSaved: Boolean, + @SerialName("isLiked") val isLiked: Boolean, + @SerialName("isWriter") val isWriter: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/feeds/response/MyFeedResponse.kt b/app/src/main/java/com/texthip/thip/data/model/feeds/response/MyFeedResponse.kt new file mode 100644 index 00000000..dae8e309 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/feeds/response/MyFeedResponse.kt @@ -0,0 +1,27 @@ +package com.texthip.thip.data.model.feeds.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class MyFeedResponse( + @SerialName("feedList") val feedList: List, + @SerialName("nextCursor") val nextCursor: String?, + @SerialName("isLast") val isLast: Boolean +) + +@Serializable +data class MyFeedItem( + @SerialName("feedId") val feedId: Int, + @SerialName("postDate") val postDate: String, + @SerialName("isbn") val isbn: String, + @SerialName("bookTitle") val bookTitle: String, + @SerialName("bookAuthor") val bookAuthor: String, + @SerialName("contentBody") val contentBody: String, + @SerialName("contentUrls") val contentUrls: List, + @SerialName("likeCount") val likeCount: Int, + @SerialName("commentCount") val commentCount: Int, + @SerialName("isPublic") val isPublic: Boolean, + @SerialName("isWriter") val isWriter: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/rooms/request/RoomsDailyGreetingRequest.kt b/app/src/main/java/com/texthip/thip/data/model/rooms/request/RoomsDailyGreetingRequest.kt new file mode 100644 index 00000000..22b080fc --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/rooms/request/RoomsDailyGreetingRequest.kt @@ -0,0 +1,8 @@ +package com.texthip.thip.data.model.rooms.request + +import kotlinx.serialization.Serializable + +@Serializable +data class RoomsDailyGreetingRequest( + val content: String, +) diff --git a/app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsDailyGreetingResponse.kt b/app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsDailyGreetingResponse.kt new file mode 100644 index 00000000..04e567ea --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsDailyGreetingResponse.kt @@ -0,0 +1,10 @@ +package com.texthip.thip.data.model.rooms.response + +import kotlinx.serialization.Serializable + +@Serializable +data class RoomsDailyGreetingResponse( + val attendanceCheckId: Long, + val roomId: Long, + val isFirstWrite: Boolean, +) diff --git a/app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsDeleteVoteResponse.kt b/app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsDeleteVoteResponse.kt new file mode 100644 index 00000000..53f21581 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsDeleteVoteResponse.kt @@ -0,0 +1,8 @@ +package com.texthip.thip.data.model.rooms.response + +import kotlinx.serialization.Serializable + +@Serializable +data class RoomsDeleteVoteResponse( + val roomId: Int +) diff --git a/app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsPostsResponse.kt b/app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsPostsResponse.kt index 7a77e1f8..8df0be26 100644 --- a/app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsPostsResponse.kt +++ b/app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsPostsResponse.kt @@ -24,6 +24,7 @@ data class PostList( val content: String, val likeCount: Int, val commentCount: Int, + val isOverview: Boolean, val isLiked: Boolean, val isWriter: Boolean, val isLocked: Boolean, diff --git a/app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsRecordsPinResponse.kt b/app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsRecordsPinResponse.kt new file mode 100644 index 00000000..14b51994 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsRecordsPinResponse.kt @@ -0,0 +1,11 @@ +package com.texthip.thip.data.model.rooms.response + +import kotlinx.serialization.Serializable + +@Serializable +data class RoomsRecordsPinResponse( + val bookTitle: String, + val authorName: String, + val bookImageUrl: String, + val isbn: String, +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsVoteResponse.kt b/app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsVoteResponse.kt index 1fd74ff9..1d90a17f 100644 --- a/app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsVoteResponse.kt +++ b/app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsVoteResponse.kt @@ -4,7 +4,5 @@ import kotlinx.serialization.Serializable @Serializable data class RoomsVoteResponse( - val voteItemId: Int, - val roomId: Int, - val type: Boolean, -) + val voteItems: List, +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/users/response/MyFollowingsResponse.kt b/app/src/main/java/com/texthip/thip/data/model/users/response/MyFollowingsResponse.kt index 21b395df..aa96cb6e 100644 --- a/app/src/main/java/com/texthip/thip/data/model/users/response/MyFollowingsResponse.kt +++ b/app/src/main/java/com/texthip/thip/data/model/users/response/MyFollowingsResponse.kt @@ -19,16 +19,4 @@ data class FollowingList( @SerializedName("aliasName") val aliasName: String, @SerializedName("aliasColor") val aliasColor: String, @SerializedName("isFollowing") val isFollowing: Boolean -) - -@Serializable -data class MyRecentFollowingsResponse( - @SerializedName("recentWriters") val recentWriters: List -) - -@Serializable -data class RecentWriterList( - @SerializedName("userId") val userId: Long, - @SerializedName("nickname") val nickname: String, - @SerializedName("profileImageUrl") val profileImageUrl: String? ) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/users/response/UsersMyFollowingsRecentFeedsResponse.kt b/app/src/main/java/com/texthip/thip/data/model/users/response/UsersMyFollowingsRecentFeedsResponse.kt new file mode 100644 index 00000000..e275b5d7 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/users/response/UsersMyFollowingsRecentFeedsResponse.kt @@ -0,0 +1,15 @@ +package com.texthip.thip.data.model.users.response + +import kotlinx.serialization.Serializable + +@Serializable +data class UsersMyFollowingsRecentFeedsResponse( + val recentWriters: List +) + +@Serializable +data class RecentWriterList( + val userId: Long, + val nickname: String, + val profileImageUrl: String +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/repository/CommentsRepository.kt b/app/src/main/java/com/texthip/thip/data/repository/CommentsRepository.kt index 90e618ee..a7b10c00 100644 --- a/app/src/main/java/com/texthip/thip/data/repository/CommentsRepository.kt +++ b/app/src/main/java/com/texthip/thip/data/repository/CommentsRepository.kt @@ -50,4 +50,10 @@ class CommentsRepository @Inject constructor( ) ).handleBaseResponse().getOrThrow() } + + suspend fun deleteComment( + commentId: Long + ) = runCatching { + commentsService.deleteComment(commentId).handleBaseResponse().getOrThrow() + } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/repository/FeedRepository.kt b/app/src/main/java/com/texthip/thip/data/repository/FeedRepository.kt index 0a8dc1ae..546e94cb 100644 --- a/app/src/main/java/com/texthip/thip/data/repository/FeedRepository.kt +++ b/app/src/main/java/com/texthip/thip/data/repository/FeedRepository.kt @@ -5,7 +5,10 @@ import android.net.Uri import com.texthip.thip.data.model.base.handleBaseResponse import com.texthip.thip.data.model.feed.request.CreateFeedRequest import com.texthip.thip.data.model.feed.response.CreateFeedResponse +import com.texthip.thip.data.model.feed.response.FeedDetailResponse import com.texthip.thip.data.model.feed.response.FeedWriteInfoResponse +import com.texthip.thip.data.model.feeds.response.AllFeedResponse +import com.texthip.thip.data.model.feeds.response.MyFeedResponse import com.texthip.thip.data.service.FeedService import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -32,7 +35,7 @@ class FeedRepository @Inject constructor( val response = feedService.getFeedWriteInfo() .handleBaseResponse() .getOrThrow() - + // 카테고리 순서 조정 val orderedCategories = response?.categoryList?.sortedBy { category -> when (category.category) { @@ -44,7 +47,7 @@ class FeedRepository @Inject constructor( else -> 999 } } ?: emptyList() - + response?.copy(categoryList = orderedCategories) } @@ -69,23 +72,21 @@ class FeedRepository @Inject constructor( // 임시 파일 목록 추적 val tempFiles = mutableListOf() - - try { - // 이미지 파일들을 MultipartBody.Part로 변환 - val imageParts = if (imageUris.isNotEmpty()) { - withContext(Dispatchers.IO) { - imageUris.mapNotNull { uri -> - try { - uriToMultipartBodyPart(uri, "images", tempFiles) - } catch (e: Exception) { - null - } - } + + // 이미지 파일들을 MultipartBody.Part로 변환 + val imageParts = if (imageUris.isNotEmpty()) { + withContext(Dispatchers.IO) { + imageUris.mapNotNull { uri -> + runCatching { + uriToMultipartBodyPart(uri, "images", tempFiles) + }.getOrNull() } - } else { - null } + } else { + null + } + try { feedService.createFeed(requestBody, imageParts) .handleBaseResponse() .getOrThrow() @@ -95,8 +96,12 @@ class FeedRepository @Inject constructor( } } - private fun uriToMultipartBodyPart(uri: Uri, paramName: String, tempFiles: MutableList): MultipartBody.Part? { - return try { + private fun uriToMultipartBodyPart( + uri: Uri, + paramName: String, + tempFiles: MutableList + ): MultipartBody.Part? { + return runCatching { // MIME 타입 확인 val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg" val extension = when (mimeType) { @@ -105,40 +110,72 @@ class FeedRepository @Inject constructor( "image/jpeg", "image/jpg" -> "jpg" else -> "jpg" // 기본값 } - + // 파일명 생성 val fileName = "feed_image_${System.currentTimeMillis()}.$extension" val tempFile = File(context.cacheDir, fileName) - + // 임시 파일 목록에 추가 tempFiles.add(tempFile) - + // InputStream을 use 블록으로 안전하게 관리 context.contentResolver.openInputStream(uri)?.use { inputStream -> FileOutputStream(tempFile).use { outputStream -> inputStream.copyTo(outputStream) } - } ?: return null - + } ?: throw IllegalStateException("Failed to open input stream for URI: $uri") + // MultipartBody.Part 생성 val requestFile = tempFile.asRequestBody(mimeType.toMediaType()) MultipartBody.Part.createFormData(paramName, fileName, requestFile) - } catch (e: Exception) { + }.onFailure { e -> e.printStackTrace() - null - } + }.getOrNull() + } + + /** 전체 피드 목록 조회 */ + suspend fun getAllFeeds(cursor: String? = null): Result = runCatching { + feedService.getAllFeeds(cursor) + .handleBaseResponse() + .getOrThrow() + } + + /** 내 피드 목록 조회 */ + suspend fun getMyFeeds(cursor: String? = null): Result = runCatching { + feedService.getMyFeeds(cursor) + .handleBaseResponse() + .getOrThrow() + } + + /** 피드 상세 조회 */ + suspend fun getFeedDetail(feedId: Int): Result = runCatching { + feedService.getFeedDetail(feedId) + .handleBaseResponse() + .getOrThrow() } /** 임시 파일들을 정리하는 함수 */ private fun cleanupTempFiles(tempFiles: List) { tempFiles.forEach { file -> - try { + runCatching { if (file.exists()) { file.delete() } - } catch (e: Exception) { + }.onFailure { e -> e.printStackTrace() } } } + + suspend fun getFeedUsersInfo(userId: Long) = runCatching { + feedService.getFeedUsersInfo(userId) + .handleBaseResponse() + .getOrThrow() + } + + suspend fun getFeedUsers(userId: Long) = runCatching { + feedService.getFeedUsers(userId) + .handleBaseResponse() + .getOrThrow() + } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/repository/RoomsRepository.kt b/app/src/main/java/com/texthip/thip/data/repository/RoomsRepository.kt index e4ba9c01..c8539668 100644 --- a/app/src/main/java/com/texthip/thip/data/repository/RoomsRepository.kt +++ b/app/src/main/java/com/texthip/thip/data/repository/RoomsRepository.kt @@ -8,6 +8,7 @@ import com.texthip.thip.data.model.rooms.request.CreateRoomRequest import com.texthip.thip.data.model.rooms.request.RoomJoinRequest import com.texthip.thip.data.model.rooms.request.RoomSecretRoomRequest import com.texthip.thip.data.model.rooms.request.RoomsCreateVoteRequest +import com.texthip.thip.data.model.rooms.request.RoomsDailyGreetingRequest import com.texthip.thip.data.model.rooms.request.RoomsPostsLikesRequest import com.texthip.thip.data.model.rooms.request.RoomsRecordRequest import com.texthip.thip.data.model.rooms.request.RoomsVoteRequest @@ -223,6 +224,16 @@ class RoomsRepository @Inject constructor( ).handleBaseResponse().getOrThrow() } + suspend fun deleteRoomsVote( + roomId: Int, + voteId: Int + ) = runCatching { + roomsService.deleteRoomsVote( + roomId = roomId, + voteId = voteId + ).handleBaseResponse().getOrThrow() + } + suspend fun postRoomsPostsLikes( postId: Int, type: Boolean, @@ -236,4 +247,26 @@ class RoomsRepository @Inject constructor( ) ).handleBaseResponse().getOrThrow() } + + suspend fun postRoomsDailyGreeting( + roomId: Int, + content: String + ) = runCatching { + roomsService.postRoomsDailyGreeting( + roomId = roomId, + request = RoomsDailyGreetingRequest( + content = content + ) + ).handleBaseResponse().getOrThrow() + } + + suspend fun getRoomsRecordsPin( + roomId: Int, + recordId: Int + ) = runCatching { + roomsService.getRoomsRecordsPin( + roomId = roomId, + recordId = recordId + ).handleBaseResponse().getOrThrow() + } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/repository/UserRepository.kt b/app/src/main/java/com/texthip/thip/data/repository/UserRepository.kt index 5cfd6244..425c76c1 100644 --- a/app/src/main/java/com/texthip/thip/data/repository/UserRepository.kt +++ b/app/src/main/java/com/texthip/thip/data/repository/UserRepository.kt @@ -2,12 +2,11 @@ package com.texthip.thip.data.repository import com.texthip.thip.data.model.base.handleBaseResponse import com.texthip.thip.data.model.users.request.FollowRequest -import com.texthip.thip.data.model.users.response.MyFollowingsResponse -import com.texthip.thip.data.model.users.response.MyPageInfoResponse import com.texthip.thip.data.model.users.request.NicknameRequest import com.texthip.thip.data.model.users.response.AliasChoiceResponse import com.texthip.thip.data.model.users.response.FollowResponse -import com.texthip.thip.data.model.users.response.MyRecentFollowingsResponse +import com.texthip.thip.data.model.users.response.MyFollowingsResponse +import com.texthip.thip.data.model.users.response.MyPageInfoResponse import com.texthip.thip.data.model.users.response.NicknameResponse import com.texthip.thip.data.model.users.response.OthersFollowersResponse import com.texthip.thip.data.service.UserService @@ -28,8 +27,8 @@ class UserRepository @Inject constructor( .getOrThrow() } - suspend fun getRecentWriters(): Result = runCatching { - userService.getRecentWriters() + suspend fun getMyFollowingsRecentFeeds() = runCatching { + userService.getMyFollowingsRecentFeeds() .handleBaseResponse() .getOrThrow() } diff --git a/app/src/main/java/com/texthip/thip/data/service/CommentsService.kt b/app/src/main/java/com/texthip/thip/data/service/CommentsService.kt index 4d50efdc..b8594590 100644 --- a/app/src/main/java/com/texthip/thip/data/service/CommentsService.kt +++ b/app/src/main/java/com/texthip/thip/data/service/CommentsService.kt @@ -4,9 +4,11 @@ import com.texthip.thip.data.model.base.BaseResponse import com.texthip.thip.data.model.comments.request.CommentsCreateRequest import com.texthip.thip.data.model.comments.request.CommentsLikesRequest import com.texthip.thip.data.model.comments.response.CommentsCreateResponse +import com.texthip.thip.data.model.comments.response.CommentsDeleteResponse import com.texthip.thip.data.model.comments.response.CommentsLikesResponse import com.texthip.thip.data.model.comments.response.CommentsResponse import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Path @@ -31,4 +33,9 @@ interface CommentsService { @Path("postId") postId: Long, @Body request: CommentsCreateRequest ): BaseResponse + + @DELETE("comments/{commentId}") + suspend fun deleteComment( + @Path("commentId") commentId: Long + ): BaseResponse } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/service/FeedService.kt b/app/src/main/java/com/texthip/thip/data/service/FeedService.kt index fc216f1e..78d91165 100644 --- a/app/src/main/java/com/texthip/thip/data/service/FeedService.kt +++ b/app/src/main/java/com/texthip/thip/data/service/FeedService.kt @@ -2,13 +2,21 @@ package com.texthip.thip.data.service import com.texthip.thip.data.model.base.BaseResponse import com.texthip.thip.data.model.feed.response.CreateFeedResponse +import com.texthip.thip.data.model.feed.response.FeedDetailResponse +import com.texthip.thip.data.model.feed.response.FeedUsersInfoResponse +import com.texthip.thip.data.model.feed.response.FeedUsersResponse import com.texthip.thip.data.model.feed.response.FeedWriteInfoResponse +import com.texthip.thip.data.model.feeds.response.AllFeedResponse +import com.texthip.thip.data.model.feeds.response.MyFeedResponse import okhttp3.MultipartBody import okhttp3.RequestBody import retrofit2.http.GET import retrofit2.http.Multipart import retrofit2.http.POST import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query + interface FeedService { @@ -23,4 +31,32 @@ interface FeedService { @Part("request") request: RequestBody, @Part images: List? ): BaseResponse + + /** 전체 피드 목록 조회 */ + @GET("feeds") + suspend fun getAllFeeds( + @Query("cursor") cursor: String? = null + ): BaseResponse + + /** 내 피드 목록 조회 */ + @GET("feeds/mine") + suspend fun getMyFeeds( + @Query("cursor") cursor: String? = null + ): BaseResponse + + /** 피드 상세 조회 */ + @GET("feeds/{feedId}") + suspend fun getFeedDetail( + @Path("feedId") feedId: Int + ): BaseResponse + + @GET("feeds/users/{userId}/info") + suspend fun getFeedUsersInfo( + @Path("userId") userId: Long + ): BaseResponse + + @GET("feeds/users/{userId}") + suspend fun getFeedUsers( + @Path("userId") userId: Long + ): BaseResponse } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/service/RoomsService.kt b/app/src/main/java/com/texthip/thip/data/service/RoomsService.kt index be07b09f..80a9bedd 100644 --- a/app/src/main/java/com/texthip/thip/data/service/RoomsService.kt +++ b/app/src/main/java/com/texthip/thip/data/service/RoomsService.kt @@ -5,24 +5,28 @@ import com.texthip.thip.data.model.rooms.request.CreateRoomRequest import com.texthip.thip.data.model.rooms.request.RoomJoinRequest import com.texthip.thip.data.model.rooms.request.RoomSecretRoomRequest import com.texthip.thip.data.model.rooms.request.RoomsCreateVoteRequest +import com.texthip.thip.data.model.rooms.request.RoomsDailyGreetingRequest import com.texthip.thip.data.model.rooms.request.RoomsPostsLikesRequest import com.texthip.thip.data.model.rooms.request.RoomsRecordRequest import com.texthip.thip.data.model.rooms.request.RoomsVoteRequest import com.texthip.thip.data.model.rooms.response.CreateRoomResponse import com.texthip.thip.data.model.rooms.response.JoinedRoomListResponse import com.texthip.thip.data.model.rooms.response.MyRoomListResponse +import com.texthip.thip.data.model.rooms.response.RoomCloseResponse import com.texthip.thip.data.model.rooms.response.RoomJoinResponse -import com.texthip.thip.data.model.rooms.response.RoomRecruitingResponse import com.texthip.thip.data.model.rooms.response.RoomMainList +import com.texthip.thip.data.model.rooms.response.RoomRecruitingResponse import com.texthip.thip.data.model.rooms.response.RoomSecretRoomResponse -import com.texthip.thip.data.model.rooms.response.RoomCloseResponse import com.texthip.thip.data.model.rooms.response.RoomsBookPageResponse import com.texthip.thip.data.model.rooms.response.RoomsCreateVoteResponse +import com.texthip.thip.data.model.rooms.response.RoomsDailyGreetingResponse import com.texthip.thip.data.model.rooms.response.RoomsDeleteRecordResponse +import com.texthip.thip.data.model.rooms.response.RoomsDeleteVoteResponse import com.texthip.thip.data.model.rooms.response.RoomsPlayingResponse import com.texthip.thip.data.model.rooms.response.RoomsPostsLikesResponse import com.texthip.thip.data.model.rooms.response.RoomsPostsResponse import com.texthip.thip.data.model.rooms.response.RoomsRecordResponse +import com.texthip.thip.data.model.rooms.response.RoomsRecordsPinResponse import com.texthip.thip.data.model.rooms.response.RoomsUsersResponse import com.texthip.thip.data.model.rooms.response.RoomsVoteResponse import retrofit2.http.Body @@ -138,9 +142,27 @@ interface RoomsService { @Path("recordId") recordId: Int ): BaseResponse + @DELETE("rooms/{roomId}/vote/{voteId}") + suspend fun deleteRoomsVote( + @Path("roomId") roomId: Int, + @Path("voteId") voteId: Int + ): BaseResponse + @POST("room-posts/{postId}/likes") suspend fun postRoomsPostsLikes( @Path("postId") postId: Int, @Body request: RoomsPostsLikesRequest ): BaseResponse + + @POST("rooms/{roomId}/daily-greeting") + suspend fun postRoomsDailyGreeting( + @Path("roomId") roomId: Int, + @Body request: RoomsDailyGreetingRequest + ): BaseResponse + + @GET("rooms/{roomId}/records/{recordId}/pin") + suspend fun getRoomsRecordsPin( + @Path("roomId") roomId: Int, + @Path("recordId") recordId: Int + ): BaseResponse } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/service/UserService.kt b/app/src/main/java/com/texthip/thip/data/service/UserService.kt index 6b2549f7..36f261b7 100644 --- a/app/src/main/java/com/texthip/thip/data/service/UserService.kt +++ b/app/src/main/java/com/texthip/thip/data/service/UserService.kt @@ -2,14 +2,14 @@ package com.texthip.thip.data.service import com.texthip.thip.data.model.base.BaseResponse import com.texthip.thip.data.model.users.request.FollowRequest -import com.texthip.thip.data.model.users.response.MyFollowingsResponse -import com.texthip.thip.data.model.users.response.MyPageInfoResponse import com.texthip.thip.data.model.users.request.NicknameRequest import com.texthip.thip.data.model.users.response.AliasChoiceResponse import com.texthip.thip.data.model.users.response.FollowResponse -import com.texthip.thip.data.model.users.response.MyRecentFollowingsResponse +import com.texthip.thip.data.model.users.response.MyFollowingsResponse +import com.texthip.thip.data.model.users.response.MyPageInfoResponse import com.texthip.thip.data.model.users.response.NicknameResponse import com.texthip.thip.data.model.users.response.OthersFollowersResponse +import com.texthip.thip.data.model.users.response.UsersMyFollowingsRecentFeedsResponse import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST @@ -24,7 +24,7 @@ interface UserService { ): BaseResponse @GET("users/my-followings/recent-feeds") - suspend fun getRecentWriters(): BaseResponse + suspend fun getMyFollowingsRecentFeeds(): BaseResponse @GET("users/{userId}/followers") suspend fun getUserFollowers( diff --git a/app/src/main/java/com/texthip/thip/ui/common/bottomsheet/CustomBottomSheet.kt b/app/src/main/java/com/texthip/thip/ui/common/bottomsheet/CustomBottomSheet.kt index 6cc0aa71..ec81fc0e 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/bottomsheet/CustomBottomSheet.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/bottomsheet/CustomBottomSheet.kt @@ -63,6 +63,7 @@ fun CustomBottomSheet( Box( modifier = Modifier .fillMaxSize() + .background(color = colors.Black30) .clickable( indication = null, interactionSource = remember { MutableInteractionSource() } diff --git a/app/src/main/java/com/texthip/thip/ui/common/cards/CardNote.kt b/app/src/main/java/com/texthip/thip/ui/common/cards/CardNote.kt index 018263bd..e6a8473b 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/cards/CardNote.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/cards/CardNote.kt @@ -27,7 +27,7 @@ import com.texthip.thip.ui.theme.ThipTheme.typography @Composable fun CardNote( currentPage: Int, - percentage: Double, + percentage: Int, onClick: () -> Unit = { } ) { Column( @@ -98,7 +98,7 @@ fun CardNote( private fun CardNotePreview() { CardNote( currentPage = 50, - percentage = 30.0, + percentage = 30, onClick = { } ) } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/common/header/AuthorHeader.kt b/app/src/main/java/com/texthip/thip/ui/common/header/AuthorHeader.kt index 94b4c152..7cd96399 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/header/AuthorHeader.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/header/AuthorHeader.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -29,7 +30,6 @@ import com.texthip.thip.ui.common.buttons.OutlinedButton import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography -import androidx.compose.ui.graphics.Color @Composable fun AuthorHeader( @@ -37,7 +37,7 @@ fun AuthorHeader( profileImage: String?, nickname: String, badgeText: String, - badgeTextColor: Color = colors.NeonGreen, + badgeTextColor: Color = colors.NeonGreen, buttonText: String = "", buttonWidth: Dp = 60.dp, showButton: Boolean = true, @@ -89,8 +89,8 @@ fun AuthorHeader( if (showButton) { OutlinedButton( modifier = Modifier - .width(buttonWidth) - .height(33.dp), + .width(buttonWidth) + .height(33.dp), text = buttonText, textStyle = typography.view_m500_s14, onClick = onButtonClick @@ -102,7 +102,10 @@ fun AuthorHeader( verticalAlignment = Alignment.CenterVertically ) { Text( - text = stringResource(R.string.thip_num, thipNum)+ stringResource(R.string.thip_ing), + text = stringResource( + R.string.thip_num, + thipNum + ) + stringResource(R.string.thip_ing), style = typography.view_r400_s11_h20, color = colors.White ) diff --git a/app/src/main/java/com/texthip/thip/ui/feed/component/ImageViewerModal.kt b/app/src/main/java/com/texthip/thip/ui/feed/component/ImageViewerModal.kt index 89dcb56e..166af14b 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/component/ImageViewerModal.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/component/ImageViewerModal.kt @@ -1,6 +1,5 @@ package com.texthip.thip.ui.feed.component -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -18,13 +17,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import coil.compose.AsyncImage import com.texthip.thip.R import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors @@ -32,19 +31,18 @@ import com.texthip.thip.ui.theme.ThipTheme.typography @Composable fun ImageViewerModal( - images: List, + imageUrls: List, initialIndex: Int = 0, onDismiss: () -> Unit ) { val pagerState = rememberPagerState( initialPage = initialIndex, - pageCount = { images.size } + pageCount = { imageUrls.size } ) Box( modifier = Modifier .fillMaxSize() - .background(colors.Black) .clickable { onDismiss() } ) { // 닫기 버튼 @@ -71,8 +69,8 @@ fun ImageViewerModal( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - Image( - painter = images[page], + AsyncImage( + model = imageUrls[page], contentDescription = null, contentScale = ContentScale.Fit, // 원본 비율 유지하면서 화면에 맞춤 modifier = Modifier.fillMaxSize() @@ -81,14 +79,14 @@ fun ImageViewerModal( } // 페이지 인디케이터 (이미지가 2개 이상일 때만 표시) - if (images.size > 1) { + if (imageUrls.size > 1) { Row( modifier = Modifier .align(Alignment.BottomCenter) .padding(20.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - repeat(images.size) { index -> + repeat(imageUrls.size) { index -> Box( modifier = Modifier .size(8.dp) @@ -103,9 +101,9 @@ fun ImageViewerModal( } // 이미지 카운터 (예: 1/3) - if (images.size > 1) { + if (imageUrls.size > 1) { Text( - text = stringResource(id = R.string.tag_count, images.size, 3), + text = stringResource(id = R.string.tag_count, pagerState.currentPage + 1, imageUrls.size), style = typography.copy_r400_s14, color = colors.White, modifier = Modifier @@ -126,12 +124,12 @@ fun ImageViewerModal( @Composable private fun ImageViewerModalSingleImagePreview() { ThipTheme { - val mockImages = listOf( - painterResource(R.drawable.img_book_cover_sample) + val mockImageUrls = listOf( + "https://example.com/image1.jpg" ) ImageViewerModal( - images = mockImages, + imageUrls = mockImageUrls, initialIndex = 0, onDismiss = {} ) @@ -142,14 +140,14 @@ private fun ImageViewerModalSingleImagePreview() { @Composable private fun ImageViewerModalMultipleImagesPreview() { ThipTheme { - val mockImages = listOf( - painterResource(R.drawable.character_art), - painterResource(R.drawable.character_literature), - painterResource(R.drawable.character_sociology) + val mockImageUrls = listOf( + "https://example.com/image1.jpg", + "https://example.com/image2.jpg", + "https://example.com/image3.jpg" ) ImageViewerModal( - images = mockImages, + imageUrls = mockImageUrls, initialIndex = 1, onDismiss = { } ) diff --git a/app/src/main/java/com/texthip/thip/ui/feed/component/MyFeedCard.kt b/app/src/main/java/com/texthip/thip/ui/feed/component/MyFeedCard.kt index dee0a7ab..297e75f3 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/component/MyFeedCard.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/component/MyFeedCard.kt @@ -1,7 +1,7 @@ package com.texthip.thip.ui.feed.component -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -19,6 +19,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage import com.texthip.thip.R import com.texthip.thip.ui.common.buttons.ActionBookButton import com.texthip.thip.ui.mypage.mock.FeedItem @@ -32,8 +33,7 @@ fun MyFeedCard( onLikeClick: () -> Unit = {}, onContentClick: () -> Unit = {} ) { - val images = feedItem.imageUrls.orEmpty().map { painterResource(id = it) } - val hasImages = images.isNotEmpty() + val hasImages = feedItem.imageUrls.isNotEmpty() val maxLines = if (hasImages) 3 else 8 Column( @@ -47,33 +47,39 @@ fun MyFeedCard( onClick = {} ) - Text( - text = feedItem.content, - style = typography.feedcopy_r400_s14_h20, - color = colors.White, - maxLines = maxLines, + Column( modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp) - .clickable { onContentClick() } - ) - - if (hasImages) { - Row( + .clickable { onContentClick() }, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = feedItem.content, + style = typography.feedcopy_r400_s14_h20, + color = colors.White, + maxLines = maxLines, modifier = Modifier .fillMaxWidth() - .padding(bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - images.take(3).forEach { image -> - Image( - painter = image, - contentDescription = null, - modifier = Modifier - .padding(end = 10.dp) - .size(100.dp), - contentScale = ContentScale.Crop - ) + .padding(vertical = 16.dp) + ) + + if (hasImages) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + feedItem.imageUrls.take(3).forEach { imageUrl -> + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier + .padding(end = 10.dp) + .size(100.dp), + contentScale = ContentScale.Crop + ) + } } } } @@ -94,6 +100,7 @@ fun MyFeedCard( modifier = Modifier.padding(start = 5.dp, end = 12.dp) ) Icon( + modifier = Modifier.clickable { onContentClick() }, painter = painterResource(R.drawable.ic_comment), contentDescription = null, tint = colors.White @@ -121,7 +128,7 @@ fun MyFeedCard( private fun MyFeedCardPrev() { val feed1 = FeedItem( id = 1, - userProfileImage = R.drawable.character_literature, + userProfileImage = "https://example.com/profile1.jpg", userName = "user.01", userRole = stringResource(R.string.influencer), bookTitle = "책 제목", @@ -133,11 +140,11 @@ private fun MyFeedCardPrev() { isLiked = false, isSaved = true, isLocked = true, - imageUrls = null + imageUrls = emptyList() ) val feed2 = FeedItem( id = 2, - userProfileImage = R.drawable.character_art, + userProfileImage = "https://example.com/profile2.jpg", userName = "user.01", userRole = stringResource(R.string.influencer), bookTitle = "책 제목", @@ -149,7 +156,7 @@ private fun MyFeedCardPrev() { isLiked = false, isSaved = true, isLocked = false, - imageUrls = listOf(R.drawable.img_book_cover_sample, R.drawable.img_book_cover_sample) + imageUrls = listOf("https://example.com/image1.jpg", "https://example.com/image2.jpg") ) Column { @@ -160,6 +167,4 @@ private fun MyFeedCardPrev() { feedItem = feed2 ) } - - } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/feed/component/MySubscribelistBar.kt b/app/src/main/java/com/texthip/thip/ui/feed/component/MySubscribelistBar.kt index eea3a257..5bf67212 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/component/MySubscribelistBar.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/component/MySubscribelistBar.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.texthip.thip.R -import com.texthip.thip.ui.feed.mock.MySubscriptionData +import com.texthip.thip.data.model.users.response.RecentWriterList import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography @@ -39,7 +39,7 @@ import com.texthip.thip.ui.theme.ThipTheme.typography @Composable fun MySubscribeBarlist( modifier: Modifier = Modifier, - subscriptions: List, + subscriptions: List, onClick: () -> Unit ) { // 이미지 + 간격 너비 @@ -123,6 +123,7 @@ fun MySubscribeBarlist( } } } + @Composable private fun EmptyMySubscriptionBar() { Box( @@ -143,7 +144,7 @@ private fun EmptyMySubscriptionBar() { ) Icon( - painter = painterResource(id = R.drawable.search_character_image), + painter = painterResource(id = R.drawable.ic_search_character), contentDescription = null, tint = Color.Unspecified, modifier = Modifier @@ -160,12 +161,10 @@ private fun EmptyMySubscriptionBar() { private fun MySubscribeBarlistPrev() { ThipTheme { val previewData = List(10) { - MySubscriptionData( + RecentWriterList( + userId = it.toLong(), profileImageUrl = "https://example.com/profile$it.jpg", - nickname = "닉네임임$it", - role = "문학가", - roleColor = colors.Red, - subscriberCount = 100 + it + nickname = "닉네임임$it" ) } @@ -183,7 +182,7 @@ private fun MySubscribeBarlistPrev() { @Composable private fun MySubscribeBarlistWithoutDataPrev() { ThipTheme { - val previewData = emptyList() + val previewData = emptyList() Column { MySubscribeBarlist( diff --git a/app/src/main/java/com/texthip/thip/ui/feed/component/OthersFeedCard.kt b/app/src/main/java/com/texthip/thip/ui/feed/component/OthersFeedCard.kt new file mode 100644 index 00000000..d8aef755 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/feed/component/OthersFeedCard.kt @@ -0,0 +1,123 @@ +package com.texthip.thip.ui.feed.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.texthip.thip.data.model.feed.response.FeedList +import com.texthip.thip.ui.common.buttons.ActionBarButton +import com.texthip.thip.ui.common.buttons.ActionBookButton +import com.texthip.thip.ui.theme.ThipTheme.colors +import com.texthip.thip.ui.theme.ThipTheme.typography + +@Composable +fun OthersFeedCard( + modifier: Modifier = Modifier, + feedItem: FeedList, + onLikeClick: () -> Unit = {}, + onContentClick: () -> Unit = {} +) { + val images = feedItem.contentUrls + val hasImages = images.isNotEmpty() + val maxLines = if (hasImages) 3 else 8 + + var isLiked by remember { mutableStateOf(false) } + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + ) { + ActionBookButton( + bookTitle = feedItem.bookTitle, + bookAuthor = feedItem.bookAuthor, + onClick = {} + ) + + Text( + text = feedItem.contentBody, + style = typography.feedcopy_r400_s14_h20, + color = colors.White, + maxLines = maxLines, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + .clickable { onContentClick() } + ) + + if (hasImages) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + images.take(3).forEach { image -> + AsyncImage( + model = image, + contentDescription = null, + modifier = Modifier + .padding(end = 10.dp) + .size(100.dp), + contentScale = ContentScale.Crop + ) + } + } + } + + ActionBarButton( + isLiked = feedItem.isLiked, + likeCount = feedItem.likeCount, + commentCount = feedItem.commentCount, + isSaveVisible = true, + onLikeClick = { +// onLikeClick(feedItem.feedId) + }, + onCommentClick = { +// onCommentClick() + }, + onBookmarkClick = { + + } + ) + } +} + +@Preview +@Composable +private fun OthersFeedCardPreview() { + val feed = FeedList( + feedId = 1, + postDate = "3시간 전", + isbn = "12345", bookTitle = "미드나이트 라이브러리", bookAuthor = "매트 헤이그", + contentBody = "피드 내용입니다. 정말 재미있게 읽은 책이에요. 여러분도 꼭 읽어보세요.", + contentUrls = listOf("https://picsum.photos/100"), + likeCount = 10, commentCount = 5, isPublic = true, + isSaved = true, isLiked = false, isWriter = false + ) + + Column { + OthersFeedCard( + feedItem = feed + ) + OthersFeedCard( + feedItem = feed + ) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedCommentScreen.kt b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedCommentScreen.kt index 4df9988c..7fef8619 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedCommentScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedCommentScreen.kt @@ -1,6 +1,5 @@ package com.texthip.thip.ui.feed.screen -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -20,9 +19,12 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -33,13 +35,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import coil.compose.AsyncImage import com.texthip.thip.R import com.texthip.thip.ui.common.bottomsheet.MenuBottomSheet import com.texthip.thip.ui.common.buttons.ActionBookButton @@ -49,10 +51,9 @@ import com.texthip.thip.ui.common.header.ProfileBar import com.texthip.thip.ui.common.modal.DialogPopup import com.texthip.thip.ui.common.topappbar.DefaultTopAppBar import com.texthip.thip.ui.feed.component.ImageViewerModal -import com.texthip.thip.ui.feed.mock.FeedItemType +import com.texthip.thip.ui.feed.viewmodel.FeedDetailViewModel import com.texthip.thip.ui.group.note.mock.mockCommentList import com.texthip.thip.ui.group.room.mock.MenuBottomSheetItem -import com.texthip.thip.ui.mypage.mock.FeedItem import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography @@ -62,29 +63,73 @@ import com.texthip.thip.ui.group.note.mock.ReplyItem as FeedReplyItem @Composable fun FeedCommentScreen( modifier: Modifier = Modifier, - feedItem: FeedItem, - bookImage: Painter? = null, - profileImage: String, - feedType: FeedItemType, - currentUserId: Int, - currentUserName: String, - currentUserGenre: String, - currentUserProfileImageUrl: String, + feedId: Int, + onNavigateBack: () -> Unit = {}, + currentUserId: Int = 1, + currentUserName: String = "현재사용자", + currentUserGenre: String = "문학", + currentUserProfileImageUrl: String = "", onLikeClick: () -> Unit = {}, onCommentInputChange: (String) -> Unit = {}, onSendClick: () -> Unit = {}, - commentList: SnapshotStateList? = null + commentList: SnapshotStateList? = null, + viewModel: FeedDetailViewModel = hiltViewModel() ) { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(feedId) { + viewModel.loadFeedDetail(feedId) + } + + // 로딩 상태 처리 + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = colors.White, + modifier = Modifier.size(48.dp) + ) + } + return + } + + // 에러 상태 처리 + if (uiState.error != null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "오류가 발생했습니다", + style = typography.smalltitle_sb600_s18_h24, + color = colors.White + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = uiState.error!!, + style = typography.copy_r400_s14, + color = colors.Grey + ) + } + } + return + } + + // 피드 데이터가 없으면 리턴 + val feedDetail = uiState.feedDetail ?: return val CommentList = commentList ?: remember { mutableStateListOf() } var isBottomSheetVisible by remember { mutableStateOf(false) } var showDialog by remember { mutableStateOf(false) } val commentInput = remember { mutableStateOf("") } val replyTo = remember { mutableStateOf(null) } - val feed = remember { mutableStateOf(feedItem) } + val feed = remember { mutableStateOf(feedDetail) } val justNow = stringResource(R.string.just_a_moment_ago) - val images = feedItem.imageUrls.orEmpty().map { painterResource(id = it) } + val images = feedDetail.contentUrls var showImageViewer by remember { mutableStateOf(false) } var selectedImageIndex by remember { mutableStateOf(0) } @@ -110,7 +155,7 @@ fun FeedCommentScreen( DefaultTopAppBar( isRightIconVisible = true, isTitleVisible = false, - onLeftClick = {}, + onLeftClick = onNavigateBack, onRightClick = { isBottomSheetVisible = true }, ) @@ -125,11 +170,11 @@ fun FeedCommentScreen( Column { ProfileBar( modifier = Modifier.padding(20.dp), - profileImage = profileImage, - topText = feedItem.userName, - bottomText = feedItem.userRole, + profileImage = feedDetail.creatorProfileImageUrl ?: "", + topText = feedDetail.creatorNickname, + bottomText = feedDetail.aliasName, showSubscriberInfo = false, - hoursAgo = feedItem.timeAgo + hoursAgo = feedDetail.postDate ) Column( Modifier @@ -137,13 +182,13 @@ fun FeedCommentScreen( .padding(vertical = 16.dp, horizontal = 20.dp) ) { ActionBookButton( - bookTitle = feedItem.bookTitle, - bookAuthor = feedItem.authName, + bookTitle = feedDetail.bookTitle, + bookAuthor = feedDetail.bookAuthor, onClick = {} ) } Text( - text = feedItem.content, + text = feedDetail.contentBody, style = typography.feedcopy_r400_s14_h20, color = colors.White, modifier = Modifier @@ -157,9 +202,9 @@ fun FeedCommentScreen( .padding(start = 20.dp, bottom = 16.dp), verticalAlignment = Alignment.CenterVertically ) { - itemsIndexed(images.take(3)) { index, image -> - Image( - painter = image, + itemsIndexed(images.take(3)) { index, imageUrl -> + AsyncImage( + model = imageUrl, contentDescription = null, modifier = Modifier .padding(end = 16.dp) @@ -173,23 +218,23 @@ fun FeedCommentScreen( } } } - if (feedItem.tags.isNotEmpty()) { + if (feedDetail.tagList.isNotEmpty()) { Row( Modifier .fillMaxWidth() .padding(bottom = 16.dp, start = 20.dp, end = 20.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - feedItem.tags.forEach { tag -> + feedDetail.tagList.forEach { tag -> OptionChipButton( - text = tag, + text = "#$tag", isFilled = false, isSelected = false, onClick = {}) } } } - HorizontalDivider(color = colors.DarkGrey02, thickness = 10.dp) + HorizontalDivider(color = colors.DarkGrey03, thickness = 10.dp) } } //댓글이 없는 경우 @@ -356,7 +401,7 @@ fun FeedCommentScreen( CommentTextField( modifier = Modifier.align(Alignment.BottomCenter), input = commentInput.value, - hint = stringResource(R.string.feed_reply_to, feedItem.userName), + hint = stringResource(R.string.feed_reply_to, feedDetail.creatorNickname), onInputChange = { commentInput.value = it onCommentInputChange(it) @@ -457,7 +502,7 @@ fun FeedCommentScreen( if (showImageViewer && images.isNotEmpty()) { ImageViewerModal( - images = images.take(3), + imageUrls = images.take(3), initialIndex = selectedImageIndex, onDismiss = { showImageViewer = false } ) @@ -468,36 +513,13 @@ fun FeedCommentScreen( @Composable private fun FeedCommentScreenWithMockComments() { ThipTheme { - val mockFeedItem = FeedItem( - id = 1, - userProfileImage = R.drawable.character_literature, - userName = "문학소녀", - userRole = "문학 칭호", - bookTitle = "채식주의자", - authName = "한강", - timeAgo = "1시간 전", - content = "이 책은 인간의 본성과 억압에 대한 깊은 성찰을 담고 있어요.", - likeCount = 12, - commentCount = 3, - isLiked = true, - isSaved = true, - isLocked = true, - imageUrls = listOf( - R.drawable.img_book_cover_sample, - R.drawable.img_book_cover_sample, - R.drawable.img_book_cover_sample - ), - tags = listOf("에세이", "문학", "힐링") - ) val commentList = remember { mutableStateListOf().apply { addAll(mockCommentList.commentData) } } FeedCommentScreen( - feedItem = mockFeedItem, - feedType = FeedItemType.SAVABLE, - profileImage = "https://example.com/image1.jpg", + feedId = 1, currentUserId = 999, currentUserName = "나", currentUserGenre = "문학", @@ -511,38 +533,10 @@ private fun FeedCommentScreenWithMockComments() { @Composable private fun FeedCommentScreenPrev() { ThipTheme { - val mockFeedItem = FeedItem( - id = 1, - userProfileImage = R.drawable.character_literature, - userName = "문학소녀", - userRole = "문학 칭호", - bookTitle = "채식주의자", - authName = "한강", - timeAgo = "1시간 전", - content = "이 책은 인간의 본성과 억압에 대한 깊은 성찰을 담고 있어요.", - likeCount = 12, - commentCount = 3, - isLiked = true, - isSaved = true, - isLocked = false, - imageUrls = listOf( - R.drawable.img_book_cover_sample, - R.drawable.img_book_cover_sample, - R.drawable.img_book_cover_sample - ), -// bookImage = painterResource(R.drawable.img_book_cover_sample), -// profileImage = "https://example.com/image1.jpg", -// onLikeClick = {}, -// onCommentInputChange = {}, -// onSendClick = {}, - tags = listOf("에세이", "문학", "힐링") - ) val commentList = remember { mutableStateListOf() } FeedCommentScreen( - feedItem = mockFeedItem, - feedType = FeedItemType.SAVABLE, - profileImage = "https://example.com/image1.jpg", + feedId = 1, currentUserId = 999, currentUserName = "나", currentUserGenre = "문학", diff --git a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedOthersScreen.kt b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedOthersScreen.kt index 5c4c15d1..43a67cac 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedOthersScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedOthersScreen.kt @@ -10,39 +10,52 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import com.texthip.thip.R +import com.texthip.thip.data.model.feed.response.FeedList +import com.texthip.thip.data.model.feed.response.FeedUsersInfoResponse import com.texthip.thip.ui.common.header.AuthorHeader import com.texthip.thip.ui.common.topappbar.DefaultTopAppBar import com.texthip.thip.ui.feed.component.FeedSubscribeBarlist -import com.texthip.thip.ui.feed.component.MyFeedCard -import com.texthip.thip.ui.mypage.mock.FeedItem +import com.texthip.thip.ui.feed.component.OthersFeedCard +import com.texthip.thip.ui.feed.viewmodel.FeedOthersUiState +import com.texthip.thip.ui.feed.viewmodel.FeedOthersViewModel import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography +import com.texthip.thip.utils.color.hexToColor @Composable fun FeedOthersScreen( - nickname: String, - userRole: String, - feeds: List = emptyList(), - totalFeedCount: Int = 0, - followerProfileImageUrls: List = emptyList() + onNavigateBack: () -> Unit, + viewModel: FeedOthersViewModel = hiltViewModel() ) { - val feedStateList = remember { - mutableStateListOf().apply { - addAll(feeds) - } - } + val uiState by viewModel.uiState.collectAsState() + + FeedOthersContent( + uiState = uiState, + onNavigateBack = onNavigateBack + ) +} + +@Composable +fun FeedOthersContent( + uiState: FeedOthersUiState, + onNavigateBack: () -> Unit +) { + val userInfo = uiState.userInfo + Box(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier.fillMaxSize() @@ -50,80 +63,87 @@ fun FeedOthersScreen( DefaultTopAppBar( isRightIconVisible = false, isTitleVisible = false, - onLeftClick = {}, + onLeftClick = onNavigateBack, ) - // 스크롤 영역 - LazyColumn( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - item { - Spacer(modifier = Modifier.height(32.dp)) - AuthorHeader( - profileImage = null, - nickname = nickname, - badgeText = userRole, - buttonText = stringResource(R.string.thip), - onButtonClick = {}, - modifier = Modifier.padding(bottom = 20.dp) - ) - Spacer(modifier = Modifier.height(16.dp)) - FeedSubscribeBarlist( - modifier = Modifier.padding(horizontal = 20.dp), - followerProfileImageUrls = followerProfileImageUrls, - onClick = { - } - ) - Spacer(modifier = Modifier.height(40.dp)) - Text( - text = stringResource(R.string.whole_num, totalFeedCount), - style = typography.menu_m500_s14_h24, - color = colors.Grey, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 12.dp, start = 20.dp) - ) - HorizontalDivider( - color = colors.DarkGrey02, - thickness = 1.dp, - modifier = Modifier.padding(horizontal = 20.dp) - ) + + if (uiState.isLoading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() } - if (feedStateList.isEmpty()) { + } else if (userInfo != null) { + // 스크롤 영역 + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { item { - Box( + Spacer(modifier = Modifier.height(32.dp)) + AuthorHeader( + profileImage = userInfo.profileImageUrl, + nickname = userInfo.nickname, + badgeText = userInfo.aliasName, + badgeTextColor = hexToColor(userInfo.aliasColor), + buttonText = if (userInfo.isFollowing) stringResource(R.string.thip_cancel) else stringResource( + R.string.thip + ), + // TODO: 띱하기/취소하기 로직 연결 + onButtonClick = {}, + modifier = Modifier.padding(bottom = 20.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + FeedSubscribeBarlist( + modifier = Modifier.padding(horizontal = 20.dp), + followerProfileImageUrls = userInfo.latestFollowerProfileImageUrls, + onClick = {} + ) + Spacer(modifier = Modifier.height(40.dp)) + Text( + text = stringResource(R.string.whole_num, userInfo.totalFeedCount), + style = typography.menu_m500_s14_h24, + color = colors.Grey, modifier = Modifier .fillMaxWidth() - .padding(top = 244.dp), - contentAlignment = Alignment.TopCenter - ) { - Text( - text = stringResource(R.string.empty_feed), - style = typography.smalltitle_sb600_s18_h24, - color = colors.White - ) - } + .padding(bottom = 12.dp, start = 20.dp) + ) + HorizontalDivider( + color = colors.DarkGrey03, + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 20.dp) + ) } - } else { - itemsIndexed(feedStateList, key = { _, item -> item.id }) { index, feed -> - Spacer(modifier = Modifier.height(if (index == 0) 20.dp else 40.dp)) - MyFeedCard( - feedItem = feed, - onLikeClick = { - val updated = feed.copy( - isLiked = !feed.isLiked, - likeCount = if (feed.isLiked) feed.likeCount - 1 else feed.likeCount + 1 + if (userInfo.totalFeedCount == 0) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 244.dp), + contentAlignment = Alignment.TopCenter + ) { + Text( + text = stringResource(R.string.empty_feed), + style = typography.smalltitle_sb600_s18_h24, + color = colors.White ) - feedStateList[index] = updated - }, - onContentClick = {} //TODO FeedCommentScreen으로 - ) - Spacer(modifier = Modifier.height(40.dp)) - if (index != feedStateList.lastIndex) { - HorizontalDivider( - color = colors.DarkGrey02, - thickness = 10.dp + } + } + } else { + itemsIndexed( + items = uiState.feeds, + key = { _, item -> item.feedId } + ) { index, feed -> + Spacer(modifier = Modifier.height(if (index == 0) 20.dp else 40.dp)) + OthersFeedCard( + feedItem = feed, + onLikeClick = { /* TODO: 좋아요 로직 연결 */ }, + onContentClick = { /* TODO: 피드 상세 댓글 화면으로 이동 */ } ) + Spacer(modifier = Modifier.height(40.dp)) + if (index < uiState.feeds.lastIndex) { + HorizontalDivider( + color = colors.DarkGrey03, + thickness = 10.dp + ) + } } } } @@ -135,59 +155,36 @@ fun FeedOthersScreen( @Preview @Composable private fun FeedOthersScreenPrev() { - ThipTheme { - val mockFeeds = List(5) { - FeedItem( - id = it + 1, - userProfileImage = R.drawable.character_literature, - userName = "user.$it", - userRole = "문학 칭호", - bookTitle = "책 제목 ", - authName = "한강", - timeAgo = "1시간 전", - content = "내용내용내용 입니다. 내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.", - likeCount = it, - commentCount = it, - isLiked = false, - isSaved = false, - isLocked = it % 2 == 0, - imageUrls = listOf(R.drawable.img_book_cover_sample) - ) - } - val mockFollowerImages = listOf( - "https://example.com/image1.jpg", - "https://example.com/image2.jpg", - "https://example.com/image3.jpg", - "https://example.com/image4.jpg", - "https://example.com/image5.jpg" + + val mockUserInfo = FeedUsersInfoResponse( + creatorId = 1, + profileImageUrl = "", + nickname = "김독서", + aliasName = "문학가", + aliasColor = "#A0F8E8", + followerCount = 120, + totalFeedCount = 5, + isFollowing = true, + latestFollowerProfileImageUrls = emptyList() + ) + val mockFeeds = List(5) { + FeedList( + feedId = it.toLong(), postDate = "1시간 전", isbn = "1234", + bookTitle = "미리보기 책 제목 ${it + 1}", bookAuthor = "작가", + contentBody = "미리보기 피드 내용입니다. 내용은 여기에 표시됩니다.", + contentUrls = emptyList(), likeCount = 10, commentCount = 2, + isPublic = true, isSaved = false, isLiked = true, isWriter = false ) - ThipTheme { - FeedOthersScreen( - nickname = "ThipUser01", - userRole = "문학 칭호", - feeds = mockFeeds, - totalFeedCount = mockFeeds.size, - followerProfileImageUrls = mockFollowerImages - ) - } } -} -@Preview -@Composable -private fun FeedOthersScreenWithoutFeedPrev() { ThipTheme { - val mockFeeds: List = emptyList() - val mockFollowerImages = emptyList() - - ThipTheme { - FeedOthersScreen( - nickname = "ThipUser01", - userRole = "문학 칭호", - feeds = mockFeeds, - totalFeedCount = mockFeeds.size, - followerProfileImageUrls = mockFollowerImages - ) - } + FeedOthersContent( + uiState = FeedOthersUiState( + isLoading = false, + userInfo = mockUserInfo, + feeds = mockFeeds + ), + onNavigateBack = {} + ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt index f6bf43b4..33e0f7be 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt @@ -14,21 +14,25 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -54,41 +58,67 @@ import com.texthip.thip.ui.mypage.mock.FeedItem import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography +import com.texthip.thip.utils.color.hexToColor import kotlinx.coroutines.delay import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterial3Api::class) @Composable fun FeedScreen( onNavigateToMySubscription: () -> Unit = {}, onNavigateToFeedWrite: () -> Unit = {}, + onNavigateToFeedComment: (Int) -> Unit = {}, nickname: String = "", userRole: String = "", feeds: List = emptyList(), totalFeedCount: Int = 0, selectedTabIndex: Int = 0, followerProfileImageUrls: List = emptyList(), - feedViewModel: FeedViewModel = hiltViewModel(), resultFeedId: Int? = null, onResultConsumed: () -> Unit = {}, + feedViewModel: FeedViewModel = hiltViewModel(), mySubscriptionViewModel: MySubscriptionViewModel = hiltViewModel() ) { val feedUiState by feedViewModel.uiState.collectAsState() - val selectedIndex = rememberSaveable { mutableIntStateOf(selectedTabIndex) } - val feedStateList = remember { - mutableStateListOf().apply { - addAll(feeds) - } - } val scope = rememberCoroutineScope() - var showProgressBar by remember { mutableStateOf(false) } val progress = remember { Animatable(0f) } + val feedTabTitles = listOf(stringResource(R.string.feed), stringResource(R.string.my_feed)) + + // 무한 스크롤 로직 + val listState = rememberLazyListState() + + // 무한 스크롤 로직 + val shouldLoadMore by remember(feedUiState.canLoadMoreCurrentTab, feedUiState.isLoadingMore) { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val totalItems = layoutInfo.totalItemsCount + val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + + feedUiState.canLoadMoreCurrentTab && + !feedUiState.isLoadingMore && + feedUiState.currentTabFeeds.isNotEmpty() && + totalItems > 0 && + lastVisibleIndex >= totalItems - 3 + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) { + feedViewModel.loadMoreFeeds() + } + } + + LaunchedEffect(Unit) { + feedViewModel.refreshData() + } + LaunchedEffect(resultFeedId) { if (resultFeedId != null) { onResultConsumed() - + showProgressBar = true progress.snapTo(0f) scope.launch { @@ -103,234 +133,259 @@ fun FeedScreen( } } } - val mySubscriptions = listOf( - MySubscriptionData( - profileImageUrl = "https://example.com/image1.jpg", - nickname = "abcabcabcabc", - role = "문학가", - roleColor = colors.SocialScience - ), - MySubscriptionData( - profileImageUrl = "https://example.com/image.jpg", - nickname = "aaaaaaa", - role = "작가", - roleColor = colors.SocialScience - ), - MySubscriptionData( - profileImageUrl = "https://example.com/image1.jpg", - nickname = "abcabcabcabc", - role = "문학가", - roleColor = colors.SocialScience - ), - MySubscriptionData( - profileImageUrl = "https://example.com/image.jpg", - nickname = "aaaaaaa", - role = "작가", - roleColor = colors.SocialScience - ), - MySubscriptionData( - profileImageUrl = "https://example.com/image1.jpg", - nickname = "abcabcabcabc", - role = "문학가", - roleColor = colors.SocialScience - ), - MySubscriptionData( - profileImageUrl = "https://example.com/image.jpg", - nickname = "aaaaaaa", - role = "작가", - roleColor = colors.SocialScience - ), - MySubscriptionData( - profileImageUrl = "https://example.com/image1.jpg", - nickname = "abcabcabcabc", - role = "문학가", - roleColor = colors.SocialScience - ), - MySubscriptionData( - profileImageUrl = "https://example.com/image.jpg", - nickname = "aaaaaaa", - role = "작가", - roleColor = colors.SocialScience - ) - ) + val subscriptionUiState by mySubscriptionViewModel.uiState.collectAsState() - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier.fillMaxSize() + + // 초기 로딩 상태 처리 + if (feedUiState.isLoading && feedUiState.currentTabFeeds.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { - LogoTopAppBar( - leftIcon = painterResource(R.drawable.ic_plusfriend), - hasNotification = false, - onLeftClick = {}, - onRightClick = {}, - ) - Spacer(modifier = Modifier.height(32.dp)) - HeaderMenuBarTab( - titles = listOf("피드", "내 피드"), - selectedTabIndex = selectedIndex.value, - onTabSelected = { selectedIndex.value = it } + CircularProgressIndicator( + color = colors.White, + modifier = Modifier.size(48.dp) ) + } + return + } - // 스크롤 영역 전체 - LazyColumn( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp) + Box(modifier = Modifier.fillMaxSize()) { + PullToRefreshBox( + isRefreshing = feedUiState.isRefreshing, + onRefresh = { feedViewModel.refreshCurrentTab() } + ) { + Column( + modifier = Modifier.fillMaxSize() ) { - item { - AnimatedVisibility(visible = showProgressBar) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 20.dp, end = 20.dp, top = 32.dp), - ) { - Text( - modifier = Modifier.padding(bottom = 12.dp), - text = if (progress.value < 1.0f) { - stringResource(R.string.posting_in_progress_feed) - } else { - stringResource(R.string.posting_complete_feed) - }, - style = typography.view_m500_s14, - color = colors.NeonGreen - ) + LogoTopAppBar( + leftIcon = painterResource(R.drawable.ic_plusfriend), + hasNotification = false, + onLeftClick = {}, + onRightClick = {}, + ) + Spacer(modifier = Modifier.height(32.dp)) + HeaderMenuBarTab( + titles = feedTabTitles, + selectedTabIndex = feedUiState.selectedTabIndex, + onTabSelected = feedViewModel::onTabSelected + ) - Box( + // 스크롤 영역 전체 + LazyColumn( + state = listState, + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + AnimatedVisibility(visible = showProgressBar) { + Column( modifier = Modifier .fillMaxWidth() - .height(8.dp) - .clip(RoundedCornerShape(12.dp)) - .background(color = colors.Grey02) // 트랙(배경) 색상 + .padding(start = 20.dp, end = 20.dp, top = 32.dp), ) { + Text( + modifier = Modifier.padding(bottom = 12.dp), + text = if (progress.value < 1.0f) { + stringResource(R.string.posting_in_progress_feed) + } else { + stringResource(R.string.posting_complete_feed) + }, + style = typography.view_m500_s14, + color = colors.NeonGreen + ) + Box( modifier = Modifier - .fillMaxWidth(fraction = progress.value) - .fillMaxHeight() - .background( - color = colors.NeonGreen, - shape = RoundedCornerShape(12.dp) - ) - ) + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(12.dp)) + .background(color = colors.Grey02) // 트랙(배경) 색상 + ) { + Box( + modifier = Modifier + .fillMaxWidth(fraction = progress.value) + .fillMaxHeight() + .background( + color = colors.NeonGreen, + shape = RoundedCornerShape(12.dp) + ) + ) + } } } } - } - if (selectedIndex.value == 1) { - // 내 피드 - item { - Spacer(modifier = Modifier.height(32.dp)) - AuthorHeader( - profileImage = null, - nickname = nickname, - badgeText = userRole, - buttonText = "", - buttonWidth = 60.dp, - showButton = false - ) - Spacer(modifier = Modifier.height(16.dp)) - FeedSubscribeBarlist( - modifier = Modifier.padding(horizontal = 20.dp), - followerProfileImageUrls = followerProfileImageUrls, - onClick = { - } - ) - Spacer(modifier = Modifier.height(40.dp)) - Text( - text = stringResource(R.string.whole_num, totalFeedCount), - style = typography.menu_m500_s14_h24, - color = colors.Grey, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 12.dp, start = 20.dp) - ) - HorizontalDivider( - color = colors.DarkGrey02, - thickness = 1.dp, - modifier = Modifier.padding(horizontal = 20.dp) - ) - } - - if (totalFeedCount == 0) { + + if (feedUiState.selectedTabIndex == 1) { + // 내 피드 item { - Box( + Spacer(modifier = Modifier.height(32.dp)) + AuthorHeader( + profileImage = null, + nickname = nickname, + badgeText = userRole, + buttonText = "", + buttonWidth = 60.dp, + showButton = false + ) + Spacer(modifier = Modifier.height(16.dp)) + FeedSubscribeBarlist( + modifier = Modifier.padding(horizontal = 20.dp), + followerProfileImageUrls = followerProfileImageUrls, + onClick = { + } + ) + Spacer(modifier = Modifier.height(40.dp)) + Text( + text = stringResource(R.string.whole_num, feedUiState.myFeeds.size), + style = typography.menu_m500_s14_h24, + color = colors.Grey, modifier = Modifier .fillMaxWidth() - .padding(top = 244.dp), - contentAlignment = Alignment.TopCenter - ) { - Text( - text = stringResource(R.string.create_feed), - style = typography.smalltitle_sb600_s18_h24, - color = colors.White + .padding(bottom = 12.dp, start = 20.dp) + ) + HorizontalDivider( + color = colors.DarkGrey02, + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 20.dp) + ) + } + + if (feedUiState.myFeeds.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 244.dp), + contentAlignment = Alignment.TopCenter + ) { + Text( + text = stringResource(R.string.create_feed), + style = typography.smalltitle_sb600_s18_h24, + color = colors.White + ) + } + } + } else { + itemsIndexed(feedUiState.myFeeds, key = { _, item -> item.feedId }) { index, myFeed -> + Spacer(modifier = Modifier.height(if (index == 0) 20.dp else 40.dp)) + + // MyFeedItem을 FeedItem으로 변환 + val feedItem = FeedItem( + id = myFeed.feedId, + userProfileImage = null, + userName = "", // 내 피드이므로 고정값 + userRole = "", // 내 피드이므로 고정값 + bookTitle = myFeed.bookTitle, + authName = myFeed.bookAuthor, + timeAgo = myFeed.postDate, + content = myFeed.contentBody, + likeCount = myFeed.likeCount, + commentCount = myFeed.commentCount, + isLiked = false, // 내 피드는 좋아요 개념 없음 + isSaved = false, // 내 피드는 저장 개념 없음 + isLocked = !myFeed.isPublic, // isPublic의 반대값 + tags = emptyList(), + imageUrls = myFeed.contentUrls + ) + + MyFeedCard( + feedItem = feedItem, + onLikeClick = {}, + onContentClick = { + onNavigateToFeedComment(feedItem.id) + } ) + Spacer(modifier = Modifier.height(40.dp)) + if (index != feedUiState.myFeeds.lastIndex) { + HorizontalDivider( + color = colors.DarkGrey02, + thickness = 6.dp + ) + } } } } else { - itemsIndexed(feedStateList, key = { _, item -> item.id }) { index, feed -> + //피드 + item { + Spacer(modifier = Modifier.height(20.dp)) + val subscriptionsForBar = feedUiState.recentWriters.map { user -> + MySubscriptionData( + profileImageUrl = user.profileImageUrl, + nickname = user.nickname, + role = "", + roleColor = colors.White, + subscriberCount = 0, + isSubscribed = true + ) + } + MySubscribeBarlist( + modifier = Modifier.padding(horizontal = 20.dp), + subscriptions = subscriptionsForBar, + onClick = onNavigateToMySubscription + ) + } + itemsIndexed(feedUiState.allFeeds, key = { _, item -> item.feedId }) { index, allFeed -> + // AllFeedItem을 FeedItem으로 변환 + val feedItem = FeedItem( + id = allFeed.feedId, + userProfileImage = allFeed.creatorProfileImageUrl, + userName = allFeed.creatorNickname, + userRole = allFeed.aliasName, + bookTitle = allFeed.bookTitle, + authName = allFeed.bookAuthor, + timeAgo = allFeed.postDate, + content = allFeed.contentBody, + likeCount = allFeed.likeCount, + commentCount = allFeed.commentCount, + isLiked = allFeed.isLiked, + isSaved = allFeed.isSaved, + isLocked = false, + tags = emptyList(), + imageUrls = allFeed.contentUrls + ) + Spacer(modifier = Modifier.height(if (index == 0) 20.dp else 40.dp)) - MyFeedCard( - feedItem = feed, + + SavedFeedCard( + feedItem = feedItem, + bottomTextColor = hexToColor(allFeed.aliasColor), + onBookmarkClick = { + // TODO: API 호출로 북마크 상태 변경 + }, onLikeClick = { - val updated = feed.copy( - isLiked = !feed.isLiked, - likeCount = if (feed.isLiked) feed.likeCount - 1 else feed.likeCount + 1 - ) - feedStateList[index] = updated + // TODO: API 호출로 좋아요 상태 변경 }, - onContentClick = {} //TODO FeedCommentScreen으로 + onContentClick = { + onNavigateToFeedComment(feedItem.id) + }, + onCommentClick = { + onNavigateToFeedComment(feedItem.id) + } ) Spacer(modifier = Modifier.height(40.dp)) - if (index != feeds.lastIndex) { + if (index != feedUiState.allFeeds.lastIndex) { HorizontalDivider( color = colors.DarkGrey02, - thickness = 10.dp + thickness = 6.dp ) } } } - } else { - //피드 - item { - Spacer(modifier = Modifier.height(20.dp)) - val subscriptionsForBar = feedUiState.recentWriters.map { user -> - MySubscriptionData( - profileImageUrl = user.profileImageUrl, - nickname = user.nickname, - role = "", - roleColor = colors.White, - subscriberCount = 0, - isSubscribed = true - ) - } - MySubscribeBarlist( - modifier = Modifier.padding(horizontal = 20.dp), - subscriptions = subscriptionsForBar, - onClick = onNavigateToMySubscription - ) - } - itemsIndexed(feedStateList, key = { _, item -> item.id }) { index, feed -> - val profileImage = feed.userProfileImage?.let { painterResource(it) } - SavedFeedCard( - feedItem = feed, - onBookmarkClick = { - val updated = feed.copy(isSaved = !feed.isSaved) - feedStateList[index] = updated - }, - onLikeClick = { - val updated = feed.copy( - isLiked = !feed.isLiked, - likeCount = if (feed.isLiked) feed.likeCount - 1 else feed.likeCount + 1 + // 무한 스크롤 로딩 인디케이터 + if (feedUiState.isLoadingMore) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = colors.White ) - feedStateList[index] = updated - }, - onContentClick = {} //FeedCommentScreen으로 - - ) - if (index != feeds.lastIndex) { - HorizontalDivider( - color = colors.DarkGrey02, - thickness = 10.dp - ) + } } } } @@ -350,7 +405,7 @@ private fun FeedScreenPreview() { val mockFeeds = List(5) { FeedItem( id = it + 1, - userProfileImage = R.drawable.character_literature, + userProfileImage = "https://example.com/profile$it.jpg", userName = "user.$it", userRole = "문학 칭호", bookTitle = "책 제목 ", @@ -362,7 +417,7 @@ private fun FeedScreenPreview() { isLiked = false, isSaved = false, isLocked = it % 2 == 0, - imageUrls = listOf(R.drawable.img_book_cover_sample) + imageUrls = listOf("https://example.com/image$it.jpg") ) } val mockFollowerImages = listOf( @@ -405,4 +460,4 @@ private fun FeedScreenWithoutDataPreview() { ) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/feed/screen/MySubscriptionListScreen.kt b/app/src/main/java/com/texthip/thip/ui/feed/screen/MySubscriptionListScreen.kt index 96e75127..6bbbdbe7 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/screen/MySubscriptionListScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/screen/MySubscriptionListScreen.kt @@ -1,6 +1,7 @@ package com.texthip.thip.ui.feed.screen import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -32,7 +33,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavController import com.texthip.thip.R import com.texthip.thip.data.model.users.response.FollowingList import com.texthip.thip.ui.common.header.AuthorHeader @@ -48,7 +48,8 @@ import kotlinx.coroutines.delay @Composable fun MySubscriptionScreen( - navController: NavController?= null, + onNavigateBack: () -> Unit, + onNavigateToUserProfile: (Long) -> Unit, viewModel: MySubscriptionViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() @@ -73,12 +74,15 @@ fun MySubscriptionScreen( MySubscriptionContent( uiState = uiState, lazyListState = lazyListState, - onNavigateBack = { navController?.popBackStack() }, + onNavigateBack = onNavigateBack, onToggleFollow = { userId, nickname -> val followedMessage = context.getString(R.string.toast_thip, nickname) val unfollowedMessage = context.getString(R.string.toast_thip_cancel, nickname) viewModel.toggleFollow(userId, followedMessage, unfollowedMessage) }, + onUserClick = { userId -> + onNavigateToUserProfile(userId) + }, onHideToast = { viewModel.hideToast() } ) } @@ -88,6 +92,7 @@ fun MySubscriptionContent( lazyListState: LazyListState, onNavigateBack: () -> Unit, onToggleFollow: (userId: Long, nickname: String) -> Unit, + onUserClick: (userId: Long) -> Unit, onHideToast: () -> Unit ) { LaunchedEffect(uiState.showToast) { @@ -147,7 +152,7 @@ fun MySubscriptionContent( items = uiState.followings, key = { _, user -> user.userId } ) { index, user -> - Column(modifier = Modifier.padding(horizontal = 20.dp)) { + Column(modifier = Modifier.padding(horizontal = 20.dp).clickable { onUserClick(user.userId) }) { AuthorHeader( profileImage = user.profileImageUrl, nickname = user.nickname, @@ -156,7 +161,7 @@ fun MySubscriptionContent( buttonText = stringResource(if (user.isFollowing) R.string.thip_cancel else R.string.thip), buttonWidth = 64.dp, profileImageSize = 36.dp, - onButtonClick = { onToggleFollow(user.userId, user.nickname) } + onButtonClick = { onToggleFollow(user.userId, user.nickname) }, ) if (index < uiState.followings.lastIndex) { @@ -215,6 +220,7 @@ private fun MySubscriptionListScreenPrev() { lazyListState = rememberLazyListState(), onNavigateBack = {}, onToggleFollow = { _, _ -> }, + onUserClick = {}, onHideToast = {} ) } diff --git a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedDetailViewModel.kt b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedDetailViewModel.kt new file mode 100644 index 00000000..204d3604 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedDetailViewModel.kt @@ -0,0 +1,61 @@ +package com.texthip.thip.ui.feed.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.texthip.thip.data.model.feed.response.FeedDetailResponse +import com.texthip.thip.data.repository.FeedRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class FeedDetailUiState( + val isLoading: Boolean = false, + val feedDetail: FeedDetailResponse? = null, + val error: String? = null +) + +@HiltViewModel +class FeedDetailViewModel @Inject constructor( + private val feedRepository: FeedRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(FeedDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private fun updateState(update: (FeedDetailUiState) -> FeedDetailUiState) { + _uiState.value = update(_uiState.value) + } + + fun loadFeedDetail(feedId: Int) { + viewModelScope.launch { + updateState { it.copy(isLoading = true, error = null) } + + feedRepository.getFeedDetail(feedId) + .onSuccess { response -> + updateState { + it.copy( + isLoading = false, + feedDetail = response, + error = null + ) + } + } + .onFailure { exception -> + updateState { + it.copy( + isLoading = false, + feedDetail = null, + error = exception.message ?: "알 수 없는 오류가 발생했습니다." + ) + } + } + } + } + + fun clearError() { + updateState { it.copy(error = null) } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedOthersViewModel.kt b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedOthersViewModel.kt new file mode 100644 index 00000000..f8b4fd66 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedOthersViewModel.kt @@ -0,0 +1,67 @@ +package com.texthip.thip.ui.feed.viewmodel + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.texthip.thip.data.model.feed.response.FeedList +import com.texthip.thip.data.model.feed.response.FeedUsersInfoResponse +import com.texthip.thip.data.repository.FeedRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class FeedOthersUiState( + val isLoading: Boolean = true, + val userInfo: FeedUsersInfoResponse? = null, + val feeds: List = emptyList(), + val errorMessage: String? = null +) + +@HiltViewModel +class FeedOthersViewModel @Inject constructor( + private val feedRepository: FeedRepository, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val userId: Long = requireNotNull(savedStateHandle["userId"]) + + private val _uiState = MutableStateFlow(FeedOthersUiState()) + val uiState = _uiState.asStateFlow() + + init { + fetchData() + } + + private fun fetchData() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + + val userInfoDeferred = async { feedRepository.getFeedUsersInfo(userId) } + val feedsDeferred = async { feedRepository.getFeedUsers(userId) } + + val userInfoResult = userInfoDeferred.await() + val feedsResult = feedsDeferred.await() + + val fetchedFeeds = feedsResult.getOrNull()?.feedList ?: emptyList() + + // ✅ 로그를 추가하여 유저 정보와 피드 개수를 확인 + Log.d("FeedOthersViewModel", "User Info Result: ${userInfoResult.getOrNull()}") + Log.d("FeedOthersViewModel", "Fetched Feeds Count: ${fetchedFeeds.size}") + + _uiState.update { + it.copy( + isLoading = false, + userInfo = userInfoResult.getOrNull(), + feeds = fetchedFeeds, + errorMessage = userInfoResult.exceptionOrNull()?.message + ?: feedsResult.exceptionOrNull()?.message + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedViewModel.kt b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedViewModel.kt index 04b3bdd5..d09d048b 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedViewModel.kt @@ -2,48 +2,280 @@ package com.texthip.thip.ui.feed.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.texthip.thip.data.model.feeds.response.AllFeedItem +import com.texthip.thip.data.model.feeds.response.MyFeedItem import com.texthip.thip.data.model.users.response.RecentWriterList +import com.texthip.thip.data.repository.FeedRepository import com.texthip.thip.data.repository.UserRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject data class FeedUiState( - val isLoading: Boolean = true, + val selectedTabIndex: Int = 0, + val allFeeds: List = emptyList(), + val myFeeds: List = emptyList(), val recentWriters: List = emptyList(), - val errorMessage: String? = null - //TODO 추후 피드 목록 등 다른 상태들 추가될 예정 -) + val isLoading: Boolean = false, + val isRefreshing: Boolean = false, + val isLoadingMore: Boolean = false, + val isLastPageAllFeeds: Boolean = false, + val isLastPageMyFeeds: Boolean = false, + val error: String? = null +) { + val canLoadMoreAllFeeds: Boolean get() = !isLoading && !isLoadingMore && !isRefreshing && !isLastPageAllFeeds + val canLoadMoreMyFeeds: Boolean get() = !isLoading && !isLoadingMore && !isRefreshing && !isLastPageMyFeeds + val currentTabFeeds: List + get() = when (selectedTabIndex) { + 0 -> allFeeds + 1 -> myFeeds + else -> emptyList() + } + val canLoadMoreCurrentTab: Boolean + get() = when (selectedTabIndex) { + 0 -> canLoadMoreAllFeeds + 1 -> canLoadMoreMyFeeds + else -> false + } +} @HiltViewModel class FeedViewModel @Inject constructor( + private val feedRepository: FeedRepository, private val userRepository: UserRepository ) : ViewModel() { - private val _uiState = MutableStateFlow(FeedUiState()) val uiState = _uiState.asStateFlow() + private var allFeedsNextCursor: String? = null + private var myFeedsNextCursor: String? = null + private var isLoadingAllFeeds = false + private var isLoadingMyFeeds = false + + private fun updateState(update: (FeedUiState) -> FeedUiState) { + _uiState.value = update(_uiState.value) + } + init { + loadAllFeeds() + fetchRecentWriters() + } + + fun onTabSelected(index: Int) { + updateState { it.copy(selectedTabIndex = index) } + + when (index) { + 0 -> { + loadAllFeeds(isInitial = true) + } + + 1 -> { + loadMyFeeds(isInitial = true) + } + } + } + + private fun loadAllFeeds(isInitial: Boolean = true) { + if (isLoadingAllFeeds && !isInitial) return + if (_uiState.value.isLastPageAllFeeds && !isInitial) return + + viewModelScope.launch { + try { + isLoadingAllFeeds = true + + if (isInitial) { + updateState { + it.copy( + isLoading = true, + allFeeds = emptyList(), + isLastPageAllFeeds = false + ) + } + allFeedsNextCursor = null + } else { + updateState { it.copy(isLoadingMore = true) } + } + + val cursor = if (isInitial) null else allFeedsNextCursor + + feedRepository.getAllFeeds(cursor).onSuccess { response -> + if (response != null) { + val currentList = if (isInitial) emptyList() else _uiState.value.allFeeds + updateState { + it.copy( + allFeeds = currentList + response.feedList, + error = null, + isLastPageAllFeeds = response.isLast + ) + } + allFeedsNextCursor = response.nextCursor + } else { + updateState { it.copy(isLastPageAllFeeds = true) } + } + }.onFailure { exception -> + updateState { it.copy(error = exception.message) } + } + } finally { + isLoadingAllFeeds = false + updateState { it.copy(isLoading = false, isLoadingMore = false) } + } + } + } + + private fun loadMyFeeds(isInitial: Boolean = true) { + if (isLoadingMyFeeds && !isInitial) return + if (_uiState.value.isLastPageMyFeeds && !isInitial) return + + viewModelScope.launch { + try { + isLoadingMyFeeds = true + + if (isInitial) { + updateState { + it.copy( + isLoading = true, + myFeeds = emptyList(), + isLastPageMyFeeds = false + ) + } + myFeedsNextCursor = null + } else { + updateState { it.copy(isLoadingMore = true) } + } + + val cursor = if (isInitial) null else myFeedsNextCursor + + feedRepository.getMyFeeds(cursor).onSuccess { response -> + if (response != null) { + val currentList = if (isInitial) emptyList() else _uiState.value.myFeeds + updateState { + it.copy( + myFeeds = currentList + response.feedList, + error = null, + isLastPageMyFeeds = response.isLast + ) + } + myFeedsNextCursor = response.nextCursor + } else { + updateState { it.copy(isLastPageMyFeeds = true) } + } + }.onFailure { exception -> + updateState { it.copy(error = exception.message) } + } + } finally { + isLoadingMyFeeds = false + updateState { it.copy(isLoading = false, isLoadingMore = false) } + } + } + } + + fun refreshCurrentTab() { + viewModelScope.launch { + updateState { it.copy(isRefreshing = true) } + + when (_uiState.value.selectedTabIndex) { + 0 -> refreshAllFeeds() + 1 -> refreshMyFeeds() + } + } + } + + private suspend fun refreshAllFeeds() { + allFeedsNextCursor = null + + feedRepository.getAllFeeds().onSuccess { response -> + if (response != null) { + allFeedsNextCursor = response.nextCursor + updateState { + it.copy( + allFeeds = response.feedList, + isRefreshing = false, + isLastPageAllFeeds = response.isLast, + error = null + ) + } + } else { + updateState { + it.copy( + allFeeds = emptyList(), + isRefreshing = false, + isLastPageAllFeeds = true + ) + } + } + }.onFailure { exception -> + updateState { + it.copy( + isRefreshing = false, + error = exception.message + ) + } + } + } + + private suspend fun refreshMyFeeds() { + myFeedsNextCursor = null + + feedRepository.getMyFeeds().onSuccess { response -> + if (response != null) { + myFeedsNextCursor = response.nextCursor + updateState { + it.copy( + myFeeds = response.feedList, + isRefreshing = false, + isLastPageMyFeeds = response.isLast, + error = null + ) + } + } else { + updateState { + it.copy( + myFeeds = emptyList(), + isRefreshing = false, + isLastPageMyFeeds = true + ) + } + } + }.onFailure { exception -> + updateState { + it.copy( + isRefreshing = false, + error = exception.message + ) + } + } + } + + fun loadMoreFeeds() { + if (!_uiState.value.canLoadMoreCurrentTab || _uiState.value.isRefreshing) return + + when (_uiState.value.selectedTabIndex) { + 0 -> loadAllFeeds(isInitial = false) + 1 -> loadMyFeeds(isInitial = false) + } + + fun refreshData() { fetchRecentWriters() + } private fun fetchRecentWriters() { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } - userRepository.getRecentWriters() + userRepository.getMyFollowingsRecentFeeds() .onSuccess { data -> + val writers = data?.recentWriters ?: emptyList() _uiState.update { it.copy( isLoading = false, - recentWriters = data?.recentWriters ?: emptyList() + recentWriters = writers ) } } .onFailure { exception -> - _uiState.update { it.copy(isLoading = false, errorMessage = exception.message) } + updateState { it.copy(error = exception.message) } } } } diff --git a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteViewModel.kt b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteViewModel.kt index b6fc4b6b..94a05ae0 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteViewModel.kt @@ -39,12 +39,34 @@ class FeedWriteViewModel @Inject constructor( loadFeedWriteInfo() } + fun setPinnedRecord( + isbn: String, + bookTitle: String, + bookAuthor: String, + bookImageUrl: String, + recordContent: String + ) { + val pinnedBook = BookData( + title = bookTitle, + imageUrl = bookImageUrl, + author = bookAuthor, + isbn = isbn + ) + updateState { + it.copy( + selectedBook = pinnedBook, + isBookPreselected = true, + feedContent = recordContent + ) + } + } + private fun loadFeedWriteInfo() { viewModelScope.launch { updateState { it.copy(isLoadingCategories = true) } feedRepository.getFeedWriteInfo() .onSuccess { response -> - updateState { + updateState { it.copy( categories = response?.categoryList ?: emptyList(), isLoadingCategories = false @@ -52,7 +74,7 @@ class FeedWriteViewModel @Inject constructor( } } .onFailure { - updateState { + updateState { it.copy( categories = emptyList(), isLoadingCategories = false, @@ -177,9 +199,9 @@ class FeedWriteViewModel @Inject constructor( val currentState = _uiState.value val availableSlots = 3 - currentState.imageUris.size val imagesToAdd = newImageUris.take(availableSlots) - - updateState { - it.copy(imageUris = currentState.imageUris + imagesToAdd) + + updateState { + it.copy(imageUris = currentState.imageUris + imagesToAdd) } } @@ -196,7 +218,7 @@ class FeedWriteViewModel @Inject constructor( } fun selectCategory(index: Int) { - updateState { + updateState { it.copy( selectedCategoryIndex = index, selectedTags = emptyList() // 카테고리 변경 시 태그 초기화 @@ -220,8 +242,8 @@ class FeedWriteViewModel @Inject constructor( fun removeTag(tag: String) { val currentTags = _uiState.value.selectedTags - updateState { - it.copy(selectedTags = currentTags - tag) + updateState { + it.copy(selectedTags = currentTags - tag) } } @@ -250,7 +272,7 @@ class FeedWriteViewModel @Inject constructor( tagList = currentState.selectedTags, imageUris = currentState.imageUris ) - + result.onSuccess { response -> val feedId = response?.feedId if (feedId != null) { @@ -260,7 +282,8 @@ class FeedWriteViewModel @Inject constructor( } }.onFailure { exception -> onError( - exception.message ?: stringResourceProvider.getString(R.string.error_network_error) + exception.message + ?: stringResourceProvider.getString(R.string.error_network_error) ) } diff --git a/app/src/main/java/com/texthip/thip/ui/group/note/component/CommentBottomSheet.kt b/app/src/main/java/com/texthip/thip/ui/group/note/component/CommentBottomSheet.kt index 84038c4c..f5f2ac63 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/note/component/CommentBottomSheet.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/note/component/CommentBottomSheet.kt @@ -22,15 +22,19 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.texthip.thip.R import com.texthip.thip.data.model.comments.response.CommentList +import com.texthip.thip.data.model.comments.response.ReplyList import com.texthip.thip.ui.common.bottomsheet.CustomBottomSheet +import com.texthip.thip.ui.common.bottomsheet.MenuBottomSheet import com.texthip.thip.ui.common.forms.CommentTextField import com.texthip.thip.ui.group.note.viewmodel.CommentsEvent import com.texthip.thip.ui.group.note.viewmodel.CommentsUiState +import com.texthip.thip.ui.group.room.mock.MenuBottomSheetItem import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography @@ -47,74 +51,140 @@ fun CommentBottomSheet( var replyingToCommentId by remember { mutableStateOf(null) } var replyingToNickname by remember { mutableStateOf(null) } - CustomBottomSheet(onDismiss = onDismiss) { - Column( - modifier = Modifier - .fillMaxWidth() - .height(600.dp) - .advancedImePadding() - ) { + var selectedCommentForMenu by remember { mutableStateOf(null) } + var selectedReplyForMenu by remember { mutableStateOf(null) } + + val isOverlayVisible = selectedCommentForMenu != null || selectedReplyForMenu != null + + Box( + if (isOverlayVisible) { + Modifier + .fillMaxSize() + .blur(5.dp) + } else { + Modifier.fillMaxSize() + } + ) { + CustomBottomSheet(onDismiss = onDismiss) { Column( modifier = Modifier - .weight(1f) - .fillMaxHeight(0.8f) + .fillMaxWidth() + .height(600.dp) + .advancedImePadding() ) { - Text( - text = stringResource(R.string.comments), - style = typography.title_b700_s20_h24, - color = colors.White, - modifier = Modifier.padding(start = 20.dp, top = 20.dp, end = 20.dp) - ) + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight(0.8f) + ) { + Text( + text = stringResource(R.string.comments), + style = typography.title_b700_s20_h24, + color = colors.White, + modifier = Modifier.padding(start = 20.dp, top = 20.dp, end = 20.dp) + ) - Box(modifier = Modifier.weight(1f)) { - if (uiState.isLoading) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - CircularProgressIndicator() + Box(modifier = Modifier.weight(1f)) { + if (uiState.isLoading) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator() + } + } else if (uiState.comments.isEmpty()) { + EmptyCommentView() + } else { + CommentLazyList( + commentList = uiState.comments, + isLoadingMore = uiState.isLoadingMore, + isLastPage = uiState.isLast, + onLoadMore = { onEvent(CommentsEvent.LoadMoreComments) }, + onReplyClick = { commentId, nickname -> + replyingToCommentId = commentId + replyingToNickname = nickname + }, + onEvent = onEvent, + onCommentLongPress = { comment -> + selectedCommentForMenu = comment + }, + onReplyLongPress = { reply -> selectedReplyForMenu = reply } + ) } - } else if (uiState.comments.isEmpty()) { - EmptyCommentView() - } else { - CommentLazyList( - commentList = uiState.comments, - isLoadingMore = uiState.isLoadingMore, - isLastPage = uiState.isLast, - onLoadMore = { onEvent(CommentsEvent.LoadMoreComments) }, - onReplyClick = { commentId, nickname -> - replyingToCommentId = commentId - replyingToNickname = nickname - }, - onEvent = onEvent - ) } } + + CommentTextField( + modifier = Modifier.fillMaxWidth(), + hint = stringResource(R.string.reply_to), + input = inputText, + onInputChange = { inputText = it }, + onSendClick = { + onSendReply( + inputText, + replyingToCommentId, + replyingToNickname + ) + inputText = "" + replyingToCommentId = null + replyingToNickname = null + }, + replyTo = replyingToNickname, + onCancelReply = { + replyingToCommentId = null + replyingToNickname = null + } + ) } + } + } - CommentTextField( - modifier = Modifier.fillMaxWidth(), - hint = stringResource(R.string.reply_to), - input = inputText, - onInputChange = { inputText = it }, - onSendClick = { - onSendReply( - inputText, - replyingToCommentId, - replyingToNickname - ) - inputText = "" - replyingToCommentId = null - replyingToNickname = null - }, - replyTo = replyingToNickname, - onCancelReply = { - replyingToCommentId = null - replyingToNickname = null - } - ) + val itemForMenu = selectedCommentForMenu ?: selectedReplyForMenu + if (itemForMenu != null) { + val isWriter = when (itemForMenu) { + is CommentList -> itemForMenu.isWriter + is ReplyList -> itemForMenu.isWriter + else -> false } + + MenuBottomSheet( + items = if (isWriter) { + listOf( + MenuBottomSheetItem( + text = stringResource(R.string.delete), + color = colors.Red, + onClick = { + val commentId = when (val item = itemForMenu) { + is CommentList -> item.commentId + is ReplyList -> item.commentId + else -> null + } + commentId?.let { onEvent(CommentsEvent.DeleteComment(it)) } + + selectedCommentForMenu = null + selectedReplyForMenu = null + } + ) + ) + } else { + listOf( + MenuBottomSheetItem( + text = stringResource(R.string.report), + color = colors.Red, + onClick = { + // TODO: 신고 로직 + selectedCommentForMenu = null + selectedReplyForMenu = null + } + ) + ) + }, + onDismiss = { + selectedCommentForMenu = null + selectedReplyForMenu = null + } + ) } } @@ -124,8 +194,10 @@ private fun CommentLazyList( isLoadingMore: Boolean, isLastPage: Boolean, onLoadMore: () -> Unit, - onReplyClick: (commentId: Int, nickname: String) -> Unit, - onEvent: (CommentsEvent) -> Unit + onReplyClick: (commentId: Int, nickname: String?) -> Unit, + onEvent: (CommentsEvent) -> Unit, + onCommentLongPress: (CommentList) -> Unit, + onReplyLongPress: (ReplyList) -> Unit ) { val lazyListState = rememberLazyListState() @@ -147,12 +219,21 @@ private fun CommentLazyList( LazyColumn(state = lazyListState) { items( items = commentList, - key = { it.commentId } + key = { comment -> + // commentId가 있으면 사용 + comment.commentId + // 없다면(삭제된 댓글), replyList의 첫 번째 항목 ID를 사용 + ?: comment.replyList.firstOrNull()?.commentId + // 그것마저 없다면(마지막 답글까지 삭제된 경우), 객체 자체의 hashCode를 사용 + ?: comment.hashCode() + } ) { comment -> CommentSection( commentItem = comment, onReplyClick = onReplyClick, - onEvent = onEvent + onEvent = onEvent, + onCommentLongPress = onCommentLongPress, + onReplyLongPress = onReplyLongPress ) } @@ -216,6 +297,7 @@ private fun CommentBottomSheetPreview() { aliasColor = "#A0F8E8", isDeleted = false, isLike = false, + isWriter = false, replyList = emptyList() ) ), diff --git a/app/src/main/java/com/texthip/thip/ui/group/note/component/CommentItem.kt b/app/src/main/java/com/texthip/thip/ui/group/note/component/CommentItem.kt index af7a5ad5..fe6ec29f 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/note/component/CommentItem.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/note/component/CommentItem.kt @@ -1,6 +1,7 @@ package com.texthip.thip.ui.group.note.component import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -11,6 +12,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -27,57 +29,71 @@ import com.texthip.thip.utils.color.hexToColor fun CommentItem( modifier: Modifier = Modifier, data: CommentList, - onReplyClick: (String) -> Unit = { }, - onLikeClick: () -> Unit = {} + onReplyClick: (String?) -> Unit = { }, + onLikeClick: () -> Unit = {}, + onLongPress: () -> Unit = {} ) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - ProfileBarFeed( - profileImage = data.creatorProfileImageUrl, - nickname = data.creatorNickname, - genreName = data.aliasName, - genreColor = hexToColor(data.aliasColor), - date = data.postDate + if (data.isDeleted) { + Text( + text = stringResource(R.string.comment_deleted), + style = typography.feedcopy_r400_s14_h20, + color = colors.Grey02, ) - - Row( - horizontalArrangement = Arrangement.spacedBy(20.dp) + } else { + Column( + modifier = modifier.pointerInput(Unit) { + detectTapGestures(onLongPress = { onLongPress() }) + }, + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Column( - modifier = Modifier - .weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = data.content, - color = colors.Grey, - style = typography.feedcopy_r400_s14_h20, - ) - Text( - modifier = Modifier.clickable(onClick = { onReplyClick(data.creatorNickname) }), - text = stringResource(R.string.write_reply), - style = typography.menu_sb600_s12, - color = colors.Grey02, - ) - } + data + ProfileBarFeed( + profileImage = data.creatorProfileImageUrl, + nickname = data.creatorNickname ?: "", + genreName = data.aliasName ?: "", + genreColor = hexToColor(data.aliasColor ?: "#FFFFFF"), + date = data.postDate ?: "" + ) - Column( - modifier = Modifier.clickable(onClick = onLikeClick), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp), + Row( + horizontalArrangement = Arrangement.spacedBy(20.dp) ) { - Icon( - painter = painterResource(if (data.isLike) R.drawable.ic_heart_center_filled else R.drawable.ic_heart_center), - contentDescription = null, - tint = Color.Unspecified - ) - Text( - text = data.likeCount.toString(), - style = typography.navi_m500_s10, - color = colors.White, - ) + Column( + modifier = Modifier + .weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + data.content?.let { + Text( + text = it, + color = colors.Grey, + style = typography.feedcopy_r400_s14_h20, + ) + } + Text( + modifier = Modifier.clickable(onClick = { onReplyClick(data.creatorNickname) }), + text = stringResource(R.string.write_reply), + style = typography.menu_sb600_s12, + color = colors.Grey02, + ) + } + + Column( + modifier = Modifier.clickable(onClick = onLikeClick), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Icon( + painter = painterResource(if (data.isLike) R.drawable.ic_heart_center_filled else R.drawable.ic_heart_center), + contentDescription = null, + tint = Color.Unspecified + ) + Text( + text = data.likeCount.toString(), + style = typography.navi_m500_s10, + color = colors.White, + ) + } } } } @@ -100,13 +116,14 @@ private fun CommentItemPreview() { creatorId = 1, creatorNickname = "User1", creatorProfileImageUrl = "https://example.com/image1.jpg", - aliasName= "칭호칭호", + aliasName = "칭호칭호", aliasColor = "#FF5733", content = "This is a comment.", postDate = "2023-10-01T12:00:00Z", isLike = false, likeCount = 10, isDeleted = false, + isWriter = false, replyList = emptyList() ) ) @@ -117,13 +134,14 @@ private fun CommentItemPreview() { creatorId = 1, creatorNickname = "User1", creatorProfileImageUrl = "https://example.com/image1.jpg", - aliasName= "칭호칭호", + aliasName = "칭호칭호", aliasColor = "#FF5733", content = "This is a comment.", postDate = "2023-10-01T12:00:00Z", isLike = false, likeCount = 10, isDeleted = false, + isWriter = false, replyList = emptyList() ) ) @@ -134,13 +152,14 @@ private fun CommentItemPreview() { creatorId = 1, creatorNickname = "User1", creatorProfileImageUrl = "https://example.com/image1.jpg", - aliasName= "칭호칭호", + aliasName = "칭호칭호", aliasColor = "#FF5733", content = "This is a comment.", postDate = "2023-10-01T12:00:00Z", isLike = false, likeCount = 10, isDeleted = false, + isWriter = false, replyList = emptyList() ) ) diff --git a/app/src/main/java/com/texthip/thip/ui/group/note/component/CommentSection.kt b/app/src/main/java/com/texthip/thip/ui/group/note/component/CommentSection.kt index fcc2ea4d..bd50ea28 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/note/component/CommentSection.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/note/component/CommentSection.kt @@ -11,15 +11,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.texthip.thip.data.model.comments.response.CommentList +import com.texthip.thip.data.model.comments.response.ReplyList import com.texthip.thip.ui.group.note.viewmodel.CommentsEvent import com.texthip.thip.ui.theme.ThipTheme @Composable fun CommentSection( commentItem: CommentList, - onSendReply: (String, Int?, String?) -> Unit = { _, _, _ -> }, - onReplyClick: (commentId: Int, nickname: String) -> Unit, - onEvent: (CommentsEvent) -> Unit = { _ -> } + onReplyClick: (commentId: Int, nickname: String?) -> Unit, + onEvent: (CommentsEvent) -> Unit = { _ -> }, + onCommentLongPress: (CommentList) -> Unit = { _ -> }, + onReplyLongPress: (ReplyList) -> Unit = { _ -> }, ) { Box { Column( @@ -31,23 +33,35 @@ fun CommentSection( ) { CommentItem( data = commentItem, - onReplyClick = { onReplyClick(commentItem.commentId, commentItem.creatorNickname) }, - onLikeClick = { onEvent(CommentsEvent.LikeComment(commentItem.commentId)) } + onReplyClick = { + // commentId가 null이 아닐 때만 답글 달기 가능 + // todo: 수정 가능 + commentItem.commentId?.let { id -> + onReplyClick(id, commentItem.creatorNickname) + } + }, + onLikeClick = { + // commentId가 null이 아닐 때만 좋아요 가능 + // todo: 수정 가능 + commentItem.commentId?.let { id -> + onEvent(CommentsEvent.LikeComment(id)) + } + }, + onLongPress = { onCommentLongPress(commentItem) } ) commentItem.replyList.forEach { reply -> ReplyItem( data = reply, - onReplyClick = { onReplyClick(commentItem.commentId, reply.creatorNickname) }, + onReplyClick = { + commentItem.commentId?.let { parentId -> + onReplyClick(parentId, reply.creatorNickname) + } + }, onLikeClick = { - onEvent( - CommentsEvent.LikeReply( - commentItem.commentId, - reply.commentId - ) - ) - } - + onEvent(CommentsEvent.LikeReply(reply.commentId)) + }, + onLongPress = { onReplyLongPress(reply) } ) } } @@ -73,10 +87,9 @@ fun CommentSectionPreview() { isLike = false, likeCount = 10, isDeleted = false, - replyList = emptyList() - + replyList = emptyList(), + isWriter = false ), - onSendReply = { _, _, _ -> }, onReplyClick = { commentId, nickname -> // Handle reply click } diff --git a/app/src/main/java/com/texthip/thip/ui/group/note/component/ReplyItem.kt b/app/src/main/java/com/texthip/thip/ui/group/note/component/ReplyItem.kt index d0729746..19b0d6c9 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/note/component/ReplyItem.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/note/component/ReplyItem.kt @@ -1,6 +1,7 @@ package com.texthip.thip.ui.group.note.component import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -11,6 +12,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.buildAnnotatedString @@ -30,7 +32,8 @@ fun ReplyItem( modifier: Modifier = Modifier, data: ReplyList, onReplyClick: () -> Unit = { }, - onLikeClick: () -> Unit = {} + onLikeClick: () -> Unit = {}, + onLongPress: () -> Unit = {} ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp) @@ -42,7 +45,9 @@ fun ReplyItem( ) Column( - modifier = modifier, + modifier = modifier.pointerInput(Unit) { + detectTapGestures(onLongPress = { onLongPress() }) + }, verticalArrangement = Arrangement.spacedBy(12.dp) ) { ProfileBarFeed( @@ -130,6 +135,7 @@ private fun ReplyItemPreview() { content = "This is a reply.", postDate = "2023-10-01T12:05:00Z", isLike = false, + isWriter = false, likeCount = 5 ) ) diff --git a/app/src/main/java/com/texthip/thip/ui/group/note/component/TextCommentCard.kt b/app/src/main/java/com/texthip/thip/ui/group/note/component/TextCommentCard.kt index aa55b37c..3dc8208a 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/note/component/TextCommentCard.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/note/component/TextCommentCard.kt @@ -31,6 +31,12 @@ fun TextCommentCard( val isLocked = data.isLocked val isWriter = data.isWriter + val pageText = if (data.isOverview) { + stringResource(id = R.string.general_review) + } else { + data.page.toString() + stringResource(R.string.page) + } + Column( modifier = modifier .blur(if (isLocked) 5.dp else 0.dp) @@ -46,7 +52,7 @@ fun TextCommentCard( modifier = Modifier.padding(0.dp), profileImage = "https://example.com/image1.jpg", topText = data.nickName, - bottomText = data.page.toString() + stringResource(R.string.page), + bottomText = pageText, bottomTextColor = colors.Purple, showSubscriberInfo = false, hoursAgo = data.postDate @@ -94,6 +100,7 @@ fun TextCommentCardPreview() { isLiked = true, isWriter = false, isLocked = false, + isOverview = false, voteItems = emptyList() ) ) diff --git a/app/src/main/java/com/texthip/thip/ui/group/note/component/VoteCommentCard.kt b/app/src/main/java/com/texthip/thip/ui/group/note/component/VoteCommentCard.kt index 9705ef67..04405d8c 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/note/component/VoteCommentCard.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/note/component/VoteCommentCard.kt @@ -28,7 +28,6 @@ fun VoteCommentCard( onVote: (postId: Int, voteItemId: Int, type: Boolean) -> Unit = { _, _, _ -> }, onCommentClick: () -> Unit = {}, onLongPress: () -> Unit = {}, - onPinClick: () -> Unit = {} ) { val selectedIndex = data.voteItems.indexOfFirst { it.isVoted }.takeIf { it != -1 } val hasVoted = selectedIndex != null @@ -36,6 +35,12 @@ fun VoteCommentCard( val isLocked = data.isLocked val isWriter = data.isWriter + val pageText = if (data.isOverview) { + stringResource(id = R.string.general_review) + } else { + data.page.toString() + stringResource(R.string.page) + } + Column( modifier = modifier .blur(if (isLocked) 5.dp else 0.dp) @@ -50,7 +55,7 @@ fun VoteCommentCard( ProfileBar( profileImage = "https://example.com/image1.jpg", topText = data.nickName, - bottomText = data.page.toString() + stringResource(R.string.page), + bottomText = pageText, bottomTextColor = colors.Purple, showSubscriberInfo = false, hoursAgo = data.postDate @@ -87,15 +92,11 @@ fun VoteCommentCard( isLiked = data.isLiked, likeCount = data.likeCount, commentCount = data.commentCount, - isPinVisible = isWriter, onLikeClick = { if (!isLocked) onLikeClick(data.postId, data.postType) }, onCommentClick = { if (!isLocked) onCommentClick() - }, - onPinClick = { - if (!isLocked) onPinClick() } ) } @@ -119,6 +120,7 @@ private fun VoteCommentCardPreview() { isLiked = true, isWriter = false, isLocked = false, + isOverview = false, voteItems = emptyList() ) ) diff --git a/app/src/main/java/com/texthip/thip/ui/group/note/mock/CommentData.kt b/app/src/main/java/com/texthip/thip/ui/group/note/mock/CommentData.kt deleted file mode 100644 index 5f7cbcd8..00000000 --- a/app/src/main/java/com/texthip/thip/ui/group/note/mock/CommentData.kt +++ /dev/null @@ -1,121 +0,0 @@ -package com.texthip.thip.ui.group.note.mock - -sealed class GroupNoteItem { -// abstract val postDate: String - abstract val postDate: String - abstract val page: Int - abstract val userId: Int - abstract val nickName: String - abstract val profileImageUrl: String - abstract val content: String - abstract val likeCount: Int - abstract val commentCount: Int - abstract val isLiked: Boolean - abstract val isWriter: Boolean - abstract val isLocked: Boolean -} - -data class GroupNoteRecord( - override val postDate: String, - override val page: Int, - override val userId: Int, - override val nickName: String, - override val profileImageUrl: String, - override val content: String, - override val likeCount: Int, - override val commentCount: Int, - override val isLiked: Boolean, - override val isWriter: Boolean, - override val isLocked: Boolean, - val recordId: Int -) : GroupNoteItem() - -data class GroupNoteVote( - override val postDate: String, - override val page: Int, - override val userId: Int, - override val nickName: String, - override val profileImageUrl: String, - override val content: String, - override val likeCount: Int, - override val commentCount: Int, - override val isLiked: Boolean, - override val isWriter: Boolean, - val voteId: Int, - override val isLocked: Boolean, - val voteItems: List -) : GroupNoteItem() - -data class VoteItem( - val voteItemId: Int, - val itemName: String, - val percentage: Int, - val isVoted: Boolean -) - -val mockGroupNoteItems: List = listOf( - GroupNoteRecord( - page = 132, - postDate = "12시간 전", - userId = 1, - nickName = "user.01", - profileImageUrl = "https://example.com/profile.jpg", - content = "내 생각에 이 부분이 가장 어려운 것 같다. 비유도 난해하고 잘 이해가 가지 않는데 다른 메이트들은 어떻게 읽었나요?", - likeCount = 123, - commentCount = 123, - isLiked = true, - isWriter = false, - isLocked = false, - recordId = 1 - ), - GroupNoteVote( - page = 12, - postDate = "12시간 전", - userId = 1, - nickName = "user.01", - profileImageUrl = "https://example.com/profile.jpg", - content = "3연에 나오는 심장은 무엇을 의미하는 걸까요?", - likeCount = 123, - commentCount = 123, - isLiked = false, - isWriter = false, - isLocked = true, - voteId = 1, - voteItems = listOf( - VoteItem(1, "김땡땡", 90, false), - VoteItem(2, "김땡땡", 10, false) - ) - ), - GroupNoteRecord( - page = 132, - postDate = "12시간 전", - userId = 1, - nickName = "user.01", - profileImageUrl = "https://example.com/profile.jpg", - content = "공백 포함 글자 입력입니다.", - likeCount = 123, - commentCount = 123, - isLiked = false, - isWriter = true, - isLocked = false, - recordId = 1 - ), - GroupNoteVote( - page = 12, - postDate = "12시간 전", - userId = 1, - nickName = "user.01", - profileImageUrl = "https://example.com/profile.jpg", - content = "3연에 나오는 심장은 무엇을 의미하는 걸까요?", - likeCount = 123, - commentCount = 123, - isLiked = true, - isWriter = true, - isLocked = false, - voteId = 1, - voteItems = listOf( - VoteItem(1, "김땡땡", 90, false), - VoteItem(2, "김땡땡", 10, false) - ) - ) -) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/group/note/screen/GroupNoteCreateScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/note/screen/GroupNoteCreateScreen.kt index bf69ed75..806d20ff 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/note/screen/GroupNoteCreateScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/note/screen/GroupNoteCreateScreen.kt @@ -113,7 +113,7 @@ fun GroupNoteCreateContent( } if (showTooltip && iconCoordinates.value != null) { val yOffsetDp = with(density) { - iconCoordinates.value!!.positionInRoot().y.toDp() + 32.dp + iconCoordinates.value!!.positionInRoot().y.toDp() } Box( diff --git a/app/src/main/java/com/texthip/thip/ui/group/note/screen/GroupNoteScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/note/screen/GroupNoteScreen.kt index 2c2c8944..f256aa78 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/note/screen/GroupNoteScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/note/screen/GroupNoteScreen.kt @@ -44,6 +44,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.texthip.thip.R import com.texthip.thip.data.model.rooms.response.PostList +import com.texthip.thip.data.model.rooms.response.RoomsRecordsPinResponse import com.texthip.thip.ui.common.bottomsheet.MenuBottomSheet import com.texthip.thip.ui.common.buttons.ExpandableFloatingButton import com.texthip.thip.ui.common.buttons.FabMenuItem @@ -59,6 +60,7 @@ import com.texthip.thip.ui.group.note.component.VoteCommentCard import com.texthip.thip.ui.group.note.viewmodel.CommentsEvent import com.texthip.thip.ui.group.note.viewmodel.CommentsViewModel import com.texthip.thip.ui.group.note.viewmodel.GroupNoteEvent +import com.texthip.thip.ui.group.note.viewmodel.GroupNoteSideEffect import com.texthip.thip.ui.group.note.viewmodel.GroupNoteUiState import com.texthip.thip.ui.group.note.viewmodel.GroupNoteViewModel import com.texthip.thip.ui.group.room.mock.MenuBottomSheetItem @@ -76,6 +78,7 @@ fun GroupNoteScreen( onBackClick: () -> Unit = {}, onCreateNoteClick: (recentPage: Int, totalPage: Int, isOverviewPossible: Boolean) -> Unit, onCreateVoteClick: (recentPage: Int, totalPage: Int, isOverviewPossible: Boolean) -> Unit, + onNavigateToFeedWrite: (pinInfo: RoomsRecordsPinResponse, recordContent: String) -> Unit, resultTabIndex: Int? = null, onResultConsumed: () -> Unit = {}, initialPage: Int? = null, @@ -126,6 +129,16 @@ fun GroupNoteScreen( } } + LaunchedEffect(viewModel.sideEffect) { + viewModel.sideEffect.collect { effect -> + when (effect) { + is GroupNoteSideEffect.NavigateToFeedWrite -> { + onNavigateToFeedWrite(effect.pinInfo, effect.recordContent) + } + } + } + } + GroupNoteContent( uiState = uiState, onEvent = viewModel::onEvent, @@ -160,7 +173,11 @@ fun GroupNoteContent( var selectedPostForMenu by remember { mutableStateOf(null) } var showDeleteDialog by remember { mutableStateOf(false) } var isPinDialogVisible by remember { mutableStateOf(false) } + var postToPin by remember { mutableStateOf(null) } var showToast by remember { mutableStateOf(false) } + val isOverlayVisible = + isCommentBottomSheetVisible || selectedPostForMenu != null || isPinDialogVisible || showDeleteDialog + var postToDelete by remember { mutableStateOf(null) } val commentsViewModel: CommentsViewModel = hiltViewModel() val commentsUiState by commentsViewModel.uiState.collectAsStateWithLifecycle() @@ -173,7 +190,6 @@ fun GroupNoteContent( } val tabs = listOf(stringResource(R.string.group_record), stringResource(R.string.my_record)) - val sortOptions = remember { SortType.entries.map { it.displayNameRes } } val sortDisplayStrings = remember { SortType.entries.map { it.displayNameRes } } .map { stringResource(it) } @@ -198,7 +214,7 @@ fun GroupNoteContent( } Box( - if (isCommentBottomSheetVisible || selectedPostForMenu != null || isPinDialogVisible) { + if (isOverlayVisible) { Modifier .fillMaxSize() .blur(5.dp) @@ -368,7 +384,10 @@ fun GroupNoteContent( isCommentBottomSheetVisible = true }, onLongPress = { selectedPostForMenu = post }, - onPinClick = { isPinDialogVisible = true }, + onPinClick = { + postToPin = post + isPinDialogVisible = true + }, onLikeClick = { postId, postType -> onEvent(GroupNoteEvent.OnLikeRecord(postId, postType)) } @@ -382,7 +401,6 @@ fun GroupNoteContent( isCommentBottomSheetVisible = true }, onLongPress = { selectedPostForMenu = post }, - onPinClick = { isPinDialogVisible = true }, onVote = { postId, voteItemId, type -> onEvent(GroupNoteEvent.OnVote(postId, voteItemId, type)) }, @@ -470,11 +488,13 @@ fun GroupNoteContent( } if (isCommentBottomSheetVisible && selectedPostForComment != null) { - LaunchedEffect(selectedPostForComment) { - commentsViewModel.initialize( - postId = selectedPostForComment!!.postId.toLong(), - postType = selectedPostForComment!!.postType - ) + LaunchedEffect(selectedPostForComment?.postId) { + selectedPostForComment?.let { post -> + commentsViewModel.initialize( + postId = post.postId.toLong(), + postType = post.postType + ) + } } CommentBottomSheet( @@ -483,6 +503,7 @@ fun GroupNoteContent( onDismiss = { isCommentBottomSheetVisible = false selectedPostForComment = null + onEvent(GroupNoteEvent.RefreshPosts) }, onSendReply = { text, parentId, _ -> if (text.isNotBlank()) { @@ -498,16 +519,15 @@ fun GroupNoteContent( } if (selectedPostForMenu != null) { - val isWriter = selectedPostForMenu!!.isWriter - - val menuItems = if (isWriter) { + val post = selectedPostForMenu!! + val menuItems = if (post.isWriter) { listOf( MenuBottomSheetItem( text = stringResource(R.string.delete), color = colors.Red, onClick = { - onEvent(GroupNoteEvent.OnDeleteRecord(selectedPostForMenu!!.postId)) - showDeleteDialog = false + postToDelete = post // 삭제할 포스트 정보를 기억 + showDeleteDialog = true selectedPostForMenu = null } ) @@ -527,12 +547,34 @@ fun GroupNoteContent( MenuBottomSheet( items = menuItems, - onDismiss = { - selectedPostForMenu = null - } + onDismiss = { selectedPostForMenu = null } ) } + if (showDeleteDialog) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + DialogPopup( + title = stringResource(R.string.delete_post_title), + description = stringResource(R.string.delete_post_content), + onConfirm = { + postToDelete?.let { + onEvent(GroupNoteEvent.OnDeleteRecord(it.postId, it.postType)) + } + showDeleteDialog = false + postToDelete = null + }, + onCancel = { + showDeleteDialog = false + postToDelete = null + } + ) + } + } + if (isPinDialogVisible) { Box( modifier = Modifier @@ -543,11 +585,20 @@ fun GroupNoteContent( title = stringResource(R.string.pin_modal_title), description = stringResource(R.string.pin_modal_content), onConfirm = { - // 핀하기 로직 + postToPin?.let { post -> + onEvent( + GroupNoteEvent.OnPinRecord( + recordId = post.postId, + content = post.content + ) + ) + } isPinDialogVisible = false + postToPin = null }, onCancel = { isPinDialogVisible = false + postToPin = null } ) } @@ -575,7 +626,8 @@ private fun GroupNoteScreenPreview() { likeCount = 10, commentCount = 2, isLocked = false, - isWriter = true + isWriter = true, + isOverview = false ) ), selectedTabIndex = 0, diff --git a/app/src/main/java/com/texthip/thip/ui/group/note/screen/GroupVoteCreateScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/note/screen/GroupVoteCreateScreen.kt index 832bda37..b4a613b3 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/note/screen/GroupVoteCreateScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/note/screen/GroupVoteCreateScreen.kt @@ -126,7 +126,7 @@ fun GroupVoteCreateContent( if (showTooltip && iconCoordinates.value != null) { val yOffsetDp = with(density) { - iconCoordinates.value!!.positionInRoot().y.toDp() + 32.dp + iconCoordinates.value!!.positionInRoot().y.toDp() } Box( diff --git a/app/src/main/java/com/texthip/thip/ui/group/note/viewmodel/CommentsViewModel.kt b/app/src/main/java/com/texthip/thip/ui/group/note/viewmodel/CommentsViewModel.kt index 4247ab32..07390989 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/note/viewmodel/CommentsViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/note/viewmodel/CommentsViewModel.kt @@ -3,6 +3,7 @@ package com.texthip.thip.ui.group.note.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.texthip.thip.data.model.comments.response.CommentList +import com.texthip.thip.data.model.comments.response.ReplyList import com.texthip.thip.data.repository.CommentsRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -22,8 +23,9 @@ data class CommentsUiState( sealed interface CommentsEvent { data object LoadMoreComments : CommentsEvent data class LikeComment(val commentId: Int) : CommentsEvent // 댓글 좋아요 이벤트 - data class LikeReply(val parentCommentId: Int, val replyId: Int) : CommentsEvent // 대댓글 좋아요 이벤트 + data class LikeReply(val replyId: Int) : CommentsEvent // 답글 좋아요 이벤트 data class CreateComment(val content: String, val parentId: Int?) : CommentsEvent + data class DeleteComment(val commentId: Int) : CommentsEvent } @HiltViewModel @@ -39,7 +41,6 @@ class CommentsViewModel @Inject constructor( private var currentPostType: String = "RECORD" fun initialize(postId: Long, postType: String) { - if (currentPostId == postId) return this.currentPostId = postId this.currentPostType = postType fetchComments(isRefresh = true) @@ -49,11 +50,53 @@ class CommentsViewModel @Inject constructor( when (event) { is CommentsEvent.LoadMoreComments -> fetchComments(isRefresh = false) is CommentsEvent.LikeComment -> toggleCommentLike(event.commentId) - is CommentsEvent.LikeReply -> toggleReplyLike(event.parentCommentId, event.replyId) + is CommentsEvent.LikeReply -> toggleReplyLike(event.replyId) is CommentsEvent.CreateComment -> createComment( content = event.content, parentId = event.parentId ) + + is CommentsEvent.DeleteComment -> deleteComment(event.commentId) + } + } + + private fun deleteComment(commentId: Int) { + val originalComments = _uiState.value.comments + + // 삭제하려는 대상이 부모 댓글인지 먼저 확인 + val parentCommentToDelete = originalComments.firstOrNull { it.commentId == commentId } + + val newComments = if (parentCommentToDelete != null) { + // 부모 댓글을 삭제하는 경우 + if (parentCommentToDelete.replyList.isEmpty()) { + // 답글이 없으면 목록에서 완전히 제거 + originalComments.filterNot { it.commentId == commentId } + } else { + // 답글이 있으면 "삭제됨" 상태로 변경 + originalComments.map { + if (it.commentId == commentId) it.copy(isDeleted = true) else it + } + } + } else { + // 답글을 삭제하는 경우 + // 모든 부모 댓글을 순회하며, 해당하는 답글만 목록에서 제거 + originalComments.map { parentComment -> + parentComment.copy( + replyList = parentComment.replyList.filterNot { reply -> reply.commentId == commentId } + ) + } + } + + _uiState.update { it.copy(comments = newComments) } + + viewModelScope.launch { + commentsRepository.deleteComment(commentId.toLong()) + .onSuccess { response -> + // 성공 시 별도 처리 필요 없음 + } + .onFailure { + _uiState.update { it.copy(comments = originalComments, error = "삭제 실패") } + } } } @@ -104,33 +147,43 @@ class CommentsViewModel @Inject constructor( } } - private fun toggleReplyLike(parentCommentId: Int, replyId: Int) { - // 부모 댓글 및 대댓글 찾기 + private fun toggleReplyLike(replyId: Int) { val comments = _uiState.value.comments - val parentCommentIndex = comments.indexOfFirst { it.commentId == parentCommentId } - if (parentCommentIndex == -1) return + var parentComment: CommentList? = null + var reply: ReplyList? = null + var parentCommentIndex = -1 + var replyIndex = -1 + + // 전체 댓글 목록을 돌면서 좋아요 누른 답글과 그 부모를 찾기 + for ((pIndex, pComment) in comments.withIndex()) { + val rIndex = pComment.replyList.indexOfFirst { it.commentId == replyId } + if (rIndex != -1) { + parentComment = pComment + reply = pComment.replyList[rIndex] + parentCommentIndex = pIndex + replyIndex = rIndex + break // 찾았으면 루프 종료 + } + } - val parentComment = comments[parentCommentIndex] - val replyIndex = parentComment.replyList.indexOfFirst { it.commentId == replyId } - if (replyIndex == -1) return + // 답글이나 부모를 못 찾으면 함수 종료 + if (parentComment == null || reply == null || parentCommentIndex == -1 || replyIndex == -1) return - val reply = parentComment.replyList[replyIndex] val currentIsLiked = reply.isLike val newLikeCount = if (currentIsLiked) reply.likeCount - 1 else reply.likeCount + 1 // 즉시 UI 업데이트 val updatedReply = reply.copy(isLike = !currentIsLiked, likeCount = newLikeCount) - val newReplyList = - parentComment.replyList.toMutableList().apply { set(replyIndex, updatedReply) } + val newReplyList = parentComment.replyList.toMutableList().apply { set(replyIndex, updatedReply) } val updatedParentComment = parentComment.copy(replyList = newReplyList) - val newComments = - comments.toMutableList().apply { set(parentCommentIndex, updatedParentComment) } + val newComments = comments.toMutableList().apply { set(parentCommentIndex, updatedParentComment) } _uiState.update { it.copy(comments = newComments) } viewModelScope.launch { commentsRepository.likeComment(replyId.toLong(), !currentIsLiked) .onFailure { _uiState.update { + // 실패 시 롤백 로직 (기존과 동일) val originalComments = it.comments.toMutableList() originalComments[parentCommentIndex] = parentComment it.copy(comments = originalComments) diff --git a/app/src/main/java/com/texthip/thip/ui/group/note/viewmodel/GroupNoteViewModel.kt b/app/src/main/java/com/texthip/thip/ui/group/note/viewmodel/GroupNoteViewModel.kt index e79db56a..e401fdb7 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/note/viewmodel/GroupNoteViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/note/viewmodel/GroupNoteViewModel.kt @@ -1,15 +1,19 @@ package com.texthip.thip.ui.group.note.viewmodel +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.texthip.thip.data.model.rooms.request.RoomsPostsRequestParams import com.texthip.thip.data.model.rooms.response.PostList +import com.texthip.thip.data.model.rooms.response.RoomsRecordsPinResponse import com.texthip.thip.data.repository.RoomsRepository import com.texthip.thip.utils.type.SortType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -38,6 +42,13 @@ data class GroupNoteUiState( val totalEnabled: Boolean = false ) +sealed interface GroupNoteSideEffect { + data class NavigateToFeedWrite( + val pinInfo: RoomsRecordsPinResponse, + val recordContent: String + ) : GroupNoteSideEffect +} + sealed interface GroupNoteEvent { data class OnTabSelected(val index: Int) : GroupNoteEvent data class OnSortSelected(val sortType: SortType) : GroupNoteEvent @@ -47,8 +58,10 @@ sealed interface GroupNoteEvent { data object ApplyPageFilter : GroupNoteEvent data object LoadMorePosts : GroupNoteEvent data class OnVote(val postId: Int, val voteItemId: Int, val type: Boolean) : GroupNoteEvent - data class OnDeleteRecord(val postId: Int) : GroupNoteEvent + data class OnDeleteRecord(val postId: Int, val postType: String) : GroupNoteEvent data class OnLikeRecord(val postId: Int, val postType: String) : GroupNoteEvent + data class OnPinRecord(val recordId: Int, val content: String) : GroupNoteEvent + data object RefreshPosts : GroupNoteEvent } @@ -60,6 +73,9 @@ class GroupNoteViewModel @Inject constructor( private val _uiState = MutableStateFlow(GroupNoteUiState()) val uiState = _uiState.asStateFlow() + private val _sideEffect = MutableSharedFlow() + val sideEffect = _sideEffect.asSharedFlow() + private var nextCursor: String? = null private var roomId: Int = -1 @@ -144,8 +160,31 @@ class GroupNoteViewModel @Inject constructor( type = event.type ) - is GroupNoteEvent.OnDeleteRecord -> deleteRecord(event.postId) + is GroupNoteEvent.OnDeleteRecord -> deletePost(event.postId, event.postType) is GroupNoteEvent.OnLikeRecord -> likeRecord(event.postId, event.postType) + is GroupNoteEvent.RefreshPosts -> loadPosts(isRefresh = true) + is GroupNoteEvent.OnPinRecord -> pinRecord(event.recordId, event.content) + else -> { + Log.w("GroupNoteViewModel", "Unhandled event received: $event") + } + } + } + + private fun pinRecord(recordId: Int, content: String) { + viewModelScope.launch { + roomsRepository.getRoomsRecordsPin(roomId = roomId, recordId = recordId) + .onSuccess { pinInfo -> + if (pinInfo != null) { + _sideEffect.emit( + GroupNoteSideEffect.NavigateToFeedWrite( + pinInfo = pinInfo, + recordContent = content + ) + ) + } + } + .onFailure { + } } } @@ -180,30 +219,72 @@ class GroupNoteViewModel @Inject constructor( } } - private fun deleteRecord(postId: Int) { + private fun deletePost(postId: Int, postType: String) { viewModelScope.launch { - roomsRepository.deleteRoomsRecord(roomId = roomId, recordId = postId) - .onSuccess { - val updatedPosts = _uiState.value.posts.filter { it.postId != postId } - _uiState.update { it.copy(posts = updatedPosts) } - } - .onFailure { throwable -> - _uiState.update { it.copy(error = throwable.message) } - } + val result = when (postType) { + "RECORD" -> roomsRepository.deleteRoomsRecord(roomId = roomId, recordId = postId) + "VOTE" -> roomsRepository.deleteRoomsVote(roomId = roomId, voteId = postId) + else -> Result.failure(IllegalArgumentException("Unknown post type for deletion: $postType")) + } + + result.onSuccess { + val updatedPosts = _uiState.value.posts.filter { it.postId != postId } + _uiState.update { it.copy(posts = updatedPosts) } + }.onFailure { throwable -> + _uiState.update { it.copy(error = throwable.message) } + } } } private fun vote(postId: Int, voteItemId: Int, type: Boolean) { + val originalPosts = _uiState.value.posts + val postIndex = originalPosts.indexOfFirst { it.postId == postId } + if (postIndex == -1) return + val postToUpdate = originalPosts[postIndex] + + val optimisticVoteItems = postToUpdate.voteItems.map { voteItem -> + voteItem.copy(isVoted = if (voteItem.voteItemId == voteItemId) type else false) + } + + val optimisticPosts = originalPosts.toMutableList().apply { + this[postIndex] = postToUpdate.copy(voteItems = optimisticVoteItems) + } + + _uiState.update { it.copy(posts = optimisticPosts) } + viewModelScope.launch { roomsRepository.postRoomsVote( roomId = roomId, voteId = postId, voteItemId = voteItemId, type = type - ).onSuccess { - loadPosts(isRefresh = true) - }.onFailure { throwable -> - _uiState.update { it.copy(error = throwable.message) } + ).onSuccess { voteResponse -> + if (voteResponse != null) { + val serverVoteItems = voteResponse.voteItems + + // 현재 UI가 가지고 있는 포스트 목록을 가져오기 + val currentPosts = _uiState.value.posts + val postIndex = currentPosts.indexOfFirst { it.postId == postId } + if (postIndex == -1) return@onSuccess + + val postToUpdate = currentPosts[postIndex] + + // 기존 순서는 유지하고 내용만 업데이트 + val updatedVoteItems = postToUpdate.voteItems.map { originalItem -> + val newItem = serverVoteItems.find { it.voteItemId == originalItem.voteItemId } + newItem ?: originalItem + } + + // 순서가 유지된 목록으로 최종 업데이트 + val finalPosts = currentPosts.toMutableList().apply { + this[postIndex] = postToUpdate.copy(voteItems = updatedVoteItems) + } + _uiState.update { it.copy(posts = finalPosts) } + } else { + loadPosts(isRefresh = true) + } + }.onFailure { + _uiState.update { it.copy(posts = originalPosts) } } } } diff --git a/app/src/main/java/com/texthip/thip/ui/group/room/component/GroupRoomBody.kt b/app/src/main/java/com/texthip/thip/ui/group/room/component/GroupRoomBody.kt index 1e25e71d..1a33072a 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/room/component/GroupRoomBody.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/room/component/GroupRoomBody.kt @@ -15,16 +15,20 @@ import com.texthip.thip.ui.common.buttons.ActionBookButton import com.texthip.thip.ui.common.cards.CardChat import com.texthip.thip.ui.common.cards.CardNote import com.texthip.thip.ui.common.cards.CardVote +import kotlin.math.roundToInt @Composable fun GroupRoomBody( modifier: Modifier = Modifier, bookTitle: String, authorName: String, + isbn: String, currentPage: Int, userPercentage: Double, currentVotes: List, + onNavigateToBookDetail: (isbn: String) -> Unit = {}, onNavigateToNote: () -> Unit = {}, + onNavigateToChat: () -> Unit = {}, onVoteClick: (CurrentVote) -> Unit = {} ) { Column( @@ -34,11 +38,13 @@ fun GroupRoomBody( ActionBookButton( bookTitle = bookTitle, bookAuthor = authorName - ) {} + ) { + onNavigateToBookDetail(isbn) + } CardNote( currentPage = currentPage, - percentage = userPercentage + percentage = userPercentage.roundToInt(), ) { onNavigateToNote() } @@ -46,7 +52,9 @@ fun GroupRoomBody( CardChat( title = stringResource(R.string.group_room_chat), subtitle = stringResource(R.string.group_room_chat_description) - ) {} + ) { + onNavigateToChat() + } CardVote( voteData = currentVotes, @@ -61,6 +69,7 @@ private fun GroupRoomBodyPreview() { GroupRoomBody( bookTitle = "책 제목", authorName = "저자 이름", + isbn = "1234567890", currentPage = 100, userPercentage = 50.0, currentVotes = listOf( diff --git a/app/src/main/java/com/texthip/thip/ui/group/room/screen/GroupRoomChatScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/room/screen/GroupRoomChatScreen.kt index 693aa802..fa771b18 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/room/screen/GroupRoomChatScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/room/screen/GroupRoomChatScreen.kt @@ -1,5 +1,6 @@ package com.texthip.thip.ui.group.room.screen +import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,6 +11,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -17,9 +19,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import com.texthip.thip.R import com.texthip.thip.ui.common.bottomsheet.MenuBottomSheet import com.texthip.thip.ui.common.cards.CardCommentGroup @@ -29,17 +33,56 @@ import com.texthip.thip.ui.common.view.CountingBar import com.texthip.thip.ui.group.room.mock.GroupRoomChatData import com.texthip.thip.ui.group.room.mock.MenuBottomSheetItem import com.texthip.thip.ui.group.room.mock.mockMessages +import com.texthip.thip.ui.group.room.viewmodel.GroupRoomChatEvent +import com.texthip.thip.ui.group.room.viewmodel.GroupRoomChatViewModel import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography +import com.texthip.thip.utils.rooms.advancedImePadding +import kotlinx.coroutines.flow.collectLatest @Composable -fun GroupRoomChatScreen() { -// val mockMessages = emptyList() +fun GroupRoomChatScreen( + onBackClick: () -> Unit, + viewModel: GroupRoomChatViewModel = hiltViewModel() +) { + var inputText by remember { mutableStateOf("") } + val context = LocalContext.current + // val uiState by viewModel.uiState.collectAsState() + val chatMessages = emptyList() - var input by remember { mutableStateOf("") } - var replyTo by remember { mutableStateOf(null) } + LaunchedEffect(key1 = Unit) { + viewModel.eventFlow.collectLatest { event -> + when(event) { + is GroupRoomChatEvent.ShowToast -> { + Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() + } + is GroupRoomChatEvent.SubmissionSuccess -> { + } + } + } + } + GroupRoomChatContent( + chatMessages = chatMessages, + inputText = inputText, + onInputTextChanged = { newText -> inputText = newText }, + onSendClick = { + viewModel.postDailyGreeting(inputText) + inputText = "" + }, + onNavigateBack = onBackClick + ) +} + +@Composable +fun GroupRoomChatContent( + chatMessages: List, + inputText: String, + onInputTextChanged: (String) -> Unit, + onSendClick: () -> Unit, + onNavigateBack: () -> Unit +) { var isBottomSheetVisible by remember { mutableStateOf(false) } var selectedMessage by remember { mutableStateOf(null) } @@ -55,10 +98,11 @@ fun GroupRoomChatScreen() { Column( modifier = Modifier .fillMaxSize() + .advancedImePadding() ) { DefaultTopAppBar( title = stringResource(R.string.group_room_chat), - onLeftClick = {}, + onLeftClick = onNavigateBack, ) if (mockMessages.isEmpty()) { @@ -104,7 +148,7 @@ fun GroupRoomChatScreen() { ) { if (isNewDate) { HorizontalDivider( - color = colors.DarkGrey02, + color = colors.DarkGrey03, thickness = 10.dp ) CountingBar( @@ -126,15 +170,10 @@ fun GroupRoomChatScreen() { } CommentTextField( - input = input, + input = inputText, hint = stringResource(R.string.group_room_chat_hint), - onInputChange = { input = it }, - onSendClick = { - input = "" - replyTo = null - }, - replyTo = replyTo, - onCancelReply = { replyTo = null } + onInputChange = onInputTextChanged, + onSendClick = onSendClick ) } } @@ -186,6 +225,13 @@ fun GroupRoomChatScreen() { @Composable private fun GroupRoomChatScreenPreview() { ThipTheme { - GroupRoomChatScreen() + var inputText by remember { mutableStateOf("") } + GroupRoomChatContent( + chatMessages = emptyList(), + inputText = inputText, + onInputTextChanged = { newText -> inputText = newText }, + onSendClick = {}, + onNavigateBack = {} + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/group/room/screen/GroupRoomScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/room/screen/GroupRoomScreen.kt index 19ccfba6..11060209 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/room/screen/GroupRoomScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/room/screen/GroupRoomScreen.kt @@ -51,7 +51,9 @@ fun GroupRoomScreen( roomId: Int, onBackClick: () -> Unit = {}, onNavigateToMates: () -> Unit = {}, + onNavigateToChat: () -> Unit = {}, onNavigateToNote: (page: Int?, isOverview: Boolean?) -> Unit = { _, _ -> }, + onNavigateToBookDetail: (isbn: String) -> Unit = {}, viewModel: GroupRoomViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -75,7 +77,9 @@ fun GroupRoomScreen( roomDetails = state.roomsPlaying, onBackClick = onBackClick, onNavigateToMates = onNavigateToMates, - onNavigateToNote = onNavigateToNote + onNavigateToChat = onNavigateToChat, + onNavigateToNote = onNavigateToNote, + onNavigateToBookDetail = onNavigateToBookDetail ) } @@ -93,7 +97,9 @@ fun GroupRoomContent( roomDetails: RoomsPlayingResponse, onBackClick: () -> Unit = {}, onNavigateToMates: () -> Unit = {}, + onNavigateToChat: () -> Unit = {}, onNavigateToNote: (page: Int?, isOverview: Boolean?) -> Unit = { _, _ -> }, + onNavigateToBookDetail: (isbn: String) -> Unit = {}, ) { val scrollState = rememberScrollState() @@ -173,11 +179,14 @@ fun GroupRoomContent( GroupRoomBody( bookTitle = roomDetails.bookTitle, authorName = roomDetails.authorName, + isbn = roomDetails.isbn, currentPage = roomDetails.currentPage, userPercentage = roomDetails.userPercentage, currentVotes = roomDetails.currentVotes, + onNavigateToBookDetail = onNavigateToBookDetail, // 일반 노트 카드 클릭 시 필터 없이 이동 onNavigateToNote = { onNavigateToNote(null, null) }, + onNavigateToChat = onNavigateToChat, // 투표 카드 클릭 시 필터 값과 함께 이동 onVoteClick = { vote: CurrentVote -> if (vote.isOverview) { diff --git a/app/src/main/java/com/texthip/thip/ui/group/room/viewmodel/GroupRoomChatViewModel.kt b/app/src/main/java/com/texthip/thip/ui/group/room/viewmodel/GroupRoomChatViewModel.kt new file mode 100644 index 00000000..84e2066f --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/room/viewmodel/GroupRoomChatViewModel.kt @@ -0,0 +1,52 @@ +package com.texthip.thip.ui.group.room.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.texthip.thip.data.repository.RoomsRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class GroupRoomChatUiState( + val isSubmitting: Boolean = false, + val error: String? = null +) + +sealed interface GroupRoomChatEvent { + data class ShowToast(val message: String) : GroupRoomChatEvent + data object SubmissionSuccess : GroupRoomChatEvent +} + +@HiltViewModel +class GroupRoomChatViewModel @Inject constructor( + private val roomsRepository: RoomsRepository, + savedStateHandle: SavedStateHandle +) : ViewModel() { + private val roomId: Int = requireNotNull(savedStateHandle["roomId"]) + + // TODO: 오늘의 한마디 조회 연결 + // private val _uiState = MutableStateFlow(GroupRoomChatUiState()) + // val uiState = _uiState.asStateFlow() + + private val _eventFlow = MutableSharedFlow() + val eventFlow = _eventFlow.asSharedFlow() + + fun postDailyGreeting(content: String) { + if (content.isBlank()) return + + viewModelScope.launch { + roomsRepository.postRoomsDailyGreeting( + roomId = roomId, + content = content + ).onSuccess { + _eventFlow.emit(GroupRoomChatEvent.SubmissionSuccess) + _eventFlow.emit(GroupRoomChatEvent.ShowToast("오늘의 한마디가 등록되었어요!")) + }.onFailure { + _eventFlow.emit(GroupRoomChatEvent.ShowToast(it.message ?: "등록에 실패했습니다.")) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/component/SavedFeedCard.kt b/app/src/main/java/com/texthip/thip/ui/mypage/component/SavedFeedCard.kt index 53255ff0..b9032072 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/component/SavedFeedCard.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/component/SavedFeedCard.kt @@ -1,28 +1,26 @@ package com.texthip.thip.ui.mypage.component -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage import com.texthip.thip.R +import com.texthip.thip.ui.common.buttons.ActionBarButton import com.texthip.thip.ui.common.buttons.ActionBookButton import com.texthip.thip.ui.common.header.ProfileBar import com.texthip.thip.ui.mypage.mock.FeedItem @@ -34,13 +32,13 @@ import com.texthip.thip.ui.theme.ThipTheme.typography fun SavedFeedCard( modifier: Modifier = Modifier, feedItem: FeedItem, + bottomTextColor: Color = colors.NeonGreen, onBookmarkClick: () -> Unit = {}, onLikeClick: () -> Unit = {}, - onContentClick: () -> Unit = {} + onContentClick: () -> Unit = {}, + onCommentClick: () -> Unit = {} ) { - val images = feedItem.imageUrls.orEmpty().map { painterResource(id = it) } - val imagePainters = feedItem.imageUrls.orEmpty().map { painterResource(it) } - val hasImages = imagePainters.isNotEmpty() + val hasImages = feedItem.imageUrls.isNotEmpty() val maxLines = if (hasImages) 3 else 8 Column( @@ -49,9 +47,10 @@ fun SavedFeedCard( .padding(horizontal = 20.dp) ) { ProfileBar( - profileImage = feedItem.userProfileImage.toString(), + profileImage = feedItem.userProfileImage ?: "https://example.com/image1.jpg", topText = feedItem.userName, bottomText = feedItem.userRole, + bottomTextColor = bottomTextColor, showSubscriberInfo = false, hoursAgo = feedItem.timeAgo ) @@ -66,69 +65,53 @@ fun SavedFeedCard( onClick = {} ) } - Text( - text = feedItem.content, - style = typography.feedcopy_r400_s14_h20, - color = colors.White, - maxLines = maxLines, + + Column( modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp) - .clickable { onContentClick() } - ) - if (images.isNotEmpty()) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - images.take(3).forEach { image -> - Image( - painter = image, - contentDescription = null, - modifier = Modifier - .padding(end = 10.dp) - .size(100.dp), - contentScale = ContentScale.Crop - ) - } - } - } - Row( - verticalAlignment = Alignment.CenterVertically + .clickable { onContentClick() }, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { - Icon( - modifier = Modifier.clickable { onLikeClick() }, - painter = painterResource(if (feedItem.isLiked) R.drawable.ic_heart_filled else R.drawable.ic_heart), - contentDescription = null, - tint = Color.Unspecified - ) - Text( - text = feedItem.likeCount.toString(), - style = typography.feedcopy_r400_s14_h20, - color = colors.White, - modifier = Modifier.padding(start = 5.dp, end = 12.dp) - ) - Icon( - painter = painterResource(R.drawable.ic_comment), - contentDescription = null, - tint = colors.White - ) Text( - text = feedItem.commentCount.toString(), + text = feedItem.content, style = typography.feedcopy_r400_s14_h20, color = colors.White, - modifier = Modifier.padding(start = 5.dp, end = 12.dp) - ) - Spacer(modifier = Modifier.weight(1f)) - Icon( - modifier = Modifier.clickable { onBookmarkClick() }, - painter = painterResource(if (feedItem.isSaved) R.drawable.ic_save_filled else R.drawable.ic_save), - contentDescription = null, - tint = Color.Unspecified + maxLines = maxLines, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) ) + if (hasImages) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + feedItem.imageUrls.take(3).forEach { imageUrl -> + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier + .padding(end = 10.dp) + .size(100.dp), + contentScale = ContentScale.Crop + ) + } + } + } } + + ActionBarButton( + isLiked = feedItem.isLiked, + likeCount = feedItem.likeCount, + commentCount = feedItem.commentCount, + isSaveVisible = true, + isSaved = feedItem.isSaved, + onLikeClick = onLikeClick, + onCommentClick = onCommentClick, + onBookmarkClick = onBookmarkClick + ) } } @@ -137,7 +120,7 @@ fun SavedFeedCard( private fun SavedFeedCardPrev() { val feed1 = FeedItem( id = 1, - userProfileImage = R.drawable.character_literature, + userProfileImage = "https://example.com/profile1.jpg", userName = "user.01", userRole = stringResource(R.string.influencer), bookTitle = "책 제목", @@ -148,12 +131,12 @@ private fun SavedFeedCardPrev() { commentCount = 5, isLiked = false, isSaved = true, - imageUrls = null + imageUrls = emptyList() ) val feed2 = FeedItem( id = 2, - userProfileImage = R.drawable.character_art, + userProfileImage = "https://example.com/profile2.jpg", userName = "user.01", userRole = stringResource(R.string.influencer), bookTitle = "책 제목", @@ -166,9 +149,9 @@ private fun SavedFeedCardPrev() { isLiked = false, isSaved = true, imageUrls = listOf( - R.drawable.img_book_cover_sample, - R.drawable.img_book_cover_sample, - R.drawable.img_book_cover_sample + "https://example.com/image1.jpg", + "https://example.com/image2.jpg", + "https://example.com/image3.jpg" ) ) val scrollState = rememberScrollState() diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/mock/FeedItem.kt b/app/src/main/java/com/texthip/thip/ui/mypage/mock/FeedItem.kt index 99009098..32c75942 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/mock/FeedItem.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/mock/FeedItem.kt @@ -2,7 +2,7 @@ package com.texthip.thip.ui.mypage.mock data class FeedItem( val id: Int, - val userProfileImage: Int? = null, + val userProfileImage: String? = null, val userName: String, val userRole: String, val bookTitle: String, @@ -15,6 +15,6 @@ data class FeedItem( val isSaved: Boolean, val isLocked: Boolean = false, val tags: List = emptyList(), - val imageUrls: List? = emptyList() + val imageUrls: List = emptyList() ) diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/SavedFeedViewModel.kt b/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/SavedFeedViewModel.kt index 056d05fe..7cb2229c 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/SavedFeedViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/SavedFeedViewModel.kt @@ -11,7 +11,7 @@ open class SavedFeedViewModel: ViewModel() { listOf( FeedItem( id = 1, - userProfileImage = R.drawable.character_art, + userProfileImage = "https://example.com/profile.jpg", userName = "user", userRole = "학생", bookTitle = "라랄ㄹ라라", @@ -25,7 +25,7 @@ open class SavedFeedViewModel: ViewModel() { ), FeedItem( id = 2, - userProfileImage = R.drawable.character_art, + userProfileImage = "https://example.com/profile.jpg", userName = "user", userRole = "학생", bookTitle = "라랄ㄹ라라", @@ -36,11 +36,11 @@ open class SavedFeedViewModel: ViewModel() { commentCount = 4, isLiked = false, isSaved = true, - imageUrls = null + imageUrls = emptyList() ), FeedItem( id = 3, - userProfileImage = R.drawable.character_art, + userProfileImage = "https://example.com/profile.jpg", userName = "user", userRole = "학생", bookTitle = "라랄ㄹ라라", @@ -51,11 +51,11 @@ open class SavedFeedViewModel: ViewModel() { commentCount = 4, isLiked = false, isSaved = true, - imageUrls = listOf(R.drawable.img_book_cover_sample) + imageUrls = listOf("https://example.com/image.jpg") ), FeedItem( id = 4, - userProfileImage = R.drawable.character_art, + userProfileImage = "https://example.com/profile.jpg", userName = "user", userRole = "학생", bookTitle = "책이름책이름", @@ -66,11 +66,11 @@ open class SavedFeedViewModel: ViewModel() { commentCount = 4, isLiked = false, isSaved = true, - imageUrls = listOf(R.drawable.img_book_cover_sample) + imageUrls = listOf("https://example.com/image.jpg") ), FeedItem( id = 5, - userProfileImage = R.drawable.character_art, + userProfileImage = "https://example.com/profile.jpg", userName = "user", userRole = "학생", bookTitle = "책이름책이름", diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/MainNavHost.kt b/app/src/main/java/com/texthip/thip/ui/navigator/MainNavHost.kt index 894a09d8..0bda5892 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/MainNavHost.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/MainNavHost.kt @@ -3,18 +3,18 @@ package com.texthip.thip.ui.navigator import androidx.compose.runtime.Composable import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost -import com.texthip.thip.ui.navigator.routes.MainTabRoutes +import com.texthip.thip.ui.navigator.navigations.commonNavigation import com.texthip.thip.ui.navigator.navigations.feedNavigation import com.texthip.thip.ui.navigator.navigations.groupNavigation import com.texthip.thip.ui.navigator.navigations.myPageNavigation import com.texthip.thip.ui.navigator.navigations.searchNavigation -import com.texthip.thip.ui.navigator.navigations.commonNavigation +import com.texthip.thip.ui.navigator.routes.MainTabRoutes // 메인 네비게이션 @Composable fun MainNavHost(navController: NavHostController) { NavHost(navController = navController, startDestination = MainTabRoutes.Feed) { - feedNavigation(navController) + feedNavigation(navController, navigateBack = navController::popBackStack) groupNavigation( navController = navController, navigateBack = navController::popBackStack diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/FeedNavigationExtensions.kt b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/FeedNavigationExtensions.kt index 02387a67..2b0fcdfa 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/FeedNavigationExtensions.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/FeedNavigationExtensions.kt @@ -17,4 +17,30 @@ fun NavHostController.navigateToMySubscription() { // 피드 작성으로 fun NavHostController.navigateToFeedWrite() { navigate(FeedRoutes.Write) +} + +// 피드 댓글으로 +fun NavHostController.navigateToFeedComment(feedId: Int) { + navigate(FeedRoutes.Comment(feedId)) + +fun NavHostController.navigateToFeedWrite( + isbn: String? = null, + bookTitle: String? = null, + bookAuthor: String? = null, + bookImageUrl: String? = null, + recordContent: String? = null +) { + navigate(FeedRoutes.Write( + isbn = isbn, + bookTitle = bookTitle, + bookAuthor = bookAuthor, + bookImageUrl = bookImageUrl, + recordContent = recordContent + )) +} + +// 유저 프로필(피드)로 +fun NavHostController.navigateToUserProfile(userId: Long) { + navigate(FeedRoutes.Others(userId)) + } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/GroupNavigationExtensions.kt b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/GroupNavigationExtensions.kt index 7812cf71..4de4f1b4 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/GroupNavigationExtensions.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/GroupNavigationExtensions.kt @@ -80,6 +80,11 @@ fun NavHostController.navigateToGroupRoomMates(roomId: Int) { navigate(GroupRoutes.RoomMates(roomId)) } +// 오늘의 한마디 회면으로 이동 +fun NavHostController.navigateToGroupRoomChat(roomId: Int) { + navigate(GroupRoutes.RoomChat(roomId)) +} + // 기록장 화면으로 이동 fun NavHostController.navigateToGroupNote( roomId: Int, diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/FeedNavigation.kt b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/FeedNavigation.kt index 40df9757..3d2e4f93 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/FeedNavigation.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/FeedNavigation.kt @@ -1,21 +1,31 @@ package com.texthip.thip.ui.navigator.navigations +import androidx.compose.runtime.LaunchedEffect +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable +import com.texthip.thip.ui.feed.screen.FeedCommentScreen import com.texthip.thip.ui.feed.screen.FeedScreen import com.texthip.thip.ui.feed.screen.FeedWriteScreen import com.texthip.thip.ui.feed.screen.MySubscriptionScreen +import com.texthip.thip.ui.navigator.extensions.navigateToFeedComment +import androidx.navigation.toRoute +import com.texthip.thip.ui.feed.screen.FeedOthersScreen +import com.texthip.thip.ui.feed.screen.FeedScreen +import com.texthip.thip.ui.feed.screen.FeedWriteScreen +import com.texthip.thip.ui.feed.screen.MySubscriptionScreen +import com.texthip.thip.ui.feed.viewmodel.FeedWriteViewModel import com.texthip.thip.ui.navigator.extensions.navigateToFeedWrite import com.texthip.thip.ui.navigator.extensions.navigateToMySubscription import com.texthip.thip.ui.navigator.routes.FeedRoutes import com.texthip.thip.ui.navigator.routes.MainTabRoutes // Feed -fun NavGraphBuilder.feedNavigation(navController: NavHostController) { +fun NavGraphBuilder.feedNavigation(navController: NavHostController, navigateBack: () -> Unit) { composable { backStackEntry -> val resultFeedId = backStackEntry.savedStateHandle.get("feedId") - + FeedScreen( nickname = "ThipUser01", userRole = "문학가", @@ -32,22 +42,69 @@ fun NavGraphBuilder.feedNavigation(navController: NavHostController) { }, onNavigateToFeedWrite = { navController.navigateToFeedWrite() + }, + onNavigateToFeedComment = { feedId -> + navController.navigateToFeedComment(feedId) } ) } composable { - MySubscriptionScreen(navController = navController) - } - composable { - FeedWriteScreen( + MySubscriptionScreen( onNavigateBack = { - navController.popBackStack() + navigateBack() }, + onNavigateToUserProfile = { userId -> + navController.navigate(FeedRoutes.Others(userId)) + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel: FeedWriteViewModel = hiltViewModel() + + LaunchedEffect(route) { + if (route.isbn != null && + route.bookTitle != null && + route.bookAuthor != null && + route.bookImageUrl != null && + route.recordContent != null) { + viewModel.setPinnedRecord( + isbn = route.isbn, + bookTitle = route.bookTitle, + bookAuthor = route.bookAuthor, + bookImageUrl = route.bookImageUrl, + recordContent = route.recordContent + ) + } + } + + FeedWriteScreen( + viewModel = viewModel, + onNavigateBack = { navigateBack() }, onFeedCreated = { feedId -> // 피드 생성 성공 시 결과를 저장하고 피드 목록으로 돌아가기 navController.getBackStackEntry(MainTabRoutes.Feed) - .savedStateHandle - .set("feedId", feedId) + .savedStateHandle["feedId"] = feedId + navController.popBackStack(MainTabRoutes.Feed, inclusive = false) + } + ) + } + composable { backStackEntry -> + // 다른 유저의 피드 화면 + FeedOthersScreen( + onNavigateBack = { + navigateBack() + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.arguments?.let { + FeedRoutes.Comment(it.getInt("feedId")) + } ?: return@composable + + FeedCommentScreen( + feedId = route.feedId, + onNavigateBack = { navController.popBackStack() } ) diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/GroupNavigation.kt b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/GroupNavigation.kt index 2fd20e47..28320fff 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/GroupNavigation.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/GroupNavigation.kt @@ -20,6 +20,7 @@ import com.texthip.thip.ui.group.note.screen.GroupNoteCreateScreen import com.texthip.thip.ui.group.note.screen.GroupNoteScreen import com.texthip.thip.ui.group.note.screen.GroupVoteCreateScreen import com.texthip.thip.ui.group.note.viewmodel.GroupNoteViewModel +import com.texthip.thip.ui.group.room.screen.GroupRoomChatScreen import com.texthip.thip.ui.group.room.screen.GroupRoomMatesScreen import com.texthip.thip.ui.group.room.screen.GroupRoomRecruitScreen import com.texthip.thip.ui.group.room.screen.GroupRoomScreen @@ -30,6 +31,7 @@ import com.texthip.thip.ui.group.search.screen.GroupSearchScreen import com.texthip.thip.ui.group.viewmodel.GroupViewModel import com.texthip.thip.ui.navigator.extensions.navigateToAlarm import com.texthip.thip.ui.navigator.extensions.navigateToBookDetail +import com.texthip.thip.ui.navigator.extensions.navigateToFeedWrite import com.texthip.thip.ui.navigator.extensions.navigateToGroupDone import com.texthip.thip.ui.navigator.extensions.navigateToGroupMakeRoom import com.texthip.thip.ui.navigator.extensions.navigateToGroupMy @@ -37,6 +39,7 @@ import com.texthip.thip.ui.navigator.extensions.navigateToGroupNote import com.texthip.thip.ui.navigator.extensions.navigateToGroupNoteCreate import com.texthip.thip.ui.navigator.extensions.navigateToGroupRecruit import com.texthip.thip.ui.navigator.extensions.navigateToGroupRoom +import com.texthip.thip.ui.navigator.extensions.navigateToGroupRoomChat import com.texthip.thip.ui.navigator.extensions.navigateToGroupRoomMates import com.texthip.thip.ui.navigator.extensions.navigateToGroupRoomUnlock import com.texthip.thip.ui.navigator.extensions.navigateToGroupSearch @@ -271,9 +274,15 @@ fun NavGraphBuilder.groupNavigation( onNavigateToMates = { navController.navigateToGroupRoomMates(roomId) }, + onNavigateToChat = { + navController.navigateToGroupRoomChat(roomId) + }, onNavigateToNote = { page, isOverview -> navController.navigateToGroupNote(roomId, page, isOverview) }, + onNavigateToBookDetail = { isbn -> + navController.navigateToBookDetail(isbn) + } ) } @@ -293,6 +302,12 @@ fun NavGraphBuilder.groupNavigation( ) } + composable { + GroupRoomChatScreen( + onBackClick = { navigateBack() }, + ) + } + // Group Note 화면 composable { backStackEntry -> val route = backStackEntry.toRoute() @@ -331,6 +346,15 @@ fun NavGraphBuilder.groupNavigation( isOverviewPossible = isOverviewPossible ) }, + onNavigateToFeedWrite = { pinInfo, recordContent -> + navController.navigateToFeedWrite( + isbn = pinInfo.isbn, + bookTitle = pinInfo.bookTitle, + bookAuthor = pinInfo.authorName, + bookImageUrl = pinInfo.bookImageUrl, + recordContent = recordContent + ) + }, viewModel = viewModel ) } diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/routes/FeedRoutes.kt b/app/src/main/java/com/texthip/thip/ui/navigator/routes/FeedRoutes.kt index c8c9eeb6..2969393b 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/routes/FeedRoutes.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/routes/FeedRoutes.kt @@ -4,7 +4,21 @@ import kotlinx.serialization.Serializable @Serializable sealed class FeedRoutes : Routes() { - + @Serializable data object MySubscription : FeedRoutes() + @Serializable data object Write : FeedRoutes() + + @Serializable data class Comment(val feedId: Int) : FeedRoutes() + + @Serializable + data class Write( + val isbn: String? = null, + val bookTitle: String? = null, + val bookAuthor: String? = null, + val bookImageUrl: String? = null, + val recordContent: String? = null + ) : FeedRoutes() + + @Serializable data class Others(val userId: Long) : FeedRoutes() } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/routes/GroupRoutes.kt b/app/src/main/java/com/texthip/thip/ui/navigator/routes/GroupRoutes.kt index 1cc8056f..bb7ec57c 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/routes/GroupRoutes.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/routes/GroupRoutes.kt @@ -36,6 +36,9 @@ sealed class GroupRoutes : Routes() { @Serializable data class RoomMates(val roomId: Int) : GroupRoutes() + @Serializable + data class RoomChat(val roomId: Int) : GroupRoutes() + @Serializable data class Note(val roomId: Int, val page: Int? = null, val isOverview: Boolean? = null) : GroupRoutes() diff --git a/app/src/main/java/com/texthip/thip/ui/theme/Color.kt b/app/src/main/java/com/texthip/thip/ui/theme/Color.kt index 89d77798..41c82ba2 100644 --- a/app/src/main/java/com/texthip/thip/ui/theme/Color.kt +++ b/app/src/main/java/com/texthip/thip/ui/theme/Color.kt @@ -41,6 +41,7 @@ val DarkGrey02 = Color(0xFF282828) val DarkGrey01 = Color(0x4B4B4B4B) val Black = Color(0xFF121212) val Black50 = Color(0x80121212) +val Black30 = Color(0x4D121212) val Black10 = Color(0x1A121212) val Black00 = Color(0x00121212) val Black700 = Color(0xFF090909) @@ -82,6 +83,7 @@ data class ThipColors( val DarkGrey02: Color, val Black: Color, val Black50: Color, + val Black30: Color, val Black10: Color, val Black00: Color, val Black800: Color, @@ -121,6 +123,7 @@ val defaultThipColors = ThipColors( DarkGrey02 = DarkGrey02, Black = Black, Black50 = Black50, + Black30 = Black30, Black10 = Black10, Black00 = Black00, Black700 = Black700, diff --git a/app/src/main/res/drawable/ic_search_character.xml b/app/src/main/res/drawable/ic_search_character.xml new file mode 100644 index 00000000..2485534e --- /dev/null +++ b/app/src/main/res/drawable/ic_search_character.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/app/src/main/res/drawable/search_character_image.png b/app/src/main/res/drawable/search_character_image.png deleted file mode 100644 index 0e8a899c..00000000 Binary files a/app/src/main/res/drawable/search_character_image.png and /dev/null differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af22d257..18d66a97 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -231,9 +231,13 @@ 첫번째 댓글을 남겨보세요 이 기록을 피드에 핀할까요? 핀하면 내 피드에 글을 옮길 수 있어요. + 이 기록을 삭제하시겠어요? + 삭제 후에는 되돌릴 수 없어요. 댓글 많은 순 기록을 게시 중입니다... 기록이 게시되었습니다! + 이 댓글을 삭제하시겠어요? + 삭제된 댓글이에요. 피드 @@ -310,6 +314,7 @@ 피드에 글을 작성해보세요 피드에 작성된 글이 없어요 관심있는 독서메이트를 찾아보세요! + 내 피드 모집을 마감하시겠습니까? 독서메이트 모집을 마감하면\n지금 바로 모임방 활동을 바로 시작할 수 있어요.