diff --git a/app/src/main/java/com/boostcamp/and03/And03Application.kt b/app/src/main/java/com/boostcamp/and03/And03Application.kt index 1a753e08..b6f8a489 100644 --- a/app/src/main/java/com/boostcamp/and03/And03Application.kt +++ b/app/src/main/java/com/boostcamp/and03/And03Application.kt @@ -1,6 +1,5 @@ package com.boostcamp.and03 - import android.app.Application import dagger.hilt.android.HiltAndroidApp diff --git a/app/src/main/java/com/boostcamp/and03/data/di/BookRepositoryModule.kt b/app/src/main/java/com/boostcamp/and03/data/di/BookRepositoryModule.kt new file mode 100644 index 00000000..da69fbb8 --- /dev/null +++ b/app/src/main/java/com/boostcamp/and03/data/di/BookRepositoryModule.kt @@ -0,0 +1,20 @@ +package com.boostcamp.and03.data.di + +import com.boostcamp.and03.data.repository.book.BookRepository +import com.boostcamp.and03.data.repository.book.BookRepositoryImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class BookRepositoryModule { + + @Binds + @Singleton + abstract fun bindBookRepository( + impl: BookRepositoryImpl + ): BookRepository +} \ No newline at end of file diff --git a/app/src/main/java/com/boostcamp/and03/data/model/response/NaverBookItem.kt b/app/src/main/java/com/boostcamp/and03/data/model/response/NaverBookItem.kt index 797400d2..898cacf2 100644 --- a/app/src/main/java/com/boostcamp/and03/data/model/response/NaverBookItem.kt +++ b/app/src/main/java/com/boostcamp/and03/data/model/response/NaverBookItem.kt @@ -1,11 +1,13 @@ package com.boostcamp.and03.data.model.response +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class NaverBookItem( - val title: String, - val image: String, - val author: String, - val publisher: String, + @SerialName("title") val title: String, + @SerialName("image") val thumbnail: String, + @SerialName("author") val author: 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/boostcamp/and03/data/repository/book/NaverBookItemMapper.kt b/app/src/main/java/com/boostcamp/and03/data/repository/book/NaverBookItemMapper.kt new file mode 100644 index 00000000..41852aa3 --- /dev/null +++ b/app/src/main/java/com/boostcamp/and03/data/repository/book/NaverBookItemMapper.kt @@ -0,0 +1,13 @@ +package com.boostcamp.and03.data.repository.book + +import com.boostcamp.and03.data.model.response.NaverBookItem +import com.boostcamp.and03.ui.screen.booklist.model.BookUIModel +import kotlinx.collections.immutable.toImmutableList + +fun NaverBookItem.toUiModel() = BookUIModel( + title = title, + authors = author.split("^").toImmutableList(), + publisher = publisher, + thumbnail = thumbnail, + isbn = isbn +) \ No newline at end of file diff --git a/app/src/main/java/com/boostcamp/and03/ui/component/MainTopBar.kt b/app/src/main/java/com/boostcamp/and03/ui/component/MainTopBar.kt deleted file mode 100644 index e69de29b..00000000 diff --git a/app/src/main/java/com/boostcamp/and03/ui/component/SearchResultItem.kt b/app/src/main/java/com/boostcamp/and03/ui/component/SearchResultItem.kt index bef90688..b8af8569 100644 --- a/app/src/main/java/com/boostcamp/and03/ui/component/SearchResultItem.kt +++ b/app/src/main/java/com/boostcamp/and03/ui/component/SearchResultItem.kt @@ -23,6 +23,8 @@ import coil.compose.AsyncImage import com.boostcamp.and03.ui.theme.And03Padding import com.boostcamp.and03.ui.theme.And03Spacing import com.boostcamp.and03.ui.theme.And03Theme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf private object SearchResultItemValues { val borderWidth = 2.dp @@ -36,7 +38,7 @@ private object SearchResultItemValues { fun SearchResultItem( thumbnail: String, title: String, - authors: String, + authors: ImmutableList, publisher: String, isSelected: Boolean, modifier: Modifier = Modifier, @@ -66,7 +68,8 @@ fun SearchResultItem( ) { Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(And03Spacing.SPACE_M) ) { AsyncImage( model = thumbnail, @@ -77,9 +80,7 @@ fun SearchResultItem( ) Column( - modifier = Modifier - .fillMaxHeight() - .padding(start = And03Padding.PADDING_M), + modifier = Modifier.fillMaxHeight(), verticalArrangement = Arrangement.SpaceEvenly ) { Text( @@ -90,7 +91,7 @@ fun SearchResultItem( ) Text( - text = authors, + text = authors.joinToString(", "), style = And03Theme.typography.bodyMedium, color = And03Theme.colors.onSurfaceVariant, maxLines = SearchResultItemValues.AUTHOR_PUBLISHER_MAX_LINES, @@ -119,7 +120,7 @@ private fun SearchResultItemPreview() { SearchResultItem( thumbnail = "", title = "책 제목", - authors = "책 저자", + authors = persistentListOf("김김김", "앤앤앤", "장장장"), publisher = "책 출판사", isSelected = false, onClick = {} @@ -130,7 +131,7 @@ private fun SearchResultItemPreview() { title = """ 선택된 책 제목. 테두리 색이 바뀌었습니다. 그런데 여기서 개행을 해야 한다면...........???????????? """.trimIndent(), - authors = "선택된 책 저자", + authors = persistentListOf("패트", "매트"), publisher = "선택된 책 출판사", isSelected = true, onClick = {} diff --git a/app/src/main/java/com/boostcamp/and03/ui/component/SearchTopBar.kt b/app/src/main/java/com/boostcamp/and03/ui/component/SearchTopBar.kt new file mode 100644 index 00000000..f894b135 --- /dev/null +++ b/app/src/main/java/com/boostcamp/and03/ui/component/SearchTopBar.kt @@ -0,0 +1,63 @@ +package com.boostcamp.and03.ui.component + +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import com.boostcamp.and03.R +import com.boostcamp.and03.ui.theme.And03Theme + +// 임시로 사용할 탑 바 +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchTopBar( + title: String, + onBackClick: () -> Unit, + onSaveClick: () -> Unit, + modifier: Modifier = Modifier, + isSaveEnabled: Boolean = false +) { + CenterAlignedTopAppBar( + title = { Text(text = title) }, + navigationIcon = { + IconButton( + onClick = onBackClick + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_keyboard_arrow_left_filled), + contentDescription = stringResource(R.string.content_description_go_back) + ) + } + }, + actions = { + IconButton( + onClick = onSaveClick, + enabled = isSaveEnabled + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_check_filled), + contentDescription = stringResource(R.string.content_description_save_button) + ) + } + } + ) +} + +@Preview +@Composable +private fun SearchTopBarPreview() { + And03Theme { + SearchTopBar( + title = "책 검색", + onBackClick = {}, + onSaveClick = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/boostcamp/and03/ui/navigation/MainNavHost.kt b/app/src/main/java/com/boostcamp/and03/ui/navigation/MainNavHost.kt index 049592a1..eea9d8c5 100644 --- a/app/src/main/java/com/boostcamp/and03/ui/navigation/MainNavHost.kt +++ b/app/src/main/java/com/boostcamp/and03/ui/navigation/MainNavHost.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier import androidx.navigation.compose.NavHost import com.boostcamp.and03.ui.screen.addbook.addBookNavGraph import com.boostcamp.and03.ui.screen.booklist.booklistNavGraph +import com.boostcamp.and03.ui.screen.booksearch.bookSearchNavGraph import com.boostcamp.and03.ui.screen.mypage.myPageNavGraph import com.boostcamp.and03.ui.screen.prototype.screen.SnackBarEvent @@ -26,6 +27,11 @@ fun MainNavHost( onShowSnackBar = onShowSnackBar ) + bookSearchNavGraph( + navigator = navigator, + modifier = modifier.padding(paddingValues) + ) + addBookNavGraph(modifier = modifier.padding(paddingValues)) myPageNavGraph(modifier = modifier.padding(paddingValues)) diff --git a/app/src/main/java/com/boostcamp/and03/ui/navigation/MainNavigator.kt b/app/src/main/java/com/boostcamp/and03/ui/navigation/MainNavigator.kt index 97bcec51..6873738b 100644 --- a/app/src/main/java/com/boostcamp/and03/ui/navigation/MainNavigator.kt +++ b/app/src/main/java/com/boostcamp/and03/ui/navigation/MainNavigator.kt @@ -68,7 +68,6 @@ class MainNavigator( } fun navigatePopBackStack() = navController.popBackStack() - } @SuppressLint("ComposableNaming") diff --git a/app/src/main/java/com/boostcamp/and03/ui/navigation/Route.kt b/app/src/main/java/com/boostcamp/and03/ui/navigation/Route.kt index 400c47d6..6c22b2ac 100644 --- a/app/src/main/java/com/boostcamp/and03/ui/navigation/Route.kt +++ b/app/src/main/java/com/boostcamp/and03/ui/navigation/Route.kt @@ -6,6 +6,9 @@ sealed interface Route { @Serializable data object Booklist : Route + @Serializable + data object BookSearch : Route + @Serializable data object AddBook : Route diff --git a/app/src/main/java/com/boostcamp/and03/ui/screen/booksearch/BookSearchNavGraph.kt b/app/src/main/java/com/boostcamp/and03/ui/screen/booksearch/BookSearchNavGraph.kt new file mode 100644 index 00000000..9a7660e2 --- /dev/null +++ b/app/src/main/java/com/boostcamp/and03/ui/screen/booksearch/BookSearchNavGraph.kt @@ -0,0 +1,19 @@ +package com.boostcamp.and03.ui.screen.booksearch + +import androidx.compose.ui.Modifier +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.boostcamp.and03.ui.navigation.MainNavigator +import com.boostcamp.and03.ui.navigation.Route + +fun NavGraphBuilder.bookSearchNavGraph( + navigator: MainNavigator, + modifier: Modifier = Modifier +) { + composable { + BookSearchRoute( + onBackClick = navigator::navigatePopBackStack + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/boostcamp/and03/ui/screen/booksearch/BookSearchScreen.kt b/app/src/main/java/com/boostcamp/and03/ui/screen/booksearch/BookSearchScreen.kt new file mode 100644 index 00000000..50aba2de --- /dev/null +++ b/app/src/main/java/com/boostcamp/and03/ui/screen/booksearch/BookSearchScreen.kt @@ -0,0 +1,228 @@ +package com.boostcamp.and03.ui.screen.booksearch + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.tooling.preview.Preview +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.PagingData +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import com.boostcamp.and03.R +import com.boostcamp.and03.ui.component.And03Button +import com.boostcamp.and03.ui.component.SearchResultItem +import com.boostcamp.and03.ui.component.SearchTextField +import com.boostcamp.and03.ui.component.SearchTopBar +import com.boostcamp.and03.ui.screen.booklist.model.BookUIModel +import com.boostcamp.and03.ui.theme.And03Padding +import com.boostcamp.and03.ui.theme.And03Spacing +import com.boostcamp.and03.ui.theme.And03Theme +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.flowOf + +@Composable +fun BookSearchRoute( + onBackClick: () -> Unit, + viewModel: BookSearchViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val searchResults = viewModel.pagingBooksFlow.collectAsLazyPagingItems() + + BookSearchScreen( + uiState = uiState, + searchResults = searchResults, + onQueryChange = viewModel::changeQuery, + onBackClick = onBackClick, + onItemClick = viewModel::clickItem, + onSaveClick = { /* TODO: viewModel::saveItem 구현 */ }, + onManualAddClick = { /* TODO: 책 추가 화면 구현 및 이동 동작 구현 */ } + ) +} + +@Composable +private fun BookSearchScreen( + uiState: BookSearchUiState, + searchResults: LazyPagingItems, + onQueryChange: (String) -> Unit, + onBackClick: () -> Unit, + onItemClick: (BookUIModel) -> Unit, + onSaveClick: () -> Unit, + onManualAddClick: () -> Unit, + modifier: Modifier = Modifier +) { + val searchTextState = remember { TextFieldState(uiState.query) } + + LaunchedEffect(Unit) { + snapshotFlow { searchTextState.text.toString() } + .collect { text -> + onQueryChange(text) + } + } + + Column(modifier = modifier.fillMaxSize()) { + SearchTopBar( + title = stringResource(R.string.book_search_title), + onBackClick = onBackClick, + onSaveClick = onSaveClick, + isSaveEnabled = uiState.isSaveEnabled + ) + + SearchTextField( + state = searchTextState, + onSearch = { onQueryChange(searchTextState.text.toString()) }, + modifier = Modifier + .fillMaxWidth() + .padding(And03Padding.PADDING_M) + ) + + when { + uiState.query.isBlank() -> { + BookSearchEmptySection( + message = stringResource(R.string.book_search_empty_before_query), + onManualAddClick = onManualAddClick + ) + } + + searchResults.itemCount == 0 -> { + BookSearchEmptySection( + message = stringResource(R.string.book_search_empty_after_query), + onManualAddClick = onManualAddClick + ) + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = And03Padding.PADDING_L), + verticalArrangement = Arrangement.spacedBy(And03Spacing.SPACE_M) + ) { + items( + count = searchResults.itemCount, + key = searchResults.itemKey { it.isbn } + ) { index -> + val book = searchResults[index] ?: return@items + SearchResultItem( + thumbnail = book.thumbnail, + title = book.title, + authors = book.authors, + publisher = book.publisher, + isSelected = book.isbn == uiState.selectedBookISBN, + onClick = { onItemClick(book) } + ) + } + } + } + } + } +} + +@Composable +private fun BookSearchEmptySection( + message: String, + onManualAddClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = message, + style = And03Theme.typography.bodyLarge, + color = And03Theme.colors.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(And03Spacing.SPACE_L)) + + And03Button( + text = stringResource(R.string.book_search_button_text), + onClick = onManualAddClick + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun BookSearchScreenPreview() { + val uiState = BookSearchUiState( + query = "안드로이드", + selectedBookISBN = "222" + ) + + val previewBooks = listOf( + BookUIModel( + isbn = "111", + title = "이펙티브 코틀린", + authors = persistentListOf("마르친 모스칼라"), + publisher = "인사이트", + thumbnail = "" + ), + BookUIModel( + isbn = "222", + title = "안드로이드 Compose 완벽 가이드", + authors = persistentListOf("Compose 팀"), + publisher = "구글", + thumbnail = "" + ) + ) + + val pagingItems = flowOf( + PagingData.from(previewBooks) + ).collectAsLazyPagingItems() + + And03Theme { + BookSearchScreen( + uiState = uiState, + searchResults = pagingItems, + onQueryChange = {}, + onBackClick = {}, + onItemClick = {}, + onSaveClick = {}, + onManualAddClick = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun BookSearchScreenEmptyBeforeQueryPreview() { + val uiState = BookSearchUiState( + query = "", + selectedBookISBN = null + ) + + val pagingItems = flowOf( + PagingData.empty() + ).collectAsLazyPagingItems() + + And03Theme { + BookSearchScreen( + uiState = uiState, + searchResults = pagingItems, + onQueryChange = {}, + onBackClick = {}, + onItemClick = {}, + onSaveClick = {}, + onManualAddClick = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/boostcamp/and03/ui/screen/booksearch/BookSearchUiState.kt b/app/src/main/java/com/boostcamp/and03/ui/screen/booksearch/BookSearchUiState.kt new file mode 100644 index 00000000..0bb3a57c --- /dev/null +++ b/app/src/main/java/com/boostcamp/and03/ui/screen/booksearch/BookSearchUiState.kt @@ -0,0 +1,10 @@ +package com.boostcamp.and03.ui.screen.booksearch + +data class BookSearchUiState( + val query: String = "", + val selectedBookISBN: String? = null, + val isLoading: Boolean = false +) { + val isSaveEnabled: Boolean + get() = selectedBookISBN != null +} \ No newline at end of file diff --git a/app/src/main/java/com/boostcamp/and03/ui/screen/booksearch/BookSearchViewModel.kt b/app/src/main/java/com/boostcamp/and03/ui/screen/booksearch/BookSearchViewModel.kt new file mode 100644 index 00000000..3c5b6931 --- /dev/null +++ b/app/src/main/java/com/boostcamp/and03/ui/screen/booksearch/BookSearchViewModel.kt @@ -0,0 +1,60 @@ +package com.boostcamp.and03.ui.screen.booksearch + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import com.boostcamp.and03.data.repository.book.BookRepository +import com.boostcamp.and03.data.repository.book.toUiModel +import com.boostcamp.and03.ui.screen.booklist.model.BookUIModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@HiltViewModel +class BookSearchViewModel @Inject constructor( + private val bookRepository: BookRepository +) : ViewModel() { + private val _uiState = MutableStateFlow(BookSearchUiState()) + val uiState = _uiState.asStateFlow() + + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + val pagingBooksFlow: Flow> = _uiState + .map { it.query } + .debounce(300) + .distinctUntilChanged() + .filter { it.isNotBlank() } + .flatMapLatest { query -> + bookRepository.loadBooksPagingFlow(query) + .map { pagingData -> + pagingData.map { it.toUiModel() } + } + } + + fun changeQuery(query: String) { + _uiState.update { it.copy(query = query) } + } + + fun clickItem(item: BookUIModel) { + _uiState.update { + it.copy( + selectedBookISBN = if (it.selectedBookISBN == item.isbn) { + null + } else { + item.isbn + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_left_filled.xml b/app/src/main/res/drawable/ic_keyboard_arrow_left_filled.xml new file mode 100644 index 00000000..0d5e0c35 --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_arrow_left_filled.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 34235495..f7d8a5bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,6 +6,7 @@ 책 제목을 입력하세요. 책이 추가됐어요. 지금 메모를 남겨볼까요? 메모 작성 + 책 목록 책 추가 @@ -21,6 +22,12 @@ 이미지에서 가져오기 기기에 저장된 사진을 선택합니다 + + 책 추가 + 책을 직접 추가할 수 있어요. + 검색 결과가 없어요. + 직접 추가 + 뒤로 가기 닫기 @@ -28,5 +35,5 @@ 갤러리에서 사진을 선택하여 텍스트를 가져옵니다 더보기 버튼을 눌러 메뉴를 표시합니다 노드를 나타내는 아이콘입니다. - + 저장 버튼을 눌러 책을 저장합니다 \ No newline at end of file