diff --git a/app/src/main/java/com/texthip/thip/data/model/feed/request/UpdateFeedRequest.kt b/app/src/main/java/com/texthip/thip/data/model/feed/request/UpdateFeedRequest.kt new file mode 100644 index 00000000..ac9dc2da --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/feed/request/UpdateFeedRequest.kt @@ -0,0 +1,12 @@ +package com.texthip.thip.data.model.feed.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateFeedRequest( + @SerialName("contentBody") val contentBody: String? = null, + @SerialName("isPublic") val isPublic: Boolean? = null, + @SerialName("tagList") val tagList: List? = null, + @SerialName("remainImageUrls") val remainImageUrls: List? = null +) \ 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 index 9b03febd..360dd988 100644 --- 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 @@ -15,6 +15,7 @@ data class FeedDetailResponse( @SerialName("bookTitle") val bookTitle: String, @SerialName("isbn") val isbn: String, @SerialName("bookAuthor") val bookAuthor: String, + @SerialName("bookImageUrl") val bookImageUrl: String? = null, // 책 이미지 URL 추가 @SerialName("contentBody") val contentBody: String, @SerialName("contentUrls") val contentUrls: List, @SerialName("likeCount") val likeCount: Int, @@ -22,5 +23,6 @@ data class FeedDetailResponse( @SerialName("isSaved") val isSaved: Boolean, @SerialName("isLiked") val isLiked: Boolean, @SerialName("isWriter") val isWriter: Boolean, + @SerialName("isPublic") val isPublic: Boolean? = null, // 공개/비공개 설정 추가 @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/FeedMineInfoResponse.kt b/app/src/main/java/com/texthip/thip/data/model/feed/response/FeedMineInfoResponse.kt new file mode 100644 index 00000000..9a6777f4 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/feed/response/FeedMineInfoResponse.kt @@ -0,0 +1,17 @@ +package com.texthip.thip.data.model.feed.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FeedMineInfoResponse( + @SerialName("creatorId") val creatorId: Int, + @SerialName("profileImageUrl") val profileImageUrl: String?, + @SerialName("nickname") val nickname: String, + @SerialName("aliasName") val aliasName: String, + @SerialName("aliasColor") val aliasColor: String, + @SerialName("followerCount") val followerCount: Int, + @SerialName("totalFeedCount") val totalFeedCount: Int, + @SerialName("isFollowing") val isFollowing: Boolean, + @SerialName("latestFollowerProfileImageUrls") val latestFollowerProfileImageUrls: List +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/feed/response/RelatedBooksResponse.kt b/app/src/main/java/com/texthip/thip/data/model/feed/response/RelatedBooksResponse.kt new file mode 100644 index 00000000..d725a953 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/feed/response/RelatedBooksResponse.kt @@ -0,0 +1,32 @@ +package com.texthip.thip.data.model.feed.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RelatedBooksResponse( + @SerialName("feeds") val feeds: List, + @SerialName("nextCursor") val nextCursor: String?, + @SerialName("isLast") val isLast: Boolean +) + +@Serializable +data class RelatedFeedItem( + @SerialName("feedId") val feedId: Int, + @SerialName("creatorId") val creatorId: Int, + @SerialName("isWriter") val isWriter: Boolean, + @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 +) \ 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 index e275b5d7..7a04925f 100644 --- 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 @@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable @Serializable data class UsersMyFollowingsRecentFeedsResponse( - val recentWriters: List + val myFollowingUsers: List ) @Serializable 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 e1ee396c..fc112ef7 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 @@ -4,9 +4,12 @@ import android.content.Context 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.request.UpdateFeedRequest 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.feed.response.FeedMineInfoResponse +import com.texthip.thip.data.model.feed.response.RelatedBooksResponse import com.texthip.thip.data.model.feed.response.AllFeedResponse import com.texthip.thip.data.model.feed.response.MyFeedResponse import com.texthip.thip.data.service.FeedService @@ -147,6 +150,24 @@ class FeedRepository @Inject constructor( .getOrThrow() } + /** 내 피드 정보 조회 */ + suspend fun getMyFeedInfo(): Result = runCatching { + feedService.getMyFeedInfo() + .handleBaseResponse() + .getOrThrow() + } + + /** 특정 책과 관련된 피드 목록 조회 */ + suspend fun getRelatedBookFeeds( + isbn: String, + sort: String? = null, + cursor: String? = null + ): Result = runCatching { + feedService.getRelatedBookFeeds(isbn, sort, cursor) + .handleBaseResponse() + .getOrThrow() + } + /** 피드 상세 조회 */ suspend fun getFeedDetail(feedId: Int): Result = runCatching { feedService.getFeedDetail(feedId) @@ -154,6 +175,26 @@ class FeedRepository @Inject constructor( .getOrThrow() } + /** 피드 수정 */ + suspend fun updateFeed( + feedId: Int, + contentBody: String? = null, + isPublic: Boolean? = null, + tagList: List? = null, + remainImageUrls: List? = null + ): Result = runCatching { + val request = UpdateFeedRequest( + contentBody = contentBody, + isPublic = isPublic, + tagList = tagList, + remainImageUrls = remainImageUrls + ) + + feedService.updateFeed(feedId, request) + .handleBaseResponse() + .getOrThrow() + } + /** 임시 파일들을 정리하는 함수 */ private fun cleanupTempFiles(tempFiles: List) { tempFiles.forEach { 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 bed7573b..26aa7fd7 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 @@ -6,12 +6,17 @@ 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.feed.response.FeedMineInfoResponse +import com.texthip.thip.data.model.feed.response.RelatedBooksResponse import com.texthip.thip.data.model.feed.response.AllFeedResponse import com.texthip.thip.data.model.feed.response.MyFeedResponse +import com.texthip.thip.data.model.feed.request.UpdateFeedRequest import okhttp3.MultipartBody import okhttp3.RequestBody +import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Multipart +import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.Part import retrofit2.http.Path @@ -44,6 +49,18 @@ interface FeedService { @Query("cursor") cursor: String? = null ): BaseResponse + /** 내 피드 정보 조회 */ + @GET("feeds/mine/info") + suspend fun getMyFeedInfo(): BaseResponse + + /** 특정 책과 관련된 피드 목록 조회 */ + @GET("feeds/related-books/{isbn}") + suspend fun getRelatedBookFeeds( + @Path("isbn") isbn: String, + @Query("sort") sort: String? = null, + @Query("cursor") cursor: String? = null + ): BaseResponse + /** 피드 상세 조회 */ @GET("feeds/{feedId}") suspend fun getFeedDetail( @@ -59,4 +76,11 @@ interface FeedService { suspend fun getFeedUsers( @Path("userId") userId: Long ): BaseResponse + + /** 피드 수정 */ + @PATCH("feeds/{feedId}") + suspend fun updateFeed( + @Path("feedId") feedId: Int, + @Body request: UpdateFeedRequest + ): BaseResponse } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/common/topappbar/DefaultTopAppBar.kt b/app/src/main/java/com/texthip/thip/ui/common/topappbar/DefaultTopAppBar.kt index f5c60751..77f05f0c 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/topappbar/DefaultTopAppBar.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/topappbar/DefaultTopAppBar.kt @@ -1,11 +1,11 @@ package com.texthip.thip.ui.common.topappbar -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -33,14 +33,16 @@ fun DefaultTopAppBar( .fillMaxWidth() .padding(horizontal = 20.dp, vertical = 16.dp) ) { - Icon( - painter = painterResource(R.drawable.ic_arrow_back), - contentDescription = "Back Button", - tint = Color.Unspecified, - modifier = Modifier - .align(Alignment.CenterStart) - .clickable { onLeftClick() } - ) + IconButton( + onClick = onLeftClick, + modifier = Modifier.align(Alignment.CenterStart) + ) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = "Back Button", + tint = Color.Unspecified + ) + } if (isTitleVisible) { Text( @@ -52,14 +54,16 @@ fun DefaultTopAppBar( } if (isRightIconVisible) { - Icon( - painter = painterResource(R.drawable.ic_more), - contentDescription = "More Options", - tint = Color.Unspecified, - modifier = Modifier - .align(Alignment.CenterEnd) - .clickable { onRightClick() } - ) + IconButton( + onClick = onRightClick, + modifier = Modifier.align(Alignment.CenterEnd) + ) { + Icon( + painter = painterResource(R.drawable.ic_more), + contentDescription = "More Options", + tint = Color.Unspecified + ) + } } } } diff --git a/app/src/main/java/com/texthip/thip/ui/common/topappbar/GradationTopAppBar.kt b/app/src/main/java/com/texthip/thip/ui/common/topappbar/GradationTopAppBar.kt index 5d766e07..1fbec027 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/topappbar/GradationTopAppBar.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/topappbar/GradationTopAppBar.kt @@ -1,13 +1,13 @@ package com.texthip.thip.ui.common.topappbar 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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -43,14 +43,16 @@ fun GradationTopAppBar( .height(56.dp), contentAlignment = Alignment.Center, ) { - Icon( - painter = painterResource(R.drawable.ic_arrow_back), - contentDescription = "Back Button", - tint = Color.Unspecified, - modifier = Modifier - .align(Alignment.CenterStart) - .clickable { onLeftClick() } - ) + IconButton( + onClick = onLeftClick, + modifier = Modifier.align(Alignment.CenterStart) + ) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = "Back Button", + tint = Color.Unspecified + ) + } if (isImageVisible) { CountingBar( @@ -60,14 +62,16 @@ fun GradationTopAppBar( ) } - Icon( - painter = painterResource(R.drawable.ic_more), - contentDescription = "More Options", - tint = Color.Unspecified, - modifier = Modifier - .align(Alignment.CenterEnd) - .clickable { onRightClick() } - ) + IconButton( + onClick = onRightClick, + modifier = Modifier.align(Alignment.CenterEnd) + ) { + Icon( + painter = painterResource(R.drawable.ic_more), + contentDescription = "More Options", + tint = Color.Unspecified + ) + } } } 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 297e75f3..a000e02a 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 @@ -31,7 +31,8 @@ fun MyFeedCard( modifier: Modifier = Modifier, feedItem: FeedItem, onLikeClick: () -> Unit = {}, - onContentClick: () -> Unit = {} + onContentClick: () -> Unit = {}, + onBookClick: () -> Unit = {} ) { val hasImages = feedItem.imageUrls.isNotEmpty() val maxLines = if (hasImages) 3 else 8 @@ -44,7 +45,7 @@ fun MyFeedCard( ActionBookButton( bookTitle = feedItem.bookTitle, bookAuthor = feedItem.authName, - onClick = {} + onClick = onBookClick ) Column( 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 7fef8619..aaa0a2a0 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 @@ -37,9 +37,11 @@ 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.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 coil.compose.AsyncImage import com.texthip.thip.R @@ -49,6 +51,7 @@ import com.texthip.thip.ui.common.buttons.OptionChipButton import com.texthip.thip.ui.common.forms.CommentTextField import com.texthip.thip.ui.common.header.ProfileBar import com.texthip.thip.ui.common.modal.DialogPopup +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 @@ -59,12 +62,14 @@ 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 fun FeedCommentScreen( modifier: Modifier = Modifier, feedId: Int, onNavigateBack: () -> Unit = {}, + onNavigateToFeedEdit: (Int) -> Unit = {}, currentUserId: Int = 1, currentUserName: String = "현재사용자", currentUserGenre: String = "문학", @@ -76,6 +81,7 @@ fun FeedCommentScreen( viewModel: FeedDetailViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current LaunchedEffect(feedId) { viewModel.loadFeedDetail(feedId) @@ -123,6 +129,7 @@ fun FeedCommentScreen( val CommentList = commentList ?: remember { mutableStateListOf() } var isBottomSheetVisible 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) } @@ -136,7 +143,8 @@ fun FeedCommentScreen( var selectedComment by remember { mutableStateOf(null) } var selectedReply by remember { mutableStateOf(null) } - Box( + Box(modifier = Modifier.fillMaxSize()) { + Box( modifier = if (isBottomSheetVisible || showDialog) { Modifier .fillMaxSize() @@ -455,15 +463,31 @@ fun FeedCommentScreen( replyTo = replyTo.value, onCancelReply = { replyTo.value = null } ) + } + + // 신고 완료 토스트 + if (showToast) { + ToastWithDate( + message = "게시글 신고를 완료했어요.", + modifier = Modifier + .align(Alignment.TopCenter) + .padding(horizontal = 20.dp, vertical = 16.dp) + .zIndex(2f) + ) + } } if (isBottomSheetVisible) { - MenuBottomSheet( - items = listOf( + val menuItems = if (feedDetail.isWriter) { + // 내 피드인 경우: 수정, 삭제 + listOf( MenuBottomSheetItem( text = stringResource(R.string.edit_feed), color = colors.White, - onClick = {} + onClick = { + isBottomSheetVisible = false + onNavigateToFeedEdit(feedDetail.feedId) + } ), MenuBottomSheetItem( text = stringResource(R.string.delete_feed), @@ -473,7 +497,24 @@ fun FeedCommentScreen( showDialog = true } ) - ), + ) + } else { + // 다른 사람 피드인 경우: 신고만 + listOf( + MenuBottomSheetItem( + text = stringResource(R.string.report), + color = colors.Red, + onClick = { + isBottomSheetVisible = false + // TODO: 피드 신고 API 호출 + showToast = true + } + ) + ) + } + + MenuBottomSheet( + items = menuItems, onDismiss = { isBottomSheetVisible = false } ) } @@ -490,6 +531,7 @@ fun FeedCommentScreen( onConfirm = { showDialog = false isBottomSheetVisible = false + // TODO: 피드 삭제 API 호출 }, onCancel = { showDialog = false @@ -500,6 +542,13 @@ fun FeedCommentScreen( } } + LaunchedEffect(showToast) { + if (showToast) { + delay(3000) + showToast = false + } + } + if (showImageViewer && images.isNotEmpty()) { ImageViewerModal( imageUrls = images.take(3), @@ -520,6 +569,7 @@ private fun FeedCommentScreenWithMockComments() { } FeedCommentScreen( feedId = 1, + onNavigateToFeedEdit = {}, currentUserId = 999, currentUserName = "나", currentUserGenre = "문학", @@ -537,6 +587,7 @@ private fun FeedCommentScreenPrev() { FeedCommentScreen( feedId = 1, + onNavigateToFeedEdit = {}, currentUserId = 999, currentUserName = "나", currentUserGenre = "문학", 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 614b6a03..13e81322 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 @@ -67,9 +67,7 @@ fun FeedScreen( onNavigateToMySubscription: () -> Unit = {}, onNavigateToFeedWrite: () -> Unit = {}, onNavigateToFeedComment: (Int) -> Unit = {}, - nickname: String = "", - userRole: String = "", - followerProfileImageUrls: List = emptyList(), + onNavigateToBookDetail: (String) -> Unit = {}, resultFeedId: Int? = null, onResultConsumed: () -> Unit = {}, feedViewModel: FeedViewModel = hiltViewModel(), @@ -129,8 +127,6 @@ fun FeedScreen( } } - val subscriptionUiState by mySubscriptionViewModel.uiState.collectAsState() - // 초기 로딩 상태 처리 if (feedUiState.isLoading && feedUiState.currentTabFeeds.isEmpty()) { Box( @@ -215,10 +211,13 @@ fun FeedScreen( // 내 피드 item { Spacer(modifier = Modifier.height(32.dp)) + + val myFeedInfo = feedUiState.myFeedInfo AuthorHeader( - profileImage = null, - nickname = nickname, - badgeText = userRole, + profileImage = myFeedInfo?.profileImageUrl, + nickname = myFeedInfo?.nickname ?: "", + badgeText = myFeedInfo?.aliasName ?: "", + badgeTextColor = myFeedInfo?.aliasColor?.let { hexToColor(it) } ?: colors.NeonGreen, buttonText = "", buttonWidth = 60.dp, showButton = false @@ -226,13 +225,13 @@ fun FeedScreen( Spacer(modifier = Modifier.height(16.dp)) FeedSubscribeBarlist( modifier = Modifier.padding(horizontal = 20.dp), - followerProfileImageUrls = followerProfileImageUrls, + followerProfileImageUrls = myFeedInfo?.latestFollowerProfileImageUrls ?: emptyList(), onClick = { } ) Spacer(modifier = Modifier.height(40.dp)) Text( - text = stringResource(R.string.whole_num, feedUiState.myFeeds.size), + text = stringResource(R.string.whole_num, myFeedInfo?.totalFeedCount ?: 0), style = typography.menu_m500_s14_h24, color = colors.Grey, modifier = Modifier @@ -289,6 +288,9 @@ fun FeedScreen( onLikeClick = {}, onContentClick = { onNavigateToFeedComment(feedItem.id) + }, + onBookClick = { + onNavigateToBookDetail(myFeed.isbn) } ) Spacer(modifier = Modifier.height(40.dp)) @@ -346,6 +348,9 @@ fun FeedScreen( }, onCommentClick = { onNavigateToFeedComment(feedItem.id) + }, + onBookClick = { + onNavigateToBookDetail(allFeed.isbn) } ) Spacer(modifier = Modifier.height(40.dp)) @@ -387,39 +392,10 @@ fun FeedScreen( @Composable private fun FeedScreenPreview() { ThipTheme { - val mockFeeds = List(5) { - FeedItem( - id = it + 1, - userProfileImage = "https://example.com/profile$it.jpg", - userName = "user.$it", - userRole = "문학 칭호", - bookTitle = "책 제목 ", - authName = "한강", - timeAgo = "1시간 전", - content = "내용내용내용 입니다. 내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.내용내용내용 입니다.", - likeCount = it, - commentCount = it, - isLiked = false, - isSaved = false, - isLocked = it % 2 == 0, - imageUrls = listOf("https://example.com/image$it.jpg") - ) - } - 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" + FeedScreen( + onNavigateToFeedWrite = { }, + onNavigateToBookDetail = { } ) - ThipTheme { - FeedScreen( - nickname = "ThipUser01", - userRole = "문학 칭호", - followerProfileImageUrls = mockFollowerImages, - onNavigateToFeedWrite = { } - ) - } } } @@ -427,16 +403,9 @@ private fun FeedScreenPreview() { @Composable private fun FeedScreenWithoutDataPreview() { ThipTheme { - val mockFeeds: List = emptyList() - val mockFollowerImages = emptyList() - - ThipTheme { - FeedScreen( - nickname = "ThipUser01", - userRole = "문학 칭호", - followerProfileImageUrls = mockFollowerImages, - onNavigateToFeedWrite = { } - ) - } + FeedScreen( + onNavigateToFeedWrite = { }, + onNavigateToBookDetail = { } + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedWriteScreen.kt b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedWriteScreen.kt index 9620029c..2f485e1b 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedWriteScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedWriteScreen.kt @@ -70,8 +70,8 @@ fun FeedWriteScreen( FeedWriteContent( uiState = uiState, onNavigateBack = onNavigateBack, - onCreateFeed = { - viewModel.createFeed( + onCreateFeed = { + viewModel.createOrUpdateFeed( onSuccess = { feedId -> onFeedCreated(feedId) }, @@ -84,6 +84,7 @@ fun FeedWriteScreen( onUpdateFeedContent = viewModel::updateFeedContent, onAddImages = viewModel::addImages, onRemoveImage = viewModel::removeImage, + onRemoveExistingImage = viewModel::removeExistingImage, onTogglePrivate = viewModel::togglePrivate, onSelectCategory = viewModel::selectCategory, onToggleTag = viewModel::toggleTag, @@ -104,6 +105,7 @@ fun FeedWriteContent( onUpdateFeedContent: (String) -> Unit = {}, onAddImages: (List) -> Unit = {}, onRemoveImage: (Int) -> Unit = {}, + onRemoveExistingImage: (Int) -> Unit = {}, onTogglePrivate: (Boolean) -> Unit = {}, onSelectCategory: (Int) -> Unit = {}, onToggleTag: (String) -> Unit = {}, @@ -128,8 +130,13 @@ fun FeedWriteContent( horizontalAlignment = Alignment.CenterHorizontally ) { InputTopAppBar( - title = stringResource(R.string.new_feed), - rightButtonName = stringResource(R.string.registration), + title = stringResource( + if (uiState.isEditMode) R.string.edit_feed_title + else R.string.new_feed + ), + rightButtonName = stringResource( + R.string.registration + ), isRightButtonEnabled = uiState.isFormValid && !uiState.isLoading, onLeftClick = onNavigateBack, onRightClick = onCreateFeed @@ -178,12 +185,15 @@ fun FeedWriteContent( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { + // 이미지 추가 버튼 (수정 모드에서는 비활성화) item { Box( modifier = Modifier .size(80.dp) .background(color = colors.DarkGrey02) - .border(width = 1.dp, color = if (!uiState.canAddMoreImages) colors.DarkGrey else colors.Grey02, + .border( + width = 1.dp, + color = if (!uiState.canAddMoreImages) colors.DarkGrey else colors.Grey02, ) .let { if (uiState.canAddMoreImages) it.clickable { @@ -200,6 +210,30 @@ fun FeedWriteContent( ) } } + // 기존 이미지들 (수정 모드에서만 표시) + items(uiState.existingImageUrls.size) { index -> + Box(modifier = Modifier.size(80.dp)) { + AsyncImage( + model = uiState.existingImageUrls[index], + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + IconButton( + onClick = { onRemoveExistingImage(index) }, + modifier = Modifier + .align(Alignment.TopEnd) + .size(24.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_x), + contentDescription = null, + tint = colors.White + ) + } + } + } + // 새로 추가한 이미지들 items(uiState.imageUris.size) { index -> Box(modifier = Modifier.size(80.dp)) { AsyncImage( @@ -224,11 +258,17 @@ fun FeedWriteContent( } } Row( - modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), horizontalArrangement = Arrangement.End ) { Text( - text = stringResource(id = R.string.photo_count, uiState.imageUris.size, 3), + text = stringResource( + id = R.string.photo_count, + uiState.currentImageCount, + 3 + ), style = typography.info_r400_s12, color = colors.NeonGreen, ) @@ -284,7 +324,11 @@ fun FeedWriteContent( ) { Text( modifier = Modifier.padding(top = 12.dp), - text = stringResource(id = R.string.tag_count, uiState.selectedTags.size, 5), + text = stringResource( + id = R.string.tag_count, + uiState.selectedTags.size, + 5 + ), style = typography.info_r400_s12, color = colors.NeonGreen ) 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 871c3809..40298576 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 @@ -3,6 +3,7 @@ package com.texthip.thip.ui.feed.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.texthip.thip.data.model.feed.response.AllFeedItem +import com.texthip.thip.data.model.feed.response.FeedMineInfoResponse import com.texthip.thip.data.model.feed.response.MyFeedItem import com.texthip.thip.data.model.users.response.RecentWriterList import com.texthip.thip.data.repository.FeedRepository @@ -18,6 +19,7 @@ data class FeedUiState( val allFeeds: List = emptyList(), val myFeeds: List = emptyList(), val recentWriters: List = emptyList(), + val myFeedInfo: FeedMineInfoResponse? = null, val isLoading: Boolean = false, val isRefreshing: Boolean = false, val isLoadingMore: Boolean = false, @@ -73,6 +75,7 @@ class FeedViewModel @Inject constructor( 1 -> { loadMyFeeds(isInitial = true) + fetchMyFeedInfo() } } } @@ -266,7 +269,7 @@ class FeedViewModel @Inject constructor( updateState { it.copy(isLoading = true) } userRepository.getMyFollowingsRecentFeeds() .onSuccess { data -> - val writers = data?.recentWriters ?: emptyList() + val writers = data?.myFollowingUsers ?: emptyList() updateState { it.copy( isLoading = false, @@ -275,11 +278,27 @@ class FeedViewModel @Inject constructor( } } .onFailure { exception -> - updateState { + updateState { it.copy( isLoading = false, error = exception.message - ) + ) + } + } + } + } + + private fun fetchMyFeedInfo() { + viewModelScope.launch { + feedRepository.getMyFeedInfo() + .onSuccess { data -> + updateState { + it.copy(myFeedInfo = data) + } + } + .onFailure { exception -> + updateState { + it.copy(error = exception.message) } } } diff --git a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteUiState.kt b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteUiState.kt index a598ba83..65780274 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteUiState.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteUiState.kt @@ -9,6 +9,7 @@ data class FeedWriteUiState( val showBookSearchSheet: Boolean = false, val feedContent: String = "", val imageUris: List = emptyList(), + val existingImageUrls: List = emptyList(), // 기존 피드 이미지 URL들 val isPrivate: Boolean = false, val selectedCategoryIndex: Int = -1, val selectedTags: List = emptyList(), @@ -21,14 +22,20 @@ data class FeedWriteUiState( val isSearching: Boolean = false, val categories: List = emptyList(), val isBookPreselected: Boolean = false, - val isLoadingCategories: Boolean = false + val isLoadingCategories: Boolean = false, + val isEditMode: Boolean = false, + val editingFeedId: Int? = null ) { // 유효성 검사 로직 val isContentValid: Boolean get() = feedContent.isNotBlank() && feedContent.length <= 2000 + // 현재 모드에 따른 이미지 개수 + val currentImageCount: Int + get() = if (isEditMode) existingImageUrls.size else imageUris.size + val isImageCountValid: Boolean - get() = imageUris.size <= 3 + get() = currentImageCount <= 3 val isFormValid: Boolean get() = selectedBook != null && @@ -40,9 +47,9 @@ data class FeedWriteUiState( val canAddMoreTags: Boolean get() = selectedTags.size < 5 - // 이미지 개수 제한 (최대 3개) + // 이미지 추가 가능 여부 val canAddMoreImages: Boolean - get() = imageUris.size < 3 + get() = !isEditMode && imageUris.size < 3 // 현재 선택된 카테고리의 태그 목록 val availableTags: List 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 94a05ae0..fa262004 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 @@ -61,6 +61,99 @@ class FeedWriteViewModel @Inject constructor( } } + fun loadFeedForEdit(feedId: Int) { + viewModelScope.launch { + updateState { it.copy(isLoading = true) } + + feedRepository.getFeedDetail(feedId) + .onSuccess { feedDetail -> + if (feedDetail != null) { + // 선택된 카테고리 인덱스 찾기 + val categoryIndex = _uiState.value.categories.indexOfFirst { category -> + feedDetail.tagList.any { tag -> + category.tagList.contains(tag) + } + }.let { if (it == -1) 0 else it } + + val selectedBook = BookData( + title = feedDetail.bookTitle, + imageUrl = feedDetail.bookImageUrl ?: "", // 새로 추가된 필드 사용 + author = feedDetail.bookAuthor, + isbn = feedDetail.isbn + ) + + updateState { currentState -> + currentState.copy( + selectedBook = selectedBook, + isBookPreselected = true, + feedContent = feedDetail.contentBody, + isPrivate = !(feedDetail.isPublic ?: true), // 새로 추가된 필드 사용, 기본값은 공개 + selectedCategoryIndex = categoryIndex, + selectedTags = feedDetail.tagList, + existingImageUrls = feedDetail.contentUrls, // 기존 이미지 URL 저장 + isLoading = false, + isEditMode = true, + editingFeedId = feedId + ) + } + } else { + updateState { + it.copy( + isLoading = false, + errorMessage = "피드 정보를 불러올 수 없습니다." + ) + } + } + } + .onFailure { exception -> + updateState { + it.copy( + isLoading = false, + errorMessage = exception.message ?: "네트워크 오류가 발생했습니다." + ) + } + } + } + } + + fun setEditData( + feedId: Int, + isbn: String, + bookTitle: String, + bookAuthor: String, + bookImageUrl: String, + contentBody: String, + isPublic: Boolean, + tagList: List + ) { + // 선택된 카테고리 인덱스 찾기 + val categoryIndex = _uiState.value.categories.indexOfFirst { category -> + tagList.any { tag -> + category.tagList.contains(tag) + } + }.let { if (it == -1) 0 else it } + + val selectedBook = BookData( + title = bookTitle, + imageUrl = bookImageUrl, + author = bookAuthor, + isbn = isbn + ) + + updateState { currentState -> + currentState.copy( + selectedBook = selectedBook, + isBookPreselected = true, + feedContent = contentBody, + isPrivate = !isPublic, + selectedCategoryIndex = categoryIndex, + selectedTags = tagList, + isEditMode = true, + editingFeedId = feedId + ) + } + } + private fun loadFeedWriteInfo() { viewModelScope.launch { updateState { it.copy(isLoadingCategories = true) } @@ -197,6 +290,10 @@ class FeedWriteViewModel @Inject constructor( fun addImages(newImageUris: List) { val currentState = _uiState.value + + // 수정 모드에서는 새 이미지 추가 불가 + if (currentState.isEditMode) return + val availableSlots = 3 - currentState.imageUris.size val imagesToAdd = newImageUris.take(availableSlots) @@ -213,6 +310,14 @@ class FeedWriteViewModel @Inject constructor( } } + fun removeExistingImage(index: Int) { + val currentExistingImages = _uiState.value.existingImageUrls.toMutableList() + if (index in currentExistingImages.indices) { + currentExistingImages.removeAt(index) + updateState { it.copy(existingImageUrls = currentExistingImages) } + } + } + fun togglePrivate(isPrivate: Boolean) { updateState { it.copy(isPrivate = isPrivate) } } @@ -247,6 +352,16 @@ class FeedWriteViewModel @Inject constructor( } } + fun createOrUpdateFeed(onSuccess: (Int) -> Unit, onError: (String) -> Unit) { + val currentState = _uiState.value + + if (currentState.isEditMode && currentState.editingFeedId != null) { + updateFeed(currentState.editingFeedId, onSuccess, onError) + } else { + createFeed(onSuccess, onError) + } + } + fun createFeed(onSuccess: (Int) -> Unit, onError: (String) -> Unit) { val currentState = _uiState.value @@ -300,6 +415,49 @@ class FeedWriteViewModel @Inject constructor( } } + private fun updateFeed(feedId: Int, onSuccess: (Int) -> Unit, onError: (String) -> Unit) { + val currentState = _uiState.value + + if (!currentState.isFormValid) { + onError(stringResourceProvider.getString(R.string.error_form_validation)) + return + } + + viewModelScope.launch { + try { + updateState { it.copy(isLoading = true, errorMessage = null) } + + val result = feedRepository.updateFeed( + feedId = feedId, + contentBody = currentState.feedContent.trim(), + isPublic = !currentState.isPrivate, + tagList = currentState.selectedTags, + remainImageUrls = currentState.existingImageUrls + ) + + result.onSuccess { response -> + val updatedFeedId = response?.feedId ?: feedId + onSuccess(updatedFeedId) + }.onFailure { exception -> + onError( + exception.message + ?: stringResourceProvider.getString(R.string.error_network_error) + ) + } + + } catch (e: Exception) { + onError( + stringResourceProvider.getString( + R.string.error_network_error, + e.message ?: "" + ) + ) + } finally { + updateState { it.copy(isLoading = false) } + } + } + } + fun clearError() { updateState { it.copy(errorMessage = null) } } 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 b9032072..d35d3103 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 @@ -36,7 +36,8 @@ fun SavedFeedCard( onBookmarkClick: () -> Unit = {}, onLikeClick: () -> Unit = {}, onContentClick: () -> Unit = {}, - onCommentClick: () -> Unit = {} + onCommentClick: () -> Unit = {}, + onBookClick: () -> Unit = {} ) { val hasImages = feedItem.imageUrls.isNotEmpty() val maxLines = if (hasImages) 3 else 8 @@ -62,7 +63,7 @@ fun SavedFeedCard( ActionBookButton( bookTitle = feedItem.bookTitle, bookAuthor = feedItem.authName, - onClick = {} + onClick = onBookClick ) } 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 16523728..d9013c37 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 @@ -15,6 +15,7 @@ import com.texthip.thip.ui.feed.screen.FeedOthersScreen 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.extensions.navigateToBookDetail import com.texthip.thip.ui.navigator.routes.FeedRoutes import com.texthip.thip.ui.navigator.routes.MainTabRoutes @@ -24,9 +25,6 @@ fun NavGraphBuilder.feedNavigation(navController: NavHostController, navigateBac val resultFeedId = backStackEntry.savedStateHandle.get("feedId") FeedScreen( - nickname = "ThipUser01", - userRole = "문학가", - followerProfileImageUrls = emptyList(), resultFeedId = resultFeedId, onResultConsumed = { backStackEntry.savedStateHandle.remove("feedId") @@ -39,6 +37,9 @@ fun NavGraphBuilder.feedNavigation(navController: NavHostController, navigateBac }, onNavigateToFeedComment = { feedId -> navController.navigateToFeedComment(feedId) + }, + onNavigateToBookDetail = { isbn -> + navController.navigateToBookDetail(isbn) } ) } @@ -57,11 +58,33 @@ fun NavGraphBuilder.feedNavigation(navController: NavHostController, navigateBac val viewModel: FeedWriteViewModel = hiltViewModel() LaunchedEffect(route) { - if (route.isbn != null && + if ( + route.feedId != null && + route.editContentBody != null && + route.isbn != null && + route.bookTitle != null && + route.bookAuthor != null + ) { + viewModel.setEditData( + feedId = route.feedId, + isbn = route.isbn, + bookTitle = route.bookTitle, + bookAuthor = route.bookAuthor, + bookImageUrl = route.bookImageUrl ?: "", + contentBody = route.editContentBody, + isPublic = route.editIsPublic ?: true, + tagList = route.editTagList ?: emptyList() + ) + } else if (route.feedId != null) { + // 수정 모드: 기존 방식 (API 호출) + viewModel.loadFeedForEdit(route.feedId) + } else if (route.isbn != null && route.bookTitle != null && route.bookAuthor != null && route.bookImageUrl != null && - route.recordContent != null) { + route.recordContent != null + ) { + // 새 글 작성 모드: 기록장에서 온 데이터 설정 viewModel.setPinnedRecord( isbn = route.isbn, bookTitle = route.bookTitle, @@ -100,6 +123,9 @@ fun NavGraphBuilder.feedNavigation(navController: NavHostController, navigateBac feedId = route.feedId, onNavigateBack = { navController.popBackStack() + }, + onNavigateToFeedEdit = { feedId -> + navController.navigate(FeedRoutes.Write(feedId = feedId)) } ) } diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/SearchNavigation.kt b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/SearchNavigation.kt index 71a0f77b..d1e08ad5 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/SearchNavigation.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/SearchNavigation.kt @@ -6,6 +6,7 @@ import androidx.navigation.compose.composable import androidx.navigation.toRoute import com.texthip.thip.ui.navigator.extensions.navigateToBookDetail import com.texthip.thip.ui.navigator.extensions.navigateToBookGroup +import com.texthip.thip.ui.navigator.extensions.navigateToFeedComment import com.texthip.thip.ui.navigator.extensions.navigateToGroupMakeRoomWithBook import com.texthip.thip.ui.navigator.extensions.navigateToGroupRecruit import com.texthip.thip.ui.navigator.extensions.navigateToRegisterBook @@ -44,6 +45,9 @@ fun NavGraphBuilder.searchNavigation(navController: NavHostController) { }, onWriteFeedClick = { // TODO: 피드 작성 화면으로 이동 + }, + onFeedClick = { feedId -> + navController.navigateToFeedComment(feedId) } ) } 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 55e0d1d5..e70faa67 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 @@ -11,11 +11,17 @@ sealed class FeedRoutes : Routes() { @Serializable data class Write( + val feedId: Int? = null, // 수정 모드용 피드 ID val isbn: String? = null, val bookTitle: String? = null, val bookAuthor: String? = null, val bookImageUrl: String? = null, - val recordContent: String? = null + val recordContent: String? = null, + // 수정 모드용 추가 필드들 + val editContentBody: String? = null, + val editIsPublic: Boolean? = null, + val editTagList: List? = null, + val editContentUrls: List? = null ) : FeedRoutes() @Serializable data class Others(val userId: Long) : FeedRoutes() diff --git a/app/src/main/java/com/texthip/thip/ui/search/component/SearchFilterButton.kt b/app/src/main/java/com/texthip/thip/ui/search/component/SearchFilterButton.kt new file mode 100644 index 00000000..f23752da --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/search/component/SearchFilterButton.kt @@ -0,0 +1,142 @@ +package com.texthip.thip.ui.search.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.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.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.texthip.thip.R +import com.texthip.thip.ui.theme.ThipTheme.colors +import com.texthip.thip.ui.theme.ThipTheme.typography + +// 단순 FilterButton 컴포넌트 (드롭다운 제거) +@Composable +fun SearchFilterButton( + modifier: Modifier = Modifier, + selectedOption: String, + isExpanded: Boolean = false, + onClick: () -> Unit +) { + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.TopEnd + ) { + Row( + modifier = Modifier + .height(36.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClick() }, + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = selectedOption, + color = colors.Grey01, + style = typography.menu_m500_s14_h24 + ) + Icon( + painter = painterResource( + id = if (isExpanded) R.drawable.ic_upmore else R.drawable.ic_downmore + ), + contentDescription = "Dropdown", + tint = colors.Grey02, + modifier = Modifier.size(24.dp) + ) + } + } +} + +// Screen 레벨 드롭다운 오버레이 +@Composable +fun SearchFilterDropdownOverlay( + modifier: Modifier = Modifier, + isVisible: Boolean, + options: List, + selectedOption: String, + filterButtonPosition: IntOffset, + density: Density, + onOptionSelected: (String) -> Unit, + onDismiss: () -> Unit +) { + if (isVisible) { + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Transparent) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onDismiss() } + ) + } + + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(), + exit = fadeOut() + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopEnd + ) { + Column( + modifier = Modifier + .padding( + top = with(density) { (filterButtonPosition.y - 36).toDp() }, + end = 20.dp // 기존 FilterButton과 동일하게 화면 오른쪽에서 20dp + ) + .width(144.dp) + .border( + width = 1.dp, + color = colors.Grey01, + shape = RoundedCornerShape(16.dp) + ) + .background(color = colors.Black, shape = RoundedCornerShape(16.dp)) + .padding(vertical = 20.dp, horizontal = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + options.forEach { option -> + val isSelected = option == selectedOption + Text( + text = option, + color = if (isSelected) colors.White else colors.Grey02, + style = typography.feedcopy_r400_s14_h20, + modifier = Modifier + .fillMaxWidth() + .clickable { + onOptionSelected(option) + onDismiss() + } + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/search/screen/SearchBookDetailScreen.kt b/app/src/main/java/com/texthip/thip/ui/search/screen/SearchBookDetailScreen.kt index 3d5491cc..2ea2b17e 100644 --- a/app/src/main/java/com/texthip/thip/ui/search/screen/SearchBookDetailScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/search/screen/SearchBookDetailScreen.kt @@ -1,7 +1,6 @@ package com.texthip.thip.ui.search.screen import androidx.compose.animation.AnimatedVisibility -import coil.compose.AsyncImage import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -13,8 +12,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn 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.Icon @@ -22,45 +25,56 @@ 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.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.hilt.navigation.compose.hiltViewModel import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset 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.data.model.book.response.BookDetailResponse -import com.texthip.thip.ui.search.viewmodel.BookDetailViewModel import com.texthip.thip.ui.common.buttons.ActionMediumButton -import com.texthip.thip.ui.common.buttons.FilterButton import com.texthip.thip.ui.common.modal.InfoPopup import com.texthip.thip.ui.common.topappbar.DefaultTopAppBar import com.texthip.thip.ui.common.topappbar.GradationTopAppBar +import com.texthip.thip.ui.mypage.component.SavedFeedCard +import com.texthip.thip.ui.search.component.SearchFilterButton +import com.texthip.thip.ui.search.component.SearchFilterDropdownOverlay +import com.texthip.thip.ui.search.viewmodel.BookDetailUiState +import com.texthip.thip.ui.search.viewmodel.BookDetailViewModel 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 + @Composable fun SearchBookDetailScreen( modifier: Modifier = Modifier, isbn: String, - feedList: List = emptyList(), onLeftClick: () -> Unit = {}, onRightClick: () -> Unit = {}, onRecruitingGroupClick: () -> Unit = {}, onWriteFeedClick: () -> Unit = {}, + onFeedClick: (Int) -> Unit = {}, + onBookmarkClick: (String, Boolean) -> Unit = { _, _ -> }, viewModel: BookDetailViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() @@ -70,53 +84,29 @@ fun SearchBookDetailScreen( viewModel.loadBookDetail(isbn) } - when { - uiState.isLoading -> { - SearchBookDetailScreenContent( - modifier = modifier, - isLoading = true, - error = null, - bookDetail = null, - feedList = feedList, - onLeftClick = onLeftClick, - onRightClick = onRightClick, - onRecruitingGroupClick = onRecruitingGroupClick, - onWriteFeedClick = onWriteFeedClick, - onBookmarkClick = { _, _ -> } - ) - } - uiState.error != null -> { - SearchBookDetailScreenContent( - modifier = modifier, - isLoading = false, - error = uiState.error!!, - bookDetail = null, - feedList = feedList, - onLeftClick = onLeftClick, - onRightClick = onRightClick, - onRecruitingGroupClick = onRecruitingGroupClick, - onWriteFeedClick = onWriteFeedClick, - onBookmarkClick = { _, _ -> } - ) + SearchBookDetailScreenContent( + modifier = modifier, + isLoading = uiState.isLoading, + error = uiState.error, + bookDetail = uiState.bookDetail, + uiState = uiState, + onLeftClick = onLeftClick, + onRightClick = onRightClick, + onRecruitingGroupClick = onRecruitingGroupClick, + onWriteFeedClick = onWriteFeedClick, + onFeedClick = onFeedClick, + onBookmarkClick = { isbnParam, newState -> + viewModel.saveBook(isbnParam, newState) + onBookmarkClick(isbnParam, newState) + }, + onSortChange = { sortType -> + val apiSortType = if (sortType == "인기순") "like" else "latest" + viewModel.changeSortOrder(isbn, apiSortType) + }, + onLoadMore = { + viewModel.loadMoreFeeds(isbn) } - uiState.bookDetail != null -> { - SearchBookDetailScreenContent( - modifier = modifier, - isLoading = false, - error = null, - bookDetail = uiState.bookDetail!!, - feedList = feedList, - onLeftClick = onLeftClick, - onRightClick = onRightClick, - onRecruitingGroupClick = onRecruitingGroupClick, - onWriteFeedClick = onWriteFeedClick, - onBookmarkClick = { isbn, newState -> - viewModel.saveBook(isbn, newState) - } - ) - } - else -> {} - } + ) } @Composable @@ -125,26 +115,52 @@ private fun SearchBookDetailScreenContent( isLoading: Boolean = false, error: String? = null, bookDetail: BookDetailResponse? = null, - feedList: List = emptyList(), + uiState: BookDetailUiState? = null, onLeftClick: () -> Unit = {}, onRightClick: () -> Unit = {}, onRecruitingGroupClick: () -> Unit = {}, onWriteFeedClick: () -> Unit = {}, - onBookmarkClick: (String, Boolean) -> Unit = { _, _ -> } + onFeedClick: (Int) -> Unit = {}, + onBookmarkClick: (String, Boolean) -> Unit = { _, _ -> }, + onSortChange: (String) -> Unit = {}, + onLoadMore: () -> Unit = {} ) { var isAlarmVisible by remember { mutableStateOf(true) } var isIntroductionPopupVisible by remember { mutableStateOf(false) } var isBookmarked by remember { mutableStateOf(bookDetail?.isSaved ?: false) } - var selectedFilterOption by remember { mutableIntStateOf(0) } - + var isFilterDropdownVisible by remember { mutableStateOf(false) } + var filterButtonPosition by remember { mutableStateOf(IntOffset.Zero) } + val density = LocalDensity.current val filterOptions = listOf( stringResource(R.string.sort_like), stringResource(R.string.sort_latest) ) - // 알림 5초간 노출 (미리보기에서는 항상 보이도록) - LaunchedEffect(Unit) { - if (!isLoading && error == null && bookDetail != null) { + // UI 상태를 ViewModel의 currentSort와 동기화 + val currentSortFromViewModel = uiState?.currentSort ?: "like" + val selectedFilterOption = if (currentSortFromViewModel == "like") 0 else 1 + + val lazyListState = rememberLazyListState() + val shouldLoadMore by remember { + derivedStateOf { + val layoutInfo = lazyListState.layoutInfo + val totalItemsNumber = layoutInfo.totalItemsCount + val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 + + lastVisibleItemIndex > (totalItemsNumber - 3) + } + } + + // 무한 스크롤 로직 + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore && uiState?.isLoadingMore == false && !uiState.isLast) { + onLoadMore() + } + } + + // 알림 5초간 노출 + LaunchedEffect(bookDetail) { + if (bookDetail != null) { isAlarmVisible = true delay(5000) isAlarmVisible = false @@ -153,7 +169,7 @@ private fun SearchBookDetailScreenContent( // 북마크 상태 동기화 LaunchedEffect(bookDetail?.isSaved) { - bookDetail?.let { + bookDetail?.let { isBookmarked = it.isSaved } } @@ -169,6 +185,7 @@ private fun SearchBookDetailScreenContent( ) } } + error != null -> { Box( modifier = modifier.fillMaxSize(), @@ -181,12 +198,13 @@ private fun SearchBookDetailScreenContent( ) } } + bookDetail != null -> { Box(modifier = modifier.fillMaxSize()) { - // 메인 컨텐츠 Box( modifier = Modifier .fillMaxSize() + .background(colors.Black) .then( if (isIntroductionPopupVisible) { Modifier.blur(4.dp) @@ -195,190 +213,202 @@ private fun SearchBookDetailScreenContent( } ) ) { - // 실제 책 이미지 사용 - Box(modifier = Modifier - .height(420.dp) - .fillMaxWidth()) { - AsyncImage( - model = bookDetail.imageUrl, - contentDescription = bookDetail.title, - modifier = Modifier - .matchParentSize() - .blur(4.dp), - contentScale = ContentScale.Crop, - fallback = painterResource(R.drawable.img_book_cover_sample), - error = painterResource(R.drawable.img_book_cover_sample) - ) - Box( - modifier = Modifier - .matchParentSize() - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color.Transparent, - colors.Black.copy(alpha = 0.3f), - colors.Black.copy(alpha = 0.6f), - colors.Black.copy(alpha = 0.9f), - colors.Black - ), - startY = 0f, - endY = Float.POSITIVE_INFINITY - ) - ) - ) - } - - Column(modifier = Modifier.fillMaxSize()) { - AnimatedVisibility(visible = isAlarmVisible) { - GradationTopAppBar( - isImageVisible = true, - count = bookDetail.readCount, - onLeftClick = onLeftClick, - onRightClick = {} - ) - } - AnimatedVisibility(visible = !isAlarmVisible) { - DefaultTopAppBar( - isRightIconVisible = true, - isTitleVisible = false, - onLeftClick = onLeftClick, - onRightClick = onRightClick - ) - } - - // 상세 정보 영역 - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - ) { - Text( - modifier = Modifier.padding(top = 40.dp), - text = bookDetail.title, - color = colors.White, - style = typography.bigtitle_b700_s22 - ) - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = stringResource( - R.string.search_book_author, - bookDetail.authorName, - bookDetail.publisher - ), - color = colors.Grey, - style = typography.menu_sb600_s12_h20 - ) - - Column( - modifier = Modifier - .padding(top = 33.dp) - .fillMaxWidth() - .clickable { isIntroductionPopupVisible = true } + // 전체 스크롤 가능한 컨텐츠 + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize() + ) { + // 상단 책 이미지 배경 영역 + item { + Box( + modifier = Modifier.fillMaxWidth() ) { - Text( - text = stringResource(R.string.search_book_comment), - color = colors.White, - style = typography.menu_sb600_s14_h24, + AsyncImage( + model = bookDetail.imageUrl, + contentDescription = bookDetail.title, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 420.dp) + .blur(4.dp), + contentScale = ContentScale.Crop, + fallback = painterResource(R.drawable.img_book_cover_sample), + error = painterResource(R.drawable.img_book_cover_sample) ) - Spacer(modifier = Modifier.height(5.dp)) - - Text( - text = bookDetail.description, - color = colors.Grey, - style = typography.copy_r400_s12_h20, - maxLines = 2, - overflow = TextOverflow.Ellipsis + Box( + modifier = Modifier + .matchParentSize() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + colors.Black.copy(alpha = 0.3f), + colors.Black.copy(alpha = 0.6f), + colors.Black.copy(alpha = 0.9f), + colors.Black + ), + startY = 0f, + endY = Float.POSITIVE_INFINITY + ) + ) ) - } - Spacer(modifier = Modifier.height(40.dp)) - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - ActionMediumButton( - text = stringResource( - R.string.search_recruiting_group_count, - bookDetail.recruitingRoomCount - ), - contentColor = colors.Grey, - backgroundColor = Color.Transparent, - hasRightIcon = true, + // 책 정보 컨텐츠를 이미지 위에 배치 + Column( modifier = Modifier .fillMaxWidth() - .border( - width = 1.dp, - color = colors.Grey, - shape = RoundedCornerShape(12.dp) + .padding(horizontal = 20.dp) + ) { + Spacer(modifier = Modifier.height(96.dp)) // TopAppBar 공간 + + Text( + modifier = Modifier.padding(top = 40.dp), + text = bookDetail.title, + color = colors.White, + style = typography.bigtitle_b700_s22 + ) + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource( + R.string.search_book_author, + bookDetail.authorName, + bookDetail.publisher ), - onClick = onRecruitingGroupClick, - ) - Row { - ActionMediumButton( - text = stringResource(R.string.search_write_feed_comment), - contentColor = colors.White, - backgroundColor = colors.Purple, - hasRightIcon = true, - hasRightPlusIcon = true, - modifier = Modifier.weight(1f), - onClick = onWriteFeedClick + color = colors.Grey, + style = typography.menu_sb600_s12_h20 ) - Box( + + Column( modifier = Modifier - .padding(start = 12.dp) - .size(44.dp) - .border( - width = 1.dp, - color = colors.Grey02, - shape = RoundedCornerShape(12.dp) - ) - .background( - color = Color.Transparent, - shape = RoundedCornerShape(12.dp) - ) - .clickable { - val newBookmarkState = !isBookmarked - isBookmarked = newBookmarkState - onBookmarkClick(bookDetail.isbn, newBookmarkState) - }, - contentAlignment = Alignment.Center, + .padding(top = 33.dp) + .fillMaxWidth() + .clickable { isIntroductionPopupVisible = true } ) { - Icon( - painter = painterResource( - if (isBookmarked) - R.drawable.ic_save_filled - else - R.drawable.ic_save + Text( + text = stringResource(R.string.search_book_comment), + color = colors.White, + style = typography.menu_sb600_s14_h24, + ) + Spacer(modifier = Modifier.height(5.dp)) + + Text( + text = bookDetail.description, + color = colors.Grey, + style = typography.copy_r400_s12_h20, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(modifier = Modifier.height(40.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + ActionMediumButton( + text = stringResource( + R.string.search_recruiting_group_count, + bookDetail.recruitingRoomCount ), - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier.size(24.dp) + contentColor = colors.Grey, + backgroundColor = Color.Transparent, + hasRightIcon = true, + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = colors.Grey, + shape = RoundedCornerShape(12.dp) + ), + onClick = onRecruitingGroupClick, ) + Row { + ActionMediumButton( + text = stringResource(R.string.search_write_feed_comment), + contentColor = colors.White, + backgroundColor = colors.Purple, + hasRightIcon = true, + hasRightPlusIcon = true, + modifier = Modifier.weight(1f), + onClick = onWriteFeedClick + ) + Box( + modifier = Modifier + .padding(start = 12.dp) + .size(44.dp) + .border( + width = 1.dp, + color = colors.Grey02, + shape = RoundedCornerShape(12.dp) + ) + .background( + color = Color.Transparent, + shape = RoundedCornerShape(12.dp) + ) + .clickable { + val newBookmarkState = !isBookmarked + isBookmarked = newBookmarkState + onBookmarkClick(bookDetail.isbn, newBookmarkState) + }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource( + if (isBookmarked) + R.drawable.ic_save_filled + else + R.drawable.ic_save + ), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(24.dp) + ) + } + } } + Spacer(modifier = Modifier.height(44.dp)) + + Text( + text = stringResource(R.string.search_watch_feed), + color = colors.Grey, + style = typography.smalltitle_sb600_s18_h24, + modifier = Modifier.padding(bottom = 8.dp) + ) + + SearchFilterButton( + modifier = Modifier + .onGloballyPositioned { coordinates -> + filterButtonPosition = IntOffset( + coordinates.positionInRoot().x.toInt(), + coordinates.positionInRoot().y.toInt() + ) + }, + selectedOption = filterOptions[selectedFilterOption], + isExpanded = isFilterDropdownVisible, + onClick = { + isFilterDropdownVisible = !isFilterDropdownVisible + } + ) + // 구분선 + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(colors.DarkGrey02) + ) } } - Spacer(modifier = Modifier.height(44.dp)) + } - Text( - text = stringResource(R.string.search_watch_feed), - color = colors.Grey, - style = typography.smalltitle_sb600_s18_h24, - modifier = Modifier.padding(bottom = 33.dp) - ) + // 피드 목록 (ViewModel에서 변환된 데이터 사용) + val feedItems = uiState?.feedItems ?: emptyList() - // 피드 리스트 - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(colors.DarkGrey02) - ) - if (feedList.isEmpty()) { + if (feedItems.isEmpty() && uiState?.isLoadingFeeds != true) { + item { Box( modifier = Modifier .fillMaxWidth() - .weight(1f), + .height(250.dp), contentAlignment = Alignment.Center ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -395,23 +425,87 @@ private fun SearchBookDetailScreenContent( ) } } - } else { - // TODO: 피드 UI 구현 되면 수정 + } + } + + if (uiState?.isLoadingFeeds == true && feedItems.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = colors.White) + } + } + } + + // 피드 아이템들 + itemsIndexed( + items = feedItems, + key = { _, feedItem -> feedItem.id } + ) { index, feedItem -> + val relatedFeedItem = uiState?.relatedFeeds?.getOrNull(index) + + Spacer(modifier = Modifier.height(if (index == 0) 20.dp else 40.dp)) + + SavedFeedCard( + feedItem = feedItem, + bottomTextColor = relatedFeedItem?.aliasColor?.let { hexToColor(it) } ?: colors.NeonGreen, + onContentClick = { onFeedClick(feedItem.id) }, + onBookClick = { /* TODO: Book navigation */ } + ) + + Spacer(modifier = Modifier.height(40.dp)) + + if (index != feedItems.lastIndex) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .background(colors.DarkGrey02) + ) + } + } + + // 무한 스크롤 로딩 인디케이터 + if (uiState?.isLoadingMore == true) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = colors.White + ) + } } } } } - FilterButton( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 462.dp, start = 20.dp, end = 20.dp), - selectedOption = filterOptions[selectedFilterOption], - options = filterOptions, - onOptionSelected = { option -> - selectedFilterOption = filterOptions.indexOf(option) + // TopAppBar 오버레이 (고정) + Column { + AnimatedVisibility(visible = isAlarmVisible) { + GradationTopAppBar( + isImageVisible = true, + count = bookDetail.readCount, + onLeftClick = onLeftClick, + onRightClick = {} + ) } - ) + AnimatedVisibility(visible = !isAlarmVisible) { + DefaultTopAppBar( + isRightIconVisible = true, + isTitleVisible = false, + onLeftClick = onLeftClick, + onRightClick = onRightClick + ) + } + } if (isIntroductionPopupVisible) { Box( @@ -426,6 +520,19 @@ private fun SearchBookDetailScreenContent( ) } } + + // FilterDropdown 오버레이 + SearchFilterDropdownOverlay( + isVisible = isFilterDropdownVisible, + options = filterOptions, + selectedOption = filterOptions[selectedFilterOption], + filterButtonPosition = filterButtonPosition, + density = density, + onOptionSelected = { option -> + onSortChange(option) + }, + onDismiss = { isFilterDropdownVisible = false } + ) } } else -> {} @@ -452,8 +559,7 @@ private val mockBookDetailSaved = mockBookDetail.copy(isSaved = true) fun SearchBookDetailScreenContentPreview() { ThipTheme { SearchBookDetailScreenContent( - bookDetail = mockBookDetail, - feedList = emptyList() + bookDetail = mockBookDetail ) } } @@ -463,19 +569,7 @@ fun SearchBookDetailScreenContentPreview() { fun SearchBookDetailScreenContentSavedPreview() { ThipTheme { SearchBookDetailScreenContent( - bookDetail = mockBookDetailSaved, - feedList = emptyList() - ) - } -} - -@Preview(showBackground = true) -@Composable -fun SearchBookDetailScreenContentWithFeedsPreview() { - ThipTheme { - SearchBookDetailScreenContent( - bookDetail = mockBookDetail, - feedList = listOf("피드 1", "피드 2", "피드 3") + bookDetail = mockBookDetailSaved ) } } @@ -488,4 +582,4 @@ fun SearchBookDetailScreenContentErrorPreview() { error = "책 정보를 불러오는데 실패했습니다." ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/BookDetailViewModel.kt b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/BookDetailViewModel.kt index 839cae5b..f1c2235c 100644 --- a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/BookDetailViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/BookDetailViewModel.kt @@ -4,8 +4,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.texthip.thip.R import com.texthip.thip.data.model.book.response.BookDetailResponse +import com.texthip.thip.data.model.feed.response.RelatedFeedItem import com.texthip.thip.data.provider.StringResourceProvider +import com.texthip.thip.ui.mypage.mock.FeedItem import com.texthip.thip.data.repository.BookRepository +import com.texthip.thip.data.repository.FeedRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -13,65 +16,180 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject + +data class BookDetailUiState( + val bookDetail: BookDetailResponse? = null, + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val error: String? = null, + val relatedFeeds: List = emptyList(), + val isLoadingFeeds: Boolean = false, + val isLoadingMore: Boolean = false, + val nextCursor: String? = null, + val isLast: Boolean = false, + val currentSort: String = "like", + val feedError: String? = null +) { + val feedItems: List + get() = relatedFeeds.map { it.toFeedItem() } +} + @HiltViewModel class BookDetailViewModel @Inject constructor( private val bookRepository: BookRepository, + private val feedRepository: FeedRepository, private val stringResourceProvider: StringResourceProvider ) : ViewModel() { private val _uiState = MutableStateFlow(BookDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private fun updateState(update: (BookDetailUiState) -> BookDetailUiState) { + _uiState.value = update(_uiState.value) + } + fun loadBookDetail(isbn: String) { viewModelScope.launch { - _uiState.value = _uiState.value.copy(isLoading = true, error = null) - + updateState { it.copy(isLoading = true, error = null) } + bookRepository.getBookDetail(isbn) .onSuccess { bookDetail -> - _uiState.value = _uiState.value.copy( - bookDetail = bookDetail, - isLoading = false, - error = null - ) + updateState { + it.copy( + bookDetail = bookDetail, + isLoading = false, + error = null + ) + } + // 책 상세 정보 로드 후 관련 피드도 로드 + loadRelatedFeeds(isbn, "like") + } + .onFailure { exception -> + updateState { + it.copy( + isLoading = false, + error = exception.message + ?: stringResourceProvider.getString(R.string.error_book_detail_load_failed) + ) + } + } + } + } + + fun loadRelatedFeeds(isbn: String, sort: String = "like") { + viewModelScope.launch { + updateState { it.copy(isLoadingFeeds = true) } + + feedRepository.getRelatedBookFeeds(isbn, sort, null) + .onSuccess { response -> + updateState { + it.copy( + relatedFeeds = response?.feeds ?: emptyList(), + nextCursor = response?.nextCursor, + isLast = response?.isLast ?: true, + isLoadingFeeds = false, + currentSort = sort + ) + } + } + .onFailure { exception -> + updateState { + it.copy( + isLoadingFeeds = false, + feedError = exception.message + ) + } + } + } + } + + fun loadMoreFeeds(isbn: String) { + val currentState = _uiState.value + if (currentState.isLoadingMore || currentState.isLast || currentState.nextCursor == null) return + + viewModelScope.launch { + updateState { it.copy(isLoadingMore = true) } + + feedRepository.getRelatedBookFeeds( + isbn, + currentState.currentSort, + currentState.nextCursor + ) + .onSuccess { response -> + updateState { state -> + state.copy( + relatedFeeds = currentState.relatedFeeds + (response?.feeds + ?: emptyList()), + nextCursor = response?.nextCursor, + isLast = response?.isLast ?: true, + isLoadingMore = false + ) + } } .onFailure { exception -> - _uiState.value = _uiState.value.copy( - isLoading = false, - error = exception.message ?: stringResourceProvider.getString(R.string.error_book_detail_load_failed) - ) + updateState { state -> + state.copy( + isLoadingMore = false, + feedError = exception.message + ) + } } } } + fun changeSortOrder(isbn: String, sort: String) { + if (_uiState.value.currentSort != sort) { + loadRelatedFeeds(isbn, sort) + } + } + fun saveBook(isbn: String, type: Boolean) { viewModelScope.launch { - _uiState.value = _uiState.value.copy(isSaving = true, error = null) - + updateState { it.copy(isSaving = true, error = null) } + bookRepository.saveBook(isbn, type) .onSuccess { saveResponse -> - saveResponse?.let { + saveResponse?.let { it -> // 책 상세 정보의 isSaved 상태 업데이트 - val updatedBookDetail = _uiState.value.bookDetail?.copy(isSaved = it.isSaved) - _uiState.value = _uiState.value.copy( - bookDetail = updatedBookDetail, - isSaving = false, - error = null - ) + val updatedBookDetail = + _uiState.value.bookDetail?.copy(isSaved = it.isSaved) + updateState { + it.copy( + bookDetail = updatedBookDetail, + isSaving = false, + error = null + ) + } } } .onFailure { exception -> - _uiState.value = _uiState.value.copy( - isSaving = false, - error = exception.message ?: stringResourceProvider.getString(R.string.error_book_save_failed) - ) + updateState { + it.copy( + isSaving = false, + error = exception.message + ?: stringResourceProvider.getString(R.string.error_book_save_failed) + ) + } } } } } -data class BookDetailUiState( - val bookDetail: BookDetailResponse? = null, - val isLoading: Boolean = false, - val isSaving: Boolean = false, - val error: String? = null -) \ No newline at end of file +// RelatedFeedItem을 FeedItem으로 변환하는 확장 함수 +private fun RelatedFeedItem.toFeedItem(): FeedItem { + return FeedItem( + id = this.feedId, + userProfileImage = this.creatorProfileImageUrl, + userName = this.creatorNickname, + userRole = this.aliasName, + bookTitle = this.bookTitle, + authName = this.bookAuthor, + timeAgo = this.postDate, + content = this.contentBody, + likeCount = this.likeCount, + commentCount = this.commentCount, + isLiked = this.isLiked, + isSaved = this.isSaved, + imageUrls = this.contentUrls + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt index 5efbe973..4a0445c3 100644 --- a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt @@ -52,7 +52,7 @@ class SearchBookViewModel @Inject constructor( ) } searchJob = viewModelScope.launch { - delay(1000) // Live search에 딜레이 추가 + delay(300) performSearch(query, isLiveSearch = true) } } else { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3a0488d1..76ad949f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -339,6 +339,7 @@ 선택된 태그 %1$d / %2$d개 수정하기 + 글 수정 삭제하기 이 피드를 삭제하시겠어요? 삭제 후에는 되돌릴 수 없어요. @@ -415,6 +416,10 @@ 서버 feedId 반환 오류 + + like + latest + 과학/IT