diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6d9336ac..79bce4b1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,10 +1,11 @@ - + + @SerialName("bookList") val bookList: List = emptyList() ) @Serializable data class BookSavedResponse( - @SerialName("isbn") val isbn: String, - @SerialName("bookTitle") val bookTitle: String, - @SerialName("authorName") val authorName: String, - @SerialName("publisher") val publisher: String, - @SerialName("imageUrl") val imageUrl: String? + @SerialName("isbn") val isbn: String = "", + @SerialName("bookTitle") val bookTitle: String = "", + @SerialName("authorName") val authorName: String = "", + @SerialName("publisher") val publisher: String = "", + @SerialName("imageUrl") val imageUrl: String? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/book/response/BookSaveResponse.kt b/app/src/main/java/com/texthip/thip/data/model/book/response/BookSaveResponse.kt new file mode 100644 index 00000000..29ae456c --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/book/response/BookSaveResponse.kt @@ -0,0 +1,10 @@ +package com.texthip.thip.data.model.book.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BookSaveResponse( + @SerialName("isbn") val isbn: String = "", + @SerialName("isSaved") val isSaved: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/book/response/BookSearchResponse.kt b/app/src/main/java/com/texthip/thip/data/model/book/response/BookSearchResponse.kt new file mode 100644 index 00000000..ef835215 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/book/response/BookSearchResponse.kt @@ -0,0 +1,24 @@ +package com.texthip.thip.data.model.book.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BookSearchResponse( + @SerialName("searchResult") val searchResult: List = emptyList(), + @SerialName("page") val page: Int = 0, + @SerialName("size") val size: Int = 0, + @SerialName("totalElements") val totalElements: Int = 0, + @SerialName("totalPages") val totalPages: Int = 0, + @SerialName("last") val last: Boolean = true, + @SerialName("first") val first: Boolean = true +) + +@Serializable +data class BookSearchItem( + @SerialName("title") val title: String = "", + @SerialName("imageUrl") val imageUrl: String? = null, + @SerialName("authorName") val authorName: String = "", + @SerialName("publisher") val publisher: String = "", + @SerialName("isbn") val isbn: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/book/response/MostSearchedBooksResponse.kt b/app/src/main/java/com/texthip/thip/data/model/book/response/MostSearchedBooksResponse.kt new file mode 100644 index 00000000..cc37ffce --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/book/response/MostSearchedBooksResponse.kt @@ -0,0 +1,17 @@ +package com.texthip.thip.data.model.book.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MostSearchedBooksResponse( + @SerialName("bookList") val bookList: List = emptyList() +) + +@Serializable +data class PopularBookItem( + @SerialName("rank") val rank: Int = 0, + @SerialName("title") val title: String = "", + @SerialName("imageUrl") val imageUrl: String? = null, + @SerialName("isbn") val isbn: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/book/response/RecentSearchResponse.kt b/app/src/main/java/com/texthip/thip/data/model/book/response/RecentSearchResponse.kt new file mode 100644 index 00000000..f0266204 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/book/response/RecentSearchResponse.kt @@ -0,0 +1,15 @@ +package com.texthip.thip.data.model.book.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RecentSearchResponse( + @SerialName("recentSearchList") val recentSearchList: List = emptyList() +) + +@Serializable +data class RecentSearchItem( + @SerialName("recentSearchId") val recentSearchId: Int = 0, + @SerialName("searchTerm") val searchTerm: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/book/response/RecruitingRoomsResponse.kt b/app/src/main/java/com/texthip/thip/data/model/book/response/RecruitingRoomsResponse.kt new file mode 100644 index 00000000..8dae69fe --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/book/response/RecruitingRoomsResponse.kt @@ -0,0 +1,22 @@ +package com.texthip.thip.data.model.book.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RecruitingRoomsResponse( + @SerialName("recruitingRoomList") val recruitingRoomList: List = emptyList(), + @SerialName("totalRoomCount") val totalRoomCount: Int = 0, + @SerialName("nextCursor") val nextCursor: String? = null, + @SerialName("isLast") val isLast: Boolean = true +) + +@Serializable +data class RecruitingRoomItem( + @SerialName("roomId") val roomId: Int = 0, + @SerialName("bookImageUrl") val bookImageUrl: String? = null, + @SerialName("roomName") val roomName: String = "", + @SerialName("memberCount") val memberCount: Int = 0, + @SerialName("recruitCount") val recruitCount: Int = 0, + @SerialName("deadlineEndDate") val deadlineEndDate: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/group/response/JoinedRoomListResponse.kt b/app/src/main/java/com/texthip/thip/data/model/group/response/JoinedRoomListResponse.kt index c5fb0eb6..9ad032ea 100644 --- a/app/src/main/java/com/texthip/thip/data/model/group/response/JoinedRoomListResponse.kt +++ b/app/src/main/java/com/texthip/thip/data/model/group/response/JoinedRoomListResponse.kt @@ -18,7 +18,7 @@ data class JoinedRoomListResponse( data class JoinedRoomResponse( @SerialName("roomId") val roomId: Int, @SerialName("bookImageUrl") val bookImageUrl: String?, - @SerialName("bookTitle") val bookTitle: String, + @SerialName("roomTitle") val roomTitle: String, @SerialName("memberCount") val memberCount: Int, @SerialName("userPercentage") val userPercentage: Int ) diff --git a/app/src/main/java/com/texthip/thip/data/repository/BookRepository.kt b/app/src/main/java/com/texthip/thip/data/repository/BookRepository.kt index 5641421f..8e09405c 100644 --- a/app/src/main/java/com/texthip/thip/data/repository/BookRepository.kt +++ b/app/src/main/java/com/texthip/thip/data/repository/BookRepository.kt @@ -1,6 +1,14 @@ package com.texthip.thip.data.repository import com.texthip.thip.data.model.base.handleBaseResponse +import com.texthip.thip.data.model.book.request.BookSaveRequest +import com.texthip.thip.data.model.book.response.BookDetailResponse +import com.texthip.thip.data.model.book.response.BookSaveResponse +import com.texthip.thip.data.model.book.response.BookSavedResponse +import com.texthip.thip.data.model.book.response.BookSearchResponse +import com.texthip.thip.data.model.book.response.MostSearchedBooksResponse +import com.texthip.thip.data.model.book.response.RecentSearchResponse +import com.texthip.thip.data.model.book.response.RecruitingRoomsResponse import com.texthip.thip.data.service.BookService import javax.inject.Inject import javax.inject.Singleton @@ -11,10 +19,59 @@ class BookRepository @Inject constructor( ) { /** 저장된 책 또는 모임 책 목록 조회 */ - suspend fun getBooks(type: String) = runCatching { + suspend fun getBooks(type: String): Result> = runCatching { bookService.getBooks(type) .handleBaseResponse() .getOrThrow() ?.bookList ?: emptyList() } + + /** 책 검색 */ + suspend fun searchBooks(keyword: String, page: Int = 1, isFinalized: Boolean = false): Result = runCatching { + bookService.searchBooks(keyword, page, isFinalized) + .handleBaseResponse() + .getOrThrow() + } + + /** 인기 책 조회 */ + suspend fun getMostSearchedBooks(): Result = runCatching { + bookService.getMostSearchedBooks() + .handleBaseResponse() + .getOrThrow() + } + + /** 최근 검색어 조회 */ + suspend fun getRecentSearches(type: String = "BOOK"): Result = runCatching { + bookService.getRecentSearches(type) + .handleBaseResponse() + .getOrThrow() + } + + /** 최근 검색어 삭제 */ + suspend fun deleteRecentSearch(recentSearchId: Int): Result = runCatching { + bookService.deleteRecentSearch(recentSearchId) + .handleBaseResponse() + .getOrThrow() + } + + /** 책 상세 조회 */ + suspend fun getBookDetail(isbn: String): Result = runCatching { + bookService.getBookDetail(isbn) + .handleBaseResponse() + .getOrThrow() + } + + /** 책 저장/저장취소 */ + suspend fun saveBook(isbn: String, type: Boolean): Result = runCatching { + bookService.saveBook(isbn, BookSaveRequest(type)) + .handleBaseResponse() + .getOrThrow() + } + + /** 모집중인 방 조회 */ + suspend fun getRecruitingRooms(isbn: String, cursor: String? = null): Result = runCatching { + bookService.getRecruitingRooms(isbn, cursor) + .handleBaseResponse() + .getOrThrow() + } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/repository/GroupRepository.kt b/app/src/main/java/com/texthip/thip/data/repository/GroupRepository.kt index a5c04326..e808a3c2 100644 --- a/app/src/main/java/com/texthip/thip/data/repository/GroupRepository.kt +++ b/app/src/main/java/com/texthip/thip/data/repository/GroupRepository.kt @@ -65,23 +65,26 @@ class GroupRepository @Inject constructor( suspend fun getRoomRecruiting(roomId: Int): Result = runCatching { groupService.getRoomRecruiting(roomId) .handleBaseResponse() - .getOrThrow()!! + .getOrThrow() + ?: throw NoSuchElementException("모집중인 모임방 정보를 찾을 수 없습니다.") } /** 새 모임방 생성 */ suspend fun createRoom(request: CreateRoomRequest): Result = runCatching { - groupService.createRoom(request) + val response = groupService.createRoom(request) .handleBaseResponse() - .getOrThrow()!! - .roomId + .getOrThrow() + ?: throw NoSuchElementException("모임방 생성 응답을 받을 수 없습니다.") + response.roomId } /** 모임방 참여 또는 취소 */ suspend fun joinOrCancelRoom(roomId: Int, type: String): Result = runCatching { val request = RoomJoinRequest(type = type) - groupService.joinOrCancelRoom(roomId, request) + val response = groupService.joinOrCancelRoom(roomId, request) .handleBaseResponse() - .getOrThrow()!! - .type + .getOrThrow() + ?: throw NoSuchElementException("모임방 참여/취소 응답을 받을 수 없습니다.") + response.type } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/service/BookService.kt b/app/src/main/java/com/texthip/thip/data/service/BookService.kt index e167a521..b674619a 100644 --- a/app/src/main/java/com/texthip/thip/data/service/BookService.kt +++ b/app/src/main/java/com/texthip/thip/data/service/BookService.kt @@ -1,8 +1,19 @@ package com.texthip.thip.data.service import com.texthip.thip.data.model.base.BaseResponse +import com.texthip.thip.data.model.book.request.BookSaveRequest +import com.texthip.thip.data.model.book.response.BookDetailResponse import com.texthip.thip.data.model.book.response.BookListResponse +import com.texthip.thip.data.model.book.response.BookSaveResponse +import com.texthip.thip.data.model.book.response.BookSearchResponse +import com.texthip.thip.data.model.book.response.MostSearchedBooksResponse +import com.texthip.thip.data.model.book.response.RecentSearchResponse +import com.texthip.thip.data.model.book.response.RecruitingRoomsResponse +import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path import retrofit2.http.Query interface BookService { @@ -12,4 +23,48 @@ interface BookService { suspend fun getBooks( @Query("type") type: String ): BaseResponse + + /** 책 검색 */ + @GET("books") + suspend fun searchBooks( + @Query("keyword") keyword: String, + @Query("page") page: Int = 1, + @Query("isFinalized") isFinalized: Boolean = false + ): BaseResponse + + /** 인기 책 조회 */ + @GET("books/most-searched") + suspend fun getMostSearchedBooks(): BaseResponse + + /** 최근 검색어 조회 */ + @GET("recent-searches") + suspend fun getRecentSearches( + @Query("type") type: String + ): BaseResponse + + /** 최근 검색어 삭제 */ + @DELETE("recent-searches/{recentSearchId}") + suspend fun deleteRecentSearch( + @Path("recentSearchId") recentSearchId: Int + ): BaseResponse + + /** 책 상세 조회 */ + @GET("books/{isbn}") + suspend fun getBookDetail( + @Path("isbn") isbn: String + ): BaseResponse + + /** 책 저장/저장취소 */ + @POST("books/{isbn}/saved") + suspend fun saveBook( + @Path("isbn") isbn: String, + @Body request: BookSaveRequest + ): BaseResponse + + /** 모집중인 방 조회 */ + @GET("books/{isbn}/recruiting-rooms") + suspend fun getRecruitingRooms( + @Path("isbn") isbn: String, + @Query("cursor") cursor: String? = null + ): BaseResponse } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/common/buttons/GenreChipButton.kt b/app/src/main/java/com/texthip/thip/ui/common/buttons/GenreChipButton.kt index 772b7838..95aa52db 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/buttons/GenreChipButton.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/buttons/GenreChipButton.kt @@ -8,6 +8,7 @@ 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -16,9 +17,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.texthip.thip.R @@ -34,16 +35,19 @@ fun GenreChipButton( ) { Box( modifier = modifier + .height(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(color = Color.Transparent, shape = RoundedCornerShape(12.dp)) + .clickable { + onClick() + } .border( width = 1.dp, color = colors.Grey02, shape = RoundedCornerShape(20.dp) ) - .background(color = Color.Transparent, shape = RoundedCornerShape(12.dp)) - .padding(top = 8.dp, bottom = 8.dp, end = 8.dp, start = 12.dp) - .clickable { - onClick() - }, + .padding(top = 8.dp, bottom = 8.dp, end = 8.dp, start = 12.dp), + contentAlignment = Alignment.Center, ) { Row( @@ -60,10 +64,10 @@ fun GenreChipButton( contentDescription = null, tint = Color.Unspecified, modifier = Modifier - .size(20.dp) .clickable { onCloseClick() } + .size(20.dp) ) } } @@ -79,7 +83,7 @@ private fun GenreChipButtonPreview() { verticalArrangement = Arrangement.spacedBy(30.dp, Alignment.CenterVertically), ) { GenreChipButton( - text = stringResource(R.string.essay), + text = "책", ) } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/common/screen/RegisterBookScreen.kt b/app/src/main/java/com/texthip/thip/ui/common/screen/RegisterBookScreen.kt index 819328e2..5e59b62c 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/screen/RegisterBookScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/screen/RegisterBookScreen.kt @@ -19,7 +19,10 @@ import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography @Composable -fun RegisterBookScreen(modifier: Modifier = Modifier) { +fun RegisterBookScreen( + modifier: Modifier = Modifier, + onLeftClick: () -> Unit = {} +) { Column( modifier = modifier .fillMaxSize(), @@ -28,7 +31,7 @@ fun RegisterBookScreen(modifier: Modifier = Modifier) { ) { DefaultTopAppBar( title = stringResource(R.string.group_request_book), - onLeftClick = {}, + onLeftClick = onLeftClick, ) Column ( modifier = Modifier diff --git a/app/src/main/java/com/texthip/thip/ui/group/done/screen/GroupDoneScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/done/screen/GroupDoneScreen.kt index 32f8d9ca..6bb1dcef 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/done/screen/GroupDoneScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/done/screen/GroupDoneScreen.kt @@ -61,16 +61,16 @@ fun GroupDoneContent( val listState = rememberLazyListState() // 무한 스크롤을 위한 로직 - val shouldLoadMore by remember { + val shouldLoadMore by remember(uiState.canLoadMore, uiState.isLoadingMore) { derivedStateOf { val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 val totalItems = listState.layoutInfo.totalItemsCount - lastVisibleIndex >= totalItems - 3 // 마지막 3개 아이템에 도달했을 때 + uiState.canLoadMore && !uiState.isLoadingMore && totalItems > 0 && lastVisibleIndex >= totalItems - 3 } } LaunchedEffect(shouldLoadMore) { - if (shouldLoadMore && uiState.canLoadMore) { + if (shouldLoadMore) { onLoadMore() } } diff --git a/app/src/main/java/com/texthip/thip/ui/group/done/viewmodel/GroupDoneViewModel.kt b/app/src/main/java/com/texthip/thip/ui/group/done/viewmodel/GroupDoneViewModel.kt index f76b7678..338cb28e 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/done/viewmodel/GroupDoneViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/done/viewmodel/GroupDoneViewModel.kt @@ -78,6 +78,10 @@ class GroupDoneViewModel @Inject constructor( } nextCursor = response.nextCursor isLastPage = response.isLast + } ?: run { + // null 응답 시 더 이상 로드할 수 없음을 명시 + updateState { it.copy(hasMore = false, isLoadingMore = false) } + isLastPage = true } } .onFailure { exception -> diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupSelectBook.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupSelectBook.kt index 98115678..dea635ba 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupSelectBook.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupSelectBook.kt @@ -37,6 +37,7 @@ fun GroupSelectBook( onChangeBookClick: () -> Unit, onSelectBookClick: () -> Unit, modifier: Modifier = Modifier, + isBookPreselected: Boolean = false ) { Column( modifier = modifier.fillMaxWidth(), @@ -117,11 +118,13 @@ fun GroupSelectBook( ) } } - OptionChipButton( - text = stringResource(R.string.change), - onClick = onChangeBookClick, - isSelected = false - ) + if (!isBookPreselected) { + OptionChipButton( + text = stringResource(R.string.change), + onClick = onChangeBookClick, + isSelected = false + ) + } } } } @@ -157,3 +160,16 @@ fun GroupSelectBookPreview_Selected() { ) } } + +@Preview(showBackground = true) +@Composable +fun GroupSelectBookPreview_Preselected() { + ThipTheme { + GroupSelectBook( + selectedBook = dummyBook, + onChangeBookClick = {}, + onSelectBookClick = {}, + isBookPreselected = true + ) + } +} diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/screen/GroupMakeRoomScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/screen/GroupMakeRoomScreen.kt index a4fc96ff..5c86173b 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/makeroom/screen/GroupMakeRoomScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/screen/GroupMakeRoomScreen.kt @@ -137,7 +137,8 @@ fun GroupMakeRoomContent( GroupSelectBook( selectedBook = uiState.selectedBook, onChangeBookClick = { onToggleBookSearchSheet(true) }, - onSelectBookClick = { onToggleBookSearchSheet(true) } + onSelectBookClick = { onToggleBookSearchSheet(true) }, + isBookPreselected = uiState.isBookPreselected ) SectionDivider() diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/viewmodel/GroupMakeRoomUiState.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/viewmodel/GroupMakeRoomUiState.kt index 9e95010a..25104279 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/makeroom/viewmodel/GroupMakeRoomUiState.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/viewmodel/GroupMakeRoomUiState.kt @@ -22,7 +22,8 @@ data class GroupMakeRoomUiState( val savedBooks: List = emptyList(), val groupBooks: List = emptyList(), val isLoadingBooks: Boolean = false, - val genres: List = emptyList() + val genres: List = emptyList(), + val isBookPreselected: Boolean = false ) { // 유효성 검사 로직 val isDurationValid: Boolean diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/viewmodel/GroupMakeRoomViewModel.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/viewmodel/GroupMakeRoomViewModel.kt index 4652e06b..04d910a3 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/makeroom/viewmodel/GroupMakeRoomViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/viewmodel/GroupMakeRoomViewModel.kt @@ -54,6 +54,21 @@ class GroupMakeRoomViewModel @Inject constructor( fun selectBook(book: BookData) { updateState { it.copy(selectedBook = book) } } + + fun setPreselectedBook(isbn: String, title: String, imageUrl: String, author: String) { + val preselectedBook = BookData( + title = title, + imageUrl = imageUrl, + author = author, + isbn = isbn + ) + updateState { + it.copy( + selectedBook = preselectedBook, + isBookPreselected = true + ) + } + } fun toggleBookSearchSheet(show: Boolean) { updateState { it.copy(showBookSearchSheet = show) } diff --git a/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GroupMainCard.kt b/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GroupMainCard.kt index 0a531efc..46ffe402 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GroupMainCard.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GroupMainCard.kt @@ -90,7 +90,7 @@ fun GroupMainCard( Spacer(Modifier.height(2.dp)) // 제목 Text( - text = data.bookTitle, + text = data.roomTitle, style = typography.smalltitle_sb600_s18_h24, color = colors.Black, maxLines = 1 @@ -171,7 +171,7 @@ fun PreviewMyGroupMainCard() { GroupMainCard( data = JoinedRoomResponse( roomId = 1, - bookTitle = "호르몬 체인지 완독하는 방", + roomTitle = "호르몬 체인지 완독하는 방", memberCount = 22, bookImageUrl = "https://picsum.photos/300/200?1", userPercentage = 40 diff --git a/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GroupPager.kt b/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GroupPager.kt index 8e3c2d42..4716683b 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GroupPager.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GroupPager.kt @@ -143,21 +143,21 @@ fun PreviewMyGroupPager() { val list = listOf( JoinedRoomResponse( roomId = 1, - bookTitle = "호르몬 체인지 완독하는 방", + roomTitle = "호르몬 체인지 완독하는 방", memberCount = 22, bookImageUrl = "https://picsum.photos/300/200?1", userPercentage = 40 ), JoinedRoomResponse( roomId = 2, - bookTitle = "명작 읽기방", + roomTitle = "명작 읽기방", memberCount = 10, bookImageUrl = "https://picsum.photos/300/200?2", userPercentage = 70 ), JoinedRoomResponse( roomId = 3, - bookTitle = "또 다른 방", + roomTitle = "또 다른 방", memberCount = 13, bookImageUrl = "https://picsum.photos/300/200?3", userPercentage = 10 @@ -174,7 +174,7 @@ fun PreviewSingleGroupPager() { val single = listOf( JoinedRoomResponse( roomId = 4, - bookTitle = "단일 그룹", + roomTitle = "단일 그룹", memberCount = 15, bookImageUrl = "https://picsum.photos/300/200?4", userPercentage = 60 diff --git a/app/src/main/java/com/texthip/thip/ui/group/myroom/screen/GroupMyScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/myroom/screen/GroupMyScreen.kt index 011b68f9..b05f0d0e 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/myroom/screen/GroupMyScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/myroom/screen/GroupMyScreen.kt @@ -71,16 +71,16 @@ fun GroupMyContent( val listState = rememberLazyListState() // 무한 스크롤 로직 - val shouldLoadMore by remember { + val shouldLoadMore by remember(uiState.canLoadMore, uiState.isLoadingMore) { derivedStateOf { val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 val totalItems = listState.layoutInfo.totalItemsCount - lastVisibleIndex >= totalItems - 3 + uiState.canLoadMore && !uiState.isLoadingMore && totalItems > 0 && lastVisibleIndex >= totalItems - 3 } } LaunchedEffect(shouldLoadMore) { - if (shouldLoadMore && uiState.canLoadMore) { + if (shouldLoadMore) { onLoadMore() } } diff --git a/app/src/main/java/com/texthip/thip/ui/group/myroom/viewmodel/GroupMyViewModel.kt b/app/src/main/java/com/texthip/thip/ui/group/myroom/viewmodel/GroupMyViewModel.kt index 6b8d79df..f34aabdc 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/myroom/viewmodel/GroupMyViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/myroom/viewmodel/GroupMyViewModel.kt @@ -60,6 +60,10 @@ class GroupMyViewModel @Inject constructor( } nextCursor = response.nextCursor isLastPage = response.isLast + } ?: run { + // null 응답 시 더 이상 로드할 수 없음을 명시 + updateState { it.copy(hasMore = false) } + isLastPage = true } } .onFailure { exception -> diff --git a/app/src/main/java/com/texthip/thip/ui/group/room/screen/GroupRoomRecruitScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/room/screen/GroupRoomRecruitScreen.kt index ba5fe966..85cddd88 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/room/screen/GroupRoomRecruitScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/room/screen/GroupRoomRecruitScreen.kt @@ -14,7 +14,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -100,6 +102,7 @@ fun GroupRoomRecruitContent( onHideToast: () -> Unit = {} ) { val context = LocalContext.current + val scrollState = rememberScrollState() Box(Modifier.fillMaxSize()) { // 로딩 상태 @@ -160,6 +163,7 @@ fun GroupRoomRecruitContent( Modifier .fillMaxSize() .padding(start = 20.dp, end = 20.dp, bottom = 20.dp) + .verticalScroll(scrollState) ) { Row( verticalAlignment = Alignment.CenterVertically diff --git a/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt index c49d0723..76bf820f 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt @@ -193,21 +193,21 @@ fun PreviewGroupScreen() { com.texthip.thip.data.model.group.response.JoinedRoomResponse( roomId = 1, bookImageUrl = "https://picsum.photos/300/400?joined1", - bookTitle = "미드나이트 라이브러리", + roomTitle = "미드나이트 라이브러리", memberCount = 18, userPercentage = 75 ), com.texthip.thip.data.model.group.response.JoinedRoomResponse( roomId = 2, bookImageUrl = "https://picsum.photos/300/400?joined2", - bookTitle = "코스모스", + roomTitle = "코스모스", memberCount = 25, userPercentage = 42 ), com.texthip.thip.data.model.group.response.JoinedRoomResponse( roomId = 3, bookImageUrl = "https://picsum.photos/300/400?joined3", - bookTitle = "사피엔스", + roomTitle = "사피엔스", memberCount = 15, userPercentage = 88 ) diff --git a/app/src/main/java/com/texthip/thip/ui/group/viewmodel/GroupViewModel.kt b/app/src/main/java/com/texthip/thip/ui/group/viewmodel/GroupViewModel.kt index 663e322b..861dc8c1 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/viewmodel/GroupViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/viewmodel/GroupViewModel.kt @@ -89,6 +89,9 @@ class GroupViewModel @Inject constructor( } loadedPagesCount++ currentMyGroupsPage = page + 1 + } ?: run { + // null 응답 시 더 이상 로드할 수 없음을 명시 + updateState { it.copy(hasMoreMyGroups = false) } } } .onFailure { diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/CommonNavigationExtensions.kt b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/CommonNavigationExtensions.kt index 1e16cea8..e5fa7e4f 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/CommonNavigationExtensions.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/CommonNavigationExtensions.kt @@ -2,6 +2,7 @@ package com.texthip.thip.ui.navigator.extensions import androidx.navigation.NavDestination import androidx.navigation.NavHostController +import com.texthip.thip.ui.navigator.routes.CommonRoutes import com.texthip.thip.ui.navigator.routes.MainTabRoutes @@ -35,4 +36,8 @@ fun NavDestination.isRoute(targetRoute: MainTabRoutes): Boolean { return route == targetRoute::class.qualifiedName } +// 공통 화면 네비게이션 +fun NavHostController.navigateToRegisterBook() { + navigate(CommonRoutes.RegisterBook) +} diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/GroupNavigationExtensions.kt b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/GroupNavigationExtensions.kt index 2a6151ca..5757bbef 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/GroupNavigationExtensions.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/GroupNavigationExtensions.kt @@ -16,6 +16,21 @@ fun NavHostController.navigateToGroupMakeRoom() { navigate(GroupRoutes.MakeRoom) } +// 책 정보가 미리 선택된 모임방 만들기 화면으로 이동 +fun NavHostController.navigateToGroupMakeRoomWithBook( + isbn: String, + title: String, + imageUrl: String, + author: String +) { + navigate(GroupRoutes.MakeRoomWithBook( + isbn = isbn, + title = title, + imageUrl = imageUrl, + author = author + )) +} + // 완료된 모임방 목록으로 이동 fun NavHostController.navigateToGroupDone() { navigate(GroupRoutes.Done) diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/SearchNavigationExtensions.kt b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/SearchNavigationExtensions.kt index aa993a8f..1770dbf8 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/SearchNavigationExtensions.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/SearchNavigationExtensions.kt @@ -2,9 +2,18 @@ package com.texthip.thip.ui.navigator.extensions import androidx.navigation.NavHostController import com.texthip.thip.ui.navigator.routes.MainTabRoutes +import com.texthip.thip.ui.navigator.routes.SearchRoutes // Search 관련 네비게이션 확장 함수들 fun NavHostController.navigateToSearch() { navigate(MainTabRoutes.Search) +} + +fun NavHostController.navigateToBookDetail(isbn: String) { + navigate(SearchRoutes.BookDetail(isbn = isbn)) +} + +fun NavHostController.navigateToBookGroup(isbn: String) { + navigate(SearchRoutes.BookGroup(isbn = isbn)) } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/CommonNavigation.kt b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/CommonNavigation.kt index d2cd398c..734373c7 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/CommonNavigation.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/CommonNavigation.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import com.texthip.thip.ui.common.alarmpage.screen.AlarmScreen import com.texthip.thip.ui.common.alarmpage.viewmodel.AlarmViewModel +import com.texthip.thip.ui.common.screen.RegisterBookScreen import com.texthip.thip.ui.navigator.routes.CommonRoutes // Common 관련 네비게이션 @@ -26,4 +27,11 @@ fun NavGraphBuilder.commonNavigation( onNavigateBack = navigateBack ) } + + // 책 요청 화면 + composable { + RegisterBookScreen( + onLeftClick = navigateBack + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/GroupNavigation.kt b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/GroupNavigation.kt index 9fe1a3e6..d3b9b7e0 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/GroupNavigation.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/GroupNavigation.kt @@ -95,6 +95,32 @@ fun NavGraphBuilder.groupNavigation( ) } + // Group MakeRoom 화면 (책 정보 미리 선택됨) + composable { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel: GroupMakeRoomViewModel = hiltViewModel() + + // 책 정보를 ViewModel에 미리 설정 + LaunchedEffect(route) { + viewModel.setPreselectedBook( + isbn = route.isbn, + title = route.title, + imageUrl = route.imageUrl, + author = route.author + ) + } + + GroupMakeRoomScreen( + viewModel = viewModel, + onNavigateBack = { + navigateBack() + }, + onGroupCreated = { + navigateBack() + } + ) + } + // Group Done 화면 composable { GroupDoneScreen( 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 26558e63..71a0f77b 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 @@ -3,11 +3,71 @@ package com.texthip.thip.ui.navigator.navigations import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController 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.navigateToGroupMakeRoomWithBook +import com.texthip.thip.ui.navigator.extensions.navigateToGroupRecruit +import com.texthip.thip.ui.navigator.extensions.navigateToRegisterBook import com.texthip.thip.ui.navigator.routes.MainTabRoutes +import com.texthip.thip.ui.navigator.routes.SearchRoutes import com.texthip.thip.ui.search.screen.SearchBookScreen +import com.texthip.thip.ui.search.screen.SearchBookDetailScreen +import com.texthip.thip.ui.search.screen.SearchBookGroupScreen fun NavGraphBuilder.searchNavigation(navController: NavHostController) { composable { - SearchBookScreen(navController = navController) + SearchBookScreen( + onBookClick = { isbn -> + navController.navigateToBookDetail(isbn) + }, + onRequestBook = { + navController.navigateToRegisterBook() + } + ) + } + + composable { backStackEntry -> + val route = backStackEntry.toRoute() + val isbn = route.isbn + + SearchBookDetailScreen( + isbn = isbn, + onLeftClick = { + navController.popBackStack() + }, + onRightClick = { + // TODO: 우측 버튼 액션 구현 + }, + onRecruitingGroupClick = { + navController.navigateToBookGroup(isbn) + }, + onWriteFeedClick = { + // TODO: 피드 작성 화면으로 이동 + } + ) + } + + composable { backStackEntry -> + val route = backStackEntry.toRoute() + val isbn = route.isbn + + SearchBookGroupScreen( + isbn = isbn, + onLeftClick = { + navController.popBackStack() + }, + onCardClick = { roomId -> + navController.navigateToGroupRecruit(roomId) + }, + onCreateRoomClick = { isbn, title, imageUrl, author -> + navController.navigateToGroupMakeRoomWithBook( + isbn = isbn, + title = title, + imageUrl = imageUrl, + author = author + ) + } + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/routes/CommonRoutes.kt b/app/src/main/java/com/texthip/thip/ui/navigator/routes/CommonRoutes.kt index 623bb43f..8afc059b 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/routes/CommonRoutes.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/routes/CommonRoutes.kt @@ -6,4 +6,7 @@ import kotlinx.serialization.Serializable sealed class CommonRoutes : Routes() { @Serializable data object Alarm : CommonRoutes() + + @Serializable + data object RegisterBook : CommonRoutes() } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/routes/GroupRoutes.kt b/app/src/main/java/com/texthip/thip/ui/navigator/routes/GroupRoutes.kt index 533bf084..594553db 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/routes/GroupRoutes.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/routes/GroupRoutes.kt @@ -7,6 +7,14 @@ sealed class GroupRoutes : Routes() { @Serializable data object MakeRoom : GroupRoutes() + @Serializable + data class MakeRoomWithBook( + val isbn: String, + val title: String, + val imageUrl: String, + val author: String + ) : GroupRoutes() + @Serializable data object Done : GroupRoutes() diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/routes/SearchRoutes.kt b/app/src/main/java/com/texthip/thip/ui/navigator/routes/SearchRoutes.kt index 2b8e1078..21eb2524 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/routes/SearchRoutes.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/routes/SearchRoutes.kt @@ -4,7 +4,9 @@ import kotlinx.serialization.Serializable @Serializable sealed class SearchRoutes : Routes() { - // 향후 추가될 Search 관련 화면들 - // @Serializable data object BookDetail : SearchRoutes - // @Serializable data object BookGroup : SearchRoutes + @Serializable + data class BookDetail(val isbn: String) : SearchRoutes() + + @Serializable + data class BookGroup(val isbn: String) : SearchRoutes() } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/search/component/SearchActiveField.kt b/app/src/main/java/com/texthip/thip/ui/search/component/SearchActiveField.kt index 72d6f269..b25d9c88 100644 --- a/app/src/main/java/com/texthip/thip/ui/search/component/SearchActiveField.kt +++ b/app/src/main/java/com/texthip/thip/ui/search/component/SearchActiveField.kt @@ -1,14 +1,25 @@ package com.texthip.thip.ui.search.component import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer 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.material3.CircularProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.texthip.thip.ui.search.mock.BookData @@ -17,26 +28,69 @@ import com.texthip.thip.ui.theme.ThipTheme.colors @Composable fun SearchActiveField( - bookList: List + bookList: List, + isLoading: Boolean = false, + hasMore: Boolean = true, + onLoadMore: () -> Unit = {}, + onBookClick: (BookData) -> Unit = {} ) { + val listState = rememberLazyListState() + + // 무한 스크롤을 위한 로직 + val shouldLoadMore by remember(hasMore, isLoading) { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val totalItemsCount = layoutInfo.totalItemsCount + val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 + + hasMore && !isLoading && totalItemsCount > 0 && lastVisibleItemIndex >= totalItemsCount - 3 + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) { + onLoadMore() + } + } + LazyColumn( - verticalArrangement = Arrangement.Center + state = listState, + verticalArrangement = Arrangement.spacedBy(12.dp) ) { itemsIndexed(bookList) { index, book -> - CardBookList( - title = book.title, - author = book.author, - publisher = book.publisher, - imageUrl = book.imageUrl - ) - if (index < bookList.size - 1) { - Spacer( + Column { + CardBookList( + modifier = Modifier.clickable { onBookClick(book) }, + title = book.title, + author = book.author, + publisher = book.publisher, + imageUrl = book.imageUrl + ) + if (index < bookList.size - 1) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(colors.DarkGrey02) + ) + } + } + } + + // 로딩 인디케이터 + if (isLoading) { + item { + Box( modifier = Modifier - .padding(top = 12.dp, bottom = 12.dp) .fillMaxWidth() - .height(1.dp) - .background(colors.DarkGrey02) - ) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = colors.White + ) + } } } } diff --git a/app/src/main/java/com/texthip/thip/ui/search/component/SearchBookFilteredResult.kt b/app/src/main/java/com/texthip/thip/ui/search/component/SearchBookFilteredResult.kt index 95d150b4..b6775d63 100644 --- a/app/src/main/java/com/texthip/thip/ui/search/component/SearchBookFilteredResult.kt +++ b/app/src/main/java/com/texthip/thip/ui/search/component/SearchBookFilteredResult.kt @@ -1,25 +1,34 @@ package com.texthip.thip.ui.search.component import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.texthip.thip.R -import com.texthip.thip.ui.search.mock.BookData import com.texthip.thip.ui.common.cards.CardBookList +import com.texthip.thip.ui.search.mock.BookData import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography @@ -27,8 +36,31 @@ import com.texthip.thip.ui.theme.ThipTheme.typography @Composable fun SearchBookFilteredResult( resultCount: Int, - bookList: List + bookList: List, + isLoading: Boolean = false, + hasMore: Boolean = true, + onLoadMore: () -> Unit = {}, + onBookClick: (BookData) -> Unit = {} ) { + val listState = rememberLazyListState() + + // 무한 스크롤을 위한 로직 + val shouldLoadMore by remember(hasMore, isLoading) { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val totalItemsCount = layoutInfo.totalItemsCount + val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 + + hasMore && !isLoading && totalItemsCount > 0 && lastVisibleItemIndex >= totalItemsCount - 3 + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) { + onLoadMore() + } + } + Column { Row( modifier = Modifier.fillMaxWidth(), @@ -48,39 +80,51 @@ fun SearchBookFilteredResult( .background(colors.DarkGrey02) ) - if (bookList.isEmpty()) { - SearchEmptyResult( - mainText = stringResource(R.string.book_no_search_result1), - subText = stringResource(R.string.book_no_search_result2), - onRequestBook = { /*책 요청 처리*/ } - ) - } else { - LazyColumn( - verticalArrangement = Arrangement.Center - ) { + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { itemsIndexed(bookList) { index, book -> - CardBookList( - title = book.title, - author = book.author, - publisher = book.publisher, - imageUrl = book.imageUrl - ) - if (index < bookList.size - 1) { - Spacer( + Column { + CardBookList( + modifier = Modifier.clickable { onBookClick(book) }, + title = book.title, + author = book.author, + publisher = book.publisher, + imageUrl = book.imageUrl + ) + if (index < bookList.size - 1) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(colors.DarkGrey02) + ) + } + } + } + + // 로딩 인디케이터 + if (isLoading) { + item { + Box( modifier = Modifier - .padding(top = 12.dp, bottom = 12.dp) .fillMaxWidth() - .height(1.dp) - .background(colors.DarkGrey02) - ) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = colors.White + ) + } } } - } } } } -@Preview +@Preview(showBackground = true) @Composable fun PreviewBookFilteredSearchResult() { ThipTheme { @@ -106,3 +150,14 @@ fun PreviewBookFilteredSearchResult() { ) } } + +@Preview(showBackground = true) +@Composable +fun PreviewBookFilteredSearchResultEmpty() { + ThipTheme { + SearchBookFilteredResult( + resultCount = 0, + bookList = emptyList() + ) + } +} diff --git a/app/src/main/java/com/texthip/thip/ui/search/mock/BookData.kt b/app/src/main/java/com/texthip/thip/ui/search/mock/BookData.kt index b4610c61..8d132998 100644 --- a/app/src/main/java/com/texthip/thip/ui/search/mock/BookData.kt +++ b/app/src/main/java/com/texthip/thip/ui/search/mock/BookData.kt @@ -6,5 +6,6 @@ data class BookData( val title: String, val author: String = "", val publisher: String = "", - val imageUrl: String? = null + val imageUrl: String? = null, + val isbn: String = "" ) 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 59acf186..b02b3e9d 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,7 @@ package com.texthip.thip.ui.search.screen import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.Image +import coil.compose.AsyncImage import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -16,15 +16,18 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon 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.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 @@ -37,7 +40,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.texthip.thip.R -import com.texthip.thip.ui.search.mock.DetailBookData +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 @@ -51,17 +55,86 @@ import kotlinx.coroutines.delay @Composable fun SearchBookDetailScreen( modifier: Modifier = Modifier, - book: DetailBookData, + isbn: String, feedList: List = emptyList(), onLeftClick: () -> Unit = {}, onRightClick: () -> Unit = {}, onRecruitingGroupClick: () -> Unit = {}, - onBookMarkClick: (Boolean) -> Unit = {}, - onWriteFeedClick: () -> Unit = {} + onWriteFeedClick: () -> Unit = {}, + viewModel: BookDetailViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + + // ISBN으로 책 상세 정보 로드 + LaunchedEffect(isbn) { + 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 = { _, _ -> } + ) + } + 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 +private fun SearchBookDetailScreenContent( + modifier: Modifier = Modifier, + isLoading: Boolean = false, + error: String? = null, + bookDetail: BookDetailResponse? = null, + feedList: List = emptyList(), + onLeftClick: () -> Unit = {}, + onRightClick: () -> Unit = {}, + onRecruitingGroupClick: () -> Unit = {}, + onWriteFeedClick: () -> Unit = {}, + onBookmarkClick: (String, Boolean) -> Unit = { _, _ -> } ) { var isAlarmVisible by remember { mutableStateOf(true) } var isIntroductionPopupVisible by remember { mutableStateOf(false) } - var isBookmarked by remember { mutableStateOf(false) } + var isBookmarked by remember { mutableStateOf(bookDetail?.isSaved ?: false) } var selectedFilterOption by remember { mutableIntStateOf(0) } val filterOptions = listOf( @@ -69,275 +142,350 @@ fun SearchBookDetailScreen( stringResource(R.string.search_filter_latest) ) - // 알림 5초간 노출 + // 알림 5초간 노출 (미리보기에서는 항상 보이도록) LaunchedEffect(Unit) { - isAlarmVisible = true - delay(5000) - isAlarmVisible = false + if (!isLoading && error == null && bookDetail != null) { + isAlarmVisible = true + delay(5000) + isAlarmVisible = false + } } - Box(modifier = modifier.fillMaxSize()) { - // 메인 컨텐츠 - Box( - modifier = Modifier - .fillMaxSize() - .then( - if (isIntroductionPopupVisible) { - Modifier.blur(4.dp) - } else { - Modifier - } + // 북마크 상태 동기화 + LaunchedEffect(bookDetail?.isSaved) { + bookDetail?.let { + isBookmarked = it.isSaved + } + } + + when { + isLoading -> { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = colors.White ) - ) { - if (book.coverImageRes != null) { - Box(modifier = Modifier - .height(420.dp) - .fillMaxWidth()) { - Image( - painter = painterResource(book.coverImageRes), - contentDescription = null, - modifier = Modifier - .matchParentSize() - .blur(4.dp), - contentScale = ContentScale.Crop - ) - 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 = book.participantsCount, - onLeftClick = {}, - onRightClick = {} - ) - } - AnimatedVisibility(visible = !isAlarmVisible) { - DefaultTopAppBar( - isRightIconVisible = true, - isTitleVisible = false, - onLeftClick = onLeftClick, - onRightClick = onRightClick - ) - } - - // 상세 정보 영역 - Column( + } + error != null -> { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = error, + color = colors.White, + style = typography.smalltitle_sb600_s16_h20 + ) + } + } + bookDetail != null -> { + Box(modifier = modifier.fillMaxSize()) { + // 메인 컨텐츠 + Box( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - ) { - Text( - modifier = Modifier.padding(top = 40.dp), - text = book.title, - color = colors.White, - style = typography.bigtitle_b700_s22 - ) - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = stringResource( - R.string.search_book_author, - book.author, - book.publisher - ), - color = colors.Grey, - style = typography.menu_sb600_s12_h20 - ) - - Column( - modifier = Modifier - .padding(top = 33.dp) - .fillMaxWidth() - .clickable { isIntroductionPopupVisible = true } - ) { - 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 = book.description, - color = colors.Grey, - style = typography.copy_r400_s12_h20, - maxLines = 2, - overflow = TextOverflow.Ellipsis + .fillMaxSize() + .then( + if (isIntroductionPopupVisible) { + Modifier.blur(4.dp) + } else { + Modifier + } ) - } - - Spacer(modifier = Modifier.height(40.dp)) - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - ActionMediumButton( - text = stringResource( - R.string.search_recruiting_group_count, - book.recruitingRoomCount - ), - contentColor = colors.Grey, - backgroundColor = Color.Transparent, - hasRightIcon = true, + ) { + // 실제 책 이미지 사용 + Box(modifier = Modifier + .height(420.dp) + .fillMaxWidth()) { + AsyncImage( + model = bookDetail.imageUrl, + contentDescription = bookDetail.title, modifier = Modifier - .fillMaxWidth() - .border( - width = 1.dp, - color = colors.Grey, - shape = RoundedCornerShape(12.dp) - ), - onClick = onRecruitingGroupClick, + .matchParentSize() + .blur(4.dp), + contentScale = ContentScale.Crop, + fallback = painterResource(R.drawable.img_book_cover_sample), + error = painterResource(R.drawable.img_book_cover_sample) ) - 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) + 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 ) - .clickable { - isBookmarked = !isBookmarked - onBookMarkClick(isBookmarked) - }, - 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 = 33.dp) - ) + 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 + ) + } - // 피드 리스트 - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(colors.DarkGrey02) - ) - if (feedList.isEmpty()) { - Box( + // 상세 정보 영역 + Column( modifier = Modifier .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center + .padding(horizontal = 20.dp) ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { + 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 } + ) { Text( - text = stringResource(R.string.search_no_feed_comment_1), + text = stringResource(R.string.search_book_comment), color = colors.White, - style = typography.smalltitle_sb600_s18_h24 + style = typography.menu_sb600_s14_h24, ) - Spacer(modifier = Modifier.height(6.dp)) + Spacer(modifier = Modifier.height(5.dp)) + Text( - text = stringResource(R.string.search_no_feed_comment_2), - color = colors.Grey01, - style = typography.feedcopy_r400_s14_h20 + 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 + ), + 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 = 33.dp) + ) + + // 피드 리스트 + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(colors.DarkGrey02) + ) + if (feedList.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.search_no_feed_comment_1), + color = colors.White, + style = typography.smalltitle_sb600_s18_h24 + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = stringResource(R.string.search_no_feed_comment_2), + color = colors.Grey01, + style = typography.feedcopy_r400_s14_h20 + ) + } + } + } else { + // TODO: 피드 UI 구현 되면 수정 + } } - } else { - // TODO: 피드 UI 구현 되면 수정 } } - } - - 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) - } - ) - } - if (isIntroductionPopupVisible) { - Box( - modifier = Modifier - .align(Alignment.Center) - .padding(horizontal = 20.dp) - ) { - InfoPopup( - title = stringResource(R.string.introduction), - content = book.description, - onDismiss = { isIntroductionPopupVisible = false } + 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) + } ) + + if (isIntroductionPopupVisible) { + Box( + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 20.dp) + ) { + InfoPopup( + title = stringResource(R.string.introduction), + content = bookDetail.description, + onDismiss = { isIntroductionPopupVisible = false } + ) + } + } } } + else -> {} + } +} + +// Preview용 Mock 데이터 +private val mockBookDetail = BookDetailResponse( + title = "데미안", + imageUrl = "https://example.com/demian.jpg", + authorName = "헤르만 헤세", + publisher = "민음사", + isbn = "9788954682152", + description = "한 소년의 성장 이야기를 통해 인간의 내면 세계를 탐구한 헤르만 헤세의 대표작. 주인공 싱클레어가 겪는 정신적 성장과 자아 발견의 과정을 그린 소설로, 청소년기의 혼란과 성인으로의 성장을 섬세하게 그려낸다. 선악의 이분법을 넘어서서 인간 내면의 복잡성을 인정하고 받아들이는 과정을 통해 진정한 자아를 찾아가는 이야기다.", + recruitingRoomCount = 8, + readCount = 1250, + isSaved = false +) + +private val mockBookDetailSaved = mockBookDetail.copy(isSaved = true) + +@Preview(showBackground = true) +@Composable +fun SearchBookDetailScreenContentPreview() { + ThipTheme { + SearchBookDetailScreenContent( + bookDetail = mockBookDetail, + feedList = emptyList() + ) } } -@Preview +@Preview(showBackground = true) @Composable -fun PreviewBookDetailScreen() { +fun SearchBookDetailScreenContentSavedPreview() { ThipTheme { - SearchBookDetailScreen( - book = DetailBookData( - title = "채식주의자", - author = "한강", - publisher = "창비", - description = - "인터내셔널 북커상, 산클레멘테 문학상 수상작. 전세계가 주목한 인간의 역작을 다시 만나다.2016년 인터내셔널 북커상을 수상하며 한국문학의 입지를 한단계 확장시킨 한강의 명단소설 『채식주의자』. 15년 만에 새로운 장정과 판형으로 출간된다. 식물화로 건설해온 극단적이며 실재적인 상상력의 강렬한 결실로 고통과 구속의 피안에 존재하는 인간의 본성에 다가간 작품." + - "인터내셔널 북커상, 산클레멘테 문학상 수상작. 전세계가 주목한 인간의 역작을 다시 만나다. \n\n2016년 인터내셔널 북커상을 수상하며 한국문학의 입지를 한단계 확장시킨 한강의 명단소설 『채식주의자』. 15년 만에 새로운 장정과 판형으로 출간된다. 식물화로 건설해온 극단적이며 실재적인 상상력의 강렬한 결실로 고통과 구속의 피안에 존재하는 인간의 본성에 다가간 작품.", - coverImageRes = R.drawable.img_book_cover_sample, - participantsCount = 210, - recruitingRoomCount = 4 - ), + SearchBookDetailScreenContent( + bookDetail = mockBookDetailSaved, feedList = emptyList() ) } -} \ No newline at end of file +} + +@Preview(showBackground = true) +@Composable +fun SearchBookDetailScreenContentWithFeedsPreview() { + ThipTheme { + SearchBookDetailScreenContent( + bookDetail = mockBookDetail, + feedList = listOf("피드 1", "피드 2", "피드 3") + ) + } +} + +@Preview(showBackground = true) +@Composable +fun SearchBookDetailScreenContentErrorPreview() { + ThipTheme { + SearchBookDetailScreenContent( + error = "책 정보를 불러오는데 실패했습니다." + ) + } +} diff --git a/app/src/main/java/com/texthip/thip/ui/search/screen/SearchBookGroupScreen.kt b/app/src/main/java/com/texthip/thip/ui/search/screen/SearchBookGroupScreen.kt index 778622cc..cf6fcea9 100644 --- a/app/src/main/java/com/texthip/thip/ui/search/screen/SearchBookGroupScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/search/screen/SearchBookGroupScreen.kt @@ -13,215 +13,349 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator 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.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import com.texthip.thip.R import com.texthip.thip.ui.common.cards.CardItemRoom import com.texthip.thip.ui.common.topappbar.DefaultTopAppBar import com.texthip.thip.ui.group.myroom.mock.GroupCardItemRoomData +import com.texthip.thip.ui.search.viewmodel.SearchBookGroupViewModel import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography +import com.texthip.thip.utils.rooms.DateUtils + @Composable fun SearchBookGroupScreen( - recruitingList: List, - onCardClick: (GroupCardItemRoomData) -> Unit = {}, - onCreateRoomClick: () -> Unit = {} + isbn: String, + onLeftClick: () -> Unit = {}, + onCardClick: (Int) -> Unit = {}, + onCreateRoomClick: (isbn: String, title: String, imageUrl: String, author: String) -> Unit = { _, _, _, _ -> }, + viewModel: SearchBookGroupViewModel = hiltViewModel() ) { - Box( - modifier = Modifier.fillMaxSize() - ) { - Column( - Modifier.fillMaxSize() - ) { - DefaultTopAppBar( - title = stringResource(R.string.group_recruiting_title), - onLeftClick = {}, - ) - - Column( - Modifier - .background(colors.Black) - .fillMaxSize() - .padding(horizontal = 20.dp) - .padding(top = 16.dp) + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(isbn) { + viewModel.loadRecruitingRooms(isbn) + } + + val recruitingList = uiState.recruitingRooms.map { item -> + val daysLeft = DateUtils.extractDaysFromDeadline(item.deadlineEndDate) + + GroupCardItemRoomData( + id = item.roomId, + title = item.roomName, + participants = item.memberCount, + maxParticipants = item.recruitCount, + endDate = daysLeft, + imageUrl = item.bookImageUrl, + isRecruiting = true + ) + } + + SearchBookGroupScreenContent( + isLoading = uiState.isLoading, + error = uiState.error, + recruitingList = recruitingList, + totalCount = uiState.totalCount, + isLoadingMore = uiState.isLoadingMore, + canLoadMore = uiState.canLoadMore, + isbn = isbn, + bookTitle = uiState.bookDetail?.title ?: "", + bookImageUrl = uiState.bookDetail?.imageUrl ?: "", + bookAuthor = uiState.bookDetail?.authorName ?: "", + onLeftClick = onLeftClick, + onCardClick = onCardClick, + onCreateRoomClick = onCreateRoomClick, + onLoadMore = { + viewModel.loadMoreRooms() + } + ) +} + +@Composable +private fun SearchBookGroupScreenContent( + modifier: Modifier = Modifier, + isLoading: Boolean = false, + error: String? = null, + recruitingList: List = emptyList(), + totalCount: Int = 0, + isLoadingMore: Boolean = false, + canLoadMore: Boolean = true, + isbn: String = "", + bookTitle: String = "", + bookImageUrl: String = "", + bookAuthor: String = "", + onLeftClick: () -> Unit = {}, + onCardClick: (Int) -> Unit = {}, + onCreateRoomClick: (isbn: String, title: String, imageUrl: String, author: String) -> Unit = { _, _, _, _ -> }, + onLoadMore: () -> Unit = {} +) { + when { + isLoading -> { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + CircularProgressIndicator( + color = colors.White + ) + } + } + error != null -> { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = error, + color = colors.White, + style = typography.smalltitle_sb600_s16_h20 + ) + } + } + else -> { + Box( + modifier = modifier.fillMaxSize() + ) { + // 기존 콘텐츠 Column + Column( + Modifier.fillMaxSize() ) { - Text( - text = stringResource(R.string.group_searched_room_size, recruitingList.size), - color = colors.Grey, - style = typography.menu_m500_s14_h24 + DefaultTopAppBar( + title = stringResource(R.string.group_recruiting_title), + onLeftClick = onLeftClick, ) - } - Spacer( - modifier = Modifier - .padding(top = 4.dp, bottom = 20.dp) - .fillMaxWidth() - .height(1.dp) - .background(colors.DarkGrey02) - ) - if (recruitingList.isEmpty()) { Column( - modifier = Modifier + Modifier .fillMaxSize() - .padding(bottom = 50.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = stringResource(R.string.book_recruiting_empty_message), - color = colors.White, - style = typography.smalltitle_sb600_s18_h24, - textAlign = TextAlign.Center - ) - Text( - text = stringResource(R.string.book_recruiting_empty_sub_message), - color = colors.Grey, - style = typography.feedcopy_r400_s14_h20, - textAlign = TextAlign.Center, - modifier = Modifier.padding(top = 8.dp) - ) - } - } else { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(20.dp), - contentPadding = PaddingValues(bottom = 70.dp), - modifier = Modifier.fillMaxSize() + .padding(horizontal = 20.dp) + .padding(top = 16.dp) ) { - items(recruitingList) { item -> - CardItemRoom( - title = item.title, - participants = item.participants, - maxParticipants = item.maxParticipants, - isRecruiting = item.isRecruiting, - endDate = item.endDate, - imageUrl = item.imageUrl, - onClick = { onCardClick(item) } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource( + R.string.group_searched_room_size, + totalCount + ), + color = colors.Grey, + style = typography.menu_m500_s14_h24 ) } + Spacer( + modifier = Modifier + .padding(top = 4.dp, bottom = 20.dp) + .fillMaxWidth() + .height(1.dp) + .background(colors.DarkGrey02) + ) + + if (recruitingList.isEmpty()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 50.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.book_recruiting_empty_message), + color = colors.White, + style = typography.smalltitle_sb600_s18_h24, + textAlign = TextAlign.Center + ) + Text( + text = stringResource(R.string.book_recruiting_empty_sub_message), + color = colors.Grey, + style = typography.feedcopy_r400_s14_h20, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp) + ) + } + } else { + val listState = rememberLazyListState() + + // 무한 스크롤 로직 + LaunchedEffect(listState, canLoadMore, isLoadingMore) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } + .collect { lastVisibleIndex -> + if (lastVisibleIndex != null && + recruitingList.isNotEmpty() && + !isLoadingMore && + lastVisibleIndex >= recruitingList.size - 3 && + canLoadMore) { + onLoadMore() + } + } + } + + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(20.dp), + contentPadding = PaddingValues(bottom = 80.dp), + modifier = Modifier.fillMaxSize() + ) { + items(recruitingList) { item -> + CardItemRoom( + title = item.title, + participants = item.participants, + maxParticipants = item.maxParticipants, + isRecruiting = item.isRecruiting, + endDate = item.endDate, + imageUrl = item.imageUrl, + onClick = { onCardClick(item.id) } + ) + } + + // 로딩 인디케이터 + if (isLoadingMore) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = colors.White + ) + } + } + } + } + } } } + + Button( + colors = ButtonDefaults.buttonColors( + containerColor = colors.Purple + ), + modifier = Modifier + .align(Alignment.BottomCenter) // Box의 하단 중앙에 정렬 + .fillMaxWidth() + .height(50.dp), + shape = RoundedCornerShape(0.dp), + onClick = { + onCreateRoomClick(isbn, bookTitle, bookImageUrl, bookAuthor) + } + ) { + Text( + text = stringResource(R.string.group_recruiting_create_button), + style = typography.smalltitle_sb600_s18_h24, + color = colors.White + ) + } } } + } +} - // 하단 버튼 - Button( - colors = ButtonDefaults.buttonColors( - containerColor = colors.Purple - ), - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .height(50.dp), - shape = RoundedCornerShape(0.dp), - onClick = onCreateRoomClick - ) { - Text( - text = stringResource(R.string.group_recruiting_create_button), - style = typography.smalltitle_sb600_s18_h24, - color = colors.White - ) - } +// Preview용 Mock 데이터 +private val mockRecruitingList = listOf( + GroupCardItemRoomData( + id = 1, + title = "데미안 함께 읽기 📚", + participants = 8, + maxParticipants = 12, + isRecruiting = true, + endDate = 3, + imageUrl = "https://example.com/demian.jpg", + isSecret = false + ), + GroupCardItemRoomData( + id = 2, + title = "헤르만 헤세 작품 토론방", + participants = 15, + maxParticipants = 20, + isRecruiting = true, + endDate = 7, + imageUrl = "https://example.com/demian.jpg", + isSecret = true + ), + GroupCardItemRoomData( + id = 3, + title = "클래식 문학 읽기 모임", + participants = 5, + maxParticipants = 10, + isRecruiting = true, + endDate = 1, + imageUrl = "https://example.com/demian.jpg", + isSecret = false + ) +) + +@Preview(showBackground = true) +@Composable +fun SearchBookGroupScreenContentPreview() { + ThipTheme { + SearchBookGroupScreenContent( + recruitingList = mockRecruitingList, + totalCount = 8 + ) + } +} + +@Preview(showBackground = true) +@Composable +fun SearchBookGroupScreenContentEmptyPreview() { + ThipTheme { + SearchBookGroupScreenContent( + recruitingList = emptyList(), + totalCount = 0 + ) } } -@Preview() +@Preview(showBackground = true) @Composable -fun GroupRecruitingScreenPreview() { +fun SearchBookGroupScreenContentLoadingMorePreview() { ThipTheme { - val dataList = listOf( - GroupCardItemRoomData( - id = 1, - title = "모임방 이름입니다. 모임방...", - participants = 22, - maxParticipants = 30, - endDate = 3, - isRecruiting = true, - ), - GroupCardItemRoomData( - id = 2, - title = "모임방 이름입니다. 모임방...", - participants = 22, - maxParticipants = 30, - endDate = 3, - isRecruiting = true, - ), - GroupCardItemRoomData( - id = 3, - title = "모임방 이름입니다. 모임방...", - participants = 22, - maxParticipants = 30, - endDate = 3, - isRecruiting = true, - ), - GroupCardItemRoomData( - id = 4, - title = "모임방 이름입니다. 모임방...", - participants = 22, - maxParticipants = 30, - endDate = 3, - isRecruiting = true, - ), - GroupCardItemRoomData( - id = 5, - title = "모임방 이름입니다. 모임방...", - participants = 22, - maxParticipants = 30, - endDate = 3, - isRecruiting = true, - ), - GroupCardItemRoomData( - id = 6, - title = "모임방 이름입니다. 모임방...", - participants = 22, - maxParticipants = 30, - endDate = 3, - isRecruiting = true, - ), - GroupCardItemRoomData( - id = 7, - title = "모임방 이름입니다. 모임방...", - participants = 22, - maxParticipants = 30, - endDate = 3, - isRecruiting = true, - ), - GroupCardItemRoomData( - id = 8, - title = "모임방 이름입니다. 모임방...", - participants = 22, - maxParticipants = 30, - endDate = 3, - isRecruiting = true, - ) + SearchBookGroupScreenContent( + recruitingList = mockRecruitingList, + totalCount = 15, + isLoadingMore = true, + canLoadMore = true ) + } +} - SearchBookGroupScreen( - recruitingList = dataList +@Preview(showBackground = true) +@Composable +fun SearchBookGroupScreenContentLoadingPreview() { + ThipTheme { + SearchBookGroupScreenContent( ) } } -@Preview() +@Preview(showBackground = true) @Composable -fun GroupRecruitingScreenEmptyPreview() { +fun SearchBookGroupScreenContentErrorPreview() { ThipTheme { - SearchBookGroupScreen( - recruitingList = emptyList() + SearchBookGroupScreenContent( + error = "모집 중인 그룹을 불러오는데 실패했습니다." ) } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/search/screen/SearchBookScreen.kt b/app/src/main/java/com/texthip/thip/ui/search/screen/SearchBookScreen.kt index 2eeb0f16..36e83c4a 100644 --- a/app/src/main/java/com/texthip/thip/ui/search/screen/SearchBookScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/search/screen/SearchBookScreen.kt @@ -1,6 +1,5 @@ package com.texthip.thip.ui.search.screen -import android.content.Context import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -9,22 +8,24 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.navigation.NavHostController +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import com.texthip.thip.R import com.texthip.thip.ui.common.forms.SearchBookTextField import com.texthip.thip.ui.common.topappbar.LeftNameTopAppBar @@ -33,81 +34,113 @@ import com.texthip.thip.ui.search.component.SearchBookFilteredResult import com.texthip.thip.ui.search.component.SearchEmptyResult import com.texthip.thip.ui.search.component.SearchRecentBook import com.texthip.thip.ui.search.mock.BookData +import com.texthip.thip.ui.search.viewmodel.SearchBookViewModel import com.texthip.thip.ui.theme.ThipTheme -import kotlinx.serialization.json.Json -import androidx.core.content.edit -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.builtins.serializer @Composable fun SearchBookScreen( modifier: Modifier = Modifier, - navController: NavHostController? = null, - bookList: List = emptyList(), - popularBooks: List = emptyList() + viewModel: SearchBookViewModel = hiltViewModel(), + onBookClick: (String) -> Unit = {}, + onRequestBook: () -> Unit = {} ) { - val context = LocalContext.current - val sharedPrefs = remember { - context.getSharedPreferences("book_search_prefs", Context.MODE_PRIVATE) - } + val uiState by viewModel.uiState.collectAsState() + val lifecycleOwner = LocalLifecycleOwner.current - var recentSearches by remember { - mutableStateOf( - try { - val jsonString = sharedPrefs.getString("recent_book_searches", "[]") ?: "[]" - Json.decodeFromString>(jsonString) - } catch (e: Exception) { - emptyList() + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + viewModel.refreshData() } - ) - } - - fun saveRecentSearches(searches: List) { - try { - val jsonString = Json.encodeToString(ListSerializer(String.serializer()), searches) - sharedPrefs.edit { - putString("recent_book_searches", jsonString) - } - recentSearches = searches - } catch (e: Exception) { - recentSearches = emptyList() } - } - var searchText by rememberSaveable { mutableStateOf("") } - var isSearched by rememberSaveable { mutableStateOf(false) } - val focusRequester = remember { FocusRequester() } - val focusManager = LocalFocusManager.current - - val liveFilteredBookList by remember(searchText) { - derivedStateOf { - if (searchText.isBlank()) emptyList() else - bookList.filter { book -> - book.title.contains(searchText, ignoreCase = true) || - book.author.contains(searchText, ignoreCase = true) || - book.publisher.contains(searchText, ignoreCase = true) - } + + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) } } - val filteredBookList by remember(searchText, isSearched) { - derivedStateOf { - if (!isSearched) emptyList() - else { - bookList.filter { book -> - searchText.isBlank() || - book.title.contains(searchText, ignoreCase = true) || - book.author.contains(searchText, ignoreCase = true) || - book.publisher.contains(searchText, ignoreCase = true) - } - } - } - } + SearchBookScreenContent( + modifier = modifier, + searchQuery = uiState.searchQuery, + isInitial = uiState.isInitial, + isLiveSearching = uiState.isLiveSearching, + isCompleteSearching = uiState.isCompleteSearching, + searchResults = uiState.searchResults.map { item -> + BookData( + title = item.title, + author = item.authorName, + publisher = item.publisher, + imageUrl = item.imageUrl, + isbn = item.isbn + ) + }, + popularBooks = uiState.popularBooks.map { item -> + BookData( + title = item.title, + author = "", + publisher = "", + imageUrl = item.imageUrl, + isbn = item.isbn + ) + }, + recentSearches = uiState.recentSearches.map { it.searchTerm }, + totalElements = uiState.totalElements, + isSearching = uiState.isSearching, + isLoadingMore = uiState.isLoadingMore, + canLoadMore = uiState.canLoadMore, + hasResults = uiState.hasResults, + showEmptyState = uiState.showEmptyState, + onSearchQueryChange = { query -> + viewModel.updateSearchQuery(query) + }, + onSearchClick = { + viewModel.onSearchButtonClick() + }, + onRecentSearchClick = { keyword -> + viewModel.updateSearchQuery(keyword) + viewModel.onSearchButtonClick() + }, + onRemoveRecentSearch = { keyword -> + viewModel.deleteRecentSearchByKeyword(keyword) + }, + onBookClick = { book -> + onBookClick(book.isbn) + }, + onLoadMore = { + viewModel.loadMoreBooks() + }, + onRequestBook = onRequestBook + ) +} - LaunchedEffect(isSearched) { - if (isSearched) { - focusManager.clearFocus() - } - } +@Composable +private fun SearchBookScreenContent( + modifier: Modifier = Modifier, + searchQuery: String = "", + isInitial: Boolean = true, + isLiveSearching: Boolean = false, + isCompleteSearching: Boolean = false, + searchResults: List = emptyList(), + popularBooks: List = emptyList(), + recentSearches: List = emptyList(), + totalElements: Int = 0, + isSearching: Boolean = false, + isLoadingMore: Boolean = false, + canLoadMore: Boolean = true, + hasResults: Boolean = false, + showEmptyState: Boolean = false, + onSearchQueryChange: (String) -> Unit = {}, + onSearchClick: () -> Unit = {}, + onRecentSearchClick: (String) -> Unit = {}, + onRemoveRecentSearch: (String) -> Unit = {}, + onBookClick: (BookData) -> Unit = {}, + onLoadMore: () -> Unit = {}, + onRequestBook: () -> Unit = {} +) { + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current Box( modifier = modifier.fillMaxSize() @@ -130,59 +163,55 @@ fun SearchBookScreen( .fillMaxWidth() .focusRequester(focusRequester), hint = stringResource(R.string.book_search_hint), - text = searchText, - onValueChange = { - searchText = it - isSearched = false - }, - onSearch = { query -> - if (query.isNotBlank() && !recentSearches.contains(query)) { - val newSearches = listOf(query) + recentSearches.take(9) // 최대 10개 유지 - saveRecentSearches(newSearches) - } - isSearched = true + text = searchQuery, + onValueChange = onSearchQueryChange, + onSearch = { + onSearchClick() + focusManager.clearFocus() } ) Spacer(modifier = Modifier.height(16.dp)) - when { - searchText.isBlank() && !isSearched -> { - SearchRecentBook( - recentSearches = recentSearches, - popularBooks = popularBooks, - popularBookDate = "01.12", // TODO: 서버로 날짜를 받아 오게 수정 - onSearchClick = { keyword -> - searchText = keyword - isSearched = true - }, - onRemove = { keyword -> - val updatedSearches = recentSearches.filterNot { it == keyword } - saveRecentSearches(updatedSearches) - }, - onBookClick = { book -> - // 책 클릭 시 처리 - } + if (isInitial) { + SearchRecentBook( + recentSearches = recentSearches, + popularBooks = popularBooks, + popularBookDate = SimpleDateFormat("MM.dd", Locale.getDefault()).format(Date()), + onSearchClick = onRecentSearchClick, + onRemove = onRemoveRecentSearch, + onBookClick = onBookClick + ) + } else if (isLiveSearching) { + if (hasResults) { + SearchActiveField( + bookList = searchResults, + isLoading = isSearching || isLoadingMore, + hasMore = canLoadMore, + onLoadMore = onLoadMore, + onBookClick = onBookClick + ) + } else if (showEmptyState) { + SearchEmptyResult( + mainText = stringResource(R.string.book_no_search_result1), + subText = stringResource(R.string.book_no_search_result2), + onRequestBook = onRequestBook ) } - - searchText.isNotBlank() && !isSearched -> { - if (liveFilteredBookList.isEmpty()) { - SearchEmptyResult( - mainText = stringResource(R.string.book_no_search_result1), - subText = stringResource(R.string.book_no_search_result2), - onRequestBook = { /*책 요청 처리*/ } - ) - } else { - SearchActiveField( - bookList = liveFilteredBookList - ) - } - } - - isSearched -> { + } else if (isCompleteSearching) { + if (hasResults) { SearchBookFilteredResult( - resultCount = filteredBookList.size, - bookList = filteredBookList, + resultCount = totalElements, + bookList = searchResults, + isLoading = isSearching || isLoadingMore, + hasMore = canLoadMore, + onLoadMore = onLoadMore, + onBookClick = onBookClick + ) + } else if (showEmptyState) { + SearchEmptyResult( + mainText = stringResource(R.string.book_no_search_result1), + subText = stringResource(R.string.book_no_search_result2), + onRequestBook = onRequestBook ) } } @@ -192,46 +221,129 @@ fun SearchBookScreen( } +// Preview용 Mock 데이터 +private val mockPopularBooks = listOf( + BookData( + title = "데미안", + author = "헤르만 헤세", + publisher = "민음사", + imageUrl = "https://example.com/demian.jpg", + isbn = "9788954682152" + ), + BookData( + title = "1984", + author = "조지 오웰", + publisher = "민음사", + imageUrl = "https://example.com/1984.jpg", + isbn = "9788954682153" + ), + BookData( + title = "어린왕자", + author = "생텍쥐페리", + publisher = "문예출판사", + imageUrl = "https://example.com/prince.jpg", + isbn = "9788954682154" + ) +) + +private val mockSearchResults = listOf( + BookData( + title = "데미안", + author = "헤르만 헤세", + publisher = "민음사", + imageUrl = "https://example.com/demian.jpg", + isbn = "9788954682152" + ), + BookData( + title = "데미안 읽기의 즐거움", + author = "김철수", + publisher = "문학동네", + imageUrl = "https://example.com/demian2.jpg", + isbn = "9788954682155" + ), + BookData( + title = "헤르만 헤세의 데미안 해설서", + author = "이영희", + publisher = "해냄출판사", + imageUrl = "https://example.com/demian3.jpg", + isbn = "9788954682156" + ) +) + +private val mockRecentSearches = listOf("데미안", "1984", "어린왕자", "카프카", "괴테") + @Preview(showBackground = true) @Composable -fun PreviewBookSearchScreen_Default() { +fun SearchBookScreenContentInitialPreview() { ThipTheme { - SearchBookScreen( - bookList = listOf( - BookData(title = "aaa", author = "리처드 도킨스", publisher = "을유문화사", imageUrl = null), - BookData(title = "abc", author = "마틴 셀리그만", publisher = "물푸레", imageUrl = null), - BookData(title = "abcd", author = "빅터 프랭클", publisher = "청림출판", imageUrl = null), - BookData(title = "abcde", author = "칼 융", publisher = "문학과지성사", imageUrl = null), - BookData(title = "abcdef", author = "에릭 프롬", publisher = "까치글방", imageUrl = null), - BookData(title = "abcedfg", author = "알베르 카뮈", publisher = "민음사", imageUrl = null), - BookData(title = "abcdefgh", author = "장 폴 사르트르", publisher = "문학동네", imageUrl = null), - ), - popularBooks = listOf( - BookData(title = "단 한번의 삶", author = "리처드 도킨스", publisher = "을유문화사", imageUrl = null), - BookData(title = "사랑", author = "마틴 셀리그만", publisher = "물푸레", imageUrl = null), - BookData(title = "호모 사피엔스", author = "빅터 프랭클", publisher = "청림출판", imageUrl = null), - BookData(title = "코스모스 실버", author = "칼 융", publisher = "문학과지성사", imageUrl = null), - BookData(title = "오만과 편견", author = "에릭 프롬", publisher = "까치글방", imageUrl = null), - ) + SearchBookScreenContent( + isInitial = true, + popularBooks = mockPopularBooks, + recentSearches = mockRecentSearches + ) + } +} + +@Preview(showBackground = true) +@Composable +fun SearchBookScreenContentLiveSearchPreview() { + ThipTheme { + SearchBookScreenContent( + searchQuery = "데미안", + isInitial = false, + isLiveSearching = true, + searchResults = mockSearchResults, + hasResults = true, + isSearching = false ) } } -@Preview +@Preview(showBackground = true) +@Composable +fun SearchBookScreenContentCompleteSearchPreview() { + ThipTheme { + SearchBookScreenContent( + searchQuery = "데미안", + isInitial = false, + isCompleteSearching = true, + searchResults = mockSearchResults, + totalElements = 15, + hasResults = true, + isSearching = false + ) + } +} + +@Preview(showBackground = true) +@Composable +fun SearchBookScreenContentEmptyPreview() { + ThipTheme { + SearchBookScreenContent( + searchQuery = "없는책제목", + isInitial = false, + isCompleteSearching = true, + searchResults = emptyList(), + hasResults = false, + showEmptyState = true + ) + } +} + +@Preview(showBackground = true) @Composable -fun PreviewBookSearchScreen_EmptyPopular() { +fun SearchBookScreenContentLoadingPreview() { ThipTheme { - SearchBookScreen( - bookList = listOf( - BookData(title = "aaa", author = "리처드 도킨스", publisher = "을유문화사", imageUrl = null), - BookData(title = "abc", author = "마틴 셀리그만", publisher = "물푸레", imageUrl = null), - BookData(title = "abcd", author = "빅터 프랭클", publisher = "청림출판", imageUrl = null), - BookData(title = "abcde", author = "칼 융", publisher = "문학과지성사", imageUrl = null), - BookData(title = "abcdef", author = "에릭 프롬", publisher = "까치글방", imageUrl = null), - BookData(title = "abcedfg", author = "알베르 카뮈", publisher = "민음사", imageUrl = null), - BookData(title = "abcdefgh", author = "장 폴 사르트르", publisher = "문학동네", imageUrl = null), - ), - popularBooks = emptyList() + SearchBookScreenContent( + searchQuery = "데미안", + isInitial = false, + isCompleteSearching = true, + searchResults = mockSearchResults.take(2), + totalElements = 15, + hasResults = true, + isSearching = false, + isLoadingMore = true, + canLoadMore = true ) } } 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 new file mode 100644 index 00000000..bd85f629 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/BookDetailViewModel.kt @@ -0,0 +1,74 @@ +package com.texthip.thip.ui.search.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.texthip.thip.data.model.book.response.BookDetailResponse +import com.texthip.thip.data.repository.BookRepository +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 + +@HiltViewModel +class BookDetailViewModel @Inject constructor( + private val bookRepository: BookRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(BookDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadBookDetail(isbn: String) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + bookRepository.getBookDetail(isbn) + .onSuccess { bookDetail -> + _uiState.value = _uiState.value.copy( + bookDetail = bookDetail, + isLoading = false, + error = null + ) + } + .onFailure { exception -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = exception.message ?: "책 정보를 불러오는데 실패했습니다." + ) + } + } + } + + fun saveBook(isbn: String, type: Boolean) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSaving = true, error = null) + + bookRepository.saveBook(isbn, type) + .onSuccess { saveResponse -> + saveResponse?.let { + // 책 상세 정보의 isSaved 상태 업데이트 + val updatedBookDetail = _uiState.value.bookDetail?.copy(isSaved = it.isSaved) + _uiState.value = _uiState.value.copy( + bookDetail = updatedBookDetail, + isSaving = false, + error = null + ) + } + } + .onFailure { exception -> + _uiState.value = _uiState.value.copy( + isSaving = false, + error = exception.message ?: "책 저장에 실패했습니다." + ) + } + } + } +} + +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 diff --git a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookGroupViewModel.kt b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookGroupViewModel.kt new file mode 100644 index 00000000..82eb2c77 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookGroupViewModel.kt @@ -0,0 +1,115 @@ +package com.texthip.thip.ui.search.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.texthip.thip.data.model.book.response.BookDetailResponse +import com.texthip.thip.data.model.book.response.RecruitingRoomItem +import com.texthip.thip.data.repository.BookRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SearchBookGroupViewModel @Inject constructor( + private val bookRepository: BookRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(SearchBookGroupUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var loadMoreJob: Job? = null + private var currentIsbn: String = "" + + fun loadRecruitingRooms(isbn: String) { + loadMoreJob?.cancel() // 신규 검색 시 이전 로드 작업 취소 + currentIsbn = isbn + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isLoading = true, + error = null, + recruitingRooms = emptyList(), + nextCursor = null, + hasMore = true, + totalCount = 0, + bookDetail = null + ) + + // 책 정보와 모집중인 방 정보 동시 로드 + loadBookDetail(isbn) + loadRooms(isbn, null) + } + } + + private suspend fun loadBookDetail(isbn: String) { + bookRepository.getBookDetail(isbn) + .onSuccess { bookDetail -> + _uiState.value = _uiState.value.copy(bookDetail = bookDetail) + } + .onFailure { } + } + + fun loadMoreRooms() { + val currentState = _uiState.value + if (currentState.hasMore && !currentState.isLoading && !currentState.isLoadingMore && currentIsbn.isNotBlank()) { + loadMoreJob?.cancel() + loadMoreJob = viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoadingMore = true) + loadRooms(currentIsbn, currentState.nextCursor) + } + } + } + + private suspend fun loadRooms(isbn: String, cursor: String?) { + bookRepository.getRecruitingRooms(isbn, cursor) + .onSuccess { response -> + response?.let { recruitingRoomsResponse -> + val currentRooms = if (cursor == null) emptyList() else _uiState.value.recruitingRooms + _uiState.value = _uiState.value.copy( + recruitingRooms = currentRooms + recruitingRoomsResponse.recruitingRoomList, + totalCount = recruitingRoomsResponse.totalRoomCount, + nextCursor = recruitingRoomsResponse.nextCursor, + hasMore = !recruitingRoomsResponse.isLast, + isLoading = false, + isLoadingMore = false, + error = null + ) + } ?: run { + _uiState.value = _uiState.value.copy( + isLoading = false, + isLoadingMore = false, + hasMore = false, // null 응답 시 더 이상 로드할 수 없음을 명시 + error = if (cursor == null) "모집중인 방 정보를 찾을 수 없습니다." else null + ) + } + } + .onFailure { exception -> + _uiState.value = _uiState.value.copy( + isLoading = false, + isLoadingMore = false, + error = exception.message ?: "모집중인 방을 불러오는데 실패했습니다." + ) + } + } + + override fun onCleared() { + super.onCleared() + loadMoreJob?.cancel() + } +} + +data class SearchBookGroupUiState( + val recruitingRooms: List = emptyList(), + val totalCount: Int = 0, + val nextCursor: String? = null, + val hasMore: Boolean = true, + val isLoading: Boolean = false, + val isLoadingMore: Boolean = false, + val error: String? = null, + val bookDetail: BookDetailResponse? = null +) { + val canLoadMore: Boolean get() = hasMore && !isLoading && !isLoadingMore +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookUiState.kt b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookUiState.kt new file mode 100644 index 00000000..5dd3278a --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookUiState.kt @@ -0,0 +1,37 @@ +package com.texthip.thip.ui.search.viewmodel + +import com.texthip.thip.data.model.book.response.BookSearchItem +import com.texthip.thip.data.model.book.response.PopularBookItem +import com.texthip.thip.data.model.book.response.RecentSearchItem + +data class SearchBookUiState( + val searchQuery: String = "", + + // 상태 관리 단순화 - boolean 필드 사용 + val isInitial: Boolean = true, + val isLiveSearching: Boolean = false, + val isCompleteSearching: Boolean = false, + + // 통합된 검색 결과 (Live/Complete 구분 없이) + val searchResults: List = emptyList(), + val popularBooks: List = emptyList(), + val recentSearches: List = emptyList(), + + // 로딩 상태 + val isSearching: Boolean = false, + val isLoadingMore: Boolean = false, + + // 페이징 정보 + val currentPage: Int = 1, + val totalElements: Int = 0, + val hasMorePages: Boolean = true, + + // 에러/토스트 + val error: String? = null, + val showToast: Boolean = false, + val toastMessage: String = "" +) { + val hasResults: Boolean get() = searchResults.isNotEmpty() + val canLoadMore: Boolean get() = hasMorePages && !isSearching && !isLoadingMore + val showEmptyState: Boolean get() = searchQuery.isNotBlank() && searchResults.isEmpty() && !isSearching +} \ 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 new file mode 100644 index 00000000..73c7b29a --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt @@ -0,0 +1,269 @@ +package com.texthip.thip.ui.search.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.texthip.thip.data.model.book.response.RecentSearchItem +import com.texthip.thip.data.repository.BookRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SearchBookViewModel @Inject constructor( + private val bookRepository: BookRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(SearchBookUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var searchJob: Job? = null + private var loadMoreJob: Job? = null + + // Map 기반 빠른 최근 검색어 관리 + private val recentSearchMap = mutableMapOf() + + init { + loadInitialData() + } + + private fun updateState(update: (SearchBookUiState) -> SearchBookUiState) { + _uiState.update(update) + } + + fun updateSearchQuery(query: String) { + updateState { it.copy(searchQuery = query) } + searchJob?.cancel() + loadMoreJob?.cancel() + + if (query.isNotBlank()) { + updateState { + it.copy( + isInitial = false, + isLiveSearching = true, + isCompleteSearching = false + ) + } + searchJob = viewModelScope.launch { + delay(1000) // Live search에 딜레이 추가 + performSearch(query, isLiveSearch = true) + } + } else { + clearSearchResults() + } + } + + fun onSearchButtonClick() { + val query = uiState.value.searchQuery.trim() + if (query.isNotBlank()) { + searchJob?.cancel() + loadMoreJob?.cancel() + + updateState { + it.copy( + isInitial = false, + isLiveSearching = false, + isCompleteSearching = true + ) + } + viewModelScope.launch { + performSearch(query, isLiveSearch = false) + loadRecentSearches() + } + } + } + + fun loadMoreBooks() { + val currentState = uiState.value + if (currentState.canLoadMore && currentState.searchQuery.isNotBlank()) { + loadMoreJob?.cancel() + loadMoreJob = viewModelScope.launch { + performLoadMore() + } + } + } + + private suspend fun performSearch(query: String, isLiveSearch: Boolean) { + updateState { + it.copy( + isSearching = true, + error = null, + searchResults = emptyList(), + currentPage = 1 + ) + } + + bookRepository.searchBooks(query, 1, isFinalized = !isLiveSearch) + .onSuccess { response -> + response?.let { searchResponse -> + updateState { + it.copy( + searchResults = searchResponse.searchResult, + currentPage = searchResponse.page, + totalElements = searchResponse.totalElements, + hasMorePages = !searchResponse.last, + isSearching = false, + error = null + ) + } + } ?: run { + updateState { + it.copy( + searchResults = emptyList(), + isSearching = false, + isLiveSearching = false, + isCompleteSearching = false, + hasMorePages = false, + error = if (isLiveSearch) null else "검색 결과를 불러올 수 없습니다." + ) + } + } + } + .onFailure { throwable -> + updateState { + it.copy( + searchResults = emptyList(), + isSearching = false, + isLiveSearching = false, + isCompleteSearching = false, + error = if (isLiveSearch) null else (throwable.message ?: "검색 중 오류가 발생했습니다.") + ) + } + } + } + + private suspend fun performLoadMore() { + val currentState = uiState.value + val nextPage = currentState.currentPage + 1 + + updateState { it.copy(isLoadingMore = true) } + + bookRepository.searchBooks(currentState.searchQuery, nextPage, isFinalized = true) + .onSuccess { response -> + response?.let { searchResponse -> + updateState { + it.copy( + searchResults = it.searchResults + searchResponse.searchResult, + currentPage = searchResponse.page, + totalElements = searchResponse.totalElements, + hasMorePages = !searchResponse.last, + isLoadingMore = false, + error = null + ) + } + } ?: run { + updateState { + it.copy( + isLoadingMore = false, + hasMorePages = false, // null 응답 시 더 이상 페이지가 없음을 명시 + error = "추가 결과를 불러올 수 없습니다." + ) + } + } + } + .onFailure { throwable -> + updateState { + it.copy( + isLoadingMore = false, + error = throwable.message ?: "추가 결과를 불러오는 중 오류가 발생했습니다." + ) + } + } + } + + private fun loadInitialData() { + loadPopularBooks() + loadRecentSearches() + } + + private fun loadPopularBooks() { + viewModelScope.launch { + bookRepository.getMostSearchedBooks() + .onSuccess { response -> + response?.let { mostSearchedBooks -> + updateState { + it.copy(popularBooks = mostSearchedBooks.bookList) + } + } + } + .onFailure { + // 인기 책 로딩 실패는 조용히 처리 + } + } + } + + private fun loadRecentSearches() { + viewModelScope.launch { + bookRepository.getRecentSearches() + .onSuccess { response -> + response?.let { recentSearchResponse -> + // Map에 최근 검색어 저장 (빠른 검색을 위해) + recentSearchMap.clear() + recentSearchResponse.recentSearchList.forEach { item -> + recentSearchMap[item.searchTerm] = item + } + + updateState { + it.copy(recentSearches = recentSearchResponse.recentSearchList) + } + } + } + .onFailure { + // 최근 검색어 로딩 실패는 조용히 처리 + } + } + } + + fun deleteRecentSearch(recentSearchId: Int) { + viewModelScope.launch { + bookRepository.deleteRecentSearch(recentSearchId) + .onSuccess { + loadRecentSearches() // 삭제 성공 시 목록 새로고침 + } + .onFailure { + // 삭제 실패는 조용히 처리 + } + } + } + + /** 키워드로 빠른 최근 검색어 삭제 (Map 기반) */ + fun deleteRecentSearchByKeyword(keyword: String) { + recentSearchMap[keyword]?.let { recentSearchItem -> + deleteRecentSearch(recentSearchItem.recentSearchId) + } + } + + private fun clearSearchResults() { + searchJob?.cancel() + loadMoreJob?.cancel() + updateState { + it.copy( + searchQuery = "", + isInitial = true, + isLiveSearching = false, + isCompleteSearching = false, + searchResults = emptyList(), + currentPage = 1, + hasMorePages = true, + isSearching = false, + isLoadingMore = false, + error = null + ) + } + } + + fun refreshData() { + loadInitialData() + } + + override fun onCleared() { + super.onCleared() + searchJob?.cancel() + loadMoreJob?.cancel() + } +} \ No newline at end of file diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..5c6aa7b9 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,6 @@ + + + + 3.37.87.117 + +