diff --git a/app/src/main/java/com/texthip/thip/data/model/comments/response/CommentsCreateResponse.kt b/app/src/main/java/com/texthip/thip/data/model/comments/response/CommentsCreateResponse.kt index 362a6df6..3700cd2a 100644 --- a/app/src/main/java/com/texthip/thip/data/model/comments/response/CommentsCreateResponse.kt +++ b/app/src/main/java/com/texthip/thip/data/model/comments/response/CommentsCreateResponse.kt @@ -4,5 +4,17 @@ import kotlinx.serialization.Serializable @Serializable data class CommentsCreateResponse( - val commentId: Int, + 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, ) \ 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/RoomsCreateDailyGreetingRequest.kt similarity index 75% rename from app/src/main/java/com/texthip/thip/data/model/rooms/request/RoomsDailyGreetingRequest.kt rename to app/src/main/java/com/texthip/thip/data/model/rooms/request/RoomsCreateDailyGreetingRequest.kt index 22b080fc..1e829ae5 100644 --- 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/RoomsCreateDailyGreetingRequest.kt @@ -3,6 +3,6 @@ package com.texthip.thip.data.model.rooms.request import kotlinx.serialization.Serializable @Serializable -data class RoomsDailyGreetingRequest( +data class RoomsCreateDailyGreetingRequest( val content: String, ) diff --git a/app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsCreateDailyGreetingResponse.kt b/app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsCreateDailyGreetingResponse.kt new file mode 100644 index 00000000..24bbba69 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsCreateDailyGreetingResponse.kt @@ -0,0 +1,10 @@ +package com.texthip.thip.data.model.rooms.response + +import kotlinx.serialization.Serializable + +@Serializable +data class RoomsCreateDailyGreetingResponse( + val attendanceCheckId: Long, + val roomId: Long, + val isFirstWrite: Boolean, +) 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 index 04e567ea..ba687abe 100644 --- 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 @@ -4,7 +4,19 @@ import kotlinx.serialization.Serializable @Serializable data class RoomsDailyGreetingResponse( - val attendanceCheckId: Long, - val roomId: Long, - val isFirstWrite: Boolean, + val todayCommentList: List, + val nextCursor: String?, + val isLast: Boolean ) + +@Serializable +data class TodayCommentList( + val attendanceCheckId: Int, + val creatorId: Int, + val creatorNickname: String, + val creatorProfileImageUrl: String, + val todayComment: String, + val postDate: String, + val date: String, + val isWriter: Boolean, +) \ 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 c8539668..290394d9 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 @@ -7,8 +7,8 @@ import com.texthip.thip.data.model.base.handleBaseResponse 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.RoomsCreateDailyGreetingRequest 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 @@ -29,23 +29,23 @@ class RoomsRepository @Inject constructor( private val genreManager: GenreManager, private val userDataManager: UserDataManager ) { - + /** 장르 목록 조회 */ fun getGenres(): Result> = runCatching { genreManager.getGenres() } - + /** 사용자 이름 조회(캐싱 데이터 사용)*/ fun getUserName(): Result = runCatching { userDataManager.getUserName() } - + /** 내가 참여 중인 모임방 목록 조회 */ suspend fun getMyJoinedRooms(page: Int): Result = runCatching { val response = roomsService.getJoinedRooms(page) .handleBaseResponse() .getOrThrow() - + response?.let { joinedRoomsDto -> userDataManager.cacheUserName(joinedRoomsDto.nickname) } @@ -56,19 +56,22 @@ class RoomsRepository @Inject constructor( suspend fun getRoomSections(genre: Genre? = null): Result = runCatching { val selectedGenre = genre ?: genreManager.getDefaultGenre() val apiCategory = genreManager.mapGenreToApiCategory(selectedGenre) - + roomsService.getRooms(apiCategory) .handleBaseResponse() .getOrThrow() } /** 타입별 내 모임방 목록 조회 */ - suspend fun getMyRoomsByType(type: String?, cursor: String? = null): Result = runCatching { + suspend fun getMyRoomsByType( + type: String?, + cursor: String? = null + ): Result = runCatching { roomsService.getMyRooms(type, cursor) .handleBaseResponse() .getOrThrow() } - + /** 모집중인 모임방 상세 정보 조회 */ suspend fun getRoomRecruiting(roomId: Int): Result = runCatching { roomsService.getRoomRecruiting(roomId) @@ -96,7 +99,10 @@ class RoomsRepository @Inject constructor( } /** 비밀번호 입력 */ - suspend fun postParticipateSecretRoom(roomId: Int, password: String): Result = runCatching { + suspend fun postParticipateSecretRoom( + roomId: Int, + password: String + ): Result = runCatching { val request = RoomSecretRoomRequest(password = password) val response = roomsService.postParticipateSecretRoom(roomId, request) .handleBaseResponse() @@ -116,7 +122,6 @@ class RoomsRepository @Inject constructor( } - /** 기록장 API들 */ suspend fun getRoomsPlaying( roomId: Int @@ -248,13 +253,23 @@ class RoomsRepository @Inject constructor( ).handleBaseResponse().getOrThrow() } + suspend fun getRoomsDailyGreeting( + roomId: Int, + cursor: String? + ) = runCatching { + roomsService.getRoomsDailyGreeting( + roomId = roomId, + cursor = cursor + ).handleBaseResponse().getOrThrow() + } + suspend fun postRoomsDailyGreeting( roomId: Int, content: String ) = runCatching { roomsService.postRoomsDailyGreeting( roomId = roomId, - request = RoomsDailyGreetingRequest( + request = RoomsCreateDailyGreetingRequest( content = content ) ).handleBaseResponse().getOrThrow() 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 80a9bedd..049b5aea 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 @@ -4,8 +4,8 @@ import com.texthip.thip.data.model.base.BaseResponse 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.RoomsCreateDailyGreetingRequest 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 @@ -18,6 +18,7 @@ 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.RoomsBookPageResponse +import com.texthip.thip.data.model.rooms.response.RoomsCreateDailyGreetingResponse 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 @@ -154,11 +155,17 @@ interface RoomsService { @Body request: RoomsPostsLikesRequest ): BaseResponse + @GET("rooms/{roomId}/daily-greeting") + suspend fun getRoomsDailyGreeting( + @Path("roomId") roomId: Int, + @Query("cursor") cursor: String? = null + ): BaseResponse + @POST("rooms/{roomId}/daily-greeting") suspend fun postRoomsDailyGreeting( @Path("roomId") roomId: Int, - @Body request: RoomsDailyGreetingRequest - ): BaseResponse + @Body request: RoomsCreateDailyGreetingRequest + ): BaseResponse @GET("rooms/{roomId}/records/{recordId}/pin") suspend fun getRoomsRecordsPin( diff --git a/app/src/main/java/com/texthip/thip/ui/common/CommentActionMode.kt b/app/src/main/java/com/texthip/thip/ui/common/CommentActionMode.kt new file mode 100644 index 00000000..0e39238c --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/common/CommentActionMode.kt @@ -0,0 +1,6 @@ +package com.texthip.thip.ui.common + +enum class CommentActionMode { + POPUP, + BOTTOM_SHEET +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/common/cards/CardCommentGroup.kt b/app/src/main/java/com/texthip/thip/ui/common/cards/CardCommentGroup.kt index 73036875..b2305a6f 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/cards/CardCommentGroup.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/cards/CardCommentGroup.kt @@ -10,14 +10,14 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.texthip.thip.data.model.rooms.response.TodayCommentList import com.texthip.thip.ui.common.header.ProfileBarWithDate -import com.texthip.thip.ui.group.room.mock.GroupRoomChatData import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography @Composable fun CardCommentGroup( - data: GroupRoomChatData, + data: TodayCommentList, onMenuClick: () -> Unit ) { Column( @@ -26,14 +26,14 @@ fun CardCommentGroup( .padding(horizontal = 20.dp) ) { ProfileBarWithDate( - profileImage = data.profileImage, - nickname = data.nickname, - dateText = data.date, + profileImage = data.creatorProfileImageUrl, + nickname = data.creatorNickname, + dateText = data.postDate, onMenuClick = onMenuClick ) Spacer(Modifier.height(8.dp)) Text( - text = data.content, + text = data.todayComment, style = typography.feedcopy_r400_s14_h20, color = colors.Grey @@ -45,12 +45,15 @@ fun CardCommentGroup( @Composable private fun CardCommentGroupPreview() { CardCommentGroup( - data = GroupRoomChatData( - profileImage = null, - nickname = "user.01", - date = "11시간 전", - content = "이것은 그룹 채팅의 댓글입니다. 이곳에 댓글 내용을 작성할 수 있습니다. 여러 줄로 작성해도 됩니다.", - isMine = false + data = TodayCommentList( + attendanceCheckId = 1, + creatorId = 1, + creatorProfileImageUrl = "", + creatorNickname = "user.01", + todayComment = "이것은 그룹 채팅의 댓글입니다. 이곳에 댓글 내용을 작성할 수 있습니다. 여러 줄로 작성해도 됩니다.", + postDate = "11시간 전", + date = "2025-08-18", + isWriter = false ), onMenuClick = {} ) diff --git a/app/src/main/java/com/texthip/thip/ui/common/header/ProfileBarWithDate.kt b/app/src/main/java/com/texthip/thip/ui/common/header/ProfileBarWithDate.kt index 5bb9c8c0..d901bde5 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/header/ProfileBarWithDate.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/header/ProfileBarWithDate.kt @@ -1,10 +1,7 @@ package com.texthip.thip.ui.common.header -import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -20,10 +17,10 @@ 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.graphics.painter.Painter import androidx.compose.ui.res.painterResource 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.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors @@ -31,7 +28,7 @@ import com.texthip.thip.ui.theme.ThipTheme.typography @Composable fun ProfileBarWithDate( - profileImage: Painter?, + profileImage: String, nickname: String, dateText: String, onMenuClick: () -> Unit = {} @@ -43,22 +40,13 @@ fun ProfileBarWithDate( horizontalArrangement = Arrangement.SpaceBetween ) { Row { - if (profileImage != null) { - Image( - painter = profileImage, - contentDescription = "프로필 이미지", - modifier = Modifier - .size(24.dp) - .clip(CircleShape) - ) - } else { - Box( - modifier = Modifier - .size(24.dp) - .clip(CircleShape) - .background(colors.Grey) - ) - } + AsyncImage( + model = profileImage, + contentDescription = "프로필 이미지", + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + ) Spacer(modifier = Modifier.width(4.dp)) Column { Text( @@ -91,7 +79,7 @@ fun ProfileBarWithDate( fun PreviewProfileBarWithDate() { ThipTheme { ProfileBarWithDate( - profileImage = null, + profileImage = "https://example.com", nickname = "user.01", dateText = "2025.01.12" ) diff --git a/app/src/main/java/com/texthip/thip/ui/common/modal/ToastWithDate.kt b/app/src/main/java/com/texthip/thip/ui/common/modal/ToastWithDate.kt index 1d8d1687..88c4e2c4 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/modal/ToastWithDate.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/modal/ToastWithDate.kt @@ -1,6 +1,5 @@ package com.texthip.thip.ui.common.modal -import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -14,6 +13,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.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -24,7 +24,8 @@ import com.texthip.thip.ui.theme.ThipTheme.typography @Composable fun ToastWithDate( modifier: Modifier = Modifier, - message: String, + message: String = stringResource(R.string.group_room_chat_max), + color: Color = colors.White, date: String? = null ) { Box( @@ -45,7 +46,7 @@ fun ToastWithDate( ) { Text( text = message, - color = colors.White, + color = color, style = typography.view_m500_s12_h20 ) if (date != null) { diff --git a/app/src/main/java/com/texthip/thip/ui/feed/component/CommentPopup.kt b/app/src/main/java/com/texthip/thip/ui/feed/component/CommentPopup.kt new file mode 100644 index 00000000..130d3a87 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/feed/component/CommentPopup.kt @@ -0,0 +1,71 @@ +package com.texthip.thip.ui.feed.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +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.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import com.texthip.thip.ui.theme.ThipTheme.colors +import com.texthip.thip.ui.theme.ThipTheme.typography +import kotlin.math.roundToInt + +@Composable +fun CommentActionPopup( + text: String, + textColor: Color = colors.White, + onClick: () -> Unit, + onDismissRequest: () -> Unit +) { + val density = LocalDensity.current + val yOffsetPx = with(density) { 45.dp.toPx() }.roundToInt() + + Popup( + alignment = Alignment.BottomEnd, + offset = IntOffset(x = 0, yOffsetPx), + onDismissRequest = onDismissRequest, + properties = PopupProperties(focusable = true) + ) { + Box( + modifier = Modifier + .background( + colors.Black, + RoundedCornerShape(12.dp) + ) + .border( + width = 1.dp, + color = colors.White, + shape = RoundedCornerShape(12.dp) + ) + .clickable { onClick() } + .padding(horizontal = 20.dp, vertical = 12.dp) + ) { + Text( + text = text, + style = typography.feedcopy_r400_s14_h20, + color = textColor + ) + } + } +} + +@Preview +@Composable +private fun CommentActionPopupPreview() { + CommentActionPopup( + text = "삭제하기", + onClick = { }, + onDismissRequest = { } + ) +} \ 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 aaa0a2a0..c2ba0c71 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,7 +1,5 @@ package com.texthip.thip.ui.feed.screen -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement @@ -17,8 +15,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items 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 @@ -26,18 +24,15 @@ 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 import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateList 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.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -45,6 +40,7 @@ import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import coil.compose.AsyncImage import com.texthip.thip.R +import com.texthip.thip.ui.common.CommentActionMode import com.texthip.thip.ui.common.bottomsheet.MenuBottomSheet import com.texthip.thip.ui.common.buttons.ActionBookButton import com.texthip.thip.ui.common.buttons.OptionChipButton @@ -55,13 +51,13 @@ import com.texthip.thip.ui.common.modal.ToastWithDate import com.texthip.thip.ui.common.topappbar.DefaultTopAppBar import com.texthip.thip.ui.feed.component.ImageViewerModal import com.texthip.thip.ui.feed.viewmodel.FeedDetailViewModel -import com.texthip.thip.ui.group.note.mock.mockCommentList +import com.texthip.thip.ui.group.note.component.CommentSection +import com.texthip.thip.ui.group.note.viewmodel.CommentsEvent +import com.texthip.thip.ui.group.note.viewmodel.CommentsViewModel 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 -import com.texthip.thip.ui.group.note.mock.CommentItem as FeedCommentItem -import com.texthip.thip.ui.group.note.mock.ReplyItem as FeedReplyItem import kotlinx.coroutines.delay @Composable @@ -70,25 +66,19 @@ fun FeedCommentScreen( feedId: Int, onNavigateBack: () -> Unit = {}, onNavigateToFeedEdit: (Int) -> Unit = {}, - currentUserId: Int = 1, - currentUserName: String = "현재사용자", - currentUserGenre: String = "문학", - currentUserProfileImageUrl: String = "", - onLikeClick: () -> Unit = {}, - onCommentInputChange: (String) -> Unit = {}, - onSendClick: () -> Unit = {}, - commentList: SnapshotStateList? = null, - viewModel: FeedDetailViewModel = hiltViewModel() + feedDetailViewModel: FeedDetailViewModel = hiltViewModel(), + commentsViewModel: CommentsViewModel = hiltViewModel() ) { - val uiState by viewModel.uiState.collectAsState() - val context = LocalContext.current - + val feedDetailUiState by feedDetailViewModel.uiState.collectAsState() + val commentsUiState by commentsViewModel.uiState.collectAsState() + LaunchedEffect(feedId) { - viewModel.loadFeedDetail(feedId) + feedDetailViewModel.loadFeedDetail(feedId) + commentsViewModel.initialize(postId = feedId.toLong(), postType = "FEED") } - + // 로딩 상태 처리 - if (uiState.isLoading) { + if (feedDetailUiState.isLoading) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -100,9 +90,9 @@ fun FeedCommentScreen( } return } - + // 에러 상태 처리 - if (uiState.error != null) { + if (feedDetailUiState.error != null) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -115,7 +105,7 @@ fun FeedCommentScreen( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = uiState.error!!, + text = feedDetailUiState.error!!, style = typography.copy_r400_s14, color = colors.Grey ) @@ -123,25 +113,24 @@ fun FeedCommentScreen( } return } - + // 피드 데이터가 없으면 리턴 - val feedDetail = uiState.feedDetail ?: return - val CommentList = commentList ?: remember { mutableStateListOf() } + val feedDetail = feedDetailUiState.feedDetail ?: return var isBottomSheetVisible by remember { mutableStateOf(false) } - var showDialog by remember { mutableStateOf(false) } + var showDialog by remember { mutableStateOf(false) } // 피드 삭제 var showToast by remember { mutableStateOf(false) } - val commentInput = remember { mutableStateOf("") } - val replyTo = remember { mutableStateOf(null) } - val feed = remember { mutableStateOf(feedDetail) } - val justNow = stringResource(R.string.just_a_moment_ago) - val images = feedDetail.contentUrls var showImageViewer by remember { mutableStateOf(false) } var selectedImageIndex by remember { mutableStateOf(0) } - var selectedComment by remember { mutableStateOf(null) } - var selectedReply by remember { mutableStateOf(null) } + var commentInput by remember { mutableStateOf("") } + var replyingToCommentId by remember { mutableStateOf(null) } + var replyingToNickname by remember { mutableStateOf(null) } + + var selectedCommentId by remember { mutableStateOf(null) } + + val focusManager = LocalFocusManager.current Box(modifier = Modifier.fillMaxSize()) { Box( @@ -152,11 +141,11 @@ fun FeedCommentScreen( } else { Modifier.fillMaxSize() } - //바깥 터치 시 선택 해제 되도록 + // 바깥 터치 시 키보드 숨기기 .pointerInput(Unit) { detectTapGestures(onTap = { - selectedComment = null - selectedReply = null + focusManager.clearFocus() + selectedCommentId = null }) } ) { @@ -245,161 +234,69 @@ fun FeedCommentScreen( HorizontalDivider(color = colors.DarkGrey03, thickness = 10.dp) } } - //댓글이 없는 경우 - if (CommentList.isEmpty()) { - item { - Column( - modifier = Modifier - .fillMaxWidth() - .height(400.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(R.string.no_comments_yet), - style = typography.smalltitle_sb600_s18_h24, - color = colors.White - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.no_comment_subtext), - style = typography.copy_r400_s14, - color = colors.Grey - ) + when { + commentsUiState.isLoading -> { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 40.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = colors.White) + } } } - } else { - CommentList.forEachIndexed { index, commentItem -> + // 댓글 없음 + commentsUiState.comments.isEmpty() -> { item { - Spacer(modifier = Modifier.height(40.dp)) Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp) + .height(400.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { - // 댓글 아이템 - Box( - modifier = Modifier - .fillMaxWidth() - .pointerInput(Unit) { - detectTapGestures( - onLongPress = { - selectedComment = commentItem - selectedReply = null - } - ) - } - ) { -// CommentItem( -// data = commentItem, -// onReplyClick = { replyTo.value = it } -// ) - } - if (selectedComment == commentItem) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - val isMyComment = commentItem.userId == currentUserId - Box( - modifier = Modifier - .background( - Color.Transparent, - RoundedCornerShape(12.dp) - ) - .border( - width = 1.dp, - color = colors.White, - shape = RoundedCornerShape(12.dp) - ) - .clickable { - if (isMyComment) { - //TODO 삭제 로직 - } else { - //TODO 신고 로직 - } - selectedComment = null - } - .padding(horizontal = 20.dp, vertical = 12.dp) - ) { - Text( - text = stringResource(if (isMyComment) R.string.delete else R.string.report), - color = if (isMyComment) colors.White else colors.Red, - style = typography.feedcopy_r400_s14_h20 - ) - } - } - } + Text( + text = stringResource(R.string.no_comments_yet), + style = typography.smalltitle_sb600_s18_h24, + color = colors.White + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.no_comment_subtext), + style = typography.copy_r400_s14, + color = colors.Grey + ) } + } + } - // 대댓글들 - Spacer(modifier = Modifier.height(24.dp)) - commentItem.replyList.forEach { reply -> - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - ) { - // 대댓글 아이템 - Box( - modifier = Modifier - .fillMaxWidth() - .pointerInput(Unit) { - detectTapGestures( - onLongPress = { - selectedReply = reply - selectedComment = null - } - ) - } - ) { -// ReplyItem(data = reply, onReplyClick = { replyTo.value = it }) - } - - if (selectedReply == reply) { - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - val isMyReply = reply.userId == currentUserId - Box( - modifier = Modifier - .background( - Color.Transparent, - RoundedCornerShape(12.dp) - ) - .border( - width = 1.dp, - color = colors.White, - shape = RoundedCornerShape(12.dp) - ) - .clickable { - if (isMyReply) { - //TODO 삭제 로직 - } else { - //TODO 신고 로직 - } - selectedReply = null - } - .padding(horizontal = 20.dp, vertical = 12.dp) - ) { - Text( - text = stringResource(if (isMyReply) R.string.delete else R.string.report), - color = if (isMyReply) colors.White else colors.Red, - style = typography.feedcopy_r400_s14_h20 - ) - } - } - } - Spacer(modifier = Modifier.height(24.dp)) + else -> { + items( + items = commentsUiState.comments, + key = { comment -> comment.commentId ?: comment.hashCode() } + ) { commentItem -> + CommentSection( + commentItem = commentItem, + actionMode = CommentActionMode.POPUP, + selectedCommentId = selectedCommentId, + onEvent = commentsViewModel::onEvent, + onReplyClick = { commentId, nickname -> + replyingToCommentId = commentId + replyingToNickname = nickname + selectedCommentId = null + }, + onCommentLongPress = { comment -> + selectedCommentId = comment.commentId + }, + onReplyLongPress = { reply -> + selectedCommentId = reply.commentId + }, + onDismissPopup = { + selectedCommentId = null } - } - - - if (index == CommentList.lastIndex) { - Spacer(modifier = Modifier.height(40.dp)) - } + ) } } } @@ -408,60 +305,28 @@ fun FeedCommentScreen( // 댓글 입력창 CommentTextField( modifier = Modifier.align(Alignment.BottomCenter), - input = commentInput.value, - hint = stringResource(R.string.feed_reply_to, feedDetail.creatorNickname), - onInputChange = { - commentInput.value = it - onCommentInputChange(it) - }, + input = commentInput, + hint = stringResource(R.string.reply_to), + onInputChange = { commentInput = it }, onSendClick = { - if (commentInput.value.isNotBlank()) { - val replyTargetNickname = replyTo.value - if (replyTargetNickname == null) { - CommentList.add( - FeedCommentItem( - commentId = CommentList.size + 1, - userId = currentUserId, - nickName = currentUserName, - genreName = currentUserGenre, - profileImageUrl = currentUserProfileImageUrl, - content = commentInput.value, - postDate = justNow, - isWriter = true, - isLiked = false, - likeCount = 0, - replyList = emptyList() - ) + if (commentInput.isNotBlank()) { + commentsViewModel.onEvent( + CommentsEvent.CreateComment( + content = commentInput, + parentId = replyingToCommentId ) - } else { - val parentIndex = - CommentList.indexOfFirst { it.nickName == replyTargetNickname } - if (parentIndex != -1) { - val parentComment = CommentList[parentIndex] - val newReply = FeedReplyItem( - replyId = parentComment.replyList.size + 1, - userId = currentUserId, - nickName = currentUserName, - parentNickname = replyTargetNickname, - genreName = currentUserGenre, - profileImageUrl = currentUserProfileImageUrl, - content = commentInput.value, - postDate = justNow, - isWriter = true, - isLiked = false, - likeCount = 0 - ) - CommentList[parentIndex] = - parentComment.copy(replyList = parentComment.replyList + newReply) - } - } - commentInput.value = "" - replyTo.value = null - onSendClick() + ) + commentInput = "" + replyingToCommentId = null + replyingToNickname = null + focusManager.clearFocus() } }, - replyTo = replyTo.value, - onCancelReply = { replyTo.value = null } + replyTo = replyingToNickname, + onCancelReply = { + replyingToCommentId = null + replyingToNickname = null + } ) } @@ -512,7 +377,7 @@ fun FeedCommentScreen( ) ) } - + MenuBottomSheet( items = menuItems, onDismiss = { isBottomSheetVisible = false } @@ -558,41 +423,12 @@ fun FeedCommentScreen( } } -@Preview -@Composable -private fun FeedCommentScreenWithMockComments() { - ThipTheme { - val commentList = remember { - mutableStateListOf().apply { - addAll(mockCommentList.commentData) - } - } - FeedCommentScreen( - feedId = 1, - onNavigateToFeedEdit = {}, - currentUserId = 999, - currentUserName = "나", - currentUserGenre = "문학", - currentUserProfileImageUrl = "", - commentList = commentList - ) - } -} - @Preview @Composable private fun FeedCommentScreenPrev() { ThipTheme { - val commentList = remember { mutableStateListOf() } - FeedCommentScreen( feedId = 1, - onNavigateToFeedEdit = {}, - currentUserId = 999, - currentUserName = "나", - currentUserGenre = "문학", - currentUserProfileImageUrl = "", - commentList = commentList ) } } 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 f5f2ac63..f50adfeb 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 @@ -29,6 +29,7 @@ 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.CommentActionMode import com.texthip.thip.ui.common.bottomsheet.CustomBottomSheet import com.texthip.thip.ui.common.bottomsheet.MenuBottomSheet import com.texthip.thip.ui.common.forms.CommentTextField @@ -230,6 +231,7 @@ private fun CommentLazyList( ) { comment -> CommentSection( commentItem = comment, + actionMode = CommentActionMode.BOTTOM_SHEET, onReplyClick = onReplyClick, onEvent = onEvent, onCommentLongPress = onCommentLongPress, 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 fe6ec29f..658f789d 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 @@ -3,6 +3,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.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -19,7 +20,10 @@ 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.ui.common.CommentActionMode import com.texthip.thip.ui.common.header.ProfileBarFeed +import com.texthip.thip.ui.feed.component.CommentActionPopup +import com.texthip.thip.ui.group.note.viewmodel.CommentsEvent import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography @@ -31,71 +35,92 @@ fun CommentItem( data: CommentList, onReplyClick: (String?) -> Unit = { }, onLikeClick: () -> Unit = {}, - onLongPress: () -> Unit = {} + onLongPress: () -> Unit = {}, + actionMode: CommentActionMode, + isSelected: Boolean = false, + onDismissPopup: () -> Unit = {}, + onEvent: (CommentsEvent) -> Unit = { _ -> } ) { - if (data.isDeleted) { - Text( - text = stringResource(R.string.comment_deleted), - style = typography.feedcopy_r400_s14_h20, - color = colors.Grey02, - ) - } else { - Column( - modifier = modifier.pointerInput(Unit) { - detectTapGestures(onLongPress = { onLongPress() }) - }, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - data - ProfileBarFeed( - profileImage = data.creatorProfileImageUrl, - nickname = data.creatorNickname ?: "", - genreName = data.aliasName ?: "", - genreColor = hexToColor(data.aliasColor ?: "#FFFFFF"), - date = data.postDate ?: "" + Box { + 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) + data + ProfileBarFeed( + profileImage = data.creatorProfileImageUrl, + nickname = data.creatorNickname ?: "", + genreName = data.aliasName ?: "", + genreColor = hexToColor(data.aliasColor ?: "#FFFFFF"), + date = data.postDate ?: "" + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(20.dp) ) { - data.content?.let { + 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( - text = it, - color = colors.Grey, - style = typography.feedcopy_r400_s14_h20, + modifier = Modifier.clickable(onClick = { onReplyClick(data.creatorNickname) }), + text = stringResource(R.string.write_reply), + style = typography.menu_sb600_s12, + color = colors.Grey02, ) } - 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, - ) + 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, + ) + } } } } + if (actionMode == CommentActionMode.POPUP && isSelected) { + CommentActionPopup( + text = if (data.isWriter) stringResource(R.string.delete) else stringResource(R.string.report), + textColor = if (data.isWriter) colors.Red else colors.White, + onClick = { + if (data.isWriter) { + data.commentId?.let { onEvent(CommentsEvent.DeleteComment(it)) } + } else { + // TODO: 신고 로직 + } + onDismissPopup() + }, + onDismissRequest = onDismissPopup + ) + } } } @@ -125,7 +150,8 @@ private fun CommentItemPreview() { isDeleted = false, isWriter = false, replyList = emptyList() - ) + ), + actionMode = CommentActionMode.POPUP, ) CommentItem( @@ -143,7 +169,8 @@ private fun CommentItemPreview() { isDeleted = false, isWriter = false, replyList = emptyList() - ) + ), + actionMode = CommentActionMode.BOTTOM_SHEET, ) CommentItem( @@ -161,7 +188,8 @@ private fun CommentItemPreview() { isDeleted = false, isWriter = false, replyList = emptyList() - ) + ), + actionMode = CommentActionMode.POPUP, ) } } 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 bd50ea28..cd4b6ef6 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 @@ -12,6 +12,7 @@ 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.common.CommentActionMode import com.texthip.thip.ui.group.note.viewmodel.CommentsEvent import com.texthip.thip.ui.theme.ThipTheme @@ -22,6 +23,9 @@ fun CommentSection( onEvent: (CommentsEvent) -> Unit = { _ -> }, onCommentLongPress: (CommentList) -> Unit = { _ -> }, onReplyLongPress: (ReplyList) -> Unit = { _ -> }, + actionMode: CommentActionMode, + selectedCommentId: Int? = null, + onDismissPopup: () -> Unit = {} ) { Box { Column( @@ -47,7 +51,11 @@ fun CommentSection( onEvent(CommentsEvent.LikeComment(id)) } }, - onLongPress = { onCommentLongPress(commentItem) } + onLongPress = { onCommentLongPress(commentItem) }, + actionMode = actionMode, + isSelected = selectedCommentId != null && commentItem.commentId == selectedCommentId, + onDismissPopup = onDismissPopup, + onEvent = onEvent ) commentItem.replyList.forEach { reply -> @@ -61,7 +69,11 @@ fun CommentSection( onLikeClick = { onEvent(CommentsEvent.LikeReply(reply.commentId)) }, - onLongPress = { onReplyLongPress(reply) } + onLongPress = { onReplyLongPress(reply) }, + actionMode = actionMode, + isSelected = selectedCommentId != null && reply.commentId == selectedCommentId, + onDismissPopup = onDismissPopup, + onEvent = onEvent ) } } @@ -92,7 +104,8 @@ fun CommentSectionPreview() { ), onReplyClick = { commentId, nickname -> // Handle reply click - } + }, + actionMode = CommentActionMode.POPUP, ) } } 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 19b0d6c9..829ec33c 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 @@ -3,6 +3,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.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -21,7 +22,10 @@ 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.ReplyList +import com.texthip.thip.ui.common.CommentActionMode import com.texthip.thip.ui.common.header.ProfileBarFeed +import com.texthip.thip.ui.feed.component.CommentActionPopup +import com.texthip.thip.ui.group.note.viewmodel.CommentsEvent import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography @@ -33,84 +37,104 @@ fun ReplyItem( data: ReplyList, onReplyClick: () -> Unit = { }, onLikeClick: () -> Unit = {}, - onLongPress: () -> Unit = {} + onLongPress: () -> Unit = {}, + actionMode: CommentActionMode, + isSelected: Boolean = false, + onDismissPopup: () -> Unit = {}, + onEvent: (CommentsEvent) -> Unit = { _ -> } ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - painter = painterResource(R.drawable.ic_reply), - contentDescription = null, - tint = colors.White - ) - - Column( - modifier = modifier.pointerInput(Unit) { - detectTapGestures(onLongPress = { onLongPress() }) - }, - verticalArrangement = Arrangement.spacedBy(12.dp) + Box { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - ProfileBarFeed( - profileImage = data.creatorProfileImageUrl, - nickname = data.creatorNickname, - genreName = data.aliasName, - genreColor = hexToColor(data.aliasColor), - date = data.postDate + Icon( + painter = painterResource(R.drawable.ic_reply), + contentDescription = null, + tint = colors.White ) - Row( - horizontalArrangement = Arrangement.spacedBy(20.dp) + Column( + modifier = modifier.pointerInput(Unit) { + detectTapGestures(onLongPress = { onLongPress() }) + }, + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Column( - modifier = Modifier - .weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp) + ProfileBarFeed( + profileImage = data.creatorProfileImageUrl, + nickname = data.creatorNickname, + genreName = data.aliasName, + genreColor = hexToColor(data.aliasColor), + date = data.postDate + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(20.dp) ) { - Text( - text = buildAnnotatedString { - withStyle( - style = typography.copy_m500_s14_h20.copy(color = colors.White) - .toSpanStyle() - ) { - append( - stringResource(R.string.annotation) + data.parentCommentCreatorNickname + stringResource( - R.string.space_bar + Column( + modifier = Modifier + .weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = buildAnnotatedString { + withStyle( + style = typography.copy_m500_s14_h20.copy(color = colors.White) + .toSpanStyle() + ) { + append( + stringResource(R.string.annotation) + data.parentCommentCreatorNickname + stringResource( + R.string.space_bar + ) ) - ) - } - append(data.content) - }, - color = colors.Grey, - style = typography.feedcopy_r400_s14_h20, - ) - Text( - modifier = Modifier.clickable(onClick = onReplyClick), - text = stringResource(R.string.write_reply), - style = typography.menu_sb600_s12, - color = colors.Grey02, - ) - } + } + append(data.content) + }, + color = colors.Grey, + style = typography.feedcopy_r400_s14_h20, + ) + Text( + modifier = Modifier.clickable(onClick = onReplyClick), + 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, - ) + 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, + ) + } } } } + if (actionMode == CommentActionMode.POPUP && isSelected) { + CommentActionPopup( + text = if (data.isWriter) stringResource(R.string.delete) else stringResource(R.string.report), + textColor = if (data.isWriter) colors.Red else colors.White, + onClick = { + if (data.isWriter) { + onEvent(CommentsEvent.DeleteComment(data.commentId)) + } else { + // TODO: 신고 로직 + } + onDismissPopup() + }, + onDismissRequest = onDismissPopup // 바깥 클릭 시 팝업 닫기 + ) + } } - } @Preview @@ -137,7 +161,8 @@ private fun ReplyItemPreview() { isLike = false, isWriter = false, likeCount = 5 - ) + ), + actionMode = CommentActionMode.POPUP, ) } } diff --git a/app/src/main/java/com/texthip/thip/ui/group/note/mock/ReplyData.kt b/app/src/main/java/com/texthip/thip/ui/group/note/mock/ReplyData.kt deleted file mode 100644 index f6d3cc6f..00000000 --- a/app/src/main/java/com/texthip/thip/ui/group/note/mock/ReplyData.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.texthip.thip.ui.group.note.mock - -data class CommentItem( - val commentId: Int, - val userId: Int, - val nickName: String, - val genreName: String, - val profileImageUrl: String, - val content: String, - val postDate: String, - val isWriter: Boolean, - val isLiked: Boolean, - val likeCount: Int, - val replyList: List = emptyList() -) - -data class ReplyItem( - val replyId: Int, - val userId: Int, - val nickName: String, - val genreName: String, - val profileImageUrl: String, - val parentNickname: String, - val content: String, - val postDate: String, - val isWriter: Boolean, - val isLiked: Boolean, - val likeCount: Int -) - -data class CommentResponse( - val commentData: List -) - -val mockComment = CommentItem( - commentId = 1, - userId = 1, - nickName = "user.01", - genreName = "칭호칭호", - profileImageUrl = "https://example.com/profile.jpg", - content = "입력하세요. 댓글 내용을 입력하세요오. 댓글 내용을 입력하세요. 댓글 내용을 입력하세요. 댓글 내용을 입력하세요. ", - postDate = "2025.01.12", - isWriter = false, - isLiked = true, - likeCount = 123, - replyList = listOf( - ReplyItem( - replyId = 1, - userId = 2, - nickName = "user.02", - genreName = "칭호칭호", - profileImageUrl = "https://example.com/profile2.jpg", - parentNickname = "user.01", - content = "답글 내용입니다.", - postDate = "12시간 전", - isWriter = false, - isLiked = false, - likeCount = 123 - ), - ReplyItem( - replyId = 1, - userId = 2, - nickName = "user.02", - genreName = "칭호칭호", - profileImageUrl = "https://example.com/profile2.jpg", - parentNickname = "user.01", - content = "답글 내용입니다.", - postDate = "2025.01.13", - isWriter = false, - isLiked = false, - likeCount = 2 - ) - ) -) - -val mockCommentList = CommentResponse( - commentData = listOf( - mockComment, mockComment - ) -) 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 f256aa78..379b9fd8 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 @@ -240,6 +240,7 @@ fun GroupNoteContent( ) { ToastWithDate( message = stringResource(R.string.condition_of_view_general_review), + color = colors.Red ) } 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 07390989..dc6cdd01 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 @@ -112,8 +112,57 @@ class CommentsViewModel @Inject constructor( isReplyRequest = isReply, parentId = parentId, postType = currentPostType - ).onSuccess { - fetchComments(isRefresh = true) + ).onSuccess { response -> + response?.let { res -> + _uiState.update { currentState -> + if (parentId == null) { + val newComment = CommentList( + commentId = res.commentId, + creatorId = res.creatorId, + creatorProfileImageUrl = res.creatorProfileImageUrl, + creatorNickname = res.creatorNickname, + aliasName = res.aliasName, + aliasColor = res.aliasColor, + postDate = res.postDate, + content = res.content, + likeCount = res.likeCount, + isDeleted = res.isDeleted, + isWriter = res.isWriter, + isLike = res.isLike, + replyList = res.replyList + ) + currentState.copy(comments = listOf(newComment) + currentState.comments) + } else { + val parentCommentIndex = + currentState.comments.indexOfFirst { it.commentId == parentId } + + if (parentCommentIndex != -1) { + val updatedParentComment = CommentList( + commentId = res.commentId, + creatorId = res.creatorId, + creatorProfileImageUrl = res.creatorProfileImageUrl, + creatorNickname = res.creatorNickname, + aliasName = res.aliasName, + aliasColor = res.aliasColor, + postDate = res.postDate, + content = res.content, + likeCount = res.likeCount, + isDeleted = res.isDeleted, + isWriter = res.isWriter, + isLike = res.isLike, + replyList = res.replyList + ) + + val newCommentsList = currentState.comments.toMutableList().apply { + this[parentCommentIndex] = updatedParentComment + } + currentState.copy(comments = newCommentsList) + } else { + currentState + } + } + } + } }.onFailure { throwable -> _uiState.update { it.copy(error = "댓글 작성 실패: ${throwable.message}") } } @@ -174,9 +223,11 @@ class CommentsViewModel @Inject constructor( // 즉시 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 { @@ -206,7 +257,11 @@ class CommentsViewModel @Inject constructor( val cursorToFetch = if (isRefresh) null else nextCursor - commentsRepository.getComments(postId = currentPostId, cursor = cursorToFetch) + commentsRepository.getComments( + postId = currentPostId, + postType = currentPostType, + cursor = cursorToFetch + ) .onSuccess { response -> if (response != null) { nextCursor = response.nextCursor 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 fa771b18..bc7d026c 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,17 +1,26 @@ package com.texthip.thip.ui.group.room.screen -import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth 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.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.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -19,26 +28,29 @@ 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.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import com.texthip.thip.R +import com.texthip.thip.data.model.rooms.response.TodayCommentList import com.texthip.thip.ui.common.bottomsheet.MenuBottomSheet import com.texthip.thip.ui.common.cards.CardCommentGroup import com.texthip.thip.ui.common.forms.CommentTextField +import com.texthip.thip.ui.common.modal.ToastWithDate import com.texthip.thip.ui.common.topappbar.DefaultTopAppBar 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.GroupRoomChatUiState import com.texthip.thip.ui.group.room.viewmodel.GroupRoomChatViewModel +import com.texthip.thip.ui.group.room.viewmodel.ToastType 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.delay import kotlinx.coroutines.flow.collectLatest @Composable @@ -47,44 +59,69 @@ fun GroupRoomChatScreen( viewModel: GroupRoomChatViewModel = hiltViewModel() ) { var inputText by remember { mutableStateOf("") } - val context = LocalContext.current - // val uiState by viewModel.uiState.collectAsState() - val chatMessages = emptyList() + val uiState by viewModel.uiState.collectAsState() + + var activeToast by remember { mutableStateOf(null) } LaunchedEffect(key1 = Unit) { viewModel.eventFlow.collectLatest { event -> - when(event) { + when (event) { is GroupRoomChatEvent.ShowToast -> { - Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() - } - is GroupRoomChatEvent.SubmissionSuccess -> { + activeToast = event.type } + + else -> Unit } } } GroupRoomChatContent( - chatMessages = chatMessages, + uiState = uiState, + onEvent = viewModel::onEvent, inputText = inputText, onInputTextChanged = { newText -> inputText = newText }, onSendClick = { viewModel.postDailyGreeting(inputText) inputText = "" }, - onNavigateBack = onBackClick + onNavigateBack = onBackClick, + activeToast = activeToast, + onDismissToast = { activeToast = null } ) } @Composable fun GroupRoomChatContent( - chatMessages: List, + uiState: GroupRoomChatUiState, + onEvent: (GroupRoomChatEvent) -> Unit, inputText: String, onInputTextChanged: (String) -> Unit, onSendClick: () -> Unit, - onNavigateBack: () -> Unit + onNavigateBack: () -> Unit, + activeToast: ToastType?, + onDismissToast: () -> Unit ) { var isBottomSheetVisible by remember { mutableStateOf(false) } - var selectedMessage by remember { mutableStateOf(null) } + var selectedMessage by remember { mutableStateOf(null) } + val lazyListState = rememberLazyListState() + + LaunchedEffect(key1 = uiState.greetings) { + if (uiState.greetings.isNotEmpty()) { + lazyListState.animateScrollToItem(index = 0) + } + } + + val isScrolledToTop by remember { + derivedStateOf { + lazyListState.firstVisibleItemIndex == 0 && lazyListState.firstVisibleItemScrollOffset == 0 + } + } + + LaunchedEffect(isScrolledToTop) { + if (isScrolledToTop && !uiState.isLoadingMore && !uiState.isLastPage) { + onEvent(GroupRoomChatEvent.LoadMore) + } + } Box( if (isBottomSheetVisible) { @@ -105,7 +142,16 @@ fun GroupRoomChatContent( onLeftClick = onNavigateBack, ) - if (mockMessages.isEmpty()) { + if (uiState.isLoading) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = colors.White) + } + } else if (uiState.greetings.isEmpty()) { Box( modifier = Modifier .weight(1f) @@ -130,14 +176,30 @@ fun GroupRoomChatContent( } } else { LazyColumn( + state = lazyListState, reverseLayout = true, modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.Bottom) ) { - itemsIndexed(mockMessages) { index, message -> + if (uiState.isLoadingMore) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + } + + itemsIndexed( + uiState.greetings, + key = { _, item -> item.attendanceCheckId }) { index, message -> val isNewDate = when { - index == mockMessages.lastIndex -> true - mockMessages[index + 1].date != message.date -> true + index == uiState.greetings.lastIndex -> true + uiState.greetings[index + 1].date != message.date -> true else -> false } val isBottomItem = index == 0 @@ -176,10 +238,45 @@ fun GroupRoomChatContent( onSendClick = onSendClick ) } + + AnimatedVisibility( + visible = activeToast != null, + enter = slideInVertically( + initialOffsetY = { -it }, // 위에서 아래로 + animationSpec = tween(durationMillis = 2000) + ), + exit = slideOutVertically( + targetOffsetY = { -it }, // 위로 사라짐 + animationSpec = tween(durationMillis = 2000) + ), + modifier = Modifier + .align(Alignment.TopCenter) + .padding(horizontal = 20.dp, vertical = 16.dp) + .zIndex(3f) + ) { + LaunchedEffect(activeToast) { + if (activeToast != null) { + delay(3000L) + onDismissToast() + } + } + + when (activeToast) { + ToastType.DAILY_GREETING_LIMIT -> { + ToastWithDate(color = colors.Red) + } + + ToastType.FIRST_WRITE -> { + ToastWithDate() + } + + null -> {} + } + } } if (isBottomSheetVisible && selectedMessage != null) { - val menuItems = if (selectedMessage!!.isMine) { + val menuItems = if (selectedMessage!!.isWriter) { listOf( MenuBottomSheetItem( text = stringResource(R.string.modify), @@ -227,11 +324,28 @@ private fun GroupRoomChatScreenPreview() { ThipTheme { var inputText by remember { mutableStateOf("") } GroupRoomChatContent( - chatMessages = emptyList(), + uiState = GroupRoomChatUiState( + isLoading = false, + greetings = listOf( + TodayCommentList( + attendanceCheckId = 3, + creatorId = 3, + creatorProfileImageUrl = "", + creatorNickname = "user.03", + todayComment = "세 번째 메시지입니다. 오늘 날씨가 좋네요.", + postDate = "10분 전", + date = "2025년 8월 18일", + isWriter = false + ), + ) + ), + onEvent = {}, inputText = inputText, onInputTextChanged = { newText -> inputText = newText }, onSendClick = {}, - onNavigateBack = {} + onNavigateBack = {}, + activeToast = null, + onDismissToast = {} ) } } \ No newline at end of file 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 index 84e2066f..f8858cea 100644 --- 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 @@ -3,21 +3,34 @@ 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.model.rooms.response.TodayCommentList import com.texthip.thip.data.repository.RoomsRepository import dagger.hilt.android.lifecycle.HiltViewModel 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 import javax.inject.Inject data class GroupRoomChatUiState( - val isSubmitting: Boolean = false, + val greetings: List = emptyList(), + val isLoading: Boolean = false, + val isLoadingMore: Boolean = false, + val isLastPage: Boolean = false, val error: String? = null ) +enum class ToastType { + DAILY_GREETING_LIMIT, + FIRST_WRITE +} + sealed interface GroupRoomChatEvent { - data class ShowToast(val message: String) : GroupRoomChatEvent - data object SubmissionSuccess : GroupRoomChatEvent + data object LoadMore : GroupRoomChatEvent + data class ShowToast(val type: ToastType) : GroupRoomChatEvent + data class ShowErrorToast(val message: String) : GroupRoomChatEvent } @HiltViewModel @@ -27,13 +40,70 @@ class GroupRoomChatViewModel @Inject constructor( ) : ViewModel() { private val roomId: Int = requireNotNull(savedStateHandle["roomId"]) - // TODO: 오늘의 한마디 조회 연결 - // private val _uiState = MutableStateFlow(GroupRoomChatUiState()) - // val uiState = _uiState.asStateFlow() + private val _uiState = MutableStateFlow(GroupRoomChatUiState()) + val uiState = _uiState.asStateFlow() private val _eventFlow = MutableSharedFlow() val eventFlow = _eventFlow.asSharedFlow() + private var nextCursor: String? = null + + init { + fetchDailyGreetings(isRefresh = true) + } + + fun onEvent(event: GroupRoomChatEvent) { + when (event) { + is GroupRoomChatEvent.LoadMore -> fetchDailyGreetings() + else -> Unit + } + } + + private fun fetchDailyGreetings(isRefresh: Boolean = false) { + val currentState = _uiState.value + if (currentState.isLoading || currentState.isLoadingMore || (currentState.isLastPage && !isRefresh)) return + + viewModelScope.launch { + if (isRefresh) { + _uiState.update { + it.copy( + isLoading = true, + greetings = emptyList(), + isLastPage = false + ) + } + nextCursor = null + } else { + _uiState.update { it.copy(isLoadingMore = true) } + } + + roomsRepository.getRoomsDailyGreeting( + roomId = roomId, + cursor = nextCursor + ).onSuccess { response -> + response?.let { data -> + _uiState.update { + it.copy( + isLoading = false, + isLoadingMore = false, + greetings = if (isRefresh) data.todayCommentList else it.greetings + data.todayCommentList, + isLastPage = data.isLast + ) + } + nextCursor = data.nextCursor + } + }.onFailure { throwable -> + _uiState.update { + it.copy( + isLoading = false, + isLoadingMore = false, + error = throwable.message + ) + } + } + } + } + fun postDailyGreeting(content: String) { if (content.isBlank()) return @@ -41,11 +111,15 @@ class GroupRoomChatViewModel @Inject constructor( roomsRepository.postRoomsDailyGreeting( roomId = roomId, content = content - ).onSuccess { - _eventFlow.emit(GroupRoomChatEvent.SubmissionSuccess) - _eventFlow.emit(GroupRoomChatEvent.ShowToast("오늘의 한마디가 등록되었어요!")) - }.onFailure { - _eventFlow.emit(GroupRoomChatEvent.ShowToast(it.message ?: "등록에 실패했습니다.")) + ).onSuccess { response -> + if (response != null) { + if (response.isFirstWrite) { + _eventFlow.emit(GroupRoomChatEvent.ShowToast(ToastType.FIRST_WRITE)) + } + fetchDailyGreetings(isRefresh = true) + } + }.onFailure { throwable -> + _eventFlow.emit(GroupRoomChatEvent.ShowToast(ToastType.DAILY_GREETING_LIMIT)) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3c306061..6e0f908f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -183,6 +183,7 @@ 신고하기 아직 대화가 없어요 첫번째 한마디를 남겨보세요! + 오늘의 한마디는 하루에 다섯번까지 작성할 수 있어요 %d. %s