From 30e57f7d5da64eb6d943019257e3bf4ca0061fc5 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Mon, 11 Aug 2025 22:59:38 +0900 Subject: [PATCH 01/35] =?UTF-8?q?[feat]:=20=EC=B1=85=20=EA=B2=80=EC=83=89(?= =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84,=20=EA=B2=80=EC=83=89=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C)=20=EA=B5=AC=ED=98=84=20=EC=99=84=EB=A3=8C=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/book/response/BookSearchResponse.kt | 23 ++ .../thip/data/repository/BookRepository.kt | 8 + .../texthip/thip/data/service/BookService.kt | 8 + .../thip/ui/common/buttons/GenreChipButton.kt | 5 + .../navigator/navigations/SearchNavigation.kt | 5 +- .../ui/search/component/SearchActiveField.kt | 79 ++++- .../component/SearchBookFilteredResult.kt | 81 ++++- .../thip/ui/search/screen/SearchBookScreen.kt | 141 ++++---- .../ui/search/viewmodel/SearchBookUiState.kt | 32 ++ .../search/viewmodel/SearchBookViewModel.kt | 319 ++++++++++++++++++ 10 files changed, 589 insertions(+), 112 deletions(-) create mode 100644 app/src/main/java/com/texthip/thip/data/model/book/response/BookSearchResponse.kt create mode 100644 app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookUiState.kt create mode 100644 app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt 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..b93981af --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/book/response/BookSearchResponse.kt @@ -0,0 +1,23 @@ +package com.texthip.thip.data.model.book.response + +import kotlinx.serialization.Serializable + +@Serializable +data class BookSearchResponse( + val searchResult: List, + val page: Int, + val size: Int, + val totalElements: Int, + val totalPages: Int, + val last: Boolean, + val first: Boolean +) + +@Serializable +data class BookSearchItem( + val title: String, + val imageUrl: String, + val authorName: String, + val publisher: String, + val isbn: String +) \ No newline at end of file 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..f0d35a1e 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,7 @@ package com.texthip.thip.data.repository import com.texthip.thip.data.model.base.handleBaseResponse +import com.texthip.thip.data.model.book.response.BookSearchResponse import com.texthip.thip.data.service.BookService import javax.inject.Inject import javax.inject.Singleton @@ -17,4 +18,11 @@ class BookRepository @Inject constructor( .getOrThrow() ?.bookList ?: emptyList() } + + /** 책 검색 */ + suspend fun searchBooks(keyword: String, page: Int = 1): Result = runCatching { + bookService.searchBooks(keyword, page) + .handleBaseResponse() + .getOrThrow() + } } \ 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..121496c1 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 @@ -2,6 +2,7 @@ package com.texthip.thip.data.service import com.texthip.thip.data.model.base.BaseResponse import com.texthip.thip.data.model.book.response.BookListResponse +import com.texthip.thip.data.model.book.response.BookSearchResponse import retrofit2.http.GET import retrofit2.http.Query @@ -12,4 +13,11 @@ interface BookService { suspend fun getBooks( @Query("type") type: String ): BaseResponse + + /** 책 검색 */ + @GET("books") + suspend fun searchBooks( + @Query("keyword") keyword: String, + @Query("page") page: Int = 1 + ): 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..b226d1eb 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 @@ -34,6 +35,10 @@ fun GenreChipButton( ) { Box( modifier = modifier + .height(40.dp) + .clickable { + onClick() + } .border( width = 1.dp, color = colors.Grey02, 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..7a517fa3 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 @@ -8,6 +8,9 @@ import com.texthip.thip.ui.search.screen.SearchBookScreen fun NavGraphBuilder.searchNavigation(navController: NavHostController) { composable { - SearchBookScreen(navController = navController) + SearchBookScreen( + navController = navController + // TODO: popularBooks를 서버에서 가져와서 전달 + ) } } \ 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..0b039935 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 @@ -2,13 +2,23 @@ package com.texthip.thip.ui.search.component import androidx.compose.foundation.background 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 +27,67 @@ import com.texthip.thip.ui.theme.ThipTheme.colors @Composable fun SearchActiveField( - bookList: List + bookList: List, + isLoading: Boolean = false, + hasMore: Boolean = true, + onLoadMore: () -> Unit = {} ) { + val listState = rememberLazyListState() + + // 무한 스크롤을 위한 로직 + val shouldLoadMore by remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val totalItemsCount = layoutInfo.totalItemsCount + val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 + + hasMore && !isLoading && 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( + 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..14a26944 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 @@ -2,24 +2,32 @@ package com.texthip.thip.ui.search.component import androidx.compose.foundation.background 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 +35,30 @@ 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 = {} ) { + val listState = rememberLazyListState() + + // 무한 스크롤을 위한 로직 + val shouldLoadMore by remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val totalItemsCount = layoutInfo.totalItemsCount + val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 + + hasMore && !isLoading && lastVisibleItemIndex >= totalItemsCount - 3 + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) { + onLoadMore() + } + } + Column { Row( modifier = Modifier.fillMaxWidth(), @@ -48,7 +78,7 @@ fun SearchBookFilteredResult( .background(colors.DarkGrey02) ) - if (bookList.isEmpty()) { + if (bookList.isEmpty() && !isLoading) { SearchEmptyResult( mainText = stringResource(R.string.book_no_search_result1), subText = stringResource(R.string.book_no_search_result2), @@ -56,23 +86,42 @@ fun SearchBookFilteredResult( ) } else { 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( + 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/screen/SearchBookScreen.kt b/app/src/main/java/com/texthip/thip/ui/search/screen/SearchBookScreen.kt index 2eeb0f16..7b7e72a9 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 @@ -9,12 +9,10 @@ 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.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 @@ -24,6 +22,7 @@ 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.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import com.texthip.thip.R import com.texthip.thip.ui.common.forms.SearchBookTextField @@ -33,6 +32,7 @@ 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 @@ -43,9 +43,10 @@ import kotlinx.serialization.builtins.serializer fun SearchBookScreen( modifier: Modifier = Modifier, navController: NavHostController? = null, - bookList: List = emptyList(), - popularBooks: List = emptyList() + popularBooks: List = emptyList(), + viewModel: SearchBookViewModel = hiltViewModel() ) { + val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current val sharedPrefs = remember { context.getSharedPreferences("book_search_prefs", Context.MODE_PRIVATE) @@ -73,42 +74,10 @@ fun SearchBookScreen( 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) - } - } - } - - 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) - } - } - } - } - - LaunchedEffect(isSearched) { - if (isSearched) { - focusManager.clearFocus() - } - } - Box( modifier = modifier.fillMaxSize() ) { @@ -130,59 +99,87 @@ fun SearchBookScreen( .fillMaxWidth() .focusRequester(focusRequester), hint = stringResource(R.string.book_search_hint), - text = searchText, - onValueChange = { - searchText = it - isSearched = false + text = uiState.searchQuery, + onValueChange = { query -> + viewModel.updateSearchQuery(query) }, onSearch = { query -> if (query.isNotBlank() && !recentSearches.contains(query)) { val newSearches = listOf(query) + recentSearches.take(9) // 최대 10개 유지 saveRecentSearches(newSearches) } - isSearched = true + viewModel.onSearchButtonClick() + focusManager.clearFocus() } ) Spacer(modifier = Modifier.height(16.dp)) when { - searchText.isBlank() && !isSearched -> { + // 1. 검색어가 없는 상태 - 최근 검색어와 인기 책 표시 + uiState.searchQuery.isBlank() -> { SearchRecentBook( recentSearches = recentSearches, popularBooks = popularBooks, popularBookDate = "01.12", // TODO: 서버로 날짜를 받아 오게 수정 onSearchClick = { keyword -> - searchText = keyword - isSearched = true + viewModel.updateSearchQuery(keyword) + viewModel.onSearchButtonClick() }, onRemove = { keyword -> val updatedSearches = recentSearches.filterNot { it == keyword } saveRecentSearches(updatedSearches) }, onBookClick = { book -> - // 책 클릭 시 처리 + // 책 클릭 시 처리 (책 상세 화면으로 이동) } ) } - 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 - ) - } + // 2. 검색 완료 상태 - 전체 검색 결과 표시 (무한 스크롤 포함) + uiState.hasSearchResults -> { + SearchBookFilteredResult( + resultCount = uiState.totalElements, + bookList = uiState.searchResults.map { item -> + BookData( + title = item.title, + author = item.authorName, + publisher = item.publisher, + imageUrl = item.imageUrl + ) + }, + isLoading = uiState.isSearching || uiState.isLoadingMore, + hasMore = uiState.canLoadMore, + onLoadMore = { + viewModel.loadMoreBooks() + } + ) } - isSearched -> { - SearchBookFilteredResult( - resultCount = filteredBookList.size, - bookList = filteredBookList, + // 3. Live search 결과가 있는 상태 - SearchActiveField 표시 (무한 스크롤 포함) + uiState.hasLiveResults -> { + SearchActiveField( + bookList = uiState.liveSearchResults.map { item -> + BookData( + title = item.title, + author = item.authorName, + publisher = item.publisher, + imageUrl = item.imageUrl + ) + }, + isLoading = uiState.isLiveSearching || uiState.isLiveLoadingMore, + hasMore = uiState.canLiveLoadMore, + onLoadMore = { + viewModel.loadMoreLiveSearchResults() + } + ) + } + + // 4. 검색어는 있지만 결과가 없는 상태 - Empty 표시 + uiState.showEmptyState -> { + SearchEmptyResult( + mainText = stringResource(R.string.book_no_search_result1), + subText = stringResource(R.string.book_no_search_result2), + onRequestBook = { /*책 요청 처리*/ } ) } } @@ -197,15 +194,6 @@ fun SearchBookScreen( fun PreviewBookSearchScreen_Default() { 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), @@ -222,15 +210,6 @@ fun PreviewBookSearchScreen_Default() { fun PreviewBookSearchScreen_EmptyPopular() { 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() ) } 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..2a5ea2f3 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookUiState.kt @@ -0,0 +1,32 @@ +package com.texthip.thip.ui.search.viewmodel + +import com.texthip.thip.data.model.book.response.BookSearchItem + +data class SearchBookUiState( + val searchQuery: String = "", + val liveSearchResults: List = emptyList(), // Live search 결과 (검색어 입력 시) + val searchResults: List = emptyList(), // 완료된 검색 결과 (검색 버튼 클릭 시) + val isLiveSearching: Boolean = false, // Live search 로딩 상태 + val isSearching: Boolean = false, // 완료된 검색 로딩 상태 + val isLoadingMore: Boolean = false, + val isLiveLoadingMore: Boolean = false, // Live search 무한 스크롤 로딩 + val hasMorePages: Boolean = true, + val liveHasMorePages: Boolean = true, // Live search 페이징 정보 + val isFirstPage: Boolean = true, + val currentPage: Int = 1, + val liveCurrentPage: Int = 1, // Live search 현재 페이지 + val totalPages: Int = 0, + val liveTotalPages: Int = 0, // Live search 총 페이지 + val totalElements: Int = 0, + val liveTotalElements: Int = 0, // Live search 총 요소 + val isSearchCompleted: Boolean = false, // 검색 버튼을 눌러서 완료된 검색인지 + val error: String? = null, + val showToast: Boolean = false, + val toastMessage: String = "" +) { + val hasLiveResults: Boolean get() = liveSearchResults.isNotEmpty() + val hasSearchResults: Boolean get() = searchResults.isNotEmpty() && isSearchCompleted + val canLoadMore: Boolean get() = hasMorePages && !isSearching && !isLoadingMore && isSearchCompleted + val canLiveLoadMore: Boolean get() = liveHasMorePages && !isLiveSearching && !isLiveLoadingMore && !isSearchCompleted + val showEmptyState: Boolean get() = searchQuery.isNotBlank() && liveSearchResults.isEmpty() && !isLiveSearching && !isSearchCompleted +} \ 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..6d79718f --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt @@ -0,0 +1,319 @@ +package com.texthip.thip.ui.search.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.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 + + private fun updateState(update: (SearchBookUiState) -> SearchBookUiState) { + _uiState.value = update(_uiState.value) + } + + fun updateSearchQuery(query: String) { + updateState { it.copy(searchQuery = query, isSearchCompleted = false) } + + // Live search with debounce + searchJob?.cancel() + if (query.isNotBlank()) { + searchJob = viewModelScope.launch { + delay(300) // debounce + performLiveSearch(query) + } + } else { + clearSearchResults() + } + } + + fun onSearchButtonClick() { + val query = uiState.value.searchQuery.trim() + if (query.isNotBlank()) { + performCompleteSearch(query) + } + } + + fun loadMoreBooks() { + val currentState = uiState.value + if (currentState.canLoadMore && currentState.searchQuery.isNotBlank()) { + loadMoreJob?.cancel() + loadMoreJob = viewModelScope.launch { + performLoadMore(currentState.searchQuery) + } + } + } + + fun loadMoreLiveSearchResults() { + val currentState = uiState.value + if (currentState.canLiveLoadMore && currentState.searchQuery.isNotBlank()) { + loadMoreJob?.cancel() + loadMoreJob = viewModelScope.launch { + performLiveSearchLoadMore(currentState.searchQuery) + } + } + } + + private fun performLiveSearch(query: String) { + viewModelScope.launch { + try { + updateState { + it.copy( + isLiveSearching = true, + error = null, + liveSearchResults = emptyList(), // 기존 Live search 결과 클리어 + liveCurrentPage = 1 + ) + } + + bookRepository.searchBooks(query, 1) + .onSuccess { searchResponse -> + searchResponse?.let { response -> + updateState { + it.copy( + liveSearchResults = response.searchResult, // Live search는 전체 결과 표시 (무한스크롤 포함) + liveCurrentPage = response.page, + liveTotalPages = response.totalPages, + liveTotalElements = response.totalElements, + liveHasMorePages = !response.last, + isLiveSearching = false, + error = null + ) + } + } ?: run { + updateState { + it.copy( + liveSearchResults = emptyList(), + isLiveSearching = false, + error = null + ) + } + } + } + .onFailure { + updateState { + it.copy( + liveSearchResults = emptyList(), + isLiveSearching = false, + error = null // Live search 에러는 조용히 처리 + ) + } + } + } catch (e: Exception) { + updateState { + it.copy( + liveSearchResults = emptyList(), + isLiveSearching = false, + error = null + ) + } + } + } + } + + private fun performLiveSearchLoadMore(query: String) { + viewModelScope.launch { + try { + val currentState = uiState.value + val nextPage = currentState.liveCurrentPage + 1 + + updateState { it.copy(isLiveLoadingMore = true) } + + bookRepository.searchBooks(query, nextPage) + .onSuccess { searchResponse -> + searchResponse?.let { response -> + updateState { + it.copy( + liveSearchResults = it.liveSearchResults + response.searchResult, + liveCurrentPage = response.page, + liveTotalPages = response.totalPages, + liveTotalElements = response.totalElements, + liveHasMorePages = !response.last, + isLiveLoadingMore = false, + error = null + ) + } + } ?: run { + updateState { + it.copy( + isLiveLoadingMore = false, + error = null + ) + } + } + } + .onFailure { + updateState { + it.copy( + isLiveLoadingMore = false, + error = null // Live search 에러는 조용히 처리 + ) + } + } + } catch (e: Exception) { + updateState { + it.copy( + isLiveLoadingMore = false, + error = null + ) + } + } + } + } + + private fun performCompleteSearch(query: String) { + viewModelScope.launch { + try { + updateState { + it.copy( + isSearching = true, + isSearchCompleted = true, + error = null, + searchResults = emptyList(), // 기존 검색 결과 클리어 + currentPage = 1 + ) + } + + bookRepository.searchBooks(query, 1) + .onSuccess { searchResponse -> + searchResponse?.let { response -> + updateState { + it.copy( + searchResults = response.searchResult, + currentPage = response.page, + totalPages = response.totalPages, + totalElements = response.totalElements, + hasMorePages = !response.last, + isFirstPage = response.first, + isSearching = false, + error = null + ) + } + } ?: run { + updateState { + it.copy( + searchResults = emptyList(), + isSearching = false, + error = "검색 결과를 불러올 수 없습니다." + ) + } + } + } + .onFailure { throwable -> + updateState { + it.copy( + searchResults = emptyList(), + isSearching = false, + error = throwable.message ?: "검색 중 오류가 발생했습니다." + ) + } + } + } catch (e: Exception) { + updateState { + it.copy( + searchResults = emptyList(), + isSearching = false, + error = e.message ?: "검색 중 오류가 발생했습니다." + ) + } + } + } + } + + private fun performLoadMore(query: String) { + viewModelScope.launch { + try { + val currentState = uiState.value + val nextPage = currentState.currentPage + 1 + + updateState { it.copy(isLoadingMore = true) } + + bookRepository.searchBooks(query, nextPage) + .onSuccess { searchResponse -> + searchResponse?.let { response -> + updateState { + it.copy( + searchResults = it.searchResults + response.searchResult, + currentPage = response.page, + totalPages = response.totalPages, + totalElements = response.totalElements, + hasMorePages = !response.last, + isLoadingMore = false, + error = null + ) + } + } ?: run { + updateState { + it.copy( + isLoadingMore = false, + error = "추가 결과를 불러올 수 없습니다." + ) + } + } + } + .onFailure { throwable -> + updateState { + it.copy( + isLoadingMore = false, + error = throwable.message ?: "추가 결과를 불러오는 중 오류가 발생했습니다." + ) + } + } + } catch (e: Exception) { + updateState { + it.copy( + isLoadingMore = false, + error = e.message ?: "추가 결과를 불러오는 중 오류가 발생했습니다." + ) + } + } + } + } + + + private fun clearSearchResults() { + searchJob?.cancel() + loadMoreJob?.cancel() + updateState { + SearchBookUiState(searchQuery = it.searchQuery) + } + } + + fun showToastMessage(message: String) { + updateState { + it.copy( + toastMessage = message, + showToast = true + ) + } + } + + fun hideToast() { + updateState { it.copy(showToast = false) } + } + + fun clearError() { + updateState { it.copy(error = null) } + } + + override fun onCleared() { + super.onCleared() + searchJob?.cancel() + loadMoreJob?.cancel() + } +} \ No newline at end of file From 38357a0c9fb4a4c01e719bc5ab8d09324a8b4921 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Mon, 11 Aug 2025 23:20:16 +0900 Subject: [PATCH 02/35] =?UTF-8?q?[feat]:=20=EA=B0=80=EC=9E=A5=20=EB=A7=8E?= =?UTF-8?q?=EC=9D=B4=20=EA=B2=80=EC=83=89=EB=90=9C=20=EC=B1=85=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EC=99=84=EB=A3=8C=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/MostSearchedBooksResponse.kt | 16 ++++++ .../thip/data/repository/BookRepository.kt | 8 +++ .../texthip/thip/data/service/BookService.kt | 5 ++ .../thip/ui/search/screen/SearchBookScreen.kt | 24 ++++---- .../ui/search/viewmodel/SearchBookUiState.kt | 3 + .../search/viewmodel/SearchBookViewModel.kt | 55 ++++++++++++++++++- 6 files changed, 96 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/texthip/thip/data/model/book/response/MostSearchedBooksResponse.kt 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..7bb61cd3 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/book/response/MostSearchedBooksResponse.kt @@ -0,0 +1,16 @@ +package com.texthip.thip.data.model.book.response + +import kotlinx.serialization.Serializable + +@Serializable +data class MostSearchedBooksResponse( + val bookList: List +) + +@Serializable +data class PopularBookItem( + val rank: Int, + val title: String, + val imageUrl: String, + val isbn: String +) \ No newline at end of file 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 f0d35a1e..c0691552 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 @@ -2,6 +2,7 @@ package com.texthip.thip.data.repository import com.texthip.thip.data.model.base.handleBaseResponse import com.texthip.thip.data.model.book.response.BookSearchResponse +import com.texthip.thip.data.model.book.response.MostSearchedBooksResponse import com.texthip.thip.data.service.BookService import javax.inject.Inject import javax.inject.Singleton @@ -25,4 +26,11 @@ class BookRepository @Inject constructor( .handleBaseResponse() .getOrThrow() } + + /** 인기 책 조회 */ + suspend fun getMostSearchedBooks(): Result = runCatching { + bookService.getMostSearchedBooks() + .handleBaseResponse() + .getOrThrow() + } } \ 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 121496c1..8b060809 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 @@ -3,6 +3,7 @@ package com.texthip.thip.data.service import com.texthip.thip.data.model.base.BaseResponse import com.texthip.thip.data.model.book.response.BookListResponse import com.texthip.thip.data.model.book.response.BookSearchResponse +import com.texthip.thip.data.model.book.response.MostSearchedBooksResponse import retrofit2.http.GET import retrofit2.http.Query @@ -20,4 +21,8 @@ interface BookService { @Query("keyword") keyword: String, @Query("page") page: Int = 1 ): BaseResponse + + /** 인기 책 조회 */ + @GET("books/most-searched") + suspend fun getMostSearchedBooks(): BaseResponse } \ 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 7b7e72a9..3d594e01 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 @@ -43,7 +43,6 @@ import kotlinx.serialization.builtins.serializer fun SearchBookScreen( modifier: Modifier = Modifier, navController: NavHostController? = null, - popularBooks: List = emptyList(), viewModel: SearchBookViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() @@ -119,7 +118,14 @@ fun SearchBookScreen( uiState.searchQuery.isBlank() -> { SearchRecentBook( recentSearches = recentSearches, - popularBooks = popularBooks, + popularBooks = uiState.popularBooks.map { item -> + BookData( + title = item.title, + author = "", + publisher = "", + imageUrl = item.imageUrl + ) + }, popularBookDate = "01.12", // TODO: 서버로 날짜를 받아 오게 수정 onSearchClick = { keyword -> viewModel.updateSearchQuery(keyword) @@ -193,15 +199,7 @@ fun SearchBookScreen( @Composable fun PreviewBookSearchScreen_Default() { ThipTheme { - SearchBookScreen( - 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), - ) - ) + SearchBookScreen() } } @@ -209,8 +207,6 @@ fun PreviewBookSearchScreen_Default() { @Composable fun PreviewBookSearchScreen_EmptyPopular() { ThipTheme { - SearchBookScreen( - popularBooks = emptyList() - ) + SearchBookScreen() } } 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 index 2a5ea2f3..183591ca 100644 --- 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 @@ -1,13 +1,16 @@ 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 data class SearchBookUiState( val searchQuery: String = "", val liveSearchResults: List = emptyList(), // Live search 결과 (검색어 입력 시) val searchResults: List = emptyList(), // 완료된 검색 결과 (검색 버튼 클릭 시) + val popularBooks: List = emptyList(), // 인기 책 목록 val isLiveSearching: Boolean = false, // Live search 로딩 상태 val isSearching: Boolean = false, // 완료된 검색 로딩 상태 + val isLoadingPopularBooks: Boolean = false, // 인기 책 로딩 상태 val isLoadingMore: Boolean = false, val isLiveLoadingMore: Boolean = false, // Live search 무한 스크롤 로딩 val hasMorePages: Boolean = true, diff --git a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt index 6d79718f..a79ea8bd 100644 --- a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt @@ -22,6 +22,10 @@ class SearchBookViewModel @Inject constructor( private var searchJob: Job? = null private var loadMoreJob: Job? = null + + init { + loadPopularBooks() + } private fun updateState(update: (SearchBookUiState) -> SearchBookUiState) { _uiState.value = update(_uiState.value) @@ -286,11 +290,60 @@ class SearchBookViewModel @Inject constructor( } + private fun loadPopularBooks() { + viewModelScope.launch { + try { + updateState { it.copy(isLoadingPopularBooks = true) } + + bookRepository.getMostSearchedBooks() + .onSuccess { response -> + response?.let { mostSearchedBooks -> + updateState { + it.copy( + popularBooks = mostSearchedBooks.bookList, + isLoadingPopularBooks = false, + error = null + ) + } + } ?: run { + updateState { + it.copy( + popularBooks = emptyList(), + isLoadingPopularBooks = false, + error = null + ) + } + } + } + .onFailure { + updateState { + it.copy( + popularBooks = emptyList(), + isLoadingPopularBooks = false, + error = null // 인기 책 로딩 실패는 조용히 처리 + ) + } + } + } catch (e: Exception) { + updateState { + it.copy( + popularBooks = emptyList(), + isLoadingPopularBooks = false, + error = null + ) + } + } + } + } + private fun clearSearchResults() { searchJob?.cancel() loadMoreJob?.cancel() updateState { - SearchBookUiState(searchQuery = it.searchQuery) + SearchBookUiState( + searchQuery = it.searchQuery, + popularBooks = it.popularBooks // 인기 책은 유지 + ) } } From e8abc49537cc408584def9c64d0ac1216de6da31 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Mon, 11 Aug 2025 23:57:24 +0900 Subject: [PATCH 03/35] =?UTF-8?q?[feat]:=20=EC=B5=9C=EA=B7=BC=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=96=B4=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?ui=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book/response/RecentSearchResponse.kt | 14 ++++ .../thip/data/repository/BookRepository.kt | 15 ++++ .../texthip/thip/data/service/BookService.kt | 15 ++++ .../thip/ui/search/screen/SearchBookScreen.kt | 69 +++++++---------- .../ui/search/viewmodel/SearchBookUiState.kt | 3 + .../search/viewmodel/SearchBookViewModel.kt | 76 ++++++++++++++++++- 6 files changed, 151 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/com/texthip/thip/data/model/book/response/RecentSearchResponse.kt 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..521c07a7 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/book/response/RecentSearchResponse.kt @@ -0,0 +1,14 @@ +package com.texthip.thip.data.model.book.response + +import kotlinx.serialization.Serializable + +@Serializable +data class RecentSearchResponse( + val recentSearchList: List +) + +@Serializable +data class RecentSearchItem( + val recentSearchId: Int, + val searchTerm: String +) \ No newline at end of file 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 c0691552..bde361a0 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 @@ -3,6 +3,7 @@ package com.texthip.thip.data.repository import com.texthip.thip.data.model.base.handleBaseResponse 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.service.BookService import javax.inject.Inject import javax.inject.Singleton @@ -33,4 +34,18 @@ class BookRepository @Inject constructor( .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() + } } \ 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 8b060809..4364224d 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 @@ -4,7 +4,10 @@ import com.texthip.thip.data.model.base.BaseResponse import com.texthip.thip.data.model.book.response.BookListResponse 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 retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.Path import retrofit2.http.Query interface BookService { @@ -25,4 +28,16 @@ interface BookService { /** 인기 책 조회 */ @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 } \ 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 3d594e01..f88d5d61 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,15 +8,17 @@ 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.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner 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 @@ -34,10 +35,6 @@ 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( @@ -46,36 +43,29 @@ fun SearchBookScreen( viewModel: SearchBookViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() - val context = LocalContext.current - val sharedPrefs = remember { - context.getSharedPreferences("book_search_prefs", Context.MODE_PRIVATE) - } + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + val lifecycleOwner = LocalLifecycleOwner.current - var recentSearches by remember { - mutableStateOf( - try { - val jsonString = sharedPrefs.getString("recent_book_searches", "[]") ?: "[]" - Json.decodeFromString>(jsonString) - } catch (e: Exception) { - emptyList() - } - ) + // 화면 진입 시 무조건 새로고침 + LaunchedEffect(Unit) { + viewModel.refreshData() } - fun saveRecentSearches(searches: List) { - try { - val jsonString = Json.encodeToString(ListSerializer(String.serializer()), searches) - sharedPrefs.edit { - putString("recent_book_searches", jsonString) + // 화면 생명주기를 감지하여 새로고침 (뒤로가기 포함) + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + viewModel.refreshData() } - recentSearches = searches - } catch (e: Exception) { - recentSearches = emptyList() + } + + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) } } - - val focusRequester = remember { FocusRequester() } - val focusManager = LocalFocusManager.current Box( modifier = modifier.fillMaxSize() @@ -103,10 +93,6 @@ fun SearchBookScreen( viewModel.updateSearchQuery(query) }, onSearch = { query -> - if (query.isNotBlank() && !recentSearches.contains(query)) { - val newSearches = listOf(query) + recentSearches.take(9) // 최대 10개 유지 - saveRecentSearches(newSearches) - } viewModel.onSearchButtonClick() focusManager.clearFocus() } @@ -117,7 +103,7 @@ fun SearchBookScreen( // 1. 검색어가 없는 상태 - 최근 검색어와 인기 책 표시 uiState.searchQuery.isBlank() -> { SearchRecentBook( - recentSearches = recentSearches, + recentSearches = uiState.recentSearches.map { it.searchTerm }, popularBooks = uiState.popularBooks.map { item -> BookData( title = item.title, @@ -132,8 +118,11 @@ fun SearchBookScreen( viewModel.onSearchButtonClick() }, onRemove = { keyword -> - val updatedSearches = recentSearches.filterNot { it == keyword } - saveRecentSearches(updatedSearches) + // 서버에서 해당 검색어를 찾아서 삭제 + val recentSearchItem = uiState.recentSearches.find { it.searchTerm == keyword } + recentSearchItem?.let { + viewModel.deleteRecentSearch(it.recentSearchId) + } }, onBookClick = { book -> // 책 클릭 시 처리 (책 상세 화면으로 이동) @@ -142,7 +131,7 @@ fun SearchBookScreen( } // 2. 검색 완료 상태 - 전체 검색 결과 표시 (무한 스크롤 포함) - uiState.hasSearchResults -> { + uiState.isSearchCompleted -> { SearchBookFilteredResult( resultCount = uiState.totalElements, bookList = uiState.searchResults.map { item -> 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 index 183591ca..855a56e4 100644 --- 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 @@ -2,15 +2,18 @@ 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 = "", val liveSearchResults: List = emptyList(), // Live search 결과 (검색어 입력 시) val searchResults: List = emptyList(), // 완료된 검색 결과 (검색 버튼 클릭 시) val popularBooks: List = emptyList(), // 인기 책 목록 + val recentSearches: List = emptyList(), // 최근 검색어 목록 val isLiveSearching: Boolean = false, // Live search 로딩 상태 val isSearching: Boolean = false, // 완료된 검색 로딩 상태 val isLoadingPopularBooks: Boolean = false, // 인기 책 로딩 상태 + val isLoadingRecentSearches: Boolean = false, // 최근 검색어 로딩 상태 val isLoadingMore: Boolean = false, val isLiveLoadingMore: Boolean = false, // Live search 무한 스크롤 로딩 val hasMorePages: Boolean = true, diff --git a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt index a79ea8bd..f54c3920 100644 --- a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt @@ -25,6 +25,7 @@ class SearchBookViewModel @Inject constructor( init { loadPopularBooks() + loadRecentSearches() } private fun updateState(update: (SearchBookUiState) -> SearchBookUiState) { @@ -50,6 +51,8 @@ class SearchBookViewModel @Inject constructor( val query = uiState.value.searchQuery.trim() if (query.isNotBlank()) { performCompleteSearch(query) + // 검색 완료 후 최신 검색어 목록 불러오기 (새로운 검색어가 추가되었을 수 있음) + loadRecentSearches() } } @@ -336,15 +339,81 @@ class SearchBookViewModel @Inject constructor( } } + private fun loadRecentSearches() { + viewModelScope.launch { + try { + updateState { it.copy(isLoadingRecentSearches = true) } + + bookRepository.getRecentSearches() + .onSuccess { response -> + response?.let { recentSearchResponse -> + updateState { + it.copy( + recentSearches = recentSearchResponse.recentSearchList, + isLoadingRecentSearches = false, + error = null + ) + } + } ?: run { + updateState { + it.copy( + recentSearches = emptyList(), + isLoadingRecentSearches = false, + error = null + ) + } + } + } + .onFailure { throwable -> + updateState { + it.copy( + recentSearches = emptyList(), + isLoadingRecentSearches = false, + error = null // 최근 검색어 로딩 실패는 조용히 처리 + ) + } + } + } catch (e: Exception) { + updateState { + it.copy( + recentSearches = emptyList(), + isLoadingRecentSearches = false, + error = null + ) + } + } + } + } + + fun deleteRecentSearch(recentSearchId: Int) { + viewModelScope.launch { + try { + bookRepository.deleteRecentSearch(recentSearchId) + .onSuccess { + // 삭제 성공 시 목록 새로고침 + loadRecentSearches() + } + .onFailure { + // 삭제 실패는 조용히 처리하거나 Toast로 알림 표시 가능 + } + } catch (e: Exception) { + // 예외 처리 + } + } + } + private fun clearSearchResults() { searchJob?.cancel() loadMoreJob?.cancel() updateState { SearchBookUiState( searchQuery = it.searchQuery, - popularBooks = it.popularBooks // 인기 책은 유지 + popularBooks = it.popularBooks, // 인기 책은 유지 + recentSearches = it.recentSearches // 최근 검색어도 유지 ) } + // 검색어가 삭제되어 초기화면으로 돌아갈 때 최신 검색기록 불러오기 + loadRecentSearches() } fun showToastMessage(message: String) { @@ -364,6 +433,11 @@ class SearchBookViewModel @Inject constructor( updateState { it.copy(error = null) } } + fun refreshData() { + loadPopularBooks() + loadRecentSearches() + } + override fun onCleared() { super.onCleared() searchJob?.cancel() From ec08e2c742e7a243ece8b2d3afcf2b3221602a31 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 12 Aug 2025 20:58:50 +0900 Subject: [PATCH 04/35] =?UTF-8?q?[feat]:=20=EC=B1=85=20=EC=9E=90=EC=84=B8?= =?UTF-8?q?=ED=9E=88=EB=B3=B4=EA=B8=B0=20dto,=20service,=20repository=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/book/response/BookDetailResponse.kt | 16 ++++++++++++++++ .../thip/data/repository/BookRepository.kt | 8 ++++++++ .../com/texthip/thip/data/service/BookService.kt | 7 +++++++ 3 files changed, 31 insertions(+) create mode 100644 app/src/main/java/com/texthip/thip/data/model/book/response/BookDetailResponse.kt diff --git a/app/src/main/java/com/texthip/thip/data/model/book/response/BookDetailResponse.kt b/app/src/main/java/com/texthip/thip/data/model/book/response/BookDetailResponse.kt new file mode 100644 index 00000000..9d29a609 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/book/response/BookDetailResponse.kt @@ -0,0 +1,16 @@ +package com.texthip.thip.data.model.book.response + +import kotlinx.serialization.Serializable + +@Serializable +data class BookDetailResponse( + val title: String, + val imageUrl: String, + val authorName: String, + val publisher: String, + val isbn: String, + val description: String, + val recruitingRoomCount: Int, + val readCount: Int, + val isSaved: Boolean +) \ No newline at end of file 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 bde361a0..e84091ff 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,7 @@ package com.texthip.thip.data.repository import com.texthip.thip.data.model.base.handleBaseResponse +import com.texthip.thip.data.model.book.response.BookDetailResponse 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 @@ -48,4 +49,11 @@ class BookRepository @Inject constructor( .handleBaseResponse() .getOrThrow() } + + /** 책 상세 조회 */ + suspend fun getBookDetail(isbn: String): Result = runCatching { + bookService.getBookDetail(isbn) + .handleBaseResponse() + .getOrThrow() + } } \ 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 4364224d..f7a36654 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,6 +1,7 @@ package com.texthip.thip.data.service import com.texthip.thip.data.model.base.BaseResponse +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.BookSearchResponse import com.texthip.thip.data.model.book.response.MostSearchedBooksResponse @@ -40,4 +41,10 @@ interface BookService { suspend fun deleteRecentSearch( @Path("recentSearchId") recentSearchId: Int ): BaseResponse + + /** 책 상세 조회 */ + @GET("books/{isbn}") + suspend fun getBookDetail( + @Path("isbn") isbn: String + ): BaseResponse } \ No newline at end of file From 1b9f563fbcbc6212bdf9da7a44cc865fa325202b Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 12 Aug 2025 21:44:09 +0900 Subject: [PATCH 05/35] =?UTF-8?q?[feat]:=20=EC=B1=85=20=EC=9E=90=EC=84=B8?= =?UTF-8?q?=ED=9E=88=20=EB=B3=B4=EA=B8=B0=20viewModel=20=EB=B0=8F=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=97=B0=EA=B2=B0=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/search/component/SearchActiveField.kt | 5 +- .../component/SearchBookFilteredResult.kt | 5 +- .../texthip/thip/ui/search/mock/BookData.kt | 3 +- .../thip/ui/search/screen/SearchBookScreen.kt | 45 ++++++++--------- .../search/viewmodel/BookDetailViewModel.kt | 48 +++++++++++++++++++ 5 files changed, 77 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/com/texthip/thip/ui/search/viewmodel/BookDetailViewModel.kt 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 0b039935..480b7220 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,6 +1,7 @@ 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 @@ -30,7 +31,8 @@ fun SearchActiveField( bookList: List, isLoading: Boolean = false, hasMore: Boolean = true, - onLoadMore: () -> Unit = {} + onLoadMore: () -> Unit = {}, + onBookClick: (BookData) -> Unit = {} ) { val listState = rememberLazyListState() @@ -58,6 +60,7 @@ fun SearchActiveField( itemsIndexed(bookList) { index, book -> Column { CardBookList( + modifier = Modifier.clickable { onBookClick(book) }, title = book.title, author = book.author, publisher = book.publisher, 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 14a26944..54e7b752 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,6 +1,7 @@ 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 @@ -38,7 +39,8 @@ fun SearchBookFilteredResult( bookList: List, isLoading: Boolean = false, hasMore: Boolean = true, - onLoadMore: () -> Unit = {} + onLoadMore: () -> Unit = {}, + onBookClick: (BookData) -> Unit = {} ) { val listState = rememberLazyListState() @@ -92,6 +94,7 @@ fun SearchBookFilteredResult( itemsIndexed(bookList) { index, book -> Column { CardBookList( + modifier = Modifier.clickable { onBookClick(book) }, title = book.title, author = book.author, publisher = book.publisher, 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/SearchBookScreen.kt b/app/src/main/java/com/texthip/thip/ui/search/screen/SearchBookScreen.kt index f88d5d61..eab4359a 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 @@ -9,13 +9,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -24,7 +20,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavHostController +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import com.texthip.thip.R import com.texthip.thip.ui.common.forms.SearchBookTextField import com.texthip.thip.ui.common.topappbar.LeftNameTopAppBar @@ -39,20 +37,14 @@ import com.texthip.thip.ui.theme.ThipTheme @Composable fun SearchBookScreen( modifier: Modifier = Modifier, - navController: NavHostController? = null, - viewModel: SearchBookViewModel = hiltViewModel() + viewModel: SearchBookViewModel = hiltViewModel(), + onBookClick: (String) -> Unit = {} ) { val uiState by viewModel.uiState.collectAsState() val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current val lifecycleOwner = LocalLifecycleOwner.current - // 화면 진입 시 무조건 새로고침 - LaunchedEffect(Unit) { - viewModel.refreshData() - } - - // 화면 생명주기를 감지하여 새로고침 (뒤로가기 포함) DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { @@ -109,7 +101,8 @@ fun SearchBookScreen( title = item.title, author = "", publisher = "", - imageUrl = item.imageUrl + imageUrl = item.imageUrl, + isbn = item.isbn ) }, popularBookDate = "01.12", // TODO: 서버로 날짜를 받아 오게 수정 @@ -125,7 +118,7 @@ fun SearchBookScreen( } }, onBookClick = { book -> - // 책 클릭 시 처리 (책 상세 화면으로 이동) + onBookClick(book.isbn) } ) } @@ -139,13 +132,17 @@ fun SearchBookScreen( title = item.title, author = item.authorName, publisher = item.publisher, - imageUrl = item.imageUrl + imageUrl = item.imageUrl, + isbn = item.isbn ) }, isLoading = uiState.isSearching || uiState.isLoadingMore, hasMore = uiState.canLoadMore, onLoadMore = { viewModel.loadMoreBooks() + }, + onBookClick = { book -> + onBookClick(book.isbn) } ) } @@ -158,13 +155,17 @@ fun SearchBookScreen( title = item.title, author = item.authorName, publisher = item.publisher, - imageUrl = item.imageUrl + imageUrl = item.imageUrl, + isbn = item.isbn ) }, isLoading = uiState.isLiveSearching || uiState.isLiveLoadingMore, hasMore = uiState.canLiveLoadMore, onLoadMore = { viewModel.loadMoreLiveSearchResults() + }, + onBookClick = { book -> + onBookClick(book.isbn) } ) } @@ -186,15 +187,7 @@ fun SearchBookScreen( @Preview(showBackground = true) @Composable -fun PreviewBookSearchScreen_Default() { - ThipTheme { - SearchBookScreen() - } -} - -@Preview -@Composable -fun PreviewBookSearchScreen_EmptyPopular() { +fun PreviewBookSearchScreen() { ThipTheme { SearchBookScreen() } 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..1facab87 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/BookDetailViewModel.kt @@ -0,0 +1,48 @@ +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 ?: "책 정보를 불러오는데 실패했습니다." + ) + } + } + } +} + +data class BookDetailUiState( + val bookDetail: BookDetailResponse? = null, + val isLoading: Boolean = false, + val error: String? = null +) \ No newline at end of file From 022482259e4fad8d82c9d6fbb43022e04cdd46b2 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 12 Aug 2025 21:44:19 +0900 Subject: [PATCH 06/35] =?UTF-8?q?[feat]:=20=EC=B1=85=20=EC=9E=90=EC=84=B8?= =?UTF-8?q?=ED=9E=88=20=EB=B3=B4=EA=B8=B0=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=97=B0=EA=B2=B0=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../extensions/SearchNavigationExtensions.kt | 5 +++ .../navigator/navigations/SearchNavigation.kt | 33 +++++++++++++++++-- .../thip/ui/navigator/routes/SearchRoutes.kt | 4 ++- 3 files changed, 39 insertions(+), 3 deletions(-) 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..26f0c429 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,14 @@ 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)) } \ No newline at end of file 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 7a517fa3..25b353b7 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,14 +3,43 @@ 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.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 fun NavGraphBuilder.searchNavigation(navController: NavHostController) { composable { SearchBookScreen( - navController = navController - // TODO: popularBooks를 서버에서 가져와서 전달 + onBookClick = { isbn -> + navController.navigateToBookDetail(isbn) + } + ) + } + + composable { backStackEntry -> + val route = backStackEntry.toRoute() + val isbn = route.isbn + + SearchBookDetailScreen( + isbn = isbn, + onLeftClick = { + navController.popBackStack() + }, + onRightClick = { + // TODO: 우측 버튼 액션 구현 + }, + onRecruitingGroupClick = { + // TODO: 모집중인 그룹 화면으로 이동 + }, + onBookMarkClick = { isBookmarked -> + // TODO: 북마크 액션 구현 + }, + onWriteFeedClick = { + // TODO: 피드 작성 화면으로 이동 + } ) } } \ No newline at end of file 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..bc1edc40 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() { + @Serializable + data class BookDetail(val isbn: String) : SearchRoutes() + // 향후 추가될 Search 관련 화면들 - // @Serializable data object BookDetail : SearchRoutes // @Serializable data object BookGroup : SearchRoutes } \ No newline at end of file From 66c1e30870cfe129913baba30144b03336d1de36 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 12 Aug 2025 21:44:45 +0900 Subject: [PATCH 07/35] =?UTF-8?q?[feat]:=20=EC=B1=85=20=EC=9E=90=EC=84=B8?= =?UTF-8?q?=ED=9E=88=20=EB=B3=B4=EA=B8=B0=20Detail=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../search/screen/SearchBookDetailScreen.kt | 489 ++++++++++-------- 1 file changed, 260 insertions(+), 229 deletions(-) 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..80971917 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,7 @@ 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.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,14 +54,16 @@ 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() var isAlarmVisible by remember { mutableStateOf(true) } var isIntroductionPopupVisible by remember { mutableStateOf(false) } var isBookmarked by remember { mutableStateOf(false) } @@ -69,6 +74,11 @@ fun SearchBookDetailScreen( stringResource(R.string.search_filter_latest) ) + // ISBN으로 책 상세 정보 로드 + LaunchedEffect(isbn) { + viewModel.loadBookDetail(isbn) + } + // 알림 5초간 노출 LaunchedEffect(Unit) { isAlarmVisible = true @@ -76,248 +86,279 @@ fun SearchBookDetailScreen( isAlarmVisible = false } - Box(modifier = modifier.fillMaxSize()) { - // 메인 컨텐츠 - Box( - modifier = Modifier - .fillMaxSize() - .then( - if (isIntroductionPopupVisible) { - Modifier.blur(4.dp) - } else { - Modifier - } + when { + uiState.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 - ) - ) - ) - } } + } + uiState.error != null -> { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = uiState.error!!, + color = colors.White, + style = typography.smalltitle_sb600_s16_h20 + ) + } + } + uiState.bookDetail != null -> { + val bookDetail = uiState.bookDetail!! + isBookmarked = bookDetail.isSaved - 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( + 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 { + 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) + ) + + // 피드 리스트 + 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 -> {} } } @@ -326,17 +367,7 @@ fun SearchBookDetailScreen( fun PreviewBookDetailScreen() { ThipTheme { SearchBookDetailScreen( - book = DetailBookData( - title = "채식주의자", - author = "한강", - publisher = "창비", - description = - "인터내셔널 북커상, 산클레멘테 문학상 수상작. 전세계가 주목한 인간의 역작을 다시 만나다.2016년 인터내셔널 북커상을 수상하며 한국문학의 입지를 한단계 확장시킨 한강의 명단소설 『채식주의자』. 15년 만에 새로운 장정과 판형으로 출간된다. 식물화로 건설해온 극단적이며 실재적인 상상력의 강렬한 결실로 고통과 구속의 피안에 존재하는 인간의 본성에 다가간 작품." + - "인터내셔널 북커상, 산클레멘테 문학상 수상작. 전세계가 주목한 인간의 역작을 다시 만나다. \n\n2016년 인터내셔널 북커상을 수상하며 한국문학의 입지를 한단계 확장시킨 한강의 명단소설 『채식주의자』. 15년 만에 새로운 장정과 판형으로 출간된다. 식물화로 건설해온 극단적이며 실재적인 상상력의 강렬한 결실로 고통과 구속의 피안에 존재하는 인간의 본성에 다가간 작품.", - coverImageRes = R.drawable.img_book_cover_sample, - participantsCount = 210, - recruitingRoomCount = 4 - ), + isbn = "9788954682152", feedList = emptyList() ) } From 03163bf77a352a6ea2e3313f24fd53953f95ef09 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 12 Aug 2025 22:07:00 +0900 Subject: [PATCH 08/35] =?UTF-8?q?[feat]:=20=EA=B2=80=EC=83=89=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=82=A0=EC=A7=9C=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/texthip/thip/ui/search/screen/SearchBookScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 eab4359a..69e03765 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 @@ -23,6 +23,9 @@ 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 @@ -105,7 +108,7 @@ fun SearchBookScreen( isbn = item.isbn ) }, - popularBookDate = "01.12", // TODO: 서버로 날짜를 받아 오게 수정 + popularBookDate = SimpleDateFormat("MM.dd", Locale.getDefault()).format(Date()), onSearchClick = { keyword -> viewModel.updateSearchQuery(keyword) viewModel.onSearchButtonClick() From 1ed12fdee688c70ede0fad4461b63c1b98cac4a9 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 12 Aug 2025 22:17:14 +0900 Subject: [PATCH 09/35] =?UTF-8?q?[feat]:=20=EC=B1=85=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20dto,=20service,=20?= =?UTF-8?q?repository=20=EA=B5=AC=ED=98=84=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/data/model/book/request/BookSaveRequest.kt | 8 ++++++++ .../thip/data/model/book/response/BookSaveResponse.kt | 9 +++++++++ .../texthip/thip/data/repository/BookRepository.kt | 9 +++++++++ .../java/com/texthip/thip/data/service/BookService.kt | 11 +++++++++++ 4 files changed, 37 insertions(+) create mode 100644 app/src/main/java/com/texthip/thip/data/model/book/request/BookSaveRequest.kt create mode 100644 app/src/main/java/com/texthip/thip/data/model/book/response/BookSaveResponse.kt diff --git a/app/src/main/java/com/texthip/thip/data/model/book/request/BookSaveRequest.kt b/app/src/main/java/com/texthip/thip/data/model/book/request/BookSaveRequest.kt new file mode 100644 index 00000000..10686a05 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/book/request/BookSaveRequest.kt @@ -0,0 +1,8 @@ +package com.texthip.thip.data.model.book.request + +import kotlinx.serialization.Serializable + +@Serializable +data class BookSaveRequest( + val type: Boolean +) \ 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..7de3eee4 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/book/response/BookSaveResponse.kt @@ -0,0 +1,9 @@ +package com.texthip.thip.data.model.book.response + +import kotlinx.serialization.Serializable + +@Serializable +data class BookSaveResponse( + val isbn: String, + val isSaved: Boolean +) \ No newline at end of file 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 e84091ff..b01b5b64 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,7 +1,9 @@ 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.BookSearchResponse import com.texthip.thip.data.model.book.response.MostSearchedBooksResponse import com.texthip.thip.data.model.book.response.RecentSearchResponse @@ -56,4 +58,11 @@ class BookRepository @Inject constructor( .handleBaseResponse() .getOrThrow() } + + /** 책 저장/저장취소 */ + suspend fun saveBook(isbn: String, type: Boolean): Result = runCatching { + bookService.saveBook(isbn, BookSaveRequest(type)) + .handleBaseResponse() + .getOrThrow() + } } \ 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 f7a36654..c4d98d71 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,13 +1,17 @@ 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 retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Query @@ -47,4 +51,11 @@ interface BookService { suspend fun getBookDetail( @Path("isbn") isbn: String ): BaseResponse + + /** 책 저장/저장취소 */ + @POST("books/{isbn}/saved") + suspend fun saveBook( + @Path("isbn") isbn: String, + @Body request: BookSaveRequest + ): BaseResponse } \ No newline at end of file From 3cee27ff65bd76ff744e9168bd9bf78e43f6b19f Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Tue, 12 Aug 2025 22:18:02 +0900 Subject: [PATCH 10/35] =?UTF-8?q?[feat]:=20=EC=B1=85=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../search/screen/SearchBookDetailScreen.kt | 6 +++-- .../search/viewmodel/BookDetailViewModel.kt | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) 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 80971917..75c8d38f 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 @@ -269,8 +269,10 @@ fun SearchBookDetailScreen( shape = RoundedCornerShape(12.dp) ) .clickable { - isBookmarked = !isBookmarked - onBookMarkClick(isBookmarked) + val newBookmarkState = !isBookmarked + isBookmarked = newBookmarkState + viewModel.saveBook(isbn, newBookmarkState) + onBookMarkClick(newBookmarkState) }, contentAlignment = Alignment.Center, ) { diff --git a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/BookDetailViewModel.kt b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/BookDetailViewModel.kt index 1facab87..bd85f629 100644 --- a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/BookDetailViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/BookDetailViewModel.kt @@ -39,10 +39,36 @@ class BookDetailViewModel @Inject constructor( } } } + + 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 From 10c99beaef368b59edea14cc67b7e101cf34d830 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 01:24:08 +0900 Subject: [PATCH 11/35] =?UTF-8?q?[refactor]:=20=EC=B1=85=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20viewModel,=20=EC=83=81=ED=83=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/ui/common/buttons/GenreChipButton.kt | 11 +- .../thip/ui/search/screen/SearchBookScreen.kt | 116 ++--- .../ui/search/viewmodel/SearchBookUiState.kt | 46 +- .../search/viewmodel/SearchBookViewModel.kt | 396 +++++------------- 4 files changed, 194 insertions(+), 375 deletions(-) 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 b226d1eb..69900b5f 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 @@ -19,7 +19,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.texthip.thip.R @@ -45,10 +44,8 @@ fun GenreChipButton( 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( @@ -65,10 +62,10 @@ fun GenreChipButton( contentDescription = null, tint = Color.Unspecified, modifier = Modifier - .size(20.dp) .clickable { onCloseClick() } + .size(20.dp) ) } } @@ -84,7 +81,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/search/screen/SearchBookScreen.kt b/app/src/main/java/com/texthip/thip/ui/search/screen/SearchBookScreen.kt index 69e03765..bb299a8d 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 @@ -35,6 +35,7 @@ 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.search.viewmodel.SearchMode import com.texthip.thip.ui.theme.ThipTheme @Composable @@ -94,9 +95,8 @@ fun SearchBookScreen( ) Spacer(modifier = Modifier.height(16.dp)) - when { - // 1. 검색어가 없는 상태 - 최근 검색어와 인기 책 표시 - uiState.searchQuery.isBlank() -> { + when (uiState.searchMode) { + SearchMode.Initial -> { SearchRecentBook( recentSearches = uiState.recentSearches.map { it.searchTerm }, popularBooks = uiState.popularBooks.map { item -> @@ -114,7 +114,6 @@ fun SearchBookScreen( viewModel.onSearchButtonClick() }, onRemove = { keyword -> - // 서버에서 해당 검색어를 찾아서 삭제 val recentSearchItem = uiState.recentSearches.find { it.searchTerm == keyword } recentSearchItem?.let { viewModel.deleteRecentSearch(it.recentSearchId) @@ -126,60 +125,65 @@ fun SearchBookScreen( ) } - // 2. 검색 완료 상태 - 전체 검색 결과 표시 (무한 스크롤 포함) - uiState.isSearchCompleted -> { - SearchBookFilteredResult( - resultCount = uiState.totalElements, - bookList = uiState.searchResults.map { item -> - BookData( - title = item.title, - author = item.authorName, - publisher = item.publisher, - imageUrl = item.imageUrl, - isbn = item.isbn - ) - }, - isLoading = uiState.isSearching || uiState.isLoadingMore, - hasMore = uiState.canLoadMore, - onLoadMore = { - viewModel.loadMoreBooks() - }, - onBookClick = { book -> - onBookClick(book.isbn) - } - ) - } - - // 3. Live search 결과가 있는 상태 - SearchActiveField 표시 (무한 스크롤 포함) - uiState.hasLiveResults -> { - SearchActiveField( - bookList = uiState.liveSearchResults.map { item -> - BookData( - title = item.title, - author = item.authorName, - publisher = item.publisher, - imageUrl = item.imageUrl, - isbn = item.isbn - ) - }, - isLoading = uiState.isLiveSearching || uiState.isLiveLoadingMore, - hasMore = uiState.canLiveLoadMore, - onLoadMore = { - viewModel.loadMoreLiveSearchResults() - }, - onBookClick = { book -> - onBookClick(book.isbn) - } - ) + SearchMode.LiveSearch -> { + if (uiState.hasResults) { + SearchActiveField( + bookList = uiState.searchResults.map { item -> + BookData( + title = item.title, + author = item.authorName, + publisher = item.publisher, + imageUrl = item.imageUrl, + isbn = item.isbn + ) + }, + isLoading = uiState.isSearching || uiState.isLoadingMore, + hasMore = uiState.canLoadMore, + onLoadMore = { + viewModel.loadMoreBooks() + }, + onBookClick = { book -> + onBookClick(book.isbn) + } + ) + } else if (uiState.showEmptyState) { + SearchEmptyResult( + mainText = stringResource(R.string.book_no_search_result1), + subText = stringResource(R.string.book_no_search_result2), + onRequestBook = { /*책 요청 처리*/ } + ) + } } - // 4. 검색어는 있지만 결과가 없는 상태 - Empty 표시 - uiState.showEmptyState -> { - SearchEmptyResult( - mainText = stringResource(R.string.book_no_search_result1), - subText = stringResource(R.string.book_no_search_result2), - onRequestBook = { /*책 요청 처리*/ } - ) + SearchMode.CompleteSearch -> { + if (uiState.hasResults) { + SearchBookFilteredResult( + resultCount = uiState.totalElements, + bookList = uiState.searchResults.map { item -> + BookData( + title = item.title, + author = item.authorName, + publisher = item.publisher, + imageUrl = item.imageUrl, + isbn = item.isbn + ) + }, + isLoading = uiState.isSearching || uiState.isLoadingMore, + hasMore = uiState.canLoadMore, + onLoadMore = { + viewModel.loadMoreBooks() + }, + onBookClick = { book -> + onBookClick(book.isbn) + } + ) + } else if (uiState.showEmptyState) { + SearchEmptyResult( + mainText = stringResource(R.string.book_no_search_result1), + subText = stringResource(R.string.book_no_search_result2), + onRequestBook = { /*책 요청 처리*/ } + ) + } } } } 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 index 855a56e4..f0dab877 100644 --- 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 @@ -4,35 +4,37 @@ 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 +sealed class SearchMode { + object Initial : SearchMode() + object LiveSearch : SearchMode() + object CompleteSearch : SearchMode() +} + data class SearchBookUiState( val searchQuery: String = "", - val liveSearchResults: List = emptyList(), // Live search 결과 (검색어 입력 시) - val searchResults: List = emptyList(), // 완료된 검색 결과 (검색 버튼 클릭 시) - val popularBooks: List = emptyList(), // 인기 책 목록 - val recentSearches: List = emptyList(), // 최근 검색어 목록 - val isLiveSearching: Boolean = false, // Live search 로딩 상태 - val isSearching: Boolean = false, // 완료된 검색 로딩 상태 - val isLoadingPopularBooks: Boolean = false, // 인기 책 로딩 상태 - val isLoadingRecentSearches: Boolean = false, // 최근 검색어 로딩 상태 + val searchMode: SearchMode = SearchMode.Initial, + + // 통합된 검색 결과 (Live/Complete 구분 없이) + val searchResults: List = emptyList(), + val popularBooks: List = emptyList(), + val recentSearches: List = emptyList(), + + // 로딩 상태 + val isSearching: Boolean = false, val isLoadingMore: Boolean = false, - val isLiveLoadingMore: Boolean = false, // Live search 무한 스크롤 로딩 - val hasMorePages: Boolean = true, - val liveHasMorePages: Boolean = true, // Live search 페이징 정보 - val isFirstPage: Boolean = true, + + // 페이징 정보 val currentPage: Int = 1, - val liveCurrentPage: Int = 1, // Live search 현재 페이지 - val totalPages: Int = 0, - val liveTotalPages: Int = 0, // Live search 총 페이지 val totalElements: Int = 0, - val liveTotalElements: Int = 0, // Live search 총 요소 - val isSearchCompleted: Boolean = false, // 검색 버튼을 눌러서 완료된 검색인지 + val hasMorePages: Boolean = true, + + // 에러/토스트 val error: String? = null, val showToast: Boolean = false, val toastMessage: String = "" ) { - val hasLiveResults: Boolean get() = liveSearchResults.isNotEmpty() - val hasSearchResults: Boolean get() = searchResults.isNotEmpty() && isSearchCompleted - val canLoadMore: Boolean get() = hasMorePages && !isSearching && !isLoadingMore && isSearchCompleted - val canLiveLoadMore: Boolean get() = liveHasMorePages && !isLiveSearching && !isLiveLoadingMore && !isSearchCompleted - val showEmptyState: Boolean get() = searchQuery.isNotBlank() && liveSearchResults.isEmpty() && !isLiveSearching && !isSearchCompleted + val hasResults: Boolean get() = searchResults.isNotEmpty() + val canLoadMore: Boolean get() = hasMorePages && !isSearching && !isLoadingMore + val showEmptyState: Boolean get() = searchQuery.isNotBlank() && searchResults.isEmpty() && !isSearching + val showInitialScreen: Boolean get() = searchMode == SearchMode.Initial && searchQuery.isBlank() } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt index f54c3920..c036e657 100644 --- a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt @@ -24,8 +24,7 @@ class SearchBookViewModel @Inject constructor( private var loadMoreJob: Job? = null init { - loadPopularBooks() - loadRecentSearches() + loadInitialData() } private fun updateState(update: (SearchBookUiState) -> SearchBookUiState) { @@ -33,14 +32,14 @@ class SearchBookViewModel @Inject constructor( } fun updateSearchQuery(query: String) { - updateState { it.copy(searchQuery = query, isSearchCompleted = false) } - - // Live search with debounce + updateState { it.copy(searchQuery = query) } + searchJob?.cancel() if (query.isNotBlank()) { + updateState { it.copy(searchMode = SearchMode.LiveSearch) } searchJob = viewModelScope.launch { - delay(300) // debounce - performLiveSearch(query) + delay(1000) // 디바운싱 + performSearch(query, isLiveSearch = true) } } else { clearSearchResults() @@ -50,9 +49,12 @@ class SearchBookViewModel @Inject constructor( fun onSearchButtonClick() { val query = uiState.value.searchQuery.trim() if (query.isNotBlank()) { - performCompleteSearch(query) - // 검색 완료 후 최신 검색어 목록 불러오기 (새로운 검색어가 추가되었을 수 있음) - loadRecentSearches() + searchJob?.cancel() + updateState { it.copy(searchMode = SearchMode.CompleteSearch) } + viewModelScope.launch { + performSearch(query, isLiveSearch = false) + loadRecentSearches() + } } } @@ -61,280 +63,136 @@ class SearchBookViewModel @Inject constructor( if (currentState.canLoadMore && currentState.searchQuery.isNotBlank()) { loadMoreJob?.cancel() loadMoreJob = viewModelScope.launch { - performLoadMore(currentState.searchQuery) + performLoadMore() } } } - fun loadMoreLiveSearchResults() { - val currentState = uiState.value - if (currentState.canLiveLoadMore && currentState.searchQuery.isNotBlank()) { - loadMoreJob?.cancel() - loadMoreJob = viewModelScope.launch { - performLiveSearchLoadMore(currentState.searchQuery) + private suspend fun performSearch(query: String, isLiveSearch: Boolean) { + try { + updateState { + it.copy( + isSearching = true, + error = null, + searchResults = emptyList(), + currentPage = 1 + ) } - } - } - private fun performLiveSearch(query: String) { - viewModelScope.launch { - try { - updateState { - it.copy( - isLiveSearching = true, - error = null, - liveSearchResults = emptyList(), // 기존 Live search 결과 클리어 - liveCurrentPage = 1 - ) - } + delay(if (isLiveSearch) 0 else 1000) // Complete search에만 딜레이 - bookRepository.searchBooks(query, 1) - .onSuccess { searchResponse -> - searchResponse?.let { response -> - updateState { - it.copy( - liveSearchResults = response.searchResult, // Live search는 전체 결과 표시 (무한스크롤 포함) - liveCurrentPage = response.page, - liveTotalPages = response.totalPages, - liveTotalElements = response.totalElements, - liveHasMorePages = !response.last, - isLiveSearching = false, - error = null - ) - } - } ?: run { - updateState { - it.copy( - liveSearchResults = emptyList(), - isLiveSearching = false, - error = null - ) - } - } - } - .onFailure { + bookRepository.searchBooks(query, 1) + .onSuccess { response -> + response?.let { searchResponse -> updateState { it.copy( - liveSearchResults = emptyList(), - isLiveSearching = false, - error = null // Live search 에러는 조용히 처리 + searchResults = searchResponse.searchResult, + currentPage = searchResponse.page, + totalElements = searchResponse.totalElements, + hasMorePages = !searchResponse.last, + isSearching = false, + error = null ) } - } - } catch (e: Exception) { - updateState { - it.copy( - liveSearchResults = emptyList(), - isLiveSearching = false, - error = null - ) - } - } - } - } - - private fun performLiveSearchLoadMore(query: String) { - viewModelScope.launch { - try { - val currentState = uiState.value - val nextPage = currentState.liveCurrentPage + 1 - - updateState { it.copy(isLiveLoadingMore = true) } - - bookRepository.searchBooks(query, nextPage) - .onSuccess { searchResponse -> - searchResponse?.let { response -> - updateState { - it.copy( - liveSearchResults = it.liveSearchResults + response.searchResult, - liveCurrentPage = response.page, - liveTotalPages = response.totalPages, - liveTotalElements = response.totalElements, - liveHasMorePages = !response.last, - isLiveLoadingMore = false, - error = null - ) - } - } ?: run { - updateState { - it.copy( - isLiveLoadingMore = false, - error = null - ) - } - } - } - .onFailure { + } ?: run { updateState { it.copy( - isLiveLoadingMore = false, - error = null // Live search 에러는 조용히 처리 + searchResults = emptyList(), + isSearching = false, + error = if (isLiveSearch) null else "검색 결과를 불러올 수 없습니다." ) } } - } catch (e: Exception) { - updateState { - it.copy( - isLiveLoadingMore = false, - error = null - ) } + .onFailure { throwable -> + updateState { + it.copy( + searchResults = emptyList(), + isSearching = false, + error = if (isLiveSearch) null else (throwable.message ?: "검색 중 오류가 발생했습니다.") + ) + } + } + } catch (e: Exception) { + updateState { + it.copy( + searchResults = emptyList(), + isSearching = false, + error = if (isLiveSearch) null else (e.message ?: "검색 중 오류가 발생했습니다.") + ) } } } - private fun performCompleteSearch(query: String) { - viewModelScope.launch { - try { - updateState { - it.copy( - isSearching = true, - isSearchCompleted = true, - error = null, - searchResults = emptyList(), // 기존 검색 결과 클리어 - currentPage = 1 - ) - } + private suspend fun performLoadMore() { + try { + val currentState = uiState.value + val nextPage = currentState.currentPage + 1 + + updateState { it.copy(isLoadingMore = true) } + delay(1000) // 로딩 표시를 위한 딜레이 - bookRepository.searchBooks(query, 1) - .onSuccess { searchResponse -> - searchResponse?.let { response -> - updateState { - it.copy( - searchResults = response.searchResult, - currentPage = response.page, - totalPages = response.totalPages, - totalElements = response.totalElements, - hasMorePages = !response.last, - isFirstPage = response.first, - isSearching = false, - error = null - ) - } - } ?: run { - updateState { - it.copy( - searchResults = emptyList(), - isSearching = false, - error = "검색 결과를 불러올 수 없습니다." - ) - } - } - } - .onFailure { throwable -> + bookRepository.searchBooks(currentState.searchQuery, nextPage) + .onSuccess { response -> + response?.let { searchResponse -> updateState { it.copy( - searchResults = emptyList(), - isSearching = false, - error = throwable.message ?: "검색 중 오류가 발생했습니다." + searchResults = it.searchResults + searchResponse.searchResult, + currentPage = searchResponse.page, + totalElements = searchResponse.totalElements, + hasMorePages = !searchResponse.last, + isLoadingMore = false, + error = null ) } - } - } catch (e: Exception) { - updateState { - it.copy( - searchResults = emptyList(), - isSearching = false, - error = e.message ?: "검색 중 오류가 발생했습니다." - ) - } - } - } - } - - private fun performLoadMore(query: String) { - viewModelScope.launch { - try { - val currentState = uiState.value - val nextPage = currentState.currentPage + 1 - - updateState { it.copy(isLoadingMore = true) } - - bookRepository.searchBooks(query, nextPage) - .onSuccess { searchResponse -> - searchResponse?.let { response -> - updateState { - it.copy( - searchResults = it.searchResults + response.searchResult, - currentPage = response.page, - totalPages = response.totalPages, - totalElements = response.totalElements, - hasMorePages = !response.last, - isLoadingMore = false, - error = null - ) - } - } ?: run { - updateState { - it.copy( - isLoadingMore = false, - error = "추가 결과를 불러올 수 없습니다." - ) - } - } - } - .onFailure { throwable -> + } ?: run { updateState { it.copy( isLoadingMore = false, - error = throwable.message ?: "추가 결과를 불러오는 중 오류가 발생했습니다." + error = "추가 결과를 불러올 수 없습니다." ) } } - } catch (e: Exception) { - updateState { - it.copy( - isLoadingMore = false, - error = e.message ?: "추가 결과를 불러오는 중 오류가 발생했습니다." - ) } + .onFailure { throwable -> + updateState { + it.copy( + isLoadingMore = false, + error = throwable.message ?: "추가 결과를 불러오는 중 오류가 발생했습니다." + ) + } + } + } catch (e: Exception) { + updateState { + it.copy( + isLoadingMore = false, + error = e.message ?: "추가 결과를 불러오는 중 오류가 발생했습니다." + ) } } } + private fun loadInitialData() { + loadPopularBooks() + loadRecentSearches() + } private fun loadPopularBooks() { viewModelScope.launch { try { - updateState { it.copy(isLoadingPopularBooks = true) } - bookRepository.getMostSearchedBooks() .onSuccess { response -> response?.let { mostSearchedBooks -> updateState { - it.copy( - popularBooks = mostSearchedBooks.bookList, - isLoadingPopularBooks = false, - error = null - ) - } - } ?: run { - updateState { - it.copy( - popularBooks = emptyList(), - isLoadingPopularBooks = false, - error = null - ) + it.copy(popularBooks = mostSearchedBooks.bookList) } } } .onFailure { - updateState { - it.copy( - popularBooks = emptyList(), - isLoadingPopularBooks = false, - error = null // 인기 책 로딩 실패는 조용히 처리 - ) - } + // 인기 책 로딩 실패는 조용히 처리 } } catch (e: Exception) { - updateState { - it.copy( - popularBooks = emptyList(), - isLoadingPopularBooks = false, - error = null - ) - } + // 예외 처리 - 조용히 처리 } } } @@ -342,45 +200,19 @@ class SearchBookViewModel @Inject constructor( private fun loadRecentSearches() { viewModelScope.launch { try { - updateState { it.copy(isLoadingRecentSearches = true) } - bookRepository.getRecentSearches() .onSuccess { response -> response?.let { recentSearchResponse -> updateState { - it.copy( - recentSearches = recentSearchResponse.recentSearchList, - isLoadingRecentSearches = false, - error = null - ) - } - } ?: run { - updateState { - it.copy( - recentSearches = emptyList(), - isLoadingRecentSearches = false, - error = null - ) + it.copy(recentSearches = recentSearchResponse.recentSearchList) } } } - .onFailure { throwable -> - updateState { - it.copy( - recentSearches = emptyList(), - isLoadingRecentSearches = false, - error = null // 최근 검색어 로딩 실패는 조용히 처리 - ) - } + .onFailure { + // 최근 검색어 로딩 실패는 조용히 처리 } } catch (e: Exception) { - updateState { - it.copy( - recentSearches = emptyList(), - isLoadingRecentSearches = false, - error = null - ) - } + // 예외 처리 - 조용히 처리 } } } @@ -390,11 +222,10 @@ class SearchBookViewModel @Inject constructor( try { bookRepository.deleteRecentSearch(recentSearchId) .onSuccess { - // 삭제 성공 시 목록 새로고침 - loadRecentSearches() + loadRecentSearches() // 삭제 성공 시 목록 새로고침 } .onFailure { - // 삭제 실패는 조용히 처리하거나 Toast로 알림 표시 가능 + // 삭제 실패는 조용히 처리 } } catch (e: Exception) { // 예외 처리 @@ -405,37 +236,22 @@ class SearchBookViewModel @Inject constructor( private fun clearSearchResults() { searchJob?.cancel() loadMoreJob?.cancel() - updateState { - SearchBookUiState( - searchQuery = it.searchQuery, - popularBooks = it.popularBooks, // 인기 책은 유지 - recentSearches = it.recentSearches // 최근 검색어도 유지 - ) - } - // 검색어가 삭제되어 초기화면으로 돌아갈 때 최신 검색기록 불러오기 - loadRecentSearches() - } - - fun showToastMessage(message: String) { updateState { it.copy( - toastMessage = message, - showToast = true + searchQuery = "", + searchMode = SearchMode.Initial, + searchResults = emptyList(), + currentPage = 1, + hasMorePages = true, + isSearching = false, + isLoadingMore = false, + error = null ) } } - fun hideToast() { - updateState { it.copy(showToast = false) } - } - - fun clearError() { - updateState { it.copy(error = null) } - } - fun refreshData() { - loadPopularBooks() - loadRecentSearches() + loadInitialData() } override fun onCleared() { From 911f282ccf6d670c96b6186921fdd69f62a51b5d Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 01:25:24 +0900 Subject: [PATCH 12/35] =?UTF-8?q?[feat]:=20=EC=B1=85=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=AA=A8=EC=A7=91=20=EC=A4=91=EC=9D=B8=20=EB=AA=A8=EC=9E=84?= =?UTF-8?q?=EB=B0=A9=20Response,=20Service,=20Repository=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book/response/RecruitingRoomsResponse.kt | 21 +++++++++++++++++++ .../thip/data/repository/BookRepository.kt | 8 +++++++ .../texthip/thip/data/service/BookService.kt | 8 +++++++ 3 files changed, 37 insertions(+) create mode 100644 app/src/main/java/com/texthip/thip/data/model/book/response/RecruitingRoomsResponse.kt 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..86446c2c --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/book/response/RecruitingRoomsResponse.kt @@ -0,0 +1,21 @@ +package com.texthip.thip.data.model.book.response + +import kotlinx.serialization.Serializable + +@Serializable +data class RecruitingRoomsResponse( + val recruitingRoomList: List, + val totalRoomCount: Int, + val nextCursor: String?, + val isLast: Boolean +) + +@Serializable +data class RecruitingRoomItem( + val roomId: Int, + val bookImageUrl: String, + val roomName: String, + val memberCount: Int, + val recruitCount: Int, + val deadlineEndDate: String +) \ No newline at end of file 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 b01b5b64..7514c780 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 @@ -7,6 +7,7 @@ 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 com.texthip.thip.data.service.BookService import javax.inject.Inject import javax.inject.Singleton @@ -65,4 +66,11 @@ class BookRepository @Inject constructor( .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/service/BookService.kt b/app/src/main/java/com/texthip/thip/data/service/BookService.kt index c4d98d71..3325cff8 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 @@ -8,6 +8,7 @@ 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 @@ -58,4 +59,11 @@ interface BookService { @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 From a3b0ffe762a0abc9a9bb886ea998eb90586eab97 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 01:27:32 +0900 Subject: [PATCH 13/35] =?UTF-8?q?[feat]:=20=EC=B1=85=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=AA=A8=EC=A7=91=20=EC=A4=91=EC=9D=B8=20=EB=AA=A8=EC=9E=84?= =?UTF-8?q?=EB=B0=A9=20=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../extensions/SearchNavigationExtensions.kt | 4 +++ .../navigator/navigations/SearchNavigation.kt | 25 ++++++++++++++++--- .../thip/ui/navigator/routes/SearchRoutes.kt | 4 +-- 3 files changed, 27 insertions(+), 6 deletions(-) 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 26f0c429..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 @@ -12,4 +12,8 @@ fun NavHostController.navigateToSearch() { 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/SearchNavigation.kt b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/SearchNavigation.kt index 25b353b7..5f87b462 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 @@ -5,10 +5,12 @@ 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.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 { @@ -32,14 +34,29 @@ fun NavGraphBuilder.searchNavigation(navController: NavHostController) { // TODO: 우측 버튼 액션 구현 }, onRecruitingGroupClick = { - // TODO: 모집중인 그룹 화면으로 이동 - }, - onBookMarkClick = { isBookmarked -> - // TODO: 북마크 액션 구현 + navController.navigateToBookGroup(isbn) }, onWriteFeedClick = { // TODO: 피드 작성 화면으로 이동 } ) } + + composable { backStackEntry -> + val route = backStackEntry.toRoute() + val isbn = route.isbn + + SearchBookGroupScreen( + isbn = isbn, + onLeftClick = { + navController.popBackStack() + }, + onCardClick = { roomId -> + // TODO: 그룹 상세 화면으로 이동 + }, + onCreateRoomClick = { + // TODO: 방 생성 화면으로 이동 + } + ) + } } \ No newline at end of file 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 bc1edc40..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 @@ -7,6 +7,6 @@ sealed class SearchRoutes : Routes() { @Serializable data class BookDetail(val isbn: String) : SearchRoutes() - // 향후 추가될 Search 관련 화면들 - // @Serializable data object BookGroup : SearchRoutes + @Serializable + data class BookGroup(val isbn: String) : SearchRoutes() } \ No newline at end of file From 394cfebb65e71cb8a0cf9238b62452a7ec0c7f76 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 01:28:12 +0900 Subject: [PATCH 14/35] =?UTF-8?q?[feat]:=20=EC=B1=85=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=AA=A8=EC=A7=91=20=EC=A4=91=EC=9D=B8=20=EB=AA=A8=EC=9E=84?= =?UTF-8?q?=EB=B0=A9=20viewModel=20=EA=B5=AC=ED=98=84=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../viewmodel/SearchBookGroupViewModel.kt | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookGroupViewModel.kt 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..a131d991 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookGroupViewModel.kt @@ -0,0 +1,100 @@ +package com.texthip.thip.ui.search.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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) { + currentIsbn = isbn + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isLoading = true, + error = null, + recruitingRooms = emptyList(), + nextCursor = null, + hasMore = true, + totalCount = 0 + ) + + loadRooms(isbn, null) + } + } + + 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, + 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 canLoadMore: Boolean get() = hasMore && !isLoading && !isLoadingMore +} \ No newline at end of file From b2667d6e4d65adaa0aecc28c9c31da2e0bbc5059 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 01:30:59 +0900 Subject: [PATCH 15/35] =?UTF-8?q?[feat]:=20=EC=B1=85=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=AA=A8=EC=A7=91=20=EC=A4=91=EC=9D=B8=20=EB=AA=A8=EC=9E=84?= =?UTF-8?q?=EB=B0=A9=20=ED=99=94=EB=A9=B4=20=EC=97=B0=EA=B2=B0=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../search/screen/SearchBookDetailScreen.kt | 2 - .../ui/search/screen/SearchBookGroupScreen.kt | 340 +++++++++--------- 2 files changed, 176 insertions(+), 166 deletions(-) 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 75c8d38f..204f6ba3 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 @@ -59,7 +59,6 @@ fun SearchBookDetailScreen( onLeftClick: () -> Unit = {}, onRightClick: () -> Unit = {}, onRecruitingGroupClick: () -> Unit = {}, - onBookMarkClick: (Boolean) -> Unit = {}, onWriteFeedClick: () -> Unit = {}, viewModel: BookDetailViewModel = hiltViewModel() ) { @@ -272,7 +271,6 @@ fun SearchBookDetailScreen( val newBookmarkState = !isBookmarked isBookmarked = newBookmarkState viewModel.saveBook(isbn, newBookmarkState) - onBookMarkClick(newBookmarkState) }, contentAlignment = Alignment.Center, ) { 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..08a29114 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,227 @@ 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 + @Composable fun SearchBookGroupScreen( - recruitingList: List, - onCardClick: (GroupCardItemRoomData) -> Unit = {}, - onCreateRoomClick: () -> Unit = {} + isbn: String, + onLeftClick: () -> Unit = {}, + onCardClick: (Int) -> Unit = {}, + onCreateRoomClick: () -> Unit = {}, + viewModel: SearchBookGroupViewModel = hiltViewModel() ) { - Box( - modifier = Modifier.fillMaxSize() - ) { - Column( - Modifier.fillMaxSize() - ) { - DefaultTopAppBar( - title = stringResource(R.string.group_recruiting_title), - onLeftClick = {}, - ) + val uiState by viewModel.uiState.collectAsState() - Column( - Modifier - .background(colors.Black) + LaunchedEffect(isbn) { + viewModel.loadRecruitingRooms(isbn) + } + when { + uiState.isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = colors.White + ) + } + } + uiState.error != null -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = uiState.error!!, + color = colors.White, + style = typography.smalltitle_sb600_s16_h20 + ) + } + } + else -> { + val recruitingList = uiState.recruitingRooms.map { item -> + GroupCardItemRoomData( + id = item.roomId, + title = item.roomName, + participants = item.memberCount, + maxParticipants = item.recruitCount, + endDate = item.deadlineEndDate.toIntOrNull() ?: 0, + imageUrl = item.bookImageUrl, + isRecruiting = true + ) + } + + Box( + modifier = Modifier .fillMaxSize() - .padding(horizontal = 20.dp) - .padding(top = 16.dp) ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + // 기존 콘텐츠 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 + .padding(horizontal = 20.dp) + .padding(top = 16.dp) ) { - 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() - ) { - 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, + uiState.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) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } + .collect { lastVisibleIndex -> + if (lastVisibleIndex != null && + lastVisibleIndex >= recruitingList.size - 3 && + uiState.canLoadMore) { + viewModel.loadMoreRooms() + } + } + } + + 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 (uiState.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) - .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 - ) + Button( + colors = ButtonDefaults.buttonColors( + containerColor = colors.Purple + ), + modifier = Modifier + .align(Alignment.BottomCenter) // Box의 하단 중앙에 정렬 + .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() -@Composable -fun GroupRecruitingScreenPreview() { - 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, - ) - ) - - SearchBookGroupScreen( - recruitingList = dataList - ) - } -} @Preview() @Composable -fun GroupRecruitingScreenEmptyPreview() { +fun GroupRecruitingScreenPreview() { ThipTheme { SearchBookGroupScreen( - recruitingList = emptyList() + isbn = "9788954682152" ) } } \ No newline at end of file From c6bdff8ab581a6b90fe9c21aeb8ac41e500da890 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 01:31:26 +0900 Subject: [PATCH 16/35] =?UTF-8?q?[refactor]:=20ViewModel=EC=97=90=20?= =?UTF-8?q?=EB=84=A3=EC=97=88=EB=8D=98=20=EC=9E=84=EC=9D=98=20delay=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt index c036e657..a7614424 100644 --- a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt @@ -38,7 +38,7 @@ class SearchBookViewModel @Inject constructor( if (query.isNotBlank()) { updateState { it.copy(searchMode = SearchMode.LiveSearch) } searchJob = viewModelScope.launch { - delay(1000) // 디바운싱 + delay(1000) // Live search에 딜레이 추가 performSearch(query, isLiveSearch = true) } } else { @@ -79,8 +79,6 @@ class SearchBookViewModel @Inject constructor( ) } - delay(if (isLiveSearch) 0 else 1000) // Complete search에만 딜레이 - bookRepository.searchBooks(query, 1) .onSuccess { response -> response?.let { searchResponse -> @@ -130,7 +128,6 @@ class SearchBookViewModel @Inject constructor( val nextPage = currentState.currentPage + 1 updateState { it.copy(isLoadingMore = true) } - delay(1000) // 로딩 표시를 위한 딜레이 bookRepository.searchBooks(currentState.searchQuery, nextPage) .onSuccess { response -> From 5a895c617dd03b097192a33c2ffe4e9144b46fdd Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 01:43:28 +0900 Subject: [PATCH 17/35] =?UTF-8?q?[refactor]:=20=EC=B1=85=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=ED=95=98=EA=B8=B0=20=ED=99=94=EB=A9=B4=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../texthip/thip/ui/common/screen/RegisterBookScreen.kt | 7 +++++-- .../ui/navigator/extensions/CommonNavigationExtensions.kt | 5 +++++ .../thip/ui/navigator/navigations/CommonNavigation.kt | 8 ++++++++ .../thip/ui/navigator/navigations/SearchNavigation.kt | 4 ++++ .../com/texthip/thip/ui/navigator/routes/CommonRoutes.kt | 3 +++ .../com/texthip/thip/ui/search/screen/SearchBookScreen.kt | 7 ++++--- 6 files changed, 29 insertions(+), 5 deletions(-) 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/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/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/SearchNavigation.kt b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/SearchNavigation.kt index 5f87b462..c0d27fa6 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/SearchNavigation.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/SearchNavigation.kt @@ -6,6 +6,7 @@ import androidx.navigation.compose.composable import androidx.navigation.toRoute import com.texthip.thip.ui.navigator.extensions.navigateToBookDetail import com.texthip.thip.ui.navigator.extensions.navigateToBookGroup +import com.texthip.thip.ui.navigator.extensions.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 @@ -17,6 +18,9 @@ fun NavGraphBuilder.searchNavigation(navController: NavHostController) { SearchBookScreen( onBookClick = { isbn -> navController.navigateToBookDetail(isbn) + }, + onRequestBook = { + navController.navigateToRegisterBook() } ) } 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/search/screen/SearchBookScreen.kt b/app/src/main/java/com/texthip/thip/ui/search/screen/SearchBookScreen.kt index bb299a8d..b79ab90f 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 @@ -42,7 +42,8 @@ import com.texthip.thip.ui.theme.ThipTheme fun SearchBookScreen( modifier: Modifier = Modifier, viewModel: SearchBookViewModel = hiltViewModel(), - onBookClick: (String) -> Unit = {} + onBookClick: (String) -> Unit = {}, + onRequestBook: () -> Unit = {} ) { val uiState by viewModel.uiState.collectAsState() val focusRequester = remember { FocusRequester() } @@ -150,7 +151,7 @@ fun SearchBookScreen( SearchEmptyResult( mainText = stringResource(R.string.book_no_search_result1), subText = stringResource(R.string.book_no_search_result2), - onRequestBook = { /*책 요청 처리*/ } + onRequestBook = onRequestBook ) } } @@ -181,7 +182,7 @@ fun SearchBookScreen( SearchEmptyResult( mainText = stringResource(R.string.book_no_search_result1), subText = stringResource(R.string.book_no_search_result2), - onRequestBook = { /*책 요청 처리*/ } + onRequestBook = onRequestBook ) } } From 82bd113a4f82c432b333bce9ad6b0a8b96dd6afc Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 01:52:31 +0900 Subject: [PATCH 18/35] =?UTF-8?q?[feat]:=20=EB=AA=A8=EC=A7=91=EC=A4=91?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A8=EC=9E=84=EB=B0=A9=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/ui/navigator/navigations/SearchNavigation.kt | 3 ++- .../texthip/thip/ui/search/screen/SearchBookGroupScreen.kt | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) 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 c0d27fa6..53fec45d 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/SearchNavigation.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/SearchNavigation.kt @@ -6,6 +6,7 @@ import androidx.navigation.compose.composable import androidx.navigation.toRoute import com.texthip.thip.ui.navigator.extensions.navigateToBookDetail import com.texthip.thip.ui.navigator.extensions.navigateToBookGroup +import com.texthip.thip.ui.navigator.extensions.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 @@ -56,7 +57,7 @@ fun NavGraphBuilder.searchNavigation(navController: NavHostController) { navController.popBackStack() }, onCardClick = { roomId -> - // TODO: 그룹 상세 화면으로 이동 + navController.navigateToGroupRecruit(roomId) }, onCreateRoomClick = { // TODO: 방 생성 화면으로 이동 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 08a29114..5476be74 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 @@ -39,6 +39,7 @@ 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 @@ -79,12 +80,14 @@ fun SearchBookGroupScreen( } else -> { 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 = item.deadlineEndDate.toIntOrNull() ?: 0, + endDate = daysLeft, imageUrl = item.bookImageUrl, isRecruiting = true ) From 2223337ba5d85443d4118c0eb37eb9cc75d137f2 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 02:15:26 +0900 Subject: [PATCH 19/35] =?UTF-8?q?[feat]:=20Screen=EC=9D=84=20content?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=94=EA=BE=B8=EA=B8=B0=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../search/screen/SearchBookDetailScreen.kt | 161 ++++++++-- .../ui/search/screen/SearchBookGroupScreen.kt | 165 ++++++++-- .../thip/ui/search/screen/SearchBookScreen.kt | 295 +++++++++++++----- 3 files changed, 497 insertions(+), 124 deletions(-) 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 204f6ba3..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 @@ -40,6 +40,7 @@ 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.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 @@ -63,9 +64,77 @@ fun SearchBookDetailScreen( 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( @@ -73,20 +142,24 @@ fun SearchBookDetailScreen( stringResource(R.string.search_filter_latest) ) - // ISBN으로 책 상세 정보 로드 - LaunchedEffect(isbn) { - viewModel.loadBookDetail(isbn) + // 알림 5초간 노출 (미리보기에서는 항상 보이도록) + LaunchedEffect(Unit) { + if (!isLoading && error == null && bookDetail != null) { + isAlarmVisible = true + delay(5000) + isAlarmVisible = false + } } - // 알림 5초간 노출 - LaunchedEffect(Unit) { - isAlarmVisible = true - delay(5000) - isAlarmVisible = false + // 북마크 상태 동기화 + LaunchedEffect(bookDetail?.isSaved) { + bookDetail?.let { + isBookmarked = it.isSaved + } } when { - uiState.isLoading -> { + isLoading -> { Box( modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -96,22 +169,19 @@ fun SearchBookDetailScreen( ) } } - uiState.error != null -> { + error != null -> { Box( modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text( - text = uiState.error!!, + text = error, color = colors.White, style = typography.smalltitle_sb600_s16_h20 ) } } - uiState.bookDetail != null -> { - val bookDetail = uiState.bookDetail!! - isBookmarked = bookDetail.isSaved - + bookDetail != null -> { Box(modifier = modifier.fillMaxSize()) { // 메인 컨텐츠 Box( @@ -270,7 +340,7 @@ fun SearchBookDetailScreen( .clickable { val newBookmarkState = !isBookmarked isBookmarked = newBookmarkState - viewModel.saveBook(isbn, newBookmarkState) + onBookmarkClick(bookDetail.isbn, newBookmarkState) }, contentAlignment = Alignment.Center, ) { @@ -362,13 +432,60 @@ fun SearchBookDetailScreen( } } -@Preview +// 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 PreviewBookDetailScreen() { +fun SearchBookDetailScreenContentPreview() { ThipTheme { - SearchBookDetailScreen( - isbn = "9788954682152", + SearchBookDetailScreenContent( + bookDetail = mockBookDetail, feedList = emptyList() ) } -} \ No newline at end of file +} + +@Preview(showBackground = true) +@Composable +fun SearchBookDetailScreenContentSavedPreview() { + ThipTheme { + SearchBookDetailScreenContent( + bookDetail = mockBookDetailSaved, + feedList = emptyList() + ) + } +} + +@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 5476be74..103ff69e 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 @@ -55,10 +55,55 @@ fun SearchBookGroupScreen( 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, + 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, + onLeftClick: () -> Unit = {}, + onCardClick: (Int) -> Unit = {}, + onCreateRoomClick: () -> Unit = {}, + onLoadMore: () -> Unit = {} +) { when { - uiState.isLoading -> { + isLoading -> { Box( - modifier = Modifier.fillMaxSize(), + modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { CircularProgressIndicator( @@ -66,36 +111,21 @@ fun SearchBookGroupScreen( ) } } - uiState.error != null -> { + error != null -> { Box( - modifier = Modifier.fillMaxSize(), + modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text( - text = uiState.error!!, + text = error, color = colors.White, style = typography.smalltitle_sb600_s16_h20 ) } } else -> { - 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 - ) - } - Box( - modifier = Modifier - .fillMaxSize() + modifier = modifier.fillMaxSize() ) { // 기존 콘텐츠 Column Column( @@ -119,7 +149,7 @@ fun SearchBookGroupScreen( Text( text = stringResource( R.string.group_searched_room_size, - uiState.totalCount + totalCount ), color = colors.Grey, style = typography.menu_m500_s14_h24 @@ -164,8 +194,8 @@ fun SearchBookGroupScreen( .collect { lastVisibleIndex -> if (lastVisibleIndex != null && lastVisibleIndex >= recruitingList.size - 3 && - uiState.canLoadMore) { - viewModel.loadMoreRooms() + canLoadMore) { + onLoadMore() } } } @@ -189,7 +219,7 @@ fun SearchBookGroupScreen( } // 로딩 인디케이터 - if (uiState.isLoadingMore) { + if (isLoadingMore) { item { Box( modifier = Modifier @@ -230,13 +260,90 @@ fun SearchBookGroupScreen( } } +// 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(showBackground = true) +@Composable +fun SearchBookGroupScreenContentLoadingMorePreview() { + ThipTheme { + SearchBookGroupScreenContent( + recruitingList = mockRecruitingList, + totalCount = 15, + isLoadingMore = true, + canLoadMore = true + ) + } +} + +@Preview(showBackground = true) +@Composable +fun SearchBookGroupScreenContentLoadingPreview() { + ThipTheme { + SearchBookGroupScreenContent( + ) + } +} -@Preview() +@Preview(showBackground = true) @Composable -fun GroupRecruitingScreenPreview() { +fun SearchBookGroupScreenContentErrorPreview() { ThipTheme { - SearchBookGroupScreen( - isbn = "9788954682152" + 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 b79ab90f..294570ea 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 @@ -46,8 +46,6 @@ fun SearchBookScreen( onRequestBook: () -> Unit = {} ) { val uiState by viewModel.uiState.collectAsState() - val focusRequester = remember { FocusRequester() } - val focusManager = LocalFocusManager.current val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { @@ -64,6 +62,86 @@ fun SearchBookScreen( } } + SearchBookScreenContent( + modifier = modifier, + searchQuery = uiState.searchQuery, + searchMode = uiState.searchMode, + 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 -> + val recentSearchItem = uiState.recentSearches.find { it.searchTerm == keyword } + recentSearchItem?.let { + viewModel.deleteRecentSearch(it.recentSearchId) + } + }, + onBookClick = { book -> + onBookClick(book.isbn) + }, + onLoadMore = { + viewModel.loadMoreBooks() + }, + onRequestBook = onRequestBook + ) +} + +@Composable +private fun SearchBookScreenContent( + modifier: Modifier = Modifier, + searchQuery: String = "", + searchMode: SearchMode = SearchMode.Initial, + 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() ) { @@ -85,69 +163,37 @@ fun SearchBookScreen( .fillMaxWidth() .focusRequester(focusRequester), hint = stringResource(R.string.book_search_hint), - text = uiState.searchQuery, - onValueChange = { query -> - viewModel.updateSearchQuery(query) - }, - onSearch = { query -> - viewModel.onSearchButtonClick() + text = searchQuery, + onValueChange = onSearchQueryChange, + onSearch = { + onSearchClick() focusManager.clearFocus() } ) Spacer(modifier = Modifier.height(16.dp)) - when (uiState.searchMode) { + when (searchMode) { SearchMode.Initial -> { SearchRecentBook( - recentSearches = uiState.recentSearches.map { it.searchTerm }, - popularBooks = uiState.popularBooks.map { item -> - BookData( - title = item.title, - author = "", - publisher = "", - imageUrl = item.imageUrl, - isbn = item.isbn - ) - }, + recentSearches = recentSearches, + popularBooks = popularBooks, popularBookDate = SimpleDateFormat("MM.dd", Locale.getDefault()).format(Date()), - onSearchClick = { keyword -> - viewModel.updateSearchQuery(keyword) - viewModel.onSearchButtonClick() - }, - onRemove = { keyword -> - val recentSearchItem = uiState.recentSearches.find { it.searchTerm == keyword } - recentSearchItem?.let { - viewModel.deleteRecentSearch(it.recentSearchId) - } - }, - onBookClick = { book -> - onBookClick(book.isbn) - } + onSearchClick = onRecentSearchClick, + onRemove = onRemoveRecentSearch, + onBookClick = onBookClick ) } SearchMode.LiveSearch -> { - if (uiState.hasResults) { + if (hasResults) { SearchActiveField( - bookList = uiState.searchResults.map { item -> - BookData( - title = item.title, - author = item.authorName, - publisher = item.publisher, - imageUrl = item.imageUrl, - isbn = item.isbn - ) - }, - isLoading = uiState.isSearching || uiState.isLoadingMore, - hasMore = uiState.canLoadMore, - onLoadMore = { - viewModel.loadMoreBooks() - }, - onBookClick = { book -> - onBookClick(book.isbn) - } + bookList = searchResults, + isLoading = isSearching || isLoadingMore, + hasMore = canLoadMore, + onLoadMore = onLoadMore, + onBookClick = onBookClick ) - } else if (uiState.showEmptyState) { + } else if (showEmptyState) { SearchEmptyResult( mainText = stringResource(R.string.book_no_search_result1), subText = stringResource(R.string.book_no_search_result2), @@ -157,28 +203,16 @@ fun SearchBookScreen( } SearchMode.CompleteSearch -> { - if (uiState.hasResults) { + if (hasResults) { SearchBookFilteredResult( - resultCount = uiState.totalElements, - bookList = uiState.searchResults.map { item -> - BookData( - title = item.title, - author = item.authorName, - publisher = item.publisher, - imageUrl = item.imageUrl, - isbn = item.isbn - ) - }, - isLoading = uiState.isSearching || uiState.isLoadingMore, - hasMore = uiState.canLoadMore, - onLoadMore = { - viewModel.loadMoreBooks() - }, - onBookClick = { book -> - onBookClick(book.isbn) - } + resultCount = totalElements, + bookList = searchResults, + isLoading = isSearching || isLoadingMore, + hasMore = canLoadMore, + onLoadMore = onLoadMore, + onBookClick = onBookClick ) - } else if (uiState.showEmptyState) { + } else if (showEmptyState) { SearchEmptyResult( mainText = stringResource(R.string.book_no_search_result1), subText = stringResource(R.string.book_no_search_result2), @@ -193,10 +227,125 @@ 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 SearchBookScreenContentInitialPreview() { + ThipTheme { + SearchBookScreenContent( + searchMode = SearchMode.Initial, + popularBooks = mockPopularBooks, + recentSearches = mockRecentSearches + ) + } +} + +@Preview(showBackground = true) +@Composable +fun SearchBookScreenContentLiveSearchPreview() { + ThipTheme { + SearchBookScreenContent( + searchQuery = "데미안", + searchMode = SearchMode.LiveSearch, + searchResults = mockSearchResults, + hasResults = true, + isSearching = false + ) + } +} + +@Preview(showBackground = true) +@Composable +fun SearchBookScreenContentCompleteSearchPreview() { + ThipTheme { + SearchBookScreenContent( + searchQuery = "데미안", + searchMode = SearchMode.CompleteSearch, + searchResults = mockSearchResults, + totalElements = 15, + hasResults = true, + isSearching = false + ) + } +} + +@Preview(showBackground = true) +@Composable +fun SearchBookScreenContentEmptyPreview() { + ThipTheme { + SearchBookScreenContent( + searchQuery = "없는책제목", + searchMode = SearchMode.CompleteSearch, + searchResults = emptyList(), + hasResults = false, + showEmptyState = true + ) + } +} + @Preview(showBackground = true) @Composable -fun PreviewBookSearchScreen() { +fun SearchBookScreenContentLoadingPreview() { ThipTheme { - SearchBookScreen() + SearchBookScreenContent( + searchQuery = "데미안", + searchMode = SearchMode.CompleteSearch, + searchResults = mockSearchResults.take(2), + totalElements = 15, + hasResults = true, + isSearching = false, + isLoadingMore = true, + canLoadMore = true + ) } } From 97a2e26682a56f4b6c406670443347a32a494177 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 14:25:19 +0900 Subject: [PATCH 20/35] =?UTF-8?q?[feat]:=20=EC=B1=85=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20=EB=AA=A8=EC=9E=84=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=ED=99=94=EB=A9=B4=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EA=B5=AC=ED=98=84=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../extensions/GroupNavigationExtensions.kt | 15 +++++++++++ .../navigator/navigations/GroupNavigation.kt | 26 +++++++++++++++++++ .../navigator/navigations/SearchNavigation.kt | 10 +++++-- .../thip/ui/navigator/routes/GroupRoutes.kt | 8 ++++++ 4 files changed, 57 insertions(+), 2 deletions(-) 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/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 53fec45d..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 @@ -6,6 +6,7 @@ import androidx.navigation.compose.composable import androidx.navigation.toRoute import com.texthip.thip.ui.navigator.extensions.navigateToBookDetail import com.texthip.thip.ui.navigator.extensions.navigateToBookGroup +import com.texthip.thip.ui.navigator.extensions.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 @@ -59,8 +60,13 @@ fun NavGraphBuilder.searchNavigation(navController: NavHostController) { onCardClick = { roomId -> navController.navigateToGroupRecruit(roomId) }, - onCreateRoomClick = { - // TODO: 방 생성 화면으로 이동 + onCreateRoomClick = { isbn, title, imageUrl, author -> + navController.navigateToGroupMakeRoomWithBook( + isbn = isbn, + title = title, + imageUrl = imageUrl, + author = author + ) } ) } 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() From bd8ef45cda500c82f1ab98a93ec23c661c9173c3 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 14:33:52 +0900 Subject: [PATCH 21/35] =?UTF-8?q?[feat]:=20=EC=B1=85=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20=EB=AA=A8=EC=9E=84=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=ED=99=94=EB=A9=B4=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20=EA=B5=AC=ED=98=84=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../makeroom/component/GroupSelectBook.kt | 26 +++++++++++++++---- .../makeroom/screen/GroupMakeRoomScreen.kt | 3 ++- .../viewmodel/GroupMakeRoomUiState.kt | 3 ++- .../viewmodel/GroupMakeRoomViewModel.kt | 15 +++++++++++ .../ui/search/screen/SearchBookGroupScreen.kt | 16 +++++++++--- .../viewmodel/SearchBookGroupViewModel.kt | 19 ++++++++++++-- 6 files changed, 70 insertions(+), 12 deletions(-) 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/search/screen/SearchBookGroupScreen.kt b/app/src/main/java/com/texthip/thip/ui/search/screen/SearchBookGroupScreen.kt index 103ff69e..b9200cc5 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 @@ -47,7 +47,7 @@ fun SearchBookGroupScreen( isbn: String, onLeftClick: () -> Unit = {}, onCardClick: (Int) -> Unit = {}, - onCreateRoomClick: () -> Unit = {}, + onCreateRoomClick: (isbn: String, title: String, imageUrl: String, author: String) -> Unit = { _, _, _, _ -> }, viewModel: SearchBookGroupViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() @@ -77,6 +77,10 @@ fun SearchBookGroupScreen( 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, @@ -95,9 +99,13 @@ private fun SearchBookGroupScreenContent( totalCount: Int = 0, isLoadingMore: Boolean = false, canLoadMore: Boolean = true, + isbn: String = "", + bookTitle: String = "", + bookImageUrl: String = "", + bookAuthor: String = "", onLeftClick: () -> Unit = {}, onCardClick: (Int) -> Unit = {}, - onCreateRoomClick: () -> Unit = {}, + onCreateRoomClick: (isbn: String, title: String, imageUrl: String, author: String) -> Unit = { _, _, _, _ -> }, onLoadMore: () -> Unit = {} ) { when { @@ -247,7 +255,9 @@ private fun SearchBookGroupScreenContent( .fillMaxWidth() .height(50.dp), shape = RoundedCornerShape(0.dp), - onClick = onCreateRoomClick + onClick = { + onCreateRoomClick(isbn, bookTitle, bookImageUrl, bookAuthor) + } ) { Text( text = stringResource(R.string.group_recruiting_create_button), 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 index a131d991..07b2da26 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -32,13 +33,26 @@ class SearchBookGroupViewModel @Inject constructor( recruitingRooms = emptyList(), nextCursor = null, hasMore = true, - totalCount = 0 + 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()) { @@ -94,7 +108,8 @@ data class SearchBookGroupUiState( val hasMore: Boolean = true, val isLoading: Boolean = false, val isLoadingMore: Boolean = false, - val error: String? = null + val error: String? = null, + val bookDetail: BookDetailResponse? = null ) { val canLoadMore: Boolean get() = hasMore && !isLoading && !isLoadingMore } \ No newline at end of file From 61f7a479b051813fc28b22adde6b98de9347d8af Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 14:38:12 +0900 Subject: [PATCH 22/35] =?UTF-8?q?[refactor]:=20SerialName=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/book/request/BookSaveRequest.kt | 3 ++- .../model/book/response/BookDetailResponse.kt | 19 +++++++------- .../model/book/response/BookSaveResponse.kt | 5 ++-- .../model/book/response/BookSearchResponse.kt | 25 ++++++++++--------- .../response/MostSearchedBooksResponse.kt | 11 ++++---- .../book/response/RecentSearchResponse.kt | 7 +++--- .../book/response/RecruitingRoomsResponse.kt | 21 ++++++++-------- 7 files changed, 49 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/data/model/book/request/BookSaveRequest.kt b/app/src/main/java/com/texthip/thip/data/model/book/request/BookSaveRequest.kt index 10686a05..338b06a2 100644 --- a/app/src/main/java/com/texthip/thip/data/model/book/request/BookSaveRequest.kt +++ b/app/src/main/java/com/texthip/thip/data/model/book/request/BookSaveRequest.kt @@ -1,8 +1,9 @@ package com.texthip.thip.data.model.book.request +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class BookSaveRequest( - val type: Boolean + @SerialName("type") val type: Boolean ) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/book/response/BookDetailResponse.kt b/app/src/main/java/com/texthip/thip/data/model/book/response/BookDetailResponse.kt index 9d29a609..245933bc 100644 --- a/app/src/main/java/com/texthip/thip/data/model/book/response/BookDetailResponse.kt +++ b/app/src/main/java/com/texthip/thip/data/model/book/response/BookDetailResponse.kt @@ -1,16 +1,17 @@ package com.texthip.thip.data.model.book.response +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class BookDetailResponse( - val title: String, - val imageUrl: String, - val authorName: String, - val publisher: String, - val isbn: String, - val description: String, - val recruitingRoomCount: Int, - val readCount: Int, - val isSaved: Boolean + @SerialName("title") val title: String, + @SerialName("imageUrl") val imageUrl: String, + @SerialName("authorName") val authorName: String, + @SerialName("publisher") val publisher: String, + @SerialName("isbn") val isbn: String, + @SerialName("description") val description: String, + @SerialName("recruitingRoomCount") val recruitingRoomCount: Int, + @SerialName("readCount") val readCount: Int, + @SerialName("isSaved") val isSaved: Boolean ) \ 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 index 7de3eee4..c5a1bbbd 100644 --- 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 @@ -1,9 +1,10 @@ package com.texthip.thip.data.model.book.response +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class BookSaveResponse( - val isbn: String, - val isSaved: Boolean + @SerialName("isbn") val isbn: String, + @SerialName("isSaved") val isSaved: Boolean ) \ 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 index b93981af..96ce387e 100644 --- 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 @@ -1,23 +1,24 @@ package com.texthip.thip.data.model.book.response +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class BookSearchResponse( - val searchResult: List, - val page: Int, - val size: Int, - val totalElements: Int, - val totalPages: Int, - val last: Boolean, - val first: Boolean + @SerialName("searchResult") val searchResult: List, + @SerialName("page") val page: Int, + @SerialName("size") val size: Int, + @SerialName("totalElements") val totalElements: Int, + @SerialName("totalPages") val totalPages: Int, + @SerialName("last") val last: Boolean, + @SerialName("first") val first: Boolean ) @Serializable data class BookSearchItem( - val title: String, - val imageUrl: String, - val authorName: String, - val publisher: String, - val isbn: String + @SerialName("title") val title: String, + @SerialName("imageUrl") val imageUrl: String, + @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 index 7bb61cd3..89dcd1ed 100644 --- 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 @@ -1,16 +1,17 @@ package com.texthip.thip.data.model.book.response +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class MostSearchedBooksResponse( - val bookList: List + @SerialName("bookList") val bookList: List ) @Serializable data class PopularBookItem( - val rank: Int, - val title: String, - val imageUrl: String, - val isbn: String + @SerialName("rank") val rank: Int, + @SerialName("title") val title: String, + @SerialName("imageUrl") val imageUrl: 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/RecentSearchResponse.kt b/app/src/main/java/com/texthip/thip/data/model/book/response/RecentSearchResponse.kt index 521c07a7..c77db246 100644 --- 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 @@ -1,14 +1,15 @@ package com.texthip.thip.data.model.book.response +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class RecentSearchResponse( - val recentSearchList: List + @SerialName("recentSearchList") val recentSearchList: List ) @Serializable data class RecentSearchItem( - val recentSearchId: Int, - val searchTerm: String + @SerialName("recentSearchId") val recentSearchId: Int, + @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 index 86446c2c..f08db937 100644 --- 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 @@ -1,21 +1,22 @@ package com.texthip.thip.data.model.book.response +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class RecruitingRoomsResponse( - val recruitingRoomList: List, - val totalRoomCount: Int, - val nextCursor: String?, - val isLast: Boolean + @SerialName("recruitingRoomList") val recruitingRoomList: List, + @SerialName("totalRoomCount") val totalRoomCount: Int, + @SerialName("nextCursor") val nextCursor: String?, + @SerialName("isLast") val isLast: Boolean ) @Serializable data class RecruitingRoomItem( - val roomId: Int, - val bookImageUrl: String, - val roomName: String, - val memberCount: Int, - val recruitCount: Int, - val deadlineEndDate: String + @SerialName("roomId") val roomId: Int, + @SerialName("bookImageUrl") val bookImageUrl: String, + @SerialName("roomName") val roomName: String, + @SerialName("memberCount") val memberCount: Int, + @SerialName("recruitCount") val recruitCount: Int, + @SerialName("deadlineEndDate") val deadlineEndDate: String ) \ No newline at end of file From 7159215d6017613800f24772b9be85eb08c345d4 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 14:53:16 +0900 Subject: [PATCH 23/35] =?UTF-8?q?[refactor]:=20DTO=EC=97=90=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EA=B0=92=20=EC=B6=94=EA=B0=80=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/book/request/BookSaveRequest.kt | 2 +- .../model/book/response/BookDetailResponse.kt | 18 +++++++------- .../model/book/response/BookListResponse.kt | 12 +++++----- .../model/book/response/BookSaveResponse.kt | 4 ++-- .../model/book/response/BookSearchResponse.kt | 24 +++++++++---------- .../response/MostSearchedBooksResponse.kt | 10 ++++---- .../book/response/RecentSearchResponse.kt | 6 ++--- .../book/response/RecruitingRoomsResponse.kt | 20 ++++++++-------- 8 files changed, 48 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/data/model/book/request/BookSaveRequest.kt b/app/src/main/java/com/texthip/thip/data/model/book/request/BookSaveRequest.kt index 338b06a2..52ebb5c3 100644 --- a/app/src/main/java/com/texthip/thip/data/model/book/request/BookSaveRequest.kt +++ b/app/src/main/java/com/texthip/thip/data/model/book/request/BookSaveRequest.kt @@ -5,5 +5,5 @@ import kotlinx.serialization.Serializable @Serializable data class BookSaveRequest( - @SerialName("type") val type: Boolean + @SerialName("type") val type: Boolean = false ) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/book/response/BookDetailResponse.kt b/app/src/main/java/com/texthip/thip/data/model/book/response/BookDetailResponse.kt index 245933bc..d93e2ab3 100644 --- a/app/src/main/java/com/texthip/thip/data/model/book/response/BookDetailResponse.kt +++ b/app/src/main/java/com/texthip/thip/data/model/book/response/BookDetailResponse.kt @@ -5,13 +5,13 @@ import kotlinx.serialization.Serializable @Serializable data class BookDetailResponse( - @SerialName("title") val title: String, - @SerialName("imageUrl") val imageUrl: String, - @SerialName("authorName") val authorName: String, - @SerialName("publisher") val publisher: String, - @SerialName("isbn") val isbn: String, - @SerialName("description") val description: String, - @SerialName("recruitingRoomCount") val recruitingRoomCount: Int, - @SerialName("readCount") val readCount: Int, - @SerialName("isSaved") val isSaved: Boolean + @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 = "", + @SerialName("description") val description: String = "", + @SerialName("recruitingRoomCount") val recruitingRoomCount: Int = 0, + @SerialName("readCount") val readCount: Int = 0, + @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/BookListResponse.kt b/app/src/main/java/com/texthip/thip/data/model/book/response/BookListResponse.kt index 54a61093..6abf7ff6 100644 --- a/app/src/main/java/com/texthip/thip/data/model/book/response/BookListResponse.kt +++ b/app/src/main/java/com/texthip/thip/data/model/book/response/BookListResponse.kt @@ -6,14 +6,14 @@ import kotlinx.serialization.Serializable @Serializable data class BookListResponse( - @SerialName("bookList") val bookList: List + @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 index c5a1bbbd..29ae456c 100644 --- 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 @@ -5,6 +5,6 @@ import kotlinx.serialization.Serializable @Serializable data class BookSaveResponse( - @SerialName("isbn") val isbn: String, - @SerialName("isSaved") val isSaved: Boolean + @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 index 96ce387e..ef835215 100644 --- 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 @@ -5,20 +5,20 @@ import kotlinx.serialization.Serializable @Serializable data class BookSearchResponse( - @SerialName("searchResult") val searchResult: List, - @SerialName("page") val page: Int, - @SerialName("size") val size: Int, - @SerialName("totalElements") val totalElements: Int, - @SerialName("totalPages") val totalPages: Int, - @SerialName("last") val last: Boolean, - @SerialName("first") val first: Boolean + @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, - @SerialName("authorName") val authorName: String, - @SerialName("publisher") val publisher: String, - @SerialName("isbn") val isbn: String + @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 index 89dcd1ed..cc37ffce 100644 --- 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 @@ -5,13 +5,13 @@ import kotlinx.serialization.Serializable @Serializable data class MostSearchedBooksResponse( - @SerialName("bookList") val bookList: List + @SerialName("bookList") val bookList: List = emptyList() ) @Serializable data class PopularBookItem( - @SerialName("rank") val rank: Int, - @SerialName("title") val title: String, - @SerialName("imageUrl") val imageUrl: String, - @SerialName("isbn") val isbn: String + @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 index c77db246..f0266204 100644 --- 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 @@ -5,11 +5,11 @@ import kotlinx.serialization.Serializable @Serializable data class RecentSearchResponse( - @SerialName("recentSearchList") val recentSearchList: List + @SerialName("recentSearchList") val recentSearchList: List = emptyList() ) @Serializable data class RecentSearchItem( - @SerialName("recentSearchId") val recentSearchId: Int, - @SerialName("searchTerm") val searchTerm: String + @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 index f08db937..8dae69fe 100644 --- 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 @@ -5,18 +5,18 @@ import kotlinx.serialization.Serializable @Serializable data class RecruitingRoomsResponse( - @SerialName("recruitingRoomList") val recruitingRoomList: List, - @SerialName("totalRoomCount") val totalRoomCount: Int, - @SerialName("nextCursor") val nextCursor: String?, - @SerialName("isLast") val isLast: Boolean + @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, - @SerialName("bookImageUrl") val bookImageUrl: String, - @SerialName("roomName") val roomName: String, - @SerialName("memberCount") val memberCount: Int, - @SerialName("recruitCount") val recruitCount: Int, - @SerialName("deadlineEndDate") val deadlineEndDate: String + @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 From 4e4e19c4f0c34c0e4a51c1cbb6fb517ae95e63b2 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 14:54:28 +0900 Subject: [PATCH 24/35] =?UTF-8?q?[refactor]:=20PR=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/ui/common/buttons/GenreChipButton.kt | 4 +- .../search/viewmodel/SearchBookViewModel.kt | 195 ++++++++---------- 2 files changed, 85 insertions(+), 114 deletions(-) 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 69900b5f..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 @@ -17,6 +17,7 @@ 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.tooling.preview.Preview @@ -35,6 +36,8 @@ fun GenreChipButton( Box( modifier = modifier .height(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(color = Color.Transparent, shape = RoundedCornerShape(12.dp)) .clickable { onClick() } @@ -43,7 +46,6 @@ fun GenreChipButton( 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), contentAlignment = Alignment.Center, diff --git a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt index a7614424..a3879e06 100644 --- a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt @@ -69,104 +69,85 @@ class SearchBookViewModel @Inject constructor( } private suspend fun performSearch(query: String, isLiveSearch: Boolean) { - try { - updateState { - it.copy( - isSearching = true, - error = null, - searchResults = emptyList(), - currentPage = 1 - ) - } + updateState { + it.copy( + isSearching = true, + error = null, + searchResults = emptyList(), + currentPage = 1 + ) + } - bookRepository.searchBooks(query, 1) - .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, - error = if (isLiveSearch) null else "검색 결과를 불러올 수 없습니다." - ) - } + bookRepository.searchBooks(query, 1) + .onSuccess { response -> + response?.let { searchResponse -> + updateState { + it.copy( + searchResults = searchResponse.searchResult, + currentPage = searchResponse.page, + totalElements = searchResponse.totalElements, + hasMorePages = !searchResponse.last, + isSearching = false, + error = null + ) } - } - .onFailure { throwable -> + } ?: run { updateState { it.copy( searchResults = emptyList(), isSearching = false, - error = if (isLiveSearch) null else (throwable.message ?: "검색 중 오류가 발생했습니다.") + error = if (isLiveSearch) null else "검색 결과를 불러올 수 없습니다." ) } } - } catch (e: Exception) { - updateState { - it.copy( - searchResults = emptyList(), - isSearching = false, - error = if (isLiveSearch) null else (e.message ?: "검색 중 오류가 발생했습니다.") - ) } - } + .onFailure { throwable -> + updateState { + it.copy( + searchResults = emptyList(), + isSearching = false, + error = if (isLiveSearch) null else (throwable.message ?: "검색 중 오류가 발생했습니다.") + ) + } + } } private suspend fun performLoadMore() { - try { - val currentState = uiState.value - val nextPage = currentState.currentPage + 1 - - updateState { it.copy(isLoadingMore = true) } + val currentState = uiState.value + val nextPage = currentState.currentPage + 1 + + updateState { it.copy(isLoadingMore = true) } - bookRepository.searchBooks(currentState.searchQuery, nextPage) - .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, - error = "추가 결과를 불러올 수 없습니다." - ) - } + bookRepository.searchBooks(currentState.searchQuery, nextPage) + .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 + ) } - } - .onFailure { throwable -> + } ?: run { updateState { it.copy( isLoadingMore = false, - error = throwable.message ?: "추가 결과를 불러오는 중 오류가 발생했습니다." + error = "추가 결과를 불러올 수 없습니다." ) } } - } catch (e: Exception) { - updateState { - it.copy( - isLoadingMore = false, - error = e.message ?: "추가 결과를 불러오는 중 오류가 발생했습니다." - ) } - } + .onFailure { throwable -> + updateState { + it.copy( + isLoadingMore = false, + error = throwable.message ?: "추가 결과를 불러오는 중 오류가 발생했습니다." + ) + } + } } private fun loadInitialData() { @@ -176,57 +157,45 @@ class SearchBookViewModel @Inject constructor( private fun loadPopularBooks() { viewModelScope.launch { - try { - bookRepository.getMostSearchedBooks() - .onSuccess { response -> - response?.let { mostSearchedBooks -> - updateState { - it.copy(popularBooks = mostSearchedBooks.bookList) - } + bookRepository.getMostSearchedBooks() + .onSuccess { response -> + response?.let { mostSearchedBooks -> + updateState { + it.copy(popularBooks = mostSearchedBooks.bookList) } } - .onFailure { - // 인기 책 로딩 실패는 조용히 처리 - } - } catch (e: Exception) { - // 예외 처리 - 조용히 처리 - } + } + .onFailure { + // 인기 책 로딩 실패는 조용히 처리 + } } } private fun loadRecentSearches() { viewModelScope.launch { - try { - bookRepository.getRecentSearches() - .onSuccess { response -> - response?.let { recentSearchResponse -> - updateState { - it.copy(recentSearches = recentSearchResponse.recentSearchList) - } + bookRepository.getRecentSearches() + .onSuccess { response -> + response?.let { recentSearchResponse -> + updateState { + it.copy(recentSearches = recentSearchResponse.recentSearchList) } } - .onFailure { - // 최근 검색어 로딩 실패는 조용히 처리 - } - } catch (e: Exception) { - // 예외 처리 - 조용히 처리 - } + } + .onFailure { + // 최근 검색어 로딩 실패는 조용히 처리 + } } } fun deleteRecentSearch(recentSearchId: Int) { viewModelScope.launch { - try { - bookRepository.deleteRecentSearch(recentSearchId) - .onSuccess { - loadRecentSearches() // 삭제 성공 시 목록 새로고침 - } - .onFailure { - // 삭제 실패는 조용히 처리 - } - } catch (e: Exception) { - // 예외 처리 - } + bookRepository.deleteRecentSearch(recentSearchId) + .onSuccess { + loadRecentSearches() // 삭제 성공 시 목록 새로고침 + } + .onFailure { + // 삭제 실패는 조용히 처리 + } } } From 52483ae416c81a87777ad50e561cd50dff61ebe3 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 14:59:39 +0900 Subject: [PATCH 25/35] =?UTF-8?q?[refactor]:=20=EB=B0=94=EB=80=90=20?= =?UTF-8?q?=EC=B1=85=EA=B2=80=EC=83=89=20API=20=EB=B0=98=EC=98=81=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/texthip/thip/data/repository/BookRepository.kt | 4 ++-- .../main/java/com/texthip/thip/data/service/BookService.kt | 3 ++- .../texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) 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 7514c780..0ee8f88e 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 @@ -26,8 +26,8 @@ class BookRepository @Inject constructor( } /** 책 검색 */ - suspend fun searchBooks(keyword: String, page: Int = 1): Result = runCatching { - bookService.searchBooks(keyword, page) + suspend fun searchBooks(keyword: String, page: Int = 1, isFinalized: Boolean = false): Result = runCatching { + bookService.searchBooks(keyword, page, isFinalized) .handleBaseResponse() .getOrThrow() } 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 3325cff8..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 @@ -28,7 +28,8 @@ interface BookService { @GET("books") suspend fun searchBooks( @Query("keyword") keyword: String, - @Query("page") page: Int = 1 + @Query("page") page: Int = 1, + @Query("isFinalized") isFinalized: Boolean = false ): BaseResponse /** 인기 책 조회 */ diff --git a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt index a3879e06..c030b8a0 100644 --- a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt @@ -78,7 +78,7 @@ class SearchBookViewModel @Inject constructor( ) } - bookRepository.searchBooks(query, 1) + bookRepository.searchBooks(query, 1, isFinalized = !isLiveSearch) .onSuccess { response -> response?.let { searchResponse -> updateState { @@ -118,7 +118,7 @@ class SearchBookViewModel @Inject constructor( updateState { it.copy(isLoadingMore = true) } - bookRepository.searchBooks(currentState.searchQuery, nextPage) + bookRepository.searchBooks(currentState.searchQuery, nextPage, isFinalized = true) .onSuccess { response -> response?.let { searchResponse -> updateState { From 1ec924c604c5e3b7e0e4a454b860eec7ccf13519 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 15:11:12 +0900 Subject: [PATCH 26/35] =?UTF-8?q?[refactor]:=20PR=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20=EB=B0=8F=20=EC=A4=91=EB=B3=B5=20UI?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/search/component/SearchActiveField.kt | 2 +- .../component/SearchBookFilteredResult.kt | 31 ++++++++++--------- .../ui/search/screen/SearchBookGroupScreen.kt | 1 + 3 files changed, 19 insertions(+), 15 deletions(-) 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 480b7220..f6548777 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 @@ -43,7 +43,7 @@ fun SearchActiveField( val totalItemsCount = layoutInfo.totalItemsCount val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 - hasMore && !isLoading && lastVisibleItemIndex >= totalItemsCount - 3 + hasMore && !isLoading && totalItemsCount > 0 && lastVisibleItemIndex >= totalItemsCount - 3 } } 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 54e7b752..80a7599c 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 @@ -51,7 +51,7 @@ fun SearchBookFilteredResult( val totalItemsCount = layoutInfo.totalItemsCount val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 - hasMore && !isLoading && lastVisibleItemIndex >= totalItemsCount - 3 + hasMore && !isLoading && totalItemsCount > 0 && lastVisibleItemIndex >= totalItemsCount - 3 } } @@ -80,17 +80,10 @@ fun SearchBookFilteredResult( .background(colors.DarkGrey02) ) - if (bookList.isEmpty() && !isLoading) { - SearchEmptyResult( - mainText = stringResource(R.string.book_no_search_result1), - subText = stringResource(R.string.book_no_search_result2), - onRequestBook = { /*책 요청 처리*/ } - ) - } else { - LazyColumn( - state = listState, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { itemsIndexed(bookList) { index, book -> Column { CardBookList( @@ -127,12 +120,11 @@ fun SearchBookFilteredResult( } } } - } } } } -@Preview +@Preview(showBackground = true) @Composable fun PreviewBookFilteredSearchResult() { ThipTheme { @@ -158,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/screen/SearchBookGroupScreen.kt b/app/src/main/java/com/texthip/thip/ui/search/screen/SearchBookGroupScreen.kt index b9200cc5..d0547ec5 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 @@ -201,6 +201,7 @@ private fun SearchBookGroupScreenContent( snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } .collect { lastVisibleIndex -> if (lastVisibleIndex != null && + recruitingList.isNotEmpty() && lastVisibleIndex >= recruitingList.size - 3 && canLoadMore) { onLoadMore() From f19cb8cd3a6b8ce72ebe6499275cb3e7c1fca64f Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 15:27:06 +0900 Subject: [PATCH 27/35] =?UTF-8?q?[refactor]:=20=EB=AC=B4=ED=95=9C=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../texthip/thip/ui/group/done/screen/GroupDoneScreen.kt | 6 +++--- .../texthip/thip/ui/group/myroom/screen/GroupMyScreen.kt | 6 +++--- .../texthip/thip/ui/search/component/SearchActiveField.kt | 2 +- .../thip/ui/search/component/SearchBookFilteredResult.kt | 2 +- .../texthip/thip/ui/search/screen/SearchBookGroupScreen.kt | 3 ++- 5 files changed, 10 insertions(+), 9 deletions(-) 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/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/search/component/SearchActiveField.kt b/app/src/main/java/com/texthip/thip/ui/search/component/SearchActiveField.kt index f6548777..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 @@ -37,7 +37,7 @@ fun SearchActiveField( val listState = rememberLazyListState() // 무한 스크롤을 위한 로직 - val shouldLoadMore by remember { + val shouldLoadMore by remember(hasMore, isLoading) { derivedStateOf { val layoutInfo = listState.layoutInfo val totalItemsCount = layoutInfo.totalItemsCount 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 80a7599c..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 @@ -45,7 +45,7 @@ fun SearchBookFilteredResult( val listState = rememberLazyListState() // 무한 스크롤을 위한 로직 - val shouldLoadMore by remember { + val shouldLoadMore by remember(hasMore, isLoading) { derivedStateOf { val layoutInfo = listState.layoutInfo val totalItemsCount = layoutInfo.totalItemsCount 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 d0547ec5..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 @@ -197,11 +197,12 @@ private fun SearchBookGroupScreenContent( val listState = rememberLazyListState() // 무한 스크롤 로직 - LaunchedEffect(listState) { + LaunchedEffect(listState, canLoadMore, isLoadingMore) { snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } .collect { lastVisibleIndex -> if (lastVisibleIndex != null && recruitingList.isNotEmpty() && + !isLoadingMore && lastVisibleIndex >= recruitingList.size - 3 && canLoadMore) { onLoadMore() From d88b5bc0da20db6991f70585a59c23125b1fb25b Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 15:46:57 +0900 Subject: [PATCH 28/35] =?UTF-8?q?[refactor]:=20=EB=AC=B4=ED=95=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EC=88=98=EC=A0=95=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/ui/group/done/viewmodel/GroupDoneViewModel.kt | 4 ++++ .../thip/ui/group/myroom/viewmodel/GroupMyViewModel.kt | 4 ++++ .../com/texthip/thip/ui/group/viewmodel/GroupViewModel.kt | 3 +++ .../thip/ui/search/viewmodel/SearchBookGroupViewModel.kt | 1 + .../texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt | 1 + 5 files changed, 13 insertions(+) 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/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/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/search/viewmodel/SearchBookGroupViewModel.kt b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookGroupViewModel.kt index 07b2da26..e99172f2 100644 --- 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 @@ -82,6 +82,7 @@ class SearchBookGroupViewModel @Inject constructor( _uiState.value = _uiState.value.copy( isLoading = false, isLoadingMore = false, + hasMore = false, // null 응답 시 더 이상 로드할 수 없음을 명시 error = if (cursor == null) "모집중인 방 정보를 찾을 수 없습니다." else null ) } diff --git a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt index c030b8a0..2c3bc132 100644 --- a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt @@ -135,6 +135,7 @@ class SearchBookViewModel @Inject constructor( updateState { it.copy( isLoadingMore = false, + hasMorePages = false, // null 응답 시 더 이상 페이지가 없음을 명시 error = "추가 결과를 불러올 수 없습니다." ) } From 1cd08cf377679748d61eb16271ff3ceb758a3625 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 15:59:51 +0900 Subject: [PATCH 29/35] =?UTF-8?q?[refactor]:=20=EB=A0=88=ED=8F=AC=EC=A7=80?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=ED=8C=A8=ED=84=B4=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/data/repository/BookRepository.kt | 3 ++- .../thip/data/repository/GroupRepository.kt | 17 ++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) 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 0ee8f88e..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 @@ -4,6 +4,7 @@ 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 @@ -18,7 +19,7 @@ class BookRepository @Inject constructor( ) { /** 저장된 책 또는 모임 책 목록 조회 */ - suspend fun getBooks(type: String) = runCatching { + suspend fun getBooks(type: String): Result> = runCatching { bookService.getBooks(type) .handleBaseResponse() .getOrThrow() 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 From 6ce2ba22d587edf50c7b27320194b6006aaddda2 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 16:20:57 +0900 Subject: [PATCH 30/35] =?UTF-8?q?[refactor]:=20viewModel=20=EB=B0=8F=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=88=98=EC=A0=95=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/ui/search/screen/SearchBookScreen.kt | 110 +++++++++--------- .../ui/search/viewmodel/SearchBookUiState.kt | 15 ++- .../search/viewmodel/SearchBookViewModel.kt | 37 +++++- 3 files changed, 95 insertions(+), 67 deletions(-) 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 294570ea..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 @@ -35,7 +35,6 @@ 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.search.viewmodel.SearchMode import com.texthip.thip.ui.theme.ThipTheme @Composable @@ -65,7 +64,9 @@ fun SearchBookScreen( SearchBookScreenContent( modifier = modifier, searchQuery = uiState.searchQuery, - searchMode = uiState.searchMode, + isInitial = uiState.isInitial, + isLiveSearching = uiState.isLiveSearching, + isCompleteSearching = uiState.isCompleteSearching, searchResults = uiState.searchResults.map { item -> BookData( title = item.title, @@ -102,10 +103,7 @@ fun SearchBookScreen( viewModel.onSearchButtonClick() }, onRemoveRecentSearch = { keyword -> - val recentSearchItem = uiState.recentSearches.find { it.searchTerm == keyword } - recentSearchItem?.let { - viewModel.deleteRecentSearch(it.recentSearchId) - } + viewModel.deleteRecentSearchByKeyword(keyword) }, onBookClick = { book -> onBookClick(book.isbn) @@ -121,7 +119,9 @@ fun SearchBookScreen( private fun SearchBookScreenContent( modifier: Modifier = Modifier, searchQuery: String = "", - searchMode: SearchMode = SearchMode.Initial, + isInitial: Boolean = true, + isLiveSearching: Boolean = false, + isCompleteSearching: Boolean = false, searchResults: List = emptyList(), popularBooks: List = emptyList(), recentSearches: List = emptyList(), @@ -172,53 +172,47 @@ private fun SearchBookScreenContent( ) Spacer(modifier = Modifier.height(16.dp)) - when (searchMode) { - SearchMode.Initial -> { - SearchRecentBook( - recentSearches = recentSearches, - popularBooks = popularBooks, - popularBookDate = SimpleDateFormat("MM.dd", Locale.getDefault()).format(Date()), - onSearchClick = onRecentSearchClick, - onRemove = onRemoveRecentSearch, + 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 + ) } - - SearchMode.LiveSearch -> { - 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 - ) - } - } - - SearchMode.CompleteSearch -> { - if (hasResults) { - SearchBookFilteredResult( - 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 - ) - } + } else if (isCompleteSearching) { + if (hasResults) { + SearchBookFilteredResult( + 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 + ) } } } @@ -283,7 +277,7 @@ private val mockRecentSearches = listOf("데미안", "1984", "어린왕자", " fun SearchBookScreenContentInitialPreview() { ThipTheme { SearchBookScreenContent( - searchMode = SearchMode.Initial, + isInitial = true, popularBooks = mockPopularBooks, recentSearches = mockRecentSearches ) @@ -296,7 +290,8 @@ fun SearchBookScreenContentLiveSearchPreview() { ThipTheme { SearchBookScreenContent( searchQuery = "데미안", - searchMode = SearchMode.LiveSearch, + isInitial = false, + isLiveSearching = true, searchResults = mockSearchResults, hasResults = true, isSearching = false @@ -310,7 +305,8 @@ fun SearchBookScreenContentCompleteSearchPreview() { ThipTheme { SearchBookScreenContent( searchQuery = "데미안", - searchMode = SearchMode.CompleteSearch, + isInitial = false, + isCompleteSearching = true, searchResults = mockSearchResults, totalElements = 15, hasResults = true, @@ -325,7 +321,8 @@ fun SearchBookScreenContentEmptyPreview() { ThipTheme { SearchBookScreenContent( searchQuery = "없는책제목", - searchMode = SearchMode.CompleteSearch, + isInitial = false, + isCompleteSearching = true, searchResults = emptyList(), hasResults = false, showEmptyState = true @@ -339,7 +336,8 @@ fun SearchBookScreenContentLoadingPreview() { ThipTheme { SearchBookScreenContent( searchQuery = "데미안", - searchMode = SearchMode.CompleteSearch, + isInitial = false, + isCompleteSearching = true, searchResults = mockSearchResults.take(2), totalElements = 15, hasResults = true, 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 index f0dab877..12022760 100644 --- 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 @@ -4,15 +4,13 @@ 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 -sealed class SearchMode { - object Initial : SearchMode() - object LiveSearch : SearchMode() - object CompleteSearch : SearchMode() -} - data class SearchBookUiState( val searchQuery: String = "", - val searchMode: SearchMode = SearchMode.Initial, + + // 상태 관리 단순화 - boolean 필드 사용 + val isInitial: Boolean = true, + val isLiveSearching: Boolean = false, + val isCompleteSearching: Boolean = false, // 통합된 검색 결과 (Live/Complete 구분 없이) val searchResults: List = emptyList(), @@ -36,5 +34,6 @@ data class SearchBookUiState( val hasResults: Boolean get() = searchResults.isNotEmpty() val canLoadMore: Boolean get() = hasMorePages && !isSearching && !isLoadingMore val showEmptyState: Boolean get() = searchQuery.isNotBlank() && searchResults.isEmpty() && !isSearching - val showInitialScreen: Boolean get() = searchMode == SearchMode.Initial && searchQuery.isBlank() + val showInitialScreen: Boolean get() = isInitial && searchQuery.isBlank() + val isAnySearching: Boolean get() = isLiveSearching || isCompleteSearching } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt index 2c3bc132..99222404 100644 --- a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt @@ -2,6 +2,7 @@ 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 @@ -22,6 +23,9 @@ class SearchBookViewModel @Inject constructor( private var searchJob: Job? = null private var loadMoreJob: Job? = null + + // Map 기반 빠른 최근 검색어 관리 + private val recentSearchMap = mutableMapOf() init { loadInitialData() @@ -36,7 +40,13 @@ class SearchBookViewModel @Inject constructor( searchJob?.cancel() if (query.isNotBlank()) { - updateState { it.copy(searchMode = SearchMode.LiveSearch) } + updateState { + it.copy( + isInitial = false, + isLiveSearching = true, + isCompleteSearching = false + ) + } searchJob = viewModelScope.launch { delay(1000) // Live search에 딜레이 추가 performSearch(query, isLiveSearch = true) @@ -50,7 +60,13 @@ class SearchBookViewModel @Inject constructor( val query = uiState.value.searchQuery.trim() if (query.isNotBlank()) { searchJob?.cancel() - updateState { it.copy(searchMode = SearchMode.CompleteSearch) } + updateState { + it.copy( + isInitial = false, + isLiveSearching = false, + isCompleteSearching = true + ) + } viewModelScope.launch { performSearch(query, isLiveSearch = false) loadRecentSearches() @@ -177,6 +193,12 @@ class SearchBookViewModel @Inject constructor( bookRepository.getRecentSearches() .onSuccess { response -> response?.let { recentSearchResponse -> + // Map에 최근 검색어 저장 (빠른 검색을 위해) + recentSearchMap.clear() + recentSearchResponse.recentSearchList.forEach { item -> + recentSearchMap[item.searchTerm] = item + } + updateState { it.copy(recentSearches = recentSearchResponse.recentSearchList) } @@ -199,6 +221,13 @@ class SearchBookViewModel @Inject constructor( } } } + + /** 키워드로 빠른 최근 검색어 삭제 (Map 기반) */ + fun deleteRecentSearchByKeyword(keyword: String) { + recentSearchMap[keyword]?.let { recentSearchItem -> + deleteRecentSearch(recentSearchItem.recentSearchId) + } + } private fun clearSearchResults() { searchJob?.cancel() @@ -206,7 +235,9 @@ class SearchBookViewModel @Inject constructor( updateState { it.copy( searchQuery = "", - searchMode = SearchMode.Initial, + isInitial = true, + isLiveSearching = false, + isCompleteSearching = false, searchResults = emptyList(), currentPage = 1, hasMorePages = true, From a8f086321a24145ba2add096af96200a5b2971a3 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 20:06:48 +0900 Subject: [PATCH 31/35] =?UTF-8?q?[refactor]:=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=8B=9C=20=EB=AA=A8=EB=93=9C=20=ED=94=8C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=20=ED=95=B4=EC=A0=9C=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/ui/search/viewmodel/SearchBookGroupViewModel.kt | 5 ++--- .../texthip/thip/ui/search/viewmodel/SearchBookUiState.kt | 2 -- .../texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt | 4 ++++ 3 files changed, 6 insertions(+), 5 deletions(-) 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 index e99172f2..82eb2c77 100644 --- 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 @@ -25,6 +25,7 @@ class SearchBookGroupViewModel @Inject constructor( private var currentIsbn: String = "" fun loadRecruitingRooms(isbn: String) { + loadMoreJob?.cancel() // 신규 검색 시 이전 로드 작업 취소 currentIsbn = isbn viewModelScope.launch { _uiState.value = _uiState.value.copy( @@ -48,9 +49,7 @@ class SearchBookGroupViewModel @Inject constructor( .onSuccess { bookDetail -> _uiState.value = _uiState.value.copy(bookDetail = bookDetail) } - .onFailure { - // 책 정보 로드 실패는 무시 (방 정보가 더 중요) - } + .onFailure { } } fun loadMoreRooms() { 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 index 12022760..5dd3278a 100644 --- 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 @@ -34,6 +34,4 @@ data class SearchBookUiState( val hasResults: Boolean get() = searchResults.isNotEmpty() val canLoadMore: Boolean get() = hasMorePages && !isSearching && !isLoadingMore val showEmptyState: Boolean get() = searchQuery.isNotBlank() && searchResults.isEmpty() && !isSearching - val showInitialScreen: Boolean get() = isInitial && searchQuery.isBlank() - val isAnySearching: Boolean get() = isLiveSearching || isCompleteSearching } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt index 99222404..f3da2873 100644 --- a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt @@ -112,6 +112,8 @@ class SearchBookViewModel @Inject constructor( it.copy( searchResults = emptyList(), isSearching = false, + isLiveSearching = false, + isCompleteSearching = false, error = if (isLiveSearch) null else "검색 결과를 불러올 수 없습니다." ) } @@ -122,6 +124,8 @@ class SearchBookViewModel @Inject constructor( it.copy( searchResults = emptyList(), isSearching = false, + isLiveSearching = false, + isCompleteSearching = false, error = if (isLiveSearch) null else (throwable.message ?: "검색 중 오류가 발생했습니다.") ) } From 198ea7dd24e1d5d7a305de0fc777888b04eab50f Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 20:28:29 +0900 Subject: [PATCH 32/35] =?UTF-8?q?[refactor]:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=9E=98=EB=B9=97=20=EC=B5=9C=EC=8B=A0=20pr=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/ui/search/viewmodel/SearchBookViewModel.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt index f3da2873..73c7b29a 100644 --- a/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/search/viewmodel/SearchBookViewModel.kt @@ -10,6 +10,7 @@ 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 @@ -32,13 +33,14 @@ class SearchBookViewModel @Inject constructor( } private fun updateState(update: (SearchBookUiState) -> SearchBookUiState) { - _uiState.value = update(_uiState.value) + _uiState.update(update) } fun updateSearchQuery(query: String) { updateState { it.copy(searchQuery = query) } - searchJob?.cancel() + loadMoreJob?.cancel() + if (query.isNotBlank()) { updateState { it.copy( @@ -60,6 +62,8 @@ class SearchBookViewModel @Inject constructor( val query = uiState.value.searchQuery.trim() if (query.isNotBlank()) { searchJob?.cancel() + loadMoreJob?.cancel() + updateState { it.copy( isInitial = false, @@ -114,6 +118,7 @@ class SearchBookViewModel @Inject constructor( isSearching = false, isLiveSearching = false, isCompleteSearching = false, + hasMorePages = false, error = if (isLiveSearch) null else "검색 결과를 불러올 수 없습니다." ) } From 0e57563a9e3fb1b30a892fe1939973d194934be5 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 21:10:07 +0900 Subject: [PATCH 33/35] =?UTF-8?q?[feat]:=20network=20security=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 3 ++- app/src/main/res/xml/network_security_config.xml | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 app/src/main/res/xml/network_security_config.xml 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 @@ - + + + + 3.37.87.117 + + From 0851901d35a323c8861e5e6dfd933a8d8651c200 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 21:47:59 +0900 Subject: [PATCH 34/35] =?UTF-8?q?[fix]:=20GroupMainCard=20DTO=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/java/com/texthip/thip/data/manager/Genre.kt | 2 +- .../data/model/group/response/JoinedRoomListResponse.kt | 2 +- .../thip/ui/group/myroom/component/GroupMainCard.kt | 4 ++-- .../texthip/thip/ui/group/myroom/component/GroupPager.kt | 8 ++++---- .../java/com/texthip/thip/ui/group/screen/GroupScreen.kt | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/texthip/thip/data/manager/Genre.kt b/app/src/main/java/com/texthip/thip/data/manager/Genre.kt index 3bc7a8dc..817cb49a 100644 --- a/app/src/main/java/com/texthip/thip/data/manager/Genre.kt +++ b/app/src/main/java/com/texthip/thip/data/manager/Genre.kt @@ -9,7 +9,7 @@ enum class Genre( val networkApiCategory: String = apiCategory ) { LITERATURE("literature", "문학"), - SCIENCE_IT("science_it", "과학·IT", "과학/IT"), + SCIENCE_IT("science_it", "과학·IT", "과학·IT"), SOCIAL_SCIENCE("social_science", "사회과학"), HUMANITIES("humanities", "인문학"), ART("art", "예술"); 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/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/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 ) From 7042833a9e438739c0f6f06bfdf4dbc9c4d22174 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 13 Aug 2025 21:58:46 +0900 Subject: [PATCH 35/35] =?UTF-8?q?[fix]:=20scrollState=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/ui/group/room/screen/GroupRoomRecruitScreen.kt | 4 ++++ 1 file changed, 4 insertions(+) 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