diff --git a/app/src/main/java/com/texthip/thip/data/model/feed/response/FeedDetailResponse.kt b/app/src/main/java/com/texthip/thip/data/model/feed/response/FeedDetailResponse.kt new file mode 100644 index 00000000..9b03febd --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/feed/response/FeedDetailResponse.kt @@ -0,0 +1,26 @@ +package com.texthip.thip.data.model.feed.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FeedDetailResponse( + @SerialName("feedId") val feedId: Int, + @SerialName("creatorId") val creatorId: Int, + @SerialName("creatorNickname") val creatorNickname: String, + @SerialName("creatorProfileImageUrl") val creatorProfileImageUrl: String?, + @SerialName("aliasName") val aliasName: String, + @SerialName("aliasColor") val aliasColor: String, + @SerialName("postDate") val postDate: String, + @SerialName("bookTitle") val bookTitle: String, + @SerialName("isbn") val isbn: String, + @SerialName("bookAuthor") val bookAuthor: String, + @SerialName("contentBody") val contentBody: String, + @SerialName("contentUrls") val contentUrls: List, + @SerialName("likeCount") val likeCount: Int, + @SerialName("commentCount") val commentCount: Int, + @SerialName("isSaved") val isSaved: Boolean, + @SerialName("isLiked") val isLiked: Boolean, + @SerialName("isWriter") val isWriter: Boolean, + @SerialName("tagList") val tagList: List +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/feeds/response/AllFeedResponse.kt b/app/src/main/java/com/texthip/thip/data/model/feeds/response/AllFeedResponse.kt new file mode 100644 index 00000000..e9bda179 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/feeds/response/AllFeedResponse.kt @@ -0,0 +1,33 @@ +package com.texthip.thip.data.model.feeds.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class AllFeedResponse( + @SerialName("feedList") val feedList: List, + @SerialName("nextCursor") val nextCursor: String?, + @SerialName("isLast") val isLast: Boolean +) + +@Serializable +data class AllFeedItem( + @SerialName("feedId") val feedId: Int, + @SerialName("creatorId") val creatorId: Int, + @SerialName("creatorNickname") val creatorNickname: String, + @SerialName("creatorProfileImageUrl") val creatorProfileImageUrl: String?, + @SerialName("aliasName") val aliasName: String, + @SerialName("aliasColor") val aliasColor: String, + @SerialName("postDate") val postDate: String, + @SerialName("isbn") val isbn: String, + @SerialName("bookTitle") val bookTitle: String, + @SerialName("bookAuthor") val bookAuthor: String, + @SerialName("contentBody") val contentBody: String, + @SerialName("contentUrls") val contentUrls: List, + @SerialName("likeCount") val likeCount: Int, + @SerialName("commentCount") val commentCount: Int, + @SerialName("isSaved") val isSaved: Boolean, + @SerialName("isLiked") val isLiked: Boolean, + @SerialName("isWriter") val isWriter: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/feeds/response/MyFeedResponse.kt b/app/src/main/java/com/texthip/thip/data/model/feeds/response/MyFeedResponse.kt new file mode 100644 index 00000000..dae8e309 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/feeds/response/MyFeedResponse.kt @@ -0,0 +1,27 @@ +package com.texthip.thip.data.model.feeds.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class MyFeedResponse( + @SerialName("feedList") val feedList: List, + @SerialName("nextCursor") val nextCursor: String?, + @SerialName("isLast") val isLast: Boolean +) + +@Serializable +data class MyFeedItem( + @SerialName("feedId") val feedId: Int, + @SerialName("postDate") val postDate: String, + @SerialName("isbn") val isbn: String, + @SerialName("bookTitle") val bookTitle: String, + @SerialName("bookAuthor") val bookAuthor: String, + @SerialName("contentBody") val contentBody: String, + @SerialName("contentUrls") val contentUrls: List, + @SerialName("likeCount") val likeCount: Int, + @SerialName("commentCount") val commentCount: Int, + @SerialName("isPublic") val isPublic: Boolean, + @SerialName("isWriter") val isWriter: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/repository/FeedRepository.kt b/app/src/main/java/com/texthip/thip/data/repository/FeedRepository.kt index 9a3c722e..546e94cb 100644 --- a/app/src/main/java/com/texthip/thip/data/repository/FeedRepository.kt +++ b/app/src/main/java/com/texthip/thip/data/repository/FeedRepository.kt @@ -5,7 +5,10 @@ import android.net.Uri import com.texthip.thip.data.model.base.handleBaseResponse import com.texthip.thip.data.model.feed.request.CreateFeedRequest import com.texthip.thip.data.model.feed.response.CreateFeedResponse +import com.texthip.thip.data.model.feed.response.FeedDetailResponse import com.texthip.thip.data.model.feed.response.FeedWriteInfoResponse +import com.texthip.thip.data.model.feeds.response.AllFeedResponse +import com.texthip.thip.data.model.feeds.response.MyFeedResponse import com.texthip.thip.data.service.FeedService import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -70,22 +73,20 @@ class FeedRepository @Inject constructor( // 임시 파일 목록 추적 val tempFiles = mutableListOf() - try { - // 이미지 파일들을 MultipartBody.Part로 변환 - val imageParts = if (imageUris.isNotEmpty()) { - withContext(Dispatchers.IO) { - imageUris.mapNotNull { uri -> - try { - uriToMultipartBodyPart(uri, "images", tempFiles) - } catch (e: Exception) { - null - } - } + // 이미지 파일들을 MultipartBody.Part로 변환 + val imageParts = if (imageUris.isNotEmpty()) { + withContext(Dispatchers.IO) { + imageUris.mapNotNull { uri -> + runCatching { + uriToMultipartBodyPart(uri, "images", tempFiles) + }.getOrNull() } - } else { - null } + } else { + null + } + try { feedService.createFeed(requestBody, imageParts) .handleBaseResponse() .getOrThrow() @@ -100,7 +101,7 @@ class FeedRepository @Inject constructor( paramName: String, tempFiles: MutableList ): MultipartBody.Part? { - return try { + return runCatching { // MIME 타입 확인 val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg" val extension = when (mimeType) { @@ -122,25 +123,45 @@ class FeedRepository @Inject constructor( FileOutputStream(tempFile).use { outputStream -> inputStream.copyTo(outputStream) } - } ?: return null + } ?: throw IllegalStateException("Failed to open input stream for URI: $uri") // MultipartBody.Part 생성 val requestFile = tempFile.asRequestBody(mimeType.toMediaType()) MultipartBody.Part.createFormData(paramName, fileName, requestFile) - } catch (e: Exception) { + }.onFailure { e -> e.printStackTrace() - null - } + }.getOrNull() + } + + /** 전체 피드 목록 조회 */ + suspend fun getAllFeeds(cursor: String? = null): Result = runCatching { + feedService.getAllFeeds(cursor) + .handleBaseResponse() + .getOrThrow() + } + + /** 내 피드 목록 조회 */ + suspend fun getMyFeeds(cursor: String? = null): Result = runCatching { + feedService.getMyFeeds(cursor) + .handleBaseResponse() + .getOrThrow() + } + + /** 피드 상세 조회 */ + suspend fun getFeedDetail(feedId: Int): Result = runCatching { + feedService.getFeedDetail(feedId) + .handleBaseResponse() + .getOrThrow() } /** 임시 파일들을 정리하는 함수 */ private fun cleanupTempFiles(tempFiles: List) { tempFiles.forEach { file -> - try { + runCatching { if (file.exists()) { file.delete() } - } catch (e: Exception) { + }.onFailure { e -> e.printStackTrace() } } 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 8a7bc3ea..78d91165 100644 --- a/app/src/main/java/com/texthip/thip/data/service/FeedService.kt +++ b/app/src/main/java/com/texthip/thip/data/service/FeedService.kt @@ -2,9 +2,12 @@ package com.texthip.thip.data.service import com.texthip.thip.data.model.base.BaseResponse import com.texthip.thip.data.model.feed.response.CreateFeedResponse +import com.texthip.thip.data.model.feed.response.FeedDetailResponse import com.texthip.thip.data.model.feed.response.FeedUsersInfoResponse import com.texthip.thip.data.model.feed.response.FeedUsersResponse import com.texthip.thip.data.model.feed.response.FeedWriteInfoResponse +import com.texthip.thip.data.model.feeds.response.AllFeedResponse +import com.texthip.thip.data.model.feeds.response.MyFeedResponse import okhttp3.MultipartBody import okhttp3.RequestBody import retrofit2.http.GET @@ -12,6 +15,8 @@ import retrofit2.http.Multipart import retrofit2.http.POST import retrofit2.http.Part import retrofit2.http.Path +import retrofit2.http.Query + interface FeedService { @@ -27,6 +32,24 @@ interface FeedService { @Part images: List? ): BaseResponse + /** 전체 피드 목록 조회 */ + @GET("feeds") + suspend fun getAllFeeds( + @Query("cursor") cursor: String? = null + ): BaseResponse + + /** 내 피드 목록 조회 */ + @GET("feeds/mine") + suspend fun getMyFeeds( + @Query("cursor") cursor: String? = null + ): BaseResponse + + /** 피드 상세 조회 */ + @GET("feeds/{feedId}") + suspend fun getFeedDetail( + @Path("feedId") feedId: Int + ): BaseResponse + @GET("feeds/users/{userId}/info") suspend fun getFeedUsersInfo( @Path("userId") userId: Long diff --git a/app/src/main/java/com/texthip/thip/ui/feed/component/ImageViewerModal.kt b/app/src/main/java/com/texthip/thip/ui/feed/component/ImageViewerModal.kt index 89dcb56e..166af14b 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/component/ImageViewerModal.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/component/ImageViewerModal.kt @@ -1,6 +1,5 @@ package com.texthip.thip.ui.feed.component -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -18,13 +17,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import coil.compose.AsyncImage import com.texthip.thip.R import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors @@ -32,19 +31,18 @@ import com.texthip.thip.ui.theme.ThipTheme.typography @Composable fun ImageViewerModal( - images: List, + imageUrls: List, initialIndex: Int = 0, onDismiss: () -> Unit ) { val pagerState = rememberPagerState( initialPage = initialIndex, - pageCount = { images.size } + pageCount = { imageUrls.size } ) Box( modifier = Modifier .fillMaxSize() - .background(colors.Black) .clickable { onDismiss() } ) { // 닫기 버튼 @@ -71,8 +69,8 @@ fun ImageViewerModal( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - Image( - painter = images[page], + AsyncImage( + model = imageUrls[page], contentDescription = null, contentScale = ContentScale.Fit, // 원본 비율 유지하면서 화면에 맞춤 modifier = Modifier.fillMaxSize() @@ -81,14 +79,14 @@ fun ImageViewerModal( } // 페이지 인디케이터 (이미지가 2개 이상일 때만 표시) - if (images.size > 1) { + if (imageUrls.size > 1) { Row( modifier = Modifier .align(Alignment.BottomCenter) .padding(20.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - repeat(images.size) { index -> + repeat(imageUrls.size) { index -> Box( modifier = Modifier .size(8.dp) @@ -103,9 +101,9 @@ fun ImageViewerModal( } // 이미지 카운터 (예: 1/3) - if (images.size > 1) { + if (imageUrls.size > 1) { Text( - text = stringResource(id = R.string.tag_count, images.size, 3), + text = stringResource(id = R.string.tag_count, pagerState.currentPage + 1, imageUrls.size), style = typography.copy_r400_s14, color = colors.White, modifier = Modifier @@ -126,12 +124,12 @@ fun ImageViewerModal( @Composable private fun ImageViewerModalSingleImagePreview() { ThipTheme { - val mockImages = listOf( - painterResource(R.drawable.img_book_cover_sample) + val mockImageUrls = listOf( + "https://example.com/image1.jpg" ) ImageViewerModal( - images = mockImages, + imageUrls = mockImageUrls, initialIndex = 0, onDismiss = {} ) @@ -142,14 +140,14 @@ private fun ImageViewerModalSingleImagePreview() { @Composable private fun ImageViewerModalMultipleImagesPreview() { ThipTheme { - val mockImages = listOf( - painterResource(R.drawable.character_art), - painterResource(R.drawable.character_literature), - painterResource(R.drawable.character_sociology) + val mockImageUrls = listOf( + "https://example.com/image1.jpg", + "https://example.com/image2.jpg", + "https://example.com/image3.jpg" ) ImageViewerModal( - images = mockImages, + imageUrls = mockImageUrls, initialIndex = 1, onDismiss = { } ) diff --git a/app/src/main/java/com/texthip/thip/ui/feed/component/MyFeedCard.kt b/app/src/main/java/com/texthip/thip/ui/feed/component/MyFeedCard.kt index dee0a7ab..297e75f3 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/component/MyFeedCard.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/component/MyFeedCard.kt @@ -1,7 +1,7 @@ package com.texthip.thip.ui.feed.component -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -19,6 +19,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage import com.texthip.thip.R import com.texthip.thip.ui.common.buttons.ActionBookButton import com.texthip.thip.ui.mypage.mock.FeedItem @@ -32,8 +33,7 @@ fun MyFeedCard( onLikeClick: () -> Unit = {}, onContentClick: () -> Unit = {} ) { - val images = feedItem.imageUrls.orEmpty().map { painterResource(id = it) } - val hasImages = images.isNotEmpty() + val hasImages = feedItem.imageUrls.isNotEmpty() val maxLines = if (hasImages) 3 else 8 Column( @@ -47,33 +47,39 @@ fun MyFeedCard( onClick = {} ) - Text( - text = feedItem.content, - style = typography.feedcopy_r400_s14_h20, - color = colors.White, - maxLines = maxLines, + Column( modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp) - .clickable { onContentClick() } - ) - - if (hasImages) { - Row( + .clickable { onContentClick() }, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = feedItem.content, + style = typography.feedcopy_r400_s14_h20, + color = colors.White, + maxLines = maxLines, modifier = Modifier .fillMaxWidth() - .padding(bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - images.take(3).forEach { image -> - Image( - painter = image, - contentDescription = null, - modifier = Modifier - .padding(end = 10.dp) - .size(100.dp), - contentScale = ContentScale.Crop - ) + .padding(vertical = 16.dp) + ) + + if (hasImages) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + feedItem.imageUrls.take(3).forEach { imageUrl -> + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier + .padding(end = 10.dp) + .size(100.dp), + contentScale = ContentScale.Crop + ) + } } } } @@ -94,6 +100,7 @@ fun MyFeedCard( modifier = Modifier.padding(start = 5.dp, end = 12.dp) ) Icon( + modifier = Modifier.clickable { onContentClick() }, painter = painterResource(R.drawable.ic_comment), contentDescription = null, tint = colors.White @@ -121,7 +128,7 @@ fun MyFeedCard( private fun MyFeedCardPrev() { val feed1 = FeedItem( id = 1, - userProfileImage = R.drawable.character_literature, + userProfileImage = "https://example.com/profile1.jpg", userName = "user.01", userRole = stringResource(R.string.influencer), bookTitle = "책 제목", @@ -133,11 +140,11 @@ private fun MyFeedCardPrev() { isLiked = false, isSaved = true, isLocked = true, - imageUrls = null + imageUrls = emptyList() ) val feed2 = FeedItem( id = 2, - userProfileImage = R.drawable.character_art, + userProfileImage = "https://example.com/profile2.jpg", userName = "user.01", userRole = stringResource(R.string.influencer), bookTitle = "책 제목", @@ -149,7 +156,7 @@ private fun MyFeedCardPrev() { isLiked = false, isSaved = true, isLocked = false, - imageUrls = listOf(R.drawable.img_book_cover_sample, R.drawable.img_book_cover_sample) + imageUrls = listOf("https://example.com/image1.jpg", "https://example.com/image2.jpg") ) Column { @@ -160,6 +167,4 @@ private fun MyFeedCardPrev() { feedItem = feed2 ) } - - } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedCommentScreen.kt b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedCommentScreen.kt index 7c3b30f0..7fef8619 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedCommentScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedCommentScreen.kt @@ -1,6 +1,5 @@ package com.texthip.thip.ui.feed.screen -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -20,9 +19,12 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -33,13 +35,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import coil.compose.AsyncImage import com.texthip.thip.R import com.texthip.thip.ui.common.bottomsheet.MenuBottomSheet import com.texthip.thip.ui.common.buttons.ActionBookButton @@ -49,10 +51,9 @@ import com.texthip.thip.ui.common.header.ProfileBar import com.texthip.thip.ui.common.modal.DialogPopup import com.texthip.thip.ui.common.topappbar.DefaultTopAppBar import com.texthip.thip.ui.feed.component.ImageViewerModal -import com.texthip.thip.ui.feed.mock.FeedItemType +import com.texthip.thip.ui.feed.viewmodel.FeedDetailViewModel import com.texthip.thip.ui.group.note.mock.mockCommentList import com.texthip.thip.ui.group.room.mock.MenuBottomSheetItem -import com.texthip.thip.ui.mypage.mock.FeedItem import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography @@ -62,29 +63,73 @@ import com.texthip.thip.ui.group.note.mock.ReplyItem as FeedReplyItem @Composable fun FeedCommentScreen( modifier: Modifier = Modifier, - feedItem: FeedItem, - bookImage: Painter? = null, - profileImage: String, - feedType: FeedItemType, - currentUserId: Int, - currentUserName: String, - currentUserGenre: String, - currentUserProfileImageUrl: String, + feedId: Int, + onNavigateBack: () -> Unit = {}, + currentUserId: Int = 1, + currentUserName: String = "현재사용자", + currentUserGenre: String = "문학", + currentUserProfileImageUrl: String = "", onLikeClick: () -> Unit = {}, onCommentInputChange: (String) -> Unit = {}, onSendClick: () -> Unit = {}, - commentList: SnapshotStateList? = null + commentList: SnapshotStateList? = null, + viewModel: FeedDetailViewModel = hiltViewModel() ) { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(feedId) { + viewModel.loadFeedDetail(feedId) + } + + // 로딩 상태 처리 + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = colors.White, + modifier = Modifier.size(48.dp) + ) + } + return + } + + // 에러 상태 처리 + if (uiState.error != null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "오류가 발생했습니다", + style = typography.smalltitle_sb600_s18_h24, + color = colors.White + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = uiState.error!!, + style = typography.copy_r400_s14, + color = colors.Grey + ) + } + } + return + } + + // 피드 데이터가 없으면 리턴 + val feedDetail = uiState.feedDetail ?: return val CommentList = commentList ?: remember { mutableStateListOf() } var isBottomSheetVisible by remember { mutableStateOf(false) } var showDialog by remember { mutableStateOf(false) } val commentInput = remember { mutableStateOf("") } val replyTo = remember { mutableStateOf(null) } - val feed = remember { mutableStateOf(feedItem) } + val feed = remember { mutableStateOf(feedDetail) } val justNow = stringResource(R.string.just_a_moment_ago) - val images = feedItem.imageUrls.orEmpty().map { painterResource(id = it) } + val images = feedDetail.contentUrls var showImageViewer by remember { mutableStateOf(false) } var selectedImageIndex by remember { mutableStateOf(0) } @@ -110,7 +155,7 @@ fun FeedCommentScreen( DefaultTopAppBar( isRightIconVisible = true, isTitleVisible = false, - onLeftClick = {}, + onLeftClick = onNavigateBack, onRightClick = { isBottomSheetVisible = true }, ) @@ -125,11 +170,11 @@ fun FeedCommentScreen( Column { ProfileBar( modifier = Modifier.padding(20.dp), - profileImage = profileImage, - topText = feedItem.userName, - bottomText = feedItem.userRole, + profileImage = feedDetail.creatorProfileImageUrl ?: "", + topText = feedDetail.creatorNickname, + bottomText = feedDetail.aliasName, showSubscriberInfo = false, - hoursAgo = feedItem.timeAgo + hoursAgo = feedDetail.postDate ) Column( Modifier @@ -137,13 +182,13 @@ fun FeedCommentScreen( .padding(vertical = 16.dp, horizontal = 20.dp) ) { ActionBookButton( - bookTitle = feedItem.bookTitle, - bookAuthor = feedItem.authName, + bookTitle = feedDetail.bookTitle, + bookAuthor = feedDetail.bookAuthor, onClick = {} ) } Text( - text = feedItem.content, + text = feedDetail.contentBody, style = typography.feedcopy_r400_s14_h20, color = colors.White, modifier = Modifier @@ -157,9 +202,9 @@ fun FeedCommentScreen( .padding(start = 20.dp, bottom = 16.dp), verticalAlignment = Alignment.CenterVertically ) { - itemsIndexed(images.take(3)) { index, image -> - Image( - painter = image, + itemsIndexed(images.take(3)) { index, imageUrl -> + AsyncImage( + model = imageUrl, contentDescription = null, modifier = Modifier .padding(end = 16.dp) @@ -173,16 +218,16 @@ fun FeedCommentScreen( } } } - if (feedItem.tags.isNotEmpty()) { + if (feedDetail.tagList.isNotEmpty()) { Row( Modifier .fillMaxWidth() .padding(bottom = 16.dp, start = 20.dp, end = 20.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - feedItem.tags.forEach { tag -> + feedDetail.tagList.forEach { tag -> OptionChipButton( - text = tag, + text = "#$tag", isFilled = false, isSelected = false, onClick = {}) @@ -356,7 +401,7 @@ fun FeedCommentScreen( CommentTextField( modifier = Modifier.align(Alignment.BottomCenter), input = commentInput.value, - hint = stringResource(R.string.feed_reply_to, feedItem.userName), + hint = stringResource(R.string.feed_reply_to, feedDetail.creatorNickname), onInputChange = { commentInput.value = it onCommentInputChange(it) @@ -457,7 +502,7 @@ fun FeedCommentScreen( if (showImageViewer && images.isNotEmpty()) { ImageViewerModal( - images = images.take(3), + imageUrls = images.take(3), initialIndex = selectedImageIndex, onDismiss = { showImageViewer = false } ) @@ -468,36 +513,13 @@ fun FeedCommentScreen( @Composable private fun FeedCommentScreenWithMockComments() { ThipTheme { - val mockFeedItem = FeedItem( - id = 1, - userProfileImage = R.drawable.character_literature, - userName = "문학소녀", - userRole = "문학 칭호", - bookTitle = "채식주의자", - authName = "한강", - timeAgo = "1시간 전", - content = "이 책은 인간의 본성과 억압에 대한 깊은 성찰을 담고 있어요.", - likeCount = 12, - commentCount = 3, - isLiked = true, - isSaved = true, - isLocked = true, - imageUrls = listOf( - R.drawable.img_book_cover_sample, - R.drawable.img_book_cover_sample, - R.drawable.img_book_cover_sample - ), - tags = listOf("에세이", "문학", "힐링") - ) val commentList = remember { mutableStateListOf().apply { addAll(mockCommentList.commentData) } } FeedCommentScreen( - feedItem = mockFeedItem, - feedType = FeedItemType.SAVABLE, - profileImage = "https://example.com/image1.jpg", + feedId = 1, currentUserId = 999, currentUserName = "나", currentUserGenre = "문학", @@ -511,38 +533,10 @@ private fun FeedCommentScreenWithMockComments() { @Composable private fun FeedCommentScreenPrev() { ThipTheme { - val mockFeedItem = FeedItem( - id = 1, - userProfileImage = R.drawable.character_literature, - userName = "문학소녀", - userRole = "문학 칭호", - bookTitle = "채식주의자", - authName = "한강", - timeAgo = "1시간 전", - content = "이 책은 인간의 본성과 억압에 대한 깊은 성찰을 담고 있어요.", - likeCount = 12, - commentCount = 3, - isLiked = true, - isSaved = true, - isLocked = false, - imageUrls = listOf( - R.drawable.img_book_cover_sample, - R.drawable.img_book_cover_sample, - R.drawable.img_book_cover_sample - ), -// bookImage = painterResource(R.drawable.img_book_cover_sample), -// profileImage = "https://example.com/image1.jpg", -// onLikeClick = {}, -// onCommentInputChange = {}, -// onSendClick = {}, - tags = listOf("에세이", "문학", "힐링") - ) val commentList = remember { mutableStateListOf() } FeedCommentScreen( - feedItem = mockFeedItem, - feedType = FeedItemType.SAVABLE, - profileImage = "https://example.com/image1.jpg", + feedId = 1, currentUserId = 999, currentUserName = "나", currentUserGenre = "문학", diff --git a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedOthersScreen.kt b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedOthersScreen.kt index 152dd30b..43a67cac 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedOthersScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedOthersScreen.kt @@ -155,6 +155,7 @@ fun FeedOthersContent( @Preview @Composable private fun FeedOthersScreenPrev() { + val mockUserInfo = FeedUsersInfoResponse( creatorId = 1, profileImageUrl = "", 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 83e18a55..33e0f7be 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt @@ -14,21 +14,25 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -46,20 +50,25 @@ import com.texthip.thip.ui.common.topappbar.LogoTopAppBar import com.texthip.thip.ui.feed.component.FeedSubscribeBarlist import com.texthip.thip.ui.feed.component.MyFeedCard import com.texthip.thip.ui.feed.component.MySubscribeBarlist +import com.texthip.thip.ui.feed.mock.MySubscriptionData import com.texthip.thip.ui.feed.viewmodel.FeedViewModel +import com.texthip.thip.ui.feed.viewmodel.MySubscriptionViewModel import com.texthip.thip.ui.mypage.component.SavedFeedCard import com.texthip.thip.ui.mypage.mock.FeedItem import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography +import com.texthip.thip.utils.color.hexToColor import kotlinx.coroutines.delay import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterial3Api::class) @Composable fun FeedScreen( onNavigateToMySubscription: () -> Unit = {}, onNavigateToFeedWrite: () -> Unit = {}, + onNavigateToFeedComment: (Int) -> Unit = {}, nickname: String = "", userRole: String = "", feeds: List = emptyList(), @@ -69,21 +78,39 @@ fun FeedScreen( resultFeedId: Int? = null, onResultConsumed: () -> Unit = {}, feedViewModel: FeedViewModel = hiltViewModel(), + mySubscriptionViewModel: MySubscriptionViewModel = hiltViewModel() ) { val feedUiState by feedViewModel.uiState.collectAsState() - val selectedIndex = rememberSaveable { mutableIntStateOf(selectedTabIndex) } - val feedStateList = remember { - mutableStateListOf().apply { - addAll(feeds) - } - } val scope = rememberCoroutineScope() - var showProgressBar by remember { mutableStateOf(false) } val progress = remember { Animatable(0f) } - + val feedTabTitles = listOf(stringResource(R.string.feed), stringResource(R.string.my_feed)) + // 무한 스크롤 로직 + val listState = rememberLazyListState() + + // 무한 스크롤 로직 + val shouldLoadMore by remember(feedUiState.canLoadMoreCurrentTab, feedUiState.isLoadingMore) { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val totalItems = layoutInfo.totalItemsCount + val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + + feedUiState.canLoadMoreCurrentTab && + !feedUiState.isLoadingMore && + feedUiState.currentTabFeeds.isNotEmpty() && + totalItems > 0 && + lastVisibleIndex >= totalItems - 3 + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) { + feedViewModel.loadMoreFeeds() + } + } + LaunchedEffect(Unit) { feedViewModel.refreshData() } @@ -107,174 +134,258 @@ fun FeedScreen( } } - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier.fillMaxSize() + val subscriptionUiState by mySubscriptionViewModel.uiState.collectAsState() + + // 초기 로딩 상태 처리 + if (feedUiState.isLoading && feedUiState.currentTabFeeds.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { - LogoTopAppBar( - leftIcon = painterResource(R.drawable.ic_plusfriend), - hasNotification = false, - onLeftClick = {}, - onRightClick = {}, - ) - Spacer(modifier = Modifier.height(32.dp)) - HeaderMenuBarTab( - titles = feedTabTitles, - selectedTabIndex = selectedIndex.value, - onTabSelected = { selectedIndex.value = it } + CircularProgressIndicator( + color = colors.White, + modifier = Modifier.size(48.dp) ) + } + return + } - // 스크롤 영역 전체 - LazyColumn( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp) + Box(modifier = Modifier.fillMaxSize()) { + PullToRefreshBox( + isRefreshing = feedUiState.isRefreshing, + onRefresh = { feedViewModel.refreshCurrentTab() } + ) { + Column( + modifier = Modifier.fillMaxSize() ) { - item { - AnimatedVisibility(visible = showProgressBar) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 20.dp, end = 20.dp, top = 32.dp), - ) { - Text( - modifier = Modifier.padding(bottom = 12.dp), - text = if (progress.value < 1.0f) { - stringResource(R.string.posting_in_progress_feed) - } else { - stringResource(R.string.posting_complete_feed) - }, - style = typography.view_m500_s14, - color = colors.NeonGreen - ) + LogoTopAppBar( + leftIcon = painterResource(R.drawable.ic_plusfriend), + hasNotification = false, + onLeftClick = {}, + onRightClick = {}, + ) + Spacer(modifier = Modifier.height(32.dp)) + HeaderMenuBarTab( + titles = feedTabTitles, + selectedTabIndex = feedUiState.selectedTabIndex, + onTabSelected = feedViewModel::onTabSelected + ) - Box( + // 스크롤 영역 전체 + LazyColumn( + state = listState, + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + AnimatedVisibility(visible = showProgressBar) { + Column( modifier = Modifier .fillMaxWidth() - .height(8.dp) - .clip(RoundedCornerShape(12.dp)) - .background(color = colors.Grey02) // 트랙(배경) 색상 + .padding(start = 20.dp, end = 20.dp, top = 32.dp), ) { + Text( + modifier = Modifier.padding(bottom = 12.dp), + text = if (progress.value < 1.0f) { + stringResource(R.string.posting_in_progress_feed) + } else { + stringResource(R.string.posting_complete_feed) + }, + style = typography.view_m500_s14, + color = colors.NeonGreen + ) + Box( modifier = Modifier - .fillMaxWidth(fraction = progress.value) - .fillMaxHeight() - .background( - color = colors.NeonGreen, - shape = RoundedCornerShape(12.dp) - ) - ) + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(12.dp)) + .background(color = colors.Grey02) // 트랙(배경) 색상 + ) { + Box( + modifier = Modifier + .fillMaxWidth(fraction = progress.value) + .fillMaxHeight() + .background( + color = colors.NeonGreen, + shape = RoundedCornerShape(12.dp) + ) + ) + } } } } - } - if (selectedIndex.value == 1) { - // 내 피드 - item { - Spacer(modifier = Modifier.height(32.dp)) - AuthorHeader( - profileImage = null, - nickname = nickname, - badgeText = userRole, - buttonText = "", - buttonWidth = 60.dp, - showButton = false - ) - Spacer(modifier = Modifier.height(16.dp)) - FeedSubscribeBarlist( - modifier = Modifier.padding(horizontal = 20.dp), - followerProfileImageUrls = followerProfileImageUrls, - onClick = { - } - ) - Spacer(modifier = Modifier.height(40.dp)) - Text( - text = stringResource(R.string.whole_num, totalFeedCount), - style = typography.menu_m500_s14_h24, - color = colors.Grey, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 12.dp, start = 20.dp) - ) - HorizontalDivider( - color = colors.DarkGrey03, - thickness = 1.dp, - modifier = Modifier.padding(horizontal = 20.dp) - ) - } - - if (totalFeedCount == 0) { + + if (feedUiState.selectedTabIndex == 1) { + // 내 피드 item { - Box( + Spacer(modifier = Modifier.height(32.dp)) + AuthorHeader( + profileImage = null, + nickname = nickname, + badgeText = userRole, + buttonText = "", + buttonWidth = 60.dp, + showButton = false + ) + Spacer(modifier = Modifier.height(16.dp)) + FeedSubscribeBarlist( + modifier = Modifier.padding(horizontal = 20.dp), + followerProfileImageUrls = followerProfileImageUrls, + onClick = { + } + ) + Spacer(modifier = Modifier.height(40.dp)) + Text( + text = stringResource(R.string.whole_num, feedUiState.myFeeds.size), + style = typography.menu_m500_s14_h24, + color = colors.Grey, modifier = Modifier .fillMaxWidth() - .padding(top = 244.dp), - contentAlignment = Alignment.TopCenter - ) { - Text( - text = stringResource(R.string.create_feed), - style = typography.smalltitle_sb600_s18_h24, - color = colors.White + .padding(bottom = 12.dp, start = 20.dp) + ) + HorizontalDivider( + color = colors.DarkGrey02, + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 20.dp) + ) + } + + if (feedUiState.myFeeds.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 244.dp), + contentAlignment = Alignment.TopCenter + ) { + Text( + text = stringResource(R.string.create_feed), + style = typography.smalltitle_sb600_s18_h24, + color = colors.White + ) + } + } + } else { + itemsIndexed(feedUiState.myFeeds, key = { _, item -> item.feedId }) { index, myFeed -> + Spacer(modifier = Modifier.height(if (index == 0) 20.dp else 40.dp)) + + // MyFeedItem을 FeedItem으로 변환 + val feedItem = FeedItem( + id = myFeed.feedId, + userProfileImage = null, + userName = "", // 내 피드이므로 고정값 + userRole = "", // 내 피드이므로 고정값 + bookTitle = myFeed.bookTitle, + authName = myFeed.bookAuthor, + timeAgo = myFeed.postDate, + content = myFeed.contentBody, + likeCount = myFeed.likeCount, + commentCount = myFeed.commentCount, + isLiked = false, // 내 피드는 좋아요 개념 없음 + isSaved = false, // 내 피드는 저장 개념 없음 + isLocked = !myFeed.isPublic, // isPublic의 반대값 + tags = emptyList(), + imageUrls = myFeed.contentUrls ) + + MyFeedCard( + feedItem = feedItem, + onLikeClick = {}, + onContentClick = { + onNavigateToFeedComment(feedItem.id) + } + ) + Spacer(modifier = Modifier.height(40.dp)) + if (index != feedUiState.myFeeds.lastIndex) { + HorizontalDivider( + color = colors.DarkGrey02, + thickness = 6.dp + ) + } } } } else { - itemsIndexed(feedStateList, key = { _, item -> item.id }) { index, feed -> + //피드 + item { + Spacer(modifier = Modifier.height(20.dp)) + val subscriptionsForBar = feedUiState.recentWriters.map { user -> + MySubscriptionData( + profileImageUrl = user.profileImageUrl, + nickname = user.nickname, + role = "", + roleColor = colors.White, + subscriberCount = 0, + isSubscribed = true + ) + } + MySubscribeBarlist( + modifier = Modifier.padding(horizontal = 20.dp), + subscriptions = subscriptionsForBar, + onClick = onNavigateToMySubscription + ) + } + itemsIndexed(feedUiState.allFeeds, key = { _, item -> item.feedId }) { index, allFeed -> + // AllFeedItem을 FeedItem으로 변환 + val feedItem = FeedItem( + id = allFeed.feedId, + userProfileImage = allFeed.creatorProfileImageUrl, + userName = allFeed.creatorNickname, + userRole = allFeed.aliasName, + bookTitle = allFeed.bookTitle, + authName = allFeed.bookAuthor, + timeAgo = allFeed.postDate, + content = allFeed.contentBody, + likeCount = allFeed.likeCount, + commentCount = allFeed.commentCount, + isLiked = allFeed.isLiked, + isSaved = allFeed.isSaved, + isLocked = false, + tags = emptyList(), + imageUrls = allFeed.contentUrls + ) + Spacer(modifier = Modifier.height(if (index == 0) 20.dp else 40.dp)) - MyFeedCard( - feedItem = feed, + + SavedFeedCard( + feedItem = feedItem, + bottomTextColor = hexToColor(allFeed.aliasColor), + onBookmarkClick = { + // TODO: API 호출로 북마크 상태 변경 + }, onLikeClick = { - val updated = feed.copy( - isLiked = !feed.isLiked, - likeCount = if (feed.isLiked) feed.likeCount - 1 else feed.likeCount + 1 - ) - feedStateList[index] = updated + // TODO: API 호출로 좋아요 상태 변경 }, - onContentClick = {} //TODO FeedCommentScreen으로 + onContentClick = { + onNavigateToFeedComment(feedItem.id) + }, + onCommentClick = { + onNavigateToFeedComment(feedItem.id) + } ) Spacer(modifier = Modifier.height(40.dp)) - if (index != feeds.lastIndex) { + if (index != feedUiState.allFeeds.lastIndex) { HorizontalDivider( - color = colors.DarkGrey03, - thickness = 10.dp + color = colors.DarkGrey02, + thickness = 6.dp ) } } } - } else { - //피드 - item { - Spacer(modifier = Modifier.height(20.dp)) - MySubscribeBarlist( - modifier = Modifier.padding(horizontal = 20.dp), - subscriptions = feedUiState.recentWriters, - onClick = onNavigateToMySubscription - ) - } - itemsIndexed(feedStateList, key = { _, item -> item.id }) { index, feed -> - val profileImage = feed.userProfileImage?.let { painterResource(it) } - - SavedFeedCard( - feedItem = feed, - onBookmarkClick = { - val updated = feed.copy(isSaved = !feed.isSaved) - feedStateList[index] = updated - }, - onLikeClick = { - val updated = feed.copy( - isLiked = !feed.isLiked, - likeCount = if (feed.isLiked) feed.likeCount - 1 else feed.likeCount + 1 + // 무한 스크롤 로딩 인디케이터 + if (feedUiState.isLoadingMore) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = colors.White ) - feedStateList[index] = updated - }, - onContentClick = {} //FeedCommentScreen으로 - - ) - if (index != feeds.lastIndex) { - HorizontalDivider( - color = colors.DarkGrey03, - thickness = 10.dp - ) + } } } } @@ -294,7 +405,7 @@ private fun FeedScreenPreview() { val mockFeeds = List(5) { FeedItem( id = it + 1, - userProfileImage = R.drawable.character_literature, + userProfileImage = "https://example.com/profile$it.jpg", userName = "user.$it", userRole = "문학 칭호", bookTitle = "책 제목 ", @@ -306,7 +417,7 @@ private fun FeedScreenPreview() { isLiked = false, isSaved = false, isLocked = it % 2 == 0, - imageUrls = listOf(R.drawable.img_book_cover_sample) + imageUrls = listOf("https://example.com/image$it.jpg") ) } val mockFollowerImages = listOf( @@ -349,4 +460,4 @@ private fun FeedScreenWithoutDataPreview() { ) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedDetailViewModel.kt b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedDetailViewModel.kt new file mode 100644 index 00000000..204d3604 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedDetailViewModel.kt @@ -0,0 +1,61 @@ +package com.texthip.thip.ui.feed.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.texthip.thip.data.model.feed.response.FeedDetailResponse +import com.texthip.thip.data.repository.FeedRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class FeedDetailUiState( + val isLoading: Boolean = false, + val feedDetail: FeedDetailResponse? = null, + val error: String? = null +) + +@HiltViewModel +class FeedDetailViewModel @Inject constructor( + private val feedRepository: FeedRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(FeedDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private fun updateState(update: (FeedDetailUiState) -> FeedDetailUiState) { + _uiState.value = update(_uiState.value) + } + + fun loadFeedDetail(feedId: Int) { + viewModelScope.launch { + updateState { it.copy(isLoading = true, error = null) } + + feedRepository.getFeedDetail(feedId) + .onSuccess { response -> + updateState { + it.copy( + isLoading = false, + feedDetail = response, + error = null + ) + } + } + .onFailure { exception -> + updateState { + it.copy( + isLoading = false, + feedDetail = null, + error = exception.message ?: "알 수 없는 오류가 발생했습니다." + ) + } + } + } + } + + fun clearError() { + updateState { it.copy(error = null) } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedViewModel.kt b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedViewModel.kt index cefa68b5..d09d048b 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedViewModel.kt @@ -2,36 +2,263 @@ package com.texthip.thip.ui.feed.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.texthip.thip.data.model.feeds.response.AllFeedItem +import com.texthip.thip.data.model.feeds.response.MyFeedItem import com.texthip.thip.data.model.users.response.RecentWriterList +import com.texthip.thip.data.repository.FeedRepository import com.texthip.thip.data.repository.UserRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject data class FeedUiState( - val isLoading: Boolean = true, + val selectedTabIndex: Int = 0, + val allFeeds: List = emptyList(), + val myFeeds: List = emptyList(), val recentWriters: List = emptyList(), - val errorMessage: String? = null - //TODO 추후 피드 목록 등 다른 상태들 추가될 예정 -) + val isLoading: Boolean = false, + val isRefreshing: Boolean = false, + val isLoadingMore: Boolean = false, + val isLastPageAllFeeds: Boolean = false, + val isLastPageMyFeeds: Boolean = false, + val error: String? = null +) { + val canLoadMoreAllFeeds: Boolean get() = !isLoading && !isLoadingMore && !isRefreshing && !isLastPageAllFeeds + val canLoadMoreMyFeeds: Boolean get() = !isLoading && !isLoadingMore && !isRefreshing && !isLastPageMyFeeds + val currentTabFeeds: List + get() = when (selectedTabIndex) { + 0 -> allFeeds + 1 -> myFeeds + else -> emptyList() + } + val canLoadMoreCurrentTab: Boolean + get() = when (selectedTabIndex) { + 0 -> canLoadMoreAllFeeds + 1 -> canLoadMoreMyFeeds + else -> false + } +} @HiltViewModel class FeedViewModel @Inject constructor( + private val feedRepository: FeedRepository, private val userRepository: UserRepository ) : ViewModel() { - private val _uiState = MutableStateFlow(FeedUiState()) val uiState = _uiState.asStateFlow() + private var allFeedsNextCursor: String? = null + private var myFeedsNextCursor: String? = null + private var isLoadingAllFeeds = false + private var isLoadingMyFeeds = false + + private fun updateState(update: (FeedUiState) -> FeedUiState) { + _uiState.value = update(_uiState.value) + } + init { + loadAllFeeds() fetchRecentWriters() } + + fun onTabSelected(index: Int) { + updateState { it.copy(selectedTabIndex = index) } + + when (index) { + 0 -> { + loadAllFeeds(isInitial = true) + } + + 1 -> { + loadMyFeeds(isInitial = true) + } + } + } + + private fun loadAllFeeds(isInitial: Boolean = true) { + if (isLoadingAllFeeds && !isInitial) return + if (_uiState.value.isLastPageAllFeeds && !isInitial) return + + viewModelScope.launch { + try { + isLoadingAllFeeds = true + + if (isInitial) { + updateState { + it.copy( + isLoading = true, + allFeeds = emptyList(), + isLastPageAllFeeds = false + ) + } + allFeedsNextCursor = null + } else { + updateState { it.copy(isLoadingMore = true) } + } + + val cursor = if (isInitial) null else allFeedsNextCursor + + feedRepository.getAllFeeds(cursor).onSuccess { response -> + if (response != null) { + val currentList = if (isInitial) emptyList() else _uiState.value.allFeeds + updateState { + it.copy( + allFeeds = currentList + response.feedList, + error = null, + isLastPageAllFeeds = response.isLast + ) + } + allFeedsNextCursor = response.nextCursor + } else { + updateState { it.copy(isLastPageAllFeeds = true) } + } + }.onFailure { exception -> + updateState { it.copy(error = exception.message) } + } + } finally { + isLoadingAllFeeds = false + updateState { it.copy(isLoading = false, isLoadingMore = false) } + } + } + } + + private fun loadMyFeeds(isInitial: Boolean = true) { + if (isLoadingMyFeeds && !isInitial) return + if (_uiState.value.isLastPageMyFeeds && !isInitial) return + + viewModelScope.launch { + try { + isLoadingMyFeeds = true + + if (isInitial) { + updateState { + it.copy( + isLoading = true, + myFeeds = emptyList(), + isLastPageMyFeeds = false + ) + } + myFeedsNextCursor = null + } else { + updateState { it.copy(isLoadingMore = true) } + } + + val cursor = if (isInitial) null else myFeedsNextCursor + + feedRepository.getMyFeeds(cursor).onSuccess { response -> + if (response != null) { + val currentList = if (isInitial) emptyList() else _uiState.value.myFeeds + updateState { + it.copy( + myFeeds = currentList + response.feedList, + error = null, + isLastPageMyFeeds = response.isLast + ) + } + myFeedsNextCursor = response.nextCursor + } else { + updateState { it.copy(isLastPageMyFeeds = true) } + } + }.onFailure { exception -> + updateState { it.copy(error = exception.message) } + } + } finally { + isLoadingMyFeeds = false + updateState { it.copy(isLoading = false, isLoadingMore = false) } + } + } + } + + fun refreshCurrentTab() { + viewModelScope.launch { + updateState { it.copy(isRefreshing = true) } + + when (_uiState.value.selectedTabIndex) { + 0 -> refreshAllFeeds() + 1 -> refreshMyFeeds() + } + } + } + + private suspend fun refreshAllFeeds() { + allFeedsNextCursor = null + + feedRepository.getAllFeeds().onSuccess { response -> + if (response != null) { + allFeedsNextCursor = response.nextCursor + updateState { + it.copy( + allFeeds = response.feedList, + isRefreshing = false, + isLastPageAllFeeds = response.isLast, + error = null + ) + } + } else { + updateState { + it.copy( + allFeeds = emptyList(), + isRefreshing = false, + isLastPageAllFeeds = true + ) + } + } + }.onFailure { exception -> + updateState { + it.copy( + isRefreshing = false, + error = exception.message + ) + } + } + } + + private suspend fun refreshMyFeeds() { + myFeedsNextCursor = null + + feedRepository.getMyFeeds().onSuccess { response -> + if (response != null) { + myFeedsNextCursor = response.nextCursor + updateState { + it.copy( + myFeeds = response.feedList, + isRefreshing = false, + isLastPageMyFeeds = response.isLast, + error = null + ) + } + } else { + updateState { + it.copy( + myFeeds = emptyList(), + isRefreshing = false, + isLastPageMyFeeds = true + ) + } + } + }.onFailure { exception -> + updateState { + it.copy( + isRefreshing = false, + error = exception.message + ) + } + } + } + + fun loadMoreFeeds() { + if (!_uiState.value.canLoadMoreCurrentTab || _uiState.value.isRefreshing) return + + when (_uiState.value.selectedTabIndex) { + 0 -> loadAllFeeds(isInitial = false) + 1 -> loadMyFeeds(isInitial = false) + } fun refreshData() { fetchRecentWriters() + } private fun fetchRecentWriters() { @@ -48,7 +275,7 @@ class FeedViewModel @Inject constructor( } } .onFailure { exception -> - _uiState.update { it.copy(isLoading = false, errorMessage = exception.message) } + updateState { it.copy(error = exception.message) } } } } diff --git a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteViewModel.kt b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteViewModel.kt index 7a84240f..94a05ae0 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedWriteViewModel.kt @@ -66,7 +66,7 @@ class FeedWriteViewModel @Inject constructor( updateState { it.copy(isLoadingCategories = true) } feedRepository.getFeedWriteInfo() .onSuccess { response -> - updateState { + updateState { it.copy( categories = response?.categoryList ?: emptyList(), isLoadingCategories = false @@ -74,7 +74,7 @@ class FeedWriteViewModel @Inject constructor( } } .onFailure { - updateState { + updateState { it.copy( categories = emptyList(), isLoadingCategories = false, @@ -199,9 +199,9 @@ class FeedWriteViewModel @Inject constructor( val currentState = _uiState.value val availableSlots = 3 - currentState.imageUris.size val imagesToAdd = newImageUris.take(availableSlots) - - updateState { - it.copy(imageUris = currentState.imageUris + imagesToAdd) + + updateState { + it.copy(imageUris = currentState.imageUris + imagesToAdd) } } @@ -218,7 +218,7 @@ class FeedWriteViewModel @Inject constructor( } fun selectCategory(index: Int) { - updateState { + updateState { it.copy( selectedCategoryIndex = index, selectedTags = emptyList() // 카테고리 변경 시 태그 초기화 @@ -242,8 +242,8 @@ class FeedWriteViewModel @Inject constructor( fun removeTag(tag: String) { val currentTags = _uiState.value.selectedTags - updateState { - it.copy(selectedTags = currentTags - tag) + updateState { + it.copy(selectedTags = currentTags - tag) } } @@ -272,7 +272,7 @@ class FeedWriteViewModel @Inject constructor( tagList = currentState.selectedTags, imageUris = currentState.imageUris ) - + result.onSuccess { response -> val feedId = response?.feedId if (feedId != null) { @@ -282,7 +282,8 @@ class FeedWriteViewModel @Inject constructor( } }.onFailure { exception -> onError( - exception.message ?: stringResourceProvider.getString(R.string.error_network_error) + exception.message + ?: stringResourceProvider.getString(R.string.error_network_error) ) } diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/component/SavedFeedCard.kt b/app/src/main/java/com/texthip/thip/ui/mypage/component/SavedFeedCard.kt index 53255ff0..b9032072 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/component/SavedFeedCard.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/component/SavedFeedCard.kt @@ -1,28 +1,26 @@ package com.texthip.thip.ui.mypage.component -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage import com.texthip.thip.R +import com.texthip.thip.ui.common.buttons.ActionBarButton import com.texthip.thip.ui.common.buttons.ActionBookButton import com.texthip.thip.ui.common.header.ProfileBar import com.texthip.thip.ui.mypage.mock.FeedItem @@ -34,13 +32,13 @@ import com.texthip.thip.ui.theme.ThipTheme.typography fun SavedFeedCard( modifier: Modifier = Modifier, feedItem: FeedItem, + bottomTextColor: Color = colors.NeonGreen, onBookmarkClick: () -> Unit = {}, onLikeClick: () -> Unit = {}, - onContentClick: () -> Unit = {} + onContentClick: () -> Unit = {}, + onCommentClick: () -> Unit = {} ) { - val images = feedItem.imageUrls.orEmpty().map { painterResource(id = it) } - val imagePainters = feedItem.imageUrls.orEmpty().map { painterResource(it) } - val hasImages = imagePainters.isNotEmpty() + val hasImages = feedItem.imageUrls.isNotEmpty() val maxLines = if (hasImages) 3 else 8 Column( @@ -49,9 +47,10 @@ fun SavedFeedCard( .padding(horizontal = 20.dp) ) { ProfileBar( - profileImage = feedItem.userProfileImage.toString(), + profileImage = feedItem.userProfileImage ?: "https://example.com/image1.jpg", topText = feedItem.userName, bottomText = feedItem.userRole, + bottomTextColor = bottomTextColor, showSubscriberInfo = false, hoursAgo = feedItem.timeAgo ) @@ -66,69 +65,53 @@ fun SavedFeedCard( onClick = {} ) } - Text( - text = feedItem.content, - style = typography.feedcopy_r400_s14_h20, - color = colors.White, - maxLines = maxLines, + + Column( modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp) - .clickable { onContentClick() } - ) - if (images.isNotEmpty()) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - images.take(3).forEach { image -> - Image( - painter = image, - contentDescription = null, - modifier = Modifier - .padding(end = 10.dp) - .size(100.dp), - contentScale = ContentScale.Crop - ) - } - } - } - Row( - verticalAlignment = Alignment.CenterVertically + .clickable { onContentClick() }, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { - Icon( - modifier = Modifier.clickable { onLikeClick() }, - painter = painterResource(if (feedItem.isLiked) R.drawable.ic_heart_filled else R.drawable.ic_heart), - contentDescription = null, - tint = Color.Unspecified - ) - Text( - text = feedItem.likeCount.toString(), - style = typography.feedcopy_r400_s14_h20, - color = colors.White, - modifier = Modifier.padding(start = 5.dp, end = 12.dp) - ) - Icon( - painter = painterResource(R.drawable.ic_comment), - contentDescription = null, - tint = colors.White - ) Text( - text = feedItem.commentCount.toString(), + text = feedItem.content, style = typography.feedcopy_r400_s14_h20, color = colors.White, - modifier = Modifier.padding(start = 5.dp, end = 12.dp) - ) - Spacer(modifier = Modifier.weight(1f)) - Icon( - modifier = Modifier.clickable { onBookmarkClick() }, - painter = painterResource(if (feedItem.isSaved) R.drawable.ic_save_filled else R.drawable.ic_save), - contentDescription = null, - tint = Color.Unspecified + maxLines = maxLines, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) ) + if (hasImages) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + feedItem.imageUrls.take(3).forEach { imageUrl -> + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier + .padding(end = 10.dp) + .size(100.dp), + contentScale = ContentScale.Crop + ) + } + } + } } + + ActionBarButton( + isLiked = feedItem.isLiked, + likeCount = feedItem.likeCount, + commentCount = feedItem.commentCount, + isSaveVisible = true, + isSaved = feedItem.isSaved, + onLikeClick = onLikeClick, + onCommentClick = onCommentClick, + onBookmarkClick = onBookmarkClick + ) } } @@ -137,7 +120,7 @@ fun SavedFeedCard( private fun SavedFeedCardPrev() { val feed1 = FeedItem( id = 1, - userProfileImage = R.drawable.character_literature, + userProfileImage = "https://example.com/profile1.jpg", userName = "user.01", userRole = stringResource(R.string.influencer), bookTitle = "책 제목", @@ -148,12 +131,12 @@ private fun SavedFeedCardPrev() { commentCount = 5, isLiked = false, isSaved = true, - imageUrls = null + imageUrls = emptyList() ) val feed2 = FeedItem( id = 2, - userProfileImage = R.drawable.character_art, + userProfileImage = "https://example.com/profile2.jpg", userName = "user.01", userRole = stringResource(R.string.influencer), bookTitle = "책 제목", @@ -166,9 +149,9 @@ private fun SavedFeedCardPrev() { isLiked = false, isSaved = true, imageUrls = listOf( - R.drawable.img_book_cover_sample, - R.drawable.img_book_cover_sample, - R.drawable.img_book_cover_sample + "https://example.com/image1.jpg", + "https://example.com/image2.jpg", + "https://example.com/image3.jpg" ) ) val scrollState = rememberScrollState() diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/mock/FeedItem.kt b/app/src/main/java/com/texthip/thip/ui/mypage/mock/FeedItem.kt index 99009098..32c75942 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/mock/FeedItem.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/mock/FeedItem.kt @@ -2,7 +2,7 @@ package com.texthip.thip.ui.mypage.mock data class FeedItem( val id: Int, - val userProfileImage: Int? = null, + val userProfileImage: String? = null, val userName: String, val userRole: String, val bookTitle: String, @@ -15,6 +15,6 @@ data class FeedItem( val isSaved: Boolean, val isLocked: Boolean = false, val tags: List = emptyList(), - val imageUrls: List? = emptyList() + val imageUrls: List = emptyList() ) diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/SavedFeedViewModel.kt b/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/SavedFeedViewModel.kt index 056d05fe..7cb2229c 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/SavedFeedViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/SavedFeedViewModel.kt @@ -11,7 +11,7 @@ open class SavedFeedViewModel: ViewModel() { listOf( FeedItem( id = 1, - userProfileImage = R.drawable.character_art, + userProfileImage = "https://example.com/profile.jpg", userName = "user", userRole = "학생", bookTitle = "라랄ㄹ라라", @@ -25,7 +25,7 @@ open class SavedFeedViewModel: ViewModel() { ), FeedItem( id = 2, - userProfileImage = R.drawable.character_art, + userProfileImage = "https://example.com/profile.jpg", userName = "user", userRole = "학생", bookTitle = "라랄ㄹ라라", @@ -36,11 +36,11 @@ open class SavedFeedViewModel: ViewModel() { commentCount = 4, isLiked = false, isSaved = true, - imageUrls = null + imageUrls = emptyList() ), FeedItem( id = 3, - userProfileImage = R.drawable.character_art, + userProfileImage = "https://example.com/profile.jpg", userName = "user", userRole = "학생", bookTitle = "라랄ㄹ라라", @@ -51,11 +51,11 @@ open class SavedFeedViewModel: ViewModel() { commentCount = 4, isLiked = false, isSaved = true, - imageUrls = listOf(R.drawable.img_book_cover_sample) + imageUrls = listOf("https://example.com/image.jpg") ), FeedItem( id = 4, - userProfileImage = R.drawable.character_art, + userProfileImage = "https://example.com/profile.jpg", userName = "user", userRole = "학생", bookTitle = "책이름책이름", @@ -66,11 +66,11 @@ open class SavedFeedViewModel: ViewModel() { commentCount = 4, isLiked = false, isSaved = true, - imageUrls = listOf(R.drawable.img_book_cover_sample) + imageUrls = listOf("https://example.com/image.jpg") ), FeedItem( id = 5, - userProfileImage = R.drawable.character_art, + userProfileImage = "https://example.com/profile.jpg", userName = "user", userRole = "학생", bookTitle = "책이름책이름", diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/FeedNavigationExtensions.kt b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/FeedNavigationExtensions.kt index f4d82e6e..2b0fcdfa 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/FeedNavigationExtensions.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/FeedNavigationExtensions.kt @@ -15,6 +15,14 @@ fun NavHostController.navigateToMySubscription() { } // 피드 작성으로 +fun NavHostController.navigateToFeedWrite() { + navigate(FeedRoutes.Write) +} + +// 피드 댓글으로 +fun NavHostController.navigateToFeedComment(feedId: Int) { + navigate(FeedRoutes.Comment(feedId)) + fun NavHostController.navigateToFeedWrite( isbn: String? = null, bookTitle: String? = null, @@ -34,4 +42,5 @@ fun NavHostController.navigateToFeedWrite( // 유저 프로필(피드)로 fun NavHostController.navigateToUserProfile(userId: Long) { navigate(FeedRoutes.Others(userId)) + } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/FeedNavigation.kt b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/FeedNavigation.kt index 16635c0b..3d2e4f93 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/FeedNavigation.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/FeedNavigation.kt @@ -5,6 +5,11 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable +import com.texthip.thip.ui.feed.screen.FeedCommentScreen +import com.texthip.thip.ui.feed.screen.FeedScreen +import com.texthip.thip.ui.feed.screen.FeedWriteScreen +import com.texthip.thip.ui.feed.screen.MySubscriptionScreen +import com.texthip.thip.ui.navigator.extensions.navigateToFeedComment import androidx.navigation.toRoute import com.texthip.thip.ui.feed.screen.FeedOthersScreen import com.texthip.thip.ui.feed.screen.FeedScreen @@ -37,6 +42,9 @@ fun NavGraphBuilder.feedNavigation(navController: NavHostController, navigateBac }, onNavigateToFeedWrite = { navController.navigateToFeedWrite() + }, + onNavigateToFeedComment = { feedId -> + navController.navigateToFeedComment(feedId) } ) } @@ -89,4 +97,16 @@ fun NavGraphBuilder.feedNavigation(navController: NavHostController, navigateBac } ) } + composable { backStackEntry -> + val route = backStackEntry.arguments?.let { + FeedRoutes.Comment(it.getInt("feedId")) + } ?: return@composable + + FeedCommentScreen( + feedId = route.feedId, + onNavigateBack = { + navController.popBackStack() + } + ) + } } \ No newline at end of file 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 3ed5e23b..2969393b 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/routes/FeedRoutes.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/routes/FeedRoutes.kt @@ -4,7 +4,13 @@ import kotlinx.serialization.Serializable @Serializable sealed class FeedRoutes : Routes() { + @Serializable data object MySubscription : FeedRoutes() + + @Serializable data object Write : FeedRoutes() + + @Serializable data class Comment(val feedId: Int) : FeedRoutes() + @Serializable data class Write( val isbn: String? = null,